Feat controle ponto #29
@@ -13,8 +13,11 @@
|
||||
getTipoRegistroLabel,
|
||||
getProximoTipoRegistro
|
||||
} 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 jsPDF from 'jspdf';
|
||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||
import { formatarDataHoraCompleta } from '$lib/utils/ponto';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -309,6 +312,206 @@
|
||||
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(() => {
|
||||
return !registrando && !coletandoInfo && config !== undefined;
|
||||
});
|
||||
@@ -555,7 +758,7 @@
|
||||
{#each registrosOrdenados as registro (registro._id)}
|
||||
<div class="card bg-base-200">
|
||||
<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="mb-1 flex items-center gap-2">
|
||||
<span class="font-semibold">{getTipoRegistroLabel(registro.tipo)}</span>
|
||||
@@ -575,6 +778,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
@@ -621,53 +834,158 @@
|
||||
|
||||
<!-- Modal de Confirmação -->
|
||||
{#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual}
|
||||
<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">Confirmar Registro de Ponto</h3>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" onclick={cancelarRegistro}>
|
||||
<div class="modal modal-open" style="display: flex; align-items: center; justify-content: center;">
|
||||
<div class="modal-box max-w-3xl w-[95%] max-h-[90vh] overflow-hidden flex flex-col" style="margin: auto;">
|
||||
<!-- Header fixo -->
|
||||
<div class="flex items-center justify-between mb-6 pb-4 border-b border-base-300 flex-shrink-0">
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Imagem capturada -->
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src={URL.createObjectURL(imagemCapturada)}
|
||||
alt="Foto capturada"
|
||||
class="max-w-full max-h-96 rounded-lg border-2 border-primary object-contain"
|
||||
/>
|
||||
</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>
|
||||
<!-- Conteúdo com rolagem -->
|
||||
<div class="flex-1 overflow-y-auto pr-2 space-y-6">
|
||||
<!-- 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
|
||||
src={URL.createObjectURL(imagemCapturada)}
|
||||
alt="Foto capturada do registro de ponto"
|
||||
class="max-w-full max-h-[400px] rounded-lg shadow-md object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botões -->
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<button class="btn btn-outline" onclick={cancelarRegistro} disabled={registrando}>
|
||||
<XCircle class="h-5 w-5" />
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick={confirmarRegistro} disabled={registrando}>
|
||||
{#if registrando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Registrando...
|
||||
{:else}
|
||||
<CheckCircle2 class="h-5 w-5" />
|
||||
Confirmar Registro
|
||||
<!-- Card de Informações -->
|
||||
<div class="card bg-gradient-to-br from-primary/5 to-primary/10 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/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}
|
||||
</button>
|
||||
</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" />
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary btn-lg gap-2"
|
||||
onclick={confirmarRegistro}
|
||||
disabled={registrando}
|
||||
>
|
||||
{#if registrando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Registrando...
|
||||
{:else}
|
||||
<CheckCircle2 class="h-5 w-5" />
|
||||
Confirmar Registro
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" onclick={cancelarRegistro}></div>
|
||||
|
||||
Reference in New Issue
Block a user