feat: implement absence management features in the dashboard

- Added functionality for managing absence requests, including listing, approving, and rejecting requests.
- Enhanced the user interface to display statistics and pending requests for better oversight.
- Updated backend schema to support absence requests and notifications, ensuring data integrity and efficient handling.
- Integrated new components for absence request forms and approval workflows, improving user experience and administrative efficiency.
This commit is contained in:
2025-11-04 14:23:46 -03:00
parent f02eb473ca
commit a93d55f02b
13 changed files with 3837 additions and 497 deletions

View File

@@ -0,0 +1,487 @@
<script lang="ts">
import { onMount } from "svelte";
import { Calendar } from "@fullcalendar/core";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import multiMonthPlugin from "@fullcalendar/multimonth";
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
interface Props {
dataInicio?: string;
dataFim?: string;
ausenciasExistentes?: Array<{
dataInicio: string;
dataFim: string;
status: "aguardando_aprovacao" | "aprovado" | "reprovado";
}>;
onPeriodoSelecionado?: (periodo: { dataInicio: string; dataFim: string }) => void;
modoVisualizacao?: "month" | "multiMonth";
readonly?: boolean;
}
let {
dataInicio,
dataFim,
ausenciasExistentes = [],
onPeriodoSelecionado,
modoVisualizacao = "month",
readonly = false,
}: Props = $props();
let calendarEl: HTMLDivElement;
let calendar: Calendar | null = null;
let selecionando = $state(false); // Flag para evitar atualizações durante seleção
let eventos: Array<{
id: string;
title: string;
start: string;
end: string;
backgroundColor: string;
borderColor: string;
textColor: string;
extendedProps: {
status: string;
};
}> = $state([]);
// Cores por status
const coresStatus: Record<string, { bg: string; border: string; text: string }> = {
aguardando_aprovacao: { bg: "#f59e0b", border: "#d97706", text: "#ffffff" }, // Laranja
aprovado: { bg: "#10b981", border: "#059669", text: "#ffffff" }, // Verde
reprovado: { bg: "#ef4444", border: "#dc2626", text: "#ffffff" }, // Vermelho
};
// Converter ausências existentes em eventos
function atualizarEventos() {
const novosEventos: Array<{
id: string;
title: string;
start: string;
end: string;
backgroundColor: string;
borderColor: string;
textColor: string;
extendedProps: {
status: string;
};
}> = ausenciasExistentes.map((ausencia, index) => {
const cor = coresStatus[ausencia.status] || coresStatus.aguardando_aprovacao;
return {
id: `ausencia-${index}`,
title: `${getStatusTexto(ausencia.status)} - ${calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias`,
start: ausencia.dataInicio,
end: calcularDataFim(ausencia.dataFim),
backgroundColor: cor.bg,
borderColor: cor.border,
textColor: cor.text,
extendedProps: {
status: ausencia.status,
},
};
});
// Adicionar período selecionado atual se existir
if (dataInicio && dataFim) {
novosEventos.push({
id: "periodo-selecionado",
title: `Selecionado - ${calcularDias(dataInicio, dataFim)} dias`,
start: dataInicio,
end: calcularDataFim(dataFim),
backgroundColor: "#667eea",
borderColor: "#5568d3",
textColor: "#ffffff",
extendedProps: {
status: "selecionado",
},
});
}
eventos = novosEventos;
}
function getStatusTexto(status: string): string {
const textos: Record<string, string> = {
aguardando_aprovacao: "Aguardando",
aprovado: "Aprovado",
reprovado: "Reprovado",
};
return textos[status] || status;
}
// Helper: Adicionar 1 dia à data fim (FullCalendar usa exclusive end)
function calcularDataFim(dataFim: string): string {
const data = new Date(dataFim);
data.setDate(data.getDate() + 1);
return data.toISOString().split("T")[0];
}
// Helper: Calcular dias entre datas (inclusivo)
function calcularDias(inicio: string, fim: string): number {
const dInicio = new Date(inicio);
const dFim = new Date(fim);
const diffTime = Math.abs(dFim.getTime() - dInicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return diffDays;
}
// Atualizar eventos quando mudanças ocorrem (evitar loop infinito)
$effect(() => {
if (!calendar || selecionando) return; // Não atualizar durante seleção
atualizarEventos();
// Usar requestAnimationFrame para evitar múltiplas atualizações durante seleção
requestAnimationFrame(() => {
if (calendar && !selecionando) {
calendar.removeAllEvents();
calendar.addEventSource(eventos);
}
});
});
onMount(() => {
if (!calendarEl) return;
atualizarEventos();
calendar = new Calendar(calendarEl, {
plugins: [dayGridPlugin, interactionPlugin, multiMonthPlugin],
initialView: modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth",
locale: ptBrLocale,
headerToolbar: {
left: "prev,next today",
center: "title",
right: modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth",
},
height: "auto",
selectable: !readonly,
selectMirror: true,
unselectAuto: false,
events: eventos,
// Estilo customizado
buttonText: {
today: "Hoje",
month: "Mês",
multiMonthYear: "Ano",
},
// Seleção de período
select: (info) => {
if (readonly) return;
selecionando = true; // Marcar que está selecionando
// Usar setTimeout para evitar conflito com atualizações de estado
setTimeout(() => {
const inicio = new Date(info.startStr);
const fim = new Date(info.endStr);
fim.setDate(fim.getDate() - 1); // FullCalendar usa exclusive end
// Validar que não é no passado
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
if (inicio < hoje) {
alert("A data de início não pode ser no passado");
calendar?.unselect();
selecionando = false;
return;
}
// Validar que fim >= início
if (fim < inicio) {
alert("A data de fim deve ser maior ou igual à data de início");
calendar?.unselect();
selecionando = false;
return;
}
// Chamar callback de forma assíncrona para evitar loop
if (onPeriodoSelecionado) {
onPeriodoSelecionado({
dataInicio: info.startStr,
dataFim: fim.toISOString().split("T")[0],
});
}
calendar?.unselect();
// Liberar flag após um pequeno delay para garantir que o estado foi atualizado
setTimeout(() => {
selecionando = false;
}, 100);
}, 0);
},
// Click em evento para visualizar detalhes (readonly)
eventClick: (info) => {
if (readonly) {
const status = info.event.extendedProps.status;
const texto = getStatusTexto(status);
alert(`Ausência ${texto}\nPeríodo: ${new Date(info.event.startStr).toLocaleDateString("pt-BR")} até ${new Date(calcularDataFim(info.event.endStr)).toLocaleDateString("pt-BR")}`);
}
},
// Tooltip ao passar mouse
eventDidMount: (info) => {
const status = info.event.extendedProps.status;
if (status === "selecionado") {
info.el.title = `Período selecionado\n${info.event.title}`;
} else {
info.el.title = `${info.event.title}`;
}
info.el.style.cursor = readonly ? "default" : "pointer";
},
// Desabilitar datas passadas
selectAllow: (selectInfo) => {
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
return new Date(selectInfo.start) >= hoje;
},
// Highlight de fim de semana
dayCellClassNames: (arg) => {
if (arg.date.getDay() === 0 || arg.date.getDay() === 6) {
return ["fc-day-weekend-custom"];
}
return [];
},
});
calendar.render();
return () => {
calendar?.destroy();
};
});
</script>
<div class="calendario-ausencias-wrapper">
<!-- Header com instruções -->
{#if !readonly}
<div class="alert alert-info mb-4 shadow-lg">
<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>
<div class="text-sm">
<p class="font-bold">Como usar:</p>
<ul class="list-disc list-inside mt-1">
<li>Clique e arraste no calendário para selecionar o período de ausência</li>
<li>Você pode visualizar suas ausências já solicitadas no calendário</li>
<li>A data de início não pode ser no passado</li>
</ul>
</div>
</div>
{/if}
<!-- Calendário -->
<div
bind:this={calendarEl}
class="calendario-ausencias shadow-2xl rounded-2xl overflow-hidden border-2 border-orange-500/10"
></div>
<!-- Legenda de status -->
{#if ausenciasExistentes.length > 0 || readonly}
<div class="mt-6 flex flex-wrap gap-4 justify-center">
<div class="badge badge-lg gap-2" style="background-color: #f59e0b; border-color: #d97706; color: white;">
<div class="w-3 h-3 rounded-full bg-white"></div>
Aguardando Aprovação
</div>
<div class="badge badge-lg gap-2" style="background-color: #10b981; border-color: #059669; color: white;">
<div class="w-3 h-3 rounded-full bg-white"></div>
Aprovado
</div>
<div class="badge badge-lg gap-2" style="background-color: #ef4444; border-color: #dc2626; color: white;">
<div class="w-3 h-3 rounded-full bg-white"></div>
Reprovado
</div>
</div>
{/if}
<!-- Informação do período selecionado -->
{#if dataInicio && dataFim && !readonly}
<div class="mt-6 card bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 shadow-lg border-2 border-orange-500/30">
<div class="card-body">
<h3 class="card-title text-orange-700 dark:text-orange-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
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>
Período Selecionado
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
<div>
<p class="text-sm text-base-content/70">Data Início</p>
<p class="font-bold text-lg">{new Date(dataInicio).toLocaleDateString("pt-BR")}</p>
</div>
<div>
<p class="text-sm text-base-content/70">Data Fim</p>
<p class="font-bold text-lg">{new Date(dataFim).toLocaleDateString("pt-BR")}</p>
</div>
<div>
<p class="text-sm text-base-content/70">Total de Dias</p>
<p class="font-bold text-2xl text-orange-600 dark:text-orange-400">{calcularDias(dataInicio, dataFim)} dias</p>
</div>
</div>
</div>
</div>
{/if}
</div>
<style>
/* Calendário Premium */
.calendario-ausencias {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
/* Toolbar moderna com cores laranja/amarelo */
:global(.calendario-ausencias .fc .fc-toolbar) {
background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
padding: 1rem;
border-radius: 1rem 1rem 0 0;
color: white !important;
}
:global(.calendario-ausencias .fc .fc-toolbar-title) {
color: white !important;
font-weight: 700;
font-size: 1.5rem;
}
:global(.calendario-ausencias .fc .fc-button) {
background: rgba(255, 255, 255, 0.2) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
color: white !important;
font-weight: 600;
text-transform: capitalize;
transition: all 0.3s ease;
}
:global(.calendario-ausencias .fc .fc-button:hover) {
background: rgba(255, 255, 255, 0.3) !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
:global(.calendario-ausencias .fc .fc-button-active) {
background: rgba(255, 255, 255, 0.4) !important;
}
/* Cabeçalho dos dias */
:global(.calendario-ausencias .fc .fc-col-header-cell) {
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
padding: 0.75rem 0.5rem;
color: #495057;
}
/* Células dos dias */
:global(.calendario-ausencias .fc .fc-daygrid-day) {
transition: all 0.2s ease;
}
:global(.calendario-ausencias .fc .fc-daygrid-day:hover) {
background: rgba(245, 158, 11, 0.05);
}
:global(.calendario-ausencias .fc .fc-daygrid-day-number) {
padding: 0.5rem;
font-weight: 600;
color: #495057;
}
/* Fim de semana */
:global(.calendario-ausencias .fc .fc-day-weekend-custom) {
background: rgba(255, 193, 7, 0.05);
}
/* Hoje */
:global(.calendario-ausencias .fc .fc-day-today) {
background: rgba(245, 158, 11, 0.1) !important;
border: 2px solid #f59e0b !important;
}
/* Eventos (ausências) */
:global(.calendario-ausencias .fc .fc-event) {
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
font-weight: 600;
font-size: 0.875rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
cursor: pointer;
}
:global(.calendario-ausencias .fc .fc-event:hover) {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
/* Seleção (arrastar) */
:global(.calendario-ausencias .fc .fc-highlight) {
background: rgba(245, 158, 11, 0.3) !important;
border: 2px dashed #f59e0b;
}
/* Datas desabilitadas (passado) */
:global(.calendario-ausencias .fc .fc-day-past .fc-daygrid-day-number) {
opacity: 0.4;
}
/* Remover bordas padrão */
:global(.calendario-ausencias .fc .fc-scrollgrid) {
border: none !important;
}
:global(.calendario-ausencias .fc .fc-scrollgrid-section > td) {
border: none !important;
}
/* Grid moderno */
:global(.calendario-ausencias .fc .fc-daygrid-day-frame) {
border: 1px solid #e9ecef;
min-height: 80px;
}
/* Responsivo */
@media (max-width: 768px) {
:global(.calendario-ausencias .fc .fc-toolbar) {
flex-direction: column;
gap: 0.75rem;
}
:global(.calendario-ausencias .fc .fc-toolbar-title) {
font-size: 1.25rem;
}
:global(.calendario-ausencias .fc .fc-button) {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
}
</style>