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>

View File

@@ -0,0 +1,397 @@
import { getLocalIP } from './browserInfo';
export interface InformacoesDispositivo {
ipAddress?: string;
ipPublico?: string;
ipLocal?: string;
userAgent?: string;
browser?: string;
browserVersion?: string;
engine?: string;
sistemaOperacional?: string;
osVersion?: string;
arquitetura?: string;
plataforma?: string;
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
timezone?: string;
deviceType?: string;
deviceModel?: string;
screenResolution?: string;
coresTela?: number;
idioma?: string;
isMobile?: boolean;
isTablet?: boolean;
isDesktop?: boolean;
connectionType?: string;
memoryInfo?: string;
}
/**
* Detecta informações do navegador
*/
function detectarNavegador(): { browser: string; browserVersion: string; engine: string } {
if (typeof navigator === 'undefined') {
return { browser: 'Desconhecido', browserVersion: '', engine: '' };
}
const ua = navigator.userAgent;
let browser = 'Desconhecido';
let browserVersion = '';
let engine = '';
// Detectar engine
if (ua.includes('Edg/')) {
engine = 'EdgeHTML';
} else if (ua.includes('Chrome/')) {
engine = 'Blink';
} else if (ua.includes('Firefox/')) {
engine = 'Gecko';
} else if (ua.includes('Safari/') && !ua.includes('Chrome/')) {
engine = 'WebKit';
}
// Detectar navegador
if (ua.includes('Edg/')) {
browser = 'Edge';
const match = ua.match(/Edg\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Chrome/') && !ua.includes('Edg/')) {
browser = 'Chrome';
const match = ua.match(/Chrome\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Firefox/')) {
browser = 'Firefox';
const match = ua.match(/Firefox\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Safari/') && !ua.includes('Chrome/')) {
browser = 'Safari';
const match = ua.match(/Version\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Opera/') || ua.includes('OPR/')) {
browser = 'Opera';
const match = ua.match(/(?:Opera|OPR)\/(\d+)/);
browserVersion = match ? match[1]! : '';
}
return { browser, browserVersion, engine };
}
/**
* Detecta informações do sistema operacional
*/
function detectarSistemaOperacional(): {
sistemaOperacional: string;
osVersion: string;
arquitetura: string;
plataforma: string;
} {
if (typeof navigator === 'undefined') {
return {
sistemaOperacional: 'Desconhecido',
osVersion: '',
arquitetura: '',
plataforma: '',
};
}
const ua = navigator.userAgent;
const platform = navigator.platform || '';
let sistemaOperacional = 'Desconhecido';
let osVersion = '';
let arquitetura = '';
const plataforma = platform;
// Detectar OS
if (ua.includes('Windows NT')) {
sistemaOperacional = 'Windows';
const match = ua.match(/Windows NT (\d+\.\d+)/);
if (match) {
const version = match[1]!;
const versions: Record<string, string> = {
'10.0': '10/11',
'6.3': '8.1',
'6.2': '8',
'6.1': '7',
};
osVersion = versions[version] || version;
}
} else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) {
sistemaOperacional = 'macOS';
const match = ua.match(/Mac OS X (\d+[._]\d+)/);
if (match) {
osVersion = match[1]!.replace('_', '.');
}
} else if (ua.includes('Linux')) {
sistemaOperacional = 'Linux';
osVersion = 'Linux';
} else if (ua.includes('Android')) {
sistemaOperacional = 'Android';
const match = ua.match(/Android (\d+(?:\.\d+)?)/);
osVersion = match ? match[1]! : '';
} else if (ua.includes('iPhone') || ua.includes('iPad')) {
sistemaOperacional = 'iOS';
const match = ua.match(/OS (\d+[._]\d+)/);
if (match) {
osVersion = match[1]!.replace('_', '.');
}
}
// Detectar arquitetura (se disponível)
if ('cpuClass' in navigator) {
arquitetura = (navigator as unknown as { cpuClass: string }).cpuClass;
}
return { sistemaOperacional, osVersion, arquitetura, plataforma };
}
/**
* Detecta tipo de dispositivo
*/
function detectarTipoDispositivo(): {
deviceType: string;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
} {
if (typeof navigator === 'undefined') {
return {
deviceType: 'Desconhecido',
isMobile: false,
isTablet: false,
isDesktop: true,
};
}
const ua = navigator.userAgent;
const isMobile = /Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
const isTablet = /iPad|Android(?!.*Mobile)|Tablet/i.test(ua);
const isDesktop = !isMobile && !isTablet;
let deviceType = 'Desktop';
if (isTablet) {
deviceType = 'Tablet';
} else if (isMobile) {
deviceType = 'Mobile';
}
return { deviceType, isMobile, isTablet, isDesktop };
}
/**
* Obtém informações da tela
*/
function obterInformacoesTela(): { screenResolution: string; coresTela: number } {
if (typeof screen === 'undefined') {
return { screenResolution: 'Desconhecido', coresTela: 0 };
}
const screenResolution = `${screen.width}x${screen.height}`;
const coresTela = screen.colorDepth || 24;
return { screenResolution, coresTela };
}
/**
* Obtém informações de conexão
*/
async function obterInformacoesConexao(): Promise<string> {
if (typeof navigator === 'undefined' || !('connection' in navigator)) {
return 'Desconhecido';
}
const connection = (navigator as unknown as { connection?: { effectiveType?: string } }).connection;
if (connection?.effectiveType) {
return connection.effectiveType;
}
return 'Desconhecido';
}
/**
* Obtém informações de memória (se disponível)
*/
function obterInformacoesMemoria(): string {
if (typeof navigator === 'undefined' || !('deviceMemory' in navigator)) {
return 'Desconhecido';
}
const deviceMemory = (navigator as unknown as { deviceMemory?: number }).deviceMemory;
if (deviceMemory) {
return `${deviceMemory} GB`;
}
return 'Desconhecido';
}
/**
* Obtém localização via GPS
*/
async function obterLocalizacao(): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
}> {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
return {};
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
resolve({});
}, 10000); // Timeout de 10 segundos
navigator.geolocation.getCurrentPosition(
async (position) => {
clearTimeout(timeout);
const { latitude, longitude, accuracy } = position.coords;
// Tentar obter endereço via reverse geocoding
let endereco = '';
let cidade = '';
let estado = '';
let pais = '';
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`
);
if (response.ok) {
const data = (await response.json()) as {
address?: {
road?: string;
house_number?: string;
city?: string;
town?: string;
state?: string;
country?: string;
};
};
if (data.address) {
const addr = data.address;
if (addr.road) {
endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`;
}
cidade = addr.city || addr.town || '';
estado = addr.state || '';
pais = addr.country || '';
}
}
} catch (error) {
console.warn('Erro ao obter endereço:', error);
}
resolve({
latitude,
longitude,
precisao: accuracy,
endereco,
cidade,
estado,
pais,
});
},
(error) => {
clearTimeout(timeout);
console.warn('Erro ao obter localização:', error);
resolve({});
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
}
);
});
}
/**
* Obtém IP público
*/
async function obterIPPublico(): Promise<string | undefined> {
try {
const response = await fetch('https://api.ipify.org?format=json');
if (response.ok) {
const data = (await response.json()) as { ip: string };
return data.ip;
}
} catch (error) {
console.warn('Erro ao obter IP público:', error);
}
return undefined;
}
/**
* Obtém todas as informações do dispositivo
*/
export async function obterInformacoesDispositivo(): Promise<InformacoesDispositivo> {
const informacoes: InformacoesDispositivo = {};
// Informações básicas
if (typeof navigator !== 'undefined') {
informacoes.userAgent = navigator.userAgent;
informacoes.idioma = navigator.language || navigator.languages?.[0];
informacoes.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// Informações do navegador
const navegador = detectarNavegador();
informacoes.browser = navegador.browser;
informacoes.browserVersion = navegador.browserVersion;
informacoes.engine = navegador.engine;
// Informações do sistema
const sistema = detectarSistemaOperacional();
informacoes.sistemaOperacional = sistema.sistemaOperacional;
informacoes.osVersion = sistema.osVersion;
informacoes.arquitetura = sistema.arquitetura;
informacoes.plataforma = sistema.plataforma;
// Tipo de dispositivo
const dispositivo = detectarTipoDispositivo();
informacoes.deviceType = dispositivo.deviceType;
informacoes.isMobile = dispositivo.isMobile;
informacoes.isTablet = dispositivo.isTablet;
informacoes.isDesktop = dispositivo.isDesktop;
// Informações da tela
const tela = obterInformacoesTela();
informacoes.screenResolution = tela.screenResolution;
informacoes.coresTela = tela.coresTela;
// Informações de conexão e memória (assíncronas)
const [connectionType, memoryInfo, ipPublico, ipLocal, localizacao] = await Promise.all([
obterInformacoesConexao(),
Promise.resolve(obterInformacoesMemoria()),
obterIPPublico(),
getLocalIP(),
obterLocalizacao(),
]);
informacoes.connectionType = connectionType;
informacoes.memoryInfo = memoryInfo;
informacoes.ipPublico = ipPublico;
informacoes.ipLocal = ipLocal;
informacoes.latitude = localizacao.latitude;
informacoes.longitude = localizacao.longitude;
informacoes.precisao = localizacao.precisao;
informacoes.endereco = localizacao.endereco;
informacoes.cidade = localizacao.cidade;
informacoes.estado = localizacao.estado;
informacoes.pais = localizacao.pais;
// IP address (usar público se disponível, senão local)
informacoes.ipAddress = ipPublico || ipLocal;
return informacoes;
}

View File

@@ -0,0 +1,103 @@
/**
* Formata hora no formato HH:mm
*/
export function formatarHoraPonto(hora: number, minuto: number): string {
return `${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`;
}
/**
* Formata data e hora completa
*/
export function formatarDataHoraCompleta(
data: string,
hora: number,
minuto: number,
segundo: number
): string {
const dataObj = new Date(`${data}T${formatarHoraPonto(hora, minuto)}:${segundo.toString().padStart(2, '0')}`);
return dataObj.toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
/**
* Calcula tempo trabalhado entre dois registros
*/
export function calcularTempoTrabalhado(
horaInicio: number,
minutoInicio: number,
horaFim: number,
minutoFim: number
): { horas: number; minutos: number } {
const minutosInicio = horaInicio * 60 + minutoInicio;
const minutosFim = horaFim * 60 + minutoFim;
const diferencaMinutos = minutosFim - minutosInicio;
if (diferencaMinutos < 0) {
return { horas: 0, minutos: 0 };
}
const horas = Math.floor(diferencaMinutos / 60);
const minutos = diferencaMinutos % 60;
return { horas, minutos };
}
/**
* Verifica se está dentro do prazo baseado na configuração
*/
export function verificarDentroDoPrazo(
hora: number,
minuto: number,
horarioConfigurado: string,
toleranciaMinutos: number
): boolean {
const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number);
const totalMinutosRegistro = hora * 60 + minuto;
const totalMinutosConfigurado = horaConfig * 60 + minutoConfig;
const diferenca = totalMinutosRegistro - totalMinutosConfigurado;
return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos;
}
/**
* Obtém label do tipo de registro
*/
export function getTipoRegistroLabel(tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'): string {
const labels: Record<string, string> = {
entrada: 'Entrada',
saida_almoco: 'Saída para Almoço',
retorno_almoco: 'Retorno do Almoço',
saida: 'Saída',
};
return labels[tipo] || tipo;
}
/**
* Obtém próximo tipo de registro esperado
*/
export function getProximoTipoRegistro(
ultimoTipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' | null
): 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' {
if (!ultimoTipo) {
return 'entrada';
}
switch (ultimoTipo) {
case 'entrada':
return 'saida_almoco';
case 'saida_almoco':
return 'retorno_almoco';
case 'retorno_almoco':
return 'saida';
case 'saida':
return 'entrada'; // Novo dia
default:
return 'entrada';
}
}

View File

@@ -0,0 +1,56 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { ConvexClient } from 'convex/browser';
/**
* Obtém tempo do servidor (sincronizado)
*/
export async function obterTempoServidor(client: ConvexClient): Promise<number> {
try {
// Tentar obter configuração e sincronizar se necessário
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) {
return resultado.timestamp;
}
} catch (error) {
console.warn('Erro ao sincronizar com servidor externo:', error);
if (config.fallbackParaPC) {
return Date.now();
}
throw error;
}
}
// Usar tempo do servidor Convex
const tempoServidor = await client.query(api.configuracaoRelogio.obterTempoServidor, {});
return tempoServidor.timestamp;
} catch (error) {
console.warn('Erro ao obter tempo do servidor, usando tempo local:', error);
return Date.now();
}
}
/**
* Obtém tempo do PC (fallback)
*/
export function obterTempoPC(): number {
return Date.now();
}
/**
* Calcula offset entre dois timestamps
*/
export function calcularOffset(timestampServidor: number, timestampLocal: number): number {
return timestampServidor - timestampLocal;
}
/**
* Aplica offset a um timestamp
*/
export function aplicarOffset(timestamp: number, offsetSegundos: number): number {
return timestamp + offsetSegundos * 1000;
}

View File

@@ -0,0 +1,150 @@
/**
* Verifica se webcam está disponível
*/
export async function validarWebcamDisponivel(): Promise<boolean> {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return false;
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some((device) => device.kind === 'videoinput');
} catch {
return false;
}
}
/**
* Captura imagem da webcam
*/
export async function capturarWebcam(): Promise<Blob | null> {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return null;
}
let stream: MediaStream | null = null;
try {
// Solicitar acesso à webcam
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user',
},
});
// Criar elemento de vídeo temporário
const video = document.createElement('video');
video.srcObject = stream;
video.play();
// Aguardar vídeo estar pronto
await new Promise<void>((resolve, reject) => {
video.onloadedmetadata = () => {
video.width = video.videoWidth;
video.height = video.videoHeight;
resolve();
};
video.onerror = reject;
setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000);
});
// Capturar frame
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Não foi possível obter contexto do canvas');
}
ctx.drawImage(video, 0, 0);
// Converter para blob
return await new Promise<Blob | null>((resolve) => {
canvas.toBlob(
(blob) => {
resolve(blob);
},
'image/jpeg',
0.9
);
});
} catch (error) {
console.error('Erro ao capturar webcam:', error);
return null;
} finally {
// Parar stream
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
}
}
/**
* Captura imagem da webcam com preview
*/
export async function capturarWebcamComPreview(
videoElement: HTMLVideoElement,
canvasElement: HTMLCanvasElement
): Promise<Blob | null> {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return null;
}
let stream: MediaStream | null = null;
try {
// Solicitar acesso à webcam
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user',
},
});
videoElement.srcObject = stream;
await videoElement.play();
// Aguardar vídeo estar pronto
await new Promise<void>((resolve, reject) => {
videoElement.onloadedmetadata = () => {
canvasElement.width = videoElement.videoWidth;
canvasElement.height = videoElement.videoHeight;
resolve();
};
videoElement.onerror = reject;
setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000);
});
// Capturar frame
const ctx = canvasElement.getContext('2d');
if (!ctx) {
throw new Error('Não foi possível obter contexto do canvas');
}
ctx.drawImage(videoElement, 0, 0);
// Converter para blob
return await new Promise<Blob | null>((resolve) => {
canvasElement.toBlob(
(blob) => {
resolve(blob);
},
'image/jpeg',
0.9
);
});
} catch (error) {
console.error('Erro ao capturar webcam:', error);
return null;
} finally {
// Parar stream
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
}
}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import WidgetGestaoPontos from '$lib/components/ponto/WidgetGestaoPontos.svelte';
const menuItems = [
{
categoria: "Gestão de Ausências",
@@ -115,6 +116,11 @@
{/each}
</div>
<!-- Widget Gestão de Pontos -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
<WidgetGestaoPontos />
</div>
<!-- Card de Ajuda -->
<div class="alert alert-info shadow-lg mt-8">
<svg

View File

@@ -11,7 +11,28 @@
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { FunctionReturnType } from 'convex/server';
import { X, Calendar, Users, Clock, CheckCircle2, Eye, FileCheck, CalendarDays, User, Mail, Shield, Briefcase, Hash, CreditCard, Building2, CheckCircle, ListChecks, Info } from 'lucide-svelte';
import {
X,
Calendar,
Users,
Clock,
CheckCircle2,
Eye,
FileCheck,
CalendarDays,
User,
Mail,
Shield,
Briefcase,
Hash,
CreditCard,
Building2,
CheckCircle,
ListChecks,
Info,
Fingerprint
} from 'lucide-svelte';
import RegistroPonto from '$lib/components/ponto/RegistroPonto.svelte';
import TicketCard from '$lib/components/chamados/TicketCard.svelte';
import TicketTimeline from '$lib/components/chamados/TicketTimeline.svelte';
import { chamadosStore } from '$lib/stores/chamados';
@@ -45,6 +66,7 @@
| 'minhas-ausencias'
| 'aprovar-ferias'
| 'aprovar-ausencias'
| 'meu-ponto'
>('meu-perfil');
let periodoSelecionado = $state<Id<'ferias'> | null>(null);
@@ -216,11 +238,7 @@
if (!selectedTicketId && chamadosQuery.data.length > 0) {
selectedTicketId = chamadosQuery.data[0]._id;
}
} else if (
chamadosQuery !== undefined &&
chamadosQuery?.data === null &&
!gestorIdDisponivel
) {
} else if (chamadosQuery !== undefined && chamadosQuery?.data === null && !gestorIdDisponivel) {
chamadosEstaveis = [];
chamadosStore.setTickets([]);
}
@@ -703,12 +721,12 @@
<!-- Tabs PREMIUM -->
<div
role="tablist"
class="tabs tabs-boxed from-base-200 to-base-300 mb-8 bg-gradient-to-r p-2 shadow-xl rounded-xl border border-base-300"
class="tabs tabs-boxed from-base-200 to-base-300 border-base-300 mb-8 rounded-xl border bg-gradient-to-r p-2 shadow-xl"
>
<button
type="button"
role="tab"
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'meu-perfil' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'meu-perfil' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
onclick={() => (abaAtiva = 'meu-perfil')}
>
<User class="h-5 w-5" strokeWidth={2} />
@@ -718,7 +736,7 @@
<button
type="button"
role="tab"
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'meus-chamados' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'meus-chamados' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
onclick={() => (abaAtiva = 'meus-chamados')}
aria-label="Meus Chamados"
>
@@ -730,7 +748,7 @@
<button
type="button"
role="tab"
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'minhas-ferias' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'minhas-ferias' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
onclick={() => (abaAtiva = 'minhas-ferias')}
>
<Calendar class="h-5 w-5" strokeWidth={2} />
@@ -740,7 +758,7 @@
<button
type="button"
role="tab"
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'minhas-ausencias' ? 'tab-active scale-105 bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'minhas-ausencias' ? 'tab-active scale-105 bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
onclick={() => (abaAtiva = 'minhas-ausencias')}
>
<Clock class="h-5 w-5" strokeWidth={2} />
@@ -751,7 +769,7 @@
<button
type="button"
role="tab"
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'aprovar-ferias' ? 'tab-active scale-105 bg-gradient-to-r from-green-600 to-emerald-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ferias' ? 'tab-active scale-105 bg-gradient-to-r from-green-600 to-emerald-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
onclick={() => (abaAtiva = 'aprovar-ferias')}
>
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
@@ -768,7 +786,7 @@
<button
type="button"
role="tab"
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'aprovar-ausencias' ? 'tab-active scale-105 bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ausencias' ? 'tab-active scale-105 bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
onclick={() => (abaAtiva = 'aprovar-ausencias')}
>
<Clock class="h-5 w-5" strokeWidth={2} />
@@ -782,6 +800,16 @@
</button>
{/if}
{/if}
<button
type="button"
role="tab"
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'meu-ponto' ? 'tab-active scale-105 bg-gradient-to-r from-blue-600 to-cyan-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
onclick={() => (abaAtiva = 'meu-ponto')}
>
<Fingerprint class="h-5 w-5" strokeWidth={2} />
Meu Ponto
</button>
</div>
<!-- Conteúdo das Abas -->
@@ -915,19 +943,21 @@
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<!-- Informações Pessoais PREMIUM -->
<div
class="card bg-gradient-to-br from-base-100 to-base-200 hover:shadow-3xl border-t-4 border-purple-500 shadow-2xl transition-shadow overflow-hidden"
class="card from-base-100 to-base-200 hover:shadow-3xl overflow-hidden border-t-4 border-purple-500 bg-gradient-to-br shadow-2xl transition-shadow"
>
<div class="card-body p-6">
<div class="flex items-center justify-between mb-6">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 shadow-lg ring-2 ring-purple-500/20">
<div
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 shadow-lg ring-2 ring-purple-500/20"
>
<User class="h-6 w-6 text-white" strokeWidth={2.5} />
</div>
<div>
<h2 class="text-2xl font-bold text-base-content flex items-center gap-2">
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
Informações Pessoais
</h2>
<p class="text-sm text-base-content/60 mt-0.5">
<p class="text-base-content/60 mt-0.5 text-sm">
Seus dados pessoais e de acesso
</p>
</div>
@@ -935,45 +965,54 @@
</div>
<div class="space-y-3">
<div
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500/20 to-purple-600/20">
<User class="text-purple-600 h-5 w-5" strokeWidth={2} />
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500/20 to-purple-600/20"
>
<User class="h-5 w-5 text-purple-600" strokeWidth={2} />
</div>
<div class="flex-1">
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide"
<span
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Nome Completo</span
>
<p class="text-base-content text-base font-semibold mt-1">
<p class="text-base-content mt-1 text-base font-semibold">
{currentUser.data?.nome}
</p>
</div>
</div>
<div
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/20">
<Mail class="text-blue-600 h-5 w-5" strokeWidth={2} />
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/20"
>
<Mail class="h-5 w-5 text-blue-600" strokeWidth={2} />
</div>
<div class="flex-1">
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide"
<span
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>E-mail Institucional</span
>
<p class="text-base-content text-base font-semibold break-all mt-1">
<p class="text-base-content mt-1 text-base font-semibold break-all">
{currentUser.data?.email}
</p>
</div>
</div>
<div
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary/20 to-primary/30">
<div
class="from-primary/20 to-primary/30 flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br"
>
<Shield class="text-primary h-5 w-5" strokeWidth={2} />
</div>
<div class="flex-1">
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide"
<span
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Perfil de Acesso</span
>
<div class="badge badge-primary badge-lg mt-2 font-bold shadow-sm">
@@ -988,19 +1027,21 @@
<!-- Dados Funcionais PREMIUM -->
{#if funcionario}
<div
class="card bg-gradient-to-br from-base-100 to-base-200 hover:shadow-3xl border-t-4 border-blue-500 shadow-2xl transition-shadow overflow-hidden"
class="card from-base-100 to-base-200 hover:shadow-3xl overflow-hidden border-t-4 border-blue-500 bg-gradient-to-br shadow-2xl transition-shadow"
>
<div class="card-body p-6">
<div class="flex items-center justify-between mb-6">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg ring-2 ring-blue-500/20">
<div
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg ring-2 ring-blue-500/20"
>
<Briefcase class="h-6 w-6 text-white" strokeWidth={2.5} />
</div>
<div>
<h2 class="text-2xl font-bold text-base-content flex items-center gap-2">
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
Dados Funcionais
</h2>
<p class="text-sm text-base-content/60 mt-0.5">
<p class="text-base-content/60 mt-0.5 text-sm">
Informações profissionais e organizacionais
</p>
</div>
@@ -1008,43 +1049,56 @@
</div>
<div class="space-y-3">
<div
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/20">
<Hash class="text-blue-600 h-5 w-5" strokeWidth={2} />
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/20"
>
<Hash class="h-5 w-5 text-blue-600" strokeWidth={2} />
</div>
<div class="flex-1">
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide"
<span
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Matrícula</span
>
<p class="text-base-content text-base font-semibold mt-1">
<p class="text-base-content mt-1 text-base font-semibold">
{funcionario.matricula || 'Não informada'}
</p>
</div>
</div>
<div
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-green-500/20 to-green-600/20">
<CreditCard class="text-green-600 h-5 w-5" strokeWidth={2} />
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-green-500/20 to-green-600/20"
>
<CreditCard class="h-5 w-5 text-green-600" strokeWidth={2} />
</div>
<div class="flex-1">
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide">CPF</span>
<p class="text-base-content text-base font-semibold mt-1">
<span
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>CPF</span
>
<p class="text-base-content mt-1 text-base font-semibold">
{funcionario.cpf}
</p>
</div>
</div>
<div
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500/20 to-orange-600/20">
<Building2 class="text-orange-600 h-5 w-5" strokeWidth={2} />
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500/20 to-orange-600/20"
>
<Building2 class="h-5 w-5 text-orange-600" strokeWidth={2} />
</div>
<div class="flex-1">
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide">Time</span>
<span
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Time</span
>
{#if meuTime}
<div class="mt-2">
<div
@@ -1064,13 +1118,16 @@
</div>
<div
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
>
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-success/20 to-success/30">
<div
class="from-success/20 to-success/30 flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br"
>
<CheckCircle class="text-success h-5 w-5" strokeWidth={2} />
</div>
<div class="flex-1">
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide"
<span
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Status Atual</span
>
{#if funcionario.statusFerias === 'em_ferias'}
@@ -1078,7 +1135,9 @@
🏖️ Em Férias
</div>
{:else}
<div class="badge badge-success badge-lg mt-2 font-bold shadow-sm">✅ Ativo</div>
<div class="badge badge-success badge-lg mt-2 font-bold shadow-sm">
✅ Ativo
</div>
{/if}
</div>
</div>
@@ -1091,12 +1150,10 @@
<!-- Times Gerenciados PREMIUM -->
{#if ehGestor}
<div
class="card border-t-4 border-warning bg-gradient-to-br from-warning/10 via-base-100 to-warning/5 shadow-2xl"
class="card border-warning from-warning/10 via-base-100 to-warning/5 border-t-4 bg-gradient-to-br shadow-2xl"
>
<div class="card-body">
<h2
class="card-title mb-6 flex items-center gap-2 text-2xl text-warning"
>
<h2 class="card-title text-warning mb-6 flex items-center gap-2 text-2xl">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-7 w-7"
@@ -1609,18 +1666,22 @@
</div>
<!-- Lista de Solicitações -->
<div class="card bg-gradient-to-br from-base-100 to-base-200 border-t-4 border-primary shadow-2xl overflow-hidden">
<div
class="card from-base-100 to-base-200 border-primary overflow-hidden border-t-4 bg-gradient-to-br shadow-2xl"
>
<div class="card-body p-6">
<div class="flex items-center justify-between mb-6">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-primary/80 shadow-lg ring-2 ring-primary/20">
<div
class="from-primary to-primary/80 ring-primary/20 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br shadow-lg ring-2"
>
<ListChecks class="h-6 w-6 text-white" strokeWidth={2.5} />
</div>
<div>
<h2 class="text-2xl font-bold text-base-content flex items-center gap-2">
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
Minhas Solicitações
</h2>
<p class="text-sm text-base-content/60 mt-0.5">
<p class="text-base-content/60 mt-0.5 text-sm">
Histórico de solicitações de férias
</p>
</div>
@@ -1632,32 +1693,42 @@
</div>
{#if solicitacoesFiltradas.length === 0}
<div class="alert alert-info shadow-lg border border-info/20">
<div class="alert alert-info border-info/20 border shadow-lg">
<Info class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
<span class="font-semibold">Nenhuma solicitação encontrada com os filtros aplicados.</span>
<span class="font-semibold"
>Nenhuma solicitação encontrada com os filtros aplicados.</span
>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-base-300 shadow-inner">
<table class="table table-zebra">
<div class="border-base-300 overflow-x-auto rounded-lg border shadow-inner">
<table class="table-zebra table">
<thead>
<tr class="bg-gradient-to-r from-base-200 to-base-300">
<th class="font-bold text-base-content">Ano</th>
<th class="font-bold text-base-content">Período</th>
<th class="font-bold text-base-content">Dias</th>
<th class="font-bold text-base-content">Status</th>
<th class="font-bold text-base-content">Solicitado em</th>
<tr class="from-base-200 to-base-300 bg-gradient-to-r">
<th class="text-base-content font-bold">Ano</th>
<th class="text-base-content font-bold">Período</th>
<th class="text-base-content font-bold">Dias</th>
<th class="text-base-content font-bold">Status</th>
<th class="text-base-content font-bold">Solicitado em</th>
</tr>
</thead>
<tbody>
{#each solicitacoesFiltradas as periodo (periodo._id)}
<tr class="hover:bg-base-200/50 transition-all duration-200 border-b border-base-300">
<td class="font-semibold text-base-content/80">{periodo.anoReferencia}</td>
<td class="font-medium text-base-content/70">
<tr
class="hover:bg-base-200/50 border-base-300 border-b transition-all duration-200"
>
<td class="text-base-content/80 font-semibold">{periodo.anoReferencia}</td
>
<td class="text-base-content/70 font-medium">
<div class="flex items-center gap-1.5">
<CalendarDays class="h-3.5 w-3.5 text-base-content/50" strokeWidth={2} />
<span>{formatarDataString(periodo.dataInicio)} - {formatarDataString(
periodo.dataFim
)}</span>
<CalendarDays
class="text-base-content/50 h-3.5 w-3.5"
strokeWidth={2}
/>
<span
>{formatarDataString(periodo.dataInicio)} - {formatarDataString(
periodo.dataFim
)}</span
>
</div>
</td>
<td>
@@ -1666,11 +1737,13 @@
</div>
</td>
<td>
<div class={`badge badge-sm font-semibold shadow-sm ${getStatusBadge(periodo.status)}`}>
<div
class={`badge badge-sm font-semibold shadow-sm ${getStatusBadge(periodo.status)}`}
>
{getStatusTexto(periodo.status)}
</div>
</td>
<td class="text-xs text-base-content/60 font-medium"
<td class="text-base-content/60 text-xs font-medium"
>{new Date(periodo._creationTime).toLocaleDateString('pt-BR')}</td
>
</tr>
@@ -1973,18 +2046,22 @@
</div>
{:else if abaAtiva === 'aprovar-ferias'}
<!-- Aprovar Férias (Gestores) PREMIUM -->
<div class="card bg-gradient-to-br from-base-100 to-base-200 border-t-4 border-green-500 shadow-2xl overflow-hidden">
<div
class="card from-base-100 to-base-200 overflow-hidden border-t-4 border-green-500 bg-gradient-to-br shadow-2xl"
>
<div class="card-body p-6">
<div class="flex items-center justify-between mb-6">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-green-600 shadow-lg ring-2 ring-green-500/20">
<div
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-green-600 shadow-lg ring-2 ring-green-500/20"
>
<Users class="h-6 w-6 text-white" strokeWidth={2.5} />
</div>
<div>
<h2 class="text-2xl font-bold text-base-content flex items-center gap-2">
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
Solicitações da Equipe
</h2>
<p class="text-sm text-base-content/60 mt-0.5">
<p class="text-base-content/60 mt-0.5 text-sm">
Gerencie as solicitações de férias da sua equipe
</p>
</div>
@@ -1996,29 +2073,31 @@
</div>
{#if solicitacoesSubordinados.length === 0}
<div class="alert alert-success shadow-lg border border-success/20">
<div class="alert alert-success border-success/20 border shadow-lg">
<CheckCircle2 class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
<span class="font-semibold">Nenhuma solicitação pendente no momento.</span>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-base-300 shadow-inner">
<table class="table table-zebra">
<div class="border-base-300 overflow-x-auto rounded-lg border shadow-inner">
<table class="table-zebra table">
<thead>
<tr class="bg-gradient-to-r from-base-200 to-base-300">
<th class="font-bold text-base-content">Funcionário</th>
<th class="font-bold text-base-content">Time</th>
<th class="font-bold text-base-content">Ano</th>
<th class="font-bold text-base-content">Período</th>
<th class="font-bold text-base-content">Dias</th>
<th class="font-bold text-base-content">Status</th>
<th class="font-bold text-base-content text-center">Ações</th>
<tr class="from-base-200 to-base-300 bg-gradient-to-r">
<th class="text-base-content font-bold">Funcionário</th>
<th class="text-base-content font-bold">Time</th>
<th class="text-base-content font-bold">Ano</th>
<th class="text-base-content font-bold">Período</th>
<th class="text-base-content font-bold">Dias</th>
<th class="text-base-content font-bold">Status</th>
<th class="text-base-content text-center font-bold">Ações</th>
</tr>
</thead>
<tbody>
{#each solicitacoesSubordinados as periodo (periodo._id)}
<tr class="hover:bg-base-200/50 transition-all duration-200 border-b border-base-300">
<tr
class="hover:bg-base-200/50 border-base-300 border-b transition-all duration-200"
>
<td>
<div class="font-semibold text-base-content">
<div class="text-base-content font-semibold">
{periodo.funcionario?.nome}
</div>
</td>
@@ -2033,13 +2112,18 @@
</div>
{/if}
</td>
<td class="font-semibold text-base-content/80">{periodo.anoReferencia}</td>
<td class="font-medium text-base-content/70">
<td class="text-base-content/80 font-semibold">{periodo.anoReferencia}</td>
<td class="text-base-content/70 font-medium">
<div class="flex items-center gap-1.5">
<CalendarDays class="h-3.5 w-3.5 text-base-content/50" strokeWidth={2} />
<span>{formatarDataString(periodo.dataInicio)} - {formatarDataString(
periodo.dataFim
)}</span>
<CalendarDays
class="text-base-content/50 h-3.5 w-3.5"
strokeWidth={2}
/>
<span
>{formatarDataString(periodo.dataInicio)} - {formatarDataString(
periodo.dataFim
)}</span
>
</div>
</td>
<td>
@@ -2068,7 +2152,7 @@
{:else}
<button
type="button"
class="btn btn-ghost btn-sm gap-2 hover:bg-base-300 transition-all duration-200"
class="btn btn-ghost btn-sm hover:bg-base-300 gap-2 transition-all duration-200"
onclick={() => selecionarPeriodo(periodo._id)}
>
<Eye class="h-4 w-4" strokeWidth={2} />
@@ -2087,18 +2171,22 @@
</div>
{:else if abaAtiva === 'aprovar-ausencias'}
<!-- Aprovar Ausências (Gestores) -->
<div class="card bg-gradient-to-br from-base-100 to-base-200 border-t-4 border-orange-500 shadow-2xl overflow-hidden">
<div
class="card from-base-100 to-base-200 overflow-hidden border-t-4 border-orange-500 bg-gradient-to-br shadow-2xl"
>
<div class="card-body p-6">
<div class="flex items-center justify-between mb-6">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 shadow-lg ring-2 ring-orange-500/20">
<div
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 shadow-lg ring-2 ring-orange-500/20"
>
<Clock class="h-6 w-6 text-white" strokeWidth={2.5} />
</div>
<div>
<h2 class="text-2xl font-bold text-base-content flex items-center gap-2">
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
Solicitações de Ausências da Equipe
</h2>
<p class="text-sm text-base-content/60 mt-0.5">
<p class="text-base-content/60 mt-0.5 text-sm">
Gerencie as solicitações de ausências da sua equipe
</p>
</div>
@@ -2110,28 +2198,30 @@
</div>
{#if ausenciasSubordinados.length === 0}
<div class="alert alert-success shadow-lg border border-success/20">
<div class="alert alert-success border-success/20 border shadow-lg">
<CheckCircle2 class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
<span class="font-semibold">Nenhuma solicitação pendente no momento.</span>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-base-300 shadow-inner">
<table class="table table-zebra">
<div class="border-base-300 overflow-x-auto rounded-lg border shadow-inner">
<table class="table-zebra table">
<thead>
<tr class="bg-gradient-to-r from-base-200 to-base-300">
<th class="font-bold text-base-content">Funcionário</th>
<th class="font-bold text-base-content">Time</th>
<th class="font-bold text-base-content">Período</th>
<th class="font-bold text-base-content">Dias</th>
<th class="font-bold text-base-content">Status</th>
<th class="font-bold text-base-content text-center">Ações</th>
<tr class="from-base-200 to-base-300 bg-gradient-to-r">
<th class="text-base-content font-bold">Funcionário</th>
<th class="text-base-content font-bold">Time</th>
<th class="text-base-content font-bold">Período</th>
<th class="text-base-content font-bold">Dias</th>
<th class="text-base-content font-bold">Status</th>
<th class="text-base-content text-center font-bold">Ações</th>
</tr>
</thead>
<tbody>
{#each ausenciasSubordinados as ausencia (ausencia._id)}
<tr class="hover:bg-base-200/50 transition-all duration-200 border-b border-base-300">
<tr
class="hover:bg-base-200/50 border-base-300 border-b transition-all duration-200"
>
<td>
<div class="font-semibold text-base-content">
<div class="text-base-content font-semibold">
{ausencia.funcionario?.nome || 'N/A'}
</div>
</td>
@@ -2147,9 +2237,12 @@
</div>
{/if}
</td>
<td class="font-medium text-base-content/70">
<td class="text-base-content/70 font-medium">
<div class="flex items-center gap-1.5">
<CalendarDays class="h-3.5 w-3.5 text-base-content/50" strokeWidth={2} />
<CalendarDays
class="text-base-content/50 h-3.5 w-3.5"
strokeWidth={2}
/>
<span>
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até
{new Date(ausencia.dataFim).toLocaleDateString('pt-BR')}
@@ -2186,7 +2279,7 @@
{:else}
<button
type="button"
class="btn btn-ghost btn-sm gap-2 hover:bg-base-300 transition-all duration-200"
class="btn btn-ghost btn-sm hover:bg-base-300 gap-2 transition-all duration-200"
onclick={() => (solicitacaoAusenciaAprovar = ausencia._id)}
>
<Eye class="h-4 w-4" strokeWidth={2} />
@@ -2465,6 +2558,34 @@
</form>
</dialog>
{/if}
{#if abaAtiva === 'meu-ponto'}
<!-- Meu Ponto -->
<div
class="card from-base-100 to-base-200 overflow-hidden border-t-4 border-blue-500 bg-gradient-to-br shadow-2xl"
>
<div class="card-body p-6">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg ring-2 ring-blue-500/20"
>
<Fingerprint class="h-6 w-6 text-white" strokeWidth={2.5} />
</div>
<div>
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
Meu Ponto
</h2>
<p class="text-base-content/60 mt-0.5 text-sm">
Registre sua entrada, saída e intervalos de trabalho
</p>
</div>
</div>
</div>
<RegistroPonto />
</div>
</div>
{/if}
</main>
</ProtectedRoute>

View File

@@ -17,6 +17,7 @@
CheckCircle2,
Info,
ArrowRight,
Clock,
} from "lucide-svelte";
import type { Component } from "svelte";
@@ -119,6 +120,22 @@
},
],
},
{
categoria: "Controle de Ponto",
descricao: "Gerencie registros de ponto dos funcionários",
Icon: Clock,
gradient: "from-cyan-500/10 to-cyan-600/20",
accentColor: "text-cyan-600",
bgIcon: "bg-cyan-500/20",
opcoes: [
{
nome: "Registro de Pontos",
descricao: "Visualizar e gerenciar registros de ponto",
href: "/recursos-humanos/registro-pontos",
Icon: Clock,
},
],
},
];
</script>

View File

@@ -0,0 +1,355 @@
<script lang="ts">
import { onMount } from 'svelte';
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
const client = useConvexClient();
// Estados
let dataInicio = $state(new Date().toISOString().split('T')[0]!);
let dataFim = $state(new Date().toISOString().split('T')[0]!);
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
let carregando = $state(false);
// Queries
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, {
funcionarioId: funcionarioIdFiltro || undefined,
dataInicio,
dataFim,
});
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, {
dataInicio,
dataFim,
});
const funcionarios = $derived(funcionariosQuery?.data || []);
const registros = $derived(registrosQuery?.data || []);
const estatisticas = $derived(estatisticasQuery?.data);
// Agrupar registros por funcionário e data
const registrosAgrupados = $derived.by(() => {
const agrupados: Record<
string,
{
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
registros: typeof registros;
}
> = {};
for (const registro of registros) {
const key = registro.funcionarioId;
if (!agrupados[key]) {
agrupados[key] = {
funcionario: registro.funcionario,
registros: [],
};
}
agrupados[key]!.registros.push(registro);
}
return Object.values(agrupados);
});
async function imprimirFichaPonto(funcionarioId: Id<'funcionarios'>) {
const registrosFuncionario = registros.filter((r) => r.funcionarioId === funcionarioId);
if (registrosFuncionario.length === 0) {
alert('Nenhum registro encontrado para este funcionário no período selecionado');
return;
}
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
if (!funcionario) {
alert('Funcionário não encontrado');
return;
}
try {
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('FICHA DE PONTO', 105, yPosition, { align: 'center' });
yPosition += 10;
// Dados 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 (funcionario.matricula) {
doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition);
yPosition += 6;
}
doc.text(`Nome: ${funcionario.nome}`, 15, yPosition);
yPosition += 6;
if (funcionario.descricaoCargo) {
doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition);
yPosition += 6;
}
yPosition += 5;
doc.text(`Período: ${dataInicio} a ${dataFim}`, 15, yPosition);
yPosition += 10;
// Tabela de registros
const tableData = registrosFuncionario.map((r) => [
r.data,
getTipoRegistroLabel(r.tipo),
formatarHoraPonto(r.hora, r.minuto),
r.dentroDoPrazo ? 'Sim' : 'Não',
]);
autoTable(doc, {
startY: yPosition,
head: [['Data', 'Tipo', 'Horário', 'Dentro do Prazo']],
body: tableData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
// 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 = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`;
doc.save(nomeArquivo);
} catch (error) {
console.error('Erro ao gerar PDF:', error);
alert('Erro ao gerar ficha de ponto. Tente novamente.');
}
}
</script>
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<div class="p-3 bg-primary/10 rounded-xl">
<Clock class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Registro de Pontos</h1>
<p class="text-base-content/60 mt-1">Gerencie e visualize os registros de ponto dos funcionários</p>
</div>
</div>
</div>
<!-- Filtros -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Filter class="h-5 w-5" />
Filtros
</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="form-control">
<label class="label" for="data-inicio">
<span class="label-text font-medium">Data Início</span>
</label>
<input
id="data-inicio"
type="date"
bind:value={dataInicio}
class="input input-bordered"
/>
</div>
<div class="form-control">
<label class="label" for="data-fim">
<span class="label-text font-medium">Data Fim</span>
</label>
<input
id="data-fim"
type="date"
bind:value={dataFim}
class="input input-bordered"
/>
</div>
<div class="form-control">
<label class="label" for="funcionario">
<span class="label-text font-medium">Funcionário</span>
</label>
<select
id="funcionario"
bind:value={funcionarioIdFiltro}
class="select select-bordered"
>
<option value="">Todos</option>
{#each funcionarios as funcionario}
<option value={funcionario._id}>{funcionario.nome}</option>
{/each}
</select>
</div>
</div>
</div>
</div>
<!-- Estatísticas -->
{#if estatisticas}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="stat bg-base-100 shadow-lg rounded-lg">
<div class="stat-figure text-primary">
<BarChart3 class="h-8 w-8" />
</div>
<div class="stat-title">Total de Registros</div>
<div class="stat-value text-primary">{estatisticas.totalRegistros}</div>
</div>
<div class="stat bg-base-100 shadow-lg rounded-lg">
<div class="stat-figure text-success">
<CheckCircle2 class="h-8 w-8" />
</div>
<div class="stat-title">Dentro do Prazo</div>
<div class="stat-value text-success">{estatisticas.dentroDoPrazo}</div>
<div class="stat-desc">
{estatisticas.totalRegistros > 0
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}%
</div>
</div>
<div class="stat bg-base-100 shadow-lg rounded-lg">
<div class="stat-figure text-error">
<XCircle class="h-8 w-8" />
</div>
<div class="stat-title">Fora do Prazo</div>
<div class="stat-value text-error">{estatisticas.foraDoPrazo}</div>
<div class="stat-desc">
{estatisticas.totalRegistros > 0
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}%
</div>
</div>
<div class="stat bg-base-100 shadow-lg rounded-lg">
<div class="stat-figure text-info">
<Users class="h-8 w-8" />
</div>
<div class="stat-title">Funcionários</div>
<div class="stat-value text-info">{estatisticas.totalFuncionarios}</div>
<div class="stat-desc">
{estatisticas.funcionariosDentroPrazo} dentro do prazo, {estatisticas.funcionariosForaPrazo} fora
</div>
</div>
</div>
{/if}
<!-- Lista de Registros -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Registros</h2>
{#if registrosAgrupados.length === 0}
<div class="alert alert-info">
<span>Nenhum registro encontrado para o período selecionado</span>
</div>
{:else}
<div class="space-y-4">
{#each registrosAgrupados as grupo}
<div class="card bg-base-200">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="font-bold text-lg">
{grupo.funcionario?.nome || 'Funcionário não encontrado'}
</h3>
{#if grupo.funcionario?.matricula}
<p class="text-sm text-base-content/70">
Matrícula: {grupo.funcionario.matricula}
</p>
{/if}
</div>
<button
class="btn btn-sm btn-primary"
onclick={() => imprimirFichaPonto(grupo.registros[0]!.funcionarioId)}
>
<Printer class="h-4 w-4" />
Imprimir Ficha
</button>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Data</th>
<th>Tipo</th>
<th>Horário</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each grupo.registros as registro}
<tr>
<td>{registro.data}</td>
<td>{getTipoRegistroLabel(registro.tipo)}</td>
<td>{formatarHoraPonto(registro.hora, registro.minuto)}</td>
<td>
<span
class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}"
>
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import WidgetGestaoPontos from '$lib/components/ponto/WidgetGestaoPontos.svelte';
const menuItems = [
{
categoria: "Gestão de Ausências",
@@ -113,6 +114,11 @@
{/each}
</div>
<!-- Widget Gestão de Pontos -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
<WidgetGestaoPontos />
</div>
<!-- Card de Ajuda -->
<div class="alert alert-info shadow-lg mt-8">
<svg

View File

@@ -258,6 +258,24 @@
palette: 'secondary',
icon: 'envelope'
},
{
title: 'Configurações de Ponto',
description:
'Configure os horários de trabalho, intervalos e tolerâncias para o sistema de controle de ponto.',
ctaLabel: 'Configurar Ponto',
href: '/(dashboard)/ti/configuracoes-ponto',
palette: 'primary',
icon: 'clock'
},
{
title: 'Configurações de Relógio',
description:
'Configure a sincronização de tempo com servidor NTP ou use o relógio do PC como fallback.',
ctaLabel: 'Configurar Relógio',
href: '/(dashboard)/ti/configuracoes-relogio',
palette: 'info',
icon: 'clock'
},
{
title: 'Monitoramento de Emails',
description:

View File

@@ -0,0 +1,191 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { Clock, Save, CheckCircle2 } from 'lucide-svelte';
const client = useConvexClient();
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
let horarioEntrada = $state('08:00');
let horarioSaidaAlmoco = $state('12:00');
let horarioRetornoAlmoco = $state('13:00');
let horarioSaida = $state('17:00');
let toleranciaMinutos = $state(15);
let processando = $state(false);
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
$effect(() => {
if (configQuery?.data) {
horarioEntrada = configQuery.data.horarioEntrada;
horarioSaidaAlmoco = configQuery.data.horarioSaidaAlmoco;
horarioRetornoAlmoco = configQuery.data.horarioRetornoAlmoco;
horarioSaida = configQuery.data.horarioSaida;
toleranciaMinutos = configQuery.data.toleranciaMinutos;
}
});
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
mensagem = { tipo, texto };
setTimeout(() => {
mensagem = null;
}, 5000);
}
async function salvarConfiguracao() {
// Validação básica
if (!horarioEntrada || !horarioSaidaAlmoco || !horarioRetornoAlmoco || !horarioSaida) {
mostrarMensagem('error', 'Preencha todos os horários');
return;
}
if (toleranciaMinutos < 0 || toleranciaMinutos > 60) {
mostrarMensagem('error', 'Tolerância deve estar entre 0 e 60 minutos');
return;
}
processando = true;
try {
await client.mutation(api.configuracaoPonto.salvarConfiguracao, {
horarioEntrada,
horarioSaidaAlmoco,
horarioRetornoAlmoco,
horarioSaida,
toleranciaMinutos,
});
mostrarMensagem('success', 'Configuração salva com sucesso!');
} catch (error) {
console.error('Erro ao salvar configuração:', error);
mostrarMensagem('error', error instanceof Error ? error.message : 'Erro ao salvar configuração');
} finally {
processando = false;
}
}
</script>
<div class="container mx-auto px-4 py-6 max-w-4xl">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<div class="p-3 bg-primary/10 rounded-xl">
<Clock class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Configurações de Ponto</h1>
<p class="text-base-content/60 mt-1">Configure os horários de trabalho e tolerâncias</p>
</div>
</div>
</div>
<!-- Mensagens -->
{#if mensagem}
<div
class="alert mb-6"
class:alert-success={mensagem.tipo === 'success'}
class:alert-error={mensagem.tipo === 'error'}
>
<CheckCircle2 class="h-6 w-6" />
<span>{mensagem.texto}</span>
</div>
{/if}
<!-- Formulário -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Horários de Trabalho</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Entrada -->
<div class="form-control">
<label class="label" for="horario-entrada">
<span class="label-text font-medium">Horário de Entrada *</span>
</label>
<input
id="horario-entrada"
type="time"
bind:value={horarioEntrada}
class="input input-bordered"
/>
</div>
<!-- Saída para Almoço -->
<div class="form-control">
<label class="label" for="horario-saida-almoco">
<span class="label-text font-medium">Saída para Almoço *</span>
</label>
<input
id="horario-saida-almoco"
type="time"
bind:value={horarioSaidaAlmoco}
class="input input-bordered"
/>
</div>
<!-- Retorno do Almoço -->
<div class="form-control">
<label class="label" for="horario-retorno-almoco">
<span class="label-text font-medium">Retorno do Almoço *</span>
</label>
<input
id="horario-retorno-almoco"
type="time"
bind:value={horarioRetornoAlmoco}
class="input input-bordered"
/>
</div>
<!-- Saída -->
<div class="form-control">
<label class="label" for="horario-saida">
<span class="label-text font-medium">Horário de Saída *</span>
</label>
<input
id="horario-saida"
type="time"
bind:value={horarioSaida}
class="input input-bordered"
/>
</div>
</div>
<div class="divider"></div>
<!-- Tolerância -->
<div class="form-control">
<label class="label" for="tolerancia">
<span class="label-text font-medium">Tolerância (minutos) *</span>
</label>
<input
id="tolerancia"
type="number"
bind:value={toleranciaMinutos}
min="0"
max="60"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt"
>Tempo de tolerância para registros antes ou depois do horário configurado</span
>
</div>
</div>
<!-- Ações -->
<div class="card-actions justify-end mt-6">
<button
class="btn btn-primary"
onclick={salvarConfiguracao}
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Save class="h-5 w-5" />
{/if}
Salvar Configuração
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,250 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { Clock, Save, CheckCircle2, AlertCircle, RefreshCw } from 'lucide-svelte';
const client = useConvexClient();
const configQuery = useQuery(api.configuracaoRelogio.obterConfiguracao, {});
let servidorNTP = $state('pool.ntp.org');
let portaNTP = $state(123);
let usarServidorExterno = $state(false);
let fallbackParaPC = $state(true);
let processando = $state(false);
let testando = $state(false);
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
$effect(() => {
if (configQuery?.data) {
servidorNTP = configQuery.data.servidorNTP || 'pool.ntp.org';
portaNTP = configQuery.data.portaNTP || 123;
usarServidorExterno = configQuery.data.usarServidorExterno || false;
fallbackParaPC = configQuery.data.fallbackParaPC !== undefined ? configQuery.data.fallbackParaPC : true;
}
});
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
mensagem = { tipo, texto };
setTimeout(() => {
mensagem = null;
}, 5000);
}
async function salvarConfiguracao() {
if (usarServidorExterno) {
if (!servidorNTP || servidorNTP.trim() === '') {
mostrarMensagem('error', 'Servidor NTP é obrigatório quando usar servidor externo');
return;
}
if (portaNTP < 1 || portaNTP > 65535) {
mostrarMensagem('error', 'Porta NTP deve estar entre 1 e 65535');
return;
}
}
processando = true;
try {
await client.mutation(api.configuracaoRelogio.salvarConfiguracao, {
servidorNTP: usarServidorExterno ? servidorNTP : undefined,
portaNTP: usarServidorExterno ? portaNTP : undefined,
usarServidorExterno,
fallbackParaPC,
});
mostrarMensagem('success', 'Configuração salva com sucesso!');
} catch (error) {
console.error('Erro ao salvar configuração:', error);
mostrarMensagem('error', error instanceof Error ? error.message : 'Erro ao salvar configuração');
} finally {
processando = false;
}
}
async function testarSincronizacao() {
testando = true;
mensagem = null;
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso) {
mostrarMensagem(
'success',
resultado.usandoServidorExterno
? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s`
: 'Usando relógio do PC (servidor externo não disponível)'
);
} else {
mostrarMensagem('error', 'Falha na sincronização');
}
} catch (error) {
console.error('Erro ao testar sincronização:', error);
mostrarMensagem('error', error instanceof Error ? error.message : 'Erro ao testar sincronização');
} finally {
testando = false;
}
}
</script>
<div class="container mx-auto px-4 py-6 max-w-4xl">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<div class="p-3 bg-primary/10 rounded-xl">
<Clock class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Configurações de Relógio</h1>
<p class="text-base-content/60 mt-1">Configure a sincronização de tempo do sistema</p>
</div>
</div>
</div>
<!-- Mensagens -->
{#if mensagem}
<div
class="alert mb-6"
class:alert-success={mensagem.tipo === 'success'}
class:alert-error={mensagem.tipo === 'error'}
>
{#if mensagem.tipo === 'success'}
<CheckCircle2 class="h-6 w-6" />
{:else}
<AlertCircle class="h-6 w-6" />
{/if}
<span>{mensagem.texto}</span>
</div>
{/if}
<!-- Formulário -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Sincronização de Tempo</h2>
<!-- Usar Servidor Externo -->
<div class="form-control mb-4">
<label class="label cursor-pointer gap-3">
<input
type="checkbox"
bind:checked={usarServidorExterno}
class="checkbox checkbox-primary"
/>
<span class="label-text font-medium">Usar servidor NTP externo</span>
</label>
<div class="label">
<span class="label-text-alt"
>Sincronizar com servidor de tempo externo (NTP) em vez de usar o relógio do PC</span
>
</div>
</div>
{#if usarServidorExterno}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- Servidor NTP -->
<div class="form-control">
<label class="label" for="servidor-ntp">
<span class="label-text font-medium">Servidor NTP *</span>
</label>
<input
id="servidor-ntp"
type="text"
bind:value={servidorNTP}
placeholder="pool.ntp.org"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Ex: pool.ntp.org, time.google.com, time.windows.com</span>
</div>
</div>
<!-- Porta NTP -->
<div class="form-control">
<label class="label" for="porta-ntp">
<span class="label-text font-medium">Porta NTP *</span>
</label>
<input
id="porta-ntp"
type="number"
bind:value={portaNTP}
placeholder="123"
min="1"
max="65535"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Porta padrão: 123</span>
</div>
</div>
</div>
{/if}
<!-- Fallback para PC -->
<div class="form-control mb-4">
<label class="label cursor-pointer gap-3">
<input
type="checkbox"
bind:checked={fallbackParaPC}
class="checkbox checkbox-primary"
/>
<span class="label-text font-medium">Usar relógio do PC se servidor externo falhar</span>
</label>
<div class="label">
<span class="label-text-alt"
>Se marcado, o sistema usará o relógio do PC caso não consiga sincronizar com o servidor
externo</span
>
</div>
</div>
<!-- Ações -->
<div class="card-actions justify-end mt-6 gap-3">
{#if usarServidorExterno}
<button
class="btn btn-outline btn-info"
onclick={testarSincronizacao}
disabled={testando || processando}
>
{#if testando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<RefreshCw class="h-5 w-5" />
{/if}
Testar Sincronização
</button>
{/if}
<button
class="btn btn-primary"
onclick={salvarConfiguracao}
disabled={processando || testando}
>
{#if processando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Save class="h-5 w-5" />
{/if}
Salvar Configuração
</button>
</div>
</div>
</div>
<!-- Informações -->
<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<h2 class="card-title mb-4">Informações</h2>
<div class="alert alert-info">
<AlertCircle class="h-6 w-6" />
<div>
<p>
<strong>Nota:</strong> O sistema usa uma API HTTP para sincronização de tempo como
aproximação do protocolo NTP. Para sincronização NTP real, seria necessário uma biblioteca
específica.
</p>
<p class="text-sm mt-1">
Servidores NTP recomendados: pool.ntp.org, time.google.com, time.windows.com
</p>
</div>
</div>
</div>
</div>
</div>