feat: enhance absence management with calendar integration and error handling

- Added functionality to check for date overlaps with existing absence requests in the absence calendar.
- Implemented a modal to display error messages when users attempt to create overlapping absence requests.
- Updated the calendar component to visually indicate blocked days due to existing approved or pending absence requests.
- Improved user feedback by providing alerts for unavailable periods and enhancing the overall user experience in absence management.
This commit is contained in:
2025-11-04 15:09:15 -03:00
parent a93d55f02b
commit c1e0998a5f
4 changed files with 552 additions and 15 deletions

View File

@@ -124,10 +124,223 @@
return diffDays;
}
// Helper: Verificar se há sobreposição de datas
function verificarSobreposicao(
inicio1: Date,
fim1: Date,
inicio2: string,
fim2: string
): boolean {
const d2Inicio = new Date(inicio2);
const d2Fim = new Date(fim2);
// Verificar sobreposição: início1 <= fim2 && início2 <= fim1
return inicio1 <= d2Fim && d2Inicio <= fim1;
}
// Helper: Verificar se período selecionado sobrepõe com ausências existentes
function verificarSobreposicaoComAusencias(inicio: Date, fim: Date): boolean {
if (!ausenciasExistentes || ausenciasExistentes.length === 0) return false;
// Verificar apenas ausências aprovadas ou aguardando aprovação
const ausenciasBloqueantes = ausenciasExistentes.filter(
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao"
);
return ausenciasBloqueantes.some((ausencia) =>
verificarSobreposicao(inicio, fim, ausencia.dataInicio, ausencia.dataFim)
);
}
// Helper: Atualizar classe de seleção em uma célula
function atualizarClasseSelecionado(info: any) {
if (dataInicio && dataFim && !readonly) {
const cellDate = new Date(info.date);
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
cellDate.setHours(0, 0, 0, 0);
inicio.setHours(0, 0, 0, 0);
fim.setHours(0, 0, 0, 0);
if (cellDate >= inicio && cellDate <= fim) {
info.el.classList.add("fc-day-selected");
} else {
info.el.classList.remove("fc-day-selected");
}
} else {
info.el.classList.remove("fc-day-selected");
}
}
// Helper: Atualizar classe de bloqueio para dias com ausências existentes
function atualizarClasseBloqueado(info: any) {
if (readonly || !ausenciasExistentes || ausenciasExistentes.length === 0) {
info.el.classList.remove("fc-day-blocked");
return;
}
const cellDate = new Date(info.date);
cellDate.setHours(0, 0, 0, 0);
// Verificar se a data está dentro de alguma ausência aprovada ou aguardando aprovação
const estaBloqueado = ausenciasExistentes
.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao")
.some((ausencia) => {
const inicio = new Date(ausencia.dataInicio);
const fim = new Date(ausencia.dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(0, 0, 0, 0);
return cellDate >= inicio && cellDate <= fim;
});
if (estaBloqueado) {
info.el.classList.add("fc-day-blocked");
} else {
info.el.classList.remove("fc-day-blocked");
}
}
// Helper: Atualizar todos os dias selecionados no calendário
function atualizarDiasSelecionados() {
if (!calendar || !calendarEl || !dataInicio || !dataFim || readonly) return;
// Usar a API do FullCalendar para iterar sobre todas as células visíveis
const view = calendar.view;
if (!view) return;
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(0, 0, 0, 0);
// O FullCalendar renderiza as células, então podemos usar dayCellDidMount
// Mas também precisamos atualizar células existentes
const cells = calendarEl.querySelectorAll(".fc-daygrid-day");
cells.forEach((cell) => {
// Remover classe primeiro
cell.classList.remove("fc-day-selected");
// Tentar obter a data do aria-label ou do elemento
const ariaLabel = cell.getAttribute("aria-label");
if (ariaLabel) {
// Formato: "dia mês ano" ou similar
try {
const cellDate = new Date(ariaLabel);
if (!isNaN(cellDate.getTime())) {
cellDate.setHours(0, 0, 0, 0);
if (cellDate >= inicio && cellDate <= fim) {
cell.classList.add("fc-day-selected");
}
}
} catch (e) {
// Ignorar erros de parsing
}
}
});
}
// Helper: Atualizar todos os dias bloqueados no calendário
function atualizarDiasBloqueados() {
if (!calendar || !calendarEl || readonly || !ausenciasExistentes || ausenciasExistentes.length === 0) {
// Remover classes de bloqueio se não houver ausências
if (calendarEl) {
const cells = calendarEl.querySelectorAll(".fc-daygrid-day");
cells.forEach((cell) => cell.classList.remove("fc-day-blocked"));
}
return;
}
const cells = calendarEl.querySelectorAll(".fc-daygrid-day");
const ausenciasBloqueantes = ausenciasExistentes.filter(
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao"
);
if (ausenciasBloqueantes.length === 0) {
cells.forEach((cell) => cell.classList.remove("fc-day-blocked"));
return;
}
cells.forEach((cell) => {
cell.classList.remove("fc-day-blocked");
// Tentar obter a data de diferentes formas
let cellDate: Date | null = null;
// Método 1: aria-label
const ariaLabel = cell.getAttribute("aria-label");
if (ariaLabel) {
try {
const parsed = new Date(ariaLabel);
if (!isNaN(parsed.getTime())) {
cellDate = parsed;
}
} catch (e) {
// Ignorar
}
}
// Método 2: data-date attribute
if (!cellDate) {
const dataDate = cell.getAttribute("data-date");
if (dataDate) {
try {
const parsed = new Date(dataDate);
if (!isNaN(parsed.getTime())) {
cellDate = parsed;
}
} catch (e) {
// Ignorar
}
}
}
// Método 3: Tentar obter do número do dia e contexto do calendário
if (!cellDate && calendar.view) {
const dayNumberEl = cell.querySelector(".fc-daygrid-day-number");
if (dayNumberEl) {
const dayNumber = parseInt(dayNumberEl.textContent || "0");
if (dayNumber > 0 && dayNumber <= 31) {
// Usar a data da view atual e o número do dia
const viewStart = new Date(calendar.view.activeStart);
const cellIndex = Array.from(cells).indexOf(cell);
if (cellIndex >= 0) {
const possibleDate = new Date(viewStart);
possibleDate.setDate(viewStart.getDate() + cellIndex);
// Verificar se o número do dia corresponde
if (possibleDate.getDate() === dayNumber) {
cellDate = possibleDate;
}
}
}
}
}
if (cellDate) {
cellDate.setHours(0, 0, 0, 0);
const estaBloqueado = ausenciasBloqueantes.some((ausencia) => {
const inicio = new Date(ausencia.dataInicio);
const fim = new Date(ausencia.dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(0, 0, 0, 0);
return cellDate >= inicio && cellDate <= fim;
});
if (estaBloqueado) {
cell.classList.add("fc-day-blocked");
}
}
});
}
// Atualizar eventos quando mudanças ocorrem (evitar loop infinito)
$effect(() => {
if (!calendar || selecionando) return; // Não atualizar durante seleção
// Garantir que temos as ausências antes de atualizar
const ausencias = ausenciasExistentes;
atualizarEventos();
// Usar requestAnimationFrame para evitar múltiplas atualizações durante seleção
@@ -135,9 +348,36 @@
if (calendar && !selecionando) {
calendar.removeAllEvents();
calendar.addEventSource(eventos);
// Atualizar classes de seleção e bloqueio quando as datas mudarem
setTimeout(() => {
atualizarDiasSelecionados();
atualizarDiasBloqueados();
}, 150);
}
});
});
// Efeito separado para atualizar quando ausências mudarem
$effect(() => {
if (!calendar || readonly) return;
const ausencias = ausenciasExistentes;
const ausenciasBloqueantes = ausencias?.filter(
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao"
) || [];
// Se houver ausências bloqueantes, forçar atualização
if (ausenciasBloqueantes.length > 0) {
setTimeout(() => {
if (calendar && calendarEl) {
atualizarDiasBloqueados();
// Forçar re-render para aplicar classes via dayCellClassNames
calendar.render();
}
}, 200);
}
});
onMount(() => {
if (!calendarEl) return;
@@ -157,6 +397,11 @@
selectable: !readonly,
selectMirror: true,
unselectAuto: false,
selectOverlap: false,
selectConstraint: null, // Permite seleção entre meses diferentes
validRange: {
start: new Date().toISOString().split("T")[0], // Não permite selecionar datas passadas
},
events: eventos,
// Estilo customizado
@@ -196,6 +441,14 @@
return;
}
// Validar sobreposição com ausências existentes
if (verificarSobreposicaoComAusencias(inicio, fim)) {
alert("Este período sobrepõe com uma ausência já aprovada ou aguardando aprovação. Por favor, escolha outro período.");
calendar?.unselect();
selecionando = false;
return;
}
// Chamar callback de forma assíncrona para evitar loop
if (onPeriodoSelecionado) {
onPeriodoSelecionado({
@@ -204,7 +457,8 @@
});
}
calendar?.unselect();
// Não remover seleção imediatamente para manter visualização
// calendar?.unselect();
// Liberar flag após um pequeno delay para garantir que o estado foi atualizado
setTimeout(() => {
@@ -233,19 +487,88 @@
info.el.style.cursor = readonly ? "default" : "pointer";
},
// Desabilitar datas passadas
// Desabilitar datas passadas e períodos que sobrepõem com ausências existentes
selectAllow: (selectInfo) => {
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
return new Date(selectInfo.start) >= hoje;
// Bloquear datas passadas
if (new Date(selectInfo.start) < hoje) {
return false;
}
// Verificar sobreposição com ausências existentes
if (!readonly && ausenciasExistentes && ausenciasExistentes.length > 0) {
const inicioSelecao = new Date(selectInfo.start);
const fimSelecao = new Date(selectInfo.end);
fimSelecao.setDate(fimSelecao.getDate() - 1); // FullCalendar usa exclusive end
inicioSelecao.setHours(0, 0, 0, 0);
fimSelecao.setHours(0, 0, 0, 0);
if (verificarSobreposicaoComAusencias(inicioSelecao, fimSelecao)) {
return false;
}
}
return true;
},
// Adicionar classe CSS aos dias selecionados e bloqueados
dayCellDidMount: (info) => {
atualizarClasseSelecionado(info);
atualizarClasseBloqueado(info);
},
// Atualizar quando as datas mudarem (navegação do calendário)
datesSet: () => {
setTimeout(() => {
atualizarDiasSelecionados();
atualizarDiasBloqueados();
}, 100);
},
// Garantir que as classes sejam aplicadas após renderização inicial
viewDidMount: () => {
setTimeout(() => {
if (calendar && calendarEl) {
atualizarDiasSelecionados();
atualizarDiasBloqueados();
}
}, 100);
},
// Highlight de fim de semana
// Highlight de fim de semana e aplicar classe de bloqueio
dayCellClassNames: (arg) => {
const classes: string[] = [];
if (arg.date.getDay() === 0 || arg.date.getDay() === 6) {
return ["fc-day-weekend-custom"];
classes.push("fc-day-weekend-custom");
}
return [];
// Verificar se o dia está bloqueado
if (!readonly && ausenciasExistentes && ausenciasExistentes.length > 0) {
const cellDate = new Date(arg.date);
cellDate.setHours(0, 0, 0, 0);
const ausenciasBloqueantes = ausenciasExistentes.filter(
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao"
);
const estaBloqueado = ausenciasBloqueantes.some((ausencia) => {
const inicio = new Date(ausencia.dataInicio);
const fim = new Date(ausencia.dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(0, 0, 0, 0);
return cellDate >= inicio && cellDate <= fim;
});
if (estaBloqueado) {
classes.push("fc-day-blocked");
}
}
return classes;
},
});
@@ -260,7 +583,8 @@
<div class="calendario-ausencias-wrapper">
<!-- Header com instruções -->
{#if !readonly}
<div class="alert alert-info mb-4 shadow-lg">
<div class="space-y-4 mb-4">
<div class="alert alert-info shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -282,6 +606,34 @@
<li>A data de início não pode ser no passado</li>
</ul>
</div>
</div>
<!-- Alerta sobre dias bloqueados -->
{#if ausenciasExistentes && ausenciasExistentes.filter(a => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
{@const ausenciasBloqueantes = ausenciasExistentes.filter(a => a.status === "aprovado" || a.status === "aguardando_aprovacao")}
<div class="alert alert-warning shadow-lg border-2 border-warning/50">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div class="flex-1">
<h3 class="font-bold">Atenção: Períodos Indisponíveis</h3>
<div class="text-sm mt-1">
<p>Os dias marcados em <span class="font-bold text-error">vermelho</span> estão bloqueados porque você já possui solicitações <strong>aprovadas</strong> ou <strong>aguardando aprovação</strong> para esses períodos.</p>
<p class="mt-2">Você não pode criar novas solicitações que sobreponham esses períodos. Escolha um período diferente.</p>
</div>
</div>
</div>
{/if}
</div>
{/if}
@@ -293,7 +645,8 @@
<!-- Legenda de status -->
{#if ausenciasExistentes.length > 0 || readonly}
<div class="mt-6 flex flex-wrap gap-4 justify-center">
<div class="mt-6 space-y-4">
<div class="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
@@ -306,6 +659,21 @@
<div class="w-3 h-3 rounded-full bg-white"></div>
Reprovado
</div>
{#if !readonly && ausenciasExistentes && ausenciasExistentes.filter(a => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
<div class="badge badge-lg gap-2" style="background-color: rgba(239, 68, 68, 0.2); border-color: #ef4444; color: #dc2626;">
<div class="w-3 h-3 rounded-full" style="background-color: #ef4444;"></div>
Dias Bloqueados (Indisponíveis)
</div>
{/if}
</div>
{#if !readonly && ausenciasExistentes && ausenciasExistentes.filter(a => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
<div class="text-center">
<p class="text-sm text-base-content/70">
<span class="font-semibold text-error">Dias bloqueados</span> não podem ser selecionados para novas solicitações
</p>
</div>
{/if}
</div>
{/if}
@@ -447,6 +815,71 @@
border: 2px dashed #f59e0b;
}
/* Dias selecionados (período confirmado) */
:global(.calendario-ausencias .fc .fc-day-selected) {
background: rgba(102, 126, 234, 0.2) !important;
border: 2px solid #667eea !important;
position: relative;
}
:global(.calendario-ausencias .fc .fc-day-selected .fc-daygrid-day-number) {
color: #667eea !important;
font-weight: 700 !important;
background: rgba(102, 126, 234, 0.1);
border-radius: 50%;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
/* Primeiro e último dia do período selecionado */
:global(.calendario-ausencias .fc .fc-day-selected:first-child),
:global(.calendario-ausencias .fc .fc-day-selected:last-child) {
background: rgba(102, 126, 234, 0.3) !important;
border-color: #667eea !important;
}
/* Dias bloqueados (com ausências aprovadas ou aguardando aprovação) */
:global(.calendario-ausencias .fc-daygrid-day.fc-day-blocked) {
background-color: rgba(239, 68, 68, 0.2) !important;
position: relative !important;
}
:global(.calendario-ausencias .fc-daygrid-day.fc-day-blocked .fc-daygrid-day-frame) {
background-color: rgba(239, 68, 68, 0.2) !important;
border-color: rgba(239, 68, 68, 0.4) !important;
}
:global(.calendario-ausencias .fc-daygrid-day.fc-day-blocked .fc-daygrid-day-number) {
color: #dc2626 !important;
font-weight: 700 !important;
text-decoration: line-through !important;
background-color: rgba(239, 68, 68, 0.1) !important;
border-radius: 50% !important;
padding: 0.25rem !important;
}
:global(.calendario-ausencias .fc-daygrid-day.fc-day-blocked::before) {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 6px,
rgba(239, 68, 68, 0.15) 6px,
rgba(239, 68, 68, 0.15) 12px
);
pointer-events: none;
z-index: 1;
border-radius: inherit;
}
/* Datas desabilitadas (passado) */
:global(.calendario-ausencias .fc .fc-day-past .fc-daygrid-day-number) {
opacity: 0.4;