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

1984 lines
65 KiB
Svelte

<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { FunctionReference } from 'convex/server';
import { useConvexClient, useQuery } from 'convex-svelte';
import { onMount } from 'svelte';
import { obterInformacoesDispositivo } from '$lib/utils/deviceInfo';
import { obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
import {
formatarHoraPonto,
getProximoTipoRegistro,
getTipoRegistroLabel
} from '$lib/utils/ponto';
import {
LogIn,
LogOut,
Clock,
CheckCircle2,
XCircle,
TrendingUp,
TrendingDown,
Printer,
Camera,
AlertTriangle,
Image as ImageIcon,
Info
} from 'lucide-svelte';
import jsPDF from 'jspdf';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
import { formatarDataHoraCompleta } from '$lib/utils/ponto';
import RelogioSincronizado from './RelogioSincronizado.svelte';
import WebcamCapture from './WebcamCapture.svelte';
import ComprovantePonto from './ComprovantePonto.svelte';
import ErrorModal from '../ErrorModal.svelte';
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 as FunctionReference<'query'>);
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
// Query para histórico e saldo do dia
let funcionarioId = $derived(currentUser?.data?.funcionarioId ?? null);
let dataHoje = $derived(new Date().toISOString().split('T')[0]!);
// Parâmetros reativos para queries de ponto
const registrosHojeParams = $derived({
data: dataHoje,
_refresh: refreshKey
});
const historicoSaldoParams = $derived(
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje, _refresh: refreshKey } : 'skip'
);
const dispensaParams = $derived(
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
);
const registrosHojeQuery = $derived.by(() =>
useQuery(api.pontos.listarRegistrosDia, registrosHojeParams)
);
const historicoSaldoQuery = $derived.by(() =>
useQuery(api.pontos.obterHistoricoESaldoDia, historicoSaldoParams)
);
const dispensaQuery = $derived.by(() =>
useQuery(api.pontos.verificarDispensaAtiva, dispensaParams)
);
// Query para obter status atual do funcionário (férias/licença)
const funcionarioStatusQuery = useQuery(
api.funcionarios.getCurrent,
currentUser?.data ? {} : '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 mostrandoTransicao = $state(false); // Novo estado para transição
let dataHoraAtual = $state<{ data: string; hora: string } | null>(null);
let aguardandoProcessamento = $state(false);
let etapaProcessamento = $state<'coletando' | 'sincronizando' | 'upload' | 'registrando' | null>(
null
);
let registrosHoje = $derived(registrosHojeQuery?.data || []);
let config = $derived(configQuery?.data);
// Status de férias/licença do funcionário
const funcionarioStatus = $derived(funcionarioStatusQuery?.data);
const statusFerias = $derived(funcionarioStatus?.statusFerias ?? 'ativo');
const emFerias = $derived(statusFerias === 'em_ferias');
const emLicenca = $derived(statusFerias === 'em_licenca');
let proximoTipo = $derived.by(() => {
if (registrosHoje.length === 0) {
return 'entrada';
}
const ultimoRegistro = registrosHoje[registrosHoje.length - 1];
return getProximoTipoRegistro(ultimoRegistro?.tipo || null);
});
let 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 se funcionário está em férias ou licença
if (emFerias || emLicenca) {
mensagemErroModal = 'Registro de ponto não permitido';
detalhesErroModal = emFerias
? 'Seu status atual é "Em Férias". Durante o período de férias não é permitido registrar ponto.'
: 'Seu status atual é "Em Licença". Durante o período de licença não é permitido registrar ponto.';
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;
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();
}
// Mostrar transição antes da confirmação
mostrandoTransicao = true;
setTimeout(() => {
mostrandoTransicao = false;
mostrandoModalConfirmacao = true;
}, 1500);
}
}
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;
mostrandoTransicao = true; // Mostrar transição antes do processamento
setTimeout(() => {
mostrandoTransicao = false;
aguardandoProcessamento = true;
etapaProcessamento = 'coletando';
// Usar setTimeout para garantir que o modal de processamento apareça antes de iniciar o registro
setTimeout(() => {
registrarPonto();
}, 100);
}, 1500);
}
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.');
}
}
let dispensaAtiva = $derived(dispensaQuery?.data);
let estaDispensado = $derived(dispensaAtiva?.dispensado ?? false);
let motivoDispensa = $derived(dispensaAtiva?.motivo ?? null);
let temFuncionarioAssociado = $derived(funcionarioId !== null);
let podeRegistrar = $derived.by(() => {
return (
!registrando &&
!coletandoInfo &&
config !== undefined &&
!estaDispensado &&
!emFerias &&
!emLicenca &&
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 {
// 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 {
// Ignorar erro silenciosamente
console.log('Permissão de geolocalização não concedida ainda');
}
}
});
let 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
let historicoSaldo = $derived(historicoSaldoQuery?.data);
let 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
let 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`;
});
let saldoPositivo = $derived(historicoSaldo ? historicoSaldo.saldoMinutos >= 0 : false);
// Posicionamento dos modais baseado no texto "Registrar Ponto"
let modalPosition = $state<{ top: number; left: number } | null>(null);
// Função para calcular a posição do modal baseada no card de registro de ponto
function calcularPosicaoModal() {
const cardRef = document.getElementById('card-registro-ponto-ref');
if (cardRef) {
const rect = cardRef.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Posicionar o modal na mesma altura Y do card (top do card)
// getBoundingClientRect() já retorna posição relativa à viewport quando usado com position: fixed
const top = rect.top;
// Garantir que o modal não saia da viewport
// Considerar uma altura mínima do modal (aproximadamente 300px)
const minTop = 20;
const maxTop = viewportHeight - 350; // Deixar espaço para o modal
const finalTop = Math.max(minTop, Math.min(top, maxTop));
return {
top: finalTop,
left: window.innerWidth / 2
};
}
// Se não encontrar, usar posição padrão (centro da tela)
return null;
}
// Atualizar posição quando os modais forem abertos
$effect(() => {
if (
mostrandoWebcam ||
mostrandoTransicao ||
aguardandoProcessamento ||
mostrandoModalConfirmacao
) {
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
const updatePosition = () => {
requestAnimationFrame(() => {
const pos = calcularPosicaoModal();
if (pos) {
modalPosition = pos;
} else {
// Fallback para centralização
modalPosition = {
top: window.innerHeight / 2,
left: window.innerWidth / 2
};
}
});
};
// Aguardar um pouco 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 o modal for fechado
modalPosition = null;
}
});
// Função para obter estilo do modal
function getModalStyle() {
if (modalPosition) {
// Posicionar na altura do card, centralizado horizontalmente
// position: fixed já é relativo à viewport, então podemos usar diretamente
return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 800px;`;
}
// Fallback para centralização padrão
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 800px;';
}
</script>
<div class="space-y-6">
<!-- Alerta de Funcionário Não Associado -->
{#if !temFuncionarioAssociado}
<div class="alert alert-error shadow-lg">
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
<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">
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
<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}
<!-- Alerta de Status de Férias/Licença -->
{#if temFuncionarioAssociado && (emFerias || emLicenca)}
<div class="alert alert-info shadow-lg">
<Info class="h-6 w-6 shrink-0 stroke-current" />
<div>
<h3 class="font-bold">Status do Funcionário</h3>
<div class="text-sm">
{#if emFerias}
Seu status atual é <strong>Em Férias</strong>. Durante o período de férias não é
permitido registrar ponto.
{:else}
Seu status atual é <strong>Em Licença</strong>. Durante o período de licença não é
permitido registrar ponto.
{/if}
</div>
</div>
</div>
{/if}
<!-- Card de Registro de Ponto Modernizado -->
<div
class="card from-base-100 via-base-100 to-primary/5 border-base-300 mx-auto max-w-2xl border bg-linear-to-br shadow-2xl"
>
<div id="card-registro-ponto-ref" class="card-body p-6">
<!-- Cabeçalho -->
<div class="mb-6 flex items-center justify-center gap-3">
<div class="bg-primary/10 rounded-xl p-2.5">
<Clock class="text-primary h-6 w-6" strokeWidth={2.5} />
</div>
<h2 class="card-title text-base-content text-2xl font-black">Registrar Ponto</h2>
</div>
<!-- Relógio Sincronizado -->
<div class="mb-5 flex justify-center">
<div
id="relogio-sincronizado-ref"
class="card from-primary/10 to-primary/5 border-primary/20 w-full max-w-sm rounded-2xl border-2 bg-linear-to-br p-5 shadow-lg"
>
<RelogioSincronizado />
</div>
</div>
<!-- Botão de Registro -->
<button
class="btn btn-primary mb-5 w-full gap-2 rounded-xl font-semibold shadow-lg transition-all duration-300 hover:shadow-xl"
onclick={iniciarRegistroComFoto}
disabled={!podeRegistrar}
title={!temFuncionarioAssociado
? 'Você não possui funcionário associado à sua conta'
: estaDispensado
? 'Você está dispensado de registrar ponto no momento'
: emFerias
? 'Você está em férias. Durante o período de férias não é permitido registrar ponto.'
: emLicenca
? 'Você está em licença. Durante o período de licença não é permitido registrar ponto.'
: ''}
>
{#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 emFerias}
<XCircle class="h-5 w-5" />
Em Férias
{:else if emLicenca}
<XCircle class="h-5 w-5" />
Em Licença
{: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 text-xs font-semibold"
>Justificativa <span class="text-base-content/50 font-normal">(Opcional)</span></span
>
</label>
<textarea
id="justificativa"
class="textarea textarea-bordered focus:textarea-primary focus:ring-primary/20 w-full resize-none rounded-xl text-sm focus:ring-2"
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 mb-5 rounded-xl shadow-lg">
<CheckCircle2 class="h-4 w-4" />
<span class="text-sm font-semibold">{sucesso}</span>
</div>
{/if}
<!-- Próximo Registro -->
<div
class="card from-info/10 to-info/5 border-info/20 rounded-xl border-2 bg-linear-to-br p-4 shadow-md"
>
<div class="flex items-center justify-center gap-2">
<div class="bg-info/20 rounded-lg p-1.5">
{#if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
<LogIn class="text-info h-4 w-4" strokeWidth={2.5} />
{:else}
<LogOut class="text-info h-4 w-4" strokeWidth={2.5} />
{/if}
</div>
<div class="text-center">
<p class="text-base-content/60 mb-0.5 text-xs font-semibold tracking-wide uppercase">
Próximo Registro
</p>
<p class="text-base-content text-base font-bold">{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="mx-auto max-w-[220px] min-w-[140px] flex-1">
<div
class="relative h-full rounded-xl border-2 transition-all duration-300 hover:shadow-lg {horario.registrado
? horario.dentroDoPrazo
? 'from-success/20 to-success/10 border-success bg-linear-to-br shadow-md'
: 'from-error/20 to-error/10 border-error bg-linear-to-br shadow-md'
: 'from-base-200 to-base-300 border-base-300 bg-linear-to-br'} p-5"
>
<!-- Status Icon -->
<div class="absolute top-3 right-3">
{#if horario.registrado}
{#if horario.dentroDoPrazo}
<CheckCircle2 class="text-success h-5 w-5" />
{:else}
<XCircle class="text-error h-5 w-5" />
{/if}
{:else}
<Clock class="text-base-content/30 h-5 w-5" />
{/if}
</div>
<!-- Label -->
<div class="mb-3">
<span class="text-base-content/80 text-sm font-semibold tracking-wide uppercase">
{horario.label}
</span>
</div>
<!-- Horário Padrão -->
<div class="mb-2">
<div class="text-primary font-mono text-3xl font-bold">
{horario.horario}
</div>
</div>
<!-- Horário Registrado (se houver) -->
{#if horario.registrado}
<div class="border-base-content/10 mt-3 border-t pt-3">
<div class="flex items-center gap-2">
<div class="text-base-content/60 text-xs font-medium">Registrado:</div>
<div class="text-base-content text-sm font-bold">
{horario.horarioRegistrado}
</div>
</div>
</div>
{:else}
<div class="border-base-content/10 mt-3 border-t pt-3">
<div class="text-base-content/40 text-xs 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="text-success h-8 w-8" />
{:else}
<TrendingDown class="text-error h-8 w-8" />
{/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="from-primary/20 via-base-300 to-secondary/20 absolute top-0 bottom-0 left-1/2 w-1 -translate-x-1/2 transform bg-linear-to-b"
></div>
<!-- Container com duas colunas -->
<div class="relative grid grid-cols-2 gap-4">
<!-- Coluna Entrada -->
<div class="space-y-4 pr-2">
<div class="bg-base-100 border-primary/20 sticky top-0 z-10 mb-2 border-b pb-3">
<h4
class="text-primary flex items-center justify-center gap-2 text-center text-lg font-bold"
>
<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="bg-base-300/50 absolute top-6 right-0 h-0.5 w-full"
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 transition-all hover:shadow-lg"
>
<div class="card-body p-4">
<!-- Tipo de registro e status -->
<div class="mb-2 flex items-center gap-2">
{#if registro.dentroDoPrazo}
<CheckCircle2 class="text-success h-4 w-4 shrink-0" />
{:else}
<XCircle class="text-error h-4 w-4 shrink-0" />
{/if}
<span class="text-base-content/80 text-sm font-semibold">
{config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeRetornoAlmoco: config.nomeRetornoAlmoco
})
: getTipoRegistroLabel(registro.tipo)}
</span>
</div>
<!-- Horário registrado -->
<p class="text-primary mb-1 text-3xl font-bold">
{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="mb-3 flex items-center gap-2 text-xs">
<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="bg-base-300/50 mt-2 mb-3 rounded-lg p-2 text-xs">
<p class="mb-1 font-semibold opacity-70">Justificativa:</p>
<p class="text-base-content/80">{registro.justificativa}</p>
</div>
{/if}
<button
class="btn btn-sm btn-outline btn-primary w-full gap-2"
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 (horarioEsperado.tipo)}
{#if !registrosOrdenados.find((r) => r.tipo === horarioEsperado.tipo)}
<div class="relative opacity-50">
<div
class="bg-base-300/30 absolute top-6 right-0 h-0.5 w-full border-dashed"
style="width: calc(100% - 0.5rem);"
></div>
<div class="card bg-base-200/50 border-base-300 border border-dashed">
<div class="card-body p-3">
<p class="text-base-content/50 mb-1 text-xs">
{horarioEsperado.label} (não registrado)
</p>
<p class="text-base-content/40 text-xl font-bold">
{horarioEsperado.horario}
</p>
</div>
</div>
</div>
{/if}
{/each}
{/if}
</div>
<!-- Coluna Saída -->
<div class="space-y-4 pl-2">
<div class="bg-base-100 border-secondary/20 sticky top-0 z-10 mb-2 border-b pb-3">
<h4
class="text-secondary flex items-center justify-center gap-2 text-center text-lg font-bold"
>
<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="bg-base-300/50 absolute top-6 left-0 h-0.5 w-full"
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 transition-all hover:shadow-lg"
>
<div class="card-body p-4">
<!-- Tipo de registro e status -->
<div class="mb-2 flex items-center justify-end gap-2">
<span class="text-base-content/80 text-sm font-semibold">
{config
? getTipoRegistroLabel(registro.tipo, {
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeSaida: config.nomeSaida
})
: getTipoRegistroLabel(registro.tipo)}
</span>
{#if registro.dentroDoPrazo}
<CheckCircle2 class="text-success h-4 w-4 shrink-0" />
{:else}
<XCircle class="text-error h-4 w-4 shrink-0" />
{/if}
</div>
<!-- Horário registrado -->
<p class="text-secondary mb-1 text-right text-3xl font-bold">
{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="mb-3 flex items-center justify-end gap-2 text-xs">
{#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="bg-base-300/50 mt-2 mb-3 rounded-lg p-2 text-xs">
<p class="mb-1 font-semibold opacity-70">Justificativa:</p>
<p class="text-base-content/80">{registro.justificativa}</p>
</div>
{/if}
<button
class="btn btn-sm btn-outline btn-primary w-full gap-2"
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 (horarioEsperado.tipo)}
{#if !registrosOrdenados.find((r) => r.tipo === horarioEsperado.tipo)}
<div class="relative opacity-50">
<div
class="bg-base-300/30 absolute top-6 left-0 h-0.5 w-full border-dashed"
style="width: calc(100% - 0.5rem);"
></div>
<div class="card bg-base-200/50 border-base-300 border border-dashed">
<div class="card-body p-3">
<p class="text-base-content/50 mb-1 text-right text-xs">
{horarioEsperado.label} (não registrado)
</p>
<p class="text-base-content/40 text-right text-xl font-bold">
{horarioEsperado.horario}
</p>
</div>
</div>
</div>
{/if}
{/each}
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Modal Webcam -->
{#if mostrandoWebcam}
<div
class="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-webcam-title"
>
<!-- Backdrop leve -->
<div
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
role="button"
tabindex="0"
onclick={handleWebcamCancel}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleWebcamCancel()}
></div>
<!-- Modal Box -->
<div
class="bg-base-100 pointer-events-auto absolute z-10 flex max-h-[90vh] w-full max-w-2xl transform flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
>
<!-- Header fixo -->
<div class="border-base-300 flex shrink-0 items-center justify-between border-b px-6 py-4">
<div class="flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<Camera class="text-primary h-5 w-5" strokeWidth={2} />
</div>
<h3 id="modal-webcam-title" class="text-base-content text-xl font-bold">
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="modal-scroll flex-1 overflow-y-auto px-6 py-4">
<div class="flex min-h-[200px] items-center justify-center">
<WebcamCapture
onCapture={handleWebcamCapture}
onCancel={handleWebcamCancel}
onError={handleWebcamError}
autoCapture={false}
fotoObrigatoria={true}
/>
</div>
</div>
</div>
</div>
{/if}
<!-- Modal de Transição -->
{#if mostrandoTransicao}
<div
class="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-transicao-title"
>
<!-- Backdrop leve -->
<div
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
></div>
<!-- Modal Box -->
<div
class="bg-base-100 pointer-events-auto absolute z-10 flex min-h-[300px] transform flex-col items-center justify-center rounded-2xl p-8 shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
>
<div class="flex flex-col items-center gap-6 text-center">
<div class="relative">
<div class="bg-primary/20 absolute inset-0 animate-ping rounded-full"></div>
<div class="bg-primary/10 relative rounded-full p-4">
<Clock class="text-primary h-12 w-12 animate-pulse" strokeWidth={2} />
</div>
</div>
<h3 id="modal-transicao-title" class="text-base-content text-2xl font-black">
Registro de Ponto em Andamento
</h3>
<div class="flex gap-2">
<span class="loading loading-dots loading-md text-primary"></span>
</div>
</div>
</div>
</div>
{/if}
<!-- Modal de Aguardando Processamento -->
{#if aguardandoProcessamento}
<div
class="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-aguardando-title"
>
<!-- Backdrop leve -->
<div
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
></div>
<!-- Modal Box -->
<div
class="bg-base-100 pointer-events-auto absolute z-10 w-full max-w-md transform rounded-2xl p-8 shadow-2xl transition-all duration-300"
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-base-content text-xl font-bold">
{#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="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-confirmacao-title"
>
<!-- Backdrop leve -->
<div
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
role="button"
tabindex="0"
onclick={cancelarRegistro}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && cancelarRegistro()}
></div>
<!-- Modal Box -->
<div
class="bg-base-100 pointer-events-auto absolute z-10 flex max-h-[90vh] w-full max-w-3xl transform flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
>
<!-- Header fixo -->
<div class="border-base-300 flex shrink-0 items-center justify-between border-b px-6 py-4">
<div class="flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<Clock class="text-primary h-6 w-6" strokeWidth={2} />
</div>
<div>
<h3 id="modal-confirmacao-title" class="text-base-content text-xl font-bold">
Confirmar Registro de Ponto
</h3>
<p class="text-base-content/70 text-sm">
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="modal-scroll flex-1 space-y-6 overflow-y-auto px-6 py-4">
<!-- Card da Imagem -->
<div
class="card from-base-200 to-base-300 border-primary/20 border-2 bg-linear-to-br shadow-lg"
>
<div class="card-body p-6">
<div class="mb-4 flex items-center gap-2">
<div class="bg-primary/10 rounded-lg p-1.5">
<ImageIcon class="text-primary h-5 w-5" strokeWidth={2} />
</div>
<h4 class="text-lg font-semibold">Foto Capturada</h4>
</div>
<div
class="bg-base-100 border-primary/30 flex justify-center rounded-xl border-2 p-4"
>
<img
src={URL.createObjectURL(imagemCapturada)}
alt="Foto capturada do registro de ponto"
class="max-h-[250px] max-w-full rounded-lg object-contain shadow-md"
/>
</div>
</div>
</div>
<!-- Card de Informações -->
<div
class="card from-primary/5 to-primary/10 border-primary/20 border-2 bg-linear-to-br shadow-lg"
>
<div class="card-body p-6">
<div class="mb-4 flex items-center gap-2">
<div class="bg-primary/20 rounded-lg p-1.5">
<CheckCircle2 class="text-primary h-5 w-5" strokeWidth={2} />
</div>
<h4 class="text-lg font-semibold">Informações do Registro</h4>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Tipo de Registro -->
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<p class="text-base-content/70 mb-1 text-sm font-medium">Tipo de Registro</p>
<p class="text-primary text-lg font-bold">{tipoLabel}</p>
</div>
<!-- Data -->
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<p class="text-base-content/70 mb-1 text-sm font-medium">Data</p>
<p class="text-base-content text-lg font-bold">{dataHoraAtual.data}</p>
</div>
<!-- Hora -->
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<p class="text-base-content/70 mb-1 text-sm font-medium">Horário</p>
<p class="text-base-content text-lg font-bold">{dataHoraAtual.hora}</p>
</div>
<!-- Status -->
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<p class="text-base-content/70 mb-1 text-sm font-medium">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="bg-base-100 border-base-300 mt-4 rounded-lg border p-4 shadow-sm">
<p class="text-base-content/70 mb-2 text-sm font-medium">Justificativa</p>
<p class="text-base-content text-base whitespace-pre-wrap">{justificativa}</p>
</div>
{/if}
</div>
</div>
<!-- Aviso -->
<div class="alert alert-info shadow-lg">
<Info class="h-6 w-6 shrink-0 stroke-current" />
<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="border-base-300 flex shrink-0 justify-end gap-3 border-t px-6 py-4">
<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>