Files
sgse-app/apps/web/src/routes/(dashboard)/recursos-humanos/atestados-licencas/+page.svelte
deyvisonwanderley ebde59c6d2 refactor: enhance vacation management components and add status update functionality
- Improved the vacation request component with better loading states and error handling.
- Added a new mutation to update the status of vacation requests, allowing transitions between different states.
- Enhanced the calendar display for vacation periods and integrated a 3D bar chart for visualizing vacation data.
- Refactored the code for better readability and maintainability, ensuring a smoother user experience.
2025-11-13 05:51:55 -03:00

1674 lines
49 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { toast } from 'svelte-sonner';
import FuncionarioSelect from '$lib/components/FuncionarioSelect.svelte';
import FileUpload from '$lib/components/FileUpload.svelte';
import ErrorModal from '$lib/components/ErrorModal.svelte';
import CalendarioAfastamentos from '$lib/components/CalendarioAfastamentos.svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
const client = useConvexClient();
// Estado da aba ativa
let abaAtiva = $state<'dashboard' | 'atestado' | 'declaracao' | 'maternidade' | 'paternidade'>(
'dashboard'
);
// Queries
const dadosQuery = useQuery(api.atestadosLicencas.listarTodos, {});
const statsQuery = useQuery(api.atestadosLicencas.obterEstatisticas, {});
const graficosQuery = useQuery(api.atestadosLicencas.obterDadosGraficos, {
periodo: 30
});
const eventosQuery = useQuery(api.atestadosLicencas.obterEventosCalendario, {
tipoFiltro: 'todos'
});
// Estados dos formulários
// Atestado Médico
let atestadoMedico = $state({
funcionarioId: '' as string | undefined,
dataInicio: '',
dataFim: '',
cid: '',
observacoes: '',
documentoId: '' as string | undefined
});
// Declaração
let declaracao = $state({
funcionarioId: '' as string | undefined,
dataInicio: '',
dataFim: '',
observacoes: '',
documentoId: '' as string | undefined
});
// Licença Maternidade
let licencaMaternidade = $state({
funcionarioId: '' as string | undefined,
dataInicio: '',
dataFim: '',
observacoes: '',
documentoId: '' as string | undefined,
ehProrrogacao: false,
licencaOriginalId: undefined as string | undefined
});
// Licença Paternidade
let licencaPaternidade = $state({
funcionarioId: '' as string | undefined,
dataInicio: '',
dataFim: '',
observacoes: '',
documentoId: '' as string | undefined
});
// Filtros
let filtroTipo = $state<string>('todos');
let filtroFuncionario = $state<string>('');
let filtroDataInicio = $state<string>('');
let filtroDataFim = $state<string>('');
// Estados de loading
let salvandoAtestado = $state(false);
let salvandoDeclaracao = $state(false);
let salvandoMaternidade = $state(false);
let salvandoPaternidade = $state(false);
// Modal de erro
let erroModal = $state({
aberto: false,
titulo: '',
mensagem: '',
detalhes: ''
});
// Licenças maternidade para prorrogação (derivar dos dados já carregados)
const licencasMaternidade = $derived.by(() => {
const dados = dadosQuery?.data;
if (!dados) return [];
return dados.licencas.filter((l) => l.tipo === 'maternidade' && !l.ehProrrogacao);
});
// Funções auxiliares
function formatarData(data: string) {
return new Date(data).toLocaleDateString('pt-BR');
}
function calcularDias(dataInicio: string, dataFim: string): number {
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
}
// Upload de documento
async function handleDocumentoUpload(file: File): Promise<string | undefined> {
try {
const uploadUrl = await client.mutation(api.atestadosLicencas.generateUploadUrl, {});
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file
});
const result = await response.json();
return result.storageId;
} catch (error) {
console.error('Erro no upload:', error);
throw error;
}
}
// Função para mostrar erro em modal
function mostrarErro(titulo: string, mensagem: string, detalhes?: string) {
erroModal = {
aberto: true,
titulo,
mensagem,
detalhes: detalhes || ''
};
}
// Salvar Atestado Médico
async function salvarAtestadoMedico() {
if (
!atestadoMedico.funcionarioId ||
!atestadoMedico.dataInicio ||
!atestadoMedico.dataFim ||
!atestadoMedico.cid
) {
mostrarErro(
'Campos obrigatórios',
'Preencha todos os campos obrigatórios antes de salvar.',
'Campos obrigatórios: Funcionário, Data Início, Data Fim e CID'
);
return;
}
if (!atestadoMedico.documentoId) {
mostrarErro(
'Documento obrigatório',
'É necessário anexar o documento do atestado médico.',
'Por favor, faça o upload do arquivo PDF ou imagem do atestado antes de salvar.'
);
return;
}
try {
salvandoAtestado = true;
await client.mutation(api.atestadosLicencas.criarAtestadoMedico, {
funcionarioId: atestadoMedico.funcionarioId as Id<'funcionarios'>,
dataInicio: atestadoMedico.dataInicio,
dataFim: atestadoMedico.dataFim,
cid: atestadoMedico.cid,
observacoes: atestadoMedico.observacoes || undefined,
documentoId: atestadoMedico.documentoId as Id<'_storage'>
});
toast.success('Atestado médico registrado com sucesso!');
resetarFormularioAtestado();
abaAtiva = 'dashboard';
} catch (error: any) {
const mensagemErro = error?.message || 'Erro ao registrar atestado';
const detalhesErro = error?.data?.message || error?.toString() || '';
mostrarErro('Erro ao registrar', mensagemErro, detalhesErro);
} finally {
salvandoAtestado = false;
}
}
// Salvar Declaração
async function salvarDeclaracao() {
if (!declaracao.funcionarioId || !declaracao.dataInicio || !declaracao.dataFim) {
mostrarErro(
'Campos obrigatórios',
'Preencha todos os campos obrigatórios antes de salvar.',
'Campos obrigatórios: Funcionário, Data Início e Data Fim'
);
return;
}
if (!declaracao.documentoId) {
mostrarErro(
'Documento obrigatório',
'É necessário anexar o documento da declaração.',
'Por favor, faça o upload do arquivo PDF ou imagem da declaração antes de salvar.'
);
return;
}
try {
salvandoDeclaracao = true;
await client.mutation(api.atestadosLicencas.criarDeclaracaoComparecimento, {
funcionarioId: declaracao.funcionarioId as Id<'funcionarios'>,
dataInicio: declaracao.dataInicio,
dataFim: declaracao.dataFim,
observacoes: declaracao.observacoes || undefined,
documentoId: declaracao.documentoId as Id<'_storage'>
});
toast.success('Declaração registrada com sucesso!');
resetarFormularioDeclaracao();
abaAtiva = 'dashboard';
} catch (error: any) {
const mensagemErro = error?.message || 'Erro ao registrar declaração';
const detalhesErro = error?.data?.message || error?.toString() || '';
mostrarErro('Erro ao registrar', mensagemErro, detalhesErro);
} finally {
salvandoDeclaracao = false;
}
}
// Salvar Licença Maternidade
async function salvarLicencaMaternidade() {
if (
!licencaMaternidade.funcionarioId ||
!licencaMaternidade.dataInicio ||
!licencaMaternidade.dataFim
) {
mostrarErro(
'Campos obrigatórios',
'Preencha todos os campos obrigatórios antes de salvar.',
'Campos obrigatórios: Funcionário, Data Início e Data Fim'
);
return;
}
if (licencaMaternidade.ehProrrogacao && !licencaMaternidade.licencaOriginalId) {
mostrarErro(
'Licença original obrigatória',
'Para prorrogações, é necessário selecionar a licença original.',
"Selecione a licença de maternidade original no campo 'Licença Original'."
);
return;
}
if (!licencaMaternidade.documentoId) {
mostrarErro(
'Documento obrigatório',
'É necessário anexar o documento da licença de maternidade.',
'Por favor, faça o upload do arquivo PDF ou imagem da licença antes de salvar.'
);
return;
}
try {
salvandoMaternidade = true;
// Garantir que licencaOriginalId seja undefined quando não é prorrogação
const licencaOriginalId =
licencaMaternidade.ehProrrogacao && licencaMaternidade.licencaOriginalId
? (licencaMaternidade.licencaOriginalId as Id<'licencas'>)
: undefined;
await client.mutation(api.atestadosLicencas.criarLicencaMaternidade, {
funcionarioId: licencaMaternidade.funcionarioId as Id<'funcionarios'>,
dataInicio: licencaMaternidade.dataInicio,
dataFim: licencaMaternidade.dataFim,
observacoes: licencaMaternidade.observacoes || undefined,
documentoId: licencaMaternidade.documentoId as Id<'_storage'>,
licencaOriginalId
});
toast.success('Licença maternidade registrada com sucesso!');
resetarFormularioMaternidade();
abaAtiva = 'dashboard';
} catch (error: any) {
const mensagemErro = error?.message || 'Erro ao registrar licença';
const detalhesErro = error?.data?.message || error?.toString() || '';
mostrarErro('Erro ao registrar', mensagemErro, detalhesErro);
} finally {
salvandoMaternidade = false;
}
}
// Salvar Licença Paternidade
async function salvarLicencaPaternidade() {
if (
!licencaPaternidade.funcionarioId ||
!licencaPaternidade.dataInicio ||
!licencaPaternidade.dataFim
) {
mostrarErro(
'Campos obrigatórios',
'Preencha todos os campos obrigatórios antes de salvar.',
'Campos obrigatórios: Funcionário, Data Início e Data Fim'
);
return;
}
if (!licencaPaternidade.documentoId) {
mostrarErro(
'Documento obrigatório',
'É necessário anexar o documento da licença de paternidade.',
'Por favor, faça o upload do arquivo PDF ou imagem da licença antes de salvar.'
);
return;
}
try {
salvandoPaternidade = true;
await client.mutation(api.atestadosLicencas.criarLicencaPaternidade, {
funcionarioId: licencaPaternidade.funcionarioId as Id<'funcionarios'>,
dataInicio: licencaPaternidade.dataInicio,
dataFim: licencaPaternidade.dataFim,
observacoes: licencaPaternidade.observacoes || undefined,
documentoId: licencaPaternidade.documentoId as Id<'_storage'>
});
toast.success('Licença paternidade registrada com sucesso!');
resetarFormularioPaternidade();
abaAtiva = 'dashboard';
} catch (error: any) {
const mensagemErro = error?.message || 'Erro ao registrar licença';
const detalhesErro = error?.data?.message || error?.toString() || '';
mostrarErro('Erro ao registrar', mensagemErro, detalhesErro);
} finally {
salvandoPaternidade = false;
}
}
// Resetar formulários
function resetarFormularioAtestado() {
atestadoMedico = {
funcionarioId: undefined,
dataInicio: '',
dataFim: '',
cid: '',
observacoes: '',
documentoId: undefined
};
}
function resetarFormularioDeclaracao() {
declaracao = {
funcionarioId: undefined,
dataInicio: '',
dataFim: '',
observacoes: '',
documentoId: undefined
};
}
function resetarFormularioMaternidade() {
licencaMaternidade = {
funcionarioId: undefined,
dataInicio: '',
dataFim: '',
observacoes: '',
documentoId: undefined,
ehProrrogacao: false,
licencaOriginalId: undefined
};
}
function resetarFormularioPaternidade() {
licencaPaternidade = {
funcionarioId: undefined,
dataInicio: '',
dataFim: '',
observacoes: '',
documentoId: undefined
};
}
// Limpar licencaOriginalId quando não é prorrogação
$effect(() => {
if (abaAtiva === 'maternidade' && !licencaMaternidade.ehProrrogacao) {
licencaMaternidade.licencaOriginalId = undefined;
}
});
// Calcular data fim automaticamente para licenças
$effect(() => {
if (
abaAtiva === 'maternidade' &&
licencaMaternidade.dataInicio &&
!licencaMaternidade.ehProrrogacao &&
!licencaMaternidade.dataFim
) {
const inicio = new Date(licencaMaternidade.dataInicio);
if (!isNaN(inicio.getTime())) {
inicio.setDate(inicio.getDate() + 120); // 120 dias
licencaMaternidade.dataFim = inicio.toISOString().split('T')[0];
}
}
});
$effect(() => {
if (
abaAtiva === 'paternidade' &&
licencaPaternidade.dataInicio &&
!licencaPaternidade.dataFim
) {
const inicio = new Date(licencaPaternidade.dataInicio);
if (!isNaN(inicio.getTime())) {
inicio.setDate(inicio.getDate() + 20); // 20 dias
licencaPaternidade.dataFim = inicio.toISOString().split('T')[0];
}
}
});
// Excluir registro
async function excluirRegistro(tipo: 'atestado' | 'licenca', id: string) {
if (!confirm(`Tem certeza que deseja excluir este ${tipo}?`)) return;
try {
if (tipo === 'atestado') {
await client.mutation(api.atestadosLicencas.excluirAtestado, { id: id as Id<'atestados'> });
} else {
await client.mutation(api.atestadosLicencas.excluirLicenca, { id: id as Id<'licencas'> });
}
toast.success('Registro excluído com sucesso!');
} catch (error: any) {
toast.error(error?.message || 'Erro ao excluir registro');
}
}
// Filtrar registros
const registrosFiltrados = $derived.by(() => {
const dados = dadosQuery?.data;
if (!dados) return { atestados: [], licencas: [] };
let atestados = dados.atestados;
let licencas = dados.licencas;
// Filtro por tipo
if (filtroTipo !== 'todos') {
if (filtroTipo === 'atestado_medico' || filtroTipo === 'declaracao_comparecimento') {
atestados = atestados.filter((a) => a.tipo === filtroTipo);
licencas = [];
} else if (filtroTipo === 'maternidade' || filtroTipo === 'paternidade') {
atestados = [];
licencas = licencas.filter((l) => l.tipo === filtroTipo);
}
}
// Filtro por funcionário
if (filtroFuncionario) {
const termo = filtroFuncionario.toLowerCase();
atestados = atestados.filter((a) => a.funcionario?.nome?.toLowerCase().includes(termo));
licencas = licencas.filter((l) => l.funcionario?.nome?.toLowerCase().includes(termo));
}
// Filtro por período
if (filtroDataInicio && filtroDataFim) {
const inicio = new Date(filtroDataInicio);
const fim = new Date(filtroDataFim);
atestados = atestados.filter((a) => {
const aInicio = new Date(a.dataInicio);
const aFim = new Date(a.dataFim);
return (
(aInicio >= inicio && aInicio <= fim) ||
(aFim >= inicio && aFim <= fim) ||
(aInicio <= inicio && aFim >= fim)
);
});
licencas = licencas.filter((l) => {
const lInicio = new Date(l.dataInicio);
const lFim = new Date(l.dataFim);
return (
(lInicio >= inicio && lInicio <= fim) ||
(lFim >= inicio && lFim <= fim) ||
(lInicio <= inicio && lFim >= fim)
);
});
}
return { atestados, licencas };
});
// Limpar filtros
function limparFiltros() {
filtroTipo = 'todos';
filtroFuncionario = '';
filtroDataInicio = '';
filtroDataFim = '';
}
</script>
<svelte:head>
<title>Atestados & Licenças - Recursos Humanos</title>
</svelte:head>
<main class="container mx-auto max-w-7xl px-4 py-6">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href="/recursos-humanos" class="text-primary hover:underline">Recursos Humanos</a>
</li>
<li>Atestados & Licenças</li>
</ul>
</div>
<!-- Header -->
<div class="mb-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<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="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>
<h1 class="text-primary text-3xl font-bold">Atestados & Licenças</h1>
<p class="text-base-content/70">Registro de atestados médicos e licenças</p>
</div>
</div>
<button class="btn gap-2" onclick={() => goto('/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>
<!-- Tabs -->
<div class="tabs tabs-boxed bg-base-100 mb-6 p-2 shadow-lg">
<button
class="tab {abaAtiva === 'dashboard' ? 'tab-active' : ''}"
onclick={() => (abaAtiva = 'dashboard')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
Dashboard
</button>
<button
class="tab {abaAtiva === 'atestado' ? 'tab-active' : ''}"
onclick={() => (abaAtiva = 'atestado')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5"
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>
Atestado Médico
</button>
<button
class="tab {abaAtiva === 'declaracao' ? 'tab-active' : ''}"
onclick={() => (abaAtiva = 'declaracao')}
>
Declaração
</button>
<button
class="tab {abaAtiva === 'maternidade' ? 'tab-active' : ''}"
onclick={() => (abaAtiva = 'maternidade')}
>
Licença Maternidade
</button>
<button
class="tab {abaAtiva === 'paternidade' ? 'tab-active' : ''}"
onclick={() => (abaAtiva = 'paternidade')}
>
Licença Paternidade
</button>
</div>
<!-- Conteúdo das Abas -->
{#if abaAtiva === 'dashboard'}
<!-- Dashboard -->
<!-- Estatísticas -->
{#if statsQuery?.data}
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="stat bg-base-100 rounded-box border-base-300 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="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">Atestados Ativos</div>
<div class="stat-value text-error">
{statsQuery.data.totalAtestadosAtivos}
</div>
</div>
<div class="stat bg-base-100 rounded-box border-base-300 border shadow-lg">
<div class="stat-figure text-secondary">
<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="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 class="stat-title">Licenças Ativas</div>
<div class="stat-value text-secondary">
{statsQuery.data.totalLicencasAtivas}
</div>
</div>
<div class="stat bg-base-100 rounded-box border-base-300 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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<div class="stat-title">Afastados Hoje</div>
<div class="stat-value text-warning">
{statsQuery.data.funcionariosAfastadosHoje}
</div>
</div>
<div class="stat bg-base-100 rounded-box border-base-300 border shadow-lg">
<div class="stat-figure text-info">
<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="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 class="stat-title">Dias no Mês</div>
<div class="stat-value text-info">
{statsQuery.data.totalDiasAfastamentoMes}
</div>
</div>
</div>
{/if}
<!-- Filtros -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Filtros</h2>
<div class="grid grid-cols-1 items-end gap-4 md:grid-cols-5">
<div class="form-control">
<label class="label flex cursor-pointer flex-col gap-2" for="filtro-tipo">
<span class="label-text">Tipo</span>
<select id="filtro-tipo" class="select select-bordered" bind:value={filtroTipo}>
<option value="todos">Todos</option>
<option value="atestado_medico">Atestado Médico</option>
<option value="declaracao_comparecimento">Declaração</option>
<option value="maternidade">Licença Maternidade</option>
<option value="paternidade">Licença Paternidade</option>
</select>
</label>
</div>
<div class="form-control">
<label class="label flex cursor-pointer flex-col gap-2" for="filtro-funcionario">
<span class="label-text">Funcionário</span>
<input
id="filtro-funcionario"
class="input input-bordered"
type="text"
bind:value={filtroFuncionario}
placeholder="Nome do colaborador"
/>
</label>
</div>
<div class="form-control">
<label class="label flex cursor-pointer flex-col gap-2" for="filtro-data-inicio">
<span class="label-text">Data Início</span>
<input
id="filtro-data-inicio"
class="input input-bordered"
type="date"
bind:value={filtroDataInicio}
/>
</label>
</div>
<div class="form-control">
<label class="label flex cursor-pointer flex-col gap-2" for="filtro-data-fim">
<span class="label-text">Data Fim</span>
<input
id="filtro-data-fim"
class="input input-bordered"
type="date"
bind:value={filtroDataFim}
/>
</label>
</div>
<div class="flex justify-end gap-2">
<button class="btn btn-outline" onclick={limparFiltros}>Limpar</button>
</div>
</div>
</div>
</div>
<!-- Calendário Interativo -->
{#if eventosQuery?.data}
<div class="mb-6">
<CalendarioAfastamentos eventos={eventosQuery.data} tipoFiltro={filtroTipo} />
</div>
{/if}
<!-- Lista de Funcionários Afastados -->
{#if graficosQuery?.data}
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Funcionários Atualmente Afastados</h2>
{#if graficosQuery.data.funcionariosAfastados.length > 0}
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead>
<tr>
<th>Funcionário</th>
<th>Tipo</th>
<th>Data Início</th>
<th>Data Fim</th>
</tr>
</thead>
<tbody>
{#each graficosQuery.data.funcionariosAfastados as item}
<tr>
<td>{item.funcionarioNome}</td>
<td>
<span
class="badge {item.tipo === 'atestado_medico'
? 'badge-error'
: item.tipo === 'declaracao_comparecimento'
? 'badge-warning'
: item.tipo === 'maternidade'
? 'badge-secondary'
: item.tipo === 'paternidade'
? 'badge-info'
: 'badge-success'}"
>
{item.tipo === 'atestado_medico'
? 'Atestado Médico'
: item.tipo === 'declaracao_comparecimento'
? 'Declaração'
: item.tipo === 'maternidade'
? 'Licença Maternidade'
: item.tipo === 'paternidade'
? 'Licença Paternidade'
: item.tipo}
</span>
</td>
<td>{formatarData(item.dataInicio)}</td>
<td>{formatarData(item.dataFim)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="text-base-content/60 py-10 text-center">
Nenhum funcionário afastado no momento
</div>
{/if}
</div>
</div>
{/if}
<!-- Tabela de Registros -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Registros</h2>
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead>
<tr>
<th>Funcionário</th>
<th>Tipo</th>
<th>Data Início</th>
<th>Data Fim</th>
<th>Dias</th>
<th>Status</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each registrosFiltrados.atestados as atestado}
<tr>
<td>{atestado.funcionario?.nome || '-'}</td>
<td>
<span
class="badge {atestado.tipo === 'atestado_medico'
? 'badge-error'
: 'badge-warning'}"
>
{atestado.tipo === 'atestado_medico' ? 'Atestado Médico' : 'Declaração'}
</span>
</td>
<td class="font-mono text-xs whitespace-nowrap"
>{formatarData(atestado.dataInicio)}</td
>
<td class="font-mono text-xs whitespace-nowrap"
>{formatarData(atestado.dataFim)}</td
>
<td>{atestado.dias}</td>
<td>
<span
class="badge {atestado.status === 'ativo'
? 'badge-success'
: 'badge-neutral'}"
>
{atestado.status === 'ativo' ? 'Ativo' : 'Finalizado'}
</span>
</td>
<td>
<div class="flex gap-2">
{#if atestado.documentoId}
<button
class="btn btn-xs btn-ghost"
onclick={async () => {
try {
const url = await client.query(
api.atestadosLicencas.obterUrlDocumento,
{
storageId: atestado.documentoId as any
}
);
if (url) {
window.open(url, '_blank');
} else {
mostrarErro(
'Erro ao visualizar documento',
'Não foi possível obter a URL do documento.',
'O documento pode ter sido removido ou não existe mais.'
);
}
} catch (err: any) {
console.error('Erro ao obter URL do documento:', err);
mostrarErro(
'Erro ao visualizar documento',
'Não foi possível abrir o documento.',
err?.message || err?.toString() || 'Erro desconhecido'
);
}
}}
>
Ver Doc
</button>
{/if}
<button
class="btn btn-xs btn-error"
onclick={() => excluirRegistro('atestado', atestado._id)}
>
Excluir
</button>
</div>
</td>
</tr>
{/each}
{#each registrosFiltrados.licencas as licenca}
<tr>
<td>{licenca.funcionario?.nome || '-'}</td>
<td>
<span
class="badge {licenca.tipo === 'maternidade'
? 'badge-secondary'
: 'badge-info'}"
>
Licença{' '}
{licenca.tipo === 'maternidade' ? 'Maternidade' : 'Paternidade'}
{licenca.ehProrrogacao ? ' (Prorrogação)' : ''}
</span>
</td>
<td class="font-mono text-xs whitespace-nowrap"
>{formatarData(licenca.dataInicio)}</td
>
<td class="font-mono text-xs whitespace-nowrap"
>{formatarData(licenca.dataFim)}</td
>
<td>{licenca.dias}</td>
<td>
<span
class="badge {licenca.status === 'ativo' ? 'badge-success' : 'badge-neutral'}"
>
{licenca.status === 'ativo' ? 'Ativo' : 'Finalizado'}
</span>
</td>
<td>
<div class="flex gap-2">
{#if licenca.documentoId}
<button
class="btn btn-xs btn-ghost"
onclick={async () => {
try {
const url = await client.query(
api.atestadosLicencas.obterUrlDocumento,
{
storageId: licenca.documentoId as any
}
);
if (url) {
window.open(url, '_blank');
} else {
mostrarErro(
'Erro ao visualizar documento',
'Não foi possível obter a URL do documento.',
'O documento pode ter sido removido ou não existe mais.'
);
}
} catch (err: any) {
console.error('Erro ao obter URL do documento:', err);
mostrarErro(
'Erro ao visualizar documento',
'Não foi possível abrir o documento.',
err?.message || err?.toString() || 'Erro desconhecido'
);
}
}}
>
Ver Doc
</button>
{/if}
<button
class="btn btn-xs btn-error"
onclick={() => excluirRegistro('licenca', licenca._id)}
>
Excluir
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{#if registrosFiltrados.atestados.length === 0 && registrosFiltrados.licencas.length === 0}
<div class="text-base-content/60 py-10 text-center">Nenhum registro encontrado</div>
{/if}
</div>
</div>
</div>
<!-- Gráficos -->
{#if graficosQuery?.data}
{@const dados = graficosQuery.data.totalDiasPorTipo}
{@const maxDias = Math.max(...dados.map((d) => d.dias), 1)}
{@const chartWidth = 800}
{@const chartHeight = 350}
{@const padding = { top: 20, right: 40, bottom: 80, left: 70 }}
{@const barWidth = (chartWidth - padding.left - padding.right) / dados.length - 10}
{@const innerHeight = chartHeight - padding.top - padding.bottom}
{@const tendencias = graficosQuery.data.tendenciasMensais}
{@const tipos = [
'atestado_medico',
'declaracao_comparecimento',
'maternidade',
'paternidade',
'ferias'
]}
{@const cores = ['#ef4444', '#f97316', '#ec4899', '#3b82f6', '#10b981']}
{@const nomes = ['Atestado Médico', 'Declaração', 'Maternidade', 'Paternidade', 'Férias']}
{@const maxValor = Math.max(
...tendencias.flatMap((t) => tipos.map((tipo) => t[tipo as keyof typeof t] as number)),
1
)}
{@const chartWidth2 = 900}
{@const chartHeight2 = 400}
{@const padding2 = { top: 20, right: 40, bottom: 80, left: 70 }}
{@const innerWidth = chartWidth2 - padding2.left - padding2.right}
{@const innerHeight2 = chartHeight2 - padding2.top - padding2.bottom}
<!-- Gráfico 1: Total de Dias por Tipo (Gráfico de Barras) -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Total de Dias por Tipo</h2>
<div class="bg-base-200/30 w-full overflow-x-auto rounded-xl p-4">
<svg
width={chartWidth}
height={chartHeight}
class="w-full"
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
>
<!-- Grid lines -->
{#each [0, 1, 2, 3, 4, 5] as t}
{@const val = Math.round((maxDias / 5) * t)}
{@const y = chartHeight - padding.bottom - (val / maxDias) * innerHeight}
<line
x1={padding.left}
y1={y}
x2={chartWidth - padding.right}
y2={y}
stroke="currentColor"
stroke-opacity="0.1"
stroke-dasharray="4,4"
/>
<text x={padding.left - 8} y={y + 4} text-anchor="end" class="text-xs opacity-70">
{val}
</text>
{/each}
<!-- Eixos -->
<line
x1={padding.left}
y1={chartHeight - padding.bottom}
x2={chartWidth - padding.right}
y2={chartHeight - padding.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<line
x1={padding.left}
y1={padding.top}
x2={padding.left}
y2={chartHeight - padding.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<!-- Barras -->
{#each dados as item, i}
{@const x = padding.left + i * (barWidth + 10) + 5}
{@const height = (item.dias / maxDias) * innerHeight}
{@const y = chartHeight - padding.bottom - height}
{@const colors = ['#ef4444', '#f97316', '#ec4899', '#3b82f6', '#10b981']}
<!-- Gradiente da barra -->
<defs>
<linearGradient id="gradient-{i}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop
offset="0%"
style="stop-color:{colors[i % colors.length]};stop-opacity:0.9"
/>
<stop
offset="100%"
style="stop-color:{colors[i % colors.length]};stop-opacity:0.5"
/>
</linearGradient>
</defs>
<!-- Barra -->
<rect
{x}
{y}
width={barWidth}
{height}
fill="url(#gradient-{i})"
rx="4"
class="cursor-pointer transition-opacity hover:opacity-80"
/>
<!-- Valor no topo da barra -->
{#if item.dias > 0}
<text
x={x + barWidth / 2}
y={y - 8}
text-anchor="middle"
class="fill-base-content text-xs font-semibold"
>
{item.dias}
</text>
{/if}
<!-- Label do eixo X -->
<foreignObject
x={x - 30}
y={chartHeight - padding.bottom + 15}
width="80"
height="60"
>
<div class="flex items-center justify-center text-center">
<span
class="text-base-content/80 text-xs leading-tight font-medium"
style="word-wrap: break-word; hyphens: auto;"
>
{item.tipo}
</span>
</div>
</foreignObject>
{/each}
</svg>
</div>
</div>
</div>
<!-- Gráfico 2: Tendências Mensais (Gráfico de Linha em Camadas) -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Tendências Mensais (Últimos 6 Meses)</h2>
<div class="bg-base-200/30 w-full overflow-x-auto rounded-xl p-4">
<svg
width={chartWidth2}
height={chartHeight2}
class="w-full"
viewBox={`0 0 ${chartWidth2} ${chartHeight2}`}
>
<!-- Grid lines -->
{#each [0, 1, 2, 3, 4, 5] as t}
{@const val = Math.round((maxValor / 5) * t)}
{@const y = chartHeight2 - padding2.bottom - (val / maxValor) * innerHeight2}
<line
x1={padding2.left}
y1={y}
x2={chartWidth2 - padding2.right}
y2={y}
stroke="currentColor"
stroke-opacity="0.1"
stroke-dasharray="4,4"
/>
<text x={padding2.left - 8} y={y + 4} text-anchor="end" class="text-xs opacity-70">
{val}
</text>
{/each}
<!-- Eixos -->
<line
x1={padding2.left}
y1={chartHeight2 - padding2.bottom}
x2={chartWidth2 - padding2.right}
y2={chartHeight2 - padding2.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<line
x1={padding2.left}
y1={padding2.top}
x2={padding2.left}
y2={chartHeight2 - padding2.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<!-- Linhas para cada tipo (em camadas) -->
{#each tipos as tipo, tipoIdx}
{@const cor = cores[tipoIdx]}
<!-- Área preenchida (camada) -->
<defs>
<linearGradient id="gradient-{tipo}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:{cor};stop-opacity:0.4" />
<stop offset="100%" style="stop-color:{cor};stop-opacity:0.05" />
</linearGradient>
</defs>
{@const pontos = tendencias.map((t, i) => {
const x = padding2.left + (i / (tendencias.length - 1 || 1)) * innerWidth;
const valor = t[tipo as keyof typeof t] as number;
const y = chartHeight2 - padding2.bottom - (valor / maxValor) * innerHeight2;
return { x, y, valor };
})}
<!-- Área -->
{#if pontos.length > 0}
{@const pathArea =
`M ${pontos[0].x} ${chartHeight2 - padding2.bottom} ` +
pontos.map((p) => `L ${p.x} ${p.y}`).join(' ') +
` L ${pontos[pontos.length - 1].x} ${chartHeight2 - padding2.bottom} Z`}
<path d={pathArea} fill="url(#gradient-{tipo})" />
{/if}
<!-- Linha -->
{#if pontos.length > 1}
<polyline
points={pontos.map((p) => `${p.x},${p.y}`).join(' ')}
fill="none"
stroke={cor}
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
{/if}
<!-- Pontos -->
{#each pontos as ponto, pontoIdx}
<circle
cx={ponto.x}
cy={ponto.y}
r="5"
fill={cor}
stroke="white"
stroke-width="2"
class="hover:r-7 cursor-pointer transition-all"
/>
<!-- Tooltip no hover -->
<title
>{nomes[tipoIdx]}: {ponto.valor} dias em {tendencias[pontoIdx]?.mes ||
''}</title
>
{/each}
{/each}
<!-- Labels do eixo X -->
{#each tendencias as t, i}
{@const x = padding2.left + (i / (tendencias.length - 1 || 1)) * innerWidth}
<foreignObject
x={x - 40}
y={chartHeight2 - padding2.bottom + 15}
width="80"
height="60"
>
<div class="flex items-center justify-center text-center">
<span class="text-base-content/80 text-xs font-medium">
{t.mes}
</span>
</div>
</foreignObject>
{/each}
</svg>
<!-- Legenda -->
<div class="border-base-300 mt-4 flex flex-wrap justify-center gap-4 border-t pt-4">
{#each tipos as tipo, idx}
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded" style="background-color: {cores[idx]}"></div>
<span class="text-base-content/70 text-sm">{nomes[idx]}</span>
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
{:else if abaAtiva === 'atestado'}
<!-- Formulário Atestado Médico -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Registrar Atestado Médico</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<FuncionarioSelect bind:value={atestadoMedico.funcionarioId} required={true} />
<div class="form-control">
<div class="label">
<span class="label-text font-medium"
>Data Início <span class="text-error">*</span></span
>
</div>
<input
type="date"
bind:value={atestadoMedico.dataInicio}
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<div class="label">
<span class="label-text font-medium">Data Fim <span class="text-error">*</span></span>
</div>
<input
type="date"
bind:value={atestadoMedico.dataFim}
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<div class="label">
<span class="label-text font-medium">CID <span class="text-error">*</span></span>
</div>
<input
type="text"
bind:value={atestadoMedico.cid}
placeholder="Ex: A00.0"
class="input input-bordered"
maxlength="10"
required
/>
</div>
<div class="form-control md:col-span-2">
<FileUpload
label="Anexar Atestado (PDF ou Imagem)"
bind:value={atestadoMedico.documentoId}
required={true}
onUpload={async (file) => {
atestadoMedico.documentoId = await handleDocumentoUpload(file);
}}
onRemove={async () => {
atestadoMedico.documentoId = undefined;
}}
/>
</div>
<div class="form-control md:col-span-2">
<div class="label">
<span class="label-text font-medium">Observações</span>
</div>
<textarea
bind:value={atestadoMedico.observacoes}
class="textarea textarea-bordered h-24"
placeholder="Observações adicionais..."
></textarea>
</div>
</div>
<div class="card-actions mt-6 justify-end">
<button class="btn" onclick={resetarFormularioAtestado}> Cancelar </button>
<button
class="btn btn-primary"
onclick={salvarAtestadoMedico}
disabled={salvandoAtestado}
>
{#if salvandoAtestado}
<span class="loading loading-spinner loading-sm"></span>
Salvando...
{:else}
Salvar
{/if}
</button>
</div>
</div>
</div>
{:else if abaAtiva === 'declaracao'}
<!-- Formulário Declaração -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Registrar Declaração de Comparecimento</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<FuncionarioSelect bind:value={declaracao.funcionarioId} required={true} />
<div class="form-control">
<div class="label">
<span class="label-text font-medium"
>Data Início <span class="text-error">*</span></span
>
</div>
<input
type="date"
bind:value={declaracao.dataInicio}
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<div class="label">
<span class="label-text font-medium">Data Fim <span class="text-error">*</span></span>
</div>
<input
type="date"
bind:value={declaracao.dataFim}
class="input input-bordered"
required
/>
</div>
<div class="form-control md:col-span-2">
<FileUpload
label="Anexar Documento (PDF ou Imagem)"
bind:value={declaracao.documentoId}
required={true}
onUpload={async (file) => {
declaracao.documentoId = await handleDocumentoUpload(file);
}}
onRemove={async () => {
declaracao.documentoId = undefined;
}}
/>
</div>
<div class="form-control md:col-span-2">
<div class="label">
<span class="label-text font-medium">Observações</span>
</div>
<textarea
bind:value={declaracao.observacoes}
class="textarea textarea-bordered h-24"
placeholder="Observações adicionais..."
></textarea>
</div>
</div>
<div class="card-actions mt-6 justify-end">
<button class="btn" onclick={resetarFormularioDeclaracao}> Cancelar </button>
<button class="btn btn-primary" onclick={salvarDeclaracao} disabled={salvandoDeclaracao}>
{#if salvandoDeclaracao}
<span class="loading loading-spinner loading-sm"></span>
Salvando...
{:else}
Salvar
{/if}
</button>
</div>
</div>
</div>
{:else if abaAtiva === 'maternidade'}
<!-- Formulário Licença Maternidade -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Registrar Licença Maternidade</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<FuncionarioSelect bind:value={licencaMaternidade.funcionarioId} required={true} />
<div class="form-control">
<div class="label">
<span class="label-text font-medium"
>Data Início <span class="text-error">*</span></span
>
</div>
<input
type="date"
bind:value={licencaMaternidade.dataInicio}
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<div class="label">
<span class="label-text font-medium">Data Fim <span class="text-error">*</span></span>
</div>
<input
type="date"
bind:value={licencaMaternidade.dataFim}
class="input input-bordered"
required
/>
<div class="label">
<span class="label-text-alt">Calculado automaticamente (120 dias)</span>
</div>
</div>
<div class="form-control md:col-span-2">
<label class="label cursor-pointer">
<span class="label-text font-medium">Esta é uma prorrogação?</span>
<input
type="checkbox"
bind:checked={licencaMaternidade.ehProrrogacao}
class="checkbox checkbox-primary"
/>
</label>
</div>
{#if licencaMaternidade.ehProrrogacao}
<div class="form-control md:col-span-2">
<div class="label">
<span class="label-text font-medium"
>Licença Original <span class="text-error">*</span></span
>
</div>
<select
bind:value={licencaMaternidade.licencaOriginalId}
class="select select-bordered"
required
>
<option value="">Selecione a licença original</option>
{#each licencasMaternidade as licenca}
<option value={licenca._id}>
{licenca.funcionario?.nome} -{' '}
{formatarData(licenca.dataInicio)} até{' '}
{formatarData(licenca.dataFim)}
</option>
{/each}
</select>
</div>
{/if}
<div class="form-control md:col-span-2">
<FileUpload
label="Anexar Documento (PDF ou Imagem)"
bind:value={licencaMaternidade.documentoId}
required={true}
onUpload={async (file) => {
licencaMaternidade.documentoId = await handleDocumentoUpload(file);
}}
onRemove={async () => {
licencaMaternidade.documentoId = undefined;
}}
/>
</div>
<div class="form-control md:col-span-2">
<div class="label">
<span class="label-text font-medium">Observações</span>
</div>
<textarea
bind:value={licencaMaternidade.observacoes}
class="textarea textarea-bordered h-24"
placeholder="Observações adicionais..."
></textarea>
</div>
</div>
<div class="card-actions mt-6 justify-end">
<button class="btn" onclick={resetarFormularioMaternidade}> Cancelar </button>
<button
class="btn btn-primary"
onclick={salvarLicencaMaternidade}
disabled={salvandoMaternidade}
>
{#if salvandoMaternidade}
<span class="loading loading-spinner loading-sm"></span>
Salvando...
{:else}
Salvar
{/if}
</button>
</div>
</div>
</div>
{:else if abaAtiva === 'paternidade'}
<!-- Formulário Licença Paternidade -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Registrar Licença Paternidade</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<FuncionarioSelect bind:value={licencaPaternidade.funcionarioId} required={true} />
<div class="form-control">
<div class="label">
<span class="label-text font-medium"
>Data Início <span class="text-error">*</span></span
>
</div>
<input
type="date"
bind:value={licencaPaternidade.dataInicio}
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<div class="label">
<span class="label-text font-medium">Data Fim <span class="text-error">*</span></span>
</div>
<input
type="date"
bind:value={licencaPaternidade.dataFim}
class="input input-bordered"
required
/>
<div class="label">
<span class="label-text-alt">Calculado automaticamente (20 dias)</span>
</div>
</div>
<div class="form-control md:col-span-2">
<FileUpload
label="Anexar Documento (PDF ou Imagem)"
bind:value={licencaPaternidade.documentoId}
required={true}
onUpload={async (file) => {
licencaPaternidade.documentoId = await handleDocumentoUpload(file);
}}
onRemove={async () => {
licencaPaternidade.documentoId = undefined;
}}
/>
</div>
<div class="form-control md:col-span-2">
<div class="label">
<span class="label-text font-medium">Observações</span>
</div>
<textarea
bind:value={licencaPaternidade.observacoes}
class="textarea textarea-bordered h-24"
placeholder="Observações adicionais..."
></textarea>
</div>
</div>
<div class="card-actions mt-6 justify-end">
<button class="btn" onclick={resetarFormularioPaternidade}> Cancelar </button>
<button
class="btn btn-primary"
onclick={salvarLicencaPaternidade}
disabled={salvandoPaternidade}
>
{#if salvandoPaternidade}
<span class="loading loading-spinner loading-sm"></span>
Salvando...
{:else}
Salvar
{/if}
</button>
</div>
</div>
</div>
{/if}
</main>
<!-- Modal de Erro -->
<ErrorModal
bind:open={erroModal.aberto}
title={erroModal.titulo}
message={erroModal.mensagem}
details={erroModal.detalhes}
onClose={() => {
erroModal.aberto = false;
}}
/>