Merge remote-tracking branch 'origin' into feat-licitacoes-contratos

This commit is contained in:
2025-11-19 09:29:30 -03:00
22 changed files with 5943 additions and 128 deletions

View File

@@ -0,0 +1,344 @@
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import jsPDF from 'jspdf';
import { Printer, X } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
interface Props {
registroId: Id<'registrosPonto'>;
onClose: () => void;
}
let { registroId, onClose }: Props = $props();
const registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
let gerando = $state(false);
async function gerarPDF() {
if (!registroQuery?.data) return;
gerando = true;
try {
const registro = registroQuery.data;
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;
}
if (registro.funcionario.simbolo) {
doc.text(
`Símbolo: ${registro.funcionario.simbolo.nome} (${registro.funcionario.simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'})`,
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 config = configQuery?.data;
const tipoLabel = config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo);
doc.text(`Tipo: ${tipoLabel}`, 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) {
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 = `comprovante-ponto-${registro.data}-${registro.hora}${registro.minuto.toString().padStart(2, '0')}.pdf`;
doc.save(nomeArquivo);
} catch (error) {
console.error('Erro ao gerar PDF:', error);
alert('Erro ao gerar comprovante PDF. Tente novamente.');
} finally {
gerando = false;
}
}
</script>
<div class="modal modal-open" style="display: flex; align-items: center; justify-content: center;">
<div class="modal-box max-w-2xl w-[95%] max-h-[85vh] overflow-hidden flex flex-col" style="margin: auto; max-height: 85vh;">
<!-- Header fixo -->
<div class="flex items-center justify-between mb-4 pb-4 border-b border-base-300 flex-shrink-0">
<h3 class="font-bold text-lg">Comprovante de Registro de Ponto</h3>
<button class="btn btn-sm btn-circle btn-ghost hover:bg-base-300" onclick={onClose}>
<X class="h-5 w-5" />
</button>
</div>
<!-- Conteúdo com rolagem -->
<div class="flex-1 overflow-y-auto pr-2">
{#if registroQuery === undefined}
<div class="flex justify-center items-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if !registroQuery?.data}
<div class="alert alert-error">
<span>Erro ao carregar registro</span>
</div>
{:else}
{@const registro = registroQuery.data}
<div class="space-y-4">
<!-- Informações do Funcionário -->
<div class="card bg-base-200">
<div class="card-body">
<h4 class="font-bold">Dados do Funcionário</h4>
{#if registro.funcionario}
<p><strong>Matrícula:</strong> {registro.funcionario.matricula || 'N/A'}</p>
<p><strong>Nome:</strong> {registro.funcionario.nome}</p>
{#if registro.funcionario.descricaoCargo}
<p><strong>Cargo/Função:</strong> {registro.funcionario.descricaoCargo}</p>
{/if}
{/if}
</div>
</div>
<!-- Informações do Registro -->
<div class="card bg-base-200">
<div class="card-body">
<h4 class="font-bold">Dados do Registro</h4>
<p>
<strong>Tipo:</strong>
{configQuery?.data
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: configQuery.data.nomeEntrada,
nomeSaidaAlmoco: configQuery.data.nomeSaidaAlmoco,
nomeRetornoAlmoco: configQuery.data.nomeRetornoAlmoco,
nomeSaida: configQuery.data.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo)}
</p>
<p>
<strong>Data e Hora:</strong>
{formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo)}
</p>
<p>
<strong>Status:</strong>
<span class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}">
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
</span>
</p>
<p><strong>Tolerância:</strong> {registro.toleranciaMinutos} minutos</p>
</div>
</div>
<!-- Imagem Capturada -->
{#if registro.imagemUrl}
<div class="card bg-base-200">
<div class="card-body">
<h4 class="font-bold mb-2">Foto Capturada</h4>
<div class="flex justify-center">
<img
src={registro.imagemUrl}
alt="Foto do registro de ponto"
class="max-w-full max-h-[250px] rounded-lg border-2 border-primary object-contain"
onerror={(e) => {
console.error('Erro ao carregar imagem:', e);
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer fixo com botões -->
<div class="flex justify-end gap-2 pt-4 mt-4 border-t border-base-300 flex-shrink-0">
<button class="btn btn-primary gap-2" onclick={gerarPDF} disabled={gerando}>
{#if gerando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Printer class="h-5 w-5" />
{/if}
Imprimir Comprovante
</button>
<button class="btn btn-outline" onclick={onClose}>Fechar</button>
</div>
</div>
<div class="modal-backdrop" onclick={onClose}></div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { obterTempoServidor, obterTempoPC } from '$lib/utils/sincronizacaoTempo';
import { CheckCircle2, AlertCircle, Clock } from 'lucide-svelte';
const client = useConvexClient();
let tempoAtual = $state<Date>(new Date());
let sincronizado = $state(false);
let usandoServidorExterno = $state(false);
let offsetSegundos = $state(0);
let erro = $state<string | null>(null);
let intervalId: ReturnType<typeof setInterval> | null = null;
async function atualizarTempo() {
try {
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
if (config.usarServidorExterno) {
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso && resultado.timestamp) {
tempoAtual = new Date(resultado.timestamp);
sincronizado = true;
usandoServidorExterno = resultado.usandoServidorExterno || false;
offsetSegundos = resultado.offsetSegundos || 0;
erro = null;
}
} catch (error) {
console.warn('Erro ao sincronizar:', error);
if (config.fallbackParaPC) {
tempoAtual = new Date(obterTempoPC());
sincronizado = false;
usandoServidorExterno = false;
erro = 'Usando relógio do PC (falha na sincronização)';
} else {
erro = 'Falha ao sincronizar tempo';
}
}
} else {
// Usar tempo do servidor Convex
const tempoServidor = await obterTempoServidor(client);
tempoAtual = new Date(tempoServidor);
sincronizado = true;
usandoServidorExterno = false;
erro = null;
}
} catch (error) {
console.error('Erro ao obter tempo:', error);
tempoAtual = new Date(obterTempoPC());
sincronizado = false;
erro = 'Erro ao obter tempo do servidor';
}
}
function atualizarRelogio() {
// Atualizar segundo a segundo
const agora = new Date(tempoAtual.getTime() + 1000);
tempoAtual = agora;
}
onMount(async () => {
await atualizarTempo();
// Sincronizar a cada 30 segundos
setInterval(atualizarTempo, 30000);
// Atualizar display a cada segundo
intervalId = setInterval(atualizarRelogio, 1000);
});
onDestroy(() => {
if (intervalId) {
clearInterval(intervalId);
}
});
const horaFormatada = $derived.by(() => {
return tempoAtual.toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
});
const dataFormatada = $derived.by(() => {
return tempoAtual.toLocaleDateString('pt-BR', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
});
});
</script>
<div class="flex flex-col items-center gap-2">
<div class="text-4xl font-bold font-mono text-primary">{horaFormatada}</div>
<div class="text-sm text-base-content/70 capitalize">{dataFormatada}</div>
<div class="flex items-center gap-2 text-xs">
{#if sincronizado}
<CheckCircle2 class="h-4 w-4 text-success" />
<span class="text-success">
{#if usandoServidorExterno}
Sincronizado com servidor NTP
{:else}
Sincronizado com servidor
{/if}
</span>
{:else if erro}
<AlertCircle class="h-4 w-4 text-warning" />
<span class="text-warning">{erro}</span>
{:else}
<Clock class="h-4 w-4 text-base-content/50" />
<span class="text-base-content/50">Sincronizando...</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,650 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Camera, X, Check, AlertCircle } from 'lucide-svelte';
import { validarWebcamDisponivel, capturarWebcamComPreview } from '$lib/utils/webcam';
interface Props {
onCapture: (blob: Blob | null) => void;
onCancel: () => void;
onError?: () => void;
autoCapture?: boolean;
fotoObrigatoria?: boolean; // Se true, não permite continuar sem foto
}
let { onCapture, onCancel, onError, autoCapture = false, fotoObrigatoria = false }: Props = $props();
let videoElement: HTMLVideoElement | null = $state(null);
let canvasElement: HTMLCanvasElement | null = $state(null);
let stream: MediaStream | null = $state(null);
let webcamDisponivel = $state(false);
let capturando = $state(false);
let erro = $state<string | null>(null);
let previewUrl = $state<string | null>(null);
let videoReady = $state(false);
// Efeito para garantir que o vídeo seja exibido quando o stream for atribuído
$effect(() => {
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 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) {
// Tentar método alternativo (navegadores antigos)
const getUserMedia =
navigator.getUserMedia ||
(navigator as any).webkitGetUserMedia ||
(navigator as any).mozGetUserMedia ||
(navigator as any).msGetUserMedia;
if (!getUserMedia) {
erro = 'Webcam não suportada';
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 = [
{
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user'
}
},
{
video: {
width: { ideal: 640 },
height: { ideal: 480 },
facingMode: 'user'
}
},
{
video: {
facingMode: 'user'
}
},
{
video: true
}
];
let ultimoErro: Error | null = null;
let streamObtido = false;
for (const constraint of constraints) {
try {
console.log('Tentando acessar webcam com constraint:', constraint);
const tempStream = await navigator.mediaDevices.getUserMedia(constraint);
// Verificar se o stream tem tracks de vídeo
if (tempStream.getVideoTracks().length === 0) {
tempStream.getTracks().forEach(track => track.stop());
continue;
}
console.log('Webcam acessada com sucesso');
stream = tempStream;
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;
}
}
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<void>((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 = 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 = fotoObrigatoria
? 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.'
: 'Nenhuma webcam encontrada. Continuando sem foto.';
} else {
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(() => {
onError();
}, 500);
}
}
});
onDestroy(() => {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
});
async function capturar() {
if (!videoElement || !canvasElement) {
console.error('Elementos de vídeo ou canvas não disponíveis');
if (autoCapture && onError) {
onError();
}
return;
}
// 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<void>((resolve, reject) => {
let tentativas = 0;
const maxTentativas = 50; // 5 segundos
const checkReady = () => {
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 novamente antes de capturar
if (!videoElement.videoWidth || !videoElement.videoHeight) {
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
canvasElement.width = videoElement.videoWidth;
canvasElement.height = videoElement.videoHeight;
// Obter contexto do canvas
const ctx = canvasElement.getContext('2d');
if (!ctx) {
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
const blob = await new Promise<Blob | null>((resolve, reject) => {
canvasElement.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Falha ao converter canvas para blob'));
}
},
'image/jpeg',
0.92 // Qualidade ligeiramente reduzida para melhor compatibilidade
);
});
if (blob && blob.size > 0) {
previewUrl = URL.createObjectURL(blob);
console.log('Imagem capturada com sucesso, tamanho:', blob.size, 'bytes');
// Parar stream para mostrar preview
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
// Se for captura automática, confirmar automaticamente após um pequeno delay
if (autoCapture) {
setTimeout(() => {
confirmar();
}, 500);
}
} else {
throw new Error('Blob vazio ou inválido');
}
} catch (error) {
console.error('Erro ao capturar:', error);
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(() => {
onError();
}, 500);
}
} finally {
capturando = false;
}
}
function confirmar() {
if (previewUrl) {
// Converter preview URL de volta para blob
fetch(previewUrl)
.then((res) => res.blob())
.then((blob) => {
onCapture(blob);
})
.catch((error) => {
console.error('Erro ao converter preview:', error);
erro = 'Erro ao processar imagem';
});
}
}
function cancelar() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
previewUrl = null;
}
onCancel();
}
async function recapturar() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
previewUrl = null;
}
// Reiniciar webcam
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user'
}
});
if (videoElement) {
videoElement.srcObject = stream;
await videoElement.play();
}
} catch (error) {
console.error('Erro ao reiniciar webcam:', error);
erro = 'Erro ao reiniciar webcam';
}
}
</script>
<div class="flex flex-col items-center gap-4 p-4 w-full">
{#if !webcamDisponivel && !erro}
<div class="text-warning flex items-center gap-2">
<Camera class="h-5 w-5" />
<span>Verificando webcam...</span>
</div>
{#if !autoCapture && !fotoObrigatoria}
<div class="flex gap-2">
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
</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}
{:else if erro && !webcamDisponivel}
<div class="alert alert-error max-w-md">
<AlertCircle class="h-5 w-5" />
<span>{erro}</span>
</div>
{#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">
O registro será feito sem foto.
</div>
{:else}
<div class="flex gap-2">
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
</div>
{/if}
{:else if previewUrl}
<!-- Preview da imagem capturada -->
<div class="flex flex-col items-center gap-4 w-full">
{#if autoCapture}
<!-- Modo automático: mostrar apenas preview sem botões -->
<div class="text-sm text-base-content/70 mb-2 text-center">
Foto capturada automaticamente...
</div>
{/if}
<img
src={previewUrl}
alt="Preview"
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain"
/>
{#if !autoCapture}
<!-- Botões apenas se não for automático -->
<div class="flex gap-2 flex-wrap justify-center">
<button class="btn btn-success" onclick={confirmar}>
<Check class="h-5 w-5" />
Confirmar
</button>
<button class="btn btn-outline" onclick={recapturar}>
<Camera class="h-5 w-5" />
Recapturar
</button>
<button class="btn btn-error" onclick={cancelar}>
<X class="h-5 w-5" />
Cancelar
</button>
</div>
{/if}
</div>
{:else}
<!-- Webcam ativa -->
<div class="flex flex-col items-center gap-4 w-full">
{#if autoCapture}
<div class="text-sm text-base-content/70 mb-2 text-center">
Capturando foto automaticamente...
</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}
<div class="relative w-full flex justify-center">
<video
bind:this={videoElement}
autoplay
playsinline
muted
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; transform: scaleX(-1);"
></video>
<canvas bind:this={canvasElement} class="hidden"></canvas>
{#if !videoReady && webcamDisponivel}
<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="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>
{/if}
</div>
{#if erro}
<div class="alert alert-error max-w-md">
<span>{erro}</span>
</div>
{/if}
{#if !autoCapture}
<!-- Botões sempre visíveis quando não for automático -->
<div class="flex gap-2 flex-wrap justify-center">
<button
class="btn btn-primary btn-lg"
onclick={capturar}
disabled={capturando || !videoReady || !webcamDisponivel}
>
{#if capturando}
<span class="loading loading-spinner loading-sm"></span>
Capturando...
{:else}
<Camera class="h-5 w-5" />
Capturar Foto
{/if}
</button>
<button class="btn btn-outline" onclick={cancelar}>
<X class="h-5 w-5" />
Cancelar
</button>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { Clock } from 'lucide-svelte';
import { resolve } from '$app/paths';
</script>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
<div class="card-body">
<!-- Cabeçalho da Categoria -->
<div class="flex items-start gap-6 mb-6">
<div class="p-4 bg-blue-500/20 rounded-2xl">
<div class="text-blue-600">
<Clock class="h-12 w-12" strokeWidth={2} />
</div>
</div>
<div class="flex-1">
<h2 class="card-title text-2xl mb-2 text-blue-600">
Gestão de Pontos
</h2>
<p class="text-base-content/70">Registros de ponto do dia</p>
</div>
</div>
<!-- Grid de Opções -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a
href={resolve('/(dashboard)/recursos-humanos/registro-pontos')}
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-blue-500/10 to-blue-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
>
<div
class="text-blue-600 group-hover:text-white"
>
<Clock class="h-5 w-5" strokeWidth={2} />
</div>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
>
Gestão de Pontos
</h3>
<p class="text-sm text-base-content/70 flex-1">
Visualizar e gerenciar registros de ponto
</p>
</div>
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,452 @@
import { getLocalIP } from './browserInfo';
export interface InformacoesDispositivo {
ipAddress?: string;
ipPublico?: string;
ipLocal?: string;
userAgent?: string;
browser?: string;
browserVersion?: string;
engine?: string;
sistemaOperacional?: string;
osVersion?: string;
arquitetura?: string;
plataforma?: string;
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
timezone?: string;
deviceType?: string;
deviceModel?: string;
screenResolution?: string;
coresTela?: number;
idioma?: string;
isMobile?: boolean;
isTablet?: boolean;
isDesktop?: boolean;
connectionType?: string;
memoryInfo?: string;
}
/**
* Detecta informações do navegador
*/
function detectarNavegador(): { browser: string; browserVersion: string; engine: string } {
if (typeof navigator === 'undefined') {
return { browser: 'Desconhecido', browserVersion: '', engine: '' };
}
const ua = navigator.userAgent;
let browser = 'Desconhecido';
let browserVersion = '';
let engine = '';
// Detectar engine
if (ua.includes('Edg/')) {
engine = 'EdgeHTML';
} else if (ua.includes('Chrome/')) {
engine = 'Blink';
} else if (ua.includes('Firefox/')) {
engine = 'Gecko';
} else if (ua.includes('Safari/') && !ua.includes('Chrome/')) {
engine = 'WebKit';
}
// Detectar navegador
if (ua.includes('Edg/')) {
browser = 'Edge';
const match = ua.match(/Edg\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Chrome/') && !ua.includes('Edg/')) {
browser = 'Chrome';
const match = ua.match(/Chrome\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Firefox/')) {
browser = 'Firefox';
const match = ua.match(/Firefox\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Safari/') && !ua.includes('Chrome/')) {
browser = 'Safari';
const match = ua.match(/Version\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Opera/') || ua.includes('OPR/')) {
browser = 'Opera';
const match = ua.match(/(?:Opera|OPR)\/(\d+)/);
browserVersion = match ? match[1]! : '';
}
return { browser, browserVersion, engine };
}
/**
* Detecta informações do sistema operacional
*/
function detectarSistemaOperacional(): {
sistemaOperacional: string;
osVersion: string;
arquitetura: string;
plataforma: string;
} {
if (typeof navigator === 'undefined') {
return {
sistemaOperacional: 'Desconhecido',
osVersion: '',
arquitetura: '',
plataforma: '',
};
}
const ua = navigator.userAgent;
const platform = navigator.platform || '';
let sistemaOperacional = 'Desconhecido';
let osVersion = '';
let arquitetura = '';
const plataforma = platform;
// Detectar OS
if (ua.includes('Windows NT')) {
sistemaOperacional = 'Windows';
const match = ua.match(/Windows NT (\d+\.\d+)/);
if (match) {
const version = match[1]!;
const versions: Record<string, string> = {
'10.0': '10/11',
'6.3': '8.1',
'6.2': '8',
'6.1': '7',
};
osVersion = versions[version] || version;
}
} else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) {
sistemaOperacional = 'macOS';
const match = ua.match(/Mac OS X (\d+[._]\d+)/);
if (match) {
osVersion = match[1]!.replace('_', '.');
}
} else if (ua.includes('Linux')) {
sistemaOperacional = 'Linux';
osVersion = 'Linux';
} else if (ua.includes('Android')) {
sistemaOperacional = 'Android';
const match = ua.match(/Android (\d+(?:\.\d+)?)/);
osVersion = match ? match[1]! : '';
} else if (ua.includes('iPhone') || ua.includes('iPad')) {
sistemaOperacional = 'iOS';
const match = ua.match(/OS (\d+[._]\d+)/);
if (match) {
osVersion = match[1]!.replace('_', '.');
}
}
// Detectar arquitetura (se disponível)
if ('cpuClass' in navigator) {
arquitetura = (navigator as unknown as { cpuClass: string }).cpuClass;
}
return { sistemaOperacional, osVersion, arquitetura, plataforma };
}
/**
* Detecta tipo de dispositivo
*/
function detectarTipoDispositivo(): {
deviceType: string;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
} {
if (typeof navigator === 'undefined') {
return {
deviceType: 'Desconhecido',
isMobile: false,
isTablet: false,
isDesktop: true,
};
}
const ua = navigator.userAgent;
const isMobile = /Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
const isTablet = /iPad|Android(?!.*Mobile)|Tablet/i.test(ua);
const isDesktop = !isMobile && !isTablet;
let deviceType = 'Desktop';
if (isTablet) {
deviceType = 'Tablet';
} else if (isMobile) {
deviceType = 'Mobile';
}
return { deviceType, isMobile, isTablet, isDesktop };
}
/**
* Obtém informações da tela
*/
function obterInformacoesTela(): { screenResolution: string; coresTela: number } {
if (typeof screen === 'undefined') {
return { screenResolution: 'Desconhecido', coresTela: 0 };
}
const screenResolution = `${screen.width}x${screen.height}`;
const coresTela = screen.colorDepth || 24;
return { screenResolution, coresTela };
}
/**
* Obtém informações de conexão
*/
async function obterInformacoesConexao(): Promise<string> {
if (typeof navigator === 'undefined' || !('connection' in navigator)) {
return 'Desconhecido';
}
const connection = (navigator as unknown as { connection?: { effectiveType?: string } }).connection;
if (connection?.effectiveType) {
return connection.effectiveType;
}
return 'Desconhecido';
}
/**
* Obtém informações de memória (se disponível)
*/
function obterInformacoesMemoria(): string {
if (typeof navigator === 'undefined' || !('deviceMemory' in navigator)) {
return 'Desconhecido';
}
const deviceMemory = (navigator as unknown as { deviceMemory?: number }).deviceMemory;
if (deviceMemory) {
return `${deviceMemory} GB`;
}
return 'Desconhecido';
}
/**
* Obtém localização via GPS com múltiplas tentativas
*/
async function obterLocalizacao(): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
}> {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
console.warn('Geolocalização não suportada');
return {};
}
// Tentar múltiplas estratégias
const estrategias = [
// Estratégia 1: Alta precisão (mais lento, mas mais preciso)
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
},
// Estratégia 2: Precisão média (balanceado)
{
enableHighAccuracy: false,
timeout: 8000,
maximumAge: 30000
},
// Estratégia 3: Rápido (usa cache)
{
enableHighAccuracy: false,
timeout: 5000,
maximumAge: 60000
}
];
for (const options of estrategias) {
try {
const resultado = await new Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
}>((resolve) => {
const timeout = setTimeout(() => {
resolve({});
}, options.timeout + 1000);
navigator.geolocation.getCurrentPosition(
async (position) => {
clearTimeout(timeout);
const { latitude, longitude, accuracy } = position.coords;
// Validar coordenadas
if (isNaN(latitude) || isNaN(longitude) || latitude === 0 || longitude === 0) {
resolve({});
return;
}
// Tentar obter endereço via reverse geocoding
let endereco = '';
let cidade = '';
let estado = '';
let pais = '';
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
{
headers: {
'User-Agent': 'SGSE-App/1.0'
}
}
);
if (response.ok) {
const data = (await response.json()) as {
address?: {
road?: string;
house_number?: string;
city?: string;
town?: string;
state?: string;
country?: string;
};
};
if (data.address) {
const addr = data.address;
if (addr.road) {
endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`;
}
cidade = addr.city || addr.town || '';
estado = addr.state || '';
pais = addr.country || '';
}
}
} catch (error) {
console.warn('Erro ao obter endereço:', error);
}
resolve({
latitude,
longitude,
precisao: accuracy,
endereco,
cidade,
estado,
pais,
});
},
(error) => {
clearTimeout(timeout);
console.warn('Erro ao obter localização:', error.code, error.message);
resolve({});
},
options
);
});
// Se obteve localização, retornar
if (resultado.latitude && resultado.longitude) {
console.log('Localização obtida com sucesso:', resultado);
return resultado;
}
} catch (error) {
console.warn('Erro na estratégia de geolocalização:', error);
continue;
}
}
// Se todas as estratégias falharam, retornar vazio
console.warn('Não foi possível obter localização após todas as tentativas');
return {};
}
/**
* Obtém IP público
*/
async function obterIPPublico(): Promise<string | undefined> {
try {
const response = await fetch('https://api.ipify.org?format=json');
if (response.ok) {
const data = (await response.json()) as { ip: string };
return data.ip;
}
} catch (error) {
console.warn('Erro ao obter IP público:', error);
}
return undefined;
}
/**
* Obtém todas as informações do dispositivo
*/
export async function obterInformacoesDispositivo(): Promise<InformacoesDispositivo> {
const informacoes: InformacoesDispositivo = {};
// Informações básicas
if (typeof navigator !== 'undefined') {
informacoes.userAgent = navigator.userAgent;
informacoes.idioma = navigator.language || navigator.languages?.[0];
informacoes.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// Informações do navegador
const navegador = detectarNavegador();
informacoes.browser = navegador.browser;
informacoes.browserVersion = navegador.browserVersion;
informacoes.engine = navegador.engine;
// Informações do sistema
const sistema = detectarSistemaOperacional();
informacoes.sistemaOperacional = sistema.sistemaOperacional;
informacoes.osVersion = sistema.osVersion;
informacoes.arquitetura = sistema.arquitetura;
informacoes.plataforma = sistema.plataforma;
// Tipo de dispositivo
const dispositivo = detectarTipoDispositivo();
informacoes.deviceType = dispositivo.deviceType;
informacoes.isMobile = dispositivo.isMobile;
informacoes.isTablet = dispositivo.isTablet;
informacoes.isDesktop = dispositivo.isDesktop;
// Informações da tela
const tela = obterInformacoesTela();
informacoes.screenResolution = tela.screenResolution;
informacoes.coresTela = tela.coresTela;
// Informações de conexão e memória (assíncronas)
const [connectionType, memoryInfo, ipPublico, ipLocal, localizacao] = await Promise.all([
obterInformacoesConexao(),
Promise.resolve(obterInformacoesMemoria()),
obterIPPublico(),
getLocalIP(),
obterLocalizacao(),
]);
informacoes.connectionType = connectionType;
informacoes.memoryInfo = memoryInfo;
informacoes.ipPublico = ipPublico;
informacoes.ipLocal = ipLocal;
informacoes.latitude = localizacao.latitude;
informacoes.longitude = localizacao.longitude;
informacoes.precisao = localizacao.precisao;
informacoes.endereco = localizacao.endereco;
informacoes.cidade = localizacao.cidade;
informacoes.estado = localizacao.estado;
informacoes.pais = localizacao.pais;
// IP address (usar público se disponível, senão local)
informacoes.ipAddress = ipPublico || ipLocal;
return informacoes;
}

View File

@@ -0,0 +1,124 @@
/**
* Formata hora no formato HH:mm
*/
export function formatarHoraPonto(hora: number, minuto: number): string {
return `${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`;
}
/**
* Formata data e hora completa
*/
export function formatarDataHoraCompleta(
data: string,
hora: number,
minuto: number,
segundo: number
): string {
const dataObj = new Date(`${data}T${formatarHoraPonto(hora, minuto)}:${segundo.toString().padStart(2, '0')}`);
return dataObj.toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
/**
* Calcula tempo trabalhado entre dois registros
*/
export function calcularTempoTrabalhado(
horaInicio: number,
minutoInicio: number,
horaFim: number,
minutoFim: number
): { horas: number; minutos: number } {
const minutosInicio = horaInicio * 60 + minutoInicio;
const minutosFim = horaFim * 60 + minutoFim;
const diferencaMinutos = minutosFim - minutosInicio;
if (diferencaMinutos < 0) {
return { horas: 0, minutos: 0 };
}
const horas = Math.floor(diferencaMinutos / 60);
const minutos = diferencaMinutos % 60;
return { horas, minutos };
}
/**
* Verifica se está dentro do prazo baseado na configuração
*/
export function verificarDentroDoPrazo(
hora: number,
minuto: number,
horarioConfigurado: string,
toleranciaMinutos: number
): boolean {
const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number);
const totalMinutosRegistro = hora * 60 + minuto;
const totalMinutosConfigurado = horaConfig * 60 + minutoConfig;
const diferenca = totalMinutosRegistro - totalMinutosConfigurado;
return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos;
}
/**
* Obtém label do tipo de registro
* Se config fornecida, usa os nomes personalizados, senão usa os padrões
*/
export function getTipoRegistroLabel(
tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida',
config?: {
nomeEntrada?: string;
nomeSaidaAlmoco?: string;
nomeRetornoAlmoco?: string;
nomeSaida?: string;
}
): string {
// Se config fornecida, usar nomes personalizados
if (config) {
const labels: Record<string, string> = {
entrada: config.nomeEntrada || 'Entrada 1',
saida_almoco: config.nomeSaidaAlmoco || 'Saída 1',
retorno_almoco: config.nomeRetornoAlmoco || 'Entrada 2',
saida: config.nomeSaida || 'Saída 2',
};
return labels[tipo] || tipo;
}
// Valores padrão
const labels: Record<string, string> = {
entrada: 'Entrada 1',
saida_almoco: 'Saída 1',
retorno_almoco: 'Entrada 2',
saida: 'Saída 2',
};
return labels[tipo] || tipo;
}
/**
* Obtém próximo tipo de registro esperado
*/
export function getProximoTipoRegistro(
ultimoTipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' | null
): 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' {
if (!ultimoTipo) {
return 'entrada';
}
switch (ultimoTipo) {
case 'entrada':
return 'saida_almoco';
case 'saida_almoco':
return 'retorno_almoco';
case 'retorno_almoco':
return 'saida';
case 'saida':
return 'entrada'; // Novo dia
default:
return 'entrada';
}
}

View File

@@ -0,0 +1,56 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { ConvexClient } from 'convex/browser';
/**
* Obtém tempo do servidor (sincronizado)
*/
export async function obterTempoServidor(client: ConvexClient): Promise<number> {
try {
// Tentar obter configuração e sincronizar se necessário
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
if (config.usarServidorExterno) {
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso && resultado.timestamp) {
return resultado.timestamp;
}
} catch (error) {
console.warn('Erro ao sincronizar com servidor externo:', error);
if (config.fallbackParaPC) {
return Date.now();
}
throw error;
}
}
// Usar tempo do servidor Convex
const tempoServidor = await client.query(api.configuracaoRelogio.obterTempoServidor, {});
return tempoServidor.timestamp;
} catch (error) {
console.warn('Erro ao obter tempo do servidor, usando tempo local:', error);
return Date.now();
}
}
/**
* Obtém tempo do PC (fallback)
*/
export function obterTempoPC(): number {
return Date.now();
}
/**
* Calcula offset entre dois timestamps
*/
export function calcularOffset(timestampServidor: number, timestampLocal: number): number {
return timestampServidor - timestampLocal;
}
/**
* Aplica offset a um timestamp
*/
export function aplicarOffset(timestamp: number, offsetSegundos: number): number {
return timestamp + offsetSegundos * 1000;
}

View File

@@ -0,0 +1,150 @@
/**
* Verifica se webcam está disponível
*/
export async function validarWebcamDisponivel(): Promise<boolean> {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return false;
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some((device) => device.kind === 'videoinput');
} catch {
return false;
}
}
/**
* Captura imagem da webcam
*/
export async function capturarWebcam(): Promise<Blob | null> {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return null;
}
let stream: MediaStream | null = null;
try {
// Solicitar acesso à webcam
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user',
},
});
// Criar elemento de vídeo temporário
const video = document.createElement('video');
video.srcObject = stream;
video.play();
// Aguardar vídeo estar pronto
await new Promise<void>((resolve, reject) => {
video.onloadedmetadata = () => {
video.width = video.videoWidth;
video.height = video.videoHeight;
resolve();
};
video.onerror = reject;
setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000);
});
// Capturar frame
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Não foi possível obter contexto do canvas');
}
ctx.drawImage(video, 0, 0);
// Converter para blob
return await new Promise<Blob | null>((resolve) => {
canvas.toBlob(
(blob) => {
resolve(blob);
},
'image/jpeg',
0.9
);
});
} catch (error) {
console.error('Erro ao capturar webcam:', error);
return null;
} finally {
// Parar stream
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
}
}
/**
* Captura imagem da webcam com preview
*/
export async function capturarWebcamComPreview(
videoElement: HTMLVideoElement,
canvasElement: HTMLCanvasElement
): Promise<Blob | null> {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return null;
}
let stream: MediaStream | null = null;
try {
// Solicitar acesso à webcam
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user',
},
});
videoElement.srcObject = stream;
await videoElement.play();
// Aguardar vídeo estar pronto
await new Promise<void>((resolve, reject) => {
videoElement.onloadedmetadata = () => {
canvasElement.width = videoElement.videoWidth;
canvasElement.height = videoElement.videoHeight;
resolve();
};
videoElement.onerror = reject;
setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000);
});
// Capturar frame
const ctx = canvasElement.getContext('2d');
if (!ctx) {
throw new Error('Não foi possível obter contexto do canvas');
}
ctx.drawImage(videoElement, 0, 0);
// Converter para blob
return await new Promise<Blob | null>((resolve) => {
canvasElement.toBlob(
(blob) => {
resolve(blob);
},
'image/jpeg',
0.9
);
});
} catch (error) {
console.error('Erro ao capturar webcam:', error);
return null;
} finally {
// Parar stream
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
}
}