feat: enhance vacation approval process by adding notification system for employees, including email alerts and in-app notifications; improve error handling and user feedback during vacation management

This commit is contained in:
2025-12-10 06:27:25 -03:00
parent 73da995109
commit d27c0b6f91
22 changed files with 1572 additions and 215 deletions

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { Info, X } from 'lucide-svelte';
interface Props {
open: boolean;
title?: string;
message: string;
buttonText?: string;
onClose: () => void;
}
let {
open = $bindable(false),
title = 'Atenção',
message,
buttonText = 'OK',
onClose
}: Props = $props();
function handleClose() {
open = false;
onClose();
}
</script>
{#if open}
<div
class="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-alert-title"
>
<!-- Backdrop -->
<div
class="pointer-events-auto absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity duration-200"
onclick={handleClose}
></div>
<!-- Modal Box -->
<div
class="bg-base-100 pointer-events-auto absolute left-1/2 top-1/2 z-10 flex max-h-[90vh] w-full max-w-md transform -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div
class="border-base-300 from-info/10 to-info/5 flex flex-shrink-0 items-center justify-between border-b bg-linear-to-r px-6 py-4"
>
<h2 id="modal-alert-title" class="text-info flex items-center gap-2 text-xl font-bold">
<Info class="h-6 w-6" strokeWidth={2.5} />
{title}
</h2>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
onclick={handleClose}
aria-label="Fechar"
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
<p class="text-base-content text-base leading-relaxed">{message}</p>
</div>
<!-- Footer -->
<div class="border-base-300 flex flex-shrink-0 justify-end border-t px-6 py-4">
<button class="btn btn-primary" onclick={handleClose}>{buttonText}</button>
</div>
</div>
</div>
{/if}
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, -40%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
/* Scrollbar customizada */
:global(.modal-scroll) {
scrollbar-width: thin;
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
:global(.modal-scroll::-webkit-scrollbar) {
width: 8px;
}
:global(.modal-scroll::-webkit-scrollbar-track) {
background: transparent;
border-radius: 4px;
}
:global(.modal-scroll::-webkit-scrollbar-thumb) {
background-color: hsl(var(--bc) / 0.3);
border-radius: 4px;
transition: background-color 0.2s ease;
}
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
background-color: hsl(var(--bc) / 0.5);
}
</style>

View File

@@ -76,7 +76,8 @@
dataInicio: periodo.dataInicio,
dataFim: periodo.dataFim
}
]
],
feriasIdExcluir: periodo._id // Excluir este período do cálculo de saldo pendente
});
if (!validacao.valido) {

View File

@@ -5,6 +5,7 @@
import interactionPlugin from '@fullcalendar/interaction';
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
import type { EventInput } from '@fullcalendar/core/index.js';
import { SvelteDate } from 'svelte/reactivity';
interface Props {
eventos: Array<{
@@ -40,20 +41,31 @@
return eventos.filter((e) => e.tipo === filtroAtivo);
});
// Helper: Adicionar 1 dia à data fim (FullCalendar usa exclusive end)
function calcularDataFim(dataFim: string): string {
// Usar SvelteDate para evitar problemas de mutabilidade e timezone
const data = new SvelteDate(dataFim + 'T00:00:00');
data.setDate(data.getDate() + 1);
return data.toISOString().split('T')[0];
}
// Converter eventos para formato FullCalendar
const eventosFullCalendar = $derived.by(() => {
return eventosFiltrados.map((evento) => ({
id: evento.id,
title: evento.title,
start: evento.start,
end: evento.end,
end: calcularDataFim(evento.end), // Ajustar data fim (exclusive end)
allDay: true, // IMPORTANTE: Tratar como dia inteiro sem timezone
backgroundColor: evento.color,
borderColor: evento.color,
textColor: '#ffffff',
extendedProps: {
tipo: evento.tipo,
funcionarioNome: evento.funcionarioNome,
funcionarioId: evento.funcionarioId
funcionarioId: evento.funcionarioId,
dataInicioOriginal: evento.start, // Armazenar data original para exibição
dataFimOriginal: evento.end // Armazenar data original para exibição
}
})) as EventInput[];
});
@@ -79,12 +91,14 @@
},
events: eventosFullCalendar,
eventClick: (info) => {
// Usar datas originais armazenadas nos extendedProps para exibição correta
const props = info.event.extendedProps;
eventoSelecionado = {
title: info.event.title,
start: info.event.startStr || '',
end: info.event.endStr || '',
tipo: info.event.extendedProps.tipo as string,
funcionarioNome: info.event.extendedProps.funcionarioNome as string
start: (props.dataInicioOriginal as string) || info.event.startStr || '',
end: (props.dataFimOriginal as string) || info.event.endStr || '',
tipo: props.tipo as string,
funcionarioNome: props.funcionarioNome as string
};
showModal = true;
},
@@ -359,8 +373,10 @@
<p class="text-base-content/60 text-sm">Duração</p>
<p class="font-semibold">
{(() => {
const inicio = new Date(eventoSelecionado.start);
const fim = new Date(eventoSelecionado.end);
// Usar SvelteDate para evitar problemas de mutabilidade e timezone
const inicio = new SvelteDate(eventoSelecionado.start + 'T00:00:00');
const fim = new SvelteDate(eventoSelecionado.end + 'T00:00:00');
// Não precisa ajustar porque estamos usando as datas originais dos extendedProps
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return `${diffDays} ${diffDays === 1 ? 'dia' : 'dias'}`;

View File

@@ -0,0 +1,135 @@
<script lang="ts">
import { AlertTriangle, X } from 'lucide-svelte';
interface Props {
open: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
}
let {
open = $bindable(false),
title = 'Confirmar ação',
message,
confirmText = 'Confirmar',
cancelText = 'Cancelar',
onConfirm,
onCancel
}: Props = $props();
function handleConfirm() {
open = false;
onConfirm();
}
function handleCancel() {
open = false;
onCancel();
}
</script>
{#if open}
<div
class="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-confirm-title"
>
<!-- Backdrop -->
<div
class="pointer-events-auto absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity duration-200"
onclick={handleCancel}
></div>
<!-- Modal Box -->
<div
class="bg-base-100 pointer-events-auto absolute left-1/2 top-1/2 z-10 flex max-h-[90vh] w-full max-w-md transform -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div
class="border-base-300 from-warning/10 to-warning/5 flex flex-shrink-0 items-center justify-between border-b bg-linear-to-r px-6 py-4"
>
<h2 id="modal-confirm-title" class="text-warning flex items-center gap-2 text-xl font-bold">
<AlertTriangle class="h-6 w-6" strokeWidth={2.5} />
{title}
</h2>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
onclick={handleCancel}
aria-label="Fechar"
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
<p class="text-base-content text-base leading-relaxed">{message}</p>
</div>
<!-- Footer -->
<div class="border-base-300 flex flex-shrink-0 justify-end gap-3 border-t px-6 py-4">
<button class="btn btn-ghost" onclick={handleCancel}>{cancelText}</button>
<button class="btn btn-warning" onclick={handleConfirm}>{confirmText}</button>
</div>
</div>
</div>
{/if}
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, -40%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
/* Scrollbar customizada */
:global(.modal-scroll) {
scrollbar-width: thin;
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
:global(.modal-scroll::-webkit-scrollbar) {
width: 8px;
}
:global(.modal-scroll::-webkit-scrollbar-track) {
background: transparent;
border-radius: 4px;
}
:global(.modal-scroll::-webkit-scrollbar-thumb) {
background-color: hsl(var(--bc) / 0.3);
border-radius: 4px;
transition: background-color 0.2s ease;
}
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
background-color: hsl(var(--bc) / 0.5);
}
</style>

View File

@@ -1,17 +1,24 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import AlertModal from '$lib/components/AlertModal.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
let { onClose }: { onClose: () => void } = $props();
const client = useConvexClient();
const alertasQuery = useQuery(api.monitoramento.listarAlertas, {});
// Derivação - o useQuery retorna um objeto com propriedade data quando a query retorna um array
const alertas = $derived.by(() => {
if (!alertasQuery) return [];
// O useQuery pode retornar o array diretamente ou em .data
if (Array.isArray(alertasQuery)) return alertasQuery;
return alertasQuery.data ?? [];
if (alertasQuery === undefined) return [];
// Verificar se é um objeto com propriedade data
if (alertasQuery && typeof alertasQuery === 'object' && 'data' in alertasQuery) {
return Array.isArray(alertasQuery.data) ? alertasQuery.data : [];
}
// Fallback: se for diretamente um array
return Array.isArray(alertasQuery) ? alertasQuery : [];
});
// Estado para novo alerta
@@ -25,6 +32,13 @@
let saving = $state(false);
let showForm = $state(false);
// Estado para modais
let showAlertModal = $state(false);
let alertMessage = $state('');
let showConfirmModal = $state(false);
let confirmMessage = $state('');
let confirmCallback = $state<(() => void) | null>(null);
const metricOptions = [
{ value: 'cpuUsage', label: 'Uso de CPU (%)' },
{ value: 'memoryUsage', label: 'Uso de Memória (%)' },
@@ -55,7 +69,7 @@
showForm = false;
}
function editAlert(alert: any) {
function editAlert(alert: Doc<'alertConfigurations'>) {
editingAlertId = alert._id;
metricName = alert.metricName;
threshold = alert.threshold;
@@ -67,9 +81,15 @@
}
async function saveAlert() {
if (!notifyByChat && !notifyByEmail) {
alertMessage = 'Selecione pelo menos um método de notificação (Chat ou Email)';
showAlertModal = true;
return;
}
saving = true;
try {
await client.mutation(api.monitoramento.configurarAlerta, {
const result = await client.mutation(api.monitoramento.configurarAlerta, {
alertId: editingAlertId || undefined,
metricName,
threshold,
@@ -79,23 +99,33 @@
notifyByChat
});
resetForm();
if (result.success) {
// Aguardar um pouco para garantir que a query seja atualizada pelo Convex Svelte
await new Promise(resolve => setTimeout(resolve, 200));
resetForm();
}
} catch (error) {
console.error('Erro ao salvar alerta:', error);
alert('Erro ao salvar alerta. Tente novamente.');
alertMessage = 'Erro ao salvar alerta. Tente novamente.';
showAlertModal = true;
} finally {
saving = false;
}
}
async function deleteAlert(alertId: Id<'alertConfigurations'>) {
if (!confirm('Tem certeza que deseja deletar este alerta?')) return;
function handleDeleteClick(alertId: Id<'alertConfigurations'>) {
confirmMessage = 'Tem certeza que deseja deletar este alerta?';
confirmCallback = () => deleteAlert(alertId);
showConfirmModal = true;
}
async function deleteAlert(alertId: Id<'alertConfigurations'>) {
try {
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
} catch (error) {
console.error('Erro ao deletar alerta:', error);
alert('Erro ao deletar alerta. Tente novamente.');
alertMessage = 'Erro ao deletar alerta. Tente novamente.';
showAlertModal = true;
}
}
@@ -109,7 +139,7 @@
</script>
<dialog class="modal modal-open">
<div class="modal-box from-base-100 to-base-200 max-w-4xl bg-linear-to-br">
<div class="modal-box from-base-100 to-base-200 max-w-4xl max-h-[90vh] overflow-y-auto bg-linear-to-br">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
@@ -163,7 +193,7 @@
class="select select-bordered select-primary"
bind:value={metricName}
>
{#each metricOptions as option}
{#each metricOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
@@ -179,7 +209,7 @@
class="select select-bordered select-primary"
bind:value={operator}
>
{#each operatorOptions as option}
{#each operatorOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
@@ -211,6 +241,31 @@
<!-- Notificações -->
<div class="divider">Método de Notificação</div>
<!-- Aviso sobre destinatários -->
<div class="alert alert-warning mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div>
<h4 class="font-bold">⚠️ Destinatários das Notificações</h4>
<p class="text-sm">
As notificações de alerta serão enviadas <strong>apenas para usuários com perfil TI_MASTER</strong>.
Certifique-se de que os responsáveis pelo monitoramento possuem este perfil.
</p>
</div>
</div>
<div class="flex gap-6">
<label class="label cursor-pointer gap-3">
<input
@@ -328,7 +383,11 @@
<!-- Lista de Alertas -->
<div class="divider">Alertas Configurados</div>
{#if alertas.length > 0}
{#if alertasQuery === undefined}
<div class="py-8 text-center">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if alertas.length > 0}
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead>
@@ -341,7 +400,7 @@
</tr>
</thead>
<tbody>
{#each alertas as alerta}
{#each alertas as alerta (alerta._id)}
<tr class={!alerta.enabled ? 'opacity-50' : ''}>
<td>
<div class="font-semibold">
@@ -405,7 +464,12 @@
</td>
<td>
<div class="flex gap-2">
<button type="button" class="btn btn-xs" onclick={() => editAlert(alerta)}>
<button
type="button"
class="btn btn-xs"
onclick={() => editAlert(alerta)}
aria-label="Editar alerta"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
@@ -424,7 +488,8 @@
<button
type="button"
class="btn btn-xs text-error"
onclick={() => deleteAlert(alerta._id)}
onclick={() => handleDeleteClick(alerta._id)}
aria-label="Deletar alerta"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -467,13 +532,40 @@
</div>
{/if}
<div class="modal-action">
<button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button>
</div>
<div class="modal-action">
<button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form method="dialog" class="modal-backdrop" onclick={onClose}>
<button type="button">close</button>
</form>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form method="dialog" class="modal-backdrop" onclick={onClose}>
<button type="button">close</button>
</form>
</dialog>
<!-- Modais padrão SGSE -->
<AlertModal
open={showAlertModal}
title="Atenção"
message={alertMessage}
onClose={() => (showAlertModal = false)}
/>
<ConfirmModal
open={showConfirmModal}
title="Confirmar exclusão"
message={confirmMessage}
confirmText="Deletar"
cancelText="Cancelar"
onConfirm={() => {
if (confirmCallback) {
confirmCallback();
confirmCallback = null;
}
showConfirmModal = false;
}}
onCancel={() => {
confirmCallback = null;
showConfirmModal = false;
}}
/>

View File

@@ -0,0 +1,204 @@
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { CheckCircle, XCircle, AlertCircle, RefreshCw, Mail, Users, Settings, FileText } from 'lucide-svelte';
// Criar uma chave reativa para forçar atualização da query
let refreshKey = $state(0);
// Usar refreshKey nos argumentos para forçar recarregamento quando mudar
// O backend ignora esse parâmetro, mas força o Convex Svelte a reexecutar a query
const configQuery = useQuery(api.monitoramento.verificarConfiguracaoAlertas, { _refresh: refreshKey });
function refresh() {
refreshKey++;
}
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="card-title flex items-center justify-between">
<div class="flex items-center gap-2">
<Settings class="h-6 w-6 text-primary" />
<h2 class="text-2xl font-bold">Diagnóstico de Configuração de Alertas</h2>
</div>
<button class="btn btn-sm btn-ghost" onclick={refresh}>
<RefreshCw class="h-4 w-4" />
Atualizar
</button>
</div>
{#if configQuery === undefined}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if configQuery === null}
<div class="alert alert-error">
<XCircle class="h-6 w-6" />
<span>Erro ao carregar diagnóstico</span>
</div>
{:else}
{@const config = configQuery}
<!-- Template -->
<div class="divider">Template de Email</div>
<div class="flex items-center gap-4">
{#if config.templateExiste}
<CheckCircle class="h-6 w-6 text-success" />
<div class="flex-1">
<p class="font-semibold text-success">Template encontrado</p>
{#if config.templateInfo}
<p class="text-sm text-base-content/70">
{config.templateInfo.nome} ({config.templateInfo.codigo})
</p>
{#if !config.templateInfo.htmlCorpo}
<p class="text-xs text-warning mt-1">
⚠️ Template não possui HTML personalizado
</p>
{/if}
{/if}
</div>
{:else}
<XCircle class="h-6 w-6 text-error" />
<div class="flex-1">
<p class="font-semibold text-error">Template não encontrado</p>
<p class="text-sm text-base-content/70">
O template "monitoramento_alerta_sistema" não foi encontrado no banco de dados.
</p>
<p class="text-xs text-warning mt-1">
💡 Execute a mutation "criarTemplatesPadrao" para criar os templates do sistema.
</p>
</div>
{/if}
</div>
<!-- Role TI_MASTER -->
<div class="divider">Perfil TI_MASTER</div>
<div class="flex items-center gap-4">
{#if config.roleTiMasterExiste}
<CheckCircle class="h-6 w-6 text-success" />
<div class="flex-1">
<p class="font-semibold text-success">Perfil TI_MASTER encontrado</p>
<p class="text-sm text-base-content/70">
{config.usuariosTiMaster.length} usuário(s) com este perfil
</p>
</div>
{:else}
<XCircle class="h-6 w-6 text-error" />
<div class="flex-1">
<p class="font-semibold text-error">Perfil TI_MASTER não encontrado</p>
<p class="text-sm text-base-content/70">
A role "ti_master" não existe no banco de dados.
</p>
<p class="text-xs text-warning mt-1">
💡 Execute o seed do banco de dados para criar as roles padrão.
</p>
</div>
{/if}
</div>
<!-- Usuários TI_MASTER -->
{#if config.usuariosTiMaster.length > 0}
<div class="ml-10 mt-2 space-y-2">
{#each config.usuariosTiMaster as usuario}
<div class="flex items-center gap-2">
{#if usuario.temEmail}
<CheckCircle class="h-4 w-4 text-success" />
{:else}
<XCircle class="h-4 w-4 text-error" />
{/if}
<span class="text-sm">
{usuario.nome}
{#if usuario.email}
<span class="text-base-content/60">({usuario.email})</span>
{:else}
<span class="text-error"> - Sem email cadastrado</span>
{/if}
</span>
</div>
{/each}
</div>
{:else if config.roleTiMasterExiste}
<div class="ml-10 mt-2">
<div class="alert alert-warning">
<AlertCircle class="h-5 w-5" />
<span class="text-sm">Nenhum usuário com perfil TI_MASTER encontrado</span>
</div>
</div>
{/if}
<!-- Configuração SMTP -->
<div class="divider">Configuração SMTP</div>
<div class="flex items-center gap-4">
{#if config.configSmtpAtiva}
<CheckCircle class="h-6 w-6 text-success" />
<div class="flex-1">
<p class="font-semibold text-success">Configuração SMTP ativa</p>
{#if config.configSmtpInfo}
<div class="text-sm text-base-content/70 space-y-1">
<p>Servidor: {config.configSmtpInfo.servidor}:{config.configSmtpInfo.porta}</p>
<p>Remetente: {config.configSmtpInfo.emailRemetente}</p>
</div>
{/if}
</div>
{:else}
<XCircle class="h-6 w-6 text-error" />
<div class="flex-1">
<p class="font-semibold text-error">Configuração SMTP não encontrada ou inativa</p>
<p class="text-sm text-base-content/70">
Nenhuma configuração SMTP ativa foi encontrada no banco de dados.
</p>
<p class="text-xs text-warning mt-1">
💡 Configure o SMTP em: <a href="/ti/configuracoes-email" class="link">Configurações de Email</a>
</p>
</div>
{/if}
</div>
<!-- Estatísticas -->
<div class="divider">Estatísticas</div>
<div class="grid grid-cols-2 gap-4">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-figure text-primary">
<FileText class="h-8 w-8" />
</div>
<div class="stat-title">Alertas Ativos</div>
<div class="stat-value text-primary">{config.alertasAtivos}</div>
<div class="stat-desc">com notificação por email: {config.alertasComEmail}</div>
</div>
<div class="stat bg-base-200 rounded-lg">
<div class="stat-figure text-warning">
<Mail class="h-8 w-8" />
</div>
<div class="stat-title">Emails Pendentes</div>
<div class="stat-value text-warning">{config.emailsPendentes}</div>
<div class="stat-desc">em falha: {config.emailsFalha}</div>
</div>
</div>
<!-- Resumo -->
<div class="divider">Resumo</div>
<div class="alert {config.templateExiste && config.roleTiMasterExiste && config.usuariosTiMaster.some(u => u.temEmail) && config.configSmtpAtiva ? 'alert-success' : 'alert-warning'}">
{#if config.templateExiste && config.roleTiMasterExiste && config.usuariosTiMaster.some(u => u.temEmail) && config.configSmtpAtiva}
<CheckCircle class="h-6 w-6" />
<div>
<p class="font-semibold">✅ Sistema configurado corretamente</p>
<p class="text-sm">
Todos os componentes necessários estão configurados. Os alertas devem funcionar corretamente.
</p>
</div>
{:else}
<AlertCircle class="h-6 w-6" />
<div>
<p class="font-semibold">⚠️ Configuração incompleta</p>
<p class="text-sm">
Alguns componentes necessários não estão configurados. Verifique os itens acima.
</p>
</div>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -346,7 +346,10 @@
try {
type UsuariosList = FunctionReturnType<typeof api.chat.listarTodosUsuarios>;
const usuarios = (await client.query(api.chat.listarTodosUsuarios, {})) as UsuariosList;
return usuarios.filter((u) => u.statusPresenca === 'online').length;
if (!usuarios || !Array.isArray(usuarios)) {
return 0;
}
return usuarios.filter((u) => u?.statusPresenca === 'online').length;
} catch (e) {
console.error('Erro ao obter usuários:', e);
return 0;
@@ -553,31 +556,37 @@
// Coleta todas as métricas
async function collectAllMetrics() {
const [
cpuUsage,
memoryUsage,
networkLatency,
storageUsed,
usuariosOnline,
tempoRespostaMedio,
batteryInfo,
indexedDBSize,
deviceInfo
] = await Promise.all([
estimateCPU(),
Promise.resolve(getMemoryUsage()),
measureLatency(),
getStorageUsage(),
getUsuariosOnline(),
getResponseTime(),
getBatteryInfo(),
getIndexedDBSize(),
obterInformacoesDispositivo().catch(() => ({}) as Record<string, unknown>) // Capturar erro se falhar
]);
try {
const results = await Promise.allSettled([
estimateCPU(),
Promise.resolve(getMemoryUsage()),
measureLatency(),
getStorageUsage(),
getUsuariosOnline(),
getResponseTime(),
getBatteryInfo(),
getIndexedDBSize(),
obterInformacoesDispositivo().catch(() => ({}) as Record<string, unknown>)
]);
const browserInfo = getBrowserInfo();
const networkInfo = getNetworkInfo();
const wsInfo = getWebSocketStatus();
const cpuUsage = results[0].status === 'fulfilled' ? results[0].value : 0;
const memoryUsage = results[1].status === 'fulfilled' ? results[1].value : 0;
const networkLatency = results[2].status === 'fulfilled' ? results[2].value : 0;
const storageUsed = results[3].status === 'fulfilled' ? results[3].value : 0;
const usuariosOnline = results[4].status === 'fulfilled' ? results[4].value : 0;
const tempoRespostaMedio = results[5].status === 'fulfilled' ? results[5].value : 0;
const batteryInfoResult = results[6].status === 'fulfilled'
? results[6].value
: { level: 100, charging: false };
const batteryInfo = typeof batteryInfoResult === 'object' && batteryInfoResult !== null && 'level' in batteryInfoResult
? batteryInfoResult as { level: number; charging: boolean }
: { level: 100, charging: false };
const indexedDBSize = results[7].status === 'fulfilled' ? results[7].value : 0;
const deviceInfo = results[8].status === 'fulfilled' ? results[8].value : ({} as Record<string, unknown>);
const browserInfo = getBrowserInfo();
const networkInfo = getNetworkInfo();
const wsInfo = getWebSocketStatus();
const newMetrics: Metrics = {
timestamp: Date.now(),
@@ -634,11 +643,15 @@
// Adicionar ao histórico
metricsHistory = [...metricsHistory, newMetrics].slice(-100);
// Salvar no localStorage para persistência
try {
localStorage.setItem('sgse_metrics_history', JSON.stringify(metricsHistory));
} catch (e) {
console.warn('Não foi possível salvar histórico:', e);
// Salvar no localStorage para persistência
try {
localStorage.setItem('sgse_metrics_history', JSON.stringify(metricsHistory));
} catch (e) {
console.warn('Não foi possível salvar histórico:', e);
}
} catch (error) {
console.error('Erro ao coletar métricas:', error);
// Continuar com métricas anteriores em caso de erro
}
}
@@ -661,10 +674,16 @@
}
// Coletar imediatamente
collectAllMetrics();
collectAllMetrics().catch((error) => {
console.error('Erro na coleta inicial de métricas:', error);
});
// Configurar intervalo de 2 segundos
intervalId = setInterval(collectAllMetrics, 2000);
intervalId = setInterval(() => {
collectAllMetrics().catch((error) => {
console.error('Erro na coleta periódica de métricas:', error);
});
}, 2000);
});
// Parar coleta ao desmontar

View File

@@ -440,3 +440,6 @@ export function adicionarRodape(doc: jsPDF): void {

View File

@@ -79,3 +79,6 @@

View File

@@ -9,11 +9,28 @@
const client = useConvexClient();
const templateId = $derived($page.params.id as Id<'flowTemplates'>);
// Queries
const templateQuery = useQuery(api.flows.getTemplate, () => ({ id: templateId }));
const stepsQuery = useQuery(api.flows.listStepsByTemplate, () => ({
flowTemplateId: templateId
}));
// Função para validar se o ID é válido
function isValidConvexId(id: string | undefined): id is Id<'flowTemplates'> {
if (!id || id === '' || id.trim() === '') return false;
// Convex IDs têm formato específico (geralmente começam com letras e contêm apenas alfanuméricos)
// IDs válidos do Convex têm pelo menos alguns caracteres e não são strings vazias
return /^[a-z0-9]+$/i.test(id.trim()) && id.trim().length > 0;
}
// Queries - garantir que nunca seja chamado com ID vazio
const templateQuery = useQuery(api.flows.getTemplate, () => {
const id = templateId;
if (!id || !isValidConvexId(id)) {
return 'skip';
}
return { id: id as Id<'flowTemplates'> };
});
const stepsQuery = useQuery(api.flows.listStepsByTemplate, () => {
if (!isValidConvexId(templateId)) {
return 'skip';
}
return { flowTemplateId: templateId };
});
const setoresQuery = useQuery(api.setores.list, {});
// Query de sub-etapas (reativa baseada no step selecionado)

View File

@@ -124,6 +124,14 @@
detalhes: ''
});
// Modal de documento
let documentoModal = $state({
aberto: false,
url: null as string | null,
loading: false,
titulo: ''
});
// Licenças maternidade para prorrogação (derivar dos dados já carregados)
const licencasMaternidade = $derived.by(() => {
const dados = dadosQuery?.data;
@@ -194,6 +202,58 @@
};
}
// Função para abrir modal de documento
async function abrirModalDocumento(storageId: Id<'_storage'>, titulo: string) {
try {
documentoModal = {
aberto: true,
url: null,
loading: true,
titulo
};
const url = await client.query(api.atestadosLicencas.obterUrlDocumento, {
storageId
});
if (url) {
documentoModal = {
aberto: true,
url,
loading: false,
titulo
};
} else {
documentoModal.aberto = false;
mostrarErro(
'Erro ao visualizar documento',
'Não foi possível obter a URL do documento.',
'O documento pode ter sido removido ou não existe mais.'
);
}
} catch (err: any) {
console.error('Erro ao obter URL do documento:', err);
documentoModal.aberto = false;
mostrarErro(
'Erro ao visualizar documento',
'Não foi possível abrir o documento.',
err?.message || err?.toString() || 'Erro desconhecido'
);
}
}
// Função para baixar documento
function baixarDocumento(url: string, nomeArquivo: string) {
const link = document.createElement('a');
link.href = url;
link.download = nomeArquivo;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success('Download iniciado!');
}
// Dados para gráfico de área - Total de Dias por Tipo (Layerchart)
const chartDataTotalDiasPorTipo = $derived.by(() => {
if (!graficosQuery?.data?.totalDiasPorTipo) {
@@ -1548,32 +1608,11 @@
{#if atestado.documentoId}
<button
class="btn btn-xs btn-ghost"
onclick={async () => {
try {
const url = await client.query(
api.atestadosLicencas.obterUrlDocumento,
{
storageId: atestado.documentoId as any
}
);
if (url) {
window.open(url, '_blank');
} else {
mostrarErro(
'Erro ao visualizar documento',
'Não foi possível obter a URL do documento.',
'O documento pode ter sido removido ou não existe mais.'
);
}
} catch (err: any) {
console.error('Erro ao obter URL do documento:', err);
mostrarErro(
'Erro ao visualizar documento',
'Não foi possível abrir o documento.',
err?.message || err?.toString() || 'Erro desconhecido'
);
}
}}
onclick={() =>
abrirModalDocumento(
atestado.documentoId as Id<'_storage'>,
`Documento - ${atestado.funcionario?.nome || 'Atestado'}`
)}
>
Ver Doc
</button>
@@ -1630,32 +1669,11 @@
{#if licenca.documentoId}
<button
class="btn btn-xs btn-ghost"
onclick={async () => {
try {
const url = await client.query(
api.atestadosLicencas.obterUrlDocumento,
{
storageId: licenca.documentoId as any
}
);
if (url) {
window.open(url, '_blank');
} else {
mostrarErro(
'Erro ao visualizar documento',
'Não foi possível obter a URL do documento.',
'O documento pode ter sido removido ou não existe mais.'
);
}
} catch (err: any) {
console.error('Erro ao obter URL do documento:', err);
mostrarErro(
'Erro ao visualizar documento',
'Não foi possível abrir o documento.',
err?.message || err?.toString() || 'Erro desconhecido'
);
}
}}
onclick={() =>
abrirModalDocumento(
licenca.documentoId as Id<'_storage'>,
`Documento - ${licenca.funcionario?.nome || 'Licença'}`
)}
>
Ver Doc
</button>
@@ -2344,3 +2362,95 @@
erroModal.aberto = false;
}}
/>
<!-- Modal de Documento -->
{#if documentoModal.aberto}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={() => (documentoModal.aberto = false)}
role="dialog"
aria-modal="true"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-base-100 mx-4 w-full max-w-md transform rounded-2xl shadow-2xl transition-all"
onclick={(e) => e.stopPropagation()}
>
<!-- Header do Modal -->
<div class="border-base-300 from-primary/10 to-secondary/10 border-b bg-linear-to-r p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-base-content mb-2 text-xl font-bold">{documentoModal.titulo}</h3>
<p class="text-base-content/60 text-sm">Visualizar ou baixar documento</p>
</div>
<button
class="btn btn-sm btn-circle btn-ghost"
onclick={() => (documentoModal.aberto = false)}
aria-label="Fechar"
>
<X class="h-5 w-5" />
</button>
</div>
</div>
<!-- Conteúdo do Modal -->
<div class="space-y-4 p-6">
{#if documentoModal.loading}
<div class="flex flex-col items-center justify-center py-8">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-base-content/60 mt-4 text-sm">Carregando documento...</p>
</div>
{:else if documentoModal.url}
<div class="bg-base-200/50 flex flex-col items-center gap-4 rounded-lg p-6">
<FileText class="text-primary h-16 w-16" strokeWidth={1.5} />
<p class="text-base-content/70 text-center text-sm">
Documento carregado com sucesso. Escolha uma opção abaixo:
</p>
</div>
<div class="flex flex-col gap-3">
<button
class="btn btn-primary w-full gap-2"
onclick={() => {
if (documentoModal.url) {
window.open(documentoModal.url, '_blank');
}
}}
>
<Eye class="h-5 w-5" />
Visualizar em Nova Aba
</button>
<button
class="btn btn-success w-full gap-2"
onclick={() => {
if (documentoModal.url) {
const nomeArquivo = `${documentoModal.titulo.replace(/[^a-z0-9]/gi, '_')}.pdf`;
baixarDocumento(documentoModal.url, nomeArquivo);
}
}}
>
<Download class="h-5 w-5" />
Baixar Arquivo
</button>
</div>
{:else}
<div class="bg-error/10 flex flex-col items-center gap-4 rounded-lg p-6">
<AlertTriangle class="text-error h-12 w-12" />
<p class="text-error text-center font-medium">
Não foi possível carregar o documento.
</p>
</div>
{/if}
</div>
<!-- Footer do Modal -->
<div class="border-base-300 flex justify-end border-t p-6">
<button class="btn btn-ghost" onclick={() => (documentoModal.aberto = false)}>
Fechar
</button>
</div>
</div>
</div>
{/if}

View File

@@ -14,7 +14,6 @@
import ExcelJS from 'exceljs';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
import { toast } from 'svelte-sonner';
import UserAvatar from '$lib/components/chat/UserAvatar.svelte';
const client = useConvexClient();
const currentUser = useQuery(api.auth.getCurrentUser, {});
@@ -673,17 +672,8 @@
<tbody>
{#each ausenciasFiltradas as ausencia}
<tr>
<td>
<div class="flex items-center gap-3">
<UserAvatar
fotoPerfilUrl={ausencia.funcionario?.fotoPerfilUrl}
nome={ausencia.funcionario?.nome || 'N/A'}
size="sm"
/>
<span class="font-semibold">
{ausencia.funcionario?.nome || 'N/A'}
</span>
</div>
<td class="font-semibold">
{ausencia.funcionario?.nome || 'N/A'}
</td>
<td>
{#if ausencia.time}

View File

@@ -26,8 +26,8 @@
let copiado = $state(false);
// Filtros
let filtroStatusCode = $state<number | undefined>(undefined);
let filtroNotificado = $state<boolean | undefined>(undefined);
let filtroStatusCode = $state<string>('');
let filtroNotificado = $state<string>('');
let filtroDataInicio = $state<string>('');
let filtroDataFim = $state<string>('');
@@ -36,8 +36,8 @@
// Argumentos para as queries (usando $derived para reatividade)
const argsErros = $derived({
limite,
statusCode: filtroStatusCode,
notificado: filtroNotificado,
statusCode: filtroStatusCode ? Number(filtroStatusCode) : undefined,
notificado: filtroNotificado === '' ? undefined : filtroNotificado === 'true',
dataInicio: filtroDataInicio ? new Date(filtroDataInicio).getTime() : undefined,
dataFim: filtroDataFim ? new Date(filtroDataFim + 'T23:59:59').getTime() : undefined
});
@@ -132,8 +132,8 @@
}
function limparFiltros() {
filtroStatusCode = undefined;
filtroNotificado = undefined;
filtroStatusCode = '';
filtroNotificado = '';
filtroDataInicio = '';
filtroDataFim = '';
}
@@ -248,33 +248,20 @@
<label class="label">
<span class="label-text">Código HTTP</span>
</label>
<select
class="select select-bordered"
bind:value={filtroStatusCode}
onchange={(e) => {
filtroStatusCode = e.target.value ? Number(e.target.value) : undefined;
}}
>
<option value={undefined}>Todos</option>
<option value={404}>404 - Não Encontrado</option>
<option value={500}>500 - Erro Interno</option>
<select class="select select-bordered" bind:value={filtroStatusCode}>
<option value="">Todos</option>
<option value="404">404 - Não Encontrado</option>
<option value="500">500 - Erro Interno</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Status Notificação</span>
</label>
<select
class="select select-bordered"
bind:value={filtroNotificado}
onchange={(e) => {
filtroNotificado =
e.target.value === '' ? undefined : e.target.value === 'true';
}}
>
<option value={undefined}>Todos</option>
<option value={true}>Notificados</option>
<option value={false}>Não Notificados</option>
<select class="select select-bordered" bind:value={filtroNotificado}>
<option value="">Todos</option>
<option value="true">Notificados</option>
<option value="false">Não Notificados</option>
</select>
</div>
<div class="form-control">

View File

@@ -1,7 +1,25 @@
<script lang="ts">
import { resolve } from '$app/paths';
import SystemMonitorCardLocal from '$lib/components/ti/SystemMonitorCardLocal.svelte';
import AlertDiagnosticsCard from '$lib/components/ti/AlertDiagnosticsCard.svelte';
import { Monitor, ArrowLeft } from 'lucide-svelte';
import { onMount } from 'svelte';
let error = $state<Error | null>(null);
let hasError = $derived(!!error);
onMount(() => {
// Capturar erros não tratados
window.addEventListener('error', (event) => {
console.error('Erro capturado na página de monitoramento:', event.error);
error = event.error;
});
window.addEventListener('unhandledrejection', (event) => {
console.error('Promise rejeitada na página de monitoramento:', event.reason);
error = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
});
});
</script>
<div class="container mx-auto max-w-7xl px-4 py-6">
@@ -26,6 +44,38 @@
</a>
</div>
{#if hasError}
<div class="alert alert-error mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h3 class="font-bold">Erro ao carregar página</h3>
<div class="text-sm">{error?.message || 'Erro desconhecido'}</div>
</div>
<button class="btn btn-sm" onclick={() => (error = null)}>Fechar</button>
</div>
{/if}
<!-- Diagnóstico de Configuração -->
<div class="mb-6">
{#if !hasError}
<AlertDiagnosticsCard />
{/if}
</div>
<!-- Card de Monitoramento -->
<SystemMonitorCardLocal />
{#if !hasError}
<SystemMonitorCardLocal />
{/if}
</div>

View File

@@ -79,3 +79,6 @@