feat: integrate interactive absence calendar in atestados-licencas page

- Added the CalendarioAfastamentos component to display an interactive calendar for managing absences.
- Removed the previous static calendar placeholder and replaced it with a dynamic query to fetch absence events.
- Enhanced user experience by allowing users to visualize absence events directly in the calendar format.
This commit is contained in:
2025-11-04 04:02:07 -03:00
parent bc3c7df00f
commit 0fee0cfd35
3 changed files with 905 additions and 402 deletions

View File

@@ -0,0 +1,517 @@
<script lang="ts">
import { onMount } from "svelte";
import { Calendar } from "@fullcalendar/core";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
import type { EventInput } from "@fullcalendar/core/index.js";
interface Props {
eventos: Array<{
id: string;
title: string;
start: string;
end: string;
color: string;
tipo: string;
funcionarioNome: string;
funcionarioId: string;
}>;
tipoFiltro?: string;
}
let { eventos, tipoFiltro = "todos" }: Props = $props();
let calendarEl: HTMLDivElement;
let calendar: Calendar | null = null;
let filtroAtivo = $state<string>(tipoFiltro);
let showModal = $state(false);
let eventoSelecionado = $state<{
title: string;
start: string;
end: string;
tipo: string;
funcionarioNome: string;
} | null>(null);
// Eventos filtrados
const eventosFiltrados = $derived.by(() => {
if (filtroAtivo === "todos") return eventos;
return eventos.filter((e) => e.tipo === filtroAtivo);
});
// Converter eventos para formato FullCalendar
const eventosFullCalendar = $derived.by(() => {
return eventosFiltrados.map((evento) => ({
id: evento.id,
title: evento.title,
start: evento.start,
end: evento.end,
backgroundColor: evento.color,
borderColor: evento.color,
textColor: "#ffffff",
extendedProps: {
tipo: evento.tipo,
funcionarioNome: evento.funcionarioNome,
funcionarioId: evento.funcionarioId,
},
})) as EventInput[];
});
onMount(() => {
if (!calendarEl) return;
calendar = new Calendar(calendarEl, {
plugins: [dayGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
locale: ptBrLocale,
firstDay: 0, // Domingo
headerToolbar: {
left: "prev,next today",
center: "title",
right: "dayGridMonth",
},
buttonText: {
today: "Hoje",
month: "Mês",
week: "Semana",
day: "Dia",
},
events: eventosFullCalendar,
eventClick: (info) => {
eventoSelecionado = {
title: info.event.title,
start: info.event.startStr || "",
end: info.event.endStr || "",
tipo: info.event.extendedProps.tipo as string,
funcionarioNome: info.event.extendedProps.funcionarioNome as string,
};
showModal = true;
},
eventDisplay: "block",
dayMaxEvents: 3,
moreLinkClick: "popover",
height: "auto",
contentHeight: "auto",
aspectRatio: 1.8,
eventMouseEnter: (info) => {
info.el.style.cursor = "pointer";
info.el.style.opacity = "0.9";
},
eventMouseLeave: (info) => {
info.el.style.opacity = "1";
},
});
calendar.render();
return () => {
if (calendar) {
calendar.destroy();
}
};
});
// Atualizar eventos quando mudarem
$effect(() => {
if (calendar) {
calendar.removeAllEvents();
calendar.addEventSource(eventosFullCalendar);
calendar.refetchEvents();
}
});
function formatarData(data: string): string {
return new Date(data).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
year: "numeric",
});
}
function getTipoNome(tipo: string): string {
const nomes: Record<string, string> = {
atestado_medico: "Atestado Médico",
declaracao_comparecimento: "Declaração de Comparecimento",
maternidade: "Licença Maternidade",
paternidade: "Licença Paternidade",
ferias: "Férias",
};
return nomes[tipo] || tipo;
}
function getTipoCor(tipo: string): string {
const cores: Record<string, string> = {
atestado_medico: "text-error",
declaracao_comparecimento: "text-warning",
maternidade: "text-secondary",
paternidade: "text-info",
ferias: "text-success",
};
return cores[tipo] || "text-base-content";
}
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<!-- Header com filtros -->
<div class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6">
<h2 class="card-title text-2xl">Calendário de Afastamentos</h2>
<!-- Filtros -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content/70">Filtrar:</span>
<div class="join">
<button
class="join-item btn btn-sm {filtroAtivo === 'todos' ? 'btn-active btn-primary' : 'btn-ghost'}"
onclick={() => (filtroAtivo = "todos")}
>
Todos
</button>
<button
class="join-item btn btn-sm {filtroAtivo === 'atestado_medico' ? 'btn-active btn-error' : 'btn-ghost'}"
onclick={() => (filtroAtivo = "atestado_medico")}
>
Atestados
</button>
<button
class="join-item btn btn-sm {filtroAtivo === 'declaracao_comparecimento' ? 'btn-active btn-warning' : 'btn-ghost'}"
onclick={() => (filtroAtivo = "declaracao_comparecimento")}
>
Declarações
</button>
<button
class="join-item btn btn-sm {filtroAtivo === 'maternidade' ? 'btn-active btn-secondary' : 'btn-ghost'}"
onclick={() => (filtroAtivo = "maternidade")}
>
Maternidade
</button>
<button
class="join-item btn btn-sm {filtroAtivo === 'paternidade' ? 'btn-active btn-info' : 'btn-ghost'}"
onclick={() => (filtroAtivo = "paternidade")}
>
Paternidade
</button>
<button
class="join-item btn btn-sm {filtroAtivo === 'ferias' ? 'btn-active btn-success' : 'btn-ghost'}"
onclick={() => (filtroAtivo = "ferias")}
>
Férias
</button>
</div>
</div>
</div>
<!-- Legenda -->
<div class="flex flex-wrap gap-4 mb-4 p-4 bg-base-200/50 rounded-lg">
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-error"></div>
<span class="text-sm">Atestado Médico</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-warning"></div>
<span class="text-sm">Declaração</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-secondary"></div>
<span class="text-sm">Licença Maternidade</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-info"></div>
<span class="text-sm">Licença Paternidade</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-success"></div>
<span class="text-sm">Férias</span>
</div>
</div>
<!-- Calendário -->
<div class="w-full overflow-x-auto">
<div bind:this={calendarEl} class="calendar-container"></div>
</div>
<!-- Modal de Detalhes -->
{#if showModal && eventoSelecionado}
<!-- 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={() => (showModal = false)}
role="dialog"
aria-modal="true"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-base-100 rounded-2xl shadow-2xl w-full max-w-md mx-4 transform transition-all"
onclick={(e) => e.stopPropagation()}
>
<!-- Header do Modal -->
<div class="p-6 border-b border-base-300 bg-gradient-to-r from-primary/10 to-secondary/10">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-xl font-bold text-base-content mb-2">
{eventoSelecionado.funcionarioNome}
</h3>
<p class="text-sm {getTipoCor(eventoSelecionado.tipo)} font-medium">
{getTipoNome(eventoSelecionado.tipo)}
</p>
</div>
<button
class="btn btn-sm btn-circle btn-ghost"
onclick={() => (showModal = false)}
aria-label="Fechar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Conteúdo do Modal -->
<div class="p-6 space-y-4">
<div class="flex items-center gap-3 p-4 bg-base-200/50 rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<div>
<p class="text-sm text-base-content/60">Data Início</p>
<p class="font-semibold">{formatarData(eventoSelecionado.start)}</p>
</div>
</div>
<div class="flex items-center gap-3 p-4 bg-base-200/50 rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-secondary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<div>
<p class="text-sm text-base-content/60">Data Fim</p>
<p class="font-semibold">{formatarData(eventoSelecionado.end)}</p>
</div>
</div>
<div class="flex items-center gap-3 p-4 bg-base-200/50 rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-accent"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="text-sm text-base-content/60">Duração</p>
<p class="font-semibold">
{(() => {
const inicio = new Date(eventoSelecionado.start);
const fim = new Date(eventoSelecionado.end);
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return `${diffDays} ${diffDays === 1 ? "dia" : "dias"}`;
})()}
</p>
</div>
</div>
</div>
<!-- Footer do Modal -->
<div class="p-6 border-t border-base-300 flex justify-end">
<button
class="btn btn-primary"
onclick={() => (showModal = false)}
>
Fechar
</button>
</div>
</div>
</div>
{/if}
</div>
</div>
<style>
:global(.calendar-container) {
font-family: inherit;
}
:global(.fc) {
font-family: inherit;
}
:global(.fc-header-toolbar) {
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 0.5rem;
}
:global(.fc-button) {
background-color: hsl(var(--p));
border-color: hsl(var(--p));
color: hsl(var(--pc));
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
}
:global(.fc-button:hover) {
background-color: hsl(var(--pf));
border-color: hsl(var(--pf));
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
:global(.fc-button:active) {
transform: translateY(0);
}
:global(.fc-button-active) {
background-color: hsl(var(--a));
border-color: hsl(var(--a));
color: hsl(var(--ac));
}
:global(.fc-today-button) {
background-color: hsl(var(--s));
border-color: hsl(var(--s));
}
:global(.fc-daygrid-day-number) {
padding: 0.5rem;
font-weight: 500;
}
:global(.fc-day-today) {
background-color: hsl(var(--p) / 0.1) !important;
}
:global(.fc-day-today .fc-daygrid-day-number) {
background-color: hsl(var(--p));
color: hsl(var(--pc));
border-radius: 50%;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
:global(.fc-event) {
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
}
:global(.fc-event:hover) {
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
opacity: 0.9;
}
:global(.fc-event-title) {
font-weight: 600;
padding: 0;
}
:global(.fc-daygrid-event) {
margin: 0.125rem 0;
}
:global(.fc-daygrid-day-frame) {
min-height: 100px;
}
:global(.fc-col-header-cell) {
padding: 0.75rem 0;
background-color: hsl(var(--b2));
font-weight: 600;
text-transform: uppercase;
font-size: 0.875rem;
color: hsl(var(--bc));
}
:global(.fc-daygrid-day) {
border-color: hsl(var(--b3));
}
:global(.fc-scrollgrid) {
border-color: hsl(var(--b3));
}
:global(.fc-daygrid-day-frame) {
padding: 0.25rem;
}
:global(.fc-more-link) {
font-weight: 600;
color: hsl(var(--p));
background-color: hsl(var(--p) / 0.1);
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
margin-top: 0.25rem;
}
:global(.fc-popover) {
background-color: hsl(var(--b1));
border-color: hsl(var(--b3));
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
border-radius: 0.5rem;
}
:global(.fc-popover-header) {
background-color: hsl(var(--b2));
border-color: hsl(var(--b3));
padding: 0.75rem;
font-weight: 600;
}
:global(.fc-popover-body) {
padding: 0.5rem;
}
</style>

View File

@@ -1,377 +1,383 @@
<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 } from "@sgse-app/backend/convex/_generated/dataModel";
let { onClose }: { onClose: () => void } = $props(); let { onClose }: { onClose: () => void } = $props();
const client = useConvexClient(); const client = useConvexClient();
const alertas = useQuery(api.monitoramento.listarAlertas, {}); const alertasQuery = useQuery(api.monitoramento.listarAlertas, {});
const alertas = $derived.by(() => {
// Estado para novo alerta if (!alertasQuery) return [];
let editingAlertId = $state<Id<"alertConfigurations"> | null>(null); // O useQuery pode retornar o array diretamente ou em .data
let metricName = $state("cpuUsage"); if (Array.isArray(alertasQuery)) return alertasQuery;
let threshold = $state(80); return alertasQuery.data ?? [];
let operator = $state<">" | "<" | ">=" | "<=" | "==">(">"); });
let enabled = $state(true);
let notifyByEmail = $state(false); // Estado para novo alerta
let notifyByChat = $state(true); let editingAlertId = $state<Id<"alertConfigurations"> | null>(null);
let saving = $state(false); let metricName = $state("cpuUsage");
let showForm = $state(false); let threshold = $state(80);
let operator = $state<">" | "<" | ">=" | "<=" | "==">(">");
const metricOptions = [ let enabled = $state(true);
{ value: "cpuUsage", label: "Uso de CPU (%)" }, let notifyByEmail = $state(false);
{ value: "memoryUsage", label: "Uso de Memória (%)" }, let notifyByChat = $state(true);
{ value: "networkLatency", label: "Latência de Rede (ms)" }, let saving = $state(false);
{ value: "storageUsed", label: "Armazenamento Usado (%)" }, let showForm = $state(false);
{ value: "usuariosOnline", label: "Usuários Online" },
{ value: "mensagensPorMinuto", label: "Mensagens por Minuto" }, const metricOptions = [
{ value: "tempoRespostaMedio", label: "Tempo de Resposta (ms)" }, { value: "cpuUsage", label: "Uso de CPU (%)" },
{ value: "errosCount", label: "Contagem de Erros" }, { value: "memoryUsage", label: "Uso de Memória (%)" },
]; { value: "networkLatency", label: "Latência de Rede (ms)" },
{ value: "storageUsed", label: "Armazenamento Usado (%)" },
const operatorOptions = [ { value: "usuariosOnline", label: "Usuários Online" },
{ value: ">", label: "Maior que (>)" }, { value: "mensagensPorMinuto", label: "Mensagens por Minuto" },
{ value: ">=", label: "Maior ou igual (≥)" }, { value: "tempoRespostaMedio", label: "Tempo de Resposta (ms)" },
{ value: "<", label: "Menor que (<)" }, { value: "errosCount", label: "Contagem de Erros" },
{ value: "<=", label: "Menor ou igual (≤)" }, ];
{ value: "==", label: "Igual a (=)" },
]; const operatorOptions = [
{ value: ">", label: "Maior que (>)" },
function resetForm() { { value: ">=", label: "Maior ou igual (≥)" },
editingAlertId = null; { value: "<", label: "Menor que (<)" },
metricName = "cpuUsage"; { value: "<=", label: "Menor ou igual (≤)" },
threshold = 80; { value: "==", label: "Igual a (=)" },
operator = ">"; ];
enabled = true;
notifyByEmail = false; function resetForm() {
notifyByChat = true; editingAlertId = null;
showForm = false; metricName = "cpuUsage";
} threshold = 80;
operator = ">";
function editAlert(alert: any) { enabled = true;
editingAlertId = alert._id; notifyByEmail = false;
metricName = alert.metricName; notifyByChat = true;
threshold = alert.threshold; showForm = false;
operator = alert.operator; }
enabled = alert.enabled;
notifyByEmail = alert.notifyByEmail; function editAlert(alert: any) {
notifyByChat = alert.notifyByChat; editingAlertId = alert._id;
showForm = true; metricName = alert.metricName;
} threshold = alert.threshold;
operator = alert.operator;
async function saveAlert() { enabled = alert.enabled;
saving = true; notifyByEmail = alert.notifyByEmail;
try { notifyByChat = alert.notifyByChat;
await client.mutation(api.monitoramento.configurarAlerta, { showForm = true;
alertId: editingAlertId || undefined, }
metricName,
threshold, async function saveAlert() {
operator, saving = true;
enabled, try {
notifyByEmail, await client.mutation(api.monitoramento.configurarAlerta, {
notifyByChat, alertId: editingAlertId || undefined,
}); metricName,
threshold,
resetForm(); operator,
} catch (error) { enabled,
console.error("Erro ao salvar alerta:", error); notifyByEmail,
alert("Erro ao salvar alerta. Tente novamente."); notifyByChat,
} finally { });
saving = false;
} resetForm();
} } catch (error) {
console.error("Erro ao salvar alerta:", error);
async function deleteAlert(alertId: Id<"alertConfigurations">) { alert("Erro ao salvar alerta. Tente novamente.");
if (!confirm("Tem certeza que deseja deletar este alerta?")) return; } finally {
saving = false;
try { }
await client.mutation(api.monitoramento.deletarAlerta, { alertId }); }
} catch (error) {
console.error("Erro ao deletar alerta:", error); async function deleteAlert(alertId: Id<"alertConfigurations">) {
alert("Erro ao deletar alerta. Tente novamente."); if (!confirm("Tem certeza que deseja deletar este alerta?")) return;
}
} try {
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
function getMetricLabel(metricName: string): string { } catch (error) {
return metricOptions.find(m => m.value === metricName)?.label || metricName; console.error("Erro ao deletar alerta:", error);
} alert("Erro ao deletar alerta. Tente novamente.");
}
function getOperatorLabel(op: string): string { }
return operatorOptions.find(o => o.value === op)?.label || op;
} function getMetricLabel(metricName: string): string {
</script> return metricOptions.find(m => m.value === metricName)?.label || metricName;
}
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl bg-gradient-to-br from-base-100 to-base-200"> function getOperatorLabel(op: string): string {
<button return operatorOptions.find(o => o.value === op)?.label || op;
type="button" }
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" </script>
onclick={onClose}
> <dialog class="modal modal-open">
<div class="modal-box max-w-4xl bg-gradient-to-br from-base-100 to-base-200">
</button> <button
type="button"
<h3 class="font-bold text-3xl text-primary mb-2">⚙️ Configuração de Alertas</h3> class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
<p class="text-base-content/60 mb-6">Configure alertas personalizados para monitoramento do sistema</p> onclick={onClose}
>
<!-- Botão Novo Alerta -->
{#if !showForm} </button>
<button
type="button" <h3 class="font-bold text-3xl text-primary mb-2">⚙️ Configuração de Alertas</h3>
class="btn btn-primary mb-6" <p class="text-base-content/60 mb-6">Configure alertas personalizados para monitoramento do sistema</p>
onclick={() => showForm = true}
> <!-- Botão Novo Alerta -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {#if !showForm}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> <button
</svg> type="button"
Novo Alerta class="btn btn-primary mb-6"
</button> onclick={() => showForm = true}
{/if} >
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<!-- Formulário de Alerta --> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
{#if showForm} </svg>
<div class="card bg-base-100 shadow-xl mb-6 border-2 border-primary/20"> Novo Alerta
<div class="card-body"> </button>
<h4 class="card-title text-xl"> {/if}
{editingAlertId ? "Editar Alerta" : "Novo Alerta"}
</h4> <!-- Formulário de Alerta -->
{#if showForm}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4"> <div class="card bg-base-100 shadow-xl mb-6 border-2 border-primary/20">
<!-- Métrica --> <div class="card-body">
<div class="form-control"> <h4 class="card-title text-xl">
<label class="label" for="metric"> {editingAlertId ? "Editar Alerta" : "Novo Alerta"}
<span class="label-text font-semibold">Métrica</span> </h4>
</label>
<select <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
id="metric" <!-- Métrica -->
class="select select-bordered select-primary" <div class="form-control">
bind:value={metricName} <label class="label" for="metric">
> <span class="label-text font-semibold">Métrica</span>
{#each metricOptions as option} </label>
<option value={option.value}>{option.label}</option> <select
{/each} id="metric"
</select> class="select select-bordered select-primary"
</div> bind:value={metricName}
>
<!-- Operador --> {#each metricOptions as option}
<div class="form-control"> <option value={option.value}>{option.label}</option>
<label class="label" for="operator"> {/each}
<span class="label-text font-semibold">Condição</span> </select>
</label> </div>
<select
id="operator" <!-- Operador -->
class="select select-bordered select-primary" <div class="form-control">
bind:value={operator} <label class="label" for="operator">
> <span class="label-text font-semibold">Condição</span>
{#each operatorOptions as option} </label>
<option value={option.value}>{option.label}</option> <select
{/each} id="operator"
</select> class="select select-bordered select-primary"
</div> bind:value={operator}
>
<!-- Threshold --> {#each operatorOptions as option}
<div class="form-control"> <option value={option.value}>{option.label}</option>
<label class="label" for="threshold"> {/each}
<span class="label-text font-semibold">Valor Limite</span> </select>
</label> </div>
<input
id="threshold" <!-- Threshold -->
type="number" <div class="form-control">
class="input input-bordered input-primary" <label class="label" for="threshold">
bind:value={threshold} <span class="label-text font-semibold">Valor Limite</span>
min="0" </label>
step="1" <input
/> id="threshold"
</div> type="number"
class="input input-bordered input-primary"
<!-- Ativo --> bind:value={threshold}
<div class="form-control"> min="0"
<label class="label cursor-pointer justify-start gap-4"> step="1"
<span class="label-text font-semibold">Alerta Ativo</span> />
<input </div>
type="checkbox"
class="toggle toggle-primary" <!-- Ativo -->
bind:checked={enabled} <div class="form-control">
/> <label class="label cursor-pointer justify-start gap-4">
</label> <span class="label-text font-semibold">Alerta Ativo</span>
</div> <input
</div> type="checkbox"
class="toggle toggle-primary"
<!-- Notificações --> bind:checked={enabled}
<div class="divider">Método de Notificação</div> />
<div class="flex gap-6"> </label>
<label class="label cursor-pointer gap-3"> </div>
<input </div>
type="checkbox"
class="checkbox checkbox-primary" <!-- Notificações -->
bind:checked={notifyByChat} <div class="divider">Método de Notificação</div>
/> <div class="flex gap-6">
<span class="label-text"> <label class="label cursor-pointer gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <input
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /> type="checkbox"
</svg> class="checkbox checkbox-primary"
Notificar por Chat bind:checked={notifyByChat}
</span> />
</label> <span class="label-text">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<label class="label cursor-pointer gap-3"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
<input </svg>
type="checkbox" Notificar por Chat
class="checkbox checkbox-secondary" </span>
bind:checked={notifyByEmail} </label>
/>
<span class="label-text"> <label class="label cursor-pointer gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <input
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> type="checkbox"
</svg> class="checkbox checkbox-secondary"
Notificar por E-mail bind:checked={notifyByEmail}
</span> />
</label> <span class="label-text">
</div> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
<!-- Preview --> </svg>
<div class="alert alert-info mt-4"> Notificar por E-mail
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"> </span>
<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> </label>
</svg> </div>
<div>
<h4 class="font-bold">Preview do Alerta:</h4> <!-- Preview -->
<p class="text-sm"> <div class="alert alert-info mt-4">
Alertar quando <strong>{getMetricLabel(metricName)}</strong> for <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<strong>{getOperatorLabel(operator)}</strong> a <strong>{threshold}</strong> <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>
</p> </svg>
</div> <div>
</div> <h4 class="font-bold">Preview do Alerta:</h4>
<p class="text-sm">
<!-- Botões --> Alertar quando <strong>{getMetricLabel(metricName)}</strong> for
<div class="card-actions justify-end mt-4"> <strong>{getOperatorLabel(operator)}</strong> a <strong>{threshold}</strong>
<button </p>
type="button" </div>
class="btn btn-ghost" </div>
onclick={resetForm}
disabled={saving} <!-- Botões -->
> <div class="card-actions justify-end mt-4">
Cancelar <button
</button> type="button"
<button class="btn btn-ghost"
type="button" onclick={resetForm}
class="btn btn-primary" disabled={saving}
onclick={saveAlert} >
disabled={saving || (!notifyByChat && !notifyByEmail)} Cancelar
> </button>
{#if saving} <button
<span class="loading loading-spinner"></span> type="button"
Salvando... class="btn btn-primary"
{:else} onclick={saveAlert}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> disabled={saving || (!notifyByChat && !notifyByEmail)}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> >
</svg> {#if saving}
Salvar Alerta <span class="loading loading-spinner"></span>
{/if} Salvando...
</button> {:else}
</div> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</div> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</div> </svg>
{/if} Salvar Alerta
{/if}
<!-- Lista de Alertas --> </button>
<div class="divider">Alertas Configurados</div> </div>
</div>
{#if alertas && alertas.length > 0} </div>
<div class="overflow-x-auto"> {/if}
<table class="table table-zebra">
<thead> <!-- Lista de Alertas -->
<tr> <div class="divider">Alertas Configurados</div>
<th>Métrica</th>
<th>Condição</th> {#if alertas.length > 0}
<th>Status</th> <div class="overflow-x-auto">
<th>Notificações</th> <table class="table table-zebra">
<th>Ações</th> <thead>
</tr> <tr>
</thead> <th>Métrica</th>
<tbody> <th>Condição</th>
{#each alertas as alerta} <th>Status</th>
<tr class={!alerta.enabled ? "opacity-50" : ""}> <th>Notificações</th>
<td> <th>Ações</th>
<div class="font-semibold">{getMetricLabel(alerta.metricName)}</div> </tr>
</td> </thead>
<td> <tbody>
<div class="badge badge-outline"> {#each alertas as alerta}
{getOperatorLabel(alerta.operator)} {alerta.threshold} <tr class={!alerta.enabled ? "opacity-50" : ""}>
</div> <td>
</td> <div class="font-semibold">{getMetricLabel(alerta.metricName)}</div>
<td> </td>
{#if alerta.enabled} <td>
<div class="badge badge-success gap-2"> <div class="badge badge-outline">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {getOperatorLabel(alerta.operator)} {alerta.threshold}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> </div>
</svg> </td>
Ativo <td>
</div> {#if alerta.enabled}
{:else} <div class="badge badge-success gap-2">
<div class="badge badge-ghost gap-2"> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<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>
</svg> Ativo
Inativo </div>
</div> {:else}
{/if} <div class="badge badge-ghost gap-2">
</td> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<td> <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" />
<div class="flex gap-1"> </svg>
{#if alerta.notifyByChat} Inativo
<div class="badge badge-primary badge-sm">Chat</div> </div>
{/if} {/if}
{#if alerta.notifyByEmail} </td>
<div class="badge badge-secondary badge-sm">Email</div> <td>
{/if} <div class="flex gap-1">
</div> {#if alerta.notifyByChat}
</td> <div class="badge badge-primary badge-sm">Chat</div>
<td> {/if}
<div class="flex gap-2"> {#if alerta.notifyByEmail}
<button <div class="badge badge-secondary badge-sm">Email</div>
type="button" {/if}
class="btn btn-ghost btn-xs" </div>
onclick={() => editAlert(alerta)} </td>
> <td>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div class="flex gap-2">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> <button
</svg> type="button"
</button> class="btn btn-ghost btn-xs"
<button onclick={() => editAlert(alerta)}
type="button" >
class="btn btn-ghost btn-xs text-error" <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
onclick={() => deleteAlert(alerta._id)} <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
> </svg>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </button>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> <button
</svg> type="button"
</button> class="btn btn-ghost btn-xs text-error"
</div> onclick={() => deleteAlert(alerta._id)}
</td> >
</tr> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{/each} <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</tbody> </svg>
</table> </button>
</div> </div>
{:else} </td>
<div class="alert"> </tr>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info shrink-0 w-6 h-6"> {/each}
<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> </tbody>
</svg> </table>
<span>Nenhum alerta configurado. Clique em "Novo Alerta" para criar um.</span> </div>
</div> {:else}
{/if} <div class="alert">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info shrink-0 w-6 h-6">
<div class="modal-action"> <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>
<button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button> </svg>
</div> <span>Nenhum alerta configurado. Clique em "Novo Alerta" para criar um.</span>
</div> </div>
<!-- svelte-ignore a11y_click_events_have_key_events --> {/if}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form method="dialog" class="modal-backdrop" onclick={onClose}> <div class="modal-action">
<button type="button">close</button> <button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button>
</form> </div>
</dialog> </div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form method="dialog" class="modal-backdrop" onclick={onClose}>
<button type="button">close</button>
</form>
</dialog>

View File

@@ -6,6 +6,7 @@
import FuncionarioSelect from "$lib/components/FuncionarioSelect.svelte"; import FuncionarioSelect from "$lib/components/FuncionarioSelect.svelte";
import FileUpload from "$lib/components/FileUpload.svelte"; import FileUpload from "$lib/components/FileUpload.svelte";
import ErrorModal from "$lib/components/ErrorModal.svelte"; import ErrorModal from "$lib/components/ErrorModal.svelte";
import CalendarioAfastamentos from "$lib/components/CalendarioAfastamentos.svelte";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
const client = useConvexClient(); const client = useConvexClient();
@@ -767,31 +768,10 @@
</div> </div>
</div> </div>
<!-- Calendário Simplificado --> <!-- Calendário Interativo -->
<div class="card bg-base-100 shadow-xl mb-6"> {#if eventosQuery?.data}
<div class="card-body"> <CalendarioAfastamentos eventos={eventosQuery.data} tipoFiltro={filtroTipo} />
<h2 class="card-title mb-4">Calendário de Afastamentos</h2> {/if}
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<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>
<span
>O calendário interativo completo será implementado em breve. Por
enquanto, visualize os eventos na tabela abaixo.</span
>
</div>
</div>
</div>
<!-- Gráficos --> <!-- Gráficos -->
{#if graficosQuery?.data} {#if graficosQuery?.data}