feat: add PDF receipt generation for point registration
- Implemented a new feature to generate a PDF receipt for point registrations, including employee and registration details. - Integrated jsPDF for PDF creation and added functionality to include a logo and captured images in the receipt. - Enhanced the UI with a print button for users to easily access the receipt generation feature. - Improved the confirmation modal layout for better user experience during point registration.
This commit is contained in:
@@ -13,8 +13,11 @@
|
|||||||
getTipoRegistroLabel,
|
getTipoRegistroLabel,
|
||||||
getProximoTipoRegistro
|
getProximoTipoRegistro
|
||||||
} from '$lib/utils/ponto';
|
} from '$lib/utils/ponto';
|
||||||
import { LogIn, LogOut, Clock, CheckCircle2, XCircle, TrendingUp, TrendingDown } from 'lucide-svelte';
|
import { LogIn, LogOut, Clock, CheckCircle2, XCircle, TrendingUp, TrendingDown, Printer } from 'lucide-svelte';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||||
|
import { formatarDataHoraCompleta } from '$lib/utils/ponto';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -309,6 +312,206 @@
|
|||||||
erro = null;
|
erro = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function imprimirComprovante(registroId: Id<'registrosPonto'>) {
|
||||||
|
try {
|
||||||
|
// Buscar dados completos do registro
|
||||||
|
const registro = await client.query(api.pontos.obterRegistro, { registroId });
|
||||||
|
|
||||||
|
if (!registro) {
|
||||||
|
alert('Registro não encontrado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Imagem capturada (se disponível)
|
||||||
|
if (registro.imagemUrl) {
|
||||||
|
// 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.toString().padStart(2, '0')}${registro.minuto.toString().padStart(2, '0')}.pdf`;
|
||||||
|
doc.save(nomeArquivo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar comprovante PDF:', error);
|
||||||
|
alert('Erro ao gerar comprovante PDF. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const podeRegistrar = $derived.by(() => {
|
const podeRegistrar = $derived.by(() => {
|
||||||
return !registrando && !coletandoInfo && config !== undefined;
|
return !registrando && !coletandoInfo && config !== undefined;
|
||||||
});
|
});
|
||||||
@@ -555,7 +758,7 @@
|
|||||||
{#each registrosOrdenados as registro (registro._id)}
|
{#each registrosOrdenados as registro (registro._id)}
|
||||||
<div class="card bg-base-200">
|
<div class="card bg-base-200">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="mb-1 flex items-center gap-2">
|
<div class="mb-1 flex items-center gap-2">
|
||||||
<span class="font-semibold">{getTipoRegistroLabel(registro.tipo)}</span>
|
<span class="font-semibold">{getTipoRegistroLabel(registro.tipo)}</span>
|
||||||
@@ -575,6 +778,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-primary gap-2"
|
||||||
|
onclick={() => imprimirComprovante(registro._id)}
|
||||||
|
title="Imprimir Comprovante"
|
||||||
|
>
|
||||||
|
<Printer class="h-4 w-4" />
|
||||||
|
Imprimir Comprovante
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -621,44 +834,150 @@
|
|||||||
|
|
||||||
<!-- Modal de Confirmação -->
|
<!-- Modal de Confirmação -->
|
||||||
{#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual}
|
{#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual}
|
||||||
<div class="modal modal-open">
|
<div class="modal modal-open" style="display: flex; align-items: center; justify-content: center;">
|
||||||
<div class="modal-box max-w-2xl">
|
<div class="modal-box max-w-3xl w-[95%] max-h-[90vh] overflow-hidden flex flex-col" style="margin: auto;">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<!-- Header fixo -->
|
||||||
<h3 class="font-bold text-lg">Confirmar Registro de Ponto</h3>
|
<div class="flex items-center justify-between mb-6 pb-4 border-b border-base-300 flex-shrink-0">
|
||||||
<button class="btn btn-sm btn-circle btn-ghost" onclick={cancelarRegistro}>
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<Clock class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-xl text-base-content">Confirmar Registro de Ponto</h3>
|
||||||
|
<p class="text-sm text-base-content/70">Verifique as informações antes de confirmar</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
|
||||||
|
onclick={cancelarRegistro}
|
||||||
|
disabled={registrando}
|
||||||
|
>
|
||||||
<XCircle class="h-5 w-5" />
|
<XCircle class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<!-- Conteúdo com rolagem -->
|
||||||
<!-- Imagem capturada -->
|
<div class="flex-1 overflow-y-auto pr-2 space-y-6">
|
||||||
<div class="flex justify-center">
|
<!-- Card da Imagem -->
|
||||||
|
<div class="card bg-gradient-to-br from-base-200 to-base-300 shadow-lg border-2 border-primary/20">
|
||||||
|
<div class="card-body p-6">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<div class="p-1.5 bg-primary/10 rounded-lg">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 text-primary"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold text-lg">Foto Capturada</h4>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center bg-base-100 rounded-xl p-4 border-2 border-primary/30">
|
||||||
<img
|
<img
|
||||||
src={URL.createObjectURL(imagemCapturada)}
|
src={URL.createObjectURL(imagemCapturada)}
|
||||||
alt="Foto capturada"
|
alt="Foto capturada do registro de ponto"
|
||||||
class="max-w-full max-h-96 rounded-lg border-2 border-primary object-contain"
|
class="max-w-full max-h-[400px] rounded-lg shadow-md object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Data e Hora -->
|
|
||||||
<div class="text-center space-y-2">
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
<span class="text-base-content/70">Data: </span>
|
|
||||||
<span>{dataHoraAtual.data}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
<span class="text-base-content/70">Hora: </span>
|
|
||||||
<span>{dataHoraAtual.hora}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botões -->
|
<!-- Card de Informações -->
|
||||||
<div class="flex justify-end gap-2 pt-4">
|
<div class="card bg-gradient-to-br from-primary/5 to-primary/10 shadow-lg border-2 border-primary/20">
|
||||||
<button class="btn btn-outline" onclick={cancelarRegistro} disabled={registrando}>
|
<div class="card-body p-6">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<div class="p-1.5 bg-primary/20 rounded-lg">
|
||||||
|
<CheckCircle2 class="h-5 w-5 text-primary" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold text-lg">Informações do Registro</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Tipo de Registro -->
|
||||||
|
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||||
|
<p class="text-sm font-medium text-base-content/70 mb-1">Tipo de Registro</p>
|
||||||
|
<p class="text-lg font-bold text-primary">{tipoLabel}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data -->
|
||||||
|
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||||
|
<p class="text-sm font-medium text-base-content/70 mb-1">Data</p>
|
||||||
|
<p class="text-lg font-bold text-base-content">{dataHoraAtual.data}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hora -->
|
||||||
|
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||||
|
<p class="text-sm font-medium text-base-content/70 mb-1">Horário</p>
|
||||||
|
<p class="text-lg font-bold text-base-content">{dataHoraAtual.hora}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||||
|
<p class="text-sm font-medium text-base-content/70 mb-1">Status</p>
|
||||||
|
<div class="badge badge-success badge-lg gap-2">
|
||||||
|
<CheckCircle2 class="h-4 w-4" />
|
||||||
|
Pronto para Registrar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Justificativa (se houver) -->
|
||||||
|
{#if justificativa.trim()}
|
||||||
|
<div class="mt-4 bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||||
|
<p class="text-sm font-medium text-base-content/70 mb-2">Justificativa</p>
|
||||||
|
<p class="text-base text-base-content whitespace-pre-wrap">{justificativa}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aviso -->
|
||||||
|
<div class="alert alert-info shadow-lg">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold">Confirme os dados</h3>
|
||||||
|
<div class="text-sm">
|
||||||
|
Verifique se a foto, data e horário estão corretos antes de confirmar o registro.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer fixo com botões -->
|
||||||
|
<div class="flex justify-end gap-3 pt-4 mt-4 border-t border-base-300 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline btn-lg"
|
||||||
|
onclick={cancelarRegistro}
|
||||||
|
disabled={registrando}
|
||||||
|
>
|
||||||
<XCircle class="h-5 w-5" />
|
<XCircle class="h-5 w-5" />
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" onclick={confirmarRegistro} disabled={registrando}>
|
<button
|
||||||
|
class="btn btn-primary btn-lg gap-2"
|
||||||
|
onclick={confirmarRegistro}
|
||||||
|
disabled={registrando}
|
||||||
|
>
|
||||||
{#if registrando}
|
{#if registrando}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
Registrando...
|
Registrando...
|
||||||
@@ -669,7 +988,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop" onclick={cancelarRegistro}></div>
|
<div class="modal-backdrop" onclick={cancelarRegistro}></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user