feat: enhance point registration and location validation features
- Refactored the RegistroPonto component to improve the layout and user experience, including a new section for displaying standard hours. - Updated RelogioSincronizado to include GMT offset adjustments for accurate time display. - Introduced new location validation logic in the backend to ensure point registrations are within allowed geofenced areas. - Enhanced the device information schema to capture additional GPS data, improving the reliability of location checks. - Added new endpoints for managing allowed marking addresses, facilitating better control over where points can be registered.
This commit is contained in:
@@ -747,55 +747,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Relógio Sincronizado -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body items-center">
|
||||
<RelogioSincronizado />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mapa de Horários -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<Clock class="h-5 w-5" />
|
||||
Horários do Dia
|
||||
</h2>
|
||||
<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"
|
||||
>
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-semibold">{horario.label}</span>
|
||||
{#if horario.registrado}
|
||||
{#if horario.dentroDoPrazo}
|
||||
<CheckCircle2 class="text-success h-5 w-5" />
|
||||
{:else}
|
||||
<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-base-content/70 text-sm">
|
||||
Registrado: {horario.horarioRegistrado}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botões de Registro -->
|
||||
<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="mb-6 w-full">
|
||||
<RelogioSincronizado />
|
||||
</div>
|
||||
<div class="flex w-full flex-col items-center gap-4">
|
||||
{#if sucesso}
|
||||
<div class="alert alert-success w-full">
|
||||
@@ -858,6 +816,78 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mapa de Horários -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-6">
|
||||
<Clock class="h-5 w-5" />
|
||||
Horário Padrão
|
||||
</h2>
|
||||
|
||||
<!-- Linha horizontal com espaçamento uniforme -->
|
||||
<div class="flex flex-wrap items-stretch justify-between gap-4 md:gap-6">
|
||||
{#each mapaHorarios as horario (horario.tipo)}
|
||||
<div class="flex-1 min-w-[140px] max-w-[220px] mx-auto">
|
||||
<div
|
||||
class="relative h-full rounded-xl border-2 transition-all duration-300 hover:shadow-lg {horario.registrado
|
||||
? horario.dentroDoPrazo
|
||||
? 'bg-gradient-to-br from-success/20 to-success/10 border-success shadow-md'
|
||||
: 'bg-gradient-to-br from-error/20 to-error/10 border-error shadow-md'
|
||||
: 'bg-gradient-to-br from-base-200 to-base-300 border-base-300'} p-5"
|
||||
>
|
||||
<!-- Status Icon -->
|
||||
<div class="absolute top-3 right-3">
|
||||
{#if horario.registrado}
|
||||
{#if horario.dentroDoPrazo}
|
||||
<CheckCircle2 class="h-5 w-5 text-success" />
|
||||
{:else}
|
||||
<XCircle class="h-5 w-5 text-error" />
|
||||
{/if}
|
||||
{:else}
|
||||
<Clock class="h-5 w-5 text-base-content/30" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<div class="mb-3">
|
||||
<span class="text-sm font-semibold text-base-content/80 uppercase tracking-wide">
|
||||
{horario.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Horário Padrão -->
|
||||
<div class="mb-2">
|
||||
<div class="text-3xl font-bold text-primary font-mono">
|
||||
{horario.horario}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Horário Registrado (se houver) -->
|
||||
{#if horario.registrado}
|
||||
<div class="mt-3 pt-3 border-t border-base-content/10">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-xs font-medium text-base-content/60">
|
||||
Registrado:
|
||||
</div>
|
||||
<div class="text-sm font-bold text-base-content">
|
||||
{horario.horarioRegistrado}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-3 pt-3 border-t border-base-content/10">
|
||||
<div class="text-xs text-base-content/40 italic">
|
||||
Aguardando registro
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Histórico e Saldo do Dia -->
|
||||
{#if historicoSaldo && registrosOrdenados.length > 0}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
@@ -888,57 +918,224 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Registros -->
|
||||
<!-- Timeline 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 gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span class="font-semibold">
|
||||
{config
|
||||
? getTipoRegistroLabel(registro.tipo, {
|
||||
nomeEntrada: config.nomeEntrada,
|
||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||
nomeSaida: config.nomeSaida,
|
||||
})
|
||||
: 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 class="space-y-4">
|
||||
<h3 class="font-semibold">Timeline do Dia</h3>
|
||||
|
||||
<!-- Timeline Visual com horários padrão e registros reais -->
|
||||
<div class="relative">
|
||||
<!-- Linha vertical central da timeline -->
|
||||
<div class="absolute left-1/2 top-0 bottom-0 w-1 bg-gradient-to-b from-primary/20 via-base-300 to-secondary/20 transform -translate-x-1/2"></div>
|
||||
|
||||
<!-- Container com duas colunas -->
|
||||
<div class="grid grid-cols-2 gap-4 relative">
|
||||
<!-- Coluna Entrada -->
|
||||
<div class="space-y-4 pr-2">
|
||||
<div class="sticky top-0 z-10 bg-base-100 pb-3 mb-2 border-b border-primary/20">
|
||||
<h4 class="text-lg font-bold text-primary text-center flex items-center justify-center gap-2">
|
||||
<LogIn class="h-5 w-5" />
|
||||
Entradas
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{#each registrosOrdenados.filter(r => r.tipo === 'entrada' || r.tipo === 'retorno_almoco') as registro (registro._id)}
|
||||
<div class="relative">
|
||||
<!-- Linha horizontal conectando à timeline -->
|
||||
<div class="absolute right-0 top-6 w-full h-0.5 bg-base-300/50" style="width: calc(100% - 0.5rem);"></div>
|
||||
|
||||
<!-- Card do registro -->
|
||||
<div class="card {registro.dentroDoPrazo ? 'bg-success/5 border-success/30' : 'bg-error/5 border-error/30'} border-2 shadow-md hover:shadow-lg transition-all">
|
||||
<div class="card-body p-4">
|
||||
<!-- Tipo de registro e status -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{#if registro.dentroDoPrazo}
|
||||
<CheckCircle2 class="h-4 w-4 text-success flex-shrink-0" />
|
||||
{:else}
|
||||
<XCircle class="h-4 w-4 text-error flex-shrink-0" />
|
||||
{/if}
|
||||
<span class="text-sm font-semibold text-base-content/80">
|
||||
{config
|
||||
? getTipoRegistroLabel(registro.tipo, {
|
||||
nomeEntrada: config.nomeEntrada,
|
||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||
})
|
||||
: getTipoRegistroLabel(registro.tipo)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<button
|
||||
class="btn btn-sm btn-outline btn-primary gap-2"
|
||||
onclick={() => imprimirComprovante(registro._id)}
|
||||
title="Imprimir Comprovante"
|
||||
>
|
||||
<Printer class="h-4 w-4" />
|
||||
Imprimir Comprovante
|
||||
</button>
|
||||
|
||||
<!-- Horário registrado -->
|
||||
<p class="text-3xl font-bold text-primary mb-1">
|
||||
{formatarHoraPonto(registro.hora, registro.minuto)}
|
||||
</p>
|
||||
|
||||
<!-- Comparação com horário esperado -->
|
||||
{#if config}
|
||||
{@const horarioEsperado = registro.tipo === 'entrada' ? config.horarioEntrada : config.horarioRetornoAlmoco}
|
||||
{@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)}
|
||||
{@const minutosEsperados = horaEsperada * 60 + minutoEsperado}
|
||||
{@const minutosRegistrados = registro.hora * 60 + registro.minuto}
|
||||
{@const diferenca = minutosRegistrados - minutosEsperados}
|
||||
{@const diferencaAbs = Math.abs(diferenca)}
|
||||
{@const diferencaTexto = diferencaAbs >= 60
|
||||
? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min`
|
||||
: `${diferencaAbs}min`}
|
||||
|
||||
<div class="flex items-center gap-2 text-xs mb-3">
|
||||
<span class="text-base-content/50">Esperado:</span>
|
||||
<span class="font-semibold">{horarioEsperado}</span>
|
||||
{#if diferencaAbs > 0}
|
||||
<span class="badge badge-xs {diferenca > 0 ? 'badge-warning' : 'badge-info'}">
|
||||
{diferenca > 0 ? '+' : '-'}{diferencaTexto}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if registro.justificativa}
|
||||
<div class="mt-2 rounded-lg bg-base-300/50 p-2 text-xs mb-3">
|
||||
<p class="font-semibold opacity-70 mb-1">Justificativa:</p>
|
||||
<p class="text-base-content/80">{registro.justificativa}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-outline btn-primary gap-2 w-full"
|
||||
onclick={() => imprimirComprovante(registro._id)}
|
||||
title="Imprimir Comprovante"
|
||||
>
|
||||
<Printer class="h-4 w-4" />
|
||||
Imprimir Comprovante
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Mostrar horários esperados que não foram registrados -->
|
||||
{#if config}
|
||||
{#each [
|
||||
{ tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' },
|
||||
{ tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: config.nomeRetornoAlmoco || 'Entrada 2' }
|
||||
] as horarioEsperado}
|
||||
{#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)}
|
||||
<div class="relative opacity-50">
|
||||
<div class="absolute right-0 top-6 w-full h-0.5 bg-base-300/30 border-dashed" style="width: calc(100% - 0.5rem);"></div>
|
||||
<div class="card bg-base-200/50 border border-dashed border-base-300">
|
||||
<div class="card-body p-3">
|
||||
<p class="text-xs text-base-content/50 mb-1">{horarioEsperado.label} (não registrado)</p>
|
||||
<p class="text-xl font-bold text-base-content/40">{horarioEsperado.horario}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Coluna Saída -->
|
||||
<div class="space-y-4 pl-2">
|
||||
<div class="sticky top-0 z-10 bg-base-100 pb-3 mb-2 border-b border-secondary/20">
|
||||
<h4 class="text-lg font-bold text-secondary text-center flex items-center justify-center gap-2">
|
||||
<LogOut class="h-5 w-5" />
|
||||
Saídas
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{#each registrosOrdenados.filter(r => r.tipo === 'saida_almoco' || r.tipo === 'saida') as registro (registro._id)}
|
||||
<div class="relative">
|
||||
<!-- Linha horizontal conectando à timeline -->
|
||||
<div class="absolute left-0 top-6 w-full h-0.5 bg-base-300/50" style="width: calc(100% - 0.5rem);"></div>
|
||||
|
||||
<!-- Card do registro -->
|
||||
<div class="card {registro.dentroDoPrazo ? 'bg-success/5 border-success/30' : 'bg-error/5 border-error/30'} border-2 shadow-md hover:shadow-lg transition-all">
|
||||
<div class="card-body p-4">
|
||||
<!-- Tipo de registro e status -->
|
||||
<div class="flex items-center gap-2 mb-2 justify-end">
|
||||
<span class="text-sm font-semibold text-base-content/80">
|
||||
{config
|
||||
? getTipoRegistroLabel(registro.tipo, {
|
||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
||||
nomeSaida: config.nomeSaida,
|
||||
})
|
||||
: getTipoRegistroLabel(registro.tipo)}
|
||||
</span>
|
||||
{#if registro.dentroDoPrazo}
|
||||
<CheckCircle2 class="h-4 w-4 text-success flex-shrink-0" />
|
||||
{:else}
|
||||
<XCircle class="h-4 w-4 text-error flex-shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Horário registrado -->
|
||||
<p class="text-3xl font-bold text-secondary mb-1 text-right">
|
||||
{formatarHoraPonto(registro.hora, registro.minuto)}
|
||||
</p>
|
||||
|
||||
<!-- Comparação com horário esperado -->
|
||||
{#if config}
|
||||
{@const horarioEsperado = registro.tipo === 'saida_almoco' ? config.horarioSaidaAlmoco : config.horarioSaida}
|
||||
{@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)}
|
||||
{@const minutosEsperados = horaEsperada * 60 + minutoEsperado}
|
||||
{@const minutosRegistrados = registro.hora * 60 + registro.minuto}
|
||||
{@const diferenca = minutosRegistrados - minutosEsperados}
|
||||
{@const diferencaAbs = Math.abs(diferenca)}
|
||||
{@const diferencaTexto = diferencaAbs >= 60
|
||||
? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min`
|
||||
: `${diferencaAbs}min`}
|
||||
|
||||
<div class="flex items-center gap-2 text-xs mb-3 justify-end">
|
||||
{#if diferencaAbs > 0}
|
||||
<span class="badge badge-xs {diferenca > 0 ? 'badge-warning' : 'badge-info'}">
|
||||
{diferenca > 0 ? '+' : '-'}{diferencaTexto}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="font-semibold">{horarioEsperado}</span>
|
||||
<span class="text-base-content/50">Esperado:</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if registro.justificativa}
|
||||
<div class="mt-2 rounded-lg bg-base-300/50 p-2 text-xs mb-3">
|
||||
<p class="font-semibold opacity-70 mb-1">Justificativa:</p>
|
||||
<p class="text-base-content/80">{registro.justificativa}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-outline btn-primary gap-2 w-full"
|
||||
onclick={() => imprimirComprovante(registro._id)}
|
||||
title="Imprimir Comprovante"
|
||||
>
|
||||
<Printer class="h-4 w-4" />
|
||||
Imprimir Comprovante
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Mostrar horários esperados que não foram registrados -->
|
||||
{#if config}
|
||||
{#each [
|
||||
{ tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: config.nomeSaidaAlmoco || 'Saída 1' },
|
||||
{ tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' }
|
||||
] as horarioEsperado}
|
||||
{#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)}
|
||||
<div class="relative opacity-50">
|
||||
<div class="absolute left-0 top-6 w-full h-0.5 bg-base-300/30 border-dashed" style="width: calc(100% - 0.5rem);"></div>
|
||||
<div class="card bg-base-200/50 border border-dashed border-base-300">
|
||||
<div class="card-body p-3">
|
||||
<p class="text-xs text-base-content/50 mb-1 text-right">{horarioEsperado.label} (não registrado)</p>
|
||||
<p class="text-xl font-bold text-base-content/40 text-right">{horarioEsperado.horario}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,36 +17,45 @@
|
||||
async function atualizarTempo() {
|
||||
try {
|
||||
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||
const gmtOffset = config.gmtOffset ?? 0;
|
||||
|
||||
let timestampBase: number;
|
||||
|
||||
if (config.usarServidorExterno) {
|
||||
try {
|
||||
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||
if (resultado.sucesso && resultado.timestamp) {
|
||||
tempoAtual = new Date(resultado.timestamp);
|
||||
timestampBase = resultado.timestamp;
|
||||
sincronizado = true;
|
||||
usandoServidorExterno = resultado.usandoServidorExterno || false;
|
||||
offsetSegundos = resultado.offsetSegundos || 0;
|
||||
erro = null;
|
||||
} else {
|
||||
throw new Error('Falha ao sincronizar');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao sincronizar:', error);
|
||||
if (config.fallbackParaPC) {
|
||||
tempoAtual = new Date(obterTempoPC());
|
||||
timestampBase = obterTempoPC();
|
||||
sincronizado = false;
|
||||
usandoServidorExterno = false;
|
||||
erro = 'Usando relógio do PC (falha na sincronização)';
|
||||
} else {
|
||||
erro = 'Falha ao sincronizar tempo';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Usar tempo do servidor Convex
|
||||
const tempoServidor = await obterTempoServidor(client);
|
||||
tempoAtual = new Date(tempoServidor);
|
||||
timestampBase = await obterTempoServidor(client);
|
||||
sincronizado = true;
|
||||
usandoServidorExterno = false;
|
||||
erro = null;
|
||||
}
|
||||
|
||||
// Aplicar GMT offset ao timestamp
|
||||
// O timestamp está em UTC, adicionar o offset em horas
|
||||
const timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
|
||||
tempoAtual = new Date(timestampAjustado);
|
||||
} catch (error) {
|
||||
console.error('Erro ao obter tempo:', error);
|
||||
tempoAtual = new Date(obterTempoPC());
|
||||
|
||||
Reference in New Issue
Block a user