Files
sgse-app/apps/web/src/lib/components/CalendarioAfastamentos.svelte

529 lines
13 KiB
Svelte

<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="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<h2 class="card-title text-2xl">Calendário de Afastamentos</h2>
<!-- Filtros -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-base-content/70 text-sm font-medium">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="bg-base-200/50 mb-4 flex flex-wrap gap-4 rounded-lg p-4">
<div class="flex items-center gap-2">
<div class="bg-error h-4 w-4 rounded"></div>
<span class="text-sm">Atestado Médico</span>
</div>
<div class="flex items-center gap-2">
<div class="bg-warning h-4 w-4 rounded"></div>
<span class="text-sm">Declaração</span>
</div>
<div class="flex items-center gap-2">
<div class="bg-secondary h-4 w-4 rounded"></div>
<span class="text-sm">Licença Maternidade</span>
</div>
<div class="flex items-center gap-2">
<div class="bg-info h-4 w-4 rounded"></div>
<span class="text-sm">Licença Paternidade</span>
</div>
<div class="flex items-center gap-2">
<div class="bg-success h-4 w-4 rounded"></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 mx-4 w-full max-w-md transform rounded-2xl shadow-2xl transition-all"
onclick={(e) => e.stopPropagation()}
>
<!-- Header do Modal -->
<div class="border-base-300 from-primary/10 to-secondary/10 border-b bg-linear-to-r p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-base-content mb-2 text-xl font-bold">
{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="space-y-4 p-6">
<div class="bg-base-200/50 flex items-center gap-3 rounded-lg p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary 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>
<div>
<p class="text-base-content/60 text-sm">Data Início</p>
<p class="font-semibold">
{formatarData(eventoSelecionado.start)}
</p>
</div>
</div>
<div class="bg-base-200/50 flex items-center gap-3 rounded-lg p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-secondary 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>
<div>
<p class="text-base-content/60 text-sm">Data Fim</p>
<p class="font-semibold">
{formatarData(eventoSelecionado.end)}
</p>
</div>
</div>
<div class="bg-base-200/50 flex items-center gap-3 rounded-lg p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-accent h-6 w-6"
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-base-content/60 text-sm">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="border-base-300 flex justify-end border-t p-6">
<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>