Feat controle ponto #29

Merged
deyvisonwanderley merged 8 commits from feat-controle-ponto into master 2025-11-19 09:41:57 +00:00
22 changed files with 5633 additions and 128 deletions
Showing only changes of commit b660d123d4 - Show all commits

View File

@@ -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,53 +834,158 @@
<!-- 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 -->
<img <div class="card bg-gradient-to-br from-base-200 to-base-300 shadow-lg border-2 border-primary/20">
src={URL.createObjectURL(imagemCapturada)} <div class="card-body p-6">
alt="Foto capturada" <div class="flex items-center gap-2 mb-4">
class="max-w-full max-h-96 rounded-lg border-2 border-primary object-contain" <div class="p-1.5 bg-primary/10 rounded-lg">
/> <svg
</div> xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-primary"
<!-- Data e Hora --> fill="none"
<div class="text-center space-y-2"> viewBox="0 0 24 24"
<div class="text-lg font-semibold"> stroke="currentColor"
<span class="text-base-content/70">Data: </span> >
<span>{dataHoraAtual.data}</span> <path
</div> stroke-linecap="round"
<div class="text-lg font-semibold"> stroke-linejoin="round"
<span class="text-base-content/70">Hora: </span> stroke-width="2"
<span>{dataHoraAtual.hora}</span> 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>
</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">
<XCircle class="h-5 w-5" /> <div class="flex items-center gap-2 mb-4">
Cancelar <div class="p-1.5 bg-primary/20 rounded-lg">
</button> <CheckCircle2 class="h-5 w-5 text-primary" strokeWidth={2} />
<button class="btn btn-primary" onclick={confirmarRegistro} disabled={registrando}> </div>
{#if registrando} <h4 class="font-semibold text-lg">Informações do Registro</h4>
<span class="loading loading-spinner loading-sm"></span> </div>
Registrando...
{:else} <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<CheckCircle2 class="h-5 w-5" /> <!-- Tipo de Registro -->
Confirmar 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} {/if}
</button> </div>
</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> </div>
<div class="modal-backdrop" onclick={cancelarRegistro}></div> <div class="modal-backdrop" onclick={cancelarRegistro}></div>