Merge remote-tracking branch 'origin' into feat-pedidos

This commit is contained in:
2025-12-02 00:58:10 -03:00
38 changed files with 9633 additions and 3679 deletions

View File

@@ -13,25 +13,28 @@
let modalPosition = $state<{ top: number; left: number } | null>(null);
// Função para calcular a posição baseada no relógio sincronizado
// Função para calcular a posição baseada no card de registro de ponto
function calcularPosicaoModal() {
// Procurar pelo elemento do relógio sincronizado
const relogioRef = document.getElementById('relogio-sincronizado-ref');
// Procurar pelo elemento do card de registro de ponto
const cardRef = document.getElementById('card-registro-ponto-ref');
if (relogioRef) {
const rect = relogioRef.getBoundingClientRect();
const viewportWidth = window.innerWidth;
if (cardRef) {
const rect = cardRef.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Posicionar o modal na mesma posição do relógio sincronizado
// Centralizado horizontalmente no card do relógio
const left = rect.left + (rect.width / 2);
// Posicionar abaixo do card do relógio com um pequeno espaçamento
const top = rect.bottom + 20;
// 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: top,
left: left
top: finalTop,
left: window.innerWidth / 2
};
}
@@ -75,37 +78,12 @@
// Função para obter estilo do modal baseado na posição calculada
function getModalStyle() {
if (modalPosition) {
// Garantir que o modal não saia da viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const modalWidth = 700; // Aproximadamente max-w-2xl
const modalHeight = Math.min(viewportHeight * 0.9, 600);
let left = modalPosition.left;
let top = modalPosition.top;
// Ajustar se o modal sair da viewport à direita
if (left + (modalWidth / 2) > viewportWidth - 20) {
left = viewportWidth - (modalWidth / 2) - 20;
}
// Ajustar se o modal sair da viewport à esquerda
if (left - (modalWidth / 2) < 20) {
left = (modalWidth / 2) + 20;
}
// Ajustar se o modal sair da viewport abaixo
if (top + modalHeight > viewportHeight - 20) {
top = viewportHeight - modalHeight - 20;
}
// Ajustar se o modal sair da viewport acima
if (top < 20) {
top = 20;
}
// Usar transform para centralizar horizontalmente baseado no left calculado
return `position: fixed; top: ${top}px; left: ${left}px; transform: translateX(-50%); max-width: ${Math.min(modalWidth, viewportWidth - 40)}px;`;
// Posicionar na altura do card, centralizado horizontalmente
// position: fixed já é relativo à viewport, então podemos usar diretamente
return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 700px;`;
}
// Se não houver posição calculada, centralizar na tela
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);';
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 700px;';
}
// Verificar se details contém instruções ou apenas detalhes técnicos

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
interface Props {
value?: string; // Matrícula do funcionário
placeholder?: string;
disabled?: boolean;
}
let {
value = $bindable(''),
placeholder = 'Digite a matrícula do funcionário',
disabled = false
}: Props = $props();
// Usar value diretamente como busca para evitar conflitos de sincronização
let mostrarDropdown = $state(false);
// Buscar funcionários
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
// Filtrar funcionários baseado na busca (por matrícula ou nome)
const funcionariosFiltrados = $derived.by(() => {
if (!value || !value.trim()) return funcionarios.slice(0, 10); // Limitar a 10 quando vazio
const termo = value.toLowerCase().trim();
return funcionarios.filter((f) => {
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
const nomeMatch = f.nome?.toLowerCase().includes(termo);
return matriculaMatch || nomeMatch;
}).slice(0, 20); // Limitar resultados
});
function selecionarFuncionario(matricula: string) {
value = matricula;
mostrarDropdown = false;
}
function handleFocus() {
if (!disabled) {
mostrarDropdown = true;
}
}
function handleBlur() {
// Delay para permitir click no dropdown
setTimeout(() => {
mostrarDropdown = false;
}, 200);
}
function handleInput() {
mostrarDropdown = true;
}
</script>
<div class="relative w-full">
<input
type="text"
bind:value
oninput={handleInput}
{placeholder}
{disabled}
onfocus={handleFocus}
onblur={handleBlur}
class="input input-bordered w-full pr-10"
autocomplete="off"
/>
<div class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
<div
class="bg-base-100 border-base-300 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border shadow-lg"
>
{#each funcionariosFiltrados as funcionario}
<button
type="button"
onclick={() => selecionarFuncionario(funcionario.matricula || '')}
class="hover:bg-base-200 border-base-200 w-full border-b px-4 py-3 text-left transition-colors last:border-b-0"
>
<div class="font-medium">
{#if funcionario.matricula}
Matrícula: {funcionario.matricula}
{:else}
Sem matrícula
{/if}
</div>
<div class="text-base-content/60 text-sm">
{funcionario.nome}
{#if funcionario.descricaoCargo}
{funcionario.nome ? ' • ' : ''}
{funcionario.descricaoCargo}
{/if}
</div>
</button>
{/each}
</div>
{/if}
{#if mostrarDropdown && value && value.trim() && funcionariosFiltrados.length === 0}
<div
class="bg-base-100 border-base-300 text-base-content/60 absolute z-50 mt-1 w-full rounded-lg border p-4 text-center shadow-lg"
>
<div class="text-sm">Nenhum funcionário encontrado</div>
<div class="text-xs mt-1 opacity-70">Você pode continuar digitando para buscar livremente</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,124 @@
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
interface Props {
value?: string; // Nome do funcionário
placeholder?: string;
disabled?: boolean;
}
let {
value = $bindable(''),
placeholder = 'Digite o nome do funcionário',
disabled = false
}: Props = $props();
// Usar value diretamente como busca para evitar conflitos de sincronização
let mostrarDropdown = $state(false);
// Buscar funcionários
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
// Filtrar funcionários baseado na busca (por nome ou matrícula)
const funcionariosFiltrados = $derived.by(() => {
if (!value || !value.trim()) return funcionarios.slice(0, 10); // Limitar a 10 quando vazio
const termo = value.toLowerCase().trim();
return funcionarios.filter((f) => {
const nomeMatch = f.nome?.toLowerCase().includes(termo);
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
return nomeMatch || matriculaMatch;
}).slice(0, 20); // Limitar resultados
});
function selecionarFuncionario(nome: string) {
value = nome;
mostrarDropdown = false;
}
function handleFocus() {
if (!disabled) {
mostrarDropdown = true;
}
}
function handleBlur() {
// Delay para permitir click no dropdown
setTimeout(() => {
mostrarDropdown = false;
}, 200);
}
function handleInput() {
mostrarDropdown = true;
}
</script>
<div class="relative w-full">
<input
type="text"
bind:value
oninput={handleInput}
{placeholder}
{disabled}
onfocus={handleFocus}
onblur={handleBlur}
class="input input-bordered w-full pr-10"
autocomplete="off"
/>
<div class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
<div
class="bg-base-100 border-base-300 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border shadow-lg"
>
{#each funcionariosFiltrados as funcionario}
<button
type="button"
onclick={() => selecionarFuncionario(funcionario.nome || '')}
class="hover:bg-base-200 border-base-200 w-full border-b px-4 py-3 text-left transition-colors last:border-b-0"
>
<div class="font-medium">{funcionario.nome}</div>
<div class="text-base-content/60 text-sm">
{#if funcionario.matricula}
Matrícula: {funcionario.matricula}
{/if}
{#if funcionario.descricaoCargo}
{funcionario.matricula ? ' • ' : ''}
{funcionario.descricaoCargo}
{/if}
</div>
</button>
{/each}
</div>
{/if}
{#if mostrarDropdown && value && value.trim() && funcionariosFiltrados.length === 0}
<div
class="bg-base-100 border-base-300 text-base-content/60 absolute z-50 mt-1 w-full rounded-lg border p-4 text-center shadow-lg"
>
<div class="text-sm">Nenhum funcionário encontrado</div>
<div class="text-xs mt-1 opacity-70">Você pode continuar digitando para buscar livremente</div>
</div>
{/if}
</div>

View File

@@ -4,7 +4,7 @@
import logo from '$lib/assets/logo_governo_PE.png';
import type { Snippet } from 'svelte';
import { loginModalStore } from '$lib/stores/loginModal.svelte';
import { useQuery } from 'convex-svelte';
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
import ChatWidget from '$lib/components/chat/ChatWidget.svelte';
@@ -13,12 +13,14 @@
import { Menu, User, Home, UserPlus, XCircle, LogIn, Tag, Plus, Check } from 'lucide-svelte';
import { authClient } from '$lib/auth';
import { resolve } from '$app/paths';
import { obterIPPublico } from '$lib/utils/deviceInfo';
let { children }: { children: Snippet } = $props();
const currentPath = $derived(page.url.pathname);
const currentUser = useQuery(api.auth.getCurrentUser, {});
const convexClient = useConvexClient();
// Função para obter a URL do avatar/foto do usuário
const avatarUrlDoUsuario = $derived(() => {
@@ -122,18 +124,133 @@
erroLogin = '';
carregandoLogin = true;
// const browserInfo = await getBrowserInfo();
// Obter IP público e userAgent (rápido, não bloqueia)
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : undefined;
// Obter IP público com timeout curto (não bloquear login)
const ipPublicoPromise = obterIPPublico().catch(() => undefined);
const ipPublicoTimeout = new Promise<undefined>((resolve) =>
setTimeout(() => resolve(undefined), 2000) // Timeout de 2 segundos
);
const ipPublico = await Promise.race([ipPublicoPromise, ipPublicoTimeout]);
// Função para coletar GPS em background (não bloqueia login)
async function coletarGPS(): Promise<any> {
try {
const { obterLocalizacaoRapida } = await import('$lib/utils/deviceInfo');
// Usar versão rápida com timeout curto (3 segundos máximo)
const gpsPromise = obterLocalizacaoRapida();
const gpsTimeout = new Promise<{}>((resolve) =>
setTimeout(() => resolve({}), 3000)
);
return await Promise.race([gpsPromise, gpsTimeout]);
} catch (err) {
console.warn('Erro ao obter GPS (não bloqueia login):', err);
return {};
}
}
// Iniciar coleta de GPS em background (não esperar)
const gpsPromise = coletarGPS();
const result = await authClient.signIn.email(
{ email: matricula.trim(), password: senha },
{
onError: (ctx) => {
onError: async (ctx) => {
// Registrar tentativa de login falha
try {
// Tentar obter GPS se já estiver disponível (não esperar)
let localizacaoGPS: any = {};
try {
localizacaoGPS = await Promise.race([
gpsPromise,
new Promise<{}>((resolve) => setTimeout(() => resolve({}), 100))
]);
} catch {
// Ignorar se GPS não estiver pronto
}
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
matriculaOuEmail: matricula.trim(),
sucesso: false,
motivoFalha: ctx.error?.message || 'Erro desconhecido',
userAgent: userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais,
});
} catch (err) {
console.error('Erro ao registrar tentativa de login falha:', err);
}
alert(ctx.error.message);
}
}
);
if (result.data) {
// Registrar tentativa de login bem-sucedida
// Fazer de forma assíncrona para não bloquear o login
(async () => {
try {
// Aguardar um pouco para o usuário ser sincronizado no Convex
await new Promise((resolve) => setTimeout(resolve, 500));
// Tentar obter GPS se já estiver disponível (não esperar)
let localizacaoGPS: any = {};
try {
localizacaoGPS = await Promise.race([
gpsPromise,
new Promise<{}>((resolve) => setTimeout(() => resolve({}), 100))
]);
} catch {
// Ignorar se GPS não estiver pronto
}
// Buscar o usuário no Convex usando getCurrentUser
const usuario = await convexClient.query(api.auth.getCurrentUser, {});
if (usuario && usuario._id) {
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
usuarioId: usuario._id,
matriculaOuEmail: matricula.trim(),
sucesso: true,
userAgent: userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais,
});
} else {
// Se não encontrou o usuário, registrar sem usuarioId (será atualizado depois)
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
matriculaOuEmail: matricula.trim(),
sucesso: true,
userAgent: userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais,
});
}
} catch (err) {
console.error('Erro ao registrar tentativa de login:', err);
// Não bloquear o login se houver erro ao registrar
}
})();
closeLoginModal();
goto(resolve('/'));
} else {
@@ -213,15 +330,12 @@
</div>
<div class="ml-auto flex flex-none items-center gap-4">
{#if currentUser.data}
<!-- Sino de notificações no canto superior direito -->
<div class="relative">
<NotificationBell />
</div>
<div class="mr-2 hidden flex-col items-end lg:flex">
<!-- Nome e Perfil à esquerda do avatar -->
<div class="hidden flex-col items-end lg:flex">
<span class="text-primary text-sm font-semibold">{currentUser.data.nome}</span>
<span class="text-base-content/60 text-xs">{currentUser.data.role?.nome}</span>
</div>
<div class="dropdown dropdown-end">
<!-- Botão de Perfil ULTRA MODERNO -->
<button
@@ -233,13 +347,7 @@
<!-- Efeito de brilho no hover -->
<div
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
></div>
<!-- Anel de pulso sutil -->
<div
class="absolute inset-0 rounded-2xl"
style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
></div>
> </div>
<!-- Avatar/Foto do usuário ou ícone padrão -->
{#if avatarUrlDoUsuario()}
@@ -256,11 +364,17 @@
/>
{/if}
<!-- Badge de status online -->
<!-- Anel de pulso sutil -->
<div
class="bg-success absolute top-1 right-1 z-20 h-3 w-3 rounded-full border-2 border-white shadow-lg"
style="animation: pulse-dot 2s ease-in-out infinite;"
class="absolute inset-0 rounded-2xl"
style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
></div>
<!-- Badge de status online -->
<div
class="bg-success absolute top-1 right-1 z-20 h-3 w-3 rounded-full border-2 border-white shadow-lg"
style="animation: pulse-dot 2s ease-in-out infinite;"
></div>
</button>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
@@ -278,6 +392,11 @@
</li>
</ul>
</div>
<!-- Sino de notificações no canto superior direito -->
<div class="relative">
<NotificationBell />
</div>
{:else}
<button
type="button"

View File

@@ -807,11 +807,6 @@
style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%); animation: rotate 3s linear infinite;"
></div>
<!-- Ondas de pulso -->
<div
class="absolute inset-0 rounded-full"
style="animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
></div>
<!-- Ícone de chat moderno com efeito 3D -->
<svg
@@ -825,10 +820,7 @@
class="relative z-10 h-7 w-7 text-white transition-all duration-500 group-hover:scale-110"
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<circle cx="9" cy="10" r="1" fill="currentColor" />
<circle cx="12" cy="10" r="1" fill="currentColor" />
<circle cx="15" cy="10" r="1" fill="currentColor" />
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
<!-- Badge ULTRA PREMIUM com gradiente e brilho -->

View File

@@ -2,11 +2,11 @@
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
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 logoGovPE from '$lib/assets/logo_governo_PE.png';
import { onMount } from 'svelte';
interface Props {
registroId: Id<'registrosPonto'>;
@@ -21,25 +21,28 @@
let gerando = $state(false);
let modalPosition = $state<{ top: number; left: number } | null>(null);
// Função para calcular a posição baseada no relógio sincronizado
// Função para calcular a posição baseada no card de registro de ponto
function calcularPosicaoModal() {
// Procurar pelo elemento do relógio sincronizado
const relogioRef = document.getElementById('relogio-sincronizado-ref');
// Procurar pelo elemento do card de registro de ponto
const cardRef = document.getElementById('card-registro-ponto-ref');
if (relogioRef) {
const rect = relogioRef.getBoundingClientRect();
const viewportWidth = window.innerWidth;
if (cardRef) {
const rect = cardRef.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Posicionar o modal na mesma posição do relógio sincronizado
// Centralizado horizontalmente no card do relógio
const left = rect.left + (rect.width / 2);
// Posicionar abaixo do card do relógio com um pequeno espaçamento
const top = rect.bottom + 20;
// 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: top,
left: left
top: finalTop,
left: window.innerWidth / 2
};
}
@@ -47,18 +50,26 @@
return null;
}
onMount(() => {
// 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;
} else {
// Fallback para centralização
modalPosition = {
top: window.innerHeight / 2,
left: window.innerWidth / 2
};
}
});
};
// Aguardar um pouco mais para garantir que o DOM está atualizado
// Aguardar um pouco para garantir que o DOM está atualizado
setTimeout(updatePosition, 50);
// Adicionar listener de scroll para atualizar posição
@@ -73,42 +84,21 @@
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleScroll);
};
} else {
// Limpar posição quando o modal for fechado
modalPosition = null;
}
});
// Função para obter estilo do modal baseado na posição calculada
function getModalStyle() {
if (modalPosition) {
// Garantir que o modal não saia da viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const modalWidth = 700; // Aproximadamente max-w-2xl
const modalHeight = Math.min(viewportHeight * 0.9, 600);
let left = modalPosition.left;
let top = modalPosition.top;
// Ajustar se o modal sair da viewport à direita
if (left + (modalWidth / 2) > viewportWidth - 20) {
left = viewportWidth - (modalWidth / 2) - 20;
}
// Ajustar se o modal sair da viewport à esquerda
if (left - (modalWidth / 2) < 20) {
left = (modalWidth / 2) + 20;
}
// Ajustar se o modal sair da viewport abaixo
if (top + modalHeight > viewportHeight - 20) {
top = viewportHeight - modalHeight - 20;
}
// Ajustar se o modal sair da viewport acima
if (top < 20) {
top = 20;
}
// Usar transform para centralizar horizontalmente baseado no left calculado
return `position: fixed; top: ${top}px; left: ${left}px; transform: translateX(-50%); max-width: ${Math.min(modalWidth, viewportWidth - 40)}px;`;
// Posicionar na altura do card, centralizado horizontalmente
// position: fixed já é relativo à viewport, então podemos usar diretamente
return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 700px;`;
}
// Se não houver posição calculada, centralizar na tela
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);';
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 700px;';
}
async function gerarPDF() {
@@ -120,15 +110,16 @@
const registro = registroQuery.data;
const doc = new jsPDF();
// Logo
// Adicionar logo no canto superior esquerdo
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 logoImg = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
setTimeout(() => reject(new Error('Timeout loading logo')), 3000);
img.src = logoGovPE;
});
const logoWidth = 25;
@@ -136,59 +127,75 @@
const logoHeight = logoWidth * aspectRatio;
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
yPosition = Math.max(20, 10 + logoHeight / 2);
yPosition = 10 + logoHeight + 10;
} catch (err) {
console.warn('Não foi possível carregar a logo:', err);
console.warn('Erro ao carregar logo:', err);
yPosition = 20;
}
// Cabeçalho
// Cabeçalho padrão do sistema (centralizado)
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.setFontSize(12);
doc.text('SECRETARIA DE ESPORTES', 105, Math.max(yPosition - 2, 28), { align: 'center' });
yPosition = Math.max(yPosition, 40);
yPosition += 10;
// Título do comprovante
doc.setFontSize(16);
doc.setTextColor(41, 128, 185);
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' });
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);
// Informações do Funcionário em tabela
const funcionarioData: string[][] = [];
if (registro.funcionario) {
if (registro.funcionario.matricula) {
doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition);
yPosition += 6;
funcionarioData.push(['Matrícula', registro.funcionario.matricula]);
}
doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition);
yPosition += 6;
funcionarioData.push(['Nome', registro.funcionario.nome || '-']);
if (registro.funcionario.descricaoCargo) {
doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition);
yPosition += 6;
funcionarioData.push(['Cargo/Função', registro.funcionario.descricaoCargo]);
}
if (registro.funcionario.simbolo) {
doc.text(
`Símbolo: ${registro.funcionario.simbolo.nome} (${registro.funcionario.simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'})`,
15,
yPosition
);
yPosition += 6;
const simboloTipo = registro.funcionario.simbolo.tipo === 'cargo_comissionado'
? 'Cargo Comissionado'
: 'Função Gratificada';
funcionarioData.push(['Símbolo', `${registro.funcionario.simbolo.nome} (${simboloTipo})`]);
}
}
yPosition += 5;
if (funcionarioData.length > 0) {
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(0, 0, 0);
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
yPosition += 8;
// Informações do Registro
doc.setFont('helvetica', 'bold');
doc.text('DADOS DO REGISTRO', 15, yPosition);
doc.setFont('helvetica', 'normal');
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Informação']],
body: funcionarioData,
theme: 'striped',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 10 },
margin: { left: 15, right: 15 }
});
yPosition += 8;
doc.setFontSize(10);
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalY + 10;
}
// Informações do Registro em tabela
const config = configQuery?.data;
const tipoLabel = config
? getTipoRegistroLabel(registro.tipo, {
@@ -198,25 +205,38 @@
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo);
doc.text(`Tipo: ${tipoLabel}`, 15, yPosition);
yPosition += 6;
const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo);
doc.text(`Data e Hora: ${dataHora}`, 15, yPosition);
yPosition += 6;
const registroData: string[][] = [
['Tipo', tipoLabel],
['Data e Hora', dataHora],
['Status', registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'],
['Tolerância', `${registro.toleranciaMinutos} minutos`],
['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)']
];
doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition);
yPosition += 6;
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(0, 0, 0);
doc.text('DADOS DO REGISTRO', 15, yPosition);
yPosition += 8;
doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition);
yPosition += 6;
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Informação']],
body: registroData,
theme: 'striped',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 10 },
margin: { left: 15, right: 15 }
});
doc.text(
`Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`,
15,
yPosition
);
yPosition += 10;
type JsPDFWithAutoTable2 = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY2 = (doc as JsPDFWithAutoTable2).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalY2 + 10;
// Imagem capturada (se disponível)
if (registro.imagemUrl) {
@@ -227,8 +247,10 @@
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' });
doc.setTextColor(0, 0, 0);
doc.text('FOTO CAPTURADA', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 10;

View File

@@ -63,8 +63,10 @@
let detalhesErroModal = $state('');
let justificativa = $state('');
let mostrandoModalConfirmacao = $state(false);
let mostrandoTransicao = $state(false); // Novo estado para transição
let dataHoraAtual = $state<{ data: string; hora: string } | null>(null);
let aguardandoProcessamento = $state(false);
let etapaProcessamento = $state<'coletando' | 'sincronizando' | 'upload' | 'registrando' | null>(null);
const registrosHoje = $derived(registrosHojeQuery?.data || []);
const config = $derived(configQuery?.data);
@@ -204,15 +206,19 @@
registrando = true;
sucesso = null;
coletandoInfo = true;
aguardandoProcessamento = true;
etapaProcessamento = 'coletando';
try {
// Coletar informações do dispositivo
etapaProcessamento = 'coletando';
const informacoesDispositivo = await obterInformacoesDispositivo();
// Nota: A permissão de sensor não é impeditiva - apenas câmera e localização são obrigatórias
coletandoInfo = false;
// Obter tempo sincronizado e aplicar GMT offset (igual ao relógio)
etapaProcessamento = 'sincronizando';
const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
const gmtOffset = configRelogio.gmtOffset ?? 0;
@@ -262,6 +268,7 @@
let imagemId: Id<'_storage'> | undefined = undefined;
if (imagemCapturada) {
try {
etapaProcessamento = 'upload';
imagemId = await uploadImagem(imagemCapturada);
} catch (error) {
console.error('Erro ao fazer upload da imagem:', error);
@@ -272,6 +279,7 @@
}
// Registrar ponto
etapaProcessamento = 'registrando';
const resultado = await client.mutation(api.pontos.registrarPonto, {
imagemId,
informacoesDispositivo,
@@ -314,6 +322,7 @@
} catch (error) {
console.error('Erro ao registrar ponto:', error);
aguardandoProcessamento = false;
etapaProcessamento = null;
let mensagemErro = 'Erro desconhecido ao registrar ponto';
let detalhesErro = 'Tente novamente em alguns instantes.';
@@ -392,6 +401,7 @@
registrando = false;
coletandoInfo = false;
aguardandoProcessamento = false;
etapaProcessamento = null;
}
}
@@ -453,7 +463,13 @@
console.warn('Erro ao obter tempo do servidor, usando tempo local:', error);
atualizarDataHoraAtual();
}
mostrandoModalConfirmacao = true;
// Mostrar transição antes da confirmação
mostrandoTransicao = true;
setTimeout(() => {
mostrandoTransicao = false;
mostrandoModalConfirmacao = true;
}, 1500);
}
}
@@ -517,8 +533,17 @@
function confirmarRegistro() {
mostrandoModalConfirmacao = false;
aguardandoProcessamento = true;
registrarPonto();
mostrandoTransicao = true; // Mostrar transição antes do processamento
setTimeout(() => {
mostrandoTransicao = false;
aguardandoProcessamento = true;
etapaProcessamento = 'coletando';
// Usar setTimeout para garantir que o modal de processamento apareça antes de iniciar o registro
setTimeout(() => {
registrarPonto();
}, 100);
}, 1500);
}
function cancelarRegistro() {
@@ -852,28 +877,30 @@
const saldoPositivo = $derived(historicoSaldo ? historicoSaldo.saldoMinutos >= 0 : false);
// Posicionamento dos modais
// Posicionamento dos modais baseado no texto "Registrar Ponto"
let modalPosition = $state<{ top: number; left: number } | null>(null);
// Função para calcular a posição baseada no relógio sincronizado
// Função para calcular a posição do modal baseada no card de registro de ponto
function calcularPosicaoModal() {
// Procurar pelo elemento do relógio sincronizado
const relogioRef = document.getElementById('relogio-sincronizado-ref');
const cardRef = document.getElementById('card-registro-ponto-ref');
if (relogioRef) {
const rect = relogioRef.getBoundingClientRect();
const viewportWidth = window.innerWidth;
if (cardRef) {
const rect = cardRef.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Posicionar o modal na mesma posição do relógio sincronizado
// Centralizado horizontalmente no card do relógio
const left = rect.left + (rect.width / 2);
// Posicionar abaixo do card do relógio com um pequeno espaçamento
const top = rect.bottom + 20;
// Posicionar o modal na mesma altura Y do card (top do card)
// getBoundingClientRect() já retorna posição relativa à viewport quando usado com position: fixed
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));
return {
top: top,
left: left
top: finalTop,
left: window.innerWidth / 2
};
}
@@ -881,20 +908,26 @@
return null;
}
// Atualizar posição quando os modais forem abertos ou quando a página rolar
// Atualizar posição quando os modais forem abertos
$effect(() => {
if (mostrandoWebcam || mostrandoModalConfirmacao || aguardandoProcessamento || mostrarModalErro) {
if (mostrandoWebcam || mostrandoTransicao || aguardandoProcessamento || mostrandoModalConfirmacao) {
// 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 mais para garantir que o DOM está atualizado
// Aguardar um pouco para garantir que o DOM está atualizado
setTimeout(updatePosition, 50);
// Adicionar listener de scroll para atualizar posição
@@ -910,45 +943,20 @@
window.removeEventListener('resize', handleScroll);
};
} else {
// Limpar posição quando os modais forem fechados
// Limpar posição quando o modal for fechado
modalPosition = null;
}
});
// Função para obter estilo do modal baseado na posição calculada
// Função para obter estilo do modal
function getModalStyle() {
if (modalPosition) {
// Garantir que o modal não saia da viewport
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const modalWidth = 800; // Aproximadamente max-w-2xl ou max-w-3xl
const modalHeight = Math.min(viewportHeight * 0.9, 600);
let left = modalPosition.left;
let top = modalPosition.top;
// Ajustar se o modal sair da viewport à direita
if (left + (modalWidth / 2) > viewportWidth - 20) {
left = viewportWidth - (modalWidth / 2) - 20;
}
// Ajustar se o modal sair da viewport à esquerda
if (left - (modalWidth / 2) < 20) {
left = (modalWidth / 2) + 20;
}
// Ajustar se o modal sair da viewport abaixo
if (top + modalHeight > viewportHeight - 20) {
top = viewportHeight - modalHeight - 20;
}
// Ajustar se o modal sair da viewport acima
if (top < 20) {
top = 20;
}
// Usar transform para centralizar horizontalmente baseado no left calculado
return `position: fixed; top: ${top}px; left: ${left}px; transform: translateX(-50%); max-width: ${Math.min(modalWidth, viewportWidth - 40)}px;`;
// Posicionar na altura do card, centralizado horizontalmente
// position: fixed já é relativo à viewport, então podemos usar diretamente
return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 800px;`;
}
// Se não houver posição calculada, centralizar na tela
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);';
// Fallback para centralização padrão
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 800px;';
}
</script>
@@ -1009,7 +1017,7 @@
<!-- Card de Registro de Ponto Modernizado -->
<div class="card bg-gradient-to-br from-base-100 via-base-100 to-primary/5 border border-base-300 shadow-2xl max-w-2xl mx-auto">
<div class="card-body p-6">
<div id="card-registro-ponto-ref" class="card-body p-6">
<!-- Cabeçalho -->
<div class="flex items-center justify-center gap-3 mb-6">
<div class="p-2.5 bg-primary/10 rounded-xl">
@@ -1479,6 +1487,43 @@
</div>
{/if}
<!-- Modal de Transição -->
{#if mostrandoTransicao}
<div
class="fixed inset-0 z-50 pointer-events-none"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-transicao-title"
>
<!-- Backdrop leve -->
<div class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"></div>
<!-- Modal Box -->
<div
class="absolute bg-base-100 rounded-2xl shadow-2xl z-10 transform transition-all duration-300 p-8 pointer-events-auto flex flex-col items-center justify-center min-h-[300px]"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
>
<div class="flex flex-col items-center gap-6 text-center">
<div class="relative">
<div class="absolute inset-0 bg-primary/20 rounded-full animate-ping"></div>
<div class="relative p-4 bg-primary/10 rounded-full">
<Clock class="h-12 w-12 text-primary animate-pulse" strokeWidth={2} />
</div>
</div>
<h3 id="modal-transicao-title" class="text-2xl font-black text-base-content">
Registro de Ponto em Andamento
</h3>
<div class="flex gap-2">
<span class="loading loading-dots loading-md text-primary"></span>
</div>
</div>
</div>
</div>
{/if}
<!-- Modal de Aguardando Processamento -->
{#if aguardandoProcessamento}
<div
@@ -1498,8 +1543,32 @@
>
<div class="flex flex-col items-center gap-4 text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<h3 id="modal-aguardando-title" class="text-xl font-bold text-base-content">Processando Registro</h3>
<p class="text-base-content/70">Por favor, aguarde enquanto processamos seu registro de ponto...</p>
<h3 id="modal-aguardando-title" class="text-xl font-bold text-base-content">
{#if etapaProcessamento === 'coletando'}
Coletando Informações
{:else if etapaProcessamento === 'sincronizando'}
Sincronizando Horário
{:else if etapaProcessamento === 'upload'}
Enviando Foto
{:else if etapaProcessamento === 'registrando'}
Registrando Ponto
{:else}
Processando Registro
{/if}
</h3>
<p class="text-base-content/70">
{#if etapaProcessamento === 'coletando'}
Coletando informações do dispositivo e localização...
{:else if etapaProcessamento === 'sincronizando'}
Sincronizando o horário com o servidor...
{:else if etapaProcessamento === 'upload'}
Enviando a foto capturada para o servidor...
{:else if etapaProcessamento === 'registrando'}
Finalizando o registro de ponto no sistema...
{:else}
Por favor, aguarde enquanto processamos seu registro de ponto...
{/if}
</p>
</div>
</div>
</div>
@@ -1540,7 +1609,7 @@
<button
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
onclick={cancelarRegistro}
disabled={registrando}
disabled={registrando || aguardandoProcessamento}
>
<XCircle class="h-5 w-5" />
</button>
@@ -1658,7 +1727,7 @@
<button
class="btn btn-outline"
onclick={cancelarRegistro}
disabled={registrando}
disabled={registrando || aguardandoProcessamento}
>
<XCircle class="h-5 w-5" />
Cancelar
@@ -1666,11 +1735,11 @@
<button
class="btn btn-primary gap-2"
onclick={confirmarRegistro}
disabled={registrando}
disabled={registrando || aguardandoProcessamento}
>
{#if registrando}
{#if registrando || aguardandoProcessamento}
<span class="loading loading-spinner loading-sm"></span>
Registrando...
Processando...
{:else}
<CheckCircle2 class="h-5 w-5" />
Confirmar Registro

View File

@@ -526,10 +526,97 @@ async function obterLocalizacaoMultipla(): Promise<{
};
}
/**
* Obtém localização via GPS de forma rápida (uma única leitura, sem reverse geocoding)
* Usado para login - não bloqueia o fluxo
*/
export async function obterLocalizacaoRapida(): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
}> {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
return {};
}
try {
// Uma única leitura rápida com timeout curto
const leitura = await capturarLocalizacaoUnica(true, 3000); // 3 segundos máximo
if (!leitura.latitude || !leitura.longitude || leitura.confiabilidade === 0) {
return {};
}
// Tentar obter endereço via reverse geocoding (com timeout curto)
let endereco = '';
let cidade = '';
let estado = '';
let pais = '';
try {
const geocodePromise = fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${leitura.latitude}&lon=${leitura.longitude}&zoom=18&addressdetails=1`,
{
headers: {
'User-Agent': 'SGSE-App/1.0'
}
}
);
const geocodeTimeout = new Promise<Response>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 2000)
);
const response = await Promise.race([geocodePromise, geocodeTimeout]);
if (response.ok) {
const data = (await response.json()) as {
address?: {
road?: string;
house_number?: string;
city?: string;
town?: string;
state?: string;
country?: string;
};
};
if (data.address) {
const addr = data.address;
if (addr.road) {
endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`;
}
cidade = addr.city || addr.town || '';
estado = addr.state || '';
pais = addr.country || '';
}
}
} catch (error) {
// Ignorar erro de geocoding - não é crítico
console.warn('Erro ao obter endereço (não crítico):', error);
}
return {
latitude: leitura.latitude,
longitude: leitura.longitude,
precisao: leitura.precisao,
endereco,
cidade,
estado,
pais
};
} catch (error) {
console.warn('Erro ao obter localização rápida:', error);
return {};
}
}
/**
* Obtém localização via GPS com múltiplas tentativas e validações anti-spoofing
*/
async function obterLocalizacao(): Promise<{
export async function obterLocalizacao(): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
@@ -644,7 +731,7 @@ async function obterLocalizacao(): Promise<{
/**
* Obtém IP público
*/
async function obterIPPublico(): Promise<string | undefined> {
export async function obterIPPublico(): Promise<string | undefined> {
try {
const response = await fetch('https://api.ipify.org?format=json');
if (response.ok) {