feat: integrate point management features into the dashboard

- Added a new "Meu Ponto" section for users to register their work hours, breaks, and attendance.
- Introduced a "Controle de Ponto" category in the Recursos Humanos section for managing employee time records.
- Enhanced the backend schema to support point registration and configuration settings.
- Updated various components to improve UI consistency and user experience across the dashboard.
This commit is contained in:
2025-11-18 11:44:12 -03:00
parent 52123a33b3
commit f0c6e4468f
22 changed files with 3604 additions and 128 deletions

View File

@@ -0,0 +1,286 @@
<script lang="ts">
import { onMount } from 'svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import RelogioSincronizado from './RelogioSincronizado.svelte';
import WebcamCapture from './WebcamCapture.svelte';
import ComprovantePonto from './ComprovantePonto.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 type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
const client = useConvexClient();
// Queries
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
const registrosHojeQuery = useQuery(api.pontos.listarRegistrosDia, {});
// Estados
let mostrandoWebcam = $state(false);
let registrando = $state(false);
let erro = $state<string | null>(null);
let sucesso = $state<string | null>(null);
let registroId = $state<Id<'registrosPonto'> | null>(null);
let mostrandoComprovante = $state(false);
let imagemCapturada = $state<Blob | null>(null);
let coletandoInfo = $state(false);
const registrosHoje = $derived(registrosHojeQuery?.data || []);
const config = $derived(configQuery?.data);
const proximoTipo = $derived.by(() => {
if (registrosHoje.length === 0) {
return 'entrada';
}
const ultimoRegistro = registrosHoje[registrosHoje.length - 1];
return getProximoTipoRegistro(ultimoRegistro?.tipo || null);
});
const tipoLabel = $derived.by(() => {
return getTipoRegistroLabel(proximoTipo);
});
async function uploadImagem(blob: Blob): Promise<Id<'_storage'> | undefined> {
try {
// Obter URL de upload
const uploadUrl = await client.mutation(api.pontos.generateUploadUrl, {});
// Criar File a partir do Blob
const file = new File([blob], 'ponto.jpg', { type: 'image/jpeg' });
// Fazer upload
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file,
});
if (!response.ok) {
throw new Error('Falha no upload da imagem');
}
// A resposta do Convex storage retorna JSON com storageId
const { storageId } = (await response.json()) as { storageId: string };
return storageId as Id<'_storage'>;
} catch (error) {
console.error('Erro ao fazer upload da imagem:', error);
return undefined;
}
}
async function registrarPonto() {
if (registrando) return;
registrando = true;
erro = null;
sucesso = null;
coletandoInfo = true;
try {
// Coletar informações do dispositivo
const informacoesDispositivo = await obterInformacoesDispositivo();
coletandoInfo = false;
// Obter tempo sincronizado
const timestamp = await obterTempoServidor(client);
const sincronizadoComServidor = true; // Sempre true quando usamos obterTempoServidor
// Upload da imagem se houver
let imagemId: Id<'_storage'> | undefined = undefined;
if (imagemCapturada) {
imagemId = await uploadImagem(imagemCapturada);
}
// Registrar ponto
const resultado = await client.mutation(api.pontos.registrarPonto, {
imagemId,
informacoesDispositivo,
timestamp,
sincronizadoComServidor,
});
registroId = resultado.registroId;
sucesso = `Ponto registrado com sucesso! Tipo: ${getTipoRegistroLabel(resultado.tipo)}`;
imagemCapturada = null;
// Mostrar comprovante após 1 segundo
setTimeout(() => {
mostrandoComprovante = true;
}, 1000);
} catch (error) {
console.error('Erro ao registrar ponto:', error);
erro = error instanceof Error ? error.message : 'Erro ao registrar ponto';
} finally {
registrando = false;
coletandoInfo = false;
}
}
function handleWebcamCapture(blob: Blob) {
imagemCapturada = blob;
mostrandoWebcam = false;
}
function handleWebcamCancel() {
mostrandoWebcam = false;
}
function abrirWebcam() {
mostrandoWebcam = true;
}
function fecharComprovante() {
mostrandoComprovante = false;
registroId = null;
}
const podeRegistrar = $derived.by(() => {
return !registrando && !coletandoInfo && config !== undefined;
});
const mapaHorarios = $derived.by(() => {
if (!config) return [];
const horarios = [
{ 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' },
];
return horarios.map((h) => {
const registro = registrosHoje.find((r) => r.tipo === h.tipo);
return {
...h,
registrado: !!registro,
horarioRegistrado: registro ? formatarHoraPonto(registro.hora, registro.minuto) : null,
dentroDoPrazo: registro?.dentroDoPrazo ?? null,
};
});
});
</script>
<div class="space-y-6">
<!-- 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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
{#each mapaHorarios as horario}
<div
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">
<span class="font-semibold">{horario.label}</span>
{#if horario.registrado}
{#if horario.dentroDoPrazo}
<CheckCircle2 class="h-5 w-5 text-success" />
{:else}
<XCircle class="h-5 w-5 text-error" />
{/if}
{/if}
</div>
<div class="text-2xl font-bold">{horario.horario}</div>
{#if horario.registrado}
<div class="text-sm text-base-content/70">
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="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}
{#if sucesso}
<div class="alert alert-success w-full">
<CheckCircle2 class="h-5 w-5" />
<span>{sucesso}</span>
</div>
{/if}
<div class="text-center mb-4">
<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>
</div>
</div>
</div>
</div>
<!-- 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>
<div class="modal-backdrop" onclick={handleWebcamCancel}></div>
</div>
{/if}
<!-- Modal Comprovante -->
{#if mostrandoComprovante && registroId}
<ComprovantePonto registroId={registroId} onClose={fecharComprovante} />
{/if}
</div>