diff --git a/apps/web/src/lib/components/ErrorModal.svelte b/apps/web/src/lib/components/ErrorModal.svelte
index 9684a28..e5b1ca0 100644
--- a/apps/web/src/lib/components/ErrorModal.svelte
+++ b/apps/web/src/lib/components/ErrorModal.svelte
@@ -39,7 +39,7 @@
diff --git a/apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte b/apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte
index 94ae63b..355f50c 100644
--- a/apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte
+++ b/apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte
@@ -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 @@
{#if !readonly}
-
+
+
+
+
+
+ {#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")}
+
+
+
+
Atenção: Períodos Indisponíveis
+
+
Os dias marcados em vermelho estão bloqueados porque você já possui solicitações aprovadas ou aguardando aprovação para esses períodos.
+
Você não pode criar novas solicitações que sobreponham esses períodos. Escolha um período diferente.
+
+
+
+ {/if}
{/if}
@@ -293,7 +645,8 @@
{#if ausenciasExistentes.length > 0 || readonly}
-
+
+
Aguardando Aprovação
@@ -306,6 +659,21 @@
Reprovado
+ {#if !readonly && ausenciasExistentes && ausenciasExistentes.filter(a => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
+
+
+ Dias Bloqueados (Indisponíveis)
+
+ {/if}
+
+
+ {#if !readonly && ausenciasExistentes && ausenciasExistentes.filter(a => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
+
+
+ Dias bloqueados não podem ser selecionados para novas solicitações
+
+
+ {/if}
{/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;
diff --git a/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte b/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte
index e02373f..518a403 100644
--- a/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte
+++ b/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte
@@ -2,6 +2,7 @@
import { useConvexClient, useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import CalendarioAusencias from "./CalendarioAusencias.svelte";
+ import ErrorModal from "../ErrorModal.svelte";
import { toast } from "svelte-sonner";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
@@ -25,17 +26,25 @@
let dataFim = $state
("");
let motivo = $state("");
let processando = $state(false);
+
+ // Estados para modal de erro
+ let mostrarModalErro = $state(false);
+ let mensagemErroModal = $state("");
+ let detalhesErroModal = $state("");
// Buscar ausências existentes para exibir no calendário
const ausenciasExistentesQuery = useQuery(api.ausencias.listarMinhasSolicitacoes, {
funcionarioId,
});
+ // Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações)
const ausenciasExistentes = $derived(
- (ausenciasExistentesQuery?.data || []).map((a) => ({
+ (ausenciasExistentesQuery?.data || [])
+ .filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao")
+ .map((a) => ({
dataInicio: a.dataInicio,
dataFim: a.dataFim,
- status: a.status as "aguardando_aprovacao" | "aprovado" | "reprovado",
+ status: a.status as "aguardando_aprovacao" | "aprovado",
}))
);
@@ -97,6 +106,8 @@
try {
processando = true;
+ mostrarModalErro = false;
+ mensagemErroModal = "";
await client.mutation(api.ausencias.criarSolicitacao, {
funcionarioId,
@@ -112,14 +123,32 @@
}
} catch (error) {
console.error("Erro ao criar solicitação:", error);
- toast.error(
- error instanceof Error ? error.message : "Erro ao criar solicitação de ausência"
- );
+ const mensagemErro = error instanceof Error ? error.message : String(error);
+
+ // Verificar se é erro de sobreposição de período
+ if (
+ mensagemErro.includes("Já existe uma solicitação") ||
+ mensagemErro.includes("já existe") ||
+ mensagemErro.includes("solicitação aprovada ou pendente")
+ ) {
+ mensagemErroModal = "Não é possível criar esta solicitação.";
+ detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${new Date(dataInicio).toLocaleDateString("pt-BR")} até ${new Date(dataFim).toLocaleDateString("pt-BR")}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`;
+ mostrarModalErro = true;
+ } else {
+ // Outros erros continuam usando toast
+ toast.error(mensagemErro);
+ }
} finally {
processando = false;
}
}
+ function fecharModalErro() {
+ mostrarModalErro = false;
+ mensagemErroModal = "";
+ detalhesErroModal = "";
+ }
+
function handlePeriodoSelecionado(periodo: { dataInicio: string; dataFim: string }) {
dataInicio = periodo.dataInicio;
dataFim = periodo.dataFim;
@@ -206,12 +235,19 @@
+ {#if ausenciasExistentesQuery === undefined}
+
+
+ Carregando ausências existentes...
+
+ {:else}
+ {/if}
{#if dataInicio && dataFim}
@@ -428,6 +464,15 @@
+
+
+