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>