Ajustes final etapa1 #71

Merged
killer-cf merged 12 commits from ajustes_final_etapa1 into master 2025-12-29 17:28:52 +00:00
17 changed files with 1339 additions and 1036 deletions
Showing only changes of commit b248472d65 - Show all commits

View File

@@ -1,20 +1,14 @@
<script lang="ts"> <script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { afterNavigate, goto, replaceState } from '$app/navigation'; import { afterNavigate, goto, replaceState } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { UserPlus, Mail, Clock, Award, TrendingUp, Zap, Users, Database } from 'lucide-svelte'; import { UserPlus, Mail, Users, Clock, Calendar, BadgeCheck, Package } from 'lucide-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte'; import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
// Queries para dados do dashboard // Queries para dados do dashboard
const statsQuery = useQuery(api.dashboard.getStats, {}); const statsQuery = useQuery(api.dashboard.getStats, {});
const activityQuery = useQuery(api.dashboard.getRecentActivity, {});
// Queries para monitoramento em tempo real
const statusSistemaQuery = useQuery(api.monitoramento.getStatusSistema, {});
const atividadeBDQuery = useQuery(api.monitoramento.getAtividadeBancoDados, {});
const distribuicaoQuery = useQuery(api.monitoramento.getDistribuicaoRequisicoes, {});
// Estado para animações // Estado para animações
let currentTime = $state(new Date()); let currentTime = $state(new Date());
@@ -36,6 +30,7 @@
// Se for erro de autenticação, abrir modal de login automaticamente // Se for erro de autenticação, abrir modal de login automaticamente
if (error === 'auth_required') { if (error === 'auth_required') {
const redirectTo = route || to.url.pathname; const redirectTo = route || to.url.pathname;
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, { goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, {
replaceState: true, replaceState: true,
noScroll: true noScroll: true
@@ -70,6 +65,7 @@
if (error === 'auth_required') { if (error === 'auth_required') {
const redirectTo = route || window.location.pathname; const redirectTo = route || window.location.pathname;
// eslint-disable-next-line svelte/no-navigation-without-resolve
goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, { goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, {
replaceState: true, replaceState: true,
noScroll: true noScroll: true
@@ -119,17 +115,6 @@
} }
} }
// Função para formatar números
function formatNumber(num: number): string {
return new Intl.NumberFormat('pt-BR').format(num);
}
// Função para calcular porcentagem
function calcPercentage(value: number, total: number): number {
if (total === 0) return 0;
return Math.round((value / total) * 100);
}
// Obter saudação baseada na hora // Obter saudação baseada na hora
function getSaudacao(): string { function getSaudacao(): string {
const hora = currentTime.getHours(); const hora = currentTime.getHours();
@@ -137,6 +122,43 @@
if (hora < 18) return 'Boa tarde'; if (hora < 18) return 'Boa tarde';
return 'Boa noite'; return 'Boa noite';
} }
// Função para animar contador numérico
function animateCounter(element: HTMLElement, target: number, duration: number = 2000): void {
const start = 0;
const increment = target / (duration / 16);
let current = start;
const timer = setInterval(() => {
current += increment;
if (current >= target) {
element.textContent = target.toLocaleString('pt-BR');
clearInterval(timer);
} else {
element.textContent = Math.floor(current).toLocaleString('pt-BR');
}
}, 16);
}
// Action para animar contador
function animateCounterAction(node: HTMLElement, value: number) {
if (value > 0 && value !== undefined) {
// Pequeno delay para garantir que o elemento está renderizado
setTimeout(() => {
animateCounter(node, value);
}, 100);
}
return {
update(newValue: number) {
if (newValue > 0 && newValue !== undefined && newValue !== value) {
node.textContent = '0';
setTimeout(() => {
animateCounter(node, newValue);
}, 100);
}
}
};
}
</script> </script>
<ProtectedRoute> <ProtectedRoute>
@@ -176,17 +198,23 @@
</div> </div>
{/if} {/if}
<!-- Cabeçalho com Boas-vindas --> <!-- Hero Section com Boas-vindas -->
<div class="from-primary/20 to-secondary/20 mb-6 rounded-2xl bg-linear-to-r p-8 shadow-lg"> <div
class="fade-in from-primary/20 to-secondary/20 mb-8 rounded-2xl bg-linear-to-r p-8 shadow-xl"
>
<div class="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center"> <div class="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div> <div class="flex-1">
<h1 class="text-primary mb-2 text-4xl font-bold"> <h1 class="text-primary mb-3 text-5xl font-bold">
{getSaudacao()}! 👋 {getSaudacao()}! 👋
</h1> </h1>
<p class="text-base-content/80 text-xl"> <p class="text-base-content/80 mb-3 text-2xl font-semibold">Bem-vindo ao SGSE</p>
Bem-vindo ao SGSE - Sistema de Gerenciamento de Secretaria <p
class="from-primary to-secondary mb-4 bg-gradient-to-r bg-clip-text text-3xl font-bold text-transparent"
>
Simplificando a Gestão Pública
</p> </p>
<p class="text-base-content/60 mt-2 text-sm"> <p class="text-base-content/70 text-lg">Sistema de Gerenciamento de Secretaria</p>
<p class="text-base-content/60 mt-3 text-sm">
{currentTime.toLocaleDateString('pt-BR', { {currentTime.toLocaleDateString('pt-BR', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
@@ -197,518 +225,259 @@
{currentTime.toLocaleTimeString('pt-BR')} {currentTime.toLocaleTimeString('pt-BR')}
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex flex-col gap-3">
<div class="badge badge-primary badge-lg">Sistema Online</div> <div class="badge badge-primary badge-lg animate-pulse">Sistema Online</div>
<div class="badge badge-success badge-lg">Atualizado</div> <div class="badge badge-success badge-lg">Atualizado</div>
<div class="badge badge-info badge-lg">Disponível 24h</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Cards de Estatísticas Principais --> <!-- Seção de Estatísticas -->
{#if statsQuery.isLoading} {#if statsQuery.isLoading}
<div class="flex items-center justify-center py-12"> <div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span> <span class="loading loading-spinner loading-lg text-primary"></span>
</div> </div>
{:else if statsQuery.data} {:else if statsQuery.data}
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"> <div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<!-- Total de Funcionários --> <!-- Card Usuários Cadastrados -->
<div <div
class="card transform bg-linear-to-br from-blue-500/10 to-blue-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl" class="stats-card card transform bg-linear-to-br from-blue-500/20 to-blue-600/30 shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl"
> >
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div class="flex-1">
<p class="text-base-content/70 text-sm font-semibold">Total de Funcionários</p> <p class="text-base-content/70 mb-2 text-sm font-semibold uppercase">
<h2 class="text-primary mt-2 text-4xl font-bold"> Usuários Cadastrados
{formatNumber(statsQuery.data.totalFuncionarios)}
</h2>
<p class="text-base-content/60 mt-1 text-xs">
{statsQuery.data.funcionariosAtivos} ativos
</p> </p>
<h2 class="text-primary text-4xl font-bold">
{#if statsQuery.data}
<span use:animateCounterAction={statsQuery.data.totalUsuarios}>0</span>
{:else}
0
{/if}
</h2>
<p class="text-base-content/60 mt-2 text-xs">no sistema</p>
</div> </div>
<div <div class="bg-primary/20 rounded-full p-4">
class="radial-progress text-primary" <Users class="text-primary h-8 w-8" strokeWidth={2} />
style="--value:{calcPercentage(
statsQuery.data.funcionariosAtivos,
statsQuery.data.totalFuncionarios
)}; --size:4rem;"
>
<span class="text-xs font-bold"
>{calcPercentage(
statsQuery.data.funcionariosAtivos,
statsQuery.data.totalFuncionarios
)}%</span
>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Solicitações Pendentes --> <!-- Card Funcionários Ativos -->
<div <div
class="card transform bg-linear-to-br from-yellow-500/10 to-yellow-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl" class="stats-card card transform bg-linear-to-br from-green-500/20 to-green-600/30 shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl"
> >
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div class="flex-1">
<p class="text-base-content/70 text-sm font-semibold">Solicitações Pendentes</p> <p class="text-base-content/70 mb-2 text-sm font-semibold uppercase">
<h2 class="text-warning mt-2 text-4xl font-bold">4</h2> Funcionários Ativos
<p class="text-base-content/60 mt-1 text-xs">de 5 total</p> </p>
<h2 class="text-success text-4xl font-bold">
{#if statsQuery.data}
<span use:animateCounterAction={statsQuery.data.funcionariosAtivos}>0</span>
{:else}
0
{/if}
</h2>
<p class="text-base-content/60 mt-2 text-xs">em atividade</p>
</div> </div>
<div class="bg-warning/20 rounded-full p-4"> <div class="bg-success/20 rounded-full p-4">
<Users class="text-success h-8 w-8" strokeWidth={2} />
</div>
</div>
</div>
</div>
<!-- Card Cadastros Realizados -->
<div
class="stats-card card transform bg-linear-to-br from-purple-500/20 to-purple-600/30 shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-2 text-sm font-semibold uppercase">
Cadastros Realizados
</p>
<h2 class="text-secondary text-4xl font-bold">
{#if statsQuery.data}
<span use:animateCounterAction={statsQuery.data.totalCadastros}>0</span>
{:else}
0
{/if}
</h2>
<p class="text-base-content/60 mt-2 text-xs">total de registros</p>
</div>
<div class="bg-secondary/20 rounded-full p-4">
<BadgeCheck class="text-secondary h-8 w-8" strokeWidth={2} />
</div>
</div>
</div>
</div>
<!-- Card Disponibilidade 24h -->
<div
class="stats-card card transform bg-linear-to-br from-orange-500/20 to-orange-600/30 shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-2 text-sm font-semibold uppercase">
Disponibilidade
</p>
<h2 class="text-warning text-4xl font-bold">24h</h2>
<p class="text-base-content/60 mt-2 text-xs">funcionando continuamente</p>
</div>
<div class="bg-warning/20 animate-pulse rounded-full p-4">
<Clock class="text-warning h-8 w-8" strokeWidth={2} /> <Clock class="text-warning h-8 w-8" strokeWidth={2} />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Símbolos Cadastrados -->
<div
class="card transform bg-linear-to-br from-green-500/10 to-green-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-sm font-semibold">Símbolos Cadastrados</p>
<h2 class="text-success mt-2 text-4xl font-bold">
{formatNumber(statsQuery.data.totalSimbolos)}
</h2>
<p class="text-base-content/60 mt-1 text-xs">
{statsQuery.data.cargoComissionado} CC / {statsQuery.data.funcaoGratificada} FG
</p>
</div>
<div class="bg-success/20 rounded-full p-4">
<Award class="text-success h-8 w-8" strokeWidth={2} />
</div>
</div>
</div>
</div>
<!-- Atividade 24h -->
{#if activityQuery.data}
<div
class="card transform bg-linear-to-br from-purple-500/10 to-purple-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-sm font-semibold">Atividade (24h)</p>
<p class="text-base-content/60 mt-1 text-xs">
{activityQuery.data.funcionariosCadastrados24h} cadastros
</p>
</div>
<div class="bg-secondary/20 rounded-full p-4">
<TrendingUp class="text-secondary h-8 w-8" strokeWidth={2} />
</div>
</div>
</div>
</div> </div>
{/if} {/if}
</div>
<!-- Monitoramento em Tempo Real --> <!-- Seção Sobre o SGSE -->
{#if statusSistemaQuery?.data}
{@const status = statusSistemaQuery.data}
{@const atividade = atividadeBDQuery?.data || {
historico: Array.from({ length: 30 }, () => ({ entradas: 0, saidas: 0 }))
}}
{@const distribuicao = distribuicaoQuery?.data || {
queries: 0,
mutations: 0,
leituras: 0,
escritas: 0
}}
{@const maxAtividade =
atividade.historico && atividade.historico.length > 0
? Math.max(
1,
...atividade.historico.map((p) => Math.max(p.entradas || 0, p.saidas || 0))
)
: 1}
<div class="mb-6">
<div class="mb-4 flex items-center gap-3">
<div class="bg-error/10 animate-pulse rounded-lg p-2">
<Zap class="text-error h-6 w-6" strokeWidth={2} />
</div>
<div>
<h2 class="text-base-content text-2xl font-bold">Monitoramento em Tempo Real</h2>
<p class="text-base-content/60 text-sm">
Atualizado a cada segundo • {new Date(status.ultimaAtualizacao).toLocaleTimeString(
'pt-BR'
)}
</p>
</div>
<div class="badge badge-error badge-lg ml-auto gap-2">
<span
class="bg-error absolute inline-flex h-3 w-3 animate-ping rounded-full opacity-75"
></span>
<span class="bg-error relative inline-flex h-3 w-3 rounded-full"></span>
LIVE
</div>
</div>
<!-- Cards de Status do Sistema -->
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Usuários Online -->
<div <div
class="card from-primary/10 to-primary/5 border-primary/20 border-2 bg-linear-to-br shadow-lg" class="fade-in-delay from-base-200 to-base-300 mb-8 rounded-2xl bg-linear-to-br p-8 shadow-xl"
> >
<div class="card-body p-4"> <div class="mx-auto max-w-4xl text-center">
<div class="flex items-center justify-between"> <h2 class="text-base-content mb-4 text-3xl font-bold">Sobre o SGSE</h2>
<div> <p
<p class="text-base-content/70 text-xs font-semibold uppercase"> class="from-primary to-secondary mb-6 bg-gradient-to-r bg-clip-text text-2xl font-bold text-transparent"
Usuários Online >
Simplificando a Gestão Pública
</p>
<p class="text-base-content/80 mb-4 text-lg leading-relaxed">
O Sistema de Gerenciamento de Secretaria (SGSE) é uma solução completa e moderna
desenvolvida para otimizar e simplificar os processos administrativos da gestão pública.
Com tecnologia de ponta e interface intuitiva, oferecemos rapidez, comodidade e
disponibilidade 24 horas por dia para atender às necessidades dos nossos usuários.
</p>
<p class="text-base-content/70 text-base leading-relaxed">
Nossa plataforma integra todas as funcionalidades essenciais em um único ambiente,
permitindo gestão eficiente de funcionários, controle de ponto, férias, licenças, símbolos
e muito mais. Trabalhamos continuamente para garantir que você tenha acesso rápido e
seguro a todas as informações e ferramentas necessárias para uma gestão pública de
excelência.
</p> </p>
<h3 class="text-primary mt-1 text-3xl font-bold">
{status.usuariosOnline}
</h3>
<p class="text-base-content/60 mt-1 text-xs">sessões ativas</p>
</div>
<div class="bg-primary/20 rounded-full p-3">
<Users class="text-primary h-6 w-6" strokeWidth={2} />
</div>
</div>
</div> </div>
</div> </div>
<!-- Total de Registros --> <!-- Grid de Funcionalidades Principais -->
<div <div class="fade-in-delay-2 mb-8">
class="card from-success/10 to-success/5 border-success/20 border-2 bg-linear-to-br shadow-lg" <h2 class="text-base-content mb-6 text-center text-3xl font-bold">
Principais Funcionalidades
</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<!-- Gestão de Funcionários -->
<a
href={resolve('/recursos-humanos/funcionarios')}
class="feature-card group card transform bg-linear-to-br from-blue-500/10 to-blue-600/20 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
> >
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-xs font-semibold uppercase">
Total Registros
</p>
<h3 class="text-success mt-1 text-3xl font-bold">
{status.totalRegistros.toLocaleString('pt-BR')}
</h3>
<p class="text-base-content/60 mt-1 text-xs">no banco de dados</p>
</div>
<div class="bg-success/20 rounded-full p-3">
<Database class="text-success h-6 w-6" strokeWidth={2} />
</div>
</div>
</div>
</div>
<!-- Tempo Médio de Resposta -->
<div
class="card from-info/10 to-info/5 border-info/20 border-2 bg-linear-to-br shadow-lg"
>
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content/70 text-xs font-semibold uppercase">
Tempo Resposta
</p>
<h3 class="text-info mt-1 text-3xl font-bold">
{status.tempoMedioResposta}ms
</h3>
<p class="text-base-content/60 mt-1 text-xs">média atual</p>
</div>
<div class="bg-info/20 rounded-full p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-info h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
</div>
</div>
</div>
<!-- Uso de Sistema -->
<div
class="card from-warning/10 to-warning/5 border-warning/20 border-2 bg-linear-to-br shadow-lg"
>
<div class="card-body p-4">
<div>
<p class="text-base-content/70 mb-2 text-xs font-semibold uppercase">
Uso do Sistema
</p>
<div class="space-y-2">
<div>
<div class="mb-1 flex justify-between text-xs">
<span class="text-base-content/70">CPU</span>
<span class="text-warning font-bold">{status.cpuUsada}%</span>
</div>
<progress
class="progress progress-warning w-full"
value={status.cpuUsada}
max="100"
></progress>
</div>
<div>
<div class="mb-1 flex justify-between text-xs">
<span class="text-base-content/70">Memória</span>
<span class="text-warning font-bold">{status.memoriaUsada}%</span>
</div>
<progress
class="progress progress-warning w-full"
value={status.memoriaUsada}
max="100"
></progress>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Gráfico de Atividade do Banco de Dados em Tempo Real -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body"> <div class="card-body">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<div> <div
<h3 class="text-base-content text-xl font-bold">Atividade do Banco de Dados</h3> class="rounded-xl bg-blue-500/20 p-4 transition-colors duration-300 group-hover:bg-blue-500/30"
<p class="text-base-content/60 text-sm"> >
Entradas e saídas em tempo real (último minuto) <Users class="h-10 w-10 text-blue-600" strokeWidth={2} />
</div>
</div>
<h3 class="text-base-content mb-2 text-xl font-bold">Gestão de Funcionários</h3>
<p class="text-base-content/70 text-sm">
Gerencie o cadastro completo de funcionários, informações pessoais, documentos e muito
mais.
</p> </p>
</div> </div>
<div class="badge badge-success gap-2"> </a>
<span class="loading loading-spinner loading-xs"></span>
Atualizando
</div>
</div>
<div class="relative h-64"> <!-- Controle de Ponto -->
<!-- Eixo Y -->
<div
class="absolute top-0 bottom-8 left-0 flex w-10 flex-col justify-between pr-2 text-right"
>
{#each [10, 8, 6, 4, 2, 0] as val (val)}
<span class="text-base-content/60 text-xs">{val}</span>
{/each}
</div>
<!-- Grid e Barras -->
<div class="absolute top-0 right-4 bottom-8 left-12">
<!-- Grid horizontal -->
{#each [0, 1, 2, 3, 4, 5] as i (i)}
<div
class="border-base-content/10 absolute right-0 left-0 border-t"
style="top: {(i / 5) * 100}%;"
></div>
{/each}
<!-- Barras de atividade -->
<div class="flex h-full items-end justify-around gap-1">
{#each atividade.historico || [] as ponto, idx (idx)}
{@const entradas = ponto?.entradas || 0}
{@const saidas = ponto?.saidas || 0}
<div class="group relative flex h-full flex-1 items-end gap-0.5">
<!-- Entradas (verde) -->
<div
class="from-success to-success/70 flex-1 rounded-t bg-linear-to-t transition-all duration-300 hover:scale-110"
style="height: {(entradas / maxAtividade) * 100}%; min-height: 2px;"
title="Entradas: {entradas}"
></div>
<!-- Saídas (vermelho) -->
<div
class="from-error to-error/70 flex-1 rounded-t bg-linear-to-t transition-all duration-300 hover:scale-110"
style="height: {(saidas / maxAtividade) * 100}%; min-height: 2px;"
title="Saídas: {saidas}"
></div>
<!-- Tooltip no hover -->
<div
class="bg-base-300 text-base-content absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 rounded px-2 py-1 text-xs whitespace-nowrap opacity-0 shadow-lg transition-opacity group-hover:opacity-100"
>
<div>{entradas} entradas</div>
<div>{saidas} saídas</div>
</div>
</div>
{/each}
</div>
</div>
<!-- Linha do eixo X -->
<div
class="border-base-content/30 absolute right-4 bottom-8 left-12 border-t-2"
></div>
<!-- Labels do eixo X -->
<div
class="text-base-content/60 absolute right-4 bottom-0 left-12 flex justify-between text-xs"
>
<span>-60s</span>
<span>-30s</span>
<span>agora</span>
</div>
</div>
<!-- Legenda -->
<div class="border-base-300 mt-4 flex justify-center gap-6 border-t pt-4">
<div class="flex items-center gap-2">
<div class="from-success to-success/70 h-4 w-4 rounded bg-linear-to-t"></div>
<span class="text-base-content/70 text-sm">Entradas no BD</span>
</div>
<div class="flex items-center gap-2">
<div class="from-error to-error/70 h-4 w-4 rounded bg-linear-to-t"></div>
<span class="text-base-content/70 text-sm">Saídas do BD</span>
</div>
</div>
</div>
</div>
<!-- Distribuição de Requisições -->
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="text-base-content mb-4 text-lg font-bold">Tipos de Operações</h3>
<div class="space-y-3">
<div>
<div class="mb-1 flex justify-between text-sm">
<span>Queries (Leituras)</span>
<span class="text-primary font-bold">{distribuicao?.queries ?? 0}</span>
</div>
<progress
class="progress progress-primary w-full"
value={distribuicao?.queries ?? 0}
max={Math.max(
(distribuicao?.queries ?? 0) + (distribuicao?.mutations ?? 0),
1
)}
></progress>
</div>
<div>
<div class="mb-1 flex justify-between text-sm">
<span>Mutations (Escritas)</span>
<span class="text-secondary font-bold">{distribuicao?.mutations ?? 0}</span>
</div>
<progress
class="progress progress-secondary w-full"
value={distribuicao?.mutations ?? 0}
max={Math.max(
(distribuicao?.queries ?? 0) + (distribuicao?.mutations ?? 0),
1
)}
></progress>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="text-base-content mb-4 text-lg font-bold">Operações no Banco</h3>
<div class="space-y-3">
<div>
<div class="mb-1 flex justify-between text-sm">
<span>Leituras</span>
<span class="text-info font-bold">{distribuicao?.leituras ?? 0}</span>
</div>
<progress
class="progress progress-info w-full"
value={distribuicao?.leituras ?? 0}
max={Math.max(
(distribuicao?.leituras ?? 0) + (distribuicao?.escritas ?? 0),
1
)}
></progress>
</div>
<div>
<div class="mb-1 flex justify-between text-sm">
<span>Escritas</span>
<span class="text-warning font-bold">{distribuicao?.escritas ?? 0}</span>
</div>
<progress
class="progress progress-warning w-full"
value={distribuicao?.escritas ?? 0}
max={Math.max(
(distribuicao?.leituras ?? 0) + (distribuicao?.escritas ?? 0),
1
)}
></progress>
</div>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Cards de Status -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg">Status do Sistema</h3>
<div class="mt-4 space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm">Banco de Dados</span>
<span class="badge badge-success">Online</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">API</span>
<span class="badge badge-success">Operacional</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Backup</span>
<span class="badge badge-success">Atualizado</span>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg">Acesso Rápido</h3>
<div class="mt-4 space-y-2">
<a <a
href={resolve('/recursos-humanos/funcionarios/cadastro')} href={resolve('/recursos-humanos/registro-pontos')}
class="btn btn-sm btn-primary w-full" class="feature-card group card transform bg-linear-to-br from-cyan-500/10 to-cyan-600/20 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
> >
Novo Funcionário
</a>
<a
href={resolve('/recursos-humanos/simbolos/cadastro')}
class="btn btn-sm btn-primary w-full"
>
Novo Símbolo
</a>
<a href={resolve('/ti/painel-administrativo')} class="btn btn-sm btn-primary w-full">
Painel Admin
</a>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<h3 class="card-title text-lg">Informações</h3> <div class="mb-4 flex items-center justify-between">
<div class="mt-4 space-y-2 text-sm"> <div
<p class="text-base-content/70"> class="rounded-xl bg-cyan-500/20 p-4 transition-colors duration-300 group-hover:bg-cyan-500/30"
<strong>Versão:</strong> 1.0.0 >
</p> <Clock class="h-10 w-10 text-cyan-600" strokeWidth={2} />
<p class="text-base-content/70"> </div>
<strong>Última Atualização:</strong> </div>
{new Date().toLocaleDateString('pt-BR')} <h3 class="text-base-content mb-2 text-xl font-bold">Controle de Ponto</h3>
</p> <p class="text-base-content/70 text-sm">
<p class="text-base-content/70"> Registre e gerencie pontos de funcionários, banco de horas e homologações de forma
<strong>Suporte:</strong> TI SGSE eficiente.
</p> </p>
</div> </div>
</a>
<!-- Gestão de Férias e Licenças -->
<a
href={resolve('/recursos-humanos/ferias')}
class="feature-card group card transform bg-linear-to-br from-purple-500/10 to-purple-600/20 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<div
class="rounded-xl bg-purple-500/20 p-4 transition-colors duration-300 group-hover:bg-purple-500/30"
>
<Calendar class="h-10 w-10 text-purple-600" strokeWidth={2} />
</div> </div>
</div> </div>
<h3 class="text-base-content mb-2 text-xl font-bold">Gestão de Férias e Licenças</h3>
<p class="text-base-content/70 text-sm">
Controle períodos de férias, atestados médicos e licenças de forma organizada e
simplificada.
</p>
</div>
</a>
<!-- Gestão de Símbolos -->
<a
href={resolve('/recursos-humanos/simbolos')}
class="feature-card group card transform bg-linear-to-br from-green-500/10 to-green-600/20 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<div
class="rounded-xl bg-green-500/20 p-4 transition-colors duration-300 group-hover:bg-green-500/30"
>
<BadgeCheck class="h-10 w-10 text-green-600" strokeWidth={2} />
</div>
</div>
<h3 class="text-base-content mb-2 text-xl font-bold">Gestão de Símbolos</h3>
<p class="text-base-content/70 text-sm">
Gerencie cargos comissionados e funções gratificadas com facilidade e organização.
</p>
</div>
</a>
<!-- Almoxarifado -->
<a
href={resolve('/almoxarifado')}
class="feature-card group card transform bg-linear-to-br from-amber-500/10 to-amber-600/20 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<div
class="rounded-xl bg-amber-500/20 p-4 transition-colors duration-300 group-hover:bg-amber-500/30"
>
<Package class="h-10 w-10 text-amber-600" strokeWidth={2} />
</div>
</div>
<h3 class="text-base-content mb-2 text-xl font-bold">Almoxarifado</h3>
<p class="text-base-content/70 text-sm">
Controle de estoque, materiais, movimentações e requisições de forma integrada e
eficiente.
</p>
</div>
</a>
</div> </div>
{:else}
<!-- Mensagem de erro ou estado vazio -->
<div class="alert alert-warning">
<span>Não foi possível carregar os dados do dashboard.</span>
</div> </div>
{/if}
</main> </main>
</ProtectedRoute> </ProtectedRoute>
@@ -724,7 +493,37 @@
} }
} }
.card { .fade-in {
animation: fadeIn 0.5s ease-out; animation: fadeIn 0.6s ease-out;
}
.fade-in-delay {
animation: fadeIn 0.8s ease-out 0.2s both;
}
.fade-in-delay-2 {
animation: fadeIn 1s ease-out 0.4s both;
}
.stats-card {
animation: fadeIn 0.6s ease-out;
}
.feature-card {
animation: fadeIn 0.8s ease-out;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
} }
</style> </style>

View File

@@ -13,6 +13,7 @@
let modoCriacao = $state(false); let modoCriacao = $state(false);
let mostrandoModalExcluir = $state(false); let mostrandoModalExcluir = $state(false);
let dispensaParaExcluir = $state<Id<'dispensasRegistro'> | null>(null); let dispensaParaExcluir = $state<Id<'dispensasRegistro'> | null>(null);
let filtroStatus = $state<'todas' | 'ativas' | 'expiradas'>('todas');
// Formulário // Formulário
let dataInicio = $state(new Date().toISOString().split('T')[0]!); let dataInicio = $state(new Date().toISOString().split('T')[0]!);
@@ -25,22 +26,33 @@
// Computed para converter time string para hora/minuto // Computed para converter time string para hora/minuto
let horaInicio = $derived.by(() => { let horaInicio = $derived.by(() => {
const [hora, minuto] = horaInicioTime.split(':').map(Number); const [hora, minuto] = horaInicioTime.split(':').map(Number);
return { hora: hora || 8, minuto: minuto || 0 }; return { hora: isNaN(hora) ? 8 : hora, minuto: isNaN(minuto) ? 0 : minuto };
}); });
let horaFim = $derived.by(() => { let horaFim = $derived.by(() => {
const [hora, minuto] = horaFimTime.split(':').map(Number); const [hora, minuto] = horaFimTime.split(':').map(Number);
return { hora: hora || 18, minuto: minuto || 0 }; return { hora: isNaN(hora) ? 18 : hora, minuto: isNaN(minuto) ? 0 : minuto };
}); });
// Queries // Queries
const subordinadosQuery = useQuery(api.times.listarSubordinadosDoGestorAtual, {}); const subordinadosQuery = useQuery(api.times.listarSubordinadosDoGestorAtual, {});
const dispensasQuery = useQuery(api.pontos.listarDispensas, { const dispensasQuery = useQuery(api.pontos.listarDispensas, {});
apenasAtivas: true // Mostrar apenas dispensas ativas
});
let subordinados = $derived(subordinadosQuery?.data || []); let subordinados = $derived(subordinadosQuery?.data || []);
let dispensas = $derived(dispensasQuery?.data || []); let todasDispensas = $derived(dispensasQuery?.data || []);
// Filtrar dispensas baseado no filtro selecionado
let dispensas = $derived.by(() => {
if (filtroStatus === 'todas') {
return todasDispensas;
} else if (filtroStatus === 'ativas') {
// Ativas: não expiradas (inclui isentos que já começaram)
return todasDispensas.filter((d) => !d.expirada);
} else {
// Expiradas: apenas dispensas não isentas que expiraram
return todasDispensas.filter((d) => d.expirada && !d.isento);
}
});
// Lista de funcionários do time // Lista de funcionários do time
let funcionarios = $derived.by(() => { let funcionarios = $derived.by(() => {
@@ -313,11 +325,42 @@
<!-- Lista de Dispensas --> <!-- Lista de Dispensas -->
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<h2 class="card-title mb-4">Dispensas Ativas</h2> <div class="mb-4 flex items-center justify-between">
<h2 class="card-title">Dispensas</h2>
<!-- Filtro de Status -->
<div class="join">
<button
class="btn join-item btn-sm {filtroStatus === 'todas' ? 'btn-active' : ''}"
onclick={() => (filtroStatus = 'todas')}
>
Todas
</button>
<button
class="btn join-item btn-sm {filtroStatus === 'ativas' ? 'btn-active' : ''}"
onclick={() => (filtroStatus = 'ativas')}
>
Ativas
</button>
<button
class="btn join-item btn-sm {filtroStatus === 'expiradas' ? 'btn-active' : ''}"
onclick={() => (filtroStatus = 'expiradas')}
>
Expiradas
</button>
</div>
</div>
{#if dispensas.length === 0} {#if dispensas.length === 0}
<div class="alert alert-info"> <div class="alert alert-info">
<span>Nenhuma dispensa ativa encontrada</span> <span>
{#if filtroStatus === 'todas'}
Nenhuma dispensa encontrada
{:else if filtroStatus === 'ativas'}
Nenhuma dispensa ativa encontrada
{:else}
Nenhuma dispensa expirada encontrada
{/if}
</span>
</div> </div>
{:else} {:else}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -373,7 +416,7 @@
{#if dispensa.isento} {#if dispensa.isento}
<span class="badge badge-warning">Isento (sem expiração)</span> <span class="badge badge-warning">Isento (sem expiração)</span>
{:else if dispensa.expirada} {:else if dispensa.expirada}
<span class="badge badge-error">Expirada</span> <span class="badge badge-error">Não ativo</span>
{:else} {:else}
<span class="badge badge-success">Ativa</span> <span class="badge badge-success">Ativa</span>
{/if} {/if}

View File

@@ -7,10 +7,12 @@ export const getStats = query({
returns: v.object({ returns: v.object({
totalFuncionarios: v.number(), totalFuncionarios: v.number(),
totalSimbolos: v.number(), totalSimbolos: v.number(),
totalUsuarios: v.number(),
funcionariosAtivos: v.number(), funcionariosAtivos: v.number(),
funcionariosDesligados: v.number(), funcionariosDesligados: v.number(),
cargoComissionado: v.number(), cargoComissionado: v.number(),
funcaoGratificada: v.number() funcaoGratificada: v.number(),
totalCadastros: v.number()
}), }),
handler: async (ctx) => { handler: async (ctx) => {
// Contar funcionários // Contar funcionários
@@ -36,41 +38,22 @@ export const getStats = query({
const simbolos = await ctx.db.query('simbolos').collect(); const simbolos = await ctx.db.query('simbolos').collect();
const totalSimbolos = simbolos.length; const totalSimbolos = simbolos.length;
// Contar usuários cadastrados
const usuarios = await ctx.db.query('usuarios').collect();
const totalUsuarios = usuarios.length;
// Calcular total de cadastros (funcionários + símbolos + usuários)
const totalCadastros = totalFuncionarios + totalSimbolos + totalUsuarios;
return { return {
totalFuncionarios, totalFuncionarios,
totalSimbolos, totalSimbolos,
totalUsuarios,
funcionariosAtivos, funcionariosAtivos,
funcionariosDesligados, funcionariosDesligados,
cargoComissionado, cargoComissionado,
funcaoGratificada funcaoGratificada,
}; totalCadastros
}
});
// Obter atividades recentes (últimas 24 horas)
export const getRecentActivity = query({
args: {},
returns: v.object({
funcionariosCadastrados24h: v.number(),
simbolosCadastrados24h: v.number()
}),
handler: async (ctx) => {
const now = Date.now();
const last24h = now - 24 * 60 * 60 * 1000;
// Funcionários cadastrados nas últimas 24h
const funcionarios = await ctx.db.query('funcionarios').collect();
const funcionariosCadastrados24h = funcionarios.filter(
(f) => f._creationTime >= last24h
).length;
// Símbolos cadastrados nas últimas 24h
const simbolos = await ctx.db.query('simbolos').collect();
const simbolosCadastrados24h = simbolos.filter((s) => s._creationTime >= last24h).length;
return {
funcionariosCadastrados24h,
simbolosCadastrados24h
}; };
} }
}); });

View File

@@ -847,224 +847,3 @@ export const obterHistoricoAlertas = query({
} }
}); });
/**
* Status consolidado do sistema para o dashboard
*/
export const getStatusSistema = query({
args: {},
returns: v.object({
usuariosOnline: v.number(),
totalRegistros: v.number(),
tempoMedioResposta: v.number(),
cpuUsada: v.number(),
memoriaUsada: v.number(),
ultimaAtualizacao: v.number()
}),
handler: async (ctx) => {
try {
// Última métrica, se existir
const ultimaMetrica = (await ctx.db.query('systemMetrics').order('desc').first()) ?? null;
// Usuários online: usar métrica se disponível, senão derivar de usuários
let usuariosOnline = 0;
if (ultimaMetrica?.usuariosOnline !== undefined) {
usuariosOnline = ultimaMetrica.usuariosOnline;
} else {
const usuarios = await ctx.db.query('usuarios').collect();
usuariosOnline = usuarios.filter((u) => u.statusPresenca === 'online').length;
}
// Total de registros (estimativa baseada em tabelas principais)
const [usuarios, funcionarios, simbolos, alertas, metricas] = await Promise.all([
ctx.db.query('usuarios').collect(),
ctx.db.query('funcionarios').collect(),
ctx.db.query('simbolos').collect(),
ctx.db.query('alertConfigurations').collect(),
ctx.db.query('systemMetrics').take(100) // não precisa contar tudo
]);
const totalRegistros =
usuarios.length + funcionarios.length + simbolos.length + alertas.length + metricas.length;
// Métricas de performance com fallbacks seguros
const tempoMedioResposta = ultimaMetrica?.tempoRespostaMedio ?? 0;
const cpuUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.cpuUsage ?? 0) * 100) / 100)
);
const memoriaUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.memoryUsage ?? 0) * 100) / 100)
);
const ultimaAtualizacao = ultimaMetrica?.timestamp ?? Date.now();
return {
usuariosOnline,
totalRegistros,
tempoMedioResposta,
cpuUsada,
memoriaUsada,
ultimaAtualizacao
};
} catch (error) {
console.error('Erro em getStatusSistema:', error);
// Retornar valores padrão em caso de erro
return {
usuariosOnline: 0,
totalRegistros: 0,
tempoMedioResposta: 0,
cpuUsada: 0,
memoriaUsada: 0,
ultimaAtualizacao: Date.now()
};
}
}
});
/**
* Atividade do banco no último minuto (agregada em buckets)
* Usa logsAtividades e systemMetrics para calcular atividade real.
*/
export const getAtividadeBancoDados = query({
args: {},
returns: v.object({
historico: v.array(
v.object({
entradas: v.number(),
saidas: v.number()
})
)
}),
handler: async (ctx) => {
try {
const agora = Date.now();
const haUmMinuto = agora - 60 * 1000;
// Buscar atividades reais do sistema
const atividadesRecentes = await ctx.db
.query('logsAtividades')
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
.order('asc')
.collect();
// Buscar métricas também (para mensagens se houver)
const metricasRecentes = await ctx.db
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
.order('asc')
.collect();
// Bucketizar em 30 pontos (~2s cada) para visualização
const numBuckets = 30;
const bucketSizeMs = Math.ceil(60_000 / numBuckets);
const historico: Array<{ entradas: number; saidas: number }> = [];
for (let i = 0; i < numBuckets; i++) {
const inicio = haUmMinuto + i * bucketSizeMs;
const fim = inicio + bucketSizeMs;
// Contar atividades de criação/inserção (entradas)
const atividadesBucket = atividadesRecentes.filter(
(a) => a.timestamp >= inicio && a.timestamp < fim
);
const entradasAtividades = atividadesBucket.filter(
(a) => a.acao === 'criar' || a.acao === 'inserir' || a.acao === 'cadastrar'
).length;
// Contar atividades de exclusão/remoção (saídas)
const saidasAtividades = atividadesBucket.filter(
(a) => a.acao === 'excluir' || a.acao === 'remover' || a.acao === 'deletar'
).length;
// Usar mensagensPorMinuto como adicional se disponível
const bucketMetricas = metricasRecentes.filter(
(m) => m.timestamp >= inicio && m.timestamp < fim
);
const somaMensagens =
bucketMetricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0) || 0;
// Combinar atividades reais com métricas de mensagens
const entradas = Math.max(0, Math.round(entradasAtividades + somaMensagens * 0.3));
const saidas = Math.max(0, Math.round(saidasAtividades + somaMensagens * 0.2));
historico.push({ entradas, saidas });
}
return { historico };
} catch (error) {
console.error('Erro em getAtividadeBancoDados:', error);
// Retornar histórico vazio em caso de erro
return { historico: Array(30).fill({ entradas: 0, saidas: 0 }) };
}
}
});
/**
* Distribuição de operações (calculada a partir de logsAtividades e métricas)
*/
export const getDistribuicaoRequisicoes = query({
args: {},
returns: v.object({
queries: v.number(),
mutations: v.number(),
leituras: v.number(),
escritas: v.number()
}),
handler: async (ctx) => {
try {
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
// Buscar atividades reais do sistema
const atividades = await ctx.db
.query('logsAtividades')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.collect();
// Buscar métricas também
const metricas = await ctx.db
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.order('desc')
.take(100);
// Contar operações de leitura (consultas, visualizações)
const leituras = atividades.filter(
(a) =>
a.acao === 'consultar' ||
a.acao === 'visualizar' ||
a.acao === 'listar' ||
a.acao === 'buscar'
).length;
// Contar operações de escrita (criar, editar, excluir)
const escritas = atividades.filter(
(a) =>
a.acao === 'criar' ||
a.acao === 'editar' ||
a.acao === 'excluir' ||
a.acao === 'inserir' ||
a.acao === 'atualizar' ||
a.acao === 'deletar' ||
a.acao === 'cadastrar' ||
a.acao === 'remover'
).length;
// Adicionar estimativa baseada em mensagens se disponível
const totalMensagens = Math.max(
0,
Math.round(metricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0))
);
// Queries são leituras + parte das mensagens (como consultas de chat)
const queries = leituras + Math.round(totalMensagens * 0.5);
// Mutations são escritas + parte das mensagens (como envio de mensagens)
const mutations = escritas + Math.round(totalMensagens * 0.3);
return { queries, mutations, leituras, escritas };
} catch (error) {
console.error('Erro em getDistribuicaoRequisicoes:', error);
// Retornar valores padrão em caso de erro
return { queries: 0, mutations: 0, leituras: 0, escritas: 0 };
}
}
});

View File

@@ -636,45 +636,59 @@ export const registrarPonto = mutation({
.filter((q) => q.eq(q.field('ativo'), true)) .filter((q) => q.eq(q.field('ativo'), true))
.collect(); .collect();
const dataConsulta = new Date(data); // Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3
// A hora informada está em GMT-3, então precisamos adicionar 3 horas para obter UTC
const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos
function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC;
}
// Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto que já estão em UTC
function criarTimestampUTC(data: string, horaUTC: number, minutoUTC: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
return Date.UTC(ano, mes - 1, dia, horaUTC, minutoUTC, 0, 0);
}
// Obter timestamp atual em UTC
const agoraUTC = new Date();
const agoraTimestampUTC = agoraUTC.getTime();
// Timestamp da consulta (registro sendo feito) em UTC
// hora/minuto já estão em UTC (extraídos com getUTCHours/getUTCMinutes)
const timestampConsultaUTC = criarTimestampUTC(data, hora, minuto);
for (const dispensa of dispensas) { for (const dispensa of dispensas) {
// Se for isento, sempre está dispensado // Se for isento, sempre está dispensado
if (dispensa.isento) { if (dispensa.isento) {
throw new Error('Registro dispensado pelo gestor: Isento de registro (caso excepcional)'); throw new Error('Registro dispensado pelo gestor: Isento de registro (caso excepcional)');
} }
// Verificar se está no período // Calcular timestamps de início e fim da dispensa em UTC
const dataInicio = new Date(dispensa.dataInicio); const timestampInicioUTC = criarTimestampUTCDeGMT3(
const dataFim = new Date(dispensa.dataFim); dispensa.dataInicio,
dispensa.horaInicio,
dispensa.minutoInicio
);
const timestampFimUTC = criarTimestampUTCDeGMT3(
dispensa.dataFim,
dispensa.horaFim,
dispensa.minutoFim
);
if (dataConsulta >= dataInicio && dataConsulta <= dataFim) { // Desativar dispensa expirada ANTES de verificar bloqueio (após o fim)
// Verificar hora e minuto se necessário // Verificar se AGORA já passou do horário de fim da dispensa
const timestampConsulta = new Date( if (agoraTimestampUTC > timestampFimUTC) {
`${data}T${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}:00`
).getTime();
const timestampInicio = new Date(
`${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00`
).getTime();
const timestampFim = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) {
throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`);
}
}
// Verificar se expirou (desativar na mutation de registro)
const agora = new Date();
const dataFimTimestamp = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
if (agora.getTime() > dataFimTimestamp && !dispensa.isento) {
// Desativar dispensa expirada (mutation pode fazer isso)
await ctx.db.patch(dispensa._id, { await ctx.db.patch(dispensa._id, {
ativo: false ativo: false
}); });
continue; // Pular verificação de bloqueio se já expirou
}
// Verificar se AGORA está dentro do período da dispensa (não o horário do registro)
// Se o momento atual está dentro do período, bloqueia qualquer tentativa de registro
if (agoraTimestampUTC >= timestampInicioUTC && agoraTimestampUTC <= timestampFimUTC) {
throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`);
} }
} }
@@ -2883,10 +2897,7 @@ export const excluirHomologacao = mutation({
}; };
// Se a homologação tem valores anteriores, restaurar // Se a homologação tem valores anteriores, restaurar
if ( if (homologacao.horaAnterior !== undefined && homologacao.minutoAnterior !== undefined) {
homologacao.horaAnterior !== undefined &&
homologacao.minutoAnterior !== undefined
) {
patchData.hora = homologacao.horaAnterior; patchData.hora = homologacao.horaAnterior;
patchData.minuto = homologacao.minutoAnterior; patchData.minuto = homologacao.minutoAnterior;
} }
@@ -3033,10 +3044,8 @@ export const removerDispensaRegistro = mutation({
throw new Error('Você não tem permissão para remover esta dispensa'); throw new Error('Você não tem permissão para remover esta dispensa');
} }
// Desativar dispensa // Deletar dispensa do banco de dados
await ctx.db.patch(args.dispensaId, { await ctx.db.delete(args.dispensaId);
ativo: false
});
return { success: true }; return { success: true };
} }
@@ -3117,14 +3126,49 @@ export const listarDispensas = query({
} }
} }
// Verificar se expirou (se não for isento) // Verificar se está ativa ou expirada (considerando data, hora e minuto em GMT-3)
let expirada = false; let expirada = false;
// GMT-3 está 3 horas ATRÁS do UTC
// Offset: +3 horas para converter GMT-3 para UTC
const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos
// Obter data/hora atual em UTC
const agoraUTC = new Date();
const agoraTimestampUTC = agoraUTC.getTime();
// Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3
// A hora informada está em GMT-3, então precisamos adicionar 3 horas para obter UTC
// Exemplo: 08:00 GMT-3 = 11:00 UTC
function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
// Date.UTC cria timestamp UTC
// Se a hora está em GMT-3, adicionamos 3 horas para obter o equivalente UTC
return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC;
}
if (!d.isento) { if (!d.isento) {
const agora = new Date(); // Para dispensas não isentas, verificar se está dentro do período
const dataFimTimestamp = new Date( const dataInicioTimestamp = criarTimestampUTCDeGMT3(
`${d.dataFim}T${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}:00` d.dataInicio,
).getTime(); d.horaInicio,
expirada = agora.getTime() > dataFimTimestamp; d.minutoInicio
);
const dataFimTimestamp = criarTimestampUTCDeGMT3(d.dataFim, d.horaFim, d.minutoFim);
// Está expirada se estiver antes do início OU depois do fim
// Está ativa se: dataInicioTimestamp <= agoraTimestampUTC <= dataFimTimestamp
expirada =
agoraTimestampUTC < dataInicioTimestamp || agoraTimestampUTC > dataFimTimestamp;
} else {
// Se for isento, verificar apenas se já passou do início
const dataInicioTimestamp = criarTimestampUTCDeGMT3(
d.dataInicio,
d.horaInicio,
d.minutoInicio
);
// Se ainda não começou, está expirada (não ativa ainda)
expirada = agoraTimestampUTC < dataInicioTimestamp;
} }
return { return {
@@ -3349,7 +3393,16 @@ export const verificarDispensaAtiva = query({
.filter((q) => q.eq(q.field('ativo'), true)) .filter((q) => q.eq(q.field('ativo'), true))
.collect(); .collect();
const dataConsulta = new Date(args.data); // Helper para criar timestamp UTC a partir de data (YYYY-MM-DD), hora e minuto em GMT-3
const offsetGMT3ParaUTC = 3 * 60 * 60 * 1000; // 3 horas em milissegundos
function criarTimestampUTCDeGMT3(data: string, hora: number, minuto: number): number {
const [ano, mes, dia] = data.split('-').map(Number);
return Date.UTC(ano, mes - 1, dia, hora, minuto, 0, 0) + offsetGMT3ParaUTC;
}
// Obter timestamp atual em UTC
const agoraUTC = new Date();
const agoraTimestampUTC = agoraUTC.getTime();
for (const dispensa of dispensas) { for (const dispensa of dispensas) {
// Se for isento, sempre está dispensado // Se for isento, sempre está dispensado
@@ -3361,25 +3414,29 @@ export const verificarDispensaAtiva = query({
}; };
} }
// Verificar se está no período // Calcular timestamps de início e fim da dispensa em UTC
const dataInicio = new Date(dispensa.dataInicio); const timestampInicioUTC = criarTimestampUTCDeGMT3(
const dataFim = new Date(dispensa.dataFim); dispensa.dataInicio,
dispensa.horaInicio,
dispensa.minutoInicio
);
const timestampFimUTC = criarTimestampUTCDeGMT3(
dispensa.dataFim,
dispensa.horaFim,
dispensa.minutoFim
);
// Se a data está dentro do período // Verificar se AGORA já passou do horário de fim da dispensa
if (dataConsulta >= dataInicio && dataConsulta <= dataFim) { // Se já expirou, não está mais dispensado
// Se hora e minuto foram fornecidos, verificar também if (agoraTimestampUTC > timestampFimUTC) {
// Dispensa expirada, continuar para próxima
continue;
}
// Se hora e minuto foram fornecidos, verificar timestamp completo
if (args.hora !== undefined && args.minuto !== undefined) { if (args.hora !== undefined && args.minuto !== undefined) {
const timestampConsulta = new Date( const timestampConsultaUTC = criarTimestampUTCDeGMT3(args.data, args.hora, args.minuto);
`${args.data}T${args.hora.toString().padStart(2, '0')}:${args.minuto.toString().padStart(2, '0')}:00` if (timestampConsultaUTC >= timestampInicioUTC && timestampConsultaUTC <= timestampFimUTC) {
).getTime();
const timestampInicio = new Date(
`${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00`
).getTime();
const timestampFim = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) {
return { return {
dispensado: true, dispensado: true,
dispensa, dispensa,
@@ -3387,7 +3444,9 @@ export const verificarDispensaAtiva = query({
}; };
} }
} else { } else {
// Apenas verificar data // Se apenas data foi fornecida, verificar se AGORA está dentro do período
// (não apenas a data, mas também o horário)
if (agoraTimestampUTC >= timestampInicioUTC && agoraTimestampUTC <= timestampFimUTC) {
return { return {
dispensado: true, dispensado: true,
dispensa, dispensa,