diff --git a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte index cecf612..10c9f1e 100644 --- a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte +++ b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte @@ -118,34 +118,7 @@ ); yPosition += 10; - // Informações de Localização (se disponível) - if (registro.latitude && registro.longitude) { - doc.setFont('helvetica', 'bold'); - doc.text('LOCALIZAÇÃO', 15, yPosition); - doc.setFont('helvetica', 'normal'); - yPosition += 8; - doc.setFontSize(10); - - if (registro.endereco) { - doc.text(`Endereço: ${registro.endereco}`, 15, yPosition); - yPosition += 6; - } - if (registro.cidade) { - doc.text(`Cidade: ${registro.cidade}`, 15, yPosition); - yPosition += 6; - } - if (registro.estado) { - doc.text(`Estado: ${registro.estado}`, 15, yPosition); - yPosition += 6; - } - doc.text(`Coordenadas: ${registro.latitude.toFixed(6)}, ${registro.longitude.toFixed(6)}`, 15, yPosition); - yPosition += 6; - if (registro.precisao) { - doc.text(`Precisão: ${registro.precisao.toFixed(0)} metros`, 15, yPosition); - yPosition += 6; - } - yPosition += 5; - } + // Detalhes de localização removidos do comprovante (serão exibidos apenas no relatório detalhado) // Informações do Dispositivo (resumido) if (registro.browser || registro.sistemaOperacional) { diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index df03efd..6c1000d 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -45,6 +45,8 @@ let mensagemErroModal = $state(''); let detalhesErroModal = $state(''); let justificativa = $state(''); + let mostrandoModalConfirmacao = $state(false); + let dataHoraAtual = $state<{ data: string; hora: string } | null>(null); const registrosHoje = $derived(registrosHojeQuery?.data || []); const config = $derived(configQuery?.data); @@ -89,9 +91,64 @@ } } + // Verificar permissões de localização e webcam + async function verificarPermissoes(): Promise<{ localizacao: boolean; webcam: boolean }> { + let localizacaoAutorizada = false; + let webcamAutorizada = false; + + // Verificar permissão de geolocalização + if (navigator.geolocation) { + try { + await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error('Timeout')); + }, 5000); + + navigator.geolocation.getCurrentPosition( + () => { + clearTimeout(timeoutId); + localizacaoAutorizada = true; + resolve(); + }, + () => { + clearTimeout(timeoutId); + reject(new Error('Permissão de localização negada')); + }, + { timeout: 5000, maximumAge: 0, enableHighAccuracy: false } + ); + }); + } catch (error) { + console.warn('Permissão de localização não concedida:', error); + } + } + + // Verificar permissão de webcam + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ video: true }); + webcamAutorizada = true; + // Parar o stream imediatamente, apenas verificamos a permissão + stream.getTracks().forEach(track => track.stop()); + } catch (error) { + console.warn('Permissão de webcam não concedida:', error); + } + } + + return { localizacao: localizacaoAutorizada, webcam: webcamAutorizada }; + } + async function registrarPonto() { if (registrando) return; + // Verificar permissões antes de registrar + const permissoes = await verificarPermissoes(); + if (!permissoes.localizacao || !permissoes.webcam) { + mensagemErroModal = 'Permissões necessárias'; + detalhesErroModal = 'Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.'; + mostrarModalErro = true; + return; + } + registrando = true; erro = null; sucesso = null; @@ -106,16 +163,17 @@ const timestamp = await obterTempoServidor(client); const sincronizadoComServidor = true; // Sempre true quando usamos obterTempoServidor - // Upload da imagem se houver (não bloquear se falhar) + // Upload da imagem (obrigatória agora) let imagemId: Id<'_storage'> | undefined = undefined; if (imagemCapturada) { try { imagemId = await uploadImagem(imagemCapturada); } catch (error) { - console.warn('Erro ao fazer upload da imagem, continuando sem foto:', error); - // Continuar sem foto se o upload falhar - imagemId = undefined; + 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 @@ -131,6 +189,7 @@ sucesso = `Ponto registrado com sucesso! Tipo: ${getTipoRegistroLabel(resultado.tipo)}`; imagemCapturada = null; justificativa = ''; // Limpar justificativa após registro + mostrandoModalConfirmacao = false; // Mostrar comprovante após 1 segundo setTimeout(() => { @@ -162,50 +221,82 @@ } } - function handleWebcamCapture(blob: Blob | null) { + async function handleWebcamCapture(blob: Blob | null) { if (blob) { imagemCapturada = blob; } mostrandoWebcam = false; - // Se estava capturando automaticamente, registrar o ponto após capturar (com ou sem foto) - if (capturandoAutomaticamente) { + // Se capturou a foto, mostrar modal de confirmação + if (blob && capturandoAutomaticamente) { capturandoAutomaticamente = false; - // Pequeno delay para garantir que a imagem foi processada (se houver) - setTimeout(() => { - registrarPonto(); - }, 100); + // Obter data e hora sincronizada do servidor + try { + const timestamp = await obterTempoServidor(client); + 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() { - const estavaCapturando = capturandoAutomaticamente; mostrandoWebcam = false; capturandoAutomaticamente = false; imagemCapturada = null; - // Se estava capturando automaticamente e cancelou, registrar sem foto - if (estavaCapturando) { - registrarPonto(); - } + mostrandoModalConfirmacao = false; } function handleWebcamError() { - // Em caso de erro na captura, registrar sem foto + // Em caso de erro na captura, fechar tudo mostrandoWebcam = false; capturandoAutomaticamente = false; imagemCapturada = null; - // Registrar ponto sem foto - registrarPonto(); + 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; - // Abrir webcam automaticamente + // Verificar permissões antes de abrir webcam + const permissoes = await verificarPermissoes(); + if (!permissoes.localizacao || !permissoes.webcam) { + mensagemErroModal = 'Permissões necessárias'; + detalhesErroModal = 'Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.'; + mostrarModalErro = true; + return; + } + + // Abrir webcam capturandoAutomaticamente = true; mostrandoWebcam = true; } + function confirmarRegistro() { + mostrandoModalConfirmacao = false; + registrarPonto(); + } + + function cancelarRegistro() { + mostrandoModalConfirmacao = false; + imagemCapturada = null; + } + function fecharComprovante() { mostrandoComprovante = false; registroId = null; @@ -503,20 +594,15 @@ > @@ -533,6 +619,61 @@ {/if} + + {#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual} + + {/if} + {#if mostrandoComprovante && registroId} diff --git a/apps/web/src/lib/components/ponto/WebcamCapture.svelte b/apps/web/src/lib/components/ponto/WebcamCapture.svelte index 99e55e1..1bb93a3 100644 --- a/apps/web/src/lib/components/ponto/WebcamCapture.svelte +++ b/apps/web/src/lib/components/ponto/WebcamCapture.svelte @@ -8,9 +8,10 @@ onCancel: () => void; onError?: () => void; autoCapture?: boolean; + fotoObrigatoria?: boolean; // Se true, não permite continuar sem foto } - let { onCapture, onCancel, onError, autoCapture = false }: Props = $props(); + let { onCapture, onCancel, onError, autoCapture = false, fotoObrigatoria = false }: Props = $props(); let videoElement: HTMLVideoElement | null = $state(null); let canvasElement: HTMLCanvasElement | null = $state(null); @@ -23,24 +24,35 @@ // Efeito para garantir que o vídeo seja exibido quando o stream for atribuído $effect(() => { - if (stream && videoElement && !videoReady && videoElement.srcObject !== stream) { - // Apenas atribuir se ainda não foi atribuído - videoElement.srcObject = stream; - videoElement.play() - .then(() => { - if (videoElement && videoElement.readyState >= 2) { - videoReady = true; - } - }) - .catch((err) => { - console.warn('Erro ao reproduzir vídeo no effect:', err); - }); + if (stream && videoElement) { + // Sempre atualizar srcObject quando o stream mudar + if (videoElement.srcObject !== stream) { + videoElement.srcObject = stream; + } + + // Tentar reproduzir se ainda não estiver pronto + if (!videoReady && videoElement.readyState < 2) { + videoElement.play() + .then(() => { + // Aguardar um pouco para garantir que o vídeo esteja realmente reproduzindo + setTimeout(() => { + if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) { + videoReady = true; + } + }, 300); + }) + .catch((err) => { + console.warn('Erro ao reproduzir vídeo no effect:', err); + }); + } else if (videoElement.readyState >= 2 && videoElement.videoWidth > 0) { + videoReady = true; + } } }); onMount(async () => { - // Aguardar um pouco para garantir que os elementos estejam no DOM - await new Promise(resolve => setTimeout(resolve, 100)); + // Aguardar mais tempo para garantir que os elementos estejam no DOM + await new Promise(resolve => setTimeout(resolve, 300)); // Verificar suporte if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { @@ -60,21 +72,8 @@ } } - // Aguardar videoElement estar disponível - let tentativas = 0; - while (!videoElement && tentativas < 10) { - await new Promise(resolve => setTimeout(resolve, 100)); - tentativas++; - } - - if (!videoElement) { - erro = 'Elemento de vídeo não encontrado'; - if (autoCapture && onError) { - onError(); - } - return; - } - + // Primeiro, tentar acessar a webcam antes de verificar o elemento + // Isso garante que temos permissão antes de tentar renderizar o vídeo try { // Tentar diferentes configurações de webcam const constraints = [ @@ -103,123 +102,193 @@ ]; let ultimoErro: Error | null = null; + let streamObtido = false; for (const constraint of constraints) { try { console.log('Tentando acessar webcam com constraint:', constraint); - stream = await navigator.mediaDevices.getUserMedia(constraint); + const tempStream = await navigator.mediaDevices.getUserMedia(constraint); // Verificar se o stream tem tracks de vídeo - if (stream.getVideoTracks().length === 0) { - stream.getTracks().forEach(track => track.stop()); - stream = null; + if (tempStream.getVideoTracks().length === 0) { + tempStream.getTracks().forEach(track => track.stop()); continue; } console.log('Webcam acessada com sucesso'); + stream = tempStream; webcamDisponivel = true; - - // Atribuir stream ao elemento de vídeo (o $effect também fará isso, mas garantimos aqui) - if (videoElement) { - videoElement.srcObject = stream; - - // Aguardar o vídeo estar pronto - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Timeout ao carregar vídeo')); - }, 10000); - - const onLoadedMetadata = () => { - clearTimeout(timeout); - videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); - videoElement?.removeEventListener('playing', onPlaying); - videoElement?.removeEventListener('error', onError); - videoReady = true; - resolve(); - }; - - const onPlaying = () => { - clearTimeout(timeout); - videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); - videoElement?.removeEventListener('playing', onPlaying); - videoElement?.removeEventListener('error', onError); - videoReady = true; - resolve(); - }; - - const onError = () => { - clearTimeout(timeout); - videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); - videoElement?.removeEventListener('playing', onPlaying); - videoElement?.removeEventListener('error', onError); - reject(new Error('Erro ao carregar vídeo')); - }; - - videoElement.addEventListener('loadedmetadata', onLoadedMetadata); - videoElement.addEventListener('playing', onPlaying); - videoElement.addEventListener('error', onError); - - // Tentar reproduzir - videoElement.play() - .then(() => { - console.log('Vídeo iniciado, readyState:', videoElement?.readyState); - // Se já tiver metadata, resolver imediatamente - if (videoElement && videoElement.readyState >= 2) { - onLoadedMetadata(); - } - }) - .catch((err) => { - console.warn('Erro ao reproduzir vídeo:', err); - // Continuar mesmo assim se já tiver metadata - if (videoElement && videoElement.readyState >= 2) { - onLoadedMetadata(); - } else { - onError(); - } - }); - }); - - console.log('Vídeo pronto, dimensões:', videoElement.videoWidth, 'x', videoElement.videoHeight); - } - - // Se for captura automática, aguardar um pouco e capturar - if (autoCapture) { - // Aguardar 1.5 segundos para o vídeo estabilizar - setTimeout(() => { - if (videoElement && canvasElement && !capturando && !previewUrl && webcamDisponivel) { - capturar(); - } - }, 1500); - } - - // Sucesso, sair do loop - return; + streamObtido = true; + break; } catch (err) { console.warn('Falha ao acessar webcam com constraint:', constraint, err); ultimoErro = err instanceof Error ? err : new Error(String(err)); - if (stream) { - stream.getTracks().forEach(track => track.stop()); - stream = null; - } continue; } } - // Se chegou aqui, todas as tentativas falharam - throw ultimoErro || new Error('Não foi possível acessar a webcam'); + if (!streamObtido) { + throw ultimoErro || new Error('Não foi possível acessar a webcam'); + } + + // Agora que temos o stream, aguardar o elemento de vídeo estar disponível + let tentativas = 0; + while (!videoElement && tentativas < 30) { + await new Promise(resolve => setTimeout(resolve, 100)); + tentativas++; + } + + if (!videoElement) { + erro = 'Elemento de vídeo não encontrado'; + if (stream) { + stream.getTracks().forEach(track => track.stop()); + stream = null; + } + webcamDisponivel = false; + if (fotoObrigatoria) { + return; + } + if (autoCapture && onError) { + onError(); + } + return; + } + + // Atribuir stream ao elemento de vídeo + if (videoElement && stream) { + videoElement.srcObject = stream; + + // Aguardar o vídeo estar pronto com timeout maior + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + // Se o vídeo tem dimensões, considerar pronto mesmo sem eventos + if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { + videoReady = true; + resolve(); + } else { + reject(new Error('Timeout ao carregar vídeo')); + } + }, 15000); // Aumentar timeout para 15 segundos + + const onLoadedMetadata = () => { + clearTimeout(timeout); + videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + videoElement?.removeEventListener('playing', onPlaying); + videoElement?.removeEventListener('loadeddata', onLoadedData); + videoElement?.removeEventListener('error', onError); + // Aguardar um pouco mais para garantir que o vídeo esteja realmente visível + setTimeout(() => { + videoReady = true; + resolve(); + }, 200); + }; + + const onLoadedData = () => { + if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { + clearTimeout(timeout); + videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + videoElement?.removeEventListener('playing', onPlaying); + videoElement?.removeEventListener('loadeddata', onLoadedData); + videoElement?.removeEventListener('error', onError); + videoReady = true; + resolve(); + } + }; + + const onPlaying = () => { + clearTimeout(timeout); + videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + videoElement?.removeEventListener('playing', onPlaying); + videoElement?.removeEventListener('loadeddata', onLoadedData); + videoElement?.removeEventListener('error', onError); + videoReady = true; + resolve(); + }; + + const onError = () => { + clearTimeout(timeout); + videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata); + videoElement?.removeEventListener('playing', onPlaying); + videoElement?.removeEventListener('loadeddata', onLoadedData); + videoElement?.removeEventListener('error', onError); + reject(new Error('Erro ao carregar vídeo')); + }; + + videoElement.addEventListener('loadedmetadata', onLoadedMetadata); + videoElement.addEventListener('loadeddata', onLoadedData); + videoElement.addEventListener('playing', onPlaying); + videoElement.addEventListener('error', onError); + + // Tentar reproduzir + videoElement.play() + .then(() => { + console.log('Vídeo iniciado, readyState:', videoElement?.readyState); + // Se já tiver metadata e dimensões, resolver imediatamente + if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) { + setTimeout(() => { + onLoadedMetadata(); + }, 300); + } + }) + .catch((err) => { + console.warn('Erro ao reproduzir vídeo:', err); + // Continuar mesmo assim se já tiver metadata e dimensões + if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) { + setTimeout(() => { + onLoadedMetadata(); + }, 300); + } else { + // Aguardar um pouco mais antes de dar erro + setTimeout(() => { + if (videoElement && videoElement.videoWidth > 0) { + onLoadedMetadata(); + } else { + onError(); + } + }, 1000); + } + }); + }); + + console.log('Vídeo pronto, dimensões:', videoElement.videoWidth, 'x', videoElement.videoHeight); + } + + // Se for captura automática, aguardar um pouco e capturar + if (autoCapture) { + // Aguardar 1.5 segundos para o vídeo estabilizar + setTimeout(() => { + if (videoElement && canvasElement && !capturando && !previewUrl && webcamDisponivel) { + capturar(); + } + }, 1500); + } + + // Sucesso, sair do try + return; } catch (error) { console.error('Erro ao acessar webcam:', error); const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) { - erro = 'Permissão de webcam negada. Continuando sem foto.'; + erro = fotoObrigatoria + ? 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.' + : 'Permissão de webcam negada. Continuando sem foto.'; } else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) { - erro = 'Nenhuma webcam encontrada. Continuando sem foto.'; + erro = fotoObrigatoria + ? 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.' + : 'Nenhuma webcam encontrada. Continuando sem foto.'; } else { - erro = 'Erro ao acessar webcam. Continuando sem foto.'; + erro = fotoObrigatoria + ? 'Erro ao acessar webcam. Verifique as permissões e tente novamente.' + : 'Erro ao acessar webcam. Continuando sem foto.'; } webcamDisponivel = false; + // Se foto é obrigatória, não chamar onError para permitir continuar sem foto + if (fotoObrigatoria) { + // Apenas mostrar o erro e aguardar o usuário fechar ou tentar novamente + return; + } // Se for captura automática e houver erro, chamar onError para continuar sem foto if (autoCapture && onError) { setTimeout(() => { @@ -247,28 +316,40 @@ return; } - // Verificar se o vídeo está pronto - if (videoElement.readyState < 2) { + // Verificar se o vídeo está pronto e tem dimensões válidas + if (videoElement.readyState < 2 || videoElement.videoWidth === 0 || videoElement.videoHeight === 0) { console.warn('Vídeo ainda não está pronto, aguardando...'); - await new Promise((resolve) => { + await new Promise((resolve, reject) => { + let tentativas = 0; + const maxTentativas = 50; // 5 segundos const checkReady = () => { - if (videoElement && videoElement.readyState >= 2) { + tentativas++; + if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) { resolve(); + } else if (tentativas >= maxTentativas) { + reject(new Error('Timeout aguardando vídeo ficar pronto')); } else { setTimeout(checkReady, 100); } }; checkReady(); + }).catch((error) => { + console.error('Erro ao aguardar vídeo:', error); + erro = 'Vídeo não está pronto. Aguarde um momento e tente novamente.'; + capturando = false; + return; // Retornar aqui para não continuar }); + + // Se chegou aqui, o vídeo está pronto, continuar com a captura } capturando = true; erro = null; try { - // Verificar dimensões do vídeo + // Verificar dimensões do vídeo novamente antes de capturar if (!videoElement.videoWidth || !videoElement.videoHeight) { - throw new Error('Dimensões do vídeo não disponíveis'); + throw new Error('Dimensões do vídeo não disponíveis. Aguarde a câmera carregar completamente.'); } // Configurar canvas com as dimensões do vídeo @@ -281,7 +362,11 @@ throw new Error('Não foi possível obter contexto do canvas'); } + // Limpar canvas antes de desenhar + ctx.clearRect(0, 0, canvasElement.width, canvasElement.height); + // Desenhar frame atual do vídeo no canvas + // O vídeo está espelhado no CSS para visualização, mas capturamos normalmente ctx.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height); // Converter para blob @@ -320,7 +405,15 @@ } } catch (error) { console.error('Erro ao capturar:', error); - erro = 'Erro ao capturar imagem. Continuando sem foto.'; + erro = fotoObrigatoria + ? 'Erro ao capturar imagem. Tente novamente.' + : 'Erro ao capturar imagem. Continuando sem foto.'; + // Se foto é obrigatória, não chamar onError para permitir continuar sem foto + if (fotoObrigatoria) { + // Apenas mostrar o erro e permitir que o usuário tente novamente + capturando = false; + return; + } // Se for captura automática e houver erro, continuar sem foto if (autoCapture && onError) { setTimeout(() => { @@ -387,17 +480,68 @@ Verificando webcam... - {#if !autoCapture} + {#if !autoCapture && !fotoObrigatoria}
+ {:else if fotoObrigatoria} +
+ + A captura de foto é obrigatória para registrar o ponto. +
{/if} {:else if erro && !webcamDisponivel} -
+
{erro}
- {#if autoCapture} + {#if fotoObrigatoria} +
+ Não é possível registrar o ponto sem capturar uma foto. Verifique as permissões da webcam e tente novamente. +
+
+ + +
+ {:else if autoCapture}
O registro será feito sem foto.
@@ -445,6 +589,10 @@
Capturando foto automaticamente...
+ {:else} +
+ Posicione-se na frente da câmera e clique em "Capturar Foto" +
{/if}
{#if !videoReady && webcamDisponivel} -
+
+ Carregando câmera... +
+ {:else if videoReady && webcamDisponivel} +
+
+ + Câmera ativa +
{/if}
@@ -468,15 +624,20 @@
{/if} {#if !autoCapture} - +
- + {/each}