feat: update point registration process with mandatory photo capture and location details
- Removed location details from the point receipt, now displayed only in detailed reports. - Implemented mandatory photo capture during point registration, enhancing accountability. - Added confirmation modal for users to verify details before finalizing point registration. - Improved error handling for webcam access and photo capture, ensuring a smoother user experience. - Enhanced UI components for better feedback and interaction during the registration process.
This commit is contained in:
@@ -118,34 +118,7 @@
|
|||||||
);
|
);
|
||||||
yPosition += 10;
|
yPosition += 10;
|
||||||
|
|
||||||
// Informações de Localização (se disponível)
|
// Detalhes de localização removidos do comprovante (serão exibidos apenas no relatório detalhado)
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Informações do Dispositivo (resumido)
|
// Informações do Dispositivo (resumido)
|
||||||
if (registro.browser || registro.sistemaOperacional) {
|
if (registro.browser || registro.sistemaOperacional) {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@
|
|||||||
let mensagemErroModal = $state('');
|
let mensagemErroModal = $state('');
|
||||||
let detalhesErroModal = $state('');
|
let detalhesErroModal = $state('');
|
||||||
let justificativa = $state('');
|
let justificativa = $state('');
|
||||||
|
let mostrandoModalConfirmacao = $state(false);
|
||||||
|
let dataHoraAtual = $state<{ data: string; hora: string } | null>(null);
|
||||||
|
|
||||||
const registrosHoje = $derived(registrosHojeQuery?.data || []);
|
const registrosHoje = $derived(registrosHojeQuery?.data || []);
|
||||||
const config = $derived(configQuery?.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<void>((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
reject(new Error('Timeout'));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
() => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
localizacaoAutorizada = true;
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(new Error('Permissão de localização negada'));
|
||||||
|
},
|
||||||
|
{ timeout: 5000, maximumAge: 0, enableHighAccuracy: false }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Permissão de localização não concedida:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permissão de webcam
|
||||||
|
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
|
webcamAutorizada = true;
|
||||||
|
// Parar o stream imediatamente, apenas verificamos a permissão
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Permissão de webcam não concedida:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { localizacao: localizacaoAutorizada, webcam: webcamAutorizada };
|
||||||
|
}
|
||||||
|
|
||||||
async function registrarPonto() {
|
async function registrarPonto() {
|
||||||
if (registrando) return;
|
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;
|
registrando = true;
|
||||||
erro = null;
|
erro = null;
|
||||||
sucesso = null;
|
sucesso = null;
|
||||||
@@ -106,16 +163,17 @@
|
|||||||
const timestamp = await obterTempoServidor(client);
|
const timestamp = await obterTempoServidor(client);
|
||||||
const sincronizadoComServidor = true; // Sempre true quando usamos obterTempoServidor
|
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;
|
let imagemId: Id<'_storage'> | undefined = undefined;
|
||||||
if (imagemCapturada) {
|
if (imagemCapturada) {
|
||||||
try {
|
try {
|
||||||
imagemId = await uploadImagem(imagemCapturada);
|
imagemId = await uploadImagem(imagemCapturada);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Erro ao fazer upload da imagem, continuando sem foto:', error);
|
console.error('Erro ao fazer upload da imagem:', error);
|
||||||
// Continuar sem foto se o upload falhar
|
throw new Error('Erro ao fazer upload da imagem. Tente novamente.');
|
||||||
imagemId = undefined;
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('É necessário capturar uma foto para registrar o ponto.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registrar ponto
|
// Registrar ponto
|
||||||
@@ -131,6 +189,7 @@
|
|||||||
sucesso = `Ponto registrado com sucesso! Tipo: ${getTipoRegistroLabel(resultado.tipo)}`;
|
sucesso = `Ponto registrado com sucesso! Tipo: ${getTipoRegistroLabel(resultado.tipo)}`;
|
||||||
imagemCapturada = null;
|
imagemCapturada = null;
|
||||||
justificativa = ''; // Limpar justificativa após registro
|
justificativa = ''; // Limpar justificativa após registro
|
||||||
|
mostrandoModalConfirmacao = false;
|
||||||
|
|
||||||
// Mostrar comprovante após 1 segundo
|
// Mostrar comprovante após 1 segundo
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -162,50 +221,82 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWebcamCapture(blob: Blob | null) {
|
async function handleWebcamCapture(blob: Blob | null) {
|
||||||
if (blob) {
|
if (blob) {
|
||||||
imagemCapturada = blob;
|
imagemCapturada = blob;
|
||||||
}
|
}
|
||||||
mostrandoWebcam = false;
|
mostrandoWebcam = false;
|
||||||
|
|
||||||
// Se estava capturando automaticamente, registrar o ponto após capturar (com ou sem foto)
|
// Se capturou a foto, mostrar modal de confirmação
|
||||||
if (capturandoAutomaticamente) {
|
if (blob && capturandoAutomaticamente) {
|
||||||
capturandoAutomaticamente = false;
|
capturandoAutomaticamente = false;
|
||||||
// Pequeno delay para garantir que a imagem foi processada (se houver)
|
// Obter data e hora sincronizada do servidor
|
||||||
setTimeout(() => {
|
try {
|
||||||
registrarPonto();
|
const timestamp = await obterTempoServidor(client);
|
||||||
}, 100);
|
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() {
|
function handleWebcamCancel() {
|
||||||
const estavaCapturando = capturandoAutomaticamente;
|
|
||||||
mostrandoWebcam = false;
|
mostrandoWebcam = false;
|
||||||
capturandoAutomaticamente = false;
|
capturandoAutomaticamente = false;
|
||||||
imagemCapturada = null;
|
imagemCapturada = null;
|
||||||
// Se estava capturando automaticamente e cancelou, registrar sem foto
|
mostrandoModalConfirmacao = false;
|
||||||
if (estavaCapturando) {
|
|
||||||
registrarPonto();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWebcamError() {
|
function handleWebcamError() {
|
||||||
// Em caso de erro na captura, registrar sem foto
|
// Em caso de erro na captura, fechar tudo
|
||||||
mostrandoWebcam = false;
|
mostrandoWebcam = false;
|
||||||
capturandoAutomaticamente = false;
|
capturandoAutomaticamente = false;
|
||||||
imagemCapturada = null;
|
imagemCapturada = null;
|
||||||
// Registrar ponto sem foto
|
mostrandoModalConfirmacao = false;
|
||||||
registrarPonto();
|
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() {
|
async function iniciarRegistroComFoto() {
|
||||||
if (registrando || coletandoInfo) return;
|
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;
|
capturandoAutomaticamente = true;
|
||||||
mostrandoWebcam = true;
|
mostrandoWebcam = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmarRegistro() {
|
||||||
|
mostrandoModalConfirmacao = false;
|
||||||
|
registrarPonto();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelarRegistro() {
|
||||||
|
mostrandoModalConfirmacao = false;
|
||||||
|
imagemCapturada = null;
|
||||||
|
}
|
||||||
|
|
||||||
function fecharComprovante() {
|
function fecharComprovante() {
|
||||||
mostrandoComprovante = false;
|
mostrandoComprovante = false;
|
||||||
registroId = null;
|
registroId = null;
|
||||||
@@ -503,20 +594,15 @@
|
|||||||
>
|
>
|
||||||
<div class="modal-box max-w-2xl w-[95%] max-h-[90vh] overflow-y-auto relative" style="margin: auto; position: relative;">
|
<div class="modal-box max-w-2xl w-[95%] max-h-[90vh] overflow-y-auto relative" style="margin: auto; position: relative;">
|
||||||
<div class="sticky top-0 bg-base-100 z-10 pb-3 mb-4 border-b border-base-300 -mx-6 px-6">
|
<div class="sticky top-0 bg-base-100 z-10 pb-3 mb-4 border-b border-base-300 -mx-6 px-6">
|
||||||
<h3 class="text-lg font-bold">
|
<h3 class="text-lg font-bold">Capturar Foto</h3>
|
||||||
{#if capturandoAutomaticamente}
|
|
||||||
Capturando foto automaticamente...
|
|
||||||
{:else}
|
|
||||||
Capturar Foto
|
|
||||||
{/if}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-[200px] flex items-center justify-center py-4">
|
<div class="min-h-[200px] flex items-center justify-center py-4">
|
||||||
<WebcamCapture
|
<WebcamCapture
|
||||||
onCapture={handleWebcamCapture}
|
onCapture={handleWebcamCapture}
|
||||||
onCancel={handleWebcamCancel}
|
onCancel={handleWebcamCancel}
|
||||||
onError={handleWebcamError}
|
onError={handleWebcamError}
|
||||||
autoCapture={capturandoAutomaticamente}
|
autoCapture={false}
|
||||||
|
fotoObrigatoria={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -533,6 +619,61 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal de Confirmação -->
|
||||||
|
{#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual}
|
||||||
|
<div class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="font-bold text-lg">Confirmar Registro de Ponto</h3>
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost" onclick={cancelarRegistro}>
|
||||||
|
<XCircle class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Imagem capturada -->
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(imagemCapturada)}
|
||||||
|
alt="Foto capturada"
|
||||||
|
class="max-w-full max-h-96 rounded-lg border-2 border-primary object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data e Hora -->
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
<span class="text-base-content/70">Data: </span>
|
||||||
|
<span>{dataHoraAtual.data}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
<span class="text-base-content/70">Hora: </span>
|
||||||
|
<span>{dataHoraAtual.hora}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botões -->
|
||||||
|
<div class="flex justify-end gap-2 pt-4">
|
||||||
|
<button class="btn btn-outline" onclick={cancelarRegistro} disabled={registrando}>
|
||||||
|
<XCircle class="h-5 w-5" />
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick={confirmarRegistro} disabled={registrando}>
|
||||||
|
{#if registrando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Registrando...
|
||||||
|
{:else}
|
||||||
|
<CheckCircle2 class="h-5 w-5" />
|
||||||
|
Confirmar Registro
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" onclick={cancelarRegistro}></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Modal Comprovante -->
|
<!-- Modal Comprovante -->
|
||||||
{#if mostrandoComprovante && registroId}
|
{#if mostrandoComprovante && registroId}
|
||||||
<ComprovantePonto {registroId} onClose={fecharComprovante} />
|
<ComprovantePonto {registroId} onClose={fecharComprovante} />
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
autoCapture?: boolean;
|
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 videoElement: HTMLVideoElement | null = $state(null);
|
||||||
let canvasElement: HTMLCanvasElement | 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
|
// Efeito para garantir que o vídeo seja exibido quando o stream for atribuído
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (stream && videoElement && !videoReady && videoElement.srcObject !== stream) {
|
if (stream && videoElement) {
|
||||||
// Apenas atribuir se ainda não foi atribuído
|
// Sempre atualizar srcObject quando o stream mudar
|
||||||
|
if (videoElement.srcObject !== stream) {
|
||||||
videoElement.srcObject = stream;
|
videoElement.srcObject = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar reproduzir se ainda não estiver pronto
|
||||||
|
if (!videoReady && videoElement.readyState < 2) {
|
||||||
videoElement.play()
|
videoElement.play()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (videoElement && videoElement.readyState >= 2) {
|
// Aguardar um pouco para garantir que o vídeo esteja realmente reproduzindo
|
||||||
|
setTimeout(() => {
|
||||||
|
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||||
videoReady = true;
|
videoReady = true;
|
||||||
}
|
}
|
||||||
|
}, 300);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn('Erro ao reproduzir vídeo no effect:', err);
|
console.warn('Erro ao reproduzir vídeo no effect:', err);
|
||||||
});
|
});
|
||||||
|
} else if (videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||||
|
videoReady = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Aguardar um pouco para garantir que os elementos estejam no DOM
|
// Aguardar mais tempo para garantir que os elementos estejam no DOM
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
// Verificar suporte
|
// Verificar suporte
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
@@ -60,21 +72,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aguardar videoElement estar disponível
|
// Primeiro, tentar acessar a webcam antes de verificar o elemento
|
||||||
let tentativas = 0;
|
// Isso garante que temos permissão antes de tentar renderizar o vídeo
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Tentar diferentes configurações de webcam
|
// Tentar diferentes configurações de webcam
|
||||||
const constraints = [
|
const constraints = [
|
||||||
@@ -103,45 +102,104 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
let ultimoErro: Error | null = null;
|
let ultimoErro: Error | null = null;
|
||||||
|
let streamObtido = false;
|
||||||
|
|
||||||
for (const constraint of constraints) {
|
for (const constraint of constraints) {
|
||||||
try {
|
try {
|
||||||
console.log('Tentando acessar webcam com constraint:', constraint);
|
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
|
// Verificar se o stream tem tracks de vídeo
|
||||||
if (stream.getVideoTracks().length === 0) {
|
if (tempStream.getVideoTracks().length === 0) {
|
||||||
stream.getTracks().forEach(track => track.stop());
|
tempStream.getTracks().forEach(track => track.stop());
|
||||||
stream = null;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Webcam acessada com sucesso');
|
console.log('Webcam acessada com sucesso');
|
||||||
|
stream = tempStream;
|
||||||
webcamDisponivel = true;
|
webcamDisponivel = true;
|
||||||
|
streamObtido = true;
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Falha ao acessar webcam com constraint:', constraint, err);
|
||||||
|
ultimoErro = err instanceof Error ? err : new Error(String(err));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Atribuir stream ao elemento de vídeo (o $effect também fará isso, mas garantimos aqui)
|
if (!streamObtido) {
|
||||||
if (videoElement) {
|
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;
|
videoElement.srcObject = stream;
|
||||||
|
|
||||||
// Aguardar o vídeo estar pronto
|
// Aguardar o vídeo estar pronto com timeout maior
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
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'));
|
reject(new Error('Timeout ao carregar vídeo'));
|
||||||
}, 10000);
|
}
|
||||||
|
}, 15000); // Aumentar timeout para 15 segundos
|
||||||
|
|
||||||
const onLoadedMetadata = () => {
|
const onLoadedMetadata = () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
videoElement?.removeEventListener('playing', onPlaying);
|
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);
|
videoElement?.removeEventListener('error', onError);
|
||||||
videoReady = true;
|
videoReady = true;
|
||||||
resolve();
|
resolve();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPlaying = () => {
|
const onPlaying = () => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
videoElement?.removeEventListener('playing', onPlaying);
|
videoElement?.removeEventListener('playing', onPlaying);
|
||||||
|
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||||
videoElement?.removeEventListener('error', onError);
|
videoElement?.removeEventListener('error', onError);
|
||||||
videoReady = true;
|
videoReady = true;
|
||||||
resolve();
|
resolve();
|
||||||
@@ -151,11 +209,13 @@
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
videoElement?.removeEventListener('playing', onPlaying);
|
videoElement?.removeEventListener('playing', onPlaying);
|
||||||
|
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||||
videoElement?.removeEventListener('error', onError);
|
videoElement?.removeEventListener('error', onError);
|
||||||
reject(new Error('Erro ao carregar vídeo'));
|
reject(new Error('Erro ao carregar vídeo'));
|
||||||
};
|
};
|
||||||
|
|
||||||
videoElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
videoElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
videoElement.addEventListener('loadeddata', onLoadedData);
|
||||||
videoElement.addEventListener('playing', onPlaying);
|
videoElement.addEventListener('playing', onPlaying);
|
||||||
videoElement.addEventListener('error', onError);
|
videoElement.addEventListener('error', onError);
|
||||||
|
|
||||||
@@ -163,19 +223,30 @@
|
|||||||
videoElement.play()
|
videoElement.play()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Vídeo iniciado, readyState:', videoElement?.readyState);
|
console.log('Vídeo iniciado, readyState:', videoElement?.readyState);
|
||||||
// Se já tiver metadata, resolver imediatamente
|
// Se já tiver metadata e dimensões, resolver imediatamente
|
||||||
if (videoElement && videoElement.readyState >= 2) {
|
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
onLoadedMetadata();
|
onLoadedMetadata();
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn('Erro ao reproduzir vídeo:', err);
|
console.warn('Erro ao reproduzir vídeo:', err);
|
||||||
// Continuar mesmo assim se já tiver metadata
|
// Continuar mesmo assim se já tiver metadata e dimensões
|
||||||
if (videoElement && videoElement.readyState >= 2) {
|
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();
|
onLoadedMetadata();
|
||||||
} else {
|
} else {
|
||||||
onError();
|
onError();
|
||||||
}
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,34 +263,32 @@
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sucesso, sair do loop
|
// Sucesso, sair do try
|
||||||
return;
|
return;
|
||||||
} 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');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao acessar webcam:', error);
|
console.error('Erro ao acessar webcam:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
|
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')) {
|
} 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 {
|
} 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;
|
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
|
// Se for captura automática e houver erro, chamar onError para continuar sem foto
|
||||||
if (autoCapture && onError) {
|
if (autoCapture && onError) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -247,28 +316,40 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar se o vídeo está pronto
|
// Verificar se o vídeo está pronto e tem dimensões válidas
|
||||||
if (videoElement.readyState < 2) {
|
if (videoElement.readyState < 2 || videoElement.videoWidth === 0 || videoElement.videoHeight === 0) {
|
||||||
console.warn('Vídeo ainda não está pronto, aguardando...');
|
console.warn('Vídeo ainda não está pronto, aguardando...');
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let tentativas = 0;
|
||||||
|
const maxTentativas = 50; // 5 segundos
|
||||||
const checkReady = () => {
|
const checkReady = () => {
|
||||||
if (videoElement && videoElement.readyState >= 2) {
|
tentativas++;
|
||||||
|
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
|
||||||
resolve();
|
resolve();
|
||||||
|
} else if (tentativas >= maxTentativas) {
|
||||||
|
reject(new Error('Timeout aguardando vídeo ficar pronto'));
|
||||||
} else {
|
} else {
|
||||||
setTimeout(checkReady, 100);
|
setTimeout(checkReady, 100);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkReady();
|
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;
|
capturando = true;
|
||||||
erro = null;
|
erro = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verificar dimensões do vídeo
|
// Verificar dimensões do vídeo novamente antes de capturar
|
||||||
if (!videoElement.videoWidth || !videoElement.videoHeight) {
|
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
|
// 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');
|
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
|
// 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);
|
ctx.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height);
|
||||||
|
|
||||||
// Converter para blob
|
// Converter para blob
|
||||||
@@ -320,7 +405,15 @@
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao capturar:', 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
|
// Se for captura automática e houver erro, continuar sem foto
|
||||||
if (autoCapture && onError) {
|
if (autoCapture && onError) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -387,17 +480,68 @@
|
|||||||
<Camera class="h-5 w-5" />
|
<Camera class="h-5 w-5" />
|
||||||
<span>Verificando webcam...</span>
|
<span>Verificando webcam...</span>
|
||||||
</div>
|
</div>
|
||||||
{#if !autoCapture}
|
{#if !autoCapture && !fotoObrigatoria}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if fotoObrigatoria}
|
||||||
|
<div class="alert alert-info max-w-md">
|
||||||
|
<AlertCircle class="h-5 w-5" />
|
||||||
|
<span>A captura de foto é obrigatória para registrar o ponto.</span>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if erro && !webcamDisponivel}
|
{:else if erro && !webcamDisponivel}
|
||||||
<div class="alert alert-warning max-w-md">
|
<div class="alert alert-error max-w-md">
|
||||||
<AlertCircle class="h-5 w-5" />
|
<AlertCircle class="h-5 w-5" />
|
||||||
<span>{erro}</span>
|
<span>{erro}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if autoCapture}
|
{#if fotoObrigatoria}
|
||||||
|
<div class="alert alert-warning max-w-md">
|
||||||
|
<span>Não é possível registrar o ponto sem capturar uma foto. Verifique as permissões da webcam e tente novamente.</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-primary" onclick={async () => {
|
||||||
|
erro = null;
|
||||||
|
webcamDisponivel = false;
|
||||||
|
videoReady = false;
|
||||||
|
// Limpar stream anterior se existir
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
stream = null;
|
||||||
|
}
|
||||||
|
// Tentar reiniciar a webcam
|
||||||
|
try {
|
||||||
|
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia && videoElement) {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
|
if (stream.getVideoTracks().length > 0) {
|
||||||
|
webcamDisponivel = true;
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
await videoElement.play();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
stream = null;
|
||||||
|
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
erro = 'Webcam não disponível. Verifique as permissões e tente novamente.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao tentar novamente:', e);
|
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
|
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
|
||||||
|
erro = 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.';
|
||||||
|
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) {
|
||||||
|
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||||
|
} else {
|
||||||
|
erro = 'Erro ao acessar webcam. Verifique as permissões e tente novamente.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}>Tentar Novamente</button>
|
||||||
|
<button class="btn btn-error" onclick={cancelar}>Fechar</button>
|
||||||
|
</div>
|
||||||
|
{:else if autoCapture}
|
||||||
<div class="text-sm text-base-content/70 text-center">
|
<div class="text-sm text-base-content/70 text-center">
|
||||||
O registro será feito sem foto.
|
O registro será feito sem foto.
|
||||||
</div>
|
</div>
|
||||||
@@ -445,6 +589,10 @@
|
|||||||
<div class="text-sm text-base-content/70 mb-2 text-center">
|
<div class="text-sm text-base-content/70 mb-2 text-center">
|
||||||
Capturando foto automaticamente...
|
Capturando foto automaticamente...
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-sm text-base-content/70 mb-2 text-center">
|
||||||
|
Posicione-se na frente da câmera e clique em "Capturar Foto"
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="relative w-full flex justify-center">
|
<div class="relative w-full flex justify-center">
|
||||||
<video
|
<video
|
||||||
@@ -452,13 +600,21 @@
|
|||||||
autoplay
|
autoplay
|
||||||
playsinline
|
playsinline
|
||||||
muted
|
muted
|
||||||
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain bg-black"
|
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain bg-black {!videoReady ? 'opacity-50' : ''}"
|
||||||
style="min-width: 320px; min-height: 240px;"
|
style="min-width: 320px; min-height: 240px; transform: scaleX(-1);"
|
||||||
></video>
|
></video>
|
||||||
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
||||||
{#if !videoReady && webcamDisponivel}
|
{#if !videoReady && webcamDisponivel}
|
||||||
<div class="absolute inset-0 flex items-center justify-center bg-black/50 rounded-lg">
|
<div class="absolute inset-0 flex flex-col items-center justify-center bg-black/70 rounded-lg gap-2">
|
||||||
<span class="loading loading-spinner loading-lg text-white"></span>
|
<span class="loading loading-spinner loading-lg text-white"></span>
|
||||||
|
<span class="text-white text-sm">Carregando câmera...</span>
|
||||||
|
</div>
|
||||||
|
{:else if videoReady && webcamDisponivel}
|
||||||
|
<div class="absolute bottom-2 left-1/2 transform -translate-x-1/2">
|
||||||
|
<div class="badge badge-success gap-2">
|
||||||
|
<Camera class="h-4 w-4" />
|
||||||
|
Câmera ativa
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -468,15 +624,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !autoCapture}
|
{#if !autoCapture}
|
||||||
<!-- Botões apenas se não for automático -->
|
<!-- Botões sempre visíveis quando não for automático -->
|
||||||
<div class="flex gap-2 flex-wrap justify-center">
|
<div class="flex gap-2 flex-wrap justify-center">
|
||||||
<button class="btn btn-primary" onclick={capturar} disabled={capturando}>
|
<button
|
||||||
|
class="btn btn-primary btn-lg"
|
||||||
|
onclick={capturar}
|
||||||
|
disabled={capturando || !videoReady || !webcamDisponivel}
|
||||||
|
>
|
||||||
{#if capturando}
|
{#if capturando}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Capturando...
|
||||||
{:else}
|
{:else}
|
||||||
<Camera class="h-5 w-5" />
|
<Camera class="h-5 w-5" />
|
||||||
{/if}
|
|
||||||
Capturar Foto
|
Capturar Foto
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline" onclick={cancelar}>
|
<button class="btn btn-outline" onclick={cancelar}>
|
||||||
<X class="h-5 w-5" />
|
<X class="h-5 w-5" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle, TrendingUp, TrendingDown } from 'lucide-svelte';
|
import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle, TrendingUp, TrendingDown, FileText } from 'lucide-svelte';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto';
|
import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto';
|
||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
@@ -184,6 +184,410 @@
|
|||||||
alert('Erro ao gerar ficha de ponto. Tente novamente.');
|
alert('Erro ao gerar ficha de ponto. Tente novamente.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function imprimirDetalhesRegistro(registroId: Id<'registrosPonto'>) {
|
||||||
|
try {
|
||||||
|
// Buscar dados completos do registro
|
||||||
|
const registro = await client.query(api.pontos.obterRegistro, { registroId });
|
||||||
|
|
||||||
|
if (!registro) {
|
||||||
|
alert('Registro não encontrado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new jsPDF();
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
let yPosition = 20;
|
||||||
|
try {
|
||||||
|
const logoImg = new Image();
|
||||||
|
logoImg.src = logoGovPE;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
logoImg.onload = () => resolve();
|
||||||
|
logoImg.onerror = () => reject();
|
||||||
|
setTimeout(() => reject(), 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoWidth = 25;
|
||||||
|
const aspectRatio = logoImg.height / logoImg.width;
|
||||||
|
const logoHeight = logoWidth * aspectRatio;
|
||||||
|
|
||||||
|
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||||
|
yPosition = Math.max(20, 10 + logoHeight / 2);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Não foi possível carregar a logo:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cabeçalho
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setTextColor(41, 128, 185);
|
||||||
|
doc.text('DETALHES DO REGISTRO DE PONTO', 105, yPosition, { align: 'center' });
|
||||||
|
|
||||||
|
yPosition += 15;
|
||||||
|
|
||||||
|
// Informações do Funcionário
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
if (registro.funcionario) {
|
||||||
|
if (registro.funcionario.matricula) {
|
||||||
|
doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
if (registro.funcionario.descricaoCargo) {
|
||||||
|
doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// Informações do Registro
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('DADOS DO REGISTRO', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
doc.text(`Tipo: ${getTipoRegistroLabel(registro.tipo)}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
const dataHora = `${registro.data} ${registro.hora.toString().padStart(2, '0')}:${registro.minuto.toString().padStart(2, '0')}:${registro.segundo.toString().padStart(2, '0')}`;
|
||||||
|
doc.text(`Data e Hora: ${dataHora}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(
|
||||||
|
`Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`,
|
||||||
|
15,
|
||||||
|
yPosition
|
||||||
|
);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.justificativa) {
|
||||||
|
doc.text(`Justificativa: ${registro.justificativa}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// Localização
|
||||||
|
if (registro.latitude && registro.longitude) {
|
||||||
|
// Verificar se precisa de nova página
|
||||||
|
if (yPosition > 200) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('LOCALIZAÇÃO', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
doc.text(`Latitude: ${registro.latitude.toFixed(6)}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(`Longitude: ${registro.longitude.toFixed(6)}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.precisao) {
|
||||||
|
doc.text(`Precisão: ${registro.precisao.toFixed(2)} metros`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.endereco) {
|
||||||
|
doc.text(`Endereço: ${registro.endereco}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.cidade) {
|
||||||
|
doc.text(`Cidade: ${registro.cidade}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.estado) {
|
||||||
|
doc.text(`Estado: ${registro.estado}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.pais) {
|
||||||
|
doc.text(`País: ${registro.pais}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.timezone) {
|
||||||
|
doc.text(`Fuso Horário: ${registro.timezone}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dados Técnicos
|
||||||
|
// Verificar se precisa de nova página
|
||||||
|
if (yPosition > 200) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('DADOS TÉCNICOS', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
// Informações de Rede
|
||||||
|
if (registro.ipAddress || registro.ipPublico || registro.ipLocal) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Rede:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.ipAddress) {
|
||||||
|
doc.text(` IP: ${registro.ipAddress}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.ipPublico) {
|
||||||
|
doc.text(` IP Público: ${registro.ipPublico}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.ipLocal) {
|
||||||
|
doc.text(` IP Local: ${registro.ipLocal}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Informações do Navegador
|
||||||
|
if (registro.browser || registro.userAgent) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Navegador:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.browser) {
|
||||||
|
doc.text(` Navegador: ${registro.browser}${registro.browserVersion ? ` ${registro.browserVersion}` : ''}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.engine) {
|
||||||
|
doc.text(` Engine: ${registro.engine}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.userAgent) {
|
||||||
|
// Quebrar user agent em múltiplas linhas se necessário
|
||||||
|
const userAgentLines = doc.splitTextToSize(` User Agent: ${registro.userAgent}`, 170);
|
||||||
|
doc.text(userAgentLines, 20, yPosition);
|
||||||
|
yPosition += userAgentLines.length * 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Informações do Sistema
|
||||||
|
if (registro.sistemaOperacional || registro.arquitetura) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Sistema:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.sistemaOperacional) {
|
||||||
|
doc.text(` SO: ${registro.sistemaOperacional}${registro.osVersion ? ` ${registro.osVersion}` : ''}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.arquitetura) {
|
||||||
|
doc.text(` Arquitetura: ${registro.arquitetura}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.plataforma) {
|
||||||
|
doc.text(` Plataforma: ${registro.plataforma}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Informações do Dispositivo
|
||||||
|
if (registro.deviceType || registro.screenResolution) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Dispositivo:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.deviceType) {
|
||||||
|
doc.text(` Tipo: ${registro.deviceType}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.deviceModel) {
|
||||||
|
doc.text(` Modelo: ${registro.deviceModel}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.screenResolution) {
|
||||||
|
doc.text(` Resolução: ${registro.screenResolution}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.coresTela) {
|
||||||
|
doc.text(` Cores: ${registro.coresTela}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.isMobile || registro.isTablet || registro.isDesktop) {
|
||||||
|
const tipoDispositivo = registro.isMobile ? 'Mobile' : registro.isTablet ? 'Tablet' : 'Desktop';
|
||||||
|
doc.text(` Categoria: ${tipoDispositivo}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.idioma) {
|
||||||
|
doc.text(` Idioma: ${registro.idioma}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.connectionType) {
|
||||||
|
doc.text(` Conexão: ${registro.connectionType}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.memoryInfo) {
|
||||||
|
doc.text(` Memória: ${registro.memoryInfo}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imagem capturada (se disponível)
|
||||||
|
if (registro.imagemUrl) {
|
||||||
|
yPosition += 10;
|
||||||
|
// Verificar se precisa de nova página
|
||||||
|
if (yPosition > 200) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' });
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 10;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Carregar imagem usando fetch para evitar problemas de CORS
|
||||||
|
const response = await fetch(registro.imagemUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Erro ao carregar imagem');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
// Converter blob para base64
|
||||||
|
const base64 = await new Promise<string>((resolve, reject) => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
resolve(reader.result);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Erro ao converter imagem'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Erro ao ler imagem'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar elemento de imagem para obter dimensões
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => reject(new Error('Erro ao processar imagem'));
|
||||||
|
img.src = base64;
|
||||||
|
setTimeout(() => reject(new Error('Timeout ao processar imagem')), 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular dimensões para caber na página (largura máxima 80mm, manter proporção)
|
||||||
|
const maxWidth = 80;
|
||||||
|
const maxHeight = 60;
|
||||||
|
let imgWidth = img.width;
|
||||||
|
let imgHeight = img.height;
|
||||||
|
const aspectRatio = imgWidth / imgHeight;
|
||||||
|
|
||||||
|
if (imgWidth > maxWidth || imgHeight > maxHeight) {
|
||||||
|
if (aspectRatio > 1) {
|
||||||
|
// Imagem horizontal
|
||||||
|
imgWidth = maxWidth;
|
||||||
|
imgHeight = maxWidth / aspectRatio;
|
||||||
|
} else {
|
||||||
|
// Imagem vertical
|
||||||
|
imgHeight = maxHeight;
|
||||||
|
imgWidth = maxHeight * aspectRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centralizar imagem
|
||||||
|
const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2;
|
||||||
|
|
||||||
|
// Verificar se cabe na página atual
|
||||||
|
if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar imagem ao PDF usando base64
|
||||||
|
doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight);
|
||||||
|
yPosition += imgHeight + 10;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao adicionar imagem ao PDF:', error);
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.text('Foto não disponível para impressão', 105, yPosition, { align: 'center' });
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rodapé
|
||||||
|
const pageCount = doc.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(128, 128, 128);
|
||||||
|
doc.text(
|
||||||
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||||
|
doc.internal.pageSize.getWidth() / 2,
|
||||||
|
doc.internal.pageSize.getHeight() - 10,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salvar
|
||||||
|
const nomeArquivo = `detalhes-ponto-${registro.data}-${registro.hora.toString().padStart(2, '0')}${registro.minuto.toString().padStart(2, '0')}.pdf`;
|
||||||
|
doc.save(nomeArquivo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar PDF detalhado:', error);
|
||||||
|
alert('Erro ao gerar relatório detalhado. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto px-4 py-6">
|
<div class="container mx-auto px-4 py-6">
|
||||||
@@ -373,6 +777,7 @@
|
|||||||
<th>Tipo</th>
|
<th>Tipo</th>
|
||||||
<th>Horário</th>
|
<th>Horário</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -388,6 +793,16 @@
|
|||||||
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
|
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-primary gap-2"
|
||||||
|
onclick={() => imprimirDetalhesRegistro(registro._id)}
|
||||||
|
title="Imprimir Detalhes"
|
||||||
|
>
|
||||||
|
<FileText class="h-4 w-4" />
|
||||||
|
Imprimir Detalhes
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user