feat: Add 'atas' (minutes/records) management feature, and implement various improvements across UI, backend logic, and authentication.
This commit is contained in:
@@ -1,19 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { Printer, X, User, Clock, CheckCircle2, XCircle, Calendar, MapPin } from 'lucide-svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto';
|
||||
import { Calendar, CheckCircle2, Clock, MapPin, Printer, User, X, XCircle } from 'lucide-svelte';
|
||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||
import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto';
|
||||
|
||||
interface Props {
|
||||
registroId: Id<'registrosPonto'>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { registroId, onClose }: Props = $props();
|
||||
const { registroId, onClose }: Props = $props();
|
||||
|
||||
const registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
|
||||
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||
@@ -25,27 +25,27 @@
|
||||
function calcularPosicaoModal() {
|
||||
// Procurar pelo elemento do card de registro de ponto
|
||||
const cardRef = document.getElementById('card-registro-ponto-ref');
|
||||
|
||||
|
||||
if (cardRef) {
|
||||
const rect = cardRef.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
|
||||
// Posicionar o modal na mesma altura Y do card (top do card) - mesma posição do texto "Registrar Ponto"
|
||||
const top = rect.top;
|
||||
|
||||
|
||||
// Garantir que o modal não saia da viewport
|
||||
// Considerar uma altura mínima do modal (aproximadamente 300px)
|
||||
const minTop = 20;
|
||||
const maxTop = viewportHeight - 350; // Deixar espaço para o modal
|
||||
const finalTop = Math.max(minTop, Math.min(top, maxTop));
|
||||
|
||||
|
||||
// Centralizar horizontalmente
|
||||
return {
|
||||
top: finalTop,
|
||||
left: window.innerWidth / 2
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Se não encontrar, usar posição padrão (centro da tela)
|
||||
return null;
|
||||
}
|
||||
@@ -53,37 +53,37 @@
|
||||
// Atualizar posição quando o modal for aberto (quando registroQuery tiver dados)
|
||||
$effect(() => {
|
||||
if (registroQuery?.data) {
|
||||
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
|
||||
const updatePosition = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const pos = calcularPosicaoModal();
|
||||
if (pos) {
|
||||
modalPosition = pos;
|
||||
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
|
||||
const updatePosition = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const pos = calcularPosicaoModal();
|
||||
if (pos) {
|
||||
modalPosition = pos;
|
||||
} else {
|
||||
// Fallback para centralização
|
||||
modalPosition = {
|
||||
top: window.innerHeight / 2,
|
||||
left: window.innerWidth / 2
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Aguardar um pouco para garantir que o DOM está atualizado
|
||||
setTimeout(updatePosition, 50);
|
||||
|
||||
// Adicionar listener de scroll para atualizar posição
|
||||
const handleScroll = () => {
|
||||
updatePosition();
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
};
|
||||
setTimeout(updatePosition, 50);
|
||||
|
||||
// Adicionar listener de scroll para atualizar posição
|
||||
const handleScroll = () => {
|
||||
updatePosition();
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
};
|
||||
} else {
|
||||
// Limpar posição quando o modal for fechado
|
||||
modalPosition = null;
|
||||
@@ -137,9 +137,13 @@
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.text('GOVERNO DO ESTADO DE PERNAMBUCO', 105, Math.max(yPosition - 10, 20), { align: 'center' });
|
||||
doc.text('GOVERNO DO ESTADO DE PERNAMBUCO', 105, Math.max(yPosition - 10, 20), {
|
||||
align: 'center'
|
||||
});
|
||||
doc.setFontSize(12);
|
||||
doc.text('SECRETARIA DE ESPORTES', 105, Math.max(yPosition - 2, 28), { align: 'center' });
|
||||
doc.text('SECRETARIA DE ESPORTES', 105, Math.max(yPosition - 2, 28), {
|
||||
align: 'center'
|
||||
});
|
||||
|
||||
yPosition = Math.max(yPosition, 40);
|
||||
yPosition += 10;
|
||||
@@ -148,13 +152,15 @@
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(102, 126, 234); // Cor primária padrão do sistema
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, { align: 'center' });
|
||||
doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, {
|
||||
align: 'center'
|
||||
});
|
||||
|
||||
yPosition += 15;
|
||||
|
||||
// Informações do Funcionário em tabela
|
||||
const funcionarioData: string[][] = [];
|
||||
|
||||
|
||||
if (registro.funcionario) {
|
||||
if (registro.funcionario.matricula) {
|
||||
funcionarioData.push(['Matrícula', registro.funcionario.matricula]);
|
||||
@@ -164,10 +170,14 @@
|
||||
funcionarioData.push(['Cargo/Função', registro.funcionario.descricaoCargo]);
|
||||
}
|
||||
if (registro.funcionario.simbolo) {
|
||||
const simboloTipo = registro.funcionario.simbolo.tipo === 'cargo_comissionado'
|
||||
? 'Cargo Comissionado'
|
||||
: 'Função Gratificada';
|
||||
funcionarioData.push(['Símbolo', `${registro.funcionario.simbolo.nome} (${simboloTipo})`]);
|
||||
const simboloTipo =
|
||||
registro.funcionario.simbolo.tipo === 'cargo_comissionado'
|
||||
? 'Cargo Comissionado'
|
||||
: 'Função Gratificada';
|
||||
funcionarioData.push([
|
||||
'Símbolo',
|
||||
`${registro.funcionario.simbolo.nome} (${simboloTipo})`
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,12 +212,17 @@
|
||||
nomeEntrada: config.nomeEntrada,
|
||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||
nomeSaida: config.nomeSaida,
|
||||
nomeSaida: config.nomeSaida
|
||||
})
|
||||
: getTipoRegistroLabel(registro.tipo);
|
||||
|
||||
const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo);
|
||||
|
||||
|
||||
const dataHora = formatarDataHoraCompleta(
|
||||
registro.data,
|
||||
registro.hora,
|
||||
registro.minuto,
|
||||
registro.segundo
|
||||
);
|
||||
|
||||
const registroData: string[][] = [
|
||||
['Tipo', tipoLabel],
|
||||
['Data e Hora', dataHora],
|
||||
@@ -260,10 +275,10 @@
|
||||
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 = () => {
|
||||
@@ -307,7 +322,7 @@
|
||||
|
||||
// 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();
|
||||
@@ -320,7 +335,9 @@
|
||||
} 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' });
|
||||
doc.text('Foto não disponível para impressão', 105, yPosition, {
|
||||
align: 'center'
|
||||
});
|
||||
yPosition += 6;
|
||||
}
|
||||
}
|
||||
@@ -351,46 +368,53 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 pointer-events-none"
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-50"
|
||||
style="animation: fadeIn 0.2s ease-out;"
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-comprovante-title"
|
||||
>
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-comprovante-title"
|
||||
>
|
||||
<!-- Backdrop leve -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"
|
||||
<div
|
||||
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
|
||||
onclick={onClose}
|
||||
></div>
|
||||
|
||||
|
||||
<!-- Modal Box -->
|
||||
<div
|
||||
class="absolute bg-gradient-to-br from-base-100 via-base-100 to-primary/5 rounded-2xl shadow-2xl border-2 border-primary/20 max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300 pointer-events-auto"
|
||||
<div
|
||||
class="from-base-100 via-base-100 to-primary/5 border-primary/20 pointer-events-auto absolute z-10 flex max-h-[90vh] w-full max-w-2xl transform flex-col overflow-hidden rounded-2xl border-2 bg-gradient-to-br shadow-2xl transition-all duration-300"
|
||||
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header Premium com gradiente -->
|
||||
<div class="flex items-center justify-between px-6 py-5 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-b-2 border-primary/20 flex-shrink-0">
|
||||
<div
|
||||
class="from-primary/10 via-primary/5 border-primary/20 flex flex-shrink-0 items-center justify-between border-b-2 bg-gradient-to-r to-transparent px-6 py-5"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2.5 bg-primary/20 rounded-xl shadow-lg">
|
||||
<Clock class="h-6 w-6 text-primary" strokeWidth={2.5} />
|
||||
<div class="bg-primary/20 rounded-xl p-2.5 shadow-lg">
|
||||
<Clock class="text-primary h-6 w-6" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="modal-comprovante-title" class="font-bold text-xl text-base-content">Comprovante de Registro de Ponto</h3>
|
||||
<p class="text-sm text-base-content/70 mt-0.5">Detalhes do registro realizado</p>
|
||||
<h3 id="modal-comprovante-title" class="text-base-content text-xl font-bold">
|
||||
Comprovante de Registro de Ponto
|
||||
</h3>
|
||||
<p class="text-base-content/70 mt-0.5 text-sm">Detalhes do registro realizado</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-all" onclick={onClose}>
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-all"
|
||||
onclick={onClose}
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo com rolagem -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6 modal-scroll">
|
||||
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
|
||||
{#if registroQuery === undefined}
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if !registroQuery?.data}
|
||||
@@ -402,35 +426,58 @@
|
||||
{@const registro = registroQuery.data}
|
||||
<div class="space-y-6">
|
||||
<!-- Informações do Funcionário -->
|
||||
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-lg border-2 border-primary/10 hover:shadow-xl transition-all">
|
||||
<div
|
||||
class="card from-base-100 to-base-200 border-primary/10 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<User class="h-5 w-5 text-primary" strokeWidth={2} />
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="bg-primary/10 rounded-lg p-2">
|
||||
<User class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-bold text-lg text-base-content">Dados do Funcionário</h4>
|
||||
<h4 class="text-base-content text-lg font-bold">Dados do Funcionário</h4>
|
||||
</div>
|
||||
{#if registro.funcionario}
|
||||
<div class="space-y-3">
|
||||
{#if registro.funcionario.matricula}
|
||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
|
||||
<div
|
||||
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Matrícula</span>
|
||||
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.matricula}</p>
|
||||
<span
|
||||
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Matrícula</span
|
||||
>
|
||||
<p class="text-base-content mt-1 text-base font-semibold">
|
||||
{registro.funcionario.matricula}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
|
||||
<div
|
||||
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Nome</span>
|
||||
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.nome}</p>
|
||||
<span
|
||||
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Nome</span
|
||||
>
|
||||
<p class="text-base-content mt-1 text-base font-semibold">
|
||||
{registro.funcionario.nome}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if registro.funcionario.descricaoCargo}
|
||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
|
||||
<div
|
||||
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Cargo/Função</span>
|
||||
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.descricaoCargo}</p>
|
||||
<span
|
||||
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Cargo/Função</span
|
||||
>
|
||||
<p class="text-base-content mt-1 text-base font-semibold">
|
||||
{registro.funcionario.descricaoCargo}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -440,43 +487,60 @@
|
||||
</div>
|
||||
|
||||
<!-- Informações do Registro -->
|
||||
<div class="card bg-gradient-to-br from-primary/5 to-primary/10 shadow-lg border-2 border-primary/20 hover:shadow-xl transition-all">
|
||||
<div
|
||||
class="card from-primary/5 to-primary/10 border-primary/20 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-primary/20 rounded-lg">
|
||||
<Clock class="h-5 w-5 text-primary" strokeWidth={2} />
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="bg-primary/20 rounded-lg p-2">
|
||||
<Clock class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-bold text-lg text-base-content">Dados do Registro</h4>
|
||||
<h4 class="text-base-content text-lg font-bold">Dados do Registro</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- Tipo -->
|
||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Tipo</span>
|
||||
<p class="text-lg font-bold text-primary mt-1">
|
||||
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Tipo</span
|
||||
>
|
||||
<p class="text-primary mt-1 text-lg font-bold">
|
||||
{configQuery?.data
|
||||
? getTipoRegistroLabel(registro.tipo, {
|
||||
nomeEntrada: configQuery.data.nomeEntrada,
|
||||
nomeSaidaAlmoco: configQuery.data.nomeSaidaAlmoco,
|
||||
nomeRetornoAlmoco: configQuery.data.nomeRetornoAlmoco,
|
||||
nomeSaida: configQuery.data.nomeSaida,
|
||||
nomeSaida: configQuery.data.nomeSaida
|
||||
})
|
||||
: getTipoRegistroLabel(registro.tipo)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Data e Hora -->
|
||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Data e Hora</span>
|
||||
<p class="text-lg font-bold text-base-content mt-1">
|
||||
{formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo)}
|
||||
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Data e Hora</span
|
||||
>
|
||||
<p class="text-base-content mt-1 text-lg font-bold">
|
||||
{formatarDataHoraCompleta(
|
||||
registro.data,
|
||||
registro.hora,
|
||||
registro.minuto,
|
||||
registro.segundo
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Status</span>
|
||||
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Status</span
|
||||
>
|
||||
<div class="mt-2">
|
||||
<span class="badge badge-lg gap-2 {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}">
|
||||
<span
|
||||
class="badge badge-lg gap-2 {registro.dentroDoPrazo
|
||||
? 'badge-success'
|
||||
: 'badge-error'}"
|
||||
>
|
||||
{#if registro.dentroDoPrazo}
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
{:else}
|
||||
@@ -488,9 +552,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Tolerância -->
|
||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Tolerância</span>
|
||||
<p class="text-lg font-bold text-base-content mt-1">{registro.toleranciaMinutos} minutos</p>
|
||||
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Tolerância</span
|
||||
>
|
||||
<p class="text-base-content mt-1 text-lg font-bold">
|
||||
{registro.toleranciaMinutos} minutos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -498,13 +566,15 @@
|
||||
|
||||
<!-- Imagem Capturada -->
|
||||
{#if registro.imagemUrl}
|
||||
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-lg border-2 border-primary/10 hover:shadow-xl transition-all">
|
||||
<div
|
||||
class="card from-base-100 to-base-200 border-primary/10 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="bg-primary/10 rounded-lg p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-primary"
|
||||
class="text-primary h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -517,13 +587,15 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="font-bold text-lg text-base-content">Foto Capturada</h4>
|
||||
<h4 class="text-base-content text-lg font-bold">Foto Capturada</h4>
|
||||
</div>
|
||||
<div class="flex justify-center bg-base-100 rounded-xl p-4 border-2 border-primary/20">
|
||||
<div
|
||||
class="bg-base-100 border-primary/20 flex justify-center rounded-xl border-2 p-4"
|
||||
>
|
||||
<img
|
||||
src={registro.imagemUrl}
|
||||
alt="Foto do registro de ponto"
|
||||
class="max-w-full max-h-[300px] rounded-lg shadow-md object-contain"
|
||||
class="max-h-[300px] max-w-full rounded-lg object-contain shadow-md"
|
||||
onerror={(e) => {
|
||||
console.error('Erro ao carregar imagem:', e);
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
@@ -538,12 +610,18 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer fixo com botões -->
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t-2 border-primary/20 bg-base-100/50 backdrop-blur-sm flex-shrink-0">
|
||||
<div
|
||||
class="border-primary/20 bg-base-100/50 flex flex-shrink-0 justify-end gap-3 border-t-2 px-6 py-4 backdrop-blur-sm"
|
||||
>
|
||||
<button class="btn btn-outline gap-2" onclick={onClose}>
|
||||
<X class="h-4 w-4" />
|
||||
Fechar
|
||||
</button>
|
||||
<button class="btn btn-primary gap-2 shadow-lg hover:shadow-xl transition-all" onclick={gerarPDF} disabled={gerando}>
|
||||
<button
|
||||
class="btn btn-primary gap-2 shadow-lg transition-all hover:shadow-xl"
|
||||
onclick={gerarPDF}
|
||||
disabled={gerando}
|
||||
>
|
||||
{#if gerando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Gerando...
|
||||
@@ -604,4 +682,3 @@
|
||||
background-color: hsl(var(--bc) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { MapPin, AlertCircle, HelpCircle } from 'lucide-svelte';
|
||||
import { AlertCircle, HelpCircle, MapPin } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
dentroRaioPermitido: boolean | null | undefined;
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
let { dentroRaioPermitido, showTooltip = true }: Props = $props();
|
||||
const { dentroRaioPermitido, showTooltip = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if dentroRaioPermitido === true}
|
||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Dentro do Raio' : ''}>
|
||||
<MapPin class="h-5 w-5 text-success" strokeWidth={2.5} />
|
||||
<MapPin class="text-success h-5 w-5" strokeWidth={2.5} />
|
||||
</div>
|
||||
{:else if dentroRaioPermitido === false}
|
||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Fora do Raio' : ''}>
|
||||
<AlertCircle class="h-5 w-5 text-error" strokeWidth={2.5} />
|
||||
<AlertCircle class="text-error h-5 w-5" strokeWidth={2.5} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Não Validado' : ''}>
|
||||
<HelpCircle class="h-5 w-5 text-base-content/40" strokeWidth={2.5} />
|
||||
<HelpCircle class="text-base-content/40 h-5 w-5" strokeWidth={2.5} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { CheckCircle2, X, Printer } from 'lucide-svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { CheckCircle2, Printer, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
funcionarioId: Id<'funcionarios'>;
|
||||
@@ -15,18 +15,18 @@
|
||||
}) => void;
|
||||
}
|
||||
|
||||
let { funcionarioId, onClose, onGenerate }: Props = $props();
|
||||
const { funcionarioId, onClose, onGenerate }: Props = $props();
|
||||
|
||||
let modalRef: HTMLDialogElement;
|
||||
|
||||
// Seções selecionáveis
|
||||
let sections = $state({
|
||||
const sections = $state({
|
||||
dadosFuncionario: true,
|
||||
registrosPonto: true,
|
||||
saldoDiario: true,
|
||||
bancoHoras: true,
|
||||
alteracoesGestor: true,
|
||||
dispensasRegistro: true,
|
||||
dispensasRegistro: true
|
||||
});
|
||||
|
||||
function selectAll() {
|
||||
@@ -62,14 +62,14 @@
|
||||
|
||||
<dialog bind:this={modalRef} class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="font-bold text-2xl">Selecionar Campos para Impressão</h3>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h3 class="text-2xl font-bold">Selecionar Campos para Impressão</h3>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" onclick={handleClose} aria-label="Fechar">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- Seção 1: Dados do Funcionário -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
@@ -81,7 +81,7 @@
|
||||
bind:checked={sections.dadosFuncionario}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
<p class="text-base-content/70 mt-2 text-sm">
|
||||
Nome, matrícula, cargo e informações básicas
|
||||
</p>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
bind:checked={sections.registrosPonto}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
<p class="text-base-content/70 mt-2 text-sm">
|
||||
Data, tipo, horário e status de cada registro
|
||||
</p>
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@
|
||||
bind:checked={sections.saldoDiario}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
<p class="text-base-content/70 mt-2 text-sm">
|
||||
Saldo em horas e minutos de cada dia (positivo/negativo)
|
||||
</p>
|
||||
</div>
|
||||
@@ -132,9 +132,7 @@
|
||||
bind:checked={sections.bancoHoras}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
Saldo acumulado do banco de horas
|
||||
</p>
|
||||
<p class="text-base-content/70 mt-2 text-sm">Saldo acumulado do banco de horas</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +147,7 @@
|
||||
bind:checked={sections.alteracoesGestor}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
<p class="text-base-content/70 mt-2 text-sm">
|
||||
Edições e ajustes realizados pelo gestor (se houver)
|
||||
</p>
|
||||
</div>
|
||||
@@ -166,7 +164,7 @@
|
||||
bind:checked={sections.dispensasRegistro}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
<p class="text-base-content/70 mt-2 text-sm">
|
||||
Períodos onde o funcionário esteve dispensado de registrar ponto
|
||||
</p>
|
||||
</div>
|
||||
@@ -175,18 +173,12 @@
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" onclick={selectAll}>
|
||||
Selecionar Todos
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline" onclick={deselectAll}>
|
||||
Desmarcar Todos
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline" onclick={selectAll}> Selecionar Todos </button>
|
||||
<button class="btn btn-sm btn-outline" onclick={deselectAll}> Desmarcar Todos </button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-ghost" onclick={handleClose}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn btn-ghost" onclick={handleClose}> Cancelar </button>
|
||||
<button class="btn btn-primary gap-2" onclick={handleGenerate}>
|
||||
<Printer class="h-4 w-4" />
|
||||
Gerar PDF
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { obterTempoServidor, obterTempoPC } from '$lib/utils/sincronizacaoTempo';
|
||||
import { CheckCircle2, AlertCircle, Clock } from 'lucide-svelte';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { AlertCircle, CheckCircle2, Clock } from 'lucide-svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { obterTempoPC, obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
let timestampAjustado: number;
|
||||
if (gmtOffset !== 0) {
|
||||
// Aplicar offset configurado
|
||||
timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
|
||||
timestampAjustado = timestampBase + gmtOffset * 60 * 60 * 1000;
|
||||
} else {
|
||||
// Quando GMT = 0, manter timestamp UTC puro
|
||||
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
|
||||
@@ -99,7 +99,7 @@
|
||||
return tempoAtual.toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,30 +108,30 @@
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 w-full">
|
||||
<div class="flex w-full flex-col items-center gap-4">
|
||||
<!-- Hora -->
|
||||
<div class="text-5xl font-black font-mono text-primary tracking-tight drop-shadow-sm">
|
||||
<div class="text-primary font-mono text-5xl font-black tracking-tight drop-shadow-sm">
|
||||
{horaFormatada}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Data -->
|
||||
<div class="text-base font-semibold text-base-content/80 capitalize">
|
||||
<div class="text-base-content/80 text-base font-semibold capitalize">
|
||||
{dataFormatada}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Status de Sincronização -->
|
||||
<div class="flex items-center gap-2 px-4 py-2 rounded-full {
|
||||
sincronizado
|
||||
? 'bg-success/20 text-success border border-success/30'
|
||||
: erro
|
||||
? 'bg-warning/20 text-warning border border-warning/30'
|
||||
: 'bg-base-300/50 text-base-content/60 border border-base-300'
|
||||
}">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full px-4 py-2 {sincronizado
|
||||
? 'bg-success/20 text-success border-success/30 border'
|
||||
: erro
|
||||
? 'bg-warning/20 text-warning border-warning/30 border'
|
||||
: 'bg-base-300/50 text-base-content/60 border-base-300 border'}"
|
||||
>
|
||||
{#if sincronizado}
|
||||
<CheckCircle2 class="h-4 w-4" strokeWidth={2.5} />
|
||||
<span class="text-sm font-semibold">
|
||||
@@ -150,4 +150,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
let { saldo, size = 'md' }: Props = $props();
|
||||
const { saldo, size = 'md' }: Props = $props();
|
||||
|
||||
function formatarSaldo(saldo: NonNullable<Props['saldo']>): string {
|
||||
const sinal = saldo.positivo ? '+' : '-';
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
let { saldo, size = 'md' }: Props = $props();
|
||||
const { saldo, size = 'md' }: Props = $props();
|
||||
|
||||
function formatarMinutos(minutos: number): { horas: number; minutos: number } {
|
||||
const horas = Math.floor(Math.abs(minutos) / 60);
|
||||
@@ -28,19 +28,24 @@
|
||||
{@const diferenca = formatarMinutos(saldo.diferencaMinutos)}
|
||||
{@const sinalDiferenca = saldo.diferencaMinutos >= 0 ? '+' : '-'}
|
||||
{@const isNegativo = saldo.diferencaMinutos < 0}
|
||||
|
||||
<div class="inline-flex items-center gap-1.5 {sizeClasses[size]} rounded-lg font-semibold shadow-sm border {
|
||||
isNegativo
|
||||
? 'bg-red-50 border-red-200 text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-400'
|
||||
: 'bg-green-50 border-green-200 text-green-700 dark:bg-green-900/20 dark:border-green-800 dark:text-green-400'
|
||||
}">
|
||||
<span class="font-bold text-green-600 dark:text-green-400">+{trabalhado.horas}h {trabalhado.minutos}min</span>
|
||||
|
||||
<div
|
||||
class="inline-flex items-center gap-1.5 {sizeClasses[
|
||||
size
|
||||
]} rounded-lg border font-semibold shadow-sm {isNegativo
|
||||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400'
|
||||
: 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'}"
|
||||
>
|
||||
<span class="font-bold text-green-600 dark:text-green-400"
|
||||
>+{trabalhado.horas}h {trabalhado.minutos}min</span
|
||||
>
|
||||
<span class="text-base-content/50">/</span>
|
||||
<span class={isNegativo ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}>
|
||||
<span
|
||||
class={isNegativo ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}
|
||||
>
|
||||
{sinalDiferenca}{diferenca.horas}h {diferenca.minutos}min
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="badge badge-ghost {sizeClasses[size]}">-</span>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Camera, X, Check, AlertCircle } from 'lucide-svelte';
|
||||
import { validarWebcamDisponivel, capturarWebcamComPreview } from '$lib/utils/webcam';
|
||||
import { AlertCircle, Camera, Check, X } from 'lucide-svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { capturarWebcamComPreview, validarWebcamDisponivel } from '$lib/utils/webcam';
|
||||
|
||||
interface Props {
|
||||
onCapture: (blob: Blob | null) => void;
|
||||
@@ -11,10 +11,16 @@
|
||||
fotoObrigatoria?: boolean; // Se true, não permite continuar sem foto
|
||||
}
|
||||
|
||||
let { onCapture, onCancel, onError, autoCapture = false, fotoObrigatoria = false }: Props = $props();
|
||||
const {
|
||||
onCapture,
|
||||
onCancel,
|
||||
onError,
|
||||
autoCapture = false,
|
||||
fotoObrigatoria = false
|
||||
}: Props = $props();
|
||||
|
||||
let videoElement: HTMLVideoElement | null = $state(null);
|
||||
let canvasElement: HTMLCanvasElement | null = $state(null);
|
||||
const videoElement: HTMLVideoElement | null = $state(null);
|
||||
const canvasElement: HTMLCanvasElement | null = $state(null);
|
||||
let stream: MediaStream | null = $state(null);
|
||||
let webcamDisponivel = $state(false);
|
||||
let capturando = $state(false);
|
||||
@@ -32,7 +38,7 @@
|
||||
if (videoElement.srcObject !== stream) {
|
||||
videoElement.srcObject = stream;
|
||||
}
|
||||
|
||||
|
||||
// Tentar reproduzir se ainda não estiver pronto e não houver outra chamada em andamento
|
||||
if (!videoReady && videoElement.readyState < 2) {
|
||||
// Verificar se já não está reproduzindo
|
||||
@@ -42,7 +48,8 @@
|
||||
}
|
||||
|
||||
playEmAndamento = true;
|
||||
videoElement.play()
|
||||
videoElement
|
||||
.play()
|
||||
.then(() => {
|
||||
playEmAndamento = false;
|
||||
// Aguardar um pouco para garantir que o vídeo esteja realmente reproduzindo
|
||||
@@ -67,17 +74,17 @@
|
||||
|
||||
onMount(async () => {
|
||||
// Aguardar mais tempo para garantir que os elementos estejam no DOM
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Verificar suporte
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
// Tentar método alternativo (navegadores antigos)
|
||||
const getUserMedia =
|
||||
navigator.getUserMedia ||
|
||||
(navigator as any).webkitGetUserMedia ||
|
||||
(navigator as any).mozGetUserMedia ||
|
||||
const getUserMedia =
|
||||
navigator.getUserMedia ||
|
||||
(navigator as any).webkitGetUserMedia ||
|
||||
(navigator as any).mozGetUserMedia ||
|
||||
(navigator as any).msGetUserMedia;
|
||||
|
||||
|
||||
if (!getUserMedia) {
|
||||
erro = 'Webcam não suportada';
|
||||
if (autoCapture && onError) {
|
||||
@@ -118,15 +125,15 @@
|
||||
|
||||
let ultimoErro: Error | null = null;
|
||||
let streamObtido = false;
|
||||
|
||||
|
||||
for (const constraint of constraints) {
|
||||
try {
|
||||
console.log('Tentando acessar webcam com constraint:', constraint);
|
||||
const tempStream = await navigator.mediaDevices.getUserMedia(constraint);
|
||||
|
||||
|
||||
// Verificar se o stream tem tracks de vídeo
|
||||
if (tempStream.getVideoTracks().length === 0) {
|
||||
tempStream.getTracks().forEach(track => track.stop());
|
||||
tempStream.getTracks().forEach((track) => track.stop());
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -138,7 +145,6 @@
|
||||
} catch (err) {
|
||||
console.warn('Falha ao acessar webcam com constraint:', constraint, err);
|
||||
ultimoErro = err instanceof Error ? err : new Error(String(err));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,14 +155,14 @@
|
||||
// Agora que temos o stream, aguardar o elemento de vídeo estar disponível
|
||||
let tentativas = 0;
|
||||
while (!videoElement && tentativas < 30) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
tentativas++;
|
||||
}
|
||||
|
||||
if (!videoElement) {
|
||||
erro = 'Elemento de vídeo não encontrado';
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
webcamDisponivel = false;
|
||||
@@ -172,7 +178,7 @@
|
||||
// Atribuir stream ao elemento de vídeo
|
||||
if (videoElement && stream) {
|
||||
videoElement.srcObject = stream;
|
||||
|
||||
|
||||
// Aguardar o vídeo estar pronto com timeout maior
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -237,7 +243,8 @@
|
||||
// Tentar reproduzir apenas se não estiver já reproduzindo
|
||||
if (videoElement.paused) {
|
||||
playEmAndamento = true;
|
||||
videoElement.play()
|
||||
videoElement
|
||||
.play()
|
||||
.then(() => {
|
||||
playEmAndamento = false;
|
||||
console.log('Vídeo iniciado, readyState:', videoElement?.readyState);
|
||||
@@ -280,7 +287,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Vídeo pronto, dimensões:', videoElement.videoWidth, 'x', videoElement.videoHeight);
|
||||
console.log(
|
||||
'Vídeo pronto, dimensões:',
|
||||
videoElement.videoWidth,
|
||||
'x',
|
||||
videoElement.videoHeight
|
||||
);
|
||||
}
|
||||
|
||||
// Se for captura automática, aguardar um pouco e capturar
|
||||
@@ -298,12 +310,15 @@
|
||||
} catch (error) {
|
||||
console.error('Erro ao acessar webcam:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
|
||||
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
|
||||
erro = fotoObrigatoria
|
||||
erro = fotoObrigatoria
|
||||
? 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.'
|
||||
: 'Permissão de webcam negada. Continuando sem foto.';
|
||||
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) {
|
||||
} else if (
|
||||
errorMessage.includes('NotFoundError') ||
|
||||
errorMessage.includes('DevicesNotFoundError')
|
||||
) {
|
||||
erro = fotoObrigatoria
|
||||
? 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.'
|
||||
: 'Nenhuma webcam encontrada. Continuando sem foto.';
|
||||
@@ -312,7 +327,7 @@
|
||||
? 'Erro ao acessar webcam. Verifique as permissões e tente novamente.'
|
||||
: 'Erro ao acessar webcam. Continuando sem foto.';
|
||||
}
|
||||
|
||||
|
||||
webcamDisponivel = false;
|
||||
// Se foto é obrigatória, não chamar onError para permitir continuar sem foto
|
||||
if (fotoObrigatoria) {
|
||||
@@ -347,14 +362,23 @@
|
||||
}
|
||||
|
||||
// Verificar se o vídeo está pronto e tem dimensões válidas
|
||||
if (videoElement.readyState < 2 || videoElement.videoWidth === 0 || videoElement.videoHeight === 0) {
|
||||
if (
|
||||
videoElement.readyState < 2 ||
|
||||
videoElement.videoWidth === 0 ||
|
||||
videoElement.videoHeight === 0
|
||||
) {
|
||||
console.warn('Vídeo ainda não está pronto, aguardando...');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let tentativas = 0;
|
||||
const maxTentativas = 50; // 5 segundos
|
||||
const checkReady = () => {
|
||||
tentativas++;
|
||||
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
|
||||
if (
|
||||
videoElement &&
|
||||
videoElement.readyState >= 2 &&
|
||||
videoElement.videoWidth > 0 &&
|
||||
videoElement.videoHeight > 0
|
||||
) {
|
||||
resolve();
|
||||
} else if (tentativas >= maxTentativas) {
|
||||
reject(new Error('Timeout aguardando vídeo ficar pronto'));
|
||||
@@ -369,7 +393,7 @@
|
||||
capturando = false;
|
||||
return; // Retornar aqui para não continuar
|
||||
});
|
||||
|
||||
|
||||
// Se chegou aqui, o vídeo está pronto, continuar com a captura
|
||||
}
|
||||
|
||||
@@ -379,7 +403,9 @@
|
||||
try {
|
||||
// Verificar dimensões do vídeo novamente antes de capturar
|
||||
if (!videoElement.videoWidth || !videoElement.videoHeight) {
|
||||
throw new Error('Dimensões do vídeo não disponíveis. Aguarde a câmera carregar completamente.');
|
||||
throw new Error(
|
||||
'Dimensões do vídeo não disponíveis. Aguarde a câmera carregar completamente.'
|
||||
);
|
||||
}
|
||||
|
||||
// Configurar canvas com as dimensões do vídeo
|
||||
@@ -394,7 +420,7 @@
|
||||
|
||||
// Limpar canvas antes de desenhar
|
||||
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
|
||||
|
||||
|
||||
// Desenhar frame atual do vídeo no canvas
|
||||
// O vídeo está espelhado no CSS para visualização, mas capturamos normalmente
|
||||
ctx.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height);
|
||||
@@ -417,7 +443,7 @@
|
||||
if (blob && blob.size > 0) {
|
||||
previewUrl = URL.createObjectURL(blob);
|
||||
console.log('Imagem capturada com sucesso, tamanho:', blob.size, 'bytes');
|
||||
|
||||
|
||||
// Parar stream para mostrar preview
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
@@ -504,7 +530,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 p-4 w-full">
|
||||
<div class="flex w-full flex-col items-center gap-4 p-4">
|
||||
{#if !webcamDisponivel && !erro}
|
||||
<div class="text-warning flex items-center gap-2">
|
||||
<Camera class="h-5 w-5" />
|
||||
@@ -520,61 +546,73 @@
|
||||
<span>A captura de foto é obrigatória para registrar o ponto.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if erro && !webcamDisponivel}
|
||||
{:else if erro && !webcamDisponivel}
|
||||
<div class="alert alert-error max-w-md">
|
||||
<AlertCircle class="h-5 w-5" />
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{#if fotoObrigatoria}
|
||||
<div class="alert alert-warning max-w-md">
|
||||
<span>Não é possível registrar o ponto sem capturar uma foto. Verifique as permissões da webcam e tente novamente.</span>
|
||||
<span
|
||||
>Não é possível registrar o ponto sem capturar uma foto. Verifique as permissões da webcam
|
||||
e tente novamente.</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick={async () => {
|
||||
erro = null;
|
||||
webcamDisponivel = false;
|
||||
videoReady = false;
|
||||
// Limpar stream anterior se existir
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
// Tentar reiniciar a webcam
|
||||
try {
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia && videoElement) {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
if (stream.getVideoTracks().length > 0) {
|
||||
webcamDisponivel = true;
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={async () => {
|
||||
erro = null;
|
||||
webcamDisponivel = false;
|
||||
videoReady = false;
|
||||
// Limpar stream anterior se existir
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
// Tentar reiniciar a webcam
|
||||
try {
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia && videoElement) {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
if (stream.getVideoTracks().length > 0) {
|
||||
webcamDisponivel = true;
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
}
|
||||
} else {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
erro =
|
||||
'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||
}
|
||||
} else {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
stream = null;
|
||||
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||
erro = 'Webcam não disponível. Verifique as permissões e tente novamente.';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erro ao tentar novamente:', e);
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
if (
|
||||
errorMessage.includes('Permission denied') ||
|
||||
errorMessage.includes('NotAllowedError')
|
||||
) {
|
||||
erro =
|
||||
'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.';
|
||||
} else if (
|
||||
errorMessage.includes('NotFoundError') ||
|
||||
errorMessage.includes('DevicesNotFoundError')
|
||||
) {
|
||||
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||
} else {
|
||||
erro = 'Erro ao acessar webcam. Verifique as permissões e tente novamente.';
|
||||
}
|
||||
} else {
|
||||
erro = 'Webcam não disponível. Verifique as permissões e tente novamente.';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erro ao tentar novamente:', e);
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
|
||||
erro = 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.';
|
||||
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) {
|
||||
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||
} else {
|
||||
erro = 'Erro ao acessar webcam. Verifique as permissões e tente novamente.';
|
||||
}
|
||||
}
|
||||
}}>Tentar Novamente</button>
|
||||
}}>Tentar Novamente</button
|
||||
>
|
||||
<button class="btn btn-error" onclick={cancelar}>Fechar</button>
|
||||
</div>
|
||||
{:else if autoCapture}
|
||||
<div class="text-sm text-base-content/70 text-center">
|
||||
O registro será feito sem foto.
|
||||
</div>
|
||||
<div class="text-base-content/70 text-center text-sm">O registro será feito sem foto.</div>
|
||||
{:else}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||
@@ -582,10 +620,10 @@
|
||||
{/if}
|
||||
{:else if previewUrl}
|
||||
<!-- Preview da imagem capturada -->
|
||||
<div class="flex flex-col items-center gap-4 w-full">
|
||||
<div class="flex w-full flex-col items-center gap-4">
|
||||
{#if autoCapture}
|
||||
<!-- Modo automático: mostrar apenas preview sem botões -->
|
||||
<div class="text-sm text-base-content/70 mb-2 text-center">
|
||||
<div class="text-base-content/70 mb-2 text-center text-sm">
|
||||
Foto capturada automaticamente...
|
||||
</div>
|
||||
{/if}
|
||||
@@ -596,7 +634,7 @@
|
||||
/>
|
||||
{#if !autoCapture}
|
||||
<!-- Botões apenas se não for automático -->
|
||||
<div class="flex gap-2 flex-wrap justify-center">
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button class="btn btn-success" onclick={confirmar}>
|
||||
<Check class="h-5 w-5" />
|
||||
Confirmar
|
||||
@@ -614,33 +652,37 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Webcam ativa -->
|
||||
<div class="flex flex-col items-center gap-4 w-full">
|
||||
<div class="flex w-full flex-col items-center gap-4">
|
||||
{#if autoCapture}
|
||||
<div class="text-sm text-base-content/70 mb-2 text-center">
|
||||
<div class="text-base-content/70 mb-2 text-center text-sm">
|
||||
Capturando foto automaticamente...
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm text-base-content/70 mb-2 text-center">
|
||||
<div class="text-base-content/70 mb-2 text-center text-sm">
|
||||
Posicione-se na frente da câmera e clique em "Capturar Foto"
|
||||
</div>
|
||||
{/if}
|
||||
<div class="relative w-full flex justify-center">
|
||||
<div class="relative flex w-full justify-center">
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain bg-black {!videoReady ? 'opacity-50' : ''}"
|
||||
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 bg-black object-contain {!videoReady
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
style="min-width: 320px; min-height: 240px; transform: scaleX(-1);"
|
||||
></video>
|
||||
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
||||
{#if !videoReady && webcamDisponivel}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center bg-black/70 rounded-lg gap-2">
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-lg bg-black/70"
|
||||
>
|
||||
<span class="loading loading-spinner loading-lg text-white"></span>
|
||||
<span class="text-white text-sm">Carregando câmera...</span>
|
||||
<span class="text-sm text-white">Carregando câmera...</span>
|
||||
</div>
|
||||
{:else if videoReady && webcamDisponivel}
|
||||
<div class="absolute bottom-2 left-1/2 transform -translate-x-1/2">
|
||||
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 transform">
|
||||
<div class="badge badge-success gap-2">
|
||||
<Camera class="h-4 w-4" />
|
||||
Câmera ativa
|
||||
@@ -655,10 +697,10 @@
|
||||
{/if}
|
||||
{#if !autoCapture}
|
||||
<!-- Botões sempre visíveis quando não for automático -->
|
||||
<div class="flex gap-2 flex-wrap justify-center">
|
||||
<button
|
||||
class="btn btn-primary btn-lg"
|
||||
onclick={capturar}
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
class="btn btn-primary btn-lg"
|
||||
onclick={capturar}
|
||||
disabled={capturando || !videoReady || !webcamDisponivel}
|
||||
>
|
||||
{#if capturando}
|
||||
|
||||
@@ -1,45 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { Clock, CheckCircle2, XCircle } from 'lucide-svelte';
|
||||
import { CheckCircle2, Clock, XCircle } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
|
||||
<div class="card bg-base-100 shadow-xl transition-all duration-300 hover:shadow-2xl">
|
||||
<div class="card-body">
|
||||
<!-- Cabeçalho da Categoria -->
|
||||
<div class="flex items-start gap-6 mb-6">
|
||||
<div class="p-4 bg-blue-500/20 rounded-2xl">
|
||||
<div class="mb-6 flex items-start gap-6">
|
||||
<div class="rounded-2xl bg-blue-500/20 p-4">
|
||||
<div class="text-blue-600">
|
||||
<Clock class="h-12 w-12" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="card-title text-2xl mb-2 text-blue-600">
|
||||
Gestão de Pontos
|
||||
</h2>
|
||||
<h2 class="card-title mb-2 text-2xl text-blue-600">Gestão de Pontos</h2>
|
||||
<p class="text-base-content/70">Registros de ponto do dia</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid de Opções -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<a
|
||||
href={resolve('/(dashboard)/recursos-humanos/registro-pontos')}
|
||||
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-blue-500/10 to-blue-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
|
||||
class="group border-base-300 hover:border-primary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br from-blue-500/10 to-blue-600/20 p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div
|
||||
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
|
||||
class="bg-base-100 group-hover:bg-primary rounded-lg p-3 transition-colors duration-300 group-hover:text-white"
|
||||
>
|
||||
<div
|
||||
class="text-blue-600 group-hover:text-white"
|
||||
>
|
||||
<div class="text-blue-600 group-hover:text-white">
|
||||
<Clock class="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
|
||||
class="text-base-content/30 group-hover:text-primary h-5 w-5 transition-colors duration-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -53,33 +49,31 @@
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
|
||||
class="text-base-content group-hover:text-primary mb-2 text-lg font-bold transition-colors duration-300"
|
||||
>
|
||||
Gestão de Pontos
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70 flex-1">
|
||||
<p class="text-base-content/70 flex-1 text-sm">
|
||||
Visualizar e gerenciar registros de ponto
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/homologacao')}
|
||||
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-green-500/10 to-green-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
|
||||
class="group border-base-300 hover:border-primary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br from-green-500/10 to-green-600/20 p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div
|
||||
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
|
||||
class="bg-base-100 group-hover:bg-primary rounded-lg p-3 transition-colors duration-300 group-hover:text-white"
|
||||
>
|
||||
<div
|
||||
class="text-green-600 group-hover:text-white"
|
||||
>
|
||||
<div class="text-green-600 group-hover:text-white">
|
||||
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
|
||||
class="text-base-content/30 group-hover:text-primary h-5 w-5 transition-colors duration-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -93,33 +87,31 @@
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
|
||||
class="text-base-content group-hover:text-primary mb-2 text-lg font-bold transition-colors duration-300"
|
||||
>
|
||||
Homologação de Registro
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70 flex-1">
|
||||
<p class="text-base-content/70 flex-1 text-sm">
|
||||
Edite registros de ponto e ajuste banco de horas
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/dispensa')}
|
||||
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-orange-500/10 to-orange-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
|
||||
class="group border-base-300 hover:border-primary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br from-orange-500/10 to-orange-600/20 p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div
|
||||
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
|
||||
class="bg-base-100 group-hover:bg-primary rounded-lg p-3 transition-colors duration-300 group-hover:text-white"
|
||||
>
|
||||
<div
|
||||
class="text-orange-600 group-hover:text-white"
|
||||
>
|
||||
<div class="text-orange-600 group-hover:text-white">
|
||||
<XCircle class="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
|
||||
class="text-base-content/30 group-hover:text-primary h-5 w-5 transition-colors duration-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -133,11 +125,11 @@
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
|
||||
class="text-base-content group-hover:text-primary mb-2 text-lg font-bold transition-colors duration-300"
|
||||
>
|
||||
Dispensa de Registro
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70 flex-1">
|
||||
<p class="text-base-content/70 flex-1 text-sm">
|
||||
Gerencie períodos de dispensa de registro de ponto
|
||||
</p>
|
||||
</div>
|
||||
@@ -145,4 +137,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user