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:
125
apps/web/src/lib/components/AlertModal.svelte
Normal file
125
apps/web/src/lib/components/AlertModal.svelte
Normal 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>
|
||||||
|
|
||||||
@@ -76,7 +76,8 @@
|
|||||||
dataInicio: periodo.dataInicio,
|
dataInicio: periodo.dataInicio,
|
||||||
dataFim: periodo.dataFim
|
dataFim: periodo.dataFim
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
feriasIdExcluir: periodo._id // Excluir este período do cálculo de saldo pendente
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validacao.valido) {
|
if (!validacao.valido) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||||
import type { EventInput } from '@fullcalendar/core/index.js';
|
import type { EventInput } from '@fullcalendar/core/index.js';
|
||||||
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
eventos: Array<{
|
eventos: Array<{
|
||||||
@@ -40,20 +41,31 @@
|
|||||||
return eventos.filter((e) => e.tipo === filtroAtivo);
|
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
|
// Converter eventos para formato FullCalendar
|
||||||
const eventosFullCalendar = $derived.by(() => {
|
const eventosFullCalendar = $derived.by(() => {
|
||||||
return eventosFiltrados.map((evento) => ({
|
return eventosFiltrados.map((evento) => ({
|
||||||
id: evento.id,
|
id: evento.id,
|
||||||
title: evento.title,
|
title: evento.title,
|
||||||
start: evento.start,
|
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,
|
backgroundColor: evento.color,
|
||||||
borderColor: evento.color,
|
borderColor: evento.color,
|
||||||
textColor: '#ffffff',
|
textColor: '#ffffff',
|
||||||
extendedProps: {
|
extendedProps: {
|
||||||
tipo: evento.tipo,
|
tipo: evento.tipo,
|
||||||
funcionarioNome: evento.funcionarioNome,
|
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[];
|
})) as EventInput[];
|
||||||
});
|
});
|
||||||
@@ -79,12 +91,14 @@
|
|||||||
},
|
},
|
||||||
events: eventosFullCalendar,
|
events: eventosFullCalendar,
|
||||||
eventClick: (info) => {
|
eventClick: (info) => {
|
||||||
|
// Usar datas originais armazenadas nos extendedProps para exibição correta
|
||||||
|
const props = info.event.extendedProps;
|
||||||
eventoSelecionado = {
|
eventoSelecionado = {
|
||||||
title: info.event.title,
|
title: info.event.title,
|
||||||
start: info.event.startStr || '',
|
start: (props.dataInicioOriginal as string) || info.event.startStr || '',
|
||||||
end: info.event.endStr || '',
|
end: (props.dataFimOriginal as string) || info.event.endStr || '',
|
||||||
tipo: info.event.extendedProps.tipo as string,
|
tipo: props.tipo as string,
|
||||||
funcionarioNome: info.event.extendedProps.funcionarioNome as string
|
funcionarioNome: props.funcionarioNome as string
|
||||||
};
|
};
|
||||||
showModal = true;
|
showModal = true;
|
||||||
},
|
},
|
||||||
@@ -359,8 +373,10 @@
|
|||||||
<p class="text-base-content/60 text-sm">Duração</p>
|
<p class="text-base-content/60 text-sm">Duração</p>
|
||||||
<p class="font-semibold">
|
<p class="font-semibold">
|
||||||
{(() => {
|
{(() => {
|
||||||
const inicio = new Date(eventoSelecionado.start);
|
// Usar SvelteDate para evitar problemas de mutabilidade e timezone
|
||||||
const fim = new Date(eventoSelecionado.end);
|
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 diffTime = Math.abs(fim.getTime() - inicio.getTime());
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||||
return `${diffDays} ${diffDays === 1 ? 'dia' : 'dias'}`;
|
return `${diffDays} ${diffDays === 1 ? 'dia' : 'dias'}`;
|
||||||
|
|||||||
135
apps/web/src/lib/components/ConfirmModal.svelte
Normal file
135
apps/web/src/lib/components/ConfirmModal.svelte
Normal 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>
|
||||||
|
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
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();
|
let { onClose }: { onClose: () => void } = $props();
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const alertasQuery = useQuery(api.monitoramento.listarAlertas, {});
|
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(() => {
|
const alertas = $derived.by(() => {
|
||||||
if (!alertasQuery) return [];
|
if (alertasQuery === undefined) return [];
|
||||||
// O useQuery pode retornar o array diretamente ou em .data
|
// Verificar se é um objeto com propriedade data
|
||||||
if (Array.isArray(alertasQuery)) return alertasQuery;
|
if (alertasQuery && typeof alertasQuery === 'object' && 'data' in alertasQuery) {
|
||||||
return alertasQuery.data ?? [];
|
return Array.isArray(alertasQuery.data) ? alertasQuery.data : [];
|
||||||
|
}
|
||||||
|
// Fallback: se for diretamente um array
|
||||||
|
return Array.isArray(alertasQuery) ? alertasQuery : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Estado para novo alerta
|
// Estado para novo alerta
|
||||||
@@ -25,6 +32,13 @@
|
|||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let showForm = $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 = [
|
const metricOptions = [
|
||||||
{ value: 'cpuUsage', label: 'Uso de CPU (%)' },
|
{ value: 'cpuUsage', label: 'Uso de CPU (%)' },
|
||||||
{ value: 'memoryUsage', label: 'Uso de Memória (%)' },
|
{ value: 'memoryUsage', label: 'Uso de Memória (%)' },
|
||||||
@@ -55,7 +69,7 @@
|
|||||||
showForm = false;
|
showForm = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function editAlert(alert: any) {
|
function editAlert(alert: Doc<'alertConfigurations'>) {
|
||||||
editingAlertId = alert._id;
|
editingAlertId = alert._id;
|
||||||
metricName = alert.metricName;
|
metricName = alert.metricName;
|
||||||
threshold = alert.threshold;
|
threshold = alert.threshold;
|
||||||
@@ -67,9 +81,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveAlert() {
|
async function saveAlert() {
|
||||||
|
if (!notifyByChat && !notifyByEmail) {
|
||||||
|
alertMessage = 'Selecione pelo menos um método de notificação (Chat ou Email)';
|
||||||
|
showAlertModal = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
await client.mutation(api.monitoramento.configurarAlerta, {
|
const result = await client.mutation(api.monitoramento.configurarAlerta, {
|
||||||
alertId: editingAlertId || undefined,
|
alertId: editingAlertId || undefined,
|
||||||
metricName,
|
metricName,
|
||||||
threshold,
|
threshold,
|
||||||
@@ -79,23 +99,33 @@
|
|||||||
notifyByChat
|
notifyByChat
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Aguardar um pouco para garantir que a query seja atualizada pelo Convex Svelte
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
resetForm();
|
resetForm();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao salvar alerta:', 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 {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAlert(alertId: Id<'alertConfigurations'>) {
|
function handleDeleteClick(alertId: Id<'alertConfigurations'>) {
|
||||||
if (!confirm('Tem certeza que deseja deletar este alerta?')) return;
|
confirmMessage = 'Tem certeza que deseja deletar este alerta?';
|
||||||
|
confirmCallback = () => deleteAlert(alertId);
|
||||||
|
showConfirmModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAlert(alertId: Id<'alertConfigurations'>) {
|
||||||
try {
|
try {
|
||||||
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
|
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao deletar alerta:', 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>
|
</script>
|
||||||
|
|
||||||
<dialog class="modal modal-open">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
|
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
|
||||||
@@ -163,7 +193,7 @@
|
|||||||
class="select select-bordered select-primary"
|
class="select select-bordered select-primary"
|
||||||
bind:value={metricName}
|
bind:value={metricName}
|
||||||
>
|
>
|
||||||
{#each metricOptions as option}
|
{#each metricOptions as option (option.value)}
|
||||||
<option value={option.value}>{option.label}</option>
|
<option value={option.value}>{option.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -179,7 +209,7 @@
|
|||||||
class="select select-bordered select-primary"
|
class="select select-bordered select-primary"
|
||||||
bind:value={operator}
|
bind:value={operator}
|
||||||
>
|
>
|
||||||
{#each operatorOptions as option}
|
{#each operatorOptions as option (option.value)}
|
||||||
<option value={option.value}>{option.label}</option>
|
<option value={option.value}>{option.label}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
@@ -211,6 +241,31 @@
|
|||||||
|
|
||||||
<!-- Notificações -->
|
<!-- Notificações -->
|
||||||
<div class="divider">Método de Notificação</div>
|
<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">
|
<div class="flex gap-6">
|
||||||
<label class="label cursor-pointer gap-3">
|
<label class="label cursor-pointer gap-3">
|
||||||
<input
|
<input
|
||||||
@@ -328,7 +383,11 @@
|
|||||||
<!-- Lista de Alertas -->
|
<!-- Lista de Alertas -->
|
||||||
<div class="divider">Alertas Configurados</div>
|
<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">
|
<div class="overflow-x-auto">
|
||||||
<table class="table-zebra table">
|
<table class="table-zebra table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -341,7 +400,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each alertas as alerta}
|
{#each alertas as alerta (alerta._id)}
|
||||||
<tr class={!alerta.enabled ? 'opacity-50' : ''}>
|
<tr class={!alerta.enabled ? 'opacity-50' : ''}>
|
||||||
<td>
|
<td>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
@@ -405,7 +464,12 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex gap-2">
|
<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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -424,7 +488,8 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-xs text-error"
|
class="btn btn-xs text-error"
|
||||||
onclick={() => deleteAlert(alerta._id)}
|
onclick={() => handleDeleteClick(alerta._id)}
|
||||||
|
aria-label="Deletar alerta"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -477,3 +542,30 @@
|
|||||||
<button type="button">close</button>
|
<button type="button">close</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|||||||
204
apps/web/src/lib/components/ti/AlertDiagnosticsCard.svelte
Normal file
204
apps/web/src/lib/components/ti/AlertDiagnosticsCard.svelte
Normal 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>
|
||||||
|
|
||||||
@@ -346,7 +346,10 @@
|
|||||||
try {
|
try {
|
||||||
type UsuariosList = FunctionReturnType<typeof api.chat.listarTodosUsuarios>;
|
type UsuariosList = FunctionReturnType<typeof api.chat.listarTodosUsuarios>;
|
||||||
const usuarios = (await client.query(api.chat.listarTodosUsuarios, {})) as UsuariosList;
|
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) {
|
} catch (e) {
|
||||||
console.error('Erro ao obter usuários:', e);
|
console.error('Erro ao obter usuários:', e);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -553,17 +556,8 @@
|
|||||||
|
|
||||||
// Coleta todas as métricas
|
// Coleta todas as métricas
|
||||||
async function collectAllMetrics() {
|
async function collectAllMetrics() {
|
||||||
const [
|
try {
|
||||||
cpuUsage,
|
const results = await Promise.allSettled([
|
||||||
memoryUsage,
|
|
||||||
networkLatency,
|
|
||||||
storageUsed,
|
|
||||||
usuariosOnline,
|
|
||||||
tempoRespostaMedio,
|
|
||||||
batteryInfo,
|
|
||||||
indexedDBSize,
|
|
||||||
deviceInfo
|
|
||||||
] = await Promise.all([
|
|
||||||
estimateCPU(),
|
estimateCPU(),
|
||||||
Promise.resolve(getMemoryUsage()),
|
Promise.resolve(getMemoryUsage()),
|
||||||
measureLatency(),
|
measureLatency(),
|
||||||
@@ -572,9 +566,24 @@
|
|||||||
getResponseTime(),
|
getResponseTime(),
|
||||||
getBatteryInfo(),
|
getBatteryInfo(),
|
||||||
getIndexedDBSize(),
|
getIndexedDBSize(),
|
||||||
obterInformacoesDispositivo().catch(() => ({}) as Record<string, unknown>) // Capturar erro se falhar
|
obterInformacoesDispositivo().catch(() => ({}) as Record<string, unknown>)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
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 browserInfo = getBrowserInfo();
|
||||||
const networkInfo = getNetworkInfo();
|
const networkInfo = getNetworkInfo();
|
||||||
const wsInfo = getWebSocketStatus();
|
const wsInfo = getWebSocketStatus();
|
||||||
@@ -640,6 +649,10 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Não foi possível salvar histórico:', 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iniciar coleta ao montar
|
// Iniciar coleta ao montar
|
||||||
@@ -661,10 +674,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Coletar imediatamente
|
// Coletar imediatamente
|
||||||
collectAllMetrics();
|
collectAllMetrics().catch((error) => {
|
||||||
|
console.error('Erro na coleta inicial de métricas:', error);
|
||||||
|
});
|
||||||
|
|
||||||
// Configurar intervalo de 2 segundos
|
// 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
|
// Parar coleta ao desmontar
|
||||||
|
|||||||
@@ -440,3 +440,6 @@ export function adicionarRodape(doc: jsPDF): void {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,3 +79,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,28 @@
|
|||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const templateId = $derived($page.params.id as Id<'flowTemplates'>);
|
const templateId = $derived($page.params.id as Id<'flowTemplates'>);
|
||||||
|
|
||||||
// Queries
|
// Função para validar se o ID é válido
|
||||||
const templateQuery = useQuery(api.flows.getTemplate, () => ({ id: templateId }));
|
function isValidConvexId(id: string | undefined): id is Id<'flowTemplates'> {
|
||||||
const stepsQuery = useQuery(api.flows.listStepsByTemplate, () => ({
|
if (!id || id === '' || id.trim() === '') return false;
|
||||||
flowTemplateId: templateId
|
// 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, {});
|
const setoresQuery = useQuery(api.setores.list, {});
|
||||||
|
|
||||||
// Query de sub-etapas (reativa baseada no step selecionado)
|
// Query de sub-etapas (reativa baseada no step selecionado)
|
||||||
|
|||||||
@@ -124,6 +124,14 @@
|
|||||||
detalhes: ''
|
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)
|
// Licenças maternidade para prorrogação (derivar dos dados já carregados)
|
||||||
const licencasMaternidade = $derived.by(() => {
|
const licencasMaternidade = $derived.by(() => {
|
||||||
const dados = dadosQuery?.data;
|
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)
|
// Dados para gráfico de área - Total de Dias por Tipo (Layerchart)
|
||||||
const chartDataTotalDiasPorTipo = $derived.by(() => {
|
const chartDataTotalDiasPorTipo = $derived.by(() => {
|
||||||
if (!graficosQuery?.data?.totalDiasPorTipo) {
|
if (!graficosQuery?.data?.totalDiasPorTipo) {
|
||||||
@@ -1548,32 +1608,11 @@
|
|||||||
{#if atestado.documentoId}
|
{#if atestado.documentoId}
|
||||||
<button
|
<button
|
||||||
class="btn btn-xs btn-ghost"
|
class="btn btn-xs btn-ghost"
|
||||||
onclick={async () => {
|
onclick={() =>
|
||||||
try {
|
abrirModalDocumento(
|
||||||
const url = await client.query(
|
atestado.documentoId as Id<'_storage'>,
|
||||||
api.atestadosLicencas.obterUrlDocumento,
|
`Documento - ${atestado.funcionario?.nome || 'Atestado'}`
|
||||||
{
|
)}
|
||||||
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'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Ver Doc
|
Ver Doc
|
||||||
</button>
|
</button>
|
||||||
@@ -1630,32 +1669,11 @@
|
|||||||
{#if licenca.documentoId}
|
{#if licenca.documentoId}
|
||||||
<button
|
<button
|
||||||
class="btn btn-xs btn-ghost"
|
class="btn btn-xs btn-ghost"
|
||||||
onclick={async () => {
|
onclick={() =>
|
||||||
try {
|
abrirModalDocumento(
|
||||||
const url = await client.query(
|
licenca.documentoId as Id<'_storage'>,
|
||||||
api.atestadosLicencas.obterUrlDocumento,
|
`Documento - ${licenca.funcionario?.nome || 'Licença'}`
|
||||||
{
|
)}
|
||||||
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'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Ver Doc
|
Ver Doc
|
||||||
</button>
|
</button>
|
||||||
@@ -2344,3 +2362,95 @@
|
|||||||
erroModal.aberto = false;
|
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}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import UserAvatar from '$lib/components/chat/UserAvatar.svelte';
|
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||||
@@ -673,17 +672,8 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each ausenciasFiltradas as ausencia}
|
{#each ausenciasFiltradas as ausencia}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td class="font-semibold">
|
||||||
<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'}
|
{ausencia.funcionario?.nome || 'N/A'}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{#if ausencia.time}
|
{#if ausencia.time}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
let copiado = $state(false);
|
let copiado = $state(false);
|
||||||
|
|
||||||
// Filtros
|
// Filtros
|
||||||
let filtroStatusCode = $state<number | undefined>(undefined);
|
let filtroStatusCode = $state<string>('');
|
||||||
let filtroNotificado = $state<boolean | undefined>(undefined);
|
let filtroNotificado = $state<string>('');
|
||||||
let filtroDataInicio = $state<string>('');
|
let filtroDataInicio = $state<string>('');
|
||||||
let filtroDataFim = $state<string>('');
|
let filtroDataFim = $state<string>('');
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
// Argumentos para as queries (usando $derived para reatividade)
|
// Argumentos para as queries (usando $derived para reatividade)
|
||||||
const argsErros = $derived({
|
const argsErros = $derived({
|
||||||
limite,
|
limite,
|
||||||
statusCode: filtroStatusCode,
|
statusCode: filtroStatusCode ? Number(filtroStatusCode) : undefined,
|
||||||
notificado: filtroNotificado,
|
notificado: filtroNotificado === '' ? undefined : filtroNotificado === 'true',
|
||||||
dataInicio: filtroDataInicio ? new Date(filtroDataInicio).getTime() : undefined,
|
dataInicio: filtroDataInicio ? new Date(filtroDataInicio).getTime() : undefined,
|
||||||
dataFim: filtroDataFim ? new Date(filtroDataFim + 'T23:59:59').getTime() : undefined
|
dataFim: filtroDataFim ? new Date(filtroDataFim + 'T23:59:59').getTime() : undefined
|
||||||
});
|
});
|
||||||
@@ -132,8 +132,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function limparFiltros() {
|
function limparFiltros() {
|
||||||
filtroStatusCode = undefined;
|
filtroStatusCode = '';
|
||||||
filtroNotificado = undefined;
|
filtroNotificado = '';
|
||||||
filtroDataInicio = '';
|
filtroDataInicio = '';
|
||||||
filtroDataFim = '';
|
filtroDataFim = '';
|
||||||
}
|
}
|
||||||
@@ -248,33 +248,20 @@
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Código HTTP</span>
|
<span class="label-text">Código HTTP</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select class="select select-bordered" bind:value={filtroStatusCode}>
|
||||||
class="select select-bordered"
|
<option value="">Todos</option>
|
||||||
bind:value={filtroStatusCode}
|
<option value="404">404 - Não Encontrado</option>
|
||||||
onchange={(e) => {
|
<option value="500">500 - Erro Interno</option>
|
||||||
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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text">Status Notificação</span>
|
<span class="label-text">Status Notificação</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select class="select select-bordered" bind:value={filtroNotificado}>
|
||||||
class="select select-bordered"
|
<option value="">Todos</option>
|
||||||
bind:value={filtroNotificado}
|
<option value="true">Notificados</option>
|
||||||
onchange={(e) => {
|
<option value="false">Não Notificados</option>
|
||||||
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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import SystemMonitorCardLocal from '$lib/components/ti/SystemMonitorCardLocal.svelte';
|
import SystemMonitorCardLocal from '$lib/components/ti/SystemMonitorCardLocal.svelte';
|
||||||
|
import AlertDiagnosticsCard from '$lib/components/ti/AlertDiagnosticsCard.svelte';
|
||||||
import { Monitor, ArrowLeft } from 'lucide-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>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto max-w-7xl px-4 py-6">
|
<div class="container mx-auto max-w-7xl px-4 py-6">
|
||||||
@@ -26,6 +44,38 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card de Monitoramento -->
|
{#if hasError}
|
||||||
<SystemMonitorCardLocal />
|
<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 -->
|
||||||
|
{#if !hasError}
|
||||||
|
<SystemMonitorCardLocal />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,3 +79,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { type DataModel } from './_generated/dataModel';
|
|||||||
import { MutationCtx, query, QueryCtx } from './_generated/server';
|
import { MutationCtx, query, QueryCtx } from './_generated/server';
|
||||||
import { betterAuth } from 'better-auth';
|
import { betterAuth } from 'better-auth';
|
||||||
|
|
||||||
const siteUrl = process.env.SITE_URL!;
|
// Usar SITE_URL se disponível, caso contrário usar CONVEX_SITE_URL ou um valor padrão
|
||||||
|
const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
// The component client has methods needed for integrating Convex with Better Auth,
|
// The component client has methods needed for integrating Convex with Better Auth,
|
||||||
// as well as helper methods for general use.
|
// as well as helper methods for general use.
|
||||||
|
|||||||
@@ -859,7 +859,11 @@ export const marcarNotificacaoLida = mutation({
|
|||||||
if (!usuarioAtual) throw new Error('Não autenticado');
|
if (!usuarioAtual) throw new Error('Não autenticado');
|
||||||
|
|
||||||
const notificacao = await ctx.db.get(args.notificacaoId);
|
const notificacao = await ctx.db.get(args.notificacaoId);
|
||||||
if (!notificacao) throw new Error('Notificação não encontrada');
|
// Se a notificação não existe (já foi deletada), retornar sucesso silenciosamente
|
||||||
|
// Isso evita erros quando múltiplas tentativas são feitas ou quando a notificação já foi removida
|
||||||
|
if (!notificacao) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// SEGURANÇA: Verificar se a notificação pertence ao usuário atual
|
// SEGURANÇA: Verificar se a notificação pertence ao usuário atual
|
||||||
if (notificacao.usuarioId !== usuarioAtual._id) {
|
if (notificacao.usuarioId !== usuarioAtual._id) {
|
||||||
@@ -874,6 +878,11 @@ export const marcarNotificacaoLida = mutation({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Se já está marcada como lida, retornar sucesso sem fazer nada
|
||||||
|
if (notificacao.lida) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.db.patch(args.notificacaoId, { lida: true });
|
await ctx.db.patch(args.notificacaoId, { lida: true });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { internal } from './_generated/api';
|
|||||||
import { Id, Doc } from './_generated/dataModel';
|
import { Id, Doc } from './_generated/dataModel';
|
||||||
import { verificarLicencaAtiva } from './atestadosLicencas';
|
import { verificarLicencaAtiva } from './atestadosLicencas';
|
||||||
import { getCurrentUserFunction } from './auth';
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
import { formatarDataBR } from './utils/datas';
|
||||||
|
import { api } from './_generated/api';
|
||||||
|
|
||||||
// Validador para períodos
|
// Validador para períodos
|
||||||
const periodoValidator = v.object({
|
const periodoValidator = v.object({
|
||||||
@@ -433,13 +435,58 @@ export const aprovar = mutation({
|
|||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (usuario) {
|
if (usuario) {
|
||||||
|
// Criar notificação in-app para funcionário
|
||||||
await ctx.db.insert('notificacoesFerias', {
|
await ctx.db.insert('notificacoesFerias', {
|
||||||
destinatarioId: usuario._id,
|
destinatarioId: usuario._id,
|
||||||
feriasId: registro._id,
|
feriasId: registro._id,
|
||||||
tipo: 'aprovado',
|
tipo: 'aprovado',
|
||||||
lida: false,
|
lida: false,
|
||||||
mensagem: `Período de férias de ${registro.diasFerias} dias foi aprovado!`
|
mensagem: `Período de férias de ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)} (${registro.diasFerias} dias) foi aprovado por ${nomeGestor}!`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enviar email ao funcionário usando template (agendado via scheduler)
|
||||||
|
if (gestorUsuario) {
|
||||||
|
// Obter URL do sistema
|
||||||
|
let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
||||||
|
urlSistema = `http://${urlSistema}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
||||||
|
destinatario: usuario.email,
|
||||||
|
destinatarioId: usuario._id,
|
||||||
|
templateCodigo: 'ferias_aprovada',
|
||||||
|
variaveis: {
|
||||||
|
funcionarioNome: usuario.nome,
|
||||||
|
gestorNome: gestorUsuario.nome,
|
||||||
|
dataInicio: formatarDataBR(registro.dataInicio),
|
||||||
|
dataFim: formatarDataBR(registro.dataFim),
|
||||||
|
diasFerias: registro.diasFerias.toString(),
|
||||||
|
urlSistema
|
||||||
|
},
|
||||||
|
enviadoPor: args.gestorId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback para envio direto se houver erro ao agendar ou processar o template
|
||||||
|
console.warn(
|
||||||
|
'Erro ao agendar envio de email com template ferias_aprovada, usando envio direto:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
||||||
|
destinatario: usuario.email,
|
||||||
|
destinatarioId: usuario._id,
|
||||||
|
assunto: 'Solicitação de Férias Aprovada',
|
||||||
|
corpo: `<p>Olá ${usuario.nome},</p>
|
||||||
|
<p>Sua solicitação de férias foi <strong>aprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Período:</strong> ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)}</li>
|
||||||
|
<li><strong>Dias:</strong> ${registro.diasFerias} dias</li>
|
||||||
|
</ul>`,
|
||||||
|
enviadoPor: args.gestorId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +600,10 @@ export const ajustarEAprovar = mutation({
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Buscar nome do gestor
|
||||||
|
const gestorUsuario = await ctx.db.get(args.gestorId);
|
||||||
|
const nomeGestor = gestorUsuario?.nome || 'Gestor';
|
||||||
|
|
||||||
// Notificar funcionário
|
// Notificar funcionário
|
||||||
if (funcionario) {
|
if (funcionario) {
|
||||||
const usuario = await ctx.db
|
const usuario = await ctx.db
|
||||||
@@ -561,13 +612,58 @@ export const ajustarEAprovar = mutation({
|
|||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (usuario) {
|
if (usuario) {
|
||||||
|
// Criar notificação in-app para funcionário
|
||||||
await ctx.db.insert('notificacoesFerias', {
|
await ctx.db.insert('notificacoesFerias', {
|
||||||
destinatarioId: usuario._id,
|
destinatarioId: usuario._id,
|
||||||
feriasId: registroAntigo._id,
|
feriasId: registroAntigo._id,
|
||||||
tipo: 'data_ajustada',
|
tipo: 'data_ajustada',
|
||||||
lida: false,
|
lida: false,
|
||||||
mensagem: `Período de férias foi aprovado com ajuste de datas: ${args.novaDataInicio} a ${args.novaDataFim}`
|
mensagem: `Período de férias foi aprovado com ajuste de datas: ${formatarDataBR(args.novaDataInicio)} a ${formatarDataBR(args.novaDataFim)} (${novosDias} dias) por ${nomeGestor}`
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enviar email ao funcionário usando template (agendado via scheduler)
|
||||||
|
if (gestorUsuario) {
|
||||||
|
// Obter URL do sistema
|
||||||
|
let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
||||||
|
urlSistema = `http://${urlSistema}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
||||||
|
destinatario: usuario.email,
|
||||||
|
destinatarioId: usuario._id,
|
||||||
|
templateCodigo: 'ferias_aprovada',
|
||||||
|
variaveis: {
|
||||||
|
funcionarioNome: usuario.nome,
|
||||||
|
gestorNome: gestorUsuario.nome,
|
||||||
|
dataInicio: formatarDataBR(args.novaDataInicio),
|
||||||
|
dataFim: formatarDataBR(args.novaDataFim),
|
||||||
|
diasFerias: novosDias.toString(),
|
||||||
|
urlSistema
|
||||||
|
},
|
||||||
|
enviadoPor: args.gestorId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback para envio direto se houver erro ao agendar ou processar o template
|
||||||
|
console.warn(
|
||||||
|
'Erro ao agendar envio de email com template ferias_aprovada, usando envio direto:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
||||||
|
destinatario: usuario.email,
|
||||||
|
destinatarioId: usuario._id,
|
||||||
|
assunto: 'Solicitação de Férias Aprovada (com Ajuste de Datas)',
|
||||||
|
corpo: `<p>Olá ${usuario.nome},</p>
|
||||||
|
<p>Sua solicitação de férias foi <strong>aprovada com ajuste de datas</strong> pelo gestor ${gestorUsuario.nome}:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Período:</strong> ${formatarDataBR(args.novaDataInicio)} até ${formatarDataBR(args.novaDataFim)}</li>
|
||||||
|
<li><strong>Dias:</strong> ${novosDias} dias</li>
|
||||||
|
</ul>`,
|
||||||
|
enviadoPor: args.gestorId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,6 +814,111 @@ export const atualizarStatus = mutation({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Se o status foi alterado para Cancelado_RH, notificar o funcionário
|
||||||
|
if (args.novoStatus === 'Cancelado_RH') {
|
||||||
|
const funcionario = await ctx.db.get(registro.funcionarioId);
|
||||||
|
|
||||||
|
if (funcionario) {
|
||||||
|
const funcionarioUsuario = await ctx.db
|
||||||
|
.query('usuarios')
|
||||||
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (funcionarioUsuario) {
|
||||||
|
// Buscar usuário do RH que está cancelando
|
||||||
|
const usuarioRH = await ctx.db.get(args.usuarioId);
|
||||||
|
const nomeRH = usuarioRH?.nome || 'Recursos Humanos';
|
||||||
|
|
||||||
|
// Criar notificação in-app para funcionário
|
||||||
|
await ctx.db.insert('notificacoesFerias', {
|
||||||
|
destinatarioId: funcionarioUsuario._id,
|
||||||
|
feriasId: registro._id,
|
||||||
|
tipo: 'cancelado',
|
||||||
|
lida: false,
|
||||||
|
mensagem: `Sua solicitação de férias de ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)} (${registro.diasFerias} dias) foi cancelada pelo setor de Recursos Humanos.`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obter URL do sistema
|
||||||
|
let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
||||||
|
urlSistema = `http://${urlSistema}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enviar email ao funcionário usando template (agendado via scheduler)
|
||||||
|
try {
|
||||||
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
||||||
|
destinatario: funcionarioUsuario.email,
|
||||||
|
destinatarioId: funcionarioUsuario._id,
|
||||||
|
templateCodigo: 'ferias_cancelada_rh',
|
||||||
|
variaveis: {
|
||||||
|
funcionarioNome: funcionarioUsuario.nome,
|
||||||
|
dataInicio: formatarDataBR(registro.dataInicio),
|
||||||
|
dataFim: formatarDataBR(registro.dataFim),
|
||||||
|
diasFerias: registro.diasFerias.toString(),
|
||||||
|
urlSistema
|
||||||
|
},
|
||||||
|
enviadoPor: args.usuarioId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback para envio direto se houver erro ao agendar ou processar o template
|
||||||
|
console.warn(
|
||||||
|
'Erro ao agendar envio de email com template ferias_cancelada_rh, usando envio direto:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
||||||
|
destinatario: funcionarioUsuario.email,
|
||||||
|
destinatarioId: funcionarioUsuario._id,
|
||||||
|
assunto: 'Solicitação de Férias Cancelada',
|
||||||
|
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
|
||||||
|
<p>Sua solicitação de férias foi <strong>cancelada</strong> pelo setor de Recursos Humanos:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Período:</strong> ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)}</li>
|
||||||
|
<li><strong>Dias:</strong> ${registro.diasFerias} dias</li>
|
||||||
|
</ul>
|
||||||
|
<p>Para mais informações, entre em contato com o setor de Recursos Humanos.</p>`,
|
||||||
|
enviadoPor: args.usuarioId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar ou obter conversa entre RH e funcionário
|
||||||
|
const conversasExistentes = await ctx.db
|
||||||
|
.query('conversas')
|
||||||
|
.filter((q) => q.eq(q.field('tipo'), 'individual'))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let conversaId: Id<'conversas'> | null = null;
|
||||||
|
for (const conversa of conversasExistentes) {
|
||||||
|
if (
|
||||||
|
conversa.participantes.length === 2 &&
|
||||||
|
conversa.participantes.includes(args.usuarioId) &&
|
||||||
|
conversa.participantes.includes(funcionarioUsuario._id)
|
||||||
|
) {
|
||||||
|
conversaId = conversa._id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversaId) {
|
||||||
|
conversaId = await ctx.db.insert('conversas', {
|
||||||
|
tipo: 'individual',
|
||||||
|
participantes: [args.usuarioId, funcionarioUsuario._id],
|
||||||
|
criadoPor: args.usuarioId,
|
||||||
|
criadoEm: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar mensagem de chat (texto simples)
|
||||||
|
await ctx.db.insert('mensagens', {
|
||||||
|
conversaId,
|
||||||
|
remetenteId: args.usuarioId,
|
||||||
|
tipo: 'texto',
|
||||||
|
conteudo: `Sua solicitação de férias de ${formatarDataBR(registro.dataInicio)} até ${formatarDataBR(registro.dataFim)} (${registro.diasFerias} dias) foi cancelada pelo setor de Recursos Humanos. Para mais informações, entre em contato conosco.`,
|
||||||
|
enviadaEm: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,6 +145,180 @@ export const listarAlertas = query({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar configuração do sistema de alertas (diagnóstico)
|
||||||
|
*/
|
||||||
|
export const verificarConfiguracaoAlertas = query({
|
||||||
|
args: {
|
||||||
|
_refresh: v.optional(v.number()) // Parâmetro ignorado, usado apenas para forçar refresh no frontend
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
templateExiste: v.boolean(),
|
||||||
|
templateInfo: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('templatesMensagens'),
|
||||||
|
codigo: v.string(),
|
||||||
|
nome: v.string(),
|
||||||
|
htmlCorpo: v.optional(v.string())
|
||||||
|
}),
|
||||||
|
v.null()
|
||||||
|
),
|
||||||
|
roleTiMasterExiste: v.boolean(),
|
||||||
|
usuariosTiMaster: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('usuarios'),
|
||||||
|
nome: v.string(),
|
||||||
|
email: v.optional(v.string()),
|
||||||
|
temEmail: v.boolean()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
configSmtpAtiva: v.boolean(),
|
||||||
|
configSmtpInfo: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('configuracaoEmail'),
|
||||||
|
servidor: v.string(),
|
||||||
|
porta: v.number(),
|
||||||
|
emailRemetente: v.string(),
|
||||||
|
ativo: v.boolean()
|
||||||
|
}),
|
||||||
|
v.null()
|
||||||
|
),
|
||||||
|
emailsPendentes: v.number(),
|
||||||
|
emailsFalha: v.number(),
|
||||||
|
alertasAtivos: v.number(),
|
||||||
|
alertasComEmail: v.number()
|
||||||
|
}),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
try {
|
||||||
|
// 1. Verificar template
|
||||||
|
let template = null;
|
||||||
|
try {
|
||||||
|
template = await ctx.db
|
||||||
|
.query('templatesMensagens')
|
||||||
|
.withIndex('by_codigo', (q) => q.eq('codigo', 'monitoramento_alerta_sistema'))
|
||||||
|
.first();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao buscar template:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verificar role TI_MASTER
|
||||||
|
let roleTiMaster = null;
|
||||||
|
try {
|
||||||
|
roleTiMaster = await ctx.db
|
||||||
|
.query('roles')
|
||||||
|
.withIndex('by_nome', (q) => q.eq('nome', 'ti_master'))
|
||||||
|
.first();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao buscar role TI_MASTER:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verificar usuários TI_MASTER
|
||||||
|
let usuariosTiMaster: Array<{
|
||||||
|
_id: Id<'usuarios'>;
|
||||||
|
nome: string;
|
||||||
|
email?: string;
|
||||||
|
temEmail: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (roleTiMaster) {
|
||||||
|
try {
|
||||||
|
const usuarios = await ctx.db
|
||||||
|
.query('usuarios')
|
||||||
|
.withIndex('by_role', (q) => q.eq('roleId', roleTiMaster!._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
usuariosTiMaster = usuarios.map((u) => ({
|
||||||
|
_id: u._id,
|
||||||
|
nome: u.nome,
|
||||||
|
email: u.email,
|
||||||
|
temEmail: !!u.email
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao buscar usuários TI_MASTER:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verificar configuração SMTP
|
||||||
|
let configSmtp = null;
|
||||||
|
try {
|
||||||
|
configSmtp = await ctx.db
|
||||||
|
.query('configuracaoEmail')
|
||||||
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||||
|
.first();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao buscar configuração SMTP:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Verificar fila de emails
|
||||||
|
let emailsPendentes = 0;
|
||||||
|
let emailsFalha = 0;
|
||||||
|
try {
|
||||||
|
const todosEmails = await ctx.db.query('notificacoesEmail').collect();
|
||||||
|
emailsPendentes = todosEmails.filter((e) => e.status === 'pendente').length;
|
||||||
|
emailsFalha = todosEmails.filter((e) => e.status === 'falha').length;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao buscar emails:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verificar alertas
|
||||||
|
let alertasAtivos = 0;
|
||||||
|
let alertasComEmail = 0;
|
||||||
|
try {
|
||||||
|
const todosAlertas = await ctx.db.query('alertConfigurations').collect();
|
||||||
|
alertasAtivos = todosAlertas.filter((a) => a.enabled).length;
|
||||||
|
alertasComEmail = todosAlertas.filter(
|
||||||
|
(a) => a.enabled && a.notifyByEmail
|
||||||
|
).length;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao buscar alertas:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
templateExiste: !!template,
|
||||||
|
templateInfo: template
|
||||||
|
? {
|
||||||
|
_id: template._id,
|
||||||
|
codigo: template.codigo,
|
||||||
|
nome: template.nome,
|
||||||
|
htmlCorpo: template.htmlCorpo
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
roleTiMasterExiste: !!roleTiMaster,
|
||||||
|
usuariosTiMaster,
|
||||||
|
configSmtpAtiva: !!configSmtp,
|
||||||
|
configSmtpInfo: configSmtp
|
||||||
|
? {
|
||||||
|
_id: configSmtp._id,
|
||||||
|
servidor: configSmtp.servidor,
|
||||||
|
porta: configSmtp.porta,
|
||||||
|
emailRemetente: configSmtp.emailRemetente,
|
||||||
|
ativo: configSmtp.ativo
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
emailsPendentes,
|
||||||
|
emailsFalha,
|
||||||
|
alertasAtivos,
|
||||||
|
alertasComEmail
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao verificar configuração de alertas:', error);
|
||||||
|
// Retornar valores padrão em caso de erro
|
||||||
|
return {
|
||||||
|
templateExiste: false,
|
||||||
|
templateInfo: null,
|
||||||
|
roleTiMasterExiste: false,
|
||||||
|
usuariosTiMaster: [],
|
||||||
|
configSmtpAtiva: false,
|
||||||
|
configSmtpInfo: null,
|
||||||
|
emailsPendentes: 0,
|
||||||
|
emailsFalha: 0,
|
||||||
|
alertasAtivos: 0,
|
||||||
|
alertasComEmail: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obter métricas com filtros
|
* Obter métricas com filtros
|
||||||
*/
|
*/
|
||||||
@@ -340,18 +514,22 @@ export const verificarAlertasInternal = internalMutation({
|
|||||||
|
|
||||||
// Criar notificação no chat se configurado
|
// Criar notificação no chat se configurado
|
||||||
if (alerta.notifyByChat) {
|
if (alerta.notifyByChat) {
|
||||||
// Buscar roles administrativas (nível <= 1) e filtrar usuários por roleId
|
// Buscar apenas a role TI_MASTER
|
||||||
const rolesAdminOuTi = await ctx.db
|
const roleTiMaster = await ctx.db
|
||||||
.query('roles')
|
.query('roles')
|
||||||
.filter((q) => q.lte(q.field('nivel'), 1))
|
.withIndex('by_nome', (q) => q.eq('nome', 'ti_master'))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!roleTiMaster) {
|
||||||
|
console.warn('Role TI_MASTER não encontrada. Notificações de chat não serão enviadas.');
|
||||||
|
} else {
|
||||||
|
// Buscar usuários com role TI_MASTER
|
||||||
|
const usuarios = await ctx.db
|
||||||
|
.query('usuarios')
|
||||||
|
.withIndex('by_role', (q) => q.eq('roleId', roleTiMaster._id))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id));
|
for (const usuario of usuarios) {
|
||||||
|
|
||||||
const usuarios = await ctx.db.query('usuarios').collect();
|
|
||||||
const usuariosTI = usuarios.filter((u) => rolesPermitidas.has(u.roleId));
|
|
||||||
|
|
||||||
for (const usuario of usuariosTI) {
|
|
||||||
await ctx.db.insert('notificacoes', {
|
await ctx.db.insert('notificacoes', {
|
||||||
usuarioId: usuario._id,
|
usuarioId: usuario._id,
|
||||||
tipo: 'nova_mensagem',
|
tipo: 'nova_mensagem',
|
||||||
@@ -362,22 +540,37 @@ export const verificarAlertasInternal = internalMutation({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Enviar email se configurado (usar template HTML padronizado)
|
// Enviar email se configurado (usar template HTML padronizado)
|
||||||
if (alerta.notifyByEmail) {
|
if (alerta.notifyByEmail) {
|
||||||
// Buscar usuários administradores/TI para receber o alerta por email
|
// Buscar apenas a role TI_MASTER
|
||||||
const rolesAdminOuTi = await ctx.db
|
const roleTiMaster = await ctx.db
|
||||||
.query('roles')
|
.query('roles')
|
||||||
.filter((q) => q.lte(q.field('nivel'), 1))
|
.withIndex('by_nome', (q) => q.eq('nome', 'ti_master'))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!roleTiMaster) {
|
||||||
|
console.warn('⚠️ [Monitoramento] Role TI_MASTER não encontrada. Emails de alerta não serão enviados.');
|
||||||
|
} else {
|
||||||
|
// Buscar usuários com role TI_MASTER que possuem email
|
||||||
|
const usuarios = await ctx.db
|
||||||
|
.query('usuarios')
|
||||||
|
.withIndex('by_role', (q) => q.eq('roleId', roleTiMaster._id))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id));
|
if (usuarios.length === 0) {
|
||||||
const usuarios = await ctx.db.query('usuarios').collect();
|
console.warn('⚠️ [Monitoramento] Nenhum usuário TI_MASTER encontrado para receber alertas por email.');
|
||||||
const usuariosTI = usuarios.filter((u) => rolesPermitidas.has(u.roleId) && !!u.email);
|
} else {
|
||||||
|
// Usar o createdBy do alerta como enviadoPor (quem criou o alerta)
|
||||||
|
const enviadoPorId = alerta.createdBy;
|
||||||
|
|
||||||
for (const usuario of usuariosTI) {
|
for (const usuario of usuarios) {
|
||||||
const email = usuario.email;
|
const email = usuario.email;
|
||||||
if (!email) continue;
|
if (!email) {
|
||||||
|
console.warn(`⚠️ [Monitoramento] Usuário ${usuario._id} (TI_MASTER) não possui email cadastrado.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Montar variáveis para template de alerta de sistema
|
// Montar variáveis para template de alerta de sistema
|
||||||
const variaveisEmail = {
|
const variaveisEmail = {
|
||||||
@@ -387,6 +580,7 @@ export const verificarAlertasInternal = internalMutation({
|
|||||||
threshold: alerta.threshold.toString()
|
threshold: alerta.threshold.toString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
// Importante: usar api.email.enviarEmailComTemplate (action pública),
|
// Importante: usar api.email.enviarEmailComTemplate (action pública),
|
||||||
// e não internal.email, para corresponder à tipagem gerada em ./_generated/api.
|
// e não internal.email, para corresponder à tipagem gerada em ./_generated/api.
|
||||||
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
||||||
@@ -394,8 +588,15 @@ export const verificarAlertasInternal = internalMutation({
|
|||||||
destinatarioId: usuario._id,
|
destinatarioId: usuario._id,
|
||||||
templateCodigo: 'monitoramento_alerta_sistema',
|
templateCodigo: 'monitoramento_alerta_sistema',
|
||||||
variaveis: variaveisEmail,
|
variaveis: variaveisEmail,
|
||||||
enviadoPor: usuario._id
|
enviadoPor: enviadoPorId // ✅ CORRIGIDO: usar createdBy do alerta
|
||||||
});
|
});
|
||||||
|
console.log(`✅ [Monitoramento] Email de alerta agendado para ${email} (${usuario.nome})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ [Monitoramento] Erro ao agendar email de alerta para ${email}:`, error);
|
||||||
|
// Continuar tentando enviar para outros usuários mesmo se um falhar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export const feriasTables = {
|
|||||||
v.literal('nova_solicitacao'),
|
v.literal('nova_solicitacao'),
|
||||||
v.literal('aprovado'),
|
v.literal('aprovado'),
|
||||||
v.literal('reprovado'),
|
v.literal('reprovado'),
|
||||||
v.literal('data_ajustada')
|
v.literal('data_ajustada'),
|
||||||
|
v.literal('cancelado')
|
||||||
),
|
),
|
||||||
lida: v.boolean(),
|
lida: v.boolean(),
|
||||||
mensagem: v.string()
|
mensagem: v.string()
|
||||||
|
|||||||
@@ -352,8 +352,27 @@ export const criarTemplatesPadrao = mutation({
|
|||||||
nome: 'Boas-vindas',
|
nome: 'Boas-vindas',
|
||||||
titulo: 'Bem-vindo ao SGSE',
|
titulo: 'Bem-vindo ao SGSE',
|
||||||
corpo:
|
corpo:
|
||||||
'Olá {{nome}},\n\nSeja bem-vindo ao SGSE - Sistema de Gerenciamento de Secretaria!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI',
|
"<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>" +
|
||||||
variaveis: ['nome', 'matricula', 'senha']
|
"<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>" +
|
||||||
|
"<h2 style='color: #2563EB;'>Bem-vindo ao SGSE</h2>" +
|
||||||
|
"<p>Olá <strong>{{nome}}</strong>,</p>" +
|
||||||
|
"<p>Seja bem-vindo ao <strong>SGSE - Sistema de Gerenciamento de Secretaria</strong>!</p>" +
|
||||||
|
"<p>Seu cadastro foi realizado com sucesso.</p>" +
|
||||||
|
"<div style='background-color: #F3F4F6; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>" +
|
||||||
|
"<p style='margin: 0 0 10px 0;'><strong>Suas credenciais de acesso:</strong></p>" +
|
||||||
|
"<ul style='margin: 0; padding-left: 20px;'>" +
|
||||||
|
"<li><strong>E-mail:</strong> {{email}}</li>" +
|
||||||
|
"{{credenciaisAdicionais}}" +
|
||||||
|
"<li><strong>Senha temporária:</strong> {{senha}}</li>" +
|
||||||
|
"</ul>" +
|
||||||
|
"</div>" +
|
||||||
|
"<p><strong>⚠️ Importante:</strong> Por favor, altere sua senha no primeiro acesso ao sistema.</p>" +
|
||||||
|
"<p>Acesse o sistema através do link: <a href='{{urlSistema}}' style='color: #2563EB;'>{{urlSistema}}</a></p>" +
|
||||||
|
"<p style='margin-top: 30px; color: #6B7280; font-size: 14px;'>Equipe de TI - Secretaria de Esportes</p>" +
|
||||||
|
"</div></body></html>",
|
||||||
|
variaveis: ['nome', 'email', 'credenciaisAdicionais', 'senha', 'urlSistema'],
|
||||||
|
categoria: 'email' as const,
|
||||||
|
tags: ['boas_vindas', 'cadastro', 'credenciais']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
codigo: 'chat_mensagem',
|
codigo: 'chat_mensagem',
|
||||||
@@ -545,6 +564,33 @@ export const criarTemplatesPadrao = mutation({
|
|||||||
'Recomenda-se verificar o painel de monitoramento do SGSE para detalhes adicionais e, se necessário, ' +
|
'Recomenda-se verificar o painel de monitoramento do SGSE para detalhes adicionais e, se necessário, ' +
|
||||||
'executar ações corretivas.\n\n' +
|
'executar ações corretivas.\n\n' +
|
||||||
'Esta é uma notificação automática do sistema de monitoramento SGSE.',
|
'Esta é uma notificação automática do sistema de monitoramento SGSE.',
|
||||||
|
htmlCorpo:
|
||||||
|
'<div style="max-width: 600px; margin: 0 auto; padding: 20px;">' +
|
||||||
|
'<div style="background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%); border-radius: 8px; padding: 20px; margin-bottom: 25px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">' +
|
||||||
|
'<h2 style="color: #FFFFFF; margin: 0 0 10px 0; font-size: 24px; font-weight: bold;">⚠️ Alerta de Sistema</h2>' +
|
||||||
|
'<p style="color: #FFFFFF; margin: 0; font-size: 16px; font-weight: 500;">Métrica: <strong>{{metricName}}</strong></p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Olá <strong>{{destinatarioNome}}</strong>,</p>' +
|
||||||
|
'<div style="background-color: #FFF3CD; border-left: 4px solid #FFC107; padding: 15px; border-radius: 4px; margin: 20px 0;">' +
|
||||||
|
'<p style="margin: 0 0 10px 0; color: #856404; font-weight: bold; font-size: 14px;">📊 Detalhes do Alerta:</p>' +
|
||||||
|
'<ul style="margin: 0; padding-left: 20px; color: #856404; font-size: 14px; line-height: 1.8;">' +
|
||||||
|
'<li><strong>Métrica:</strong> {{metricName}}</li>' +
|
||||||
|
'<li><strong>Valor Atual:</strong> <span style="color: #DC3545; font-weight: bold;">{{metricValue}}</span></li>' +
|
||||||
|
'<li><strong>Limite Configurado:</strong> {{threshold}}</li>' +
|
||||||
|
'</ul>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p style="color: #333333; font-size: 14px; line-height: 1.6; margin: 20px 0;">' +
|
||||||
|
'Recomenda-se verificar o <strong>painel de monitoramento do SGSE</strong> para detalhes adicionais e, se necessário, executar ações corretivas.' +
|
||||||
|
'</p>' +
|
||||||
|
'<div style="background-color: #E7F3FF; border-left: 4px solid #0052A5; padding: 15px; border-radius: 4px; margin: 20px 0;">' +
|
||||||
|
'<p style="margin: 0; color: #004085; font-size: 14px; line-height: 1.6;">' +
|
||||||
|
'<strong>💡 Dica:</strong> Acesse o painel de monitoramento para visualizar gráficos e histórico detalhado desta métrica.' +
|
||||||
|
'</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<p style="color: #666666; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #E0E0E0;">' +
|
||||||
|
'Esta é uma notificação automática do sistema de monitoramento SGSE.' +
|
||||||
|
'</p>' +
|
||||||
|
'</div>',
|
||||||
variaveis: ['destinatarioNome', 'metricName', 'metricValue', 'threshold'],
|
variaveis: ['destinatarioNome', 'metricName', 'metricValue', 'threshold'],
|
||||||
categoria: 'email' as const,
|
categoria: 'email' as const,
|
||||||
tags: ['monitoramento', 'alerta', 'sistema', 'ti']
|
tags: ['monitoramento', 'alerta', 'sistema', 'ti']
|
||||||
@@ -702,6 +748,39 @@ export const criarTemplatesPadrao = mutation({
|
|||||||
categoria: 'email' as const,
|
categoria: 'email' as const,
|
||||||
tags: ['ausencia', 'reprovacao', 'gestao']
|
tags: ['ausencia', 'reprovacao', 'gestao']
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
codigo: 'ferias_aprovada',
|
||||||
|
nome: 'Férias Aprovada',
|
||||||
|
titulo: 'Solicitação de Férias Aprovada',
|
||||||
|
corpo:
|
||||||
|
'Olá {{funcionarioNome}},\n\nSua solicitação de férias foi <strong>aprovada</strong> pelo gestor {{gestorNome}}:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Dias:</strong> {{diasFerias}} dias</li></ul>',
|
||||||
|
variaveis: [
|
||||||
|
'funcionarioNome',
|
||||||
|
'gestorNome',
|
||||||
|
'dataInicio',
|
||||||
|
'dataFim',
|
||||||
|
'diasFerias',
|
||||||
|
'urlSistema'
|
||||||
|
],
|
||||||
|
categoria: 'email' as const,
|
||||||
|
tags: ['ferias', 'aprovacao', 'gestao']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
codigo: 'ferias_cancelada_rh',
|
||||||
|
nome: 'Férias Cancelada pelo RH',
|
||||||
|
titulo: 'Solicitação de Férias Cancelada',
|
||||||
|
corpo:
|
||||||
|
'Olá {{funcionarioNome}},\n\nSua solicitação de férias foi <strong>cancelada</strong> pelo setor de Recursos Humanos:\n\n<ul><li><strong>Período:</strong> {{dataInicio}} até {{dataFim}}</li><li><strong>Dias:</strong> {{diasFerias}} dias</li></ul>\n\nPara mais informações, entre em contato com o setor de Recursos Humanos.',
|
||||||
|
variaveis: [
|
||||||
|
'funcionarioNome',
|
||||||
|
'dataInicio',
|
||||||
|
'dataFim',
|
||||||
|
'diasFerias',
|
||||||
|
'urlSistema'
|
||||||
|
],
|
||||||
|
categoria: 'email' as const,
|
||||||
|
tags: ['ferias', 'cancelamento', 'recursos_humanos']
|
||||||
|
},
|
||||||
// ===================== ALERTAS DE SEGURANÇA CIBERNÉTICA =====================
|
// ===================== ALERTAS DE SEGURANÇA CIBERNÉTICA =====================
|
||||||
{
|
{
|
||||||
codigo: 'incidente_critico',
|
codigo: 'incidente_critico',
|
||||||
|
|||||||
@@ -124,6 +124,116 @@ export const criar = mutation({
|
|||||||
atualizadoEm: Date.now()
|
atualizadoEm: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Obter usuário que está criando (para enviar email e chat)
|
||||||
|
const usuarioCriador = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuarioCriador) {
|
||||||
|
// Se não conseguir obter o criador, retornar sucesso mesmo assim
|
||||||
|
return { sucesso: true as const, usuarioId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar funcionário para obter matrícula se houver
|
||||||
|
let matricula = '';
|
||||||
|
if (args.funcionarioId) {
|
||||||
|
const funcionario = await ctx.db.get(args.funcionarioId);
|
||||||
|
if (funcionario?.matricula) {
|
||||||
|
matricula = funcionario.matricula;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preparar credenciais adicionais (matrícula se houver)
|
||||||
|
const credenciaisAdicionais = matricula
|
||||||
|
? `<li><strong>Matrícula:</strong> ${matricula}</li>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Obter URL do sistema
|
||||||
|
let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
if (!urlSistema.match(/^https?:\/\//i)) {
|
||||||
|
urlSistema = `http://${urlSistema}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enviar email de boas-vindas usando template (agendado via scheduler)
|
||||||
|
try {
|
||||||
|
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
||||||
|
destinatario: args.email,
|
||||||
|
destinatarioId: usuarioId,
|
||||||
|
templateCodigo: 'BEM_VINDO',
|
||||||
|
variaveis: {
|
||||||
|
nome: args.nome,
|
||||||
|
email: args.email,
|
||||||
|
credenciaisAdicionais,
|
||||||
|
senha: senhaTemporaria,
|
||||||
|
urlSistema
|
||||||
|
},
|
||||||
|
enviadoPor: usuarioCriador._id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback para envio direto se houver erro ao agendar ou processar o template
|
||||||
|
console.warn(
|
||||||
|
'Erro ao agendar envio de email com template BEM_VINDO, usando envio direto:',
|
||||||
|
error
|
||||||
|
);
|
||||||
|
await ctx.runMutation(api.email.enfileirarEmail, {
|
||||||
|
destinatario: args.email,
|
||||||
|
destinatarioId: usuarioId,
|
||||||
|
assunto: 'Bem-vindo ao SGSE',
|
||||||
|
corpo: `<p>Olá <strong>${args.nome}</strong>,</p>
|
||||||
|
<p>Seja bem-vindo ao <strong>SGSE - Sistema de Gerenciamento de Secretaria</strong>!</p>
|
||||||
|
<p>Seu cadastro foi realizado com sucesso.</p>
|
||||||
|
<div style='background-color: #F3F4F6; border-left: 4px solid #2563EB; padding: 15px; border-radius: 8px; margin: 20px 0;'>
|
||||||
|
<p style='margin: 0 0 10px 0;'><strong>Suas credenciais de acesso:</strong></p>
|
||||||
|
<ul style='margin: 0; padding-left: 20px;'>
|
||||||
|
<li><strong>E-mail:</strong> ${args.email}</li>
|
||||||
|
${credenciaisAdicionais}
|
||||||
|
<li><strong>Senha temporária:</strong> ${senhaTemporaria}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p><strong>⚠️ Importante:</strong> Por favor, altere sua senha no primeiro acesso ao sistema.</p>
|
||||||
|
<p>Acesse o sistema através do link: <a href='${urlSistema}' style='color: #2563EB;'>${urlSistema}</a></p>
|
||||||
|
<p style='margin-top: 30px; color: #6B7280; font-size: 14px;'>Equipe de TI - Secretaria de Esportes</p>`,
|
||||||
|
enviadoPor: usuarioCriador._id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar ou obter conversa entre criador e novo usuário
|
||||||
|
const conversasExistentes = await ctx.db
|
||||||
|
.query('conversas')
|
||||||
|
.filter((q) => q.eq(q.field('tipo'), 'individual'))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let conversaId: Id<'conversas'> | null = null;
|
||||||
|
for (const conversa of conversasExistentes) {
|
||||||
|
if (
|
||||||
|
conversa.participantes.length === 2 &&
|
||||||
|
conversa.participantes.includes(usuarioCriador._id) &&
|
||||||
|
conversa.participantes.includes(usuarioId)
|
||||||
|
) {
|
||||||
|
conversaId = conversa._id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversaId) {
|
||||||
|
conversaId = await ctx.db.insert('conversas', {
|
||||||
|
tipo: 'individual',
|
||||||
|
participantes: [usuarioCriador._id, usuarioId],
|
||||||
|
criadoPor: usuarioCriador._id,
|
||||||
|
criadoEm: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar mensagem de chat (texto simples)
|
||||||
|
const mensagemChat = matricula
|
||||||
|
? `Bem-vindo ao SGSE! Seu cadastro foi realizado com sucesso. Suas credenciais de acesso: E-mail: ${args.email}, Matrícula: ${matricula}, Senha temporária: ${senhaTemporaria}. Por favor, altere sua senha no primeiro acesso.`
|
||||||
|
: `Bem-vindo ao SGSE! Seu cadastro foi realizado com sucesso. Suas credenciais de acesso: E-mail: ${args.email}, Senha temporária: ${senhaTemporaria}. Por favor, altere sua senha no primeiro acesso.`;
|
||||||
|
|
||||||
|
await ctx.db.insert('mensagens', {
|
||||||
|
conversaId,
|
||||||
|
remetenteId: usuarioCriador._id,
|
||||||
|
tipo: 'texto',
|
||||||
|
conteudo: mensagemChat,
|
||||||
|
enviadaEm: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
return { sucesso: true as const, usuarioId };
|
return { sucesso: true as const, usuarioId };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user