Files
sgse-app/apps/web/src/lib/components/ponto/RegistroPonto.svelte

1783 lines
62 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, Camera } 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();
// Chave de refresh para forçar atualização das queries após registro
let refreshKey = $state(0);
// Queries
const currentUser = useQuery(api.auth.getCurrentUser, {});
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
// Query para histórico e saldo do dia
const funcionarioId = $derived(currentUser?.data?.funcionarioId ?? null);
const dataHoje = $derived(new Date().toISOString().split('T')[0]!);
// Usar refreshKey para forçar atualização após registro
const registrosHojeQuery = useQuery(
api.pontos.listarRegistrosDia,
{ data: dataHoje, _refresh: refreshKey }
);
const historicoSaldoQuery = useQuery(
api.pontos.obterHistoricoESaldoDia,
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje, _refresh: refreshKey } : '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);
let aguardandoProcessamento = $state(false);
let etapaProcessamento = $state<'coletando' | 'sincronizando' | 'upload' | 'registrando' | 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;
permissoesNecessarias: string[];
}> {
let localizacaoAutorizada = false;
let webcamAutorizada = false;
const permissoesNecessarias: string[] = [];
// 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();
},
(error) => {
clearTimeout(timeoutId);
if (error.code === error.PERMISSION_DENIED) {
permissoesNecessarias.push('localização');
}
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);
permissoesNecessarias.push('câmera');
}
}
return { localizacao: localizacaoAutorizada, webcam: webcamAutorizada, permissoesNecessarias };
}
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';
const permissoesLista = permissoes.permissoesNecessarias.join(', ');
detalhesErroModal = `Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.\n\nPermissões negadas: ${permissoesLista || 'localização e/ou câmera'}`;
mostrarModalErro = true;
return;
}
registrando = true;
sucesso = null;
coletandoInfo = true;
aguardandoProcessamento = true;
etapaProcessamento = 'coletando';
try {
// Coletar informações do dispositivo
etapaProcessamento = 'coletando';
const informacoesDispositivo = await obterInformacoesDispositivo();
// Nota: A permissão de sensor não é impeditiva - apenas câmera e localização são obrigatórias
coletandoInfo = false;
// Obter tempo sincronizado e aplicar GMT offset (igual ao relógio)
etapaProcessamento = 'sincronizando';
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 {
etapaProcessamento = 'upload';
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
etapaProcessamento = 'registrando';
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;
// Forçar atualização das queries para mostrar o novo registro
refreshKey++;
if (import.meta.env.DEV) {
console.log('[RegistroPonto] Registro bem-sucedido, refreshKey incrementado:', refreshKey);
}
// Aguardar um pouco para garantir que o backend processou o registro
await new Promise(resolve => setTimeout(resolve, 500));
// Forçar mais uma atualização após o delay para garantir sincronização
refreshKey++;
// Mostrar comprovante após 1 segundo
setTimeout(() => {
mostrandoComprovante = true;
}, 1000);
} catch (error) {
console.error('Erro ao registrar ponto:', error);
aguardandoProcessamento = false;
etapaProcessamento = null;
let mensagemErro = 'Erro desconhecido ao registrar ponto';
let detalhesErro = 'Tente novamente em alguns instantes.';
if (error instanceof Error) {
const erroMessage = error.message || '';
// Erro de registro duplicado
if (
erroMessage.includes('Já existe um registro neste minuto') ||
erroMessage.includes('já existe um registro')
) {
mensagemErro = 'Registro de ponto duplicado';
const tipoLabelErro = config
? getTipoRegistroLabel(proximoTipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(proximoTipo);
detalhesErro = `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.`;
}
// Erro de validação de argumentos
else if (
erroMessage.includes('ArgumentValidationError') ||
erroMessage.includes('Object contains extra field') ||
erroMessage.includes('validation')
) {
mensagemErro = 'Erro na validação dos dados';
detalhesErro = 'Ocorreu um erro ao validar as informações do dispositivo.\n\nPor favor, tente novamente ou recarregue a página.';
}
// Erro de autenticação
else if (
erroMessage.includes('não autenticado') ||
erroMessage.includes('autenticado') ||
erroMessage.includes('auth')
) {
mensagemErro = 'Erro de autenticação';
detalhesErro = 'Sua sessão pode ter expirado. Por favor, faça login novamente.';
}
// Erro de permissão/validação de localização
else if (
erroMessage.includes('localização') ||
erroMessage.includes('Localização') ||
erroMessage.includes('location')
) {
mensagemErro = 'Erro na validação de localização';
detalhesErro = 'Não foi possível validar sua localização.\n\nPor favor, verifique se você autorizou o compartilhamento de localização e tente novamente.';
}
// Erro genérico do servidor
else if (erroMessage.includes('Server Error') || erroMessage.includes('Server')) {
mensagemErro = 'Erro no servidor';
detalhesErro = 'Ocorreu um erro no servidor ao processar seu registro.\n\nPor favor, tente novamente em alguns instantes.';
}
// Outros erros - mostrar mensagem simplificada
else {
mensagemErro = 'Erro ao registrar ponto';
// Se a mensagem de erro for muito técnica, mostrar mensagem genérica
if (
erroMessage.includes('Error:') ||
erroMessage.includes('TypeError') ||
erroMessage.includes('ReferenceError') ||
erroMessage.length > 200
) {
detalhesErro = 'Ocorreu um erro ao processar o registro.\n\nPor favor, tente novamente ou recarregue a página.';
} else {
detalhesErro = erroMessage;
}
}
}
mensagemErroModal = mensagemErro;
detalhesErroModal = detalhesErro;
mostrarModalErro = true;
} finally {
registrando = false;
coletandoInfo = false;
aguardandoProcessamento = false;
etapaProcessamento = null;
}
}
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;
aguardandoProcessamento = true;
etapaProcessamento = 'coletando';
// Usar setTimeout para garantir que o modal de processamento apareça antes de iniciar o registro
setTimeout(() => {
registrarPonto();
}, 100);
}
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;
});
// Os modais agora são centralizados automaticamente via CSS (fixed inset-0 flex items-center justify-center)
// 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' }
];
const resultado = 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
};
});
// Log para debug (apenas em desenvolvimento)
if (import.meta.env.DEV) {
console.log('[RegistroPonto] mapaHorarios atualizado:', {
totalRegistrosHoje: registrosHoje.length,
horariosComRegistro: resultado.filter(h => h.registrado).length,
registrosHoje: registrosHoje.map(r => ({ tipo: r.tipo, hora: `${r.hora}:${r.minuto}` }))
});
}
return resultado;
});
// 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);
// Posicionamento dos modais
let modalPosition = $state<{ top: number; left: number } | null>(null);
// Função para calcular a posição baseada no card de registro de ponto
function calcularPosicaoModal() {
// Procurar pelo elemento do card de registro de ponto
const cardRef = document.getElementById('card-registro-ponto-ref');
if (cardRef) {
const rect = cardRef.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Posicionar o modal na mesma posição do card de registro
// Centralizado horizontalmente no card
const left = rect.left + (rect.width / 2);
// Posicionar abaixo do card com um pequeno espaçamento
const top = rect.bottom + 20;
return {
top: top,
left: left
};
}
// Se não encontrar, usar posição padrão (centro da tela)
return null;
}
// Atualizar posição quando os modais forem abertos ou quando a página rolar
$effect(() => {
if (mostrandoWebcam || mostrandoModalConfirmacao || aguardandoProcessamento || mostrarModalErro) {
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
const updatePosition = () => {
requestAnimationFrame(() => {
const pos = calcularPosicaoModal();
if (pos) {
modalPosition = pos;
}
});
};
// Aguardar um pouco mais para garantir que o DOM está atualizado
setTimeout(updatePosition, 50);
// Adicionar listener de scroll para atualizar posição
const handleScroll = () => {
updatePosition();
};
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleScroll);
};
} else {
// Limpar posição quando os modais forem fechados
modalPosition = null;
}
});
// Função para obter estilo do modal baseado na posição calculada
function getModalStyle() {
if (modalPosition) {
// Garantir que o modal não saia da viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const modalWidth = 800; // Aproximadamente max-w-2xl ou max-w-3xl
const modalHeight = Math.min(viewportHeight * 0.9, 600);
let left = modalPosition.left;
let top = modalPosition.top;
// Ajustar se o modal sair da viewport à direita
if (left + (modalWidth / 2) > viewportWidth - 20) {
left = viewportWidth - (modalWidth / 2) - 20;
}
// Ajustar se o modal sair da viewport à esquerda
if (left - (modalWidth / 2) < 20) {
left = (modalWidth / 2) + 20;
}
// Ajustar se o modal sair da viewport abaixo
if (top + modalHeight > viewportHeight - 20) {
top = viewportHeight - modalHeight - 20;
}
// Ajustar se o modal sair da viewport acima
if (top < 20) {
top = 20;
}
// Usar transform para centralizar horizontalmente baseado no left calculado
return `position: fixed; top: ${top}px; left: ${left}px; transform: translateX(-50%); max-width: ${Math.min(modalWidth, viewportWidth - 40)}px;`;
}
// Se não houver posição calculada, centralizar na tela
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);';
}
</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}
<!-- Card de Registro de Ponto Modernizado -->
<div class="card bg-gradient-to-br from-base-100 via-base-100 to-primary/5 border border-base-300 shadow-2xl max-w-2xl mx-auto">
<div id="card-registro-ponto-ref" class="card-body p-6">
<!-- Cabeçalho -->
<div class="flex items-center justify-center gap-3 mb-6">
<div class="p-2.5 bg-primary/10 rounded-xl">
<Clock class="h-6 w-6 text-primary" strokeWidth={2.5} />
</div>
<h2 class="card-title text-2xl font-black text-base-content">Registrar Ponto</h2>
</div>
<!-- Relógio Sincronizado -->
<div class="mb-5 flex justify-center">
<div id="relogio-sincronizado-ref" class="card bg-gradient-to-br from-primary/10 to-primary/5 border-2 border-primary/20 shadow-lg rounded-2xl p-5 w-full max-w-sm">
<RelogioSincronizado />
</div>
</div>
<!-- Botão de Registro -->
<button
class="btn btn-primary w-full shadow-lg hover:shadow-xl transition-all duration-300 font-semibold rounded-xl gap-2 mb-5"
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>
<!-- Campo de Justificativa -->
<div class="mb-5">
<label for="justificativa" class="label pb-1.5">
<span class="label-text font-semibold text-xs">Justificativa <span class="text-base-content/50 font-normal">(Opcional)</span></span>
</label>
<textarea
id="justificativa"
class="textarea textarea-bordered w-full focus:textarea-primary focus:ring-2 focus:ring-primary/20 rounded-xl resize-none text-sm"
placeholder="Digite uma justificativa para este registro de ponto (opcional)"
bind:value={justificativa}
disabled={registrando}
rows="2"
></textarea>
</div>
<!-- Mensagem de Sucesso -->
{#if sucesso}
<div class="alert alert-success shadow-lg mb-5 rounded-xl">
<CheckCircle2 class="h-4 w-4" />
<span class="font-semibold text-sm">{sucesso}</span>
</div>
{/if}
<!-- Próximo Registro -->
<div class="card bg-gradient-to-br from-info/10 to-info/5 border-2 border-info/20 shadow-md rounded-xl p-4">
<div class="flex items-center justify-center gap-2">
<div class="p-1.5 bg-info/20 rounded-lg">
{#if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
<LogIn class="h-4 w-4 text-info" strokeWidth={2.5} />
{:else}
<LogOut class="h-4 w-4 text-info" strokeWidth={2.5} />
{/if}
</div>
<div class="text-center">
<p class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-0.5">Próximo Registro</p>
<p class="text-base font-bold text-base-content">{tipoLabel}</p>
</div>
</div>
</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
class="fixed inset-0 z-50 pointer-events-none"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-webcam-title"
>
<!-- Backdrop leve -->
<div
class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"
onclick={handleWebcamCancel}
></div>
<!-- Modal Box -->
<div
class="absolute bg-base-100 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300 pointer-events-auto"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
onclick={(e) => e.stopPropagation()}
>
<!-- Header fixo -->
<div class="flex items-center justify-between px-6 py-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">
<Camera class="h-5 w-5 text-primary" strokeWidth={2} />
</div>
<h3 id="modal-webcam-title" class="text-xl font-bold text-base-content">Capturar Foto</h3>
</div>
<button
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
onclick={handleWebcamCancel}
>
<XCircle class="h-5 w-5" />
</button>
</div>
<!-- Conteúdo com rolagem -->
<div class="flex-1 overflow-y-auto px-6 py-4 modal-scroll">
<div class="min-h-[200px] flex items-center justify-center">
<WebcamCapture
onCapture={handleWebcamCapture}
onCancel={handleWebcamCancel}
onError={handleWebcamError}
autoCapture={false}
fotoObrigatoria={true}
/>
</div>
</div>
</div>
</div>
{/if}
<!-- Modal de Aguardando Processamento -->
{#if aguardandoProcessamento}
<div
class="fixed inset-0 z-50 pointer-events-none"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-aguardando-title"
>
<!-- Backdrop leve -->
<div class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"></div>
<!-- Modal Box -->
<div
class="absolute bg-base-100 rounded-2xl shadow-2xl max-w-md w-full z-10 transform transition-all duration-300 p-8 pointer-events-auto"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
>
<div class="flex flex-col items-center gap-4 text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<h3 id="modal-aguardando-title" class="text-xl font-bold text-base-content">
{#if etapaProcessamento === 'coletando'}
Coletando Informações
{:else if etapaProcessamento === 'sincronizando'}
Sincronizando Horário
{:else if etapaProcessamento === 'upload'}
Enviando Foto
{:else if etapaProcessamento === 'registrando'}
Registrando Ponto
{:else}
Processando Registro
{/if}
</h3>
<p class="text-base-content/70">
{#if etapaProcessamento === 'coletando'}
Coletando informações do dispositivo e localização...
{:else if etapaProcessamento === 'sincronizando'}
Sincronizando o horário com o servidor...
{:else if etapaProcessamento === 'upload'}
Enviando a foto capturada para o servidor...
{:else if etapaProcessamento === 'registrando'}
Finalizando o registro de ponto no sistema...
{:else}
Por favor, aguarde enquanto processamos seu registro de ponto...
{/if}
</p>
</div>
</div>
</div>
{/if}
<!-- Modal de Confirmação -->
{#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual}
<div
class="fixed inset-0 z-50 pointer-events-none"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-confirmacao-title"
>
<!-- Backdrop leve -->
<div
class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"
onclick={cancelarRegistro}
></div>
<!-- Modal Box -->
<div
class="absolute bg-base-100 rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300 pointer-events-auto"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
onclick={(e) => e.stopPropagation()}
>
<!-- Header fixo -->
<div class="flex items-center justify-between px-6 py-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 id="modal-confirmacao-title" 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 || aguardandoProcessamento}
>
<XCircle class="h-5 w-5" />
</button>
</div>
<!-- Conteúdo com rolagem -->
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6 modal-scroll">
<!-- 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 px-6 py-4 border-t border-base-300 flex-shrink-0">
<button
class="btn btn-outline"
onclick={cancelarRegistro}
disabled={registrando || aguardandoProcessamento}
>
<XCircle class="h-5 w-5" />
Cancelar
</button>
<button
class="btn btn-primary gap-2"
onclick={confirmarRegistro}
disabled={registrando || aguardandoProcessamento}
>
{#if registrando || aguardandoProcessamento}
<span class="loading loading-spinner loading-sm"></span>
Processando...
{:else}
<CheckCircle2 class="h-5 w-5" />
Confirmar Registro
{/if}
</button>
</div>
</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>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Scrollbar customizada para os modais */
:global(.modal-scroll) {
scrollbar-width: thin;
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
:global(.modal-scroll::-webkit-scrollbar) {
width: 8px;
}
:global(.modal-scroll::-webkit-scrollbar-track) {
background: transparent;
border-radius: 4px;
}
:global(.modal-scroll::-webkit-scrollbar-thumb) {
background-color: hsl(var(--bc) / 0.3);
border-radius: 4px;
transition: background-color 0.2s ease;
}
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
background-color: hsl(var(--bc) / 0.5);
}
</style>