feat: implement absence management features in the dashboard

- Added functionality for managing absence requests, including listing, approving, and rejecting requests.
- Enhanced the user interface to display statistics and pending requests for better oversight.
- Updated backend schema to support absence requests and notifications, ensuring data integrity and efficient handling.
- Integrated new components for absence request forms and approval workflows, improving user experience and administrative efficiency.
This commit is contained in:
2025-11-04 14:23:46 -03:00
parent f02eb473ca
commit a93d55f02b
13 changed files with 3837 additions and 497 deletions

View File

@@ -4,13 +4,15 @@
import { authStore } from "$lib/stores/auth.svelte";
import AprovarFerias from "$lib/components/AprovarFerias.svelte";
import WizardSolicitacaoFerias from "$lib/components/ferias/WizardSolicitacaoFerias.svelte";
import WizardSolicitacaoAusencia from "$lib/components/ausencias/WizardSolicitacaoAusencia.svelte";
import AprovarAusencias from "$lib/components/AprovarAusencias.svelte";
import { generateAvatarGallery, type Avatar } from "$lib/utils/avatars";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import { page } from "$app/stores";
const client = useConvexClient();
let abaAtiva = $state<"meu-perfil" | "minhas-ferias" | "aprovar-ferias">(
let abaAtiva = $state<"meu-perfil" | "minhas-ferias" | "minhas-ausencias" | "aprovar-ferias" | "aprovar-ausencias">(
"meu-perfil"
);
let solicitacaoSelecionada = $state<any>(null);
@@ -29,6 +31,14 @@
let mostrarWizard = $state(false);
let filtroStatusFerias = $state<string>("todos");
// Estados para Minhas Ausências
let mostrarWizardAusencia = $state(false);
let filtroStatusAusencias = $state<string>("todos");
let solicitacaoAusenciaSelecionada = $state<Id<"solicitacoesAusencias"> | null>(null);
// Estados para Aprovar Ausências (Gestores)
let solicitacaoAusenciaAprovar = $state<Id<"solicitacoesAusencias"> | null>(null);
// Galeria de avatares (30 avatares profissionais 3D realistas)
const avatarGallery = generateAvatarGallery(30);
@@ -42,10 +52,15 @@
}
});
// FuncionarioId disponível diretamente do authStore
const funcionarioIdDisponivel = $derived(authStore.usuario?.funcionarioId ?? null);
// Debug: Verificar funcionarioId
$effect(() => {
console.log("🔍 [Perfil] funcionarioId:", authStore.usuario?.funcionarioId);
console.log("🔍 [Perfil] Usuário completo:", authStore.usuario);
console.log("🔍 [Perfil] funcionarioIdDisponivel:", funcionarioIdDisponivel);
console.log("🔍 [Perfil] Botão habilitado?", !!funcionarioIdDisponivel);
});
// Queries
@@ -65,6 +80,14 @@
: { data: [] }
);
const ausenciasSubordinadosQuery = $derived(
authStore.usuario?._id
? useQuery(api.ausencias.listarSolicitacoesSubordinados, {
gestorId: authStore.usuario._id as Id<"usuarios">,
})
: { data: [] }
);
const minhasSolicitacoesQuery = $derived(
funcionarioQuery.data
? useQuery(api.ferias.listarMinhasSolicitacoes, {
@@ -73,6 +96,14 @@
: { data: [] }
);
const minhasAusenciasQuery = $derived(
funcionarioQuery.data
? useQuery(api.ausencias.listarMinhasSolicitacoes, {
funcionarioId: funcionarioQuery.data._id,
})
: { data: [] }
);
const meuTimeQuery = $derived(
funcionarioQuery.data
? useQuery(api.times.obterTimeFuncionario, {
@@ -93,7 +124,11 @@
const solicitacoesSubordinados = $derived(
solicitacoesSubordinadosQuery?.data || []
);
const ausenciasSubordinados = $derived(
ausenciasSubordinadosQuery?.data || []
);
const minhasSolicitacoes = $derived(minhasSolicitacoesQuery?.data || []);
const minhasAusencias = $derived(minhasAusenciasQuery?.data || []);
const meuTime = $derived(meuTimeQuery?.data);
const meusTimesGestor = $derived(meusTimesGestorQuery?.data || []);
@@ -108,6 +143,14 @@
})
);
// Filtrar minhas ausências
const ausenciasFiltradas = $derived(
minhasAusencias.filter((a) => {
if (filtroStatusAusencias !== "todos" && a.status !== filtroStatusAusencias) return false;
return true;
})
);
// Estatísticas das minhas férias
const statsMinhasFerias = $derived({
total: minhasSolicitacoes.length,
@@ -117,6 +160,14 @@
emFerias: funcionario?.statusFerias === "em_ferias" ? 1 : 0,
});
// Estatísticas das minhas ausências
const statsMinhasAusencias = $derived({
total: minhasAusencias.length,
aguardando: minhasAusencias.filter((a) => a.status === "aguardando_aprovacao").length,
aprovadas: minhasAusencias.filter((a) => a.status === "aprovado").length,
reprovadas: minhasAusencias.filter((a) => a.status === "reprovado").length,
});
async function recarregar() {
solicitacaoSelecionada = null;
}
@@ -512,6 +563,29 @@
Minhas Férias
</button>
<button
type="button"
role="tab"
class={`tab tab-lg font-semibold transition-all duration-300 ${abaAtiva === "minhas-ausencias" ? "tab-active bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg scale-105" : "hover:bg-base-100"}`}
onclick={() => (abaAtiva = "minhas-ausencias")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Minhas Ausências
</button>
{#if ehGestor}
<button
type="button"
@@ -542,6 +616,36 @@
</span>
{/if}
</button>
<button
type="button"
role="tab"
class={`tab tab-lg font-semibold transition-all duration-300 ${abaAtiva === "aprovar-ausencias" ? "tab-active bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg scale-105" : "hover:bg-base-100"}`}
onclick={() => (abaAtiva = "aprovar-ausencias")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Aprovar Ausências
{#if (ausenciasSubordinados || []).filter((a: any) => a.status === "aguardando_aprovacao").length > 0}
<span class="badge badge-error badge-sm ml-2 animate-pulse">
{(ausenciasSubordinados || []).filter(
(a: any) => a.status === "aguardando_aprovacao"
).length}
</span>
{/if}
</button>
{/if}
</div>
@@ -1221,6 +1325,151 @@
</div>
</div>
</div>
{:else if abaAtiva === "minhas-ausencias"}
<!-- Minhas Ausências -->
<div class="space-y-6">
<!-- Estatísticas -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="stat bg-base-100 shadow-lg rounded-box border border-base-300">
<div class="stat-figure text-orange-500">
<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-orange-500">{statsMinhasAusencias.total}</div>
<div class="stat-desc">Solicitações</div>
</div>
<div class="stat bg-base-100 shadow-lg rounded-box border border-warning/30">
<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">{statsMinhasAusencias.aguardando}</div>
<div class="stat-desc">Pendentes</div>
</div>
<div class="stat bg-base-100 shadow-lg rounded-box border border-success/30">
<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">{statsMinhasAusencias.aprovadas}</div>
<div class="stat-desc">Deferidas</div>
</div>
<div class="stat bg-base-100 shadow-lg rounded-box border border-error/30">
<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">{statsMinhasAusencias.reprovadas}</div>
<div class="stat-desc">Indeferidas</div>
</div>
</div>
<!-- Filtros e Botão Nova Solicitação -->
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<h2 class="card-title text-lg">Filtros</h2>
<button
type="button"
class="btn btn-warning gap-2"
onclick={() => {
const funcionarioId = authStore.usuario?.funcionarioId;
console.log("🔍 [Perfil] Click no botão - funcionarioId:", funcionarioId);
if (funcionarioId) {
mostrarWizardAusencia = true;
} else {
alert(`Não foi possível identificar seu funcionário.\n\nVerifique no console (F12) o objeto usuario:\n${JSON.stringify(authStore.usuario, null, 2)}\n\nEntre em contato com o suporte se o problema persistir.`);
}
}}
disabled={!funcionarioIdDisponivel}
title={funcionarioIdDisponivel ? "Clique para solicitar uma ausência" : "Funcionário não identificado. Entre em contato com o suporte."}
>
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Solicitar Ausência
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-1 gap-4 mt-4">
<div class="form-control">
<label class="label" for="status-ausencias">
<span class="label-text">Status</span>
</label>
<select id="status-ausencias" class="select select-bordered" bind:value={filtroStatusAusencias}>
<option value="todos">Todos</option>
<option value="aguardando_aprovacao">Aguardando Aprovação</option>
<option value="aprovado">Aprovado</option>
<option value="reprovado">Reprovado</option>
</select>
</div>
</div>
</div>
</div>
<!-- Lista de Solicitações -->
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-lg mb-4">
Minhas Solicitações ({ausenciasFiltradas.length})
</h2>
{#if ausenciasFiltradas.length === 0}
<div class="alert">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info shrink-0 w-6 h-6">
<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 table-zebra">
<thead>
<tr>
<th>Período</th>
<th>Dias</th>
<th>Motivo</th>
<th>Status</th>
<th>Solicitado em</th>
</tr>
</thead>
<tbody>
{#each ausenciasFiltradas as ausencia}
<tr>
<td>
{new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até {new Date(ausencia.dataFim).toLocaleDateString("pt-BR")}
</td>
<td class="font-bold">
{Math.ceil((new Date(ausencia.dataFim).getTime() - new Date(ausencia.dataInicio).getTime()) / (1000 * 60 * 60 * 24)) + 1} dias
</td>
<td class="max-w-xs truncate" title={ausencia.motivo}>
{ausencia.motivo}
</td>
<td>
<div class={`badge ${getStatusBadge(ausencia.status)}`}>
{getStatusTexto(ausencia.status)}
</div>
</td>
<td class="text-xs">{new Date(ausencia.criadoEm).toLocaleDateString("pt-BR")}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</div>
{:else if abaAtiva === "aprovar-ferias"}
<!-- Aprovar Férias (Gestores) PREMIUM -->
<div class="card bg-base-100 shadow-2xl border-t-4 border-green-500">
@@ -1379,10 +1628,162 @@
{/if}
</div>
</div>
{:else if abaAtiva === "aprovar-ausencias"}
<!-- Aprovar Ausências (Gestores) -->
<div class="card bg-base-100 shadow-2xl border-t-4 border-orange-500">
<div class="card-body">
<h2 class="card-title text-2xl mb-6 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-7 w-7 text-orange-600"
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>
Solicitações de Ausências da Equipe
<div class="badge badge-lg badge-warning ml-2">
{ausenciasSubordinados.length}
</div>
</h2>
{#if ausenciasSubordinados.length === 0}
<div class="alert alert-success">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<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"
></path>
</svg>
<span class="font-semibold"
>Nenhuma solicitação pendente no momento.</span
>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table table-zebra table-lg">
<thead>
<tr class="bg-base-200">
<th class="font-bold">Funcionário</th>
<th class="font-bold">Time</th>
<th class="font-bold">Período</th>
<th class="font-bold">Dias</th>
<th class="font-bold">Status</th>
<th class="font-bold">Ações</th>
</tr>
</thead>
<tbody>
{#each ausenciasSubordinados as ausencia}
<tr class="hover:bg-base-200 transition-colors">
<td>
<div class="font-bold">
{ausencia.funcionario?.nome || "N/A"}
</div>
</td>
<td>
{#if ausencia.time}
<div
class="badge badge-lg font-semibold"
style="background-color: {ausencia.time.cor}20; border-color: {ausencia.time.cor}; color: {ausencia.time.cor}"
>
{ausencia.time.nome}
</div>
{/if}
</td>
<td class="font-semibold">
{new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até{" "}
{new Date(ausencia.dataFim).toLocaleDateString("pt-BR")}
</td>
<td class="font-bold text-lg">
{Math.ceil((new Date(ausencia.dataFim).getTime() - new Date(ausencia.dataInicio).getTime()) / (1000 * 60 * 60 * 24)) + 1} dias
</td>
<td>
<div
class={`badge badge-lg font-semibold ${getStatusBadge(ausencia.status)}`}
>
{getStatusTexto(ausencia.status)}
</div>
</td>
<td>
{#if ausencia.status === "aguardando_aprovacao"}
<button
type="button"
class="btn btn-warning btn-sm gap-2 shadow-lg hover:scale-105 transition-transform"
onclick={() =>
(solicitacaoAusenciaAprovar = ausencia._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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
Analisar
</button>
{:else}
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
onclick={() =>
(solicitacaoAusenciaAprovar = ausencia._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>
Detalhes
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Modal de Aprovação -->
<!-- Modal de Aprovação de Férias -->
{#if solicitacaoSelecionada}
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl">
@@ -1716,3 +2117,56 @@
<div class="modal-backdrop" onclick={() => (mostrarWizard = false)}></div>
</dialog>
{/if}
<!-- Modal Wizard Solicitação de Ausência -->
{#if mostrarWizardAusencia && funcionarioIdDisponivel}
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl max-h-[90vh] overflow-hidden">
<h3 class="font-bold text-2xl mb-6 text-center">
Nova Solicitação de Ausência
</h3>
<div class="max-h-[80vh] overflow-y-auto">
<WizardSolicitacaoAusencia
funcionarioId={funcionarioIdDisponivel}
onSucesso={() => {
mostrarWizardAusencia = false;
}}
onCancelar={() => (mostrarWizardAusencia = false)}
/>
</div>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={() => (mostrarWizardAusencia = false)}></div>
</dialog>
{/if}
<!-- Modal de Aprovação de Ausências -->
{#if solicitacaoAusenciaAprovar && authStore.usuario}
{#await client.query(api.ausencias.obterDetalhes, {
solicitacaoId: solicitacaoAusenciaAprovar,
}) then detalhes}
{#if detalhes}
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl">
<AprovarAusencias
solicitacao={detalhes}
gestorId={authStore.usuario._id}
onSucesso={() => {
solicitacaoAusenciaAprovar = null;
}}
onCancelar={() => (solicitacaoAusenciaAprovar = null)}
/>
</div>
<form method="dialog" class="modal-backdrop">
<button
type="button"
onclick={() => (solicitacaoAusenciaAprovar = null)}
aria-label="Fechar modal"
>Fechar</button
>
</form>
</dialog>
{/if}
{/await}
{/if}