952 lines
27 KiB
Svelte
952 lines
27 KiB
Svelte
<script lang="ts">
|
|
import { Calendar } from '@fullcalendar/core';
|
|
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
|
import interactionPlugin from '@fullcalendar/interaction';
|
|
import multiMonthPlugin from '@fullcalendar/multimonth';
|
|
import { onMount } from 'svelte';
|
|
import { SvelteDate } from 'svelte/reactivity';
|
|
|
|
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;
|
|
}
|
|
|
|
const {
|
|
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
|
|
|
|
// 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
|
|
let eventos = $derived.by(() => {
|
|
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'
|
|
}
|
|
});
|
|
}
|
|
|
|
return 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 SvelteDate(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 SvelteDate(inicio);
|
|
const dFim = new SvelteDate(fim);
|
|
const diffTime = Math.abs(dFim.getTime() - dInicio.getTime());
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
|
return diffDays;
|
|
}
|
|
|
|
// Helper: Verificar se há sobreposição de datas
|
|
function verificarSobreposicao(
|
|
inicio1: SvelteDate,
|
|
fim1: SvelteDate,
|
|
inicio2: string,
|
|
fim2: string
|
|
): boolean {
|
|
const d2Inicio = new SvelteDate(inicio2);
|
|
const d2Fim = new SvelteDate(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: SvelteDate, fim: SvelteDate): 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)
|
|
);
|
|
}
|
|
|
|
interface FullCalendarDayCellInfo {
|
|
el: HTMLElement;
|
|
date: Date;
|
|
}
|
|
|
|
// Helper: Atualizar classe de seleção em uma célula
|
|
function atualizarClasseSelecionado(info: FullCalendarDayCellInfo) {
|
|
if (dataInicio && dataFim && !readonly) {
|
|
const cellDate = new SvelteDate(info.date);
|
|
const inicio = new SvelteDate(dataInicio);
|
|
const fim = new SvelteDate(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: FullCalendarDayCellInfo) {
|
|
if (readonly || !ausenciasExistentes || ausenciasExistentes.length === 0) {
|
|
info.el.classList.remove('fc-day-blocked');
|
|
return;
|
|
}
|
|
|
|
const cellDate = new SvelteDate(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 SvelteDate(ausencia.dataInicio);
|
|
const fim = new SvelteDate(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 SvelteDate(dataInicio);
|
|
const fim = new SvelteDate(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 SvelteDate(ariaLabel);
|
|
if (!isNaN(cellDate.getTime())) {
|
|
cellDate.setHours(0, 0, 0, 0);
|
|
if (cellDate >= inicio && cellDate <= fim) {
|
|
cell.classList.add('fc-day-selected');
|
|
}
|
|
}
|
|
} catch {
|
|
// 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 calendarInstance = calendar;
|
|
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: SvelteDate | null = null;
|
|
|
|
// Método 1: aria-label
|
|
const ariaLabel = cell.getAttribute('aria-label');
|
|
if (ariaLabel) {
|
|
try {
|
|
const parsed = new SvelteDate(ariaLabel);
|
|
if (!isNaN(parsed.getTime())) {
|
|
cellDate = parsed;
|
|
}
|
|
} catch {
|
|
// Ignorar
|
|
}
|
|
}
|
|
|
|
// Método 2: data-date attribute
|
|
if (!cellDate) {
|
|
const dataDate = cell.getAttribute('data-date');
|
|
if (dataDate) {
|
|
try {
|
|
const parsed = new SvelteDate(dataDate);
|
|
if (!isNaN(parsed.getTime())) {
|
|
cellDate = parsed;
|
|
}
|
|
} catch {
|
|
// Ignorar
|
|
}
|
|
}
|
|
}
|
|
|
|
// Método 3: Tentar obter do número do dia e contexto do calendário
|
|
if (!cellDate && calendarInstance.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 SvelteDate(calendarInstance.view.activeStart);
|
|
const cellIndex = Array.from(cells).indexOf(cell);
|
|
if (cellIndex >= 0) {
|
|
const possibleDate = new SvelteDate(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 SvelteDate(ausencia.dataInicio);
|
|
const fim = new SvelteDate(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
|
|
void ausenciasExistentes;
|
|
|
|
// Usar requestAnimationFrame para evitar múltiplas atualizações durante seleção
|
|
requestAnimationFrame(() => {
|
|
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;
|
|
|
|
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,
|
|
selectOverlap: false,
|
|
selectConstraint: undefined, // Permite seleção entre meses diferentes
|
|
validRange: {
|
|
start: new SvelteDate().toISOString().split('T')[0] // Não permite selecionar datas passadas
|
|
},
|
|
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 SvelteDate(info.startStr);
|
|
const fim = new SvelteDate(info.endStr);
|
|
fim.setDate(fim.getDate() - 1); // FullCalendar usa exclusive end
|
|
|
|
// Validar que não é no passado
|
|
const hoje = new SvelteDate();
|
|
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;
|
|
}
|
|
|
|
// 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({
|
|
dataInicio: info.startStr,
|
|
dataFim: fim.toISOString().split('T')[0]
|
|
});
|
|
}
|
|
|
|
// 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(() => {
|
|
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 e períodos que sobrepõem com ausências existentes
|
|
selectAllow: (selectInfo) => {
|
|
const hoje = new SvelteDate();
|
|
hoje.setHours(0, 0, 0, 0);
|
|
|
|
// Bloquear datas passadas
|
|
if (new SvelteDate(selectInfo.start) < hoje) {
|
|
return false;
|
|
}
|
|
|
|
// Verificar sobreposição com ausências existentes
|
|
if (!readonly && ausenciasExistentes && ausenciasExistentes.length > 0) {
|
|
const inicioSelecao = new SvelteDate(selectInfo.start);
|
|
const fimSelecao = new SvelteDate(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 e aplicar classe de bloqueio
|
|
dayCellClassNames: (arg) => {
|
|
const classes: string[] = [];
|
|
|
|
if (arg.date.getDay() === 0 || arg.date.getDay() === 6) {
|
|
classes.push('fc-day-weekend-custom');
|
|
}
|
|
|
|
// Verificar se o dia está bloqueado
|
|
if (!readonly && ausenciasExistentes && ausenciasExistentes.length > 0) {
|
|
const cellDate = new SvelteDate(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 SvelteDate(ausencia.dataInicio);
|
|
const fim = new SvelteDate(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;
|
|
}
|
|
});
|
|
|
|
calendar.render();
|
|
|
|
return () => {
|
|
calendar?.destroy();
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="calendario-ausencias-wrapper">
|
|
<!-- Header com instruções -->
|
|
{#if !readonly}
|
|
<div class="mb-4 space-y-4">
|
|
<div class="alert alert-info shadow-lg">
|
|
<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 class="text-sm">
|
|
<p class="font-bold">Como usar:</p>
|
|
<ul class="mt-1 list-inside list-disc">
|
|
<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>
|
|
|
|
<!-- Alerta sobre dias bloqueados -->
|
|
{#if ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao').length > 0}
|
|
<div class="alert alert-warning border-warning/50 border-2 shadow-lg">
|
|
<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="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="mt-1 text-sm">
|
|
<p>
|
|
Os dias marcados em <span class="text-error font-bold">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}
|
|
|
|
<!-- Calendário -->
|
|
<div
|
|
bind:this={calendarEl}
|
|
class="calendario-ausencias overflow-hidden rounded-2xl border-2 border-orange-500/10 shadow-2xl"
|
|
></div>
|
|
|
|
<!-- Legenda de status -->
|
|
{#if ausenciasExistentes.length > 0 || readonly}
|
|
<div class="mt-6 space-y-4">
|
|
<div class="flex flex-wrap justify-center gap-4">
|
|
<div
|
|
class="badge badge-lg gap-2"
|
|
style="background-color: #f59e0b; border-color: #d97706; color: white;"
|
|
>
|
|
<div class="h-3 w-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="h-3 w-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="h-3 w-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="h-3 w-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-base-content/70 text-sm">
|
|
<span class="text-error font-semibold">Dias bloqueados</span> não podem ser selecionados
|
|
para novas solicitações
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Informação do período selecionado -->
|
|
{#if dataInicio && dataFim && !readonly}
|
|
<div class="card mt-6 border border-orange-400 shadow-lg">
|
|
<div class="card-body">
|
|
<h3 class="card-title">
|
|
<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="mt-2 grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
<div>
|
|
<p class="text-base-content/70 text-sm">Data Início</p>
|
|
<p class="text-lg font-bold">
|
|
{new Date(dataInicio).toLocaleDateString('pt-BR')}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-base-content/70 text-sm">Data Fim</p>
|
|
<p class="text-lg font-bold">
|
|
{new Date(dataFim).toLocaleDateString('pt-BR')}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-base-content/70 text-sm">Total de Dias</p>
|
|
<p class="text-2xl font-bold 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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* 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>
|