Merge remote-tracking branch 'origin' into feat-pedidos
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
124
apps/web/src/lib/components/FuncionarioNomeAutocomplete.svelte
Normal file
124
apps/web/src/lib/components/FuncionarioNomeAutocomplete.svelte
Normal 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user