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:
268
apps/web/src/lib/components/ponto/ComprovantePonto.svelte
Normal file
268
apps/web/src/lib/components/ponto/ComprovantePonto.svelte
Normal file
@@ -0,0 +1,268 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import jsPDF from 'jspdf';
|
||||
import { Printer, X } from 'lucide-svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto';
|
||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||
|
||||
interface Props {
|
||||
registroId: Id<'registrosPonto'>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { registroId, onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
|
||||
|
||||
let gerando = $state(false);
|
||||
|
||||
async function gerarPDF() {
|
||||
if (!registroQuery?.data) return;
|
||||
|
||||
gerando = true;
|
||||
|
||||
try {
|
||||
const registro = registroQuery.data;
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Logo
|
||||
let yPosition = 20;
|
||||
try {
|
||||
const logoImg = new Image();
|
||||
logoImg.src = logoGovPE;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
logoImg.onload = () => resolve();
|
||||
logoImg.onerror = () => reject();
|
||||
setTimeout(() => reject(), 3000);
|
||||
});
|
||||
|
||||
const logoWidth = 25;
|
||||
const aspectRatio = logoImg.height / logoImg.width;
|
||||
const logoHeight = logoWidth * aspectRatio;
|
||||
|
||||
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||
yPosition = Math.max(20, 10 + logoHeight / 2);
|
||||
} catch (err) {
|
||||
console.warn('Não foi possível carregar a logo:', err);
|
||||
}
|
||||
|
||||
// Cabeçalho
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(41, 128, 185);
|
||||
doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, { align: 'center' });
|
||||
|
||||
yPosition += 15;
|
||||
|
||||
// Informações do Funcionário
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
yPosition += 8;
|
||||
doc.setFontSize(10);
|
||||
|
||||
if (registro.funcionario) {
|
||||
if (registro.funcionario.matricula) {
|
||||
doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
}
|
||||
doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
if (registro.funcionario.descricaoCargo) {
|
||||
doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
}
|
||||
if (registro.funcionario.simbolo) {
|
||||
doc.text(
|
||||
`Símbolo: ${registro.funcionario.simbolo.nome} (${registro.funcionario.simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'})`,
|
||||
15,
|
||||
yPosition
|
||||
);
|
||||
yPosition += 6;
|
||||
}
|
||||
}
|
||||
|
||||
yPosition += 5;
|
||||
|
||||
// Informações do Registro
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('DADOS DO REGISTRO', 15, yPosition);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
yPosition += 8;
|
||||
doc.setFontSize(10);
|
||||
|
||||
doc.text(`Tipo: ${getTipoRegistroLabel(registro.tipo)}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
|
||||
const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo);
|
||||
doc.text(`Data e Hora: ${dataHora}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
|
||||
doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
|
||||
doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
|
||||
doc.text(
|
||||
`Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`,
|
||||
15,
|
||||
yPosition
|
||||
);
|
||||
yPosition += 10;
|
||||
|
||||
// Informações de Localização (se disponível)
|
||||
if (registro.latitude && registro.longitude) {
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('LOCALIZAÇÃO', 15, yPosition);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
yPosition += 8;
|
||||
doc.setFontSize(10);
|
||||
|
||||
if (registro.endereco) {
|
||||
doc.text(`Endereço: ${registro.endereco}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
}
|
||||
if (registro.cidade) {
|
||||
doc.text(`Cidade: ${registro.cidade}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
}
|
||||
if (registro.estado) {
|
||||
doc.text(`Estado: ${registro.estado}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
}
|
||||
doc.text(`Coordenadas: ${registro.latitude.toFixed(6)}, ${registro.longitude.toFixed(6)}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
if (registro.precisao) {
|
||||
doc.text(`Precisão: ${registro.precisao.toFixed(0)} metros`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
}
|
||||
yPosition += 5;
|
||||
}
|
||||
|
||||
// Informações do Dispositivo (resumido)
|
||||
if (registro.browser || registro.sistemaOperacional) {
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('DISPOSITIVO', 15, yPosition);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
yPosition += 8;
|
||||
doc.setFontSize(10);
|
||||
|
||||
if (registro.browser) {
|
||||
doc.text(`Navegador: ${registro.browser}${registro.browserVersion ? ` ${registro.browserVersion}` : ''}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
}
|
||||
if (registro.sistemaOperacional) {
|
||||
doc.text(`Sistema: ${registro.sistemaOperacional}${registro.osVersion ? ` ${registro.osVersion}` : ''}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
}
|
||||
if (registro.ipAddress) {
|
||||
doc.text(`IP: ${registro.ipAddress}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
}
|
||||
}
|
||||
|
||||
// Rodapé
|
||||
const pageCount = doc.getNumberOfPages();
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
doc.setPage(i);
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(128, 128, 128);
|
||||
doc.text(
|
||||
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||
doc.internal.pageSize.getWidth() / 2,
|
||||
doc.internal.pageSize.getHeight() - 10,
|
||||
{ align: 'center' }
|
||||
);
|
||||
}
|
||||
|
||||
// Salvar
|
||||
const nomeArquivo = `comprovante-ponto-${registro.data}-${registro.hora}${registro.minuto.toString().padStart(2, '0')}.pdf`;
|
||||
doc.save(nomeArquivo);
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PDF:', error);
|
||||
alert('Erro ao gerar comprovante PDF. Tente novamente.');
|
||||
} finally {
|
||||
gerando = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-lg">Comprovante de Registro de Ponto</h3>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" onclick={onClose}>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if registroQuery === undefined}
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else if !registroQuery?.data}
|
||||
<div class="alert alert-error">
|
||||
<span>Erro ao carregar registro</span>
|
||||
</div>
|
||||
{:else}
|
||||
{@const registro = registroQuery.data}
|
||||
<div class="space-y-4">
|
||||
<!-- Informações do Funcionário -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h4 class="font-bold">Dados do Funcionário</h4>
|
||||
{#if registro.funcionario}
|
||||
<p><strong>Matrícula:</strong> {registro.funcionario.matricula || 'N/A'}</p>
|
||||
<p><strong>Nome:</strong> {registro.funcionario.nome}</p>
|
||||
{#if registro.funcionario.descricaoCargo}
|
||||
<p><strong>Cargo/Função:</strong> {registro.funcionario.descricaoCargo}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informações do Registro -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h4 class="font-bold">Dados do Registro</h4>
|
||||
<p><strong>Tipo:</strong> {getTipoRegistroLabel(registro.tipo)}</p>
|
||||
<p>
|
||||
<strong>Data e Hora:</strong>
|
||||
{formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Status:</strong>
|
||||
<span class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}">
|
||||
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>Tolerância:</strong> {registro.toleranciaMinutos} minutos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-primary" onclick={gerarPDF} disabled={gerando}>
|
||||
{#if gerando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Printer class="h-5 w-5" />
|
||||
{/if}
|
||||
Imprimir Comprovante
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick={onClose}>Fechar</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="modal-backdrop" onclick={onClose}></div>
|
||||
</div>
|
||||
|
||||
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>
|
||||
|
||||
118
apps/web/src/lib/components/ponto/RelogioSincronizado.svelte
Normal file
118
apps/web/src/lib/components/ponto/RelogioSincronizado.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { obterTempoServidor, obterTempoPC } from '$lib/utils/sincronizacaoTempo';
|
||||
import { CheckCircle2, AlertCircle, Clock } from 'lucide-svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let tempoAtual = $state<Date>(new Date());
|
||||
let sincronizado = $state(false);
|
||||
let usandoServidorExterno = $state(false);
|
||||
let offsetSegundos = $state(0);
|
||||
let erro = $state<string | null>(null);
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function atualizarTempo() {
|
||||
try {
|
||||
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||
|
||||
if (config.usarServidorExterno) {
|
||||
try {
|
||||
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||
if (resultado.sucesso && resultado.timestamp) {
|
||||
tempoAtual = new Date(resultado.timestamp);
|
||||
sincronizado = true;
|
||||
usandoServidorExterno = resultado.usandoServidorExterno || false;
|
||||
offsetSegundos = resultado.offsetSegundos || 0;
|
||||
erro = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao sincronizar:', error);
|
||||
if (config.fallbackParaPC) {
|
||||
tempoAtual = new Date(obterTempoPC());
|
||||
sincronizado = false;
|
||||
usandoServidorExterno = false;
|
||||
erro = 'Usando relógio do PC (falha na sincronização)';
|
||||
} else {
|
||||
erro = 'Falha ao sincronizar tempo';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Usar tempo do servidor Convex
|
||||
const tempoServidor = await obterTempoServidor(client);
|
||||
tempoAtual = new Date(tempoServidor);
|
||||
sincronizado = true;
|
||||
usandoServidorExterno = false;
|
||||
erro = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao obter tempo:', error);
|
||||
tempoAtual = new Date(obterTempoPC());
|
||||
sincronizado = false;
|
||||
erro = 'Erro ao obter tempo do servidor';
|
||||
}
|
||||
}
|
||||
|
||||
function atualizarRelogio() {
|
||||
// Atualizar segundo a segundo
|
||||
const agora = new Date(tempoAtual.getTime() + 1000);
|
||||
tempoAtual = agora;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await atualizarTempo();
|
||||
// Sincronizar a cada 30 segundos
|
||||
setInterval(atualizarTempo, 30000);
|
||||
// Atualizar display a cada segundo
|
||||
intervalId = setInterval(atualizarRelogio, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
|
||||
const horaFormatada = $derived.by(() => {
|
||||
return tempoAtual.toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
});
|
||||
|
||||
const dataFormatada = $derived.by(() => {
|
||||
return tempoAtual.toLocaleDateString('pt-BR', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="text-4xl font-bold font-mono text-primary">{horaFormatada}</div>
|
||||
<div class="text-sm text-base-content/70 capitalize">{dataFormatada}</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
{#if sincronizado}
|
||||
<CheckCircle2 class="h-4 w-4 text-success" />
|
||||
<span class="text-success">
|
||||
{#if usandoServidorExterno}
|
||||
Sincronizado com servidor NTP
|
||||
{:else}
|
||||
Sincronizado com servidor
|
||||
{/if}
|
||||
</span>
|
||||
{:else if erro}
|
||||
<AlertCircle class="h-4 w-4 text-warning" />
|
||||
<span class="text-warning">{erro}</span>
|
||||
{:else}
|
||||
<Clock class="h-4 w-4 text-base-content/50" />
|
||||
<span class="text-base-content/50">Sincronizando...</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
199
apps/web/src/lib/components/ponto/WebcamCapture.svelte
Normal file
199
apps/web/src/lib/components/ponto/WebcamCapture.svelte
Normal file
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Camera, X, Check, AlertCircle } from 'lucide-svelte';
|
||||
import { validarWebcamDisponivel, capturarWebcamComPreview } from '$lib/utils/webcam';
|
||||
|
||||
interface Props {
|
||||
onCapture: (blob: Blob) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { onCapture, onCancel }: Props = $props();
|
||||
|
||||
let videoElement: HTMLVideoElement | null = $state(null);
|
||||
let canvasElement: HTMLCanvasElement | null = $state(null);
|
||||
let stream: MediaStream | null = $state(null);
|
||||
let webcamDisponivel = $state(false);
|
||||
let capturando = $state(false);
|
||||
let erro = $state<string | null>(null);
|
||||
let previewUrl = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
webcamDisponivel = await validarWebcamDisponivel();
|
||||
if (!webcamDisponivel) {
|
||||
erro = 'Webcam não disponível';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao acessar webcam:', error);
|
||||
erro = 'Erro ao acessar webcam. Verifique as permissões.';
|
||||
webcamDisponivel = false;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
async function capturar() {
|
||||
if (!videoElement || !canvasElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
capturando = true;
|
||||
erro = null;
|
||||
|
||||
try {
|
||||
const blob = await capturarWebcamComPreview(videoElement, canvasElement);
|
||||
if (blob) {
|
||||
previewUrl = URL.createObjectURL(blob);
|
||||
// Parar stream para mostrar preview
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
} else {
|
||||
erro = 'Falha ao capturar imagem';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao capturar:', error);
|
||||
erro = 'Erro ao capturar imagem';
|
||||
} finally {
|
||||
capturando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmar() {
|
||||
if (previewUrl) {
|
||||
// Converter preview URL de volta para blob
|
||||
fetch(previewUrl)
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
onCapture(blob);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Erro ao converter preview:', error);
|
||||
erro = 'Erro ao processar imagem';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function cancelar() {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
previewUrl = null;
|
||||
}
|
||||
onCancel();
|
||||
}
|
||||
|
||||
async function recapturar() {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
previewUrl = null;
|
||||
}
|
||||
// Reiniciar webcam
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao reiniciar webcam:', error);
|
||||
erro = 'Erro ao reiniciar webcam';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 p-6">
|
||||
{#if !webcamDisponivel && !erro}
|
||||
<div class="flex items-center gap-2 text-warning">
|
||||
<Camera class="h-5 w-5" />
|
||||
<span>Verificando webcam...</span>
|
||||
</div>
|
||||
{:else if erro && !webcamDisponivel}
|
||||
<div class="alert alert-warning">
|
||||
<AlertCircle class="h-5 w-5" />
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||
{: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>
|
||||
{:else}
|
||||
<!-- Webcam ativa -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="relative">
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
autoplay
|
||||
playsinline
|
||||
class="rounded-lg border-2 border-primary max-w-full max-h-96"
|
||||
></video>
|
||||
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
||||
</div>
|
||||
{#if erro}
|
||||
<div class="alert alert-error">
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
68
apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte
Normal file
68
apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte
Normal file
@@ -0,0 +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 { 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-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>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<div class="text-2xl font-bold">{estatisticas.dentroDoPrazo}</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user