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:
2025-11-18 15:28:26 -03:00
parent f0c6e4468f
commit b01d2d6786
10 changed files with 941 additions and 187 deletions

View File

@@ -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>