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:
286
apps/web/src/lib/components/ponto/RegistroPonto.svelte
Normal file
286
apps/web/src/lib/components/ponto/RegistroPonto.svelte
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user