- Added a refresh mechanism in the RegistroPonto component to ensure queries are updated after point registration, improving data accuracy. - Expanded the WebcamCapture component to prevent multiple simultaneous play calls, enhancing video playback reliability. - Updated the registro-pontos page to default the date range to the last 30 days for better visibility and user convenience. - Introduced debug logging for queries and data handling to assist in development and troubleshooting.
411 lines
12 KiB
Svelte
411 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { 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 registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
|
|
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
|
|
|
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);
|
|
|
|
const config = configQuery?.data;
|
|
const tipoLabel = config
|
|
? getTipoRegistroLabel(registro.tipo, {
|
|
nomeEntrada: config.nomeEntrada,
|
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
|
nomeSaida: config.nomeSaida,
|
|
})
|
|
: getTipoRegistroLabel(registro.tipo);
|
|
doc.text(`Tipo: ${tipoLabel}`, 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;
|
|
|
|
// Imagem capturada (se disponível)
|
|
if (registro.imagemUrl) {
|
|
yPosition += 10;
|
|
// Verificar se precisa de nova página
|
|
if (yPosition > 200) {
|
|
doc.addPage();
|
|
yPosition = 20;
|
|
}
|
|
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' });
|
|
doc.setFont('helvetica', 'normal');
|
|
yPosition += 10;
|
|
|
|
try {
|
|
// Carregar imagem usando fetch para evitar problemas de CORS
|
|
const response = await fetch(registro.imagemUrl);
|
|
if (!response.ok) {
|
|
throw new Error('Erro ao carregar imagem');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const reader = new FileReader();
|
|
|
|
// Converter blob para base64
|
|
const base64 = await new Promise<string>((resolve, reject) => {
|
|
reader.onloadend = () => {
|
|
if (typeof reader.result === 'string') {
|
|
resolve(reader.result);
|
|
} else {
|
|
reject(new Error('Erro ao converter imagem'));
|
|
}
|
|
};
|
|
reader.onerror = () => reject(new Error('Erro ao ler imagem'));
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
|
|
// Criar elemento de imagem para obter dimensões
|
|
const img = new Image();
|
|
await new Promise<void>((resolve, reject) => {
|
|
img.onload = () => resolve();
|
|
img.onerror = () => reject(new Error('Erro ao processar imagem'));
|
|
img.src = base64;
|
|
setTimeout(() => reject(new Error('Timeout ao processar imagem')), 10000);
|
|
});
|
|
|
|
// Calcular dimensões para caber na página (largura máxima 80mm, manter proporção)
|
|
const maxWidth = 80;
|
|
const maxHeight = 60;
|
|
let imgWidth = img.width;
|
|
let imgHeight = img.height;
|
|
const aspectRatio = imgWidth / imgHeight;
|
|
|
|
if (imgWidth > maxWidth || imgHeight > maxHeight) {
|
|
if (aspectRatio > 1) {
|
|
// Imagem horizontal
|
|
imgWidth = maxWidth;
|
|
imgHeight = maxWidth / aspectRatio;
|
|
} else {
|
|
// Imagem vertical
|
|
imgHeight = maxHeight;
|
|
imgWidth = maxHeight * aspectRatio;
|
|
}
|
|
}
|
|
|
|
// Centralizar imagem
|
|
const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2;
|
|
|
|
// Verificar se cabe na página atual
|
|
if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) {
|
|
doc.addPage();
|
|
yPosition = 20;
|
|
}
|
|
|
|
// Adicionar imagem ao PDF usando base64
|
|
doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight);
|
|
yPosition += imgHeight + 10;
|
|
} catch (error) {
|
|
console.warn('Erro ao adicionar imagem ao PDF:', error);
|
|
doc.setFontSize(10);
|
|
doc.text('Foto não disponível para impressão', 105, yPosition, { align: 'center' });
|
|
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="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
style="animation: fadeIn 0.2s ease-out;"
|
|
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="modal-comprovante-title"
|
|
>
|
|
<!-- Backdrop com blur -->
|
|
<div
|
|
class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-200"
|
|
onclick={onClose}
|
|
></div>
|
|
|
|
<!-- Modal Box -->
|
|
<div
|
|
class="relative bg-base-100 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300"
|
|
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
|
|
onclick={(e) => e.stopPropagation()}
|
|
>
|
|
<!-- Header fixo -->
|
|
<div class="flex items-center justify-between mb-4 pb-4 border-b border-base-300 flex-shrink-0">
|
|
<h3 id="modal-comprovante-title" class="font-bold text-lg">Comprovante de Registro de Ponto</h3>
|
|
<button class="btn btn-sm btn-circle btn-ghost hover:bg-base-300" onclick={onClose}>
|
|
<X class="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Conteúdo com rolagem -->
|
|
<div class="flex-1 overflow-y-auto pr-2 modal-scroll">
|
|
{#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>
|
|
{configQuery?.data
|
|
? getTipoRegistroLabel(registro.tipo, {
|
|
nomeEntrada: configQuery.data.nomeEntrada,
|
|
nomeSaidaAlmoco: configQuery.data.nomeSaidaAlmoco,
|
|
nomeRetornoAlmoco: configQuery.data.nomeRetornoAlmoco,
|
|
nomeSaida: configQuery.data.nomeSaida,
|
|
})
|
|
: 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>
|
|
|
|
<!-- Imagem Capturada -->
|
|
{#if registro.imagemUrl}
|
|
<div class="card bg-base-200">
|
|
<div class="card-body">
|
|
<h4 class="font-bold mb-2">Foto Capturada</h4>
|
|
<div class="flex justify-center">
|
|
<img
|
|
src={registro.imagemUrl}
|
|
alt="Foto do registro de ponto"
|
|
class="max-w-full max-h-[250px] rounded-lg border-2 border-primary object-contain"
|
|
onerror={(e) => {
|
|
console.error('Erro ao carregar imagem:', e);
|
|
(e.target as HTMLImageElement).style.display = 'none';
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Footer fixo com botões -->
|
|
<div class="flex justify-end gap-2 pt-4 mt-4 border-t border-base-300 flex-shrink-0">
|
|
<button class="btn btn-primary gap-2" 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>
|
|
</div>
|
|
|
|
<style>
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(20px) scale(0.95);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
}
|
|
|
|
/* Scrollbar customizada para os modais */
|
|
:global(.modal-scroll) {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
|
|
scroll-behavior: smooth;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
:global(.modal-scroll::-webkit-scrollbar) {
|
|
width: 8px;
|
|
}
|
|
|
|
:global(.modal-scroll::-webkit-scrollbar-track) {
|
|
background: transparent;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
:global(.modal-scroll::-webkit-scrollbar-thumb) {
|
|
background-color: hsl(var(--bc) / 0.3);
|
|
border-radius: 4px;
|
|
transition: background-color 0.2s ease;
|
|
}
|
|
|
|
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
|
|
background-color: hsl(var(--bc) / 0.5);
|
|
}
|
|
</style>
|
|
|