Files
sgse-app/apps/web/src/routes/(dashboard)/recursos-humanos/ferias/+page.svelte
deyvisonwanderley c058865817 refactor: update vacation management structure and enhance status handling
- Renamed and refactored vacation-related types and components for clarity, transitioning from 'SolicitacaoFerias' to 'PeriodoFerias'.
- Improved the handling of vacation statuses, including the addition of 'EmFérias' to the status options.
- Streamlined the vacation request and approval components to better reflect individual vacation periods.
- Enhanced data handling in backend queries and schema to support the new structure and ensure accurate status updates.
- Improved user experience by refining UI elements related to vacation periods and their statuses.
2025-11-13 15:54:59 -03:00

2099 lines
61 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount, tick } from 'svelte';
import { SvelteDate, SvelteMap } from 'svelte/reactivity';
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { FunctionReturnType } from 'convex/server';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import BarChart3D from '$lib/components/ti/charts/BarChart3D.svelte';
import AlterarStatusFerias from '$lib/components/AlterarStatusFerias.svelte';
type CalendarConstructor = typeof import('@fullcalendar/core').Calendar;
type CalendarInstance = import('@fullcalendar/core').Calendar;
type EventInput = import('@fullcalendar/core').EventInput;
type TodasSolicitacoes = FunctionReturnType<typeof api.ferias.listarTodas>;
type Solicitacao = TodasSolicitacoes[number];
type PeriodoDetalhado = {
funcionarioId: Id<'funcionarios'>;
anoReferencia: number;
feriasId: Id<'ferias'>;
funcionarioNome: string;
matricula?: string | null;
timeNome?: string | null;
timeCor?: string | null;
status: Solicitacao['status'];
dataInicio: string;
dataFim: string;
diasCorridos: number;
};
type SolicitacoesPorAnoResumo = {
ano: number;
solicitacoes: number;
diasTotais: number;
};
// Buscar TODAS as solicitações de férias (Dashboard RH)
const todasSolicitacoesQuery = useQuery(api.ferias.listarTodas, {});
const client = useConvexClient();
const currentUser = useQuery(api.auth.getCurrentUser, {});
// Estado para controlar qual período está selecionado para mudança de status
let periodoSelecionado = $state<Id<'ferias'> | null>(null);
// Estados de loading e error
const isLoading = $derived(todasSolicitacoesQuery?.isLoading ?? true);
// Debug: Log dos dados carregados
$effect(() => {
if (todasSolicitacoesQuery?.data) {
console.log('📦 [Backend] Dados carregados:', {
total: todasSolicitacoesQuery.data.length,
isLoading: todasSolicitacoesQuery.isLoading,
dados: todasSolicitacoesQuery.data
});
}
if (todasSolicitacoesQuery?.isLoading !== undefined) {
console.log('🔄 [Backend] Estado de loading:', todasSolicitacoesQuery.isLoading);
}
});
// Verificar erro de forma segura
const queryResult = $derived(todasSolicitacoesQuery);
const hasError = $derived(
queryResult !== undefined &&
queryResult !== null &&
typeof queryResult === 'object' &&
'error' in queryResult &&
(queryResult as { error?: unknown }).error !== undefined
);
const errorMessage = $derived(() => {
if (!hasError) return 'Erro desconhecido ao carregar dados';
const queryWithError = queryResult as { error?: unknown } | undefined;
if (!queryWithError?.error) return 'Erro desconhecido ao carregar dados';
if (queryWithError.error instanceof Error) {
return queryWithError.error.message;
}
if (typeof queryWithError.error === 'string') {
return queryWithError.error;
}
return 'Erro desconhecido ao carregar dados';
});
// Manter último valor válido para evitar dados desaparecendo
let ultimasSolicitacoesValidas = $state<TodasSolicitacoes>([]);
// Atualizar apenas quando temos dados válidos
$effect(() => {
if (todasSolicitacoesQuery?.data && !hasError) {
ultimasSolicitacoesValidas = todasSolicitacoesQuery.data;
}
});
// Usar último valor válido ou array vazio
const solicitacoes = $derived<TodasSolicitacoes>(
todasSolicitacoesQuery?.data ?? ultimasSolicitacoesValidas
);
let filtroStatus = $state<string>('todos');
let filtroNome = $state<string>('');
let filtroMatricula = $state<string>('');
let filtroEmail = $state<string>('');
let filtroMes = $state<string>('');
let filtroPeriodoInicio = $state<string>('');
let filtroPeriodoFim = $state<string>('');
let dataInicioRelatorio = $state<string>('');
let dataFimRelatorio = $state<string>('');
// Filtrar períodos individuais
const solicitacoesFiltradas = $derived(
solicitacoes.filter((periodo) => {
if (filtroStatus !== 'todos' && periodo.status !== filtroStatus) {
return false;
}
const nomeFiltro = normalizarTexto(filtroNome.trim());
const matriculaFiltro = normalizarTexto(filtroMatricula.trim());
const emailFiltro = normalizarTexto(filtroEmail.trim());
if (nomeFiltro || matriculaFiltro || emailFiltro) {
const funcionario = periodo.funcionario;
if (!funcionario) return false;
const contato = funcionario as {
emailInstitucional?: string | null;
email?: string | null;
};
if (nomeFiltro) {
const nomeNormalizado = normalizarTexto(funcionario.nome ?? '');
if (!nomeNormalizado.includes(nomeFiltro)) {
return false;
}
}
if (matriculaFiltro) {
const matriculaNormalizada = normalizarTexto(funcionario.matricula ?? '');
if (!matriculaNormalizada.includes(matriculaFiltro)) {
return false;
}
}
if (emailFiltro) {
const emailNormalizado = normalizarTexto(
contato.emailInstitucional ?? contato.email ?? ''
);
if (!emailNormalizado.includes(emailFiltro)) {
return false;
}
}
}
const aplicaMes = filtroMes !== '';
const aplicaPeriodo = filtroPeriodoInicio !== '' || filtroPeriodoFim !== '';
if (!aplicaMes && !aplicaPeriodo) {
return true;
}
const intervaloMes = aplicaMes ? criarIntervaloDoMes(filtroMes) : null;
const inicioFiltro = filtroPeriodoInicio
? criarDataHora(filtroPeriodoInicio, 'inicio')
: null;
const fimFiltro = filtroPeriodoFim ? criarDataHora(filtroPeriodoFim, 'fim') : null;
const inicioComparacao = inicioFiltro ?? new SvelteDate(-8640000000000000);
const fimComparacao = fimFiltro ?? new SvelteDate(8640000000000000);
const inicioPeriodo = criarDataHora(periodo.dataInicio, 'inicio');
const fimPeriodo = criarDataHora(periodo.dataFim, 'fim');
if (!inicioPeriodo || !fimPeriodo) {
return false;
}
if (intervaloMes) {
if (fimPeriodo < intervaloMes.inicio || inicioPeriodo > intervaloMes.fim) {
return false;
}
}
if (inicioFiltro || fimFiltro) {
if (fimPeriodo < inicioComparacao || inicioPeriodo > fimComparacao) {
return false;
}
}
return true;
})
);
// Estatísticas gerais
const stats = $derived({
total: solicitacoes.length,
aguardando: solicitacoes.filter((solicitacao) => solicitacao.status === 'aguardando_aprovacao')
.length,
aprovadas: solicitacoes.filter(
(solicitacao) =>
solicitacao.status === 'aprovado' || solicitacao.status === 'data_ajustada_aprovada'
).length,
reprovadas: solicitacoes.filter((solicitacao) => solicitacao.status === 'reprovado').length
});
const solicitacoesAprovadas = $derived(
solicitacoesFiltradas.filter(
(p) => p.status === 'aprovado' || p.status === 'data_ajustada_aprovada' || p.status === 'EmFérias'
)
);
const periodosDetalhados = $derived<Array<PeriodoDetalhado>>(
solicitacoesAprovadas
.map((periodo) => ({
feriasId: periodo._id,
funcionarioId: periodo.funcionarioId,
anoReferencia: periodo.anoReferencia,
funcionarioNome: periodo.funcionario?.nome ?? 'Funcionário não encontrado',
matricula: periodo.funcionario?.matricula ?? null,
timeNome: periodo.time?.nome ?? null,
timeCor: periodo.time?.cor ?? null,
status: periodo.status,
dataInicio: periodo.dataInicio,
dataFim: periodo.dataFim,
diasCorridos: periodo.diasFerias
}))
.sort(
(a, b) => new SvelteDate(a.dataInicio).getTime() - new SvelteDate(b.dataInicio).getTime()
)
);
type PeriodoPorMes = { label: string; totalDias: number; quantidadePeriodos: number };
const periodosPorMes = $derived<Array<PeriodoPorMes>>(
(() => {
const agregados = new SvelteMap<string, PeriodoPorMes>();
for (const periodo of periodosDetalhados) {
const inicio = new SvelteDate(`${periodo.dataInicio}T00:00:00`);
const chave = `${inicio.getFullYear()}-${String(inicio.getMonth() + 1).padStart(2, '0')}`;
const label = inicio.toLocaleDateString('pt-BR', {
month: 'short',
year: 'numeric'
});
const existente = agregados.get(chave) ?? {
label,
totalDias: 0,
quantidadePeriodos: 0
};
existente.totalDias += periodo.diasCorridos;
existente.quantidadePeriodos += 1;
agregados.set(chave, existente);
}
return Array.from(agregados.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, valor]) => valor);
})()
);
let rangeInicioIndice = $state(0);
let rangeFimIndice = $state(0);
$effect(() => {
if (periodosPorMes.length === 0) {
rangeInicioIndice = 0;
rangeFimIndice = 0;
return;
}
const ultimoIndice = periodosPorMes.length - 1;
if (rangeFimIndice === 0 && rangeInicioIndice === 0) {
rangeFimIndice = ultimoIndice;
return;
}
if (rangeInicioIndice > ultimoIndice) {
rangeInicioIndice = ultimoIndice;
}
if (rangeFimIndice > ultimoIndice) {
rangeFimIndice = ultimoIndice;
}
if (rangeInicioIndice > rangeFimIndice) {
rangeInicioIndice = rangeFimIndice;
}
});
const periodosPorMesAtivos = $derived<Array<PeriodoPorMes>>(
(() => {
if (periodosPorMes.length === 0) {
return [];
}
const ultimoIndice = periodosPorMes.length - 1;
const inicio = Math.max(0, Math.min(rangeInicioIndice, ultimoIndice));
const fim = Math.max(inicio, Math.min(rangeFimIndice, ultimoIndice));
return periodosPorMes.slice(inicio, fim + 1);
})()
);
const solicitacoesPorAno = $derived<Array<SolicitacoesPorAnoResumo>>(
(() => {
const agregados = new SvelteMap<number, SolicitacoesPorAnoResumo>();
for (const solicitacao of solicitacoesAprovadas) {
const totalDias = solicitacao.periodos.reduce(
(acc, periodo) => acc + periodo.diasFerias,
0
);
const existente = agregados.get(solicitacao.anoReferencia) ?? {
ano: solicitacao.anoReferencia,
solicitacoes: 0,
diasTotais: 0
};
existente.solicitacoes += 1;
existente.diasTotais += totalDias;
agregados.set(solicitacao.anoReferencia, existente);
}
return Array.from(agregados.values()).sort((a, b) => a.ano - b.ano);
})()
);
// Dados para gráfico de barras 3D - Dias por Mês
const chartDataMes = $derived(() => {
if (periodosPorMesAtivos.length === 0) {
return {
labels: [],
datasets: []
};
}
const cores = [
'#3b82f6', // Azul
'#10b981', // Verde
'#f59e0b', // Amarelo
'#ef4444', // Vermelho
'#8b5cf6', // Roxo
'#06b6d4', // Ciano
'#f97316', // Laranja
'#ec4899' // Rosa
];
// Cor principal para a legenda (azul primary)
const corPrincipal = '#3b82f6';
return {
labels: periodosPorMesAtivos.map((p) => p.label),
datasets: [
{
label: 'Dias de Férias',
data: periodosPorMesAtivos.map((p) => p.totalDias),
backgroundColor: periodosPorMesAtivos.map((_, index) => cores[index % cores.length]),
borderColor: periodosPorMesAtivos.map((_, index) => cores[index % cores.length]),
// Cor representativa para a legenda (primeira cor da paleta)
legendColor: corPrincipal,
borderWidth: 2
}
]
};
});
// Dados para gráfico de barras 3D - Dias por Ano
const chartDataAno = $derived(() => {
if (solicitacoesPorAno.length === 0) {
return {
labels: [],
datasets: []
};
}
const cores = [
'#10b981', // Verde
'#3b82f6', // Azul
'#f59e0b', // Amarelo
'#ef4444', // Vermelho
'#8b5cf6', // Roxo
'#06b6d4', // Ciano
'#f97316', // Laranja
'#ec4899' // Rosa
];
// Cores representativas para a legenda
const corDias = '#10b981'; // Verde para dias
const corSolicitacoes = lightenColor('#10b981', 25); // Verde claro para solicitações
return {
labels: solicitacoesPorAno.map((s) => String(s.ano)),
datasets: [
{
label: 'Dias Totais Aprovados',
data: solicitacoesPorAno.map((s) => s.diasTotais),
backgroundColor: solicitacoesPorAno.map((_, index) => cores[index % cores.length]),
borderColor: solicitacoesPorAno.map((_, index) => cores[index % cores.length]),
// Cor representativa para a legenda (verde)
legendColor: corDias,
borderWidth: 2
},
{
label: 'Número de Solicitações',
data: solicitacoesPorAno.map((s) => s.solicitacoes),
backgroundColor: solicitacoesPorAno.map((_, index) =>
lightenColor(cores[index % cores.length], 20)
),
borderColor: solicitacoesPorAno.map((_, index) => cores[index % cores.length]),
// Cor representativa para a legenda (verde claro)
legendColor: corSolicitacoes,
borderWidth: 2
}
]
};
});
// Função auxiliar para clarear cores
function lightenColor(color: string, percent: number): string {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = Math.min(255, (num >> 16) + amt);
const G = Math.min(255, ((num >> 8) & 0x00ff) + amt);
const B = Math.min(255, (num & 0x0000ff) + amt);
return `#${(0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1)}`;
}
const coresCalendario = [
'#2563eb',
'#16a34a',
'#f97316',
'#9333ea',
'#0ea5e9',
'#facc15',
'#f43f5e'
] as const;
const eventosFerias = $derived<Array<EventInput>>(
periodosDetalhados.map((periodo, indice) => {
const corBase = periodo.timeCor ?? coresCalendario[indice % coresCalendario.length];
return {
id: `${String(periodo.feriasId)}-${indice}`,
title: `${periodo.funcionarioNome} (${periodo.diasCorridos} dia${periodo.diasCorridos === 1 ? '' : 's'})`,
start: periodo.dataInicio,
end: adicionarDias(periodo.dataFim, 1),
allDay: true,
backgroundColor: corBase,
borderColor: corBase,
extendedProps: {
funcionarioId: periodo.funcionarioId,
timeNome: periodo.timeNome,
diasCorridos: periodo.diasCorridos,
anoReferencia: periodo.anoReferencia,
status: periodo.status
}
} satisfies EventInput;
})
);
// Debug: Log dos eventos gerados
$effect(() => {
console.log('📅 [Eventos] Total de eventos:', eventosFerias.length);
console.log('📋 [Periodos] Total de períodos:', periodosDetalhados.length);
console.log('✅ [Aprovadas] Total de solicitações aprovadas:', solicitacoesAprovadas.length);
if (eventosFerias.length > 0) {
console.log('📅 [Eventos] Primeiro evento:', eventosFerias[0]);
}
});
let calendarioContainer: HTMLDivElement | null = null;
let calendarioInstance: CalendarInstance | null = null;
let CalendarClass: CalendarConstructor | null = null;
let calendarioInicializado = $state(false);
// Função para inicializar o calendário
async function inicializarCalendario() {
if (
!calendarioContainer ||
calendarioInstance ||
calendarioInicializado ||
isLoading ||
hasError
) {
return;
}
try {
console.log('🔄 [Calendário] Iniciando inicialização...');
console.log('📊 [Calendário] Estado:', {
container: !!calendarioContainer,
loading: isLoading,
error: hasError,
eventos: eventosFerias.length,
solicitacoes: solicitacoes.length,
solicitacoesAprovadas: solicitacoesAprovadas.length,
periodosDetalhados: periodosDetalhados.length
});
const [coreModule, dayGridModule, interactionModule, localeModule] = await Promise.all([
import('@fullcalendar/core'),
import('@fullcalendar/daygrid'),
import('@fullcalendar/interaction'),
import('@fullcalendar/core/locales/pt-br')
]);
// Verificar novamente após os imports
if (
!calendarioContainer ||
isLoading ||
hasError ||
calendarioInstance ||
calendarioInicializado
) {
console.log('⚠️ [Calendário] Condições alteradas após imports');
return;
}
CalendarClass = coreModule.Calendar;
const dayGridPlugin = dayGridModule.default;
const interactionPlugin = interactionModule.default;
const ptBrLocale = localeModule.default;
console.log('✅ [Calendário] Criando instância com', eventosFerias.length, 'eventos');
if (eventosFerias.length > 0) {
console.log('📅 [Calendário] Primeiro evento:', eventosFerias[0]);
}
calendarioInstance = new CalendarClass(calendarioContainer, {
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
height: 'auto',
locale: 'pt-br',
locales: [ptBrLocale],
navLinks: true,
weekNumbers: false,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,dayGridWeek'
},
buttonText: {
today: 'Hoje',
month: 'Mês',
week: 'Semana'
},
events: eventosFerias
});
calendarioInstance.render();
calendarioInicializado = true;
console.log('✅ [Calendário] Inicializado com sucesso!');
} catch (error) {
console.error('❌ [Calendário] Erro ao inicializar:', error);
if (error instanceof Error) {
console.error('❌ [Calendário] Mensagem de erro:', error.message);
console.error('❌ [Calendário] Stack:', error.stack);
}
}
}
// Monitorar quando o container está disponível e os dados estão prontos
$effect(() => {
// Verificar se não está mais carregando e não há erro
const loading = isLoading;
const error = hasError;
const container = calendarioContainer;
// Se estiver carregando ou houver erro, não fazer nada
if (loading || error) {
return;
}
// Se não tiver container ou já estiver inicializado, não fazer nada
if (!container || calendarioInicializado || calendarioInstance) {
return;
}
// Aguardar o DOM estar pronto e inicializar
(async () => {
await tick();
// Verificar novamente após o tick
if (
!calendarioContainer ||
isLoading ||
hasError ||
calendarioInstance ||
calendarioInicializado
) {
return;
}
// Aguardar um pouco mais para garantir que o elemento está completamente renderizado
setTimeout(() => {
inicializarCalendario().catch((error) => {
console.error('❌ [Calendário] Erro ao inicializar:', error);
});
}, 100);
})();
});
// Cleanup ao desmontar o componente
onMount(() => {
return () => {
if (calendarioInstance) {
try {
calendarioInstance.destroy();
} catch (error) {
console.error('❌ [Calendário] Erro ao destruir no unmount:', error);
}
calendarioInstance = null;
calendarioInicializado = false;
}
};
});
// Sincronizar eventos do calendário quando mudarem
$effect(() => {
// Não atualizar se o calendário não estiver inicializado ou estiver carregando
if (!calendarioInstance || !calendarioInicializado || isLoading || hasError) {
return;
}
try {
console.log('🔄 Atualizando eventos do calendário:', eventosFerias.length, 'eventos');
// Remover todos os eventos existentes
calendarioInstance.removeAllEvents();
// Adicionar novos eventos
const eventosClonados = eventosFerias.map((evento) => ({
...evento,
extendedProps: { ...evento.extendedProps }
}));
// Adicionar eventos em lote
for (const evento of eventosClonados) {
calendarioInstance.addEvent(evento);
}
console.log('✅ Eventos atualizados com sucesso!');
} catch (error) {
// Log do erro, mas não interromper o fluxo
if (error instanceof Error) {
console.error('❌ Erro ao atualizar eventos do calendário:', error.message);
} else {
console.error('❌ Erro ao atualizar eventos do calendário:', error);
}
}
});
if (typeof window !== 'undefined') {
$effect(() => {
(
window as Window & typeof globalThis & { __feriasSolicitacoes: TodasSolicitacoes }
).__feriasSolicitacoes = solicitacoes;
(
window as Window & typeof globalThis & { __feriasFiltradas: TodasSolicitacoes }
).__feriasFiltradas = solicitacoesFiltradas;
(
window as Window & typeof globalThis & { __feriasAprovadas: TodasSolicitacoes }
).__feriasAprovadas = solicitacoesAprovadas;
(
window as Window & typeof globalThis & { __feriasPeriodos: PeriodoDetalhado[] }
).__feriasPeriodos = periodosDetalhados;
(window as Window & typeof globalThis & { __feriasPorMes: PeriodoPorMes[] }).__feriasPorMes =
periodosPorMes;
(
window as Window & typeof globalThis & { __feriasPorAno: SolicitacoesPorAnoResumo[] }
).__feriasPorAno = solicitacoesPorAno;
});
}
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
aguardando_aprovacao: 'badge-warning',
aprovado: 'badge-success',
reprovado: 'badge-error',
data_ajustada_aprovada: 'badge-info'
};
return badges[status] || 'badge-neutral';
}
function getStatusTexto(status: string) {
const textos: Record<string, string> = {
aguardando_aprovacao: 'Aguardando',
aprovado: 'Aprovado',
reprovado: 'Reprovado',
data_ajustada_aprovada: 'Ajustado'
};
return textos[status] || status;
}
// Função para formatar data sem problemas de timezone
function formatarDataString(dataString: string): string {
if (!dataString) return '';
// Dividir a string da data (formato YYYY-MM-DD)
const partes = dataString.split('-');
if (partes.length !== 3) return dataString;
// Retornar no formato DD/MM/YYYY
return `${partes[2]}/${partes[1]}/${partes[0]}`;
}
function formatarData(data: string | number | Date) {
const instancia = data instanceof Date ? data : new SvelteDate(data);
return instancia.toLocaleDateString('pt-BR');
}
function criarDataHora(dataISO: string | undefined, modo: 'inicio' | 'fim'): Date | null {
if (!dataISO) return null;
const data = new SvelteDate(`${dataISO}T00:00:00`);
if (Number.isNaN(data.getTime())) {
return null;
}
if (modo === 'fim') {
data.setHours(23, 59, 59, 999);
}
return data;
}
function criarIntervaloDoMes(valor: string): { inicio: Date; fim: Date } | null {
if (!valor || valor === 'todos') return null;
const [anoStr, mesStr] = valor.split('-');
const ano = Number(anoStr);
const mes = Number(mesStr) - 1;
if (Number.isNaN(ano) || Number.isNaN(mes)) return null;
const inicio = new SvelteDate(ano, mes, 1);
const fim = new SvelteDate(ano, mes + 1, 0, 23, 59, 59, 999);
return { inicio, fim };
}
function adicionarDias(dataISO: string, dias: number): string {
const data = new SvelteDate(`${dataISO}T00:00:00`);
if (Number.isNaN(data.getTime())) {
return dataISO;
}
data.setDate(data.getDate() + dias);
const ano = data.getUTCFullYear();
const mes = String(data.getUTCMonth() + 1).padStart(2, '0');
const dia = String(data.getUTCDate()).padStart(2, '0');
return `${ano}-${mes}-${dia}`;
}
function normalizarTexto(texto: string): string {
return texto
.trim()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function limparTodosFiltros() {
filtroStatus = 'todos';
filtroNome = '';
filtroMatricula = '';
filtroEmail = '';
filtroMes = '';
filtroPeriodoInicio = '';
filtroPeriodoFim = '';
}
function handleRangeInicio(event: Event) {
const target = event.target as HTMLInputElement | null;
if (!target) return;
const valor = Number(target.value);
if (Number.isNaN(valor)) return;
rangeInicioIndice = Math.min(valor, rangeFimIndice);
}
function handleRangeFim(event: Event) {
const target = event.target as HTMLInputElement | null;
if (!target) return;
const valor = Number(target.value);
if (Number.isNaN(valor)) return;
rangeFimIndice = Math.max(valor, rangeInicioIndice);
}
function limparPeriodoPersonalizado() {
filtroPeriodoInicio = '';
filtroPeriodoFim = '';
}
async function selecionarPeriodo(feriasId: Id<'ferias'>) {
periodoSelecionado = feriasId;
}
async function recarregar() {
periodoSelecionado = null;
}
let chartContainer: HTMLDivElement | null = null;
function converteParaData(dateStr: string): Date {
return new SvelteDate(`${dateStr}T00:00:00`);
}
function periodosNoIntervalo(inicio: Date, fim: Date): Array<PeriodoDetalhado> {
if (Number.isNaN(inicio.getTime()) || Number.isNaN(fim.getTime())) return [];
return periodosDetalhados
.filter((periodo) => {
const inicioPeriodo = new SvelteDate(periodo.dataInicio);
const fimPeriodo = new SvelteDate(periodo.dataFim);
return inicioPeriodo <= fim && fimPeriodo >= inicio;
})
.sort(
(a, b) =>
new SvelteDate(a.dataInicio).getTime() - new SvelteDate(b.dataInicio).getTime() ||
a.funcionarioNome.localeCompare(b.funcionarioNome, 'pt-BR')
);
}
function gerarRelatorioImpressao() {
if (!dataInicioRelatorio || !dataFimRelatorio) {
window.alert('Informe o período para gerar a programação de férias.');
return;
}
const inicio = converteParaData(dataInicioRelatorio);
const fim = converteParaData(dataFimRelatorio);
if (fim < inicio) {
window.alert('A data final não pode ser anterior à data inicial.');
return;
}
const periodosSelecionados = periodosNoIntervalo(inicio, fim);
if (periodosSelecionados.length === 0) {
window.alert('Não há férias registradas dentro do período informado.');
return;
}
const periodoLegivel = `${inicio.toLocaleDateString('pt-BR')} a ${fim.toLocaleDateString('pt-BR')}`;
const linhasTabela = periodosSelecionados
.map(
(periodo, index) => `
<tr>
<td>${index + 1}</td>
<td>${periodo.funcionarioNome}</td>
<td>${periodo.matricula ?? 'S/N'}</td>
<td>${periodo.timeNome ?? 'Sem time'}</td>
<td>${periodo.anoReferencia}</td>
<td>${formatarData(periodo.dataInicio)}</td>
<td>${formatarData(periodo.dataFim)}</td>
<td>${periodo.diasCorridos}</td>
<td>${getStatusTexto(periodo.status)}</td>
</tr>`
)
.join('');
const totalDias = periodosSelecionados.reduce((acc, periodo) => acc + periodo.diasCorridos, 0);
const relatorioHTML = `
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<title>Programação de Férias</title>
<style>
:root {
color-scheme: light;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
body {
margin: 0;
padding: 32px;
background: #f5f7fb;
color: #1f2933;
}
h1 {
margin-bottom: 4px;
}
h2 {
margin-top: 24px;
margin-bottom: 8px;
font-size: 1rem;
color: #52606d;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
background: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.08);
}
th, td {
padding: 12px 16px;
font-size: 0.95rem;
border-bottom: 1px solid #e5e7eb;
text-align: left;
}
th {
background: #eef2ff;
color: #1d4ed8;
text-transform: uppercase;
font-size: 0.72rem;
letter-spacing: 0.04em;
}
tr:last-child td {
border-bottom: none;
}
tfoot td {
font-weight: 600;
background: #f9fafb;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 16px;
}
.meta-item {
padding: 12px 18px;
background: #e0f2fe;
border-radius: 999px;
color: #0369a1;
font-weight: 600;
font-size: 0.9rem;
}
@media print {
body {
background: #ffffff;
}
table {
box-shadow: none;
}
}
</style>
</head>
<body>
<header>
<h1>Programação de Férias</h1>
<p>${periodoLegivel}</p>
</header>
<section class="meta">
<div class="meta-item">Total de colaboradores: ${
new Set(periodosSelecionados.map((periodo) => periodo.funcionarioNome)).size
}</div>
<div class="meta-item">Total de períodos: ${periodosSelecionados.length}</div>
<div class="meta-item">Dias planejados: ${totalDias}</div>
</section>
<table>
<thead>
<tr>
<th>#</th>
<th>Funcionário</th>
<th>Matrícula</th>
<th>Time</th>
<th>Ano Ref.</th>
<th>Início</th>
<th>Fim</th>
<th>Dias</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${linhasTabela}
</tbody>
<tfoot>
<tr>
<td colspan="7">Total planejado no período</td>
<td colspan="2">${totalDias} dia(s)</td>
</tr>
</tfoot>
</table>
</body>
</html>
`;
// Criar blob URL com o HTML para garantir que o conteúdo seja carregado corretamente
const blob = new Blob([relatorioHTML], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
// Abrir nova janela com o conteúdo
const relatorioJanela = window.open(url, 'programacao-ferias', 'width=1200,height=800');
if (
!relatorioJanela ||
relatorioJanela.closed ||
typeof relatorioJanela.closed === 'undefined'
) {
URL.revokeObjectURL(url);
window.alert(
'Pop-ups estão bloqueados. Por favor, permita pop-ups para este site e tente novamente.'
);
return;
}
// Função para limpar a URL do blob
const limparURL = () => {
try {
URL.revokeObjectURL(url);
} catch {
// Ignorar erros ao revogar URL
}
};
// Flag para garantir que a impressão aconteça apenas uma vez
let jaImprimiu = false;
// Função para imprimir (garantir que execute apenas uma vez)
const executarImpressao = () => {
if (jaImprimiu || relatorioJanela.closed) {
return;
}
jaImprimiu = true;
try {
relatorioJanela.focus();
relatorioJanela.print();
// Fechar janela após impressão
relatorioJanela.addEventListener(
'afterprint',
() => {
setTimeout(() => {
if (!relatorioJanela.closed) {
relatorioJanela.close();
}
limparURL();
}, 100);
},
{ once: true }
);
} catch (error) {
console.error('Erro ao imprimir:', error);
limparURL();
window.alert(
'Erro ao abrir diálogo de impressão. Verifique as configurações do navegador.'
);
}
};
// Aguardar o conteúdo carregar completamente antes de imprimir
// Usar evento load da janela quando disponível
const prepararImpressao = () => {
try {
if (relatorioJanela.closed) {
limparURL();
return;
}
// Aguardar um delay para garantir que o conteúdo foi renderizado
setTimeout(() => {
executarImpressao();
}, 600);
} catch (error) {
console.error('Erro ao preparar impressão:', error);
limparURL();
}
};
// Tentar usar evento load da janela
try {
relatorioJanela.addEventListener(
'load',
() => {
prepararImpressao();
},
{ once: true }
);
} catch {
// Se não conseguir adicionar listener, usar timeout direto
prepararImpressao();
}
// Fallback: se após 1.5 segundos ainda não imprimiu, tentar imprimir diretamente
setTimeout(() => {
if (!jaImprimiu && !relatorioJanela.closed) {
executarImpressao();
} else if (relatorioJanela.closed) {
limparURL();
}
}, 1500);
}
</script>
<main class="container mx-auto max-w-7xl px-4 py-6">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href={resolve('/recursos-humanos')} class="text-primary hover:underline"
>Recursos Humanos</a
>
</li>
<li>Gestão de Férias</li>
</ul>
</div>
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="rounded-xl bg-purple-500/20 p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-purple-600"
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>
<div>
<h1 class="text-primary text-3xl font-bold">Dashboard de Férias</h1>
<p class="text-base-content/70">Visão geral de todas as solicitações e funcionários</p>
</div>
</div>
<button class="btn gap-2" onclick={() => goto(resolve('/recursos-humanos'))}>
<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="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Voltar
</button>
</div>
</div>
<!-- Mensagem de erro -->
{#if hasError}
<div class="alert alert-error mb-6 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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h3 class="font-bold">Erro ao carregar dados</h3>
<div class="text-sm">{errorMessage}</div>
</div>
</div>
{/if}
<!-- Estatísticas -->
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
{#if isLoading && !hasError}
{#each Array.from({ length: 4 }, (_, i) => i) as index (index)}
<div class="stat bg-base-100 rounded-box border-base-300 border shadow-lg">
<div class="skeleton mb-2 h-8 w-8 rounded-full"></div>
<div class="skeleton mb-2 h-4 w-20"></div>
<div class="skeleton mb-2 h-8 w-16"></div>
<div class="skeleton h-4 w-24"></div>
</div>
{/each}
{:else}
<div class="stat bg-base-100 rounded-box border-base-300 border shadow-lg">
<div class="stat-figure text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div class="stat-title">Total</div>
<div class="stat-value text-primary">{stats.total}</div>
<div class="stat-desc">Solicitações</div>
</div>
<div class="stat bg-base-100 rounded-box border-warning/30 border shadow-lg">
<div class="stat-figure text-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
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>
<div class="stat-title">Aguardando</div>
<div class="stat-value text-warning">{stats.aguardando}</div>
<div class="stat-desc">Pendentes</div>
</div>
<div class="stat bg-base-100 rounded-box border-success/30 border shadow-lg">
<div class="stat-figure text-success">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="stat-title">Aprovadas</div>
<div class="stat-value text-success">{stats.aprovadas}</div>
<div class="stat-desc">Deferidas</div>
</div>
<div class="stat bg-base-100 rounded-box border-error/30 border shadow-lg">
<div class="stat-figure text-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="stat-title">Reprovadas</div>
<div class="stat-value text-error">{stats.reprovadas}</div>
<div class="stat-desc">Indeferidas</div>
</div>
{/if}
</div>
<!-- Filtros -->
{#if isLoading && !hasError}
<div class="card border-base-300/60 bg-base-100 mb-6 border shadow-xl">
<div class="card-body space-y-6">
<div class="skeleton h-6 w-32"></div>
<div class="grid grid-cols-12 gap-5">
{#each Array.from({ length: 6 }, (_, i) => i) as index (index)}
<div class="col-span-12 md:col-span-6 xl:col-span-4">
<div class="skeleton h-32 rounded-2xl"></div>
</div>
{/each}
</div>
</div>
</div>
{:else}
<div class="card border-base-300/60 bg-base-100 mb-6 border shadow-xl">
<div class="card-body space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-base-content text-lg font-semibold">Filtros</h2>
<button
type="button"
class="btn btn-sm btn-outline"
onclick={limparTodosFiltros}
disabled={filtroStatus === 'todos' &&
filtroNome.trim() === '' &&
filtroMatricula.trim() === '' &&
filtroEmail.trim() === '' &&
filtroMes === '' &&
filtroPeriodoInicio === '' &&
filtroPeriodoFim === ''}
>
Limpar tudo
</button>
</div>
<div class="grid grid-cols-12 gap-5">
<!-- Status -->
<div class="col-span-12 md:col-span-6 xl:col-span-4">
<div
class="border-base-300/60 bg-base-200/40 flex h-full flex-col gap-3 rounded-2xl border p-4 shadow-sm"
>
<div
class="text-base-content/80 flex items-center justify-between text-sm font-semibold"
>
<span>Status</span>
{#if filtroStatus !== 'todos'}
<button
type="button"
class="btn btn-xs btn-ghost text-primary"
onclick={() => (filtroStatus = 'todos')}
>
Limpar
</button>
{/if}
</div>
<select id="status" class="select select-bordered w-full" bind:value={filtroStatus}>
<option value="todos">Todos</option>
<option value="aguardando_aprovacao">Aguardando Aprovação</option>
<option value="aprovado">Aprovado</option>
<option value="reprovado">Reprovado</option>
<option value="data_ajustada_aprovada">Data Ajustada</option>
</select>
<p class="text-base-content/60 text-xs leading-relaxed">
Defina o status das solicitações que deseja visualizar.
</p>
</div>
</div>
<!-- Nome -->
<div class="col-span-12 md:col-span-6 xl:col-span-4">
<div
class="border-base-300/60 bg-base-200/40 flex h-full flex-col gap-3 rounded-2xl border p-4 shadow-sm"
>
<div
class="text-base-content/80 flex items-center justify-between text-sm font-semibold"
>
<span>Nome do funcionário</span>
<button
type="button"
class="btn btn-xs btn-ghost text-primary"
onclick={() => (filtroNome = '')}
disabled={filtroNome.trim() === ''}
>
Limpar
</button>
</div>
<input
id="filtro-nome"
class="input input-bordered w-full"
type="text"
placeholder="Ex.: Maria Souza"
bind:value={filtroNome}
autocomplete="off"
/>
<p class="text-base-content/60 text-xs leading-relaxed">
Pesquise por nome completo ou parcial para localizar rapidamente um colaborador.
</p>
</div>
</div>
<!-- Matrícula -->
<div class="col-span-12 md:col-span-6 xl:col-span-4">
<div
class="border-base-300/60 bg-base-200/40 flex h-full flex-col gap-3 rounded-2xl border p-4 shadow-sm"
>
<div
class="text-base-content/80 flex items-center justify-between text-sm font-semibold"
>
<span>Matrícula</span>
<button
type="button"
class="btn btn-xs btn-ghost text-primary"
onclick={() => (filtroMatricula = '')}
disabled={filtroMatricula.trim() === ''}
>
Limpar
</button>
</div>
<input
id="filtro-matricula"
class="input input-bordered w-full"
type="text"
placeholder="Informe a matrícula"
bind:value={filtroMatricula}
autocomplete="off"
/>
<p class="text-base-content/60 text-xs leading-relaxed">
Utilize a matrícula funcional para filtrar solicitações específicas.
</p>
</div>
</div>
<!-- E-mail -->
<div class="col-span-12 md:col-span-6 xl:col-span-4">
<div
class="border-base-300/60 bg-base-200/40 flex h-full flex-col gap-3 rounded-2xl border p-4 shadow-sm"
>
<div
class="text-base-content/80 flex items-center justify-between text-sm font-semibold"
>
<span>E-mail institucional</span>
<button
type="button"
class="btn btn-xs btn-ghost text-primary"
onclick={() => (filtroEmail = '')}
disabled={filtroEmail.trim() === ''}
>
Limpar
</button>
</div>
<input
id="filtro-email"
class="input input-bordered w-full"
type="text"
placeholder="nome.sobrenome@pe.gov.br"
bind:value={filtroEmail}
autocomplete="off"
/>
<p class="text-base-content/60 text-xs leading-relaxed">
Busque usando o correio institucional cadastrado na ficha do colaborador.
</p>
</div>
</div>
<!-- Mês -->
<div class="col-span-12 md:col-span-6 xl:col-span-4">
<div
class="border-base-300/60 bg-base-200/40 flex h-full flex-col gap-3 rounded-2xl border p-4 shadow-sm"
>
<div
class="text-base-content/80 flex items-center justify-between text-sm font-semibold"
>
<span>Mês de referência</span>
<button
type="button"
class="btn btn-xs btn-ghost text-primary"
onclick={() => (filtroMes = '')}
disabled={filtroMes === ''}
>
Limpar
</button>
</div>
<input
id="filtro-mes"
type="month"
class="input input-bordered w-full"
bind:value={filtroMes}
/>
<p class="text-base-content/60 text-xs leading-relaxed">
Filtra as solicitações que possuem períodos ativos dentro do mês informado.
</p>
</div>
</div>
<!-- Período personalizado -->
<div class="col-span-12 xl:col-span-8">
<div
class="border-base-300/60 bg-base-200/40 flex h-full flex-col gap-3 rounded-2xl border p-4 shadow-sm"
>
<div
class="text-base-content/80 flex items-center justify-between text-sm font-semibold"
>
<span>Período personalizado</span>
<button
type="button"
class="btn btn-xs btn-ghost text-primary"
onclick={limparPeriodoPersonalizado}
disabled={!filtroPeriodoInicio && !filtroPeriodoFim}
>
Limpar
</button>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-2">
<span class="text-base-content/60 text-xs font-medium tracking-wide uppercase"
>Data inicial</span
>
<input
type="date"
class="input input-bordered"
bind:value={filtroPeriodoInicio}
max={filtroPeriodoFim || undefined}
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-base-content/60 text-xs font-medium tracking-wide uppercase"
>Data final</span
>
<input
type="date"
class="input input-bordered"
bind:value={filtroPeriodoFim}
min={filtroPeriodoInicio || undefined}
/>
</div>
</div>
<p class="text-base-content/60 text-xs leading-relaxed">
Combine as datas para localizar períodos específicos de férias aprovadas ou em
andamento.
</p>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- Calendário Geral de Férias -->
{#if isLoading && !hasError}
<div class="card bg-base-100 border-primary/20 mb-6 border shadow-lg">
<div class="card-body space-y-4 p-6">
<div class="skeleton mb-4 h-6 w-64"></div>
<div class="skeleton h-96 w-full"></div>
</div>
</div>
{:else if !hasError}
<div class="card bg-base-100 border-primary/20 mb-6 border shadow-lg">
<div class="card-body space-y-4 p-6">
<div class="flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2.5">
<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>
<div>
<h3 class="text-base-content text-lg font-semibold">Calendário Geral de Férias</h3>
<p class="text-base-content/60 text-sm">
Visualize os períodos aprovados diretamente no calendário interativo
</p>
</div>
</div>
<div class="bg-base-200/40 relative w-full overflow-hidden rounded-xl p-4">
<div
class="calendario-ferias overflow-hidden rounded-xl shadow-2xl"
bind:this={calendarioContainer}
></div>
</div>
</div>
</div>
{/if}
<!-- Impressão da Programação -->
{#if !isLoading || !hasError}
<div class="card border-accent/30 bg-base-100 mb-6 border shadow-xl">
<div class="card-body space-y-4 p-6">
<div class="flex items-center gap-3">
<div class="bg-accent/10 rounded-lg p-2.5">
<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="M17 17h2a2 2 0 002-2v-5a2 2 0 00-2-2h-2V6a2 2 0 00-2-2H9a2 2 0 00-2 2v2H5a2 2 0 00-2 2v5a2 2 0 002 2h2m10 0v2a2 2 0 01-2 2H9a2 2 0 01-2-2v-2m10 0H7"
/>
</svg>
</div>
<div class="flex-1">
<h3 class="text-base-content text-lg font-semibold">
Impressão da Programação de Férias
</h3>
<p class="text-base-content/60 text-sm">
Escolha o período desejado e gere um relatório pronto para impressão com todos os
colaboradores em férias, incluindo detalhes completos de cada período.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="form-control">
<label class="label" for="dataInicio">
<span class="label-text font-medium">Data inicial</span>
</label>
<input
id="dataInicio"
type="date"
class="input input-bordered"
bind:value={dataInicioRelatorio}
/>
</div>
<div class="form-control">
<label class="label" for="dataFim">
<span class="label-text font-medium">Data final</span>
</label>
<input
id="dataFim"
type="date"
class="input input-bordered"
bind:value={dataFimRelatorio}
/>
</div>
<div class="flex items-end md:col-span-2">
<button
class="btn btn-accent w-full gap-2"
type="button"
onclick={gerarRelatorioImpressao}
>
<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="M17 17v4H7v-4m10 0h2a2 2 0 002-2v-5a2 2 0 00-2-2h-2V6a2 2 0 00-2-2H9a2 2 0 00-2 2v2H5a2 2 0 00-2 2v5a2 2 0 002 2h2m10 0H7"
/>
</svg>
Imprimir programação de Férias
</button>
</div>
</div>
<p class="text-base-content/60 text-xs">
O relatório será aberto em uma nova aba com formatação própria para impressão. Verifique
se o bloqueador de pop-ups está desabilitado para o domínio.
</p>
</div>
</div>
{/if}
<!-- Lista de Solicitações -->
{#if isLoading && !hasError}
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="skeleton mb-4 h-6 w-48"></div>
<div class="space-y-4">
{#each Array.from({ length: 5 }, (_, i) => i) as index (index)}
<div class="skeleton h-16 w-full"></div>
{/each}
</div>
</div>
</div>
{:else}
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title mb-4 text-lg">
Solicitações ({solicitacoesFiltradas.length})
</h2>
{#if solicitacoesFiltradas.length === 0}
<div class="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<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>
<span>Nenhuma solicitação encontrada com os filtros aplicados.</span>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead class="bg-base-200/60">
<tr class="text-base-content/70 text-sm tracking-wide uppercase">
<th>Funcionário</th>
<th>Time</th>
<th>Ano</th>
<th>Período</th>
<th>Dias</th>
<th>Status</th>
<th>Solicitado em</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each solicitacoesFiltradas as periodo (periodo._id)}
<tr>
<td>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content w-10 rounded-full">
<span class="text-xs"
>{periodo.funcionario?.nome.substring(0, 2).toUpperCase()}</span
>
</div>
</div>
<div>
<div class="font-bold">{periodo.funcionario?.nome}</div>
<div class="text-xs opacity-50">
{periodo.funcionario?.matricula || 'S/N'}
</div>
</div>
</div>
</td>
<td>
{#if periodo.time}
<div
class="badge badge-outline"
style="border-color: {periodo.time.cor}"
>
{periodo.time.nome}
</div>
{:else}
<span class="text-base-content/50 text-xs">Sem time</span>
{/if}
</td>
<td>{periodo.anoReferencia}</td>
<td>
{formatarDataString(periodo.dataInicio)} - {formatarDataString(periodo.dataFim)}
</td>
<td class="text-base-content font-bold">{periodo.diasFerias} dia(s)</td>
<td>
<div class={`badge ${getStatusBadge(periodo.status)}`}>
{getStatusTexto(periodo.status)}
</div>
</td>
<td class="text-xs">{formatarData(periodo._creationTime)}</td>
<td>
<button
type="button"
class="btn btn-primary btn-sm gap-2"
onclick={() => selecionarPeriodo(periodo._id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
Alterar Status
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
{/if}
<!-- Visualizações e Relatórios -->
{#if isLoading && !hasError}
<div class="mb-6 space-y-6" bind:this={chartContainer}>
{#each Array.from({ length: 3 }, (_, i) => i) as index (index)}
<div class="card bg-base-100 border-primary/20 border shadow-lg">
<div class="card-body space-y-4 p-6">
<div class="skeleton mb-4 h-6 w-64"></div>
<div class="skeleton h-64 w-full"></div>
</div>
</div>
{/each}
</div>
{:else}
<div class="mb-6 space-y-6" bind:this={chartContainer}>
<!-- Gráfico 1 -->
<div class="card bg-base-100 border-primary/20 border shadow-lg">
<div class="card-body space-y-4 p-6">
<div class="flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2.5">
<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="M3 4a1 1 0 011-1h4a1 1 0 01.894.553L10.382 6H19a1 1 0 011 1v2M3 4v16a1 1 0 001 1h16a1 1 0 001-1V9m-9 4h4m-4 4h4m-8-4h.01M9 17h.01"
/>
</svg>
</div>
<div>
<h3 class="text-base-content text-lg font-semibold">
Dias de Férias Programados por Mês
</h3>
<p class="text-base-content/60 text-sm">
Somatório de dias planejados considerando a data de início de cada período
</p>
</div>
</div>
<div class="bg-base-200/40 w-full overflow-x-auto rounded-xl p-4">
{#if periodosPorMesAtivos.length === 0}
<div class="flex h-96 items-center justify-center">
<p class="text-base-content/60">Sem dados registrados até o momento.</p>
</div>
{:else}
<BarChart3D data={chartDataMes()} height={400} />
{#if periodosPorMes.length > 1}
<div class="border-base-300/60 bg-base-200/60 mt-4 space-y-2 rounded-xl border p-4">
<div
class="text-base-content/60 flex flex-wrap items-center justify-between text-xs font-semibold tracking-wide uppercase"
>
<span>Janela exibida</span>
<span class="text-base-content">
{periodosPorMes[rangeInicioIndice]?.label ?? '-'}
{periodosPorMes[rangeFimIndice]?.label ?? '-'}
</span>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<label
class="text-base-content/70 w-14 text-xs font-medium"
for="range-inicio">Início</label
>
<input
id="range-inicio"
type="range"
min="0"
max={Math.max(periodosPorMes.length - 1, 0)}
value={rangeInicioIndice}
class="range range-primary flex-1"
oninput={handleRangeInicio}
/>
</div>
<div class="flex items-center gap-3">
<label class="text-base-content/70 w-14 text-xs font-medium" for="range-fim"
>Fim</label
>
<input
id="range-fim"
type="range"
min="0"
max={Math.max(periodosPorMes.length - 1, 0)}
value={rangeFimIndice}
class="range range-primary flex-1"
oninput={handleRangeFim}
/>
</div>
</div>
<p class="text-base-content/60 text-[11px]">
Ajuste com o mouse os intervalos exibidos no gráfico.
</p>
</div>
{/if}
{/if}
</div>
</div>
</div>
<!-- Gráfico 2 -->
<div class="card bg-base-100 border-secondary/20 border shadow-lg">
<div class="card-body space-y-4 p-6">
<div class="flex items-center gap-3">
<div class="bg-secondary/10 rounded-lg p-2.5">
<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="M9 17v2a2 2 0 002 2h5a2 2 0 002-2v-5M9 17v-4a2 2 0 012-2h2a2 2 0 012 2v4M9 17H7a2 2 0 01-2-2v-4a2 2 0 012-2h2m6-4h2a2 2 0 012 2v2a2 2 0 01-2 2h-2m-6-4v2a2 2 0 002 2h2a2 2 0 002-2V7m-6 0h6"
/>
</svg>
</div>
<div>
<h3 class="text-base-content text-lg font-semibold">
Dias Totais Aprovados por Ano de Referência
</h3>
<p class="text-base-content/60 text-sm">
Volume agregado de dias e número de solicitações por ano
</p>
</div>
</div>
<div class="bg-base-200/40 w-full overflow-x-auto rounded-xl p-4">
{#if solicitacoesPorAno.length === 0}
<div class="flex h-96 items-center justify-center">
<p class="text-base-content/60">
Ainda não há solicitações registradas para exibição.
</p>
</div>
{:else}
<BarChart3D data={chartDataAno()} height={400} stacked={true} />
{/if}
</div>
</div>
</div>
</div>
{/if}
</main>
<!-- Modal de Mudança de Status -->
{#if periodoSelecionado && currentUser.data}
{#await client.query( api.ferias.obterDetalhes, { feriasId: periodoSelecionado } ) then detalhes}
{#if detalhes}
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl">
<AlterarStatusFerias
solicitacao={detalhes}
usuarioId={currentUser.data._id}
onSucesso={recarregar}
onCancelar={() => (periodoSelecionado = null)}
/>
</div>
<form method="dialog" class="modal-backdrop">
<button
type="button"
onclick={() => (periodoSelecionado = null)}
aria-label="Fechar modal"
>
Fechar
</button>
</form>
</dialog>
{/if}
{/await}
{/if}
<footer
class="border-base-300/60 bg-base-100 text-base-content/70 mt-8 border-t py-6 text-center text-sm"
>
SGSE - Sistema de Gerenciamento da Secretaria de Esportes.
</footer>
<style>
/* Calendário de Férias */
.calendario-ferias {
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
/* Toolbar moderna com cores azuis/primary */
:global(.calendario-ferias .fc .fc-toolbar) {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
padding: 1rem;
border-radius: 1rem 1rem 0 0;
color: white !important;
}
:global(.calendario-ferias .fc .fc-toolbar-title) {
color: white !important;
font-weight: 700;
font-size: 1.5rem;
}
:global(.calendario-ferias .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-ferias .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-ferias .fc .fc-button-active) {
background: rgba(255, 255, 255, 0.4) !important;
}
/* Cabeçalho dos dias */
:global(.calendario-ferias .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-ferias .fc .fc-daygrid-day) {
transition: all 0.2s ease;
}
:global(.calendario-ferias .fc .fc-daygrid-day:hover) {
background: rgba(37, 99, 235, 0.05);
}
:global(.calendario-ferias .fc .fc-daygrid-day-number) {
padding: 0.5rem;
font-weight: 600;
color: #495057;
}
/* Fim de semana */
:global(.calendario-ferias .fc .fc-day-weekend) {
background: rgba(59, 130, 246, 0.05);
}
/* Hoje */
:global(.calendario-ferias .fc .fc-day-today) {
border: 2px solid #2563eb !important;
}
/* Eventos (férias) */
:global(.calendario-ferias .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-ferias .fc .fc-event:hover) {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
/* Seleção (arrastar) */
:global(.calendario-ferias .fc .fc-highlight) {
background: rgba(37, 99, 235, 0.3) !important;
border: 2px dashed #2563eb;
}
/* Datas desabilitadas (passado) */
:global(.calendario-ferias .fc .fc-day-past .fc-daygrid-day-number) {
opacity: 0.4;
}
/* Remover bordas padrão */
:global(.calendario-ferias .fc .fc-scrollgrid) {
border: none !important;
}
:global(.calendario-ferias .fc .fc-scrollgrid-section > td) {
border: none !important;
}
/* Grid moderno */
:global(.calendario-ferias .fc .fc-daygrid-day-frame) {
border: 1px solid #e9ecef;
min-height: 80px;
}
/* Garantir que o calendário seja visível */
:global(.calendario-ferias .fc) {
min-height: 400px;
width: 100%;
}
/* Responsivo */
@media (max-width: 768px) {
:global(.calendario-ferias .fc .fc-toolbar) {
flex-direction: column;
gap: 0.75rem;
}
:global(.calendario-ferias .fc .fc-toolbar-title) {
font-size: 1.25rem;
}
:global(.calendario-ferias .fc .fc-button) {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
}
</style>