feat: enhance point registration and management features
- Added functionality to capture and display images during point registration, improving user experience. - Implemented error handling for image uploads and webcam access, ensuring smoother operation. - Introduced a justification field for point registration, allowing users to provide context for their entries. - Enhanced the backend to support new features, including image handling and justification storage. - Updated UI components for better layout and responsiveness, improving overall usability.
This commit is contained in:
@@ -169,6 +169,91 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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++) {
|
||||
@@ -248,6 +333,26 @@
|
||||
</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-96 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}
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-primary" onclick={gerarPDF} disabled={gerando}>
|
||||
|
||||
@@ -5,17 +5,31 @@
|
||||
import RelogioSincronizado from './RelogioSincronizado.svelte';
|
||||
import WebcamCapture from './WebcamCapture.svelte';
|
||||
import ComprovantePonto from './ComprovantePonto.svelte';
|
||||
import ErrorModal from '../ErrorModal.svelte';
|
||||
import { obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
|
||||
import { obterInformacoesDispositivo } from '$lib/utils/deviceInfo';
|
||||
import { formatarHoraPonto, getTipoRegistroLabel, getProximoTipoRegistro } from '$lib/utils/ponto';
|
||||
import { LogIn, LogOut, Clock, CheckCircle2, XCircle, Camera, MapPin } from 'lucide-svelte';
|
||||
import {
|
||||
formatarHoraPonto,
|
||||
getTipoRegistroLabel,
|
||||
getProximoTipoRegistro
|
||||
} from '$lib/utils/ponto';
|
||||
import { LogIn, LogOut, Clock, CheckCircle2, XCircle, TrendingUp, TrendingDown } from 'lucide-svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Queries
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||
const registrosHojeQuery = useQuery(api.pontos.listarRegistrosDia, {});
|
||||
|
||||
// Query para histórico e saldo do dia
|
||||
const funcionarioId = $derived(currentUser?.data?.funcionarioId ?? null);
|
||||
const dataHoje = $derived(new Date().toISOString().split('T')[0]!);
|
||||
const historicoSaldoQuery = useQuery(
|
||||
api.pontos.obterHistoricoESaldoDia,
|
||||
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
|
||||
);
|
||||
|
||||
// Estados
|
||||
let mostrandoWebcam = $state(false);
|
||||
@@ -26,6 +40,11 @@
|
||||
let mostrandoComprovante = $state(false);
|
||||
let imagemCapturada = $state<Blob | null>(null);
|
||||
let coletandoInfo = $state(false);
|
||||
let capturandoAutomaticamente = $state(false);
|
||||
let mostrarModalErro = $state(false);
|
||||
let mensagemErroModal = $state('');
|
||||
let detalhesErroModal = $state('');
|
||||
let justificativa = $state('');
|
||||
|
||||
const registrosHoje = $derived(registrosHojeQuery?.data || []);
|
||||
const config = $derived(configQuery?.data);
|
||||
@@ -54,7 +73,7 @@
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': file.type },
|
||||
body: file,
|
||||
body: file
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -87,10 +106,16 @@
|
||||
const timestamp = await obterTempoServidor(client);
|
||||
const sincronizadoComServidor = true; // Sempre true quando usamos obterTempoServidor
|
||||
|
||||
// Upload da imagem se houver
|
||||
// Upload da imagem se houver (não bloquear se falhar)
|
||||
let imagemId: Id<'_storage'> | undefined = undefined;
|
||||
if (imagemCapturada) {
|
||||
imagemId = await uploadImagem(imagemCapturada);
|
||||
try {
|
||||
imagemId = await uploadImagem(imagemCapturada);
|
||||
} catch (error) {
|
||||
console.warn('Erro ao fazer upload da imagem, continuando sem foto:', error);
|
||||
// Continuar sem foto se o upload falhar
|
||||
imagemId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Registrar ponto
|
||||
@@ -99,11 +124,13 @@
|
||||
informacoesDispositivo,
|
||||
timestamp,
|
||||
sincronizadoComServidor,
|
||||
justificativa: justificativa.trim() || undefined
|
||||
});
|
||||
|
||||
registroId = resultado.registroId;
|
||||
sucesso = `Ponto registrado com sucesso! Tipo: ${getTipoRegistroLabel(resultado.tipo)}`;
|
||||
imagemCapturada = null;
|
||||
justificativa = ''; // Limpar justificativa após registro
|
||||
|
||||
// Mostrar comprovante após 1 segundo
|
||||
setTimeout(() => {
|
||||
@@ -111,23 +138,71 @@
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Erro ao registrar ponto:', error);
|
||||
erro = error instanceof Error ? error.message : 'Erro ao registrar ponto';
|
||||
const mensagemErro = error instanceof Error ? error.message : 'Erro ao registrar ponto';
|
||||
|
||||
// Verificar se é erro de registro duplicado
|
||||
if (
|
||||
mensagemErro.includes('Já existe um registro neste minuto') ||
|
||||
mensagemErro.includes('já existe um registro')
|
||||
) {
|
||||
mensagemErroModal = 'Registro de ponto duplicado';
|
||||
detalhesErroModal = `Não é possível registrar o ponto no mesmo minuto.\n\nVocê já possui um registro de ${getTipoRegistroLabel(proximoTipo)} para este minuto.\n\nPor favor, aguarde pelo menos 1 minuto antes de tentar registrar novamente.`;
|
||||
mostrarModalErro = true;
|
||||
} else {
|
||||
// Outros erros também mostram no modal
|
||||
mensagemErroModal = 'Erro ao registrar ponto';
|
||||
detalhesErroModal = mensagemErro;
|
||||
mostrarModalErro = true;
|
||||
}
|
||||
|
||||
erro = mensagemErro;
|
||||
} finally {
|
||||
registrando = false;
|
||||
coletandoInfo = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebcamCapture(blob: Blob) {
|
||||
imagemCapturada = blob;
|
||||
function handleWebcamCapture(blob: Blob | null) {
|
||||
if (blob) {
|
||||
imagemCapturada = blob;
|
||||
}
|
||||
mostrandoWebcam = false;
|
||||
|
||||
// Se estava capturando automaticamente, registrar o ponto após capturar (com ou sem foto)
|
||||
if (capturandoAutomaticamente) {
|
||||
capturandoAutomaticamente = false;
|
||||
// Pequeno delay para garantir que a imagem foi processada (se houver)
|
||||
setTimeout(() => {
|
||||
registrarPonto();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebcamCancel() {
|
||||
const estavaCapturando = capturandoAutomaticamente;
|
||||
mostrandoWebcam = false;
|
||||
capturandoAutomaticamente = false;
|
||||
imagemCapturada = null;
|
||||
// Se estava capturando automaticamente e cancelou, registrar sem foto
|
||||
if (estavaCapturando) {
|
||||
registrarPonto();
|
||||
}
|
||||
}
|
||||
|
||||
function abrirWebcam() {
|
||||
function handleWebcamError() {
|
||||
// Em caso de erro na captura, registrar sem foto
|
||||
mostrandoWebcam = false;
|
||||
capturandoAutomaticamente = false;
|
||||
imagemCapturada = null;
|
||||
// Registrar ponto sem foto
|
||||
registrarPonto();
|
||||
}
|
||||
|
||||
async function iniciarRegistroComFoto() {
|
||||
if (registrando || coletandoInfo) return;
|
||||
|
||||
// Abrir webcam automaticamente
|
||||
capturandoAutomaticamente = true;
|
||||
mostrandoWebcam = true;
|
||||
}
|
||||
|
||||
@@ -136,10 +211,75 @@
|
||||
registroId = null;
|
||||
}
|
||||
|
||||
function fecharModalErro() {
|
||||
mostrarModalErro = false;
|
||||
mensagemErroModal = '';
|
||||
detalhesErroModal = '';
|
||||
erro = null;
|
||||
}
|
||||
|
||||
const podeRegistrar = $derived.by(() => {
|
||||
return !registrando && !coletandoInfo && config !== undefined;
|
||||
});
|
||||
|
||||
// Referência para o modal
|
||||
let modalRef: HTMLDivElement | null = $state(null);
|
||||
|
||||
// Efeito para garantir que o modal fique visível quando abrir
|
||||
$effect(() => {
|
||||
if (mostrandoWebcam && modalRef) {
|
||||
// Aguardar um frame para garantir que o DOM foi atualizado
|
||||
setTimeout(() => {
|
||||
if (modalRef) {
|
||||
modalRef.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Também garantir que o modal-box esteja visível
|
||||
const modalBox = modalRef.querySelector('.modal-box');
|
||||
if (modalBox) {
|
||||
(modalBox as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Solicitar permissões automaticamente ao montar o componente
|
||||
onMount(async () => {
|
||||
// Solicitar permissão de webcam
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
try {
|
||||
// Solicitar apenas permissão, sem iniciar o stream ainda
|
||||
await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
} catch (error) {
|
||||
// Ignorar erro silenciosamente - a permissão será solicitada quando necessário
|
||||
console.log('Permissão de webcam não concedida ainda');
|
||||
}
|
||||
}
|
||||
|
||||
// Solicitar permissão de geolocalização
|
||||
if (navigator.geolocation) {
|
||||
try {
|
||||
// Solicitar permissão de geolocalização (timeout curto para não bloquear)
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeoutId = setTimeout(() => resolve(), 2000); // Timeout de 2 segundos
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
() => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
},
|
||||
() => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(); // Resolver mesmo se negado
|
||||
},
|
||||
{ timeout: 2000, maximumAge: 0, enableHighAccuracy: false } // enableHighAccuracy false para ser mais rápido
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignorar erro silenciosamente
|
||||
console.log('Permissão de geolocalização não concedida ainda');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const mapaHorarios = $derived.by(() => {
|
||||
if (!config) return [];
|
||||
|
||||
@@ -147,7 +287,7 @@
|
||||
{ tipo: 'entrada', horario: config.horarioEntrada, label: 'Entrada' },
|
||||
{ tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: 'Saída para Almoço' },
|
||||
{ tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: 'Retorno do Almoço' },
|
||||
{ tipo: 'saida', horario: config.horarioSaida, label: 'Saída' },
|
||||
{ tipo: 'saida', horario: config.horarioSaida, label: 'Saída' }
|
||||
];
|
||||
|
||||
return horarios.map((h) => {
|
||||
@@ -156,10 +296,33 @@
|
||||
...h,
|
||||
registrado: !!registro,
|
||||
horarioRegistrado: registro ? formatarHoraPonto(registro.hora, registro.minuto) : null,
|
||||
dentroDoPrazo: registro?.dentroDoPrazo ?? null,
|
||||
dentroDoPrazo: registro?.dentroDoPrazo ?? null
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// Dados do histórico e saldo
|
||||
const historicoSaldo = $derived(historicoSaldoQuery?.data);
|
||||
const registrosOrdenados = $derived.by(() => {
|
||||
if (!historicoSaldo?.registros) return [];
|
||||
return [...historicoSaldo.registros].sort((a, b) => {
|
||||
const minutosA = a.hora * 60 + a.minuto;
|
||||
const minutosB = b.hora * 60 + b.minuto;
|
||||
return minutosA - minutosB;
|
||||
});
|
||||
});
|
||||
|
||||
// Formatação do saldo
|
||||
const saldoFormatado = $derived.by(() => {
|
||||
if (!historicoSaldo) return null;
|
||||
const minutos = historicoSaldo.saldoMinutos;
|
||||
const horas = Math.floor(Math.abs(minutos) / 60);
|
||||
const mins = Math.abs(minutos) % 60;
|
||||
const sinal = minutos >= 0 ? '+' : '-';
|
||||
return `${sinal}${horas}h ${mins}min`;
|
||||
});
|
||||
|
||||
const saldoPositivo = $derived(historicoSaldo ? historicoSaldo.saldoMinutos >= 0 : false);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -177,25 +340,27 @@
|
||||
<Clock class="h-5 w-5" />
|
||||
Horários do Dia
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
|
||||
{#each mapaHorarios as horario}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{#each mapaHorarios as horario (horario.tipo)}
|
||||
<div
|
||||
class="card {horario.registrado ? 'bg-success/10 border-success' : 'bg-base-200'} border-2"
|
||||
class="card {horario.registrado
|
||||
? 'bg-success/10 border-success'
|
||||
: 'bg-base-200'} border-2"
|
||||
>
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-semibold">{horario.label}</span>
|
||||
{#if horario.registrado}
|
||||
{#if horario.dentroDoPrazo}
|
||||
<CheckCircle2 class="h-5 w-5 text-success" />
|
||||
<CheckCircle2 class="text-success h-5 w-5" />
|
||||
{:else}
|
||||
<XCircle class="h-5 w-5 text-error" />
|
||||
<XCircle class="text-error h-5 w-5" />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-2xl font-bold">{horario.horario}</div>
|
||||
{#if horario.registrado}
|
||||
<div class="text-sm text-base-content/70">
|
||||
<div class="text-base-content/70 text-sm">
|
||||
Registrado: {horario.horarioRegistrado}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -210,14 +375,7 @@
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body items-center">
|
||||
<h2 class="card-title mb-4">Registrar Ponto</h2>
|
||||
<div class="flex flex-col items-center gap-4 w-full">
|
||||
{#if erro}
|
||||
<div class="alert alert-error w-full">
|
||||
<XCircle class="h-5 w-5" />
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full flex-col items-center gap-4">
|
||||
{#if sucesso}
|
||||
<div class="alert alert-success w-full">
|
||||
<CheckCircle2 class="h-5 w-5" />
|
||||
@@ -225,62 +383,166 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<div class="mb-4 text-center">
|
||||
<p class="text-lg font-semibold">Próximo registro: {tipoLabel}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
{#if !imagemCapturada}
|
||||
<button class="btn btn-outline btn-primary" onclick={abrirWebcam} disabled={!podeRegistrar}>
|
||||
<Camera class="h-5 w-5" />
|
||||
Capturar Foto
|
||||
</button>
|
||||
{:else}
|
||||
<div class="badge badge-primary badge-lg gap-2">
|
||||
<Camera class="h-4 w-4" />
|
||||
Foto capturada
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="btn btn-primary btn-lg"
|
||||
onclick={registrarPonto}
|
||||
disabled={!podeRegistrar}
|
||||
>
|
||||
{#if registrando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{#if coletandoInfo}
|
||||
Coletando informações...
|
||||
{:else}
|
||||
Registrando...
|
||||
{/if}
|
||||
{:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
|
||||
<LogIn class="h-5 w-5" />
|
||||
Registrar Entrada
|
||||
{:else}
|
||||
<LogOut class="h-5 w-5" />
|
||||
Registrar Saída
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Campo de Justificativa (Opcional) -->
|
||||
<div class="w-full">
|
||||
<label for="justificativa" class="label">
|
||||
<span class="label-text">Justificativa (Opcional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="justificativa"
|
||||
class="textarea textarea-bordered w-full"
|
||||
placeholder="Digite uma justificativa para este registro de ponto (opcional)"
|
||||
bind:value={justificativa}
|
||||
disabled={registrando}
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary btn-lg"
|
||||
onclick={iniciarRegistroComFoto}
|
||||
disabled={!podeRegistrar}
|
||||
>
|
||||
{#if registrando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{#if coletandoInfo}
|
||||
Coletando informações...
|
||||
{:else}
|
||||
Registrando...
|
||||
{/if}
|
||||
{:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
|
||||
<LogIn class="h-5 w-5" />
|
||||
Registrar Entrada
|
||||
{:else}
|
||||
<LogOut class="h-5 w-5" />
|
||||
Registrar Saída
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Histórico e Saldo do Dia -->
|
||||
{#if historicoSaldo && registrosOrdenados.length > 0}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<Clock class="h-5 w-5" />
|
||||
Histórico do Dia
|
||||
</h2>
|
||||
|
||||
<!-- Saldo de Horas -->
|
||||
<div class="my-4 rounded-lg border-2 p-4 {saldoPositivo ? 'border-success bg-success/10' : 'border-error bg-error/10'}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold opacity-70">Saldo de Horas</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{saldoFormatado}
|
||||
</p>
|
||||
</div>
|
||||
{#if saldoPositivo}
|
||||
<TrendingUp class="h-8 w-8 text-success" />
|
||||
{:else}
|
||||
<TrendingDown class="h-8 w-8 text-error" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-2 text-sm opacity-70">
|
||||
<p>Carga Horária Diária: {Math.floor(historicoSaldo.cargaHorariaDiaria / 60)}h {historicoSaldo.cargaHorariaDiaria % 60}min</p>
|
||||
<p>Horas Trabalhadas: {Math.floor(historicoSaldo.horasTrabalhadas / 60)}h {historicoSaldo.horasTrabalhadas % 60}min</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Registros -->
|
||||
<div class="divider"></div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="font-semibold">Registros Realizados</h3>
|
||||
<div class="space-y-3">
|
||||
{#each registrosOrdenados as registro (registro._id)}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="font-semibold">{getTipoRegistroLabel(registro.tipo)}</span>
|
||||
{#if registro.dentroDoPrazo}
|
||||
<CheckCircle2 class="h-4 w-4 text-success" />
|
||||
{:else}
|
||||
<XCircle class="h-4 w-4 text-error" />
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-lg font-bold">
|
||||
{formatarHoraPonto(registro.hora, registro.minuto)}
|
||||
</p>
|
||||
{#if registro.justificativa}
|
||||
<div class="mt-2 rounded bg-base-300 p-2">
|
||||
<p class="text-xs font-semibold opacity-70">Justificativa:</p>
|
||||
<p class="text-sm">{registro.justificativa}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal Webcam -->
|
||||
{#if mostrandoWebcam}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="font-bold text-lg mb-4">Capturar Foto</h3>
|
||||
<WebcamCapture onCapture={handleWebcamCapture} onCancel={handleWebcamCancel} />
|
||||
<div
|
||||
bind:this={modalRef}
|
||||
class="modal modal-open"
|
||||
style="display: flex; align-items: center; justify-content: center; position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999;"
|
||||
>
|
||||
<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">
|
||||
<h3 class="text-lg font-bold">
|
||||
{#if capturandoAutomaticamente}
|
||||
Capturando foto automaticamente...
|
||||
{:else}
|
||||
Capturar Foto
|
||||
{/if}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="min-h-[200px] flex items-center justify-center py-4">
|
||||
<WebcamCapture
|
||||
onCapture={handleWebcamCapture}
|
||||
onCancel={handleWebcamCancel}
|
||||
onError={handleWebcamError}
|
||||
autoCapture={capturandoAutomaticamente}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" onclick={handleWebcamCancel}></div>
|
||||
<form
|
||||
method="dialog"
|
||||
class="modal-backdrop"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleWebcamCancel();
|
||||
}}
|
||||
>
|
||||
<button type="submit" aria-label="Fechar modal">fechar</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal Comprovante -->
|
||||
{#if mostrandoComprovante && registroId}
|
||||
<ComprovantePonto registroId={registroId} onClose={fecharComprovante} />
|
||||
<ComprovantePonto {registroId} onClose={fecharComprovante} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modal de Erro -->
|
||||
<ErrorModal
|
||||
open={mostrarModalErro}
|
||||
title={mensagemErroModal || 'Erro ao registrar ponto'}
|
||||
message={detalhesErroModal || mensagemErroModal || 'Ocorreu um erro ao registrar o ponto. Tente novamente.'}
|
||||
onClose={fecharModalErro}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
import { validarWebcamDisponivel, capturarWebcamComPreview } from '$lib/utils/webcam';
|
||||
|
||||
interface Props {
|
||||
onCapture: (blob: Blob) => void;
|
||||
onCapture: (blob: Blob | null) => void;
|
||||
onCancel: () => void;
|
||||
onError?: () => void;
|
||||
autoCapture?: boolean;
|
||||
}
|
||||
|
||||
let { onCapture, onCancel }: Props = $props();
|
||||
let { onCapture, onCancel, onError, autoCapture = false }: Props = $props();
|
||||
|
||||
let videoElement: HTMLVideoElement | null = $state(null);
|
||||
let canvasElement: HTMLCanvasElement | null = $state(null);
|
||||
@@ -19,9 +21,12 @@
|
||||
let previewUrl = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
webcamDisponivel = await validarWebcamDisponivel();
|
||||
if (!webcamDisponivel) {
|
||||
erro = 'Webcam não disponível';
|
||||
// Tentar obter permissão de webcam automaticamente
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
erro = 'Webcam não suportada';
|
||||
if (autoCapture && onError) {
|
||||
onError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -30,18 +35,36 @@
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: 'user',
|
||||
},
|
||||
facingMode: 'user'
|
||||
}
|
||||
});
|
||||
|
||||
webcamDisponivel = true;
|
||||
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
|
||||
// Se for captura automática, aguardar um pouco e capturar
|
||||
if (autoCapture) {
|
||||
// Aguardar 1 segundo para o usuário se posicionar
|
||||
setTimeout(() => {
|
||||
if (videoElement && canvasElement && !capturando && !previewUrl) {
|
||||
capturar();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao acessar webcam:', error);
|
||||
erro = 'Erro ao acessar webcam. Verifique as permissões.';
|
||||
erro = 'Erro ao acessar webcam. Continuando sem foto.';
|
||||
webcamDisponivel = false;
|
||||
// Se for captura automática e houver erro, chamar onError para continuar sem foto
|
||||
if (autoCapture && onError) {
|
||||
setTimeout(() => {
|
||||
onError();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,6 +79,9 @@
|
||||
|
||||
async function capturar() {
|
||||
if (!videoElement || !canvasElement) {
|
||||
if (autoCapture && onError) {
|
||||
onError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,12 +97,31 @@
|
||||
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 {
|
||||
erro = 'Falha ao capturar imagem';
|
||||
// Se for captura automática e falhar, continuar sem foto
|
||||
if (autoCapture && onError) {
|
||||
setTimeout(() => {
|
||||
onError();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao capturar:', error);
|
||||
erro = 'Erro ao capturar imagem';
|
||||
erro = 'Erro ao capturar imagem. Continuando sem foto.';
|
||||
// Se for captura automática e houver erro, continuar sem foto
|
||||
if (autoCapture && onError) {
|
||||
setTimeout(() => {
|
||||
onError();
|
||||
}, 500);
|
||||
}
|
||||
} finally {
|
||||
capturando = false;
|
||||
}
|
||||
@@ -116,8 +161,8 @@
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: 'user',
|
||||
},
|
||||
facingMode: 'user'
|
||||
}
|
||||
});
|
||||
|
||||
if (videoElement) {
|
||||
@@ -131,69 +176,102 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 p-6">
|
||||
<div class="flex flex-col items-center gap-4 p-4 w-full">
|
||||
{#if !webcamDisponivel && !erro}
|
||||
<div class="flex items-center gap-2 text-warning">
|
||||
<div class="text-warning flex items-center gap-2">
|
||||
<Camera class="h-5 w-5" />
|
||||
<span>Verificando webcam...</span>
|
||||
</div>
|
||||
{:else if erro && !webcamDisponivel}
|
||||
<div class="alert alert-warning">
|
||||
{#if !autoCapture}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if erro && !webcamDisponivel}
|
||||
<div class="alert alert-warning max-w-md">
|
||||
<AlertCircle class="h-5 w-5" />
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||
{#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">
|
||||
<img src={previewUrl} alt="Preview" class="max-w-full max-h-96 rounded-lg border-2 border-primary" />
|
||||
<div class="flex gap-2">
|
||||
<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>
|
||||
<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">
|
||||
<div class="relative">
|
||||
<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>
|
||||
{/if}
|
||||
<div class="relative w-full flex justify-center">
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
autoplay
|
||||
playsinline
|
||||
class="rounded-lg border-2 border-primary max-w-full max-h-96"
|
||||
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain"
|
||||
></video>
|
||||
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
||||
</div>
|
||||
{#if erro}
|
||||
<div class="alert alert-error">
|
||||
<div class="alert alert-error max-w-md">
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick={capturar} disabled={capturando}>
|
||||
{#if capturando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Camera class="h-5 w-5" />
|
||||
{/if}
|
||||
Capturar Foto
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick={cancelar}>
|
||||
<X class="h-5 w-5" />
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
{#if !autoCapture}
|
||||
<!-- Botões apenas se não for automático -->
|
||||
<div class="flex gap-2 flex-wrap justify-center">
|
||||
<button class="btn btn-primary" onclick={capturar} disabled={capturando}>
|
||||
{#if capturando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Camera class="h-5 w-5" />
|
||||
{/if}
|
||||
Capturar Foto
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick={cancelar}>
|
||||
<X class="h-5 w-5" />
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { Clock, ArrowRight, CheckCircle2, XCircle } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Clock } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
// Estatísticas do dia atual
|
||||
const hoje = new Date().toISOString().split('T')[0]!;
|
||||
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, {
|
||||
dataInicio: hoje,
|
||||
dataFim: hoje,
|
||||
});
|
||||
|
||||
const estatisticas = $derived(estatisticasQuery?.data);
|
||||
|
||||
function abrirDashboard() {
|
||||
goto(resolve('/(dashboard)/recursos-humanos/registro-pontos'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card bg-gradient-to-br from-blue-500 to-cyan-600 text-white shadow-xl hover:shadow-2xl transition-all duration-300">
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-3 bg-white/20 rounded-xl">
|
||||
<Clock class="h-6 w-6" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="card-title text-white">Gestão de Pontos</h3>
|
||||
<p class="text-white/80 text-sm">Registros de ponto do dia</p>
|
||||
<!-- 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>
|
||||
|
||||
{#if estatisticas}
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="bg-white/10 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
<span class="text-sm text-white/80">Dentro do Prazo</span>
|
||||
<!-- 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>
|
||||
<div class="text-2xl font-bold">{estatisticas.dentroDoPrazo}</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>
|
||||
|
||||
<div class="bg-white/10 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<XCircle class="h-4 w-4" />
|
||||
<span class="text-sm text-white/80">Fora do Prazo</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold">{estatisticas.foraDoPrazo}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-white/80 mb-4">
|
||||
Total: {estatisticas.totalRegistros} registros de {estatisticas.totalFuncionarios} funcionários
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-white/80 text-sm mb-4">Carregando estatísticas...</div>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-white btn-sm w-full" onclick={abrirDashboard}>
|
||||
Ver Dashboard Completo
|
||||
<ArrowRight class="h-4 w-4" />
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ async function obterLocalizacao(): Promise<{
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve({});
|
||||
}, 10000); // Timeout de 10 segundos
|
||||
}, 5000); // Timeout de 5 segundos (reduzido para não bloquear)
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
@@ -306,9 +306,9 @@ async function obterLocalizacao(): Promise<{
|
||||
resolve({});
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0,
|
||||
enableHighAccuracy: false, // false para ser mais rápido
|
||||
timeout: 5000, // Timeout reduzido para 5 segundos
|
||||
maximumAge: 60000, // Aceitar localização de até 1 minuto atrás
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user