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

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>

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

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

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