Feat ausencia #7

Merged
deyvisonwanderley merged 8 commits from feat-ausencia into master 2025-11-05 13:49:12 +00:00
24 changed files with 4434 additions and 1757 deletions
Showing only changes of commit a93d55f02b - Show all commits

View File

@@ -1,6 +1,8 @@
@import "tailwindcss";
@plugin "daisyui";
/* FullCalendar CSS - v6 não exporta CSS separado, estilos são aplicados via JavaScript */
/* Estilo padrão dos botões - mesmo estilo do sidebar */
.btn-standard {
@apply font-medium flex items-center justify-center gap-2 text-center p-3 rounded-xl border border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content hover:text-white active:text-white transition-colors;

View File

@@ -0,0 +1,398 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id, Doc } from "@sgse-app/backend/convex/_generated/dataModel";
import ErrorModal from "./ErrorModal.svelte";
type SolicitacaoAusencia = Doc<"solicitacoesAusencias"> & {
funcionario?: Doc<"funcionarios"> | null;
gestor?: Doc<"usuarios"> | null;
time?: Doc<"times"> | null;
};
interface Props {
solicitacao: SolicitacaoAusencia;
gestorId: Id<"usuarios">;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient();
let motivoReprovacao = $state("");
let processando = $state(false);
let erro = $state("");
let mostrarModalErro = $state(false);
let mensagemErroModal = $state("");
function calcularDias(dataInicio: string, dataFim: string): number {
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const diff = fim.getTime() - inicio.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
}
const totalDias = $derived(
calcularDias(solicitacao.dataInicio, solicitacao.dataFim)
);
async function aprovar() {
try {
processando = true;
erro = "";
mostrarModalErro = false;
await client.mutation(api.ausencias.aprovar, {
solicitacaoId: solicitacao._id,
gestorId: gestorId,
});
if (onSucesso) onSucesso();
} catch (e) {
const mensagemErro = e instanceof Error ? e.message : String(e);
// Verificar se é erro de permissão
if (mensagemErro.includes("permissão") || mensagemErro.includes("permission") || mensagemErro.includes("Você não tem permissão")) {
mensagemErroModal = "Você não tem permissão para aprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação.";
mostrarModalErro = true;
} else {
erro = mensagemErro;
}
} finally {
processando = false;
}
}
async function reprovar() {
if (!motivoReprovacao.trim()) {
erro = "Informe o motivo da reprovação";
return;
}
try {
processando = true;
erro = "";
mostrarModalErro = false;
await client.mutation(api.ausencias.reprovar, {
solicitacaoId: solicitacao._id,
gestorId: gestorId,
motivoReprovacao: motivoReprovacao.trim(),
});
if (onSucesso) onSucesso();
} catch (e) {
const mensagemErro = e instanceof Error ? e.message : String(e);
// Verificar se é erro de permissão
if (mensagemErro.includes("permissão") || mensagemErro.includes("permission") || mensagemErro.includes("Você não tem permissão")) {
mensagemErroModal = "Você não tem permissão para reprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação.";
mostrarModalErro = true;
} else {
erro = mensagemErro;
}
} finally {
processando = false;
}
}
function fecharModalErro() {
mostrarModalErro = false;
mensagemErroModal = "";
}
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
aguardando_aprovacao: "badge-warning",
aprovado: "badge-success",
reprovado: "badge-error",
};
return badges[status] || "badge-neutral";
}
function getStatusTexto(status: string) {
const textos: Record<string, string> = {
aguardando_aprovacao: "Aguardando Aprovação",
aprovado: "Aprovado",
reprovado: "Reprovado",
};
return textos[status] || status;
}
</script>
<div class="aprovar-ausencia">
<!-- Header -->
<div class="mb-6">
<h2 class="text-3xl font-bold text-primary mb-2">Aprovar/Reprovar Ausência</h2>
<p class="text-base-content/70">Analise a solicitação e tome uma decisão</p>
</div>
<!-- Card Principal -->
<div class="card bg-base-100 shadow-2xl border-t-4 border-orange-500">
<div class="card-body">
<!-- Informações do Funcionário -->
<div class="mb-6">
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
Funcionário
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p class="text-sm text-base-content/70">Nome</p>
<p class="font-bold text-lg">{solicitacao.funcionario?.nome || "N/A"}</p>
</div>
{#if solicitacao.time}
<div>
<p class="text-sm text-base-content/70">Time</p>
<div
class="badge badge-lg font-semibold"
style="background-color: {solicitacao.time.cor}20; border-color: {solicitacao.time.cor}; color: {solicitacao.time.cor}"
>
{solicitacao.time.nome}
</div>
</div>
{/if}
</div>
</div>
<div class="divider"></div>
<!-- Período da Ausência -->
<div class="mb-6">
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Período da Ausência
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="stat bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 rounded-xl border-2 border-orange-500/30">
<div class="stat-title">Data Início</div>
<div class="stat-value text-orange-600 dark:text-orange-400 text-2xl">
{new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")}
</div>
</div>
<div class="stat bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 rounded-xl border-2 border-orange-500/30">
<div class="stat-title">Data Fim</div>
<div class="stat-value text-orange-600 dark:text-orange-400 text-2xl">
{new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
</div>
</div>
<div class="stat bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 rounded-xl border-2 border-orange-500/30">
<div class="stat-title">Total de Dias</div>
<div class="stat-value text-orange-600 dark:text-orange-400 text-3xl">
{totalDias}
</div>
<div class="stat-desc">dias corridos</div>
</div>
</div>
</div>
<div class="divider"></div>
<!-- Motivo -->
<div class="mb-6">
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-primary"
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>
Motivo da Ausência
</h3>
<div class="card bg-base-200">
<div class="card-body">
<p class="whitespace-pre-wrap">{solicitacao.motivo}</p>
</div>
</div>
</div>
<!-- Status Atual -->
<div class="mb-6">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold">Status:</span>
<div class={`badge badge-lg ${getStatusBadge(solicitacao.status)}`}>
{getStatusTexto(solicitacao.status)}
</div>
</div>
</div>
<!-- Erro -->
{#if erro}
<div class="alert alert-error mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
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>
<span>{erro}</span>
</div>
{/if}
<!-- Ações -->
{#if solicitacao.status === "aguardando_aprovacao"}
<div class="card-actions justify-end gap-4 mt-6">
<button
type="button"
class="btn btn-error btn-lg gap-2"
onclick={reprovar}
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner"></span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if}
Reprovar
</button>
<button
type="button"
class="btn btn-success btn-lg gap-2"
onclick={aprovar}
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner"></span>
{:else}
<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="M5 13l4 4L19 7"
/>
</svg>
{/if}
Aprovar
</button>
</div>
<!-- Modal de Reprovação -->
{#if motivoReprovacao !== undefined}
<div class="mt-4">
<div class="form-control">
<label class="label" for="motivo-reprovacao">
<span class="label-text font-bold">Motivo da Reprovação</span>
</label>
<textarea
id="motivo-reprovacao"
class="textarea textarea-bordered h-24"
placeholder="Informe o motivo da reprovação..."
bind:value={motivoReprovacao}
></textarea>
</div>
</div>
{/if}
{:else}
<div class="alert alert-info">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>Esta solicitação já foi processada.</span>
</div>
{/if}
<!-- Botão Cancelar -->
<div class="mt-4 text-center">
<button
type="button"
class="btn btn-ghost"
onclick={() => {
if (onCancelar) onCancelar();
}}
disabled={processando}
>
Fechar
</button>
</div>
</div>
</div>
</div>
<!-- Modal de Erro -->
<ErrorModal
open={mostrarModalErro}
title="Erro de Permissão"
message={mensagemErroModal || "Você não tem permissão para realizar esta ação."}
onClose={fecharModalErro}
/>
<style>
.aprovar-ausencia {
max-width: 900px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,487 @@
<script lang="ts">
import { onMount } from "svelte";
import { Calendar } from "@fullcalendar/core";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import multiMonthPlugin from "@fullcalendar/multimonth";
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
interface Props {
dataInicio?: string;
dataFim?: string;
ausenciasExistentes?: Array<{
dataInicio: string;
dataFim: string;
status: "aguardando_aprovacao" | "aprovado" | "reprovado";
}>;
onPeriodoSelecionado?: (periodo: { dataInicio: string; dataFim: string }) => void;
modoVisualizacao?: "month" | "multiMonth";
readonly?: boolean;
}
let {
dataInicio,
dataFim,
ausenciasExistentes = [],
onPeriodoSelecionado,
modoVisualizacao = "month",
readonly = false,
}: Props = $props();
let calendarEl: HTMLDivElement;
let calendar: Calendar | null = null;
let selecionando = $state(false); // Flag para evitar atualizações durante seleção
let eventos: Array<{
id: string;
title: string;
start: string;
end: string;
backgroundColor: string;
borderColor: string;
textColor: string;
extendedProps: {
status: string;
};
}> = $state([]);
// Cores por status
const coresStatus: Record<string, { bg: string; border: string; text: string }> = {
aguardando_aprovacao: { bg: "#f59e0b", border: "#d97706", text: "#ffffff" }, // Laranja
aprovado: { bg: "#10b981", border: "#059669", text: "#ffffff" }, // Verde
reprovado: { bg: "#ef4444", border: "#dc2626", text: "#ffffff" }, // Vermelho
};
// Converter ausências existentes em eventos
function atualizarEventos() {
const novosEventos: Array<{
id: string;
title: string;
start: string;
end: string;
backgroundColor: string;
borderColor: string;
textColor: string;
extendedProps: {
status: string;
};
}> = ausenciasExistentes.map((ausencia, index) => {
const cor = coresStatus[ausencia.status] || coresStatus.aguardando_aprovacao;
return {
id: `ausencia-${index}`,
title: `${getStatusTexto(ausencia.status)} - ${calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias`,
start: ausencia.dataInicio,
end: calcularDataFim(ausencia.dataFim),
backgroundColor: cor.bg,
borderColor: cor.border,
textColor: cor.text,
extendedProps: {
status: ausencia.status,
},
};
});
// Adicionar período selecionado atual se existir
if (dataInicio && dataFim) {
novosEventos.push({
id: "periodo-selecionado",
title: `Selecionado - ${calcularDias(dataInicio, dataFim)} dias`,
start: dataInicio,
end: calcularDataFim(dataFim),
backgroundColor: "#667eea",
borderColor: "#5568d3",
textColor: "#ffffff",
extendedProps: {
status: "selecionado",
},
});
}
eventos = novosEventos;
}
function getStatusTexto(status: string): string {
const textos: Record<string, string> = {
aguardando_aprovacao: "Aguardando",
aprovado: "Aprovado",
reprovado: "Reprovado",
};
return textos[status] || status;
}
// Helper: Adicionar 1 dia à data fim (FullCalendar usa exclusive end)
function calcularDataFim(dataFim: string): string {
const data = new Date(dataFim);
data.setDate(data.getDate() + 1);
return data.toISOString().split("T")[0];
}
// Helper: Calcular dias entre datas (inclusivo)
function calcularDias(inicio: string, fim: string): number {
const dInicio = new Date(inicio);
const dFim = new Date(fim);
const diffTime = Math.abs(dFim.getTime() - dInicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return diffDays;
}
// Atualizar eventos quando mudanças ocorrem (evitar loop infinito)
$effect(() => {
if (!calendar || selecionando) return; // Não atualizar durante seleção
atualizarEventos();
// Usar requestAnimationFrame para evitar múltiplas atualizações durante seleção
requestAnimationFrame(() => {
if (calendar && !selecionando) {
calendar.removeAllEvents();
calendar.addEventSource(eventos);
}
});
});
onMount(() => {
if (!calendarEl) return;
atualizarEventos();
calendar = new Calendar(calendarEl, {
plugins: [dayGridPlugin, interactionPlugin, multiMonthPlugin],
initialView: modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth",
locale: ptBrLocale,
headerToolbar: {
left: "prev,next today",
center: "title",
right: modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth",
},
height: "auto",
selectable: !readonly,
selectMirror: true,
unselectAuto: false,
events: eventos,
// Estilo customizado
buttonText: {
today: "Hoje",
month: "Mês",
multiMonthYear: "Ano",
},
// Seleção de período
select: (info) => {
if (readonly) return;
selecionando = true; // Marcar que está selecionando
// Usar setTimeout para evitar conflito com atualizações de estado
setTimeout(() => {
const inicio = new Date(info.startStr);
const fim = new Date(info.endStr);
fim.setDate(fim.getDate() - 1); // FullCalendar usa exclusive end
// Validar que não é no passado
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
if (inicio < hoje) {
alert("A data de início não pode ser no passado");
calendar?.unselect();
selecionando = false;
return;
}
// Validar que fim >= início
if (fim < inicio) {
alert("A data de fim deve ser maior ou igual à data de início");
calendar?.unselect();
selecionando = false;
return;
}
// Chamar callback de forma assíncrona para evitar loop
if (onPeriodoSelecionado) {
onPeriodoSelecionado({
dataInicio: info.startStr,
dataFim: fim.toISOString().split("T")[0],
});
}
calendar?.unselect();
// Liberar flag após um pequeno delay para garantir que o estado foi atualizado
setTimeout(() => {
selecionando = false;
}, 100);
}, 0);
},
// Click em evento para visualizar detalhes (readonly)
eventClick: (info) => {
if (readonly) {
const status = info.event.extendedProps.status;
const texto = getStatusTexto(status);
alert(`Ausência ${texto}\nPeríodo: ${new Date(info.event.startStr).toLocaleDateString("pt-BR")} até ${new Date(calcularDataFim(info.event.endStr)).toLocaleDateString("pt-BR")}`);
}
},
// Tooltip ao passar mouse
eventDidMount: (info) => {
const status = info.event.extendedProps.status;
if (status === "selecionado") {
info.el.title = `Período selecionado\n${info.event.title}`;
} else {
info.el.title = `${info.event.title}`;
}
info.el.style.cursor = readonly ? "default" : "pointer";
},
// Desabilitar datas passadas
selectAllow: (selectInfo) => {
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
return new Date(selectInfo.start) >= hoje;
},
// Highlight de fim de semana
dayCellClassNames: (arg) => {
if (arg.date.getDay() === 0 || arg.date.getDay() === 6) {
return ["fc-day-weekend-custom"];
}
return [];
},
});
calendar.render();
return () => {
calendar?.destroy();
};
});
</script>
<div class="calendario-ausencias-wrapper">
<!-- Header com instruções -->
{#if !readonly}
<div class="alert alert-info mb-4 shadow-lg">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div class="text-sm">
<p class="font-bold">Como usar:</p>
<ul class="list-disc list-inside mt-1">
<li>Clique e arraste no calendário para selecionar o período de ausência</li>
<li>Você pode visualizar suas ausências já solicitadas no calendário</li>
<li>A data de início não pode ser no passado</li>
</ul>
</div>
</div>
{/if}
<!-- Calendário -->
<div
bind:this={calendarEl}
class="calendario-ausencias shadow-2xl rounded-2xl overflow-hidden border-2 border-orange-500/10"
></div>
<!-- Legenda de status -->
{#if ausenciasExistentes.length > 0 || readonly}
<div class="mt-6 flex flex-wrap gap-4 justify-center">
<div class="badge badge-lg gap-2" style="background-color: #f59e0b; border-color: #d97706; color: white;">
<div class="w-3 h-3 rounded-full bg-white"></div>
Aguardando Aprovação
</div>
<div class="badge badge-lg gap-2" style="background-color: #10b981; border-color: #059669; color: white;">
<div class="w-3 h-3 rounded-full bg-white"></div>
Aprovado
</div>
<div class="badge badge-lg gap-2" style="background-color: #ef4444; border-color: #dc2626; color: white;">
<div class="w-3 h-3 rounded-full bg-white"></div>
Reprovado
</div>
</div>
{/if}
<!-- Informação do período selecionado -->
{#if dataInicio && dataFim && !readonly}
<div class="mt-6 card bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 shadow-lg border-2 border-orange-500/30">
<div class="card-body">
<h3 class="card-title text-orange-700 dark:text-orange-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Período Selecionado
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
<div>
<p class="text-sm text-base-content/70">Data Início</p>
<p class="font-bold text-lg">{new Date(dataInicio).toLocaleDateString("pt-BR")}</p>
</div>
<div>
<p class="text-sm text-base-content/70">Data Fim</p>
<p class="font-bold text-lg">{new Date(dataFim).toLocaleDateString("pt-BR")}</p>
</div>
<div>
<p class="text-sm text-base-content/70">Total de Dias</p>
<p class="font-bold text-2xl text-orange-600 dark:text-orange-400">{calcularDias(dataInicio, dataFim)} dias</p>
</div>
</div>
</div>
</div>
{/if}
</div>
<style>
/* Calendário Premium */
.calendario-ausencias {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
/* Toolbar moderna com cores laranja/amarelo */
:global(.calendario-ausencias .fc .fc-toolbar) {
background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
padding: 1rem;
border-radius: 1rem 1rem 0 0;
color: white !important;
}
:global(.calendario-ausencias .fc .fc-toolbar-title) {
color: white !important;
font-weight: 700;
font-size: 1.5rem;
}
:global(.calendario-ausencias .fc .fc-button) {
background: rgba(255, 255, 255, 0.2) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
color: white !important;
font-weight: 600;
text-transform: capitalize;
transition: all 0.3s ease;
}
:global(.calendario-ausencias .fc .fc-button:hover) {
background: rgba(255, 255, 255, 0.3) !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
:global(.calendario-ausencias .fc .fc-button-active) {
background: rgba(255, 255, 255, 0.4) !important;
}
/* Cabeçalho dos dias */
:global(.calendario-ausencias .fc .fc-col-header-cell) {
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
padding: 0.75rem 0.5rem;
color: #495057;
}
/* Células dos dias */
:global(.calendario-ausencias .fc .fc-daygrid-day) {
transition: all 0.2s ease;
}
:global(.calendario-ausencias .fc .fc-daygrid-day:hover) {
background: rgba(245, 158, 11, 0.05);
}
:global(.calendario-ausencias .fc .fc-daygrid-day-number) {
padding: 0.5rem;
font-weight: 600;
color: #495057;
}
/* Fim de semana */
:global(.calendario-ausencias .fc .fc-day-weekend-custom) {
background: rgba(255, 193, 7, 0.05);
}
/* Hoje */
:global(.calendario-ausencias .fc .fc-day-today) {
background: rgba(245, 158, 11, 0.1) !important;
border: 2px solid #f59e0b !important;
}
/* Eventos (ausências) */
:global(.calendario-ausencias .fc .fc-event) {
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
font-weight: 600;
font-size: 0.875rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
cursor: pointer;
}
:global(.calendario-ausencias .fc .fc-event:hover) {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
/* Seleção (arrastar) */
:global(.calendario-ausencias .fc .fc-highlight) {
background: rgba(245, 158, 11, 0.3) !important;
border: 2px dashed #f59e0b;
}
/* Datas desabilitadas (passado) */
:global(.calendario-ausencias .fc .fc-day-past .fc-daygrid-day-number) {
opacity: 0.4;
}
/* Remover bordas padrão */
:global(.calendario-ausencias .fc .fc-scrollgrid) {
border: none !important;
}
:global(.calendario-ausencias .fc .fc-scrollgrid-section > td) {
border: none !important;
}
/* Grid moderno */
:global(.calendario-ausencias .fc .fc-daygrid-day-frame) {
border: 1px solid #e9ecef;
min-height: 80px;
}
/* Responsivo */
@media (max-width: 768px) {
:global(.calendario-ausencias .fc .fc-toolbar) {
flex-direction: column;
gap: 0.75rem;
}
:global(.calendario-ausencias .fc .fc-toolbar-title) {
font-size: 1.25rem;
}
:global(.calendario-ausencias .fc .fc-button) {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,437 @@
<script lang="ts">
import { useConvexClient, useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import CalendarioAusencias from "./CalendarioAusencias.svelte";
import { toast } from "svelte-sonner";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
interface Props {
funcionarioId: Id<"funcionarios">;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
// Cliente Convex
const client = useConvexClient();
// Estado do wizard
let passoAtual = $state(1);
const totalPassos = 2;
// Dados da solicitação
let dataInicio = $state<string>("");
let dataFim = $state<string>("");
let motivo = $state("");
let processando = $state(false);
// Buscar ausências existentes para exibir no calendário
const ausenciasExistentesQuery = useQuery(api.ausencias.listarMinhasSolicitacoes, {
funcionarioId,
});
const ausenciasExistentes = $derived(
(ausenciasExistentesQuery?.data || []).map((a) => ({
dataInicio: a.dataInicio,
dataFim: a.dataFim,
status: a.status as "aguardando_aprovacao" | "aprovado" | "reprovado",
}))
);
// Calcular dias selecionados
function calcularDias(inicio: string, fim: string): number {
if (!inicio || !fim) return 0;
const dInicio = new Date(inicio);
const dFim = new Date(fim);
const diffTime = Math.abs(dFim.getTime() - dInicio.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
}
const totalDias = $derived(calcularDias(dataInicio, dataFim));
// Funções de navegação
function proximoPasso() {
if (passoAtual === 1) {
if (!dataInicio || !dataFim) {
toast.error("Selecione o período de ausência no calendário");
return;
}
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
const inicio = new Date(dataInicio);
if (inicio < hoje) {
toast.error("A data de início não pode ser no passado");
return;
}
if (new Date(dataFim) < new Date(dataInicio)) {
toast.error("A data de fim deve ser maior ou igual à data de início");
return;
}
}
if (passoAtual < totalPassos) {
passoAtual++;
}
}
function passoAnterior() {
if (passoAtual > 1) {
passoAtual--;
}
}
async function enviarSolicitacao() {
if (!dataInicio || !dataFim) {
toast.error("Selecione o período de ausência");
return;
}
if (!motivo.trim() || motivo.trim().length < 10) {
toast.error("O motivo deve ter no mínimo 10 caracteres");
return;
}
try {
processando = true;
await client.mutation(api.ausencias.criarSolicitacao, {
funcionarioId,
dataInicio,
dataFim,
motivo: motivo.trim(),
});
toast.success("Solicitação de ausência criada com sucesso!");
if (onSucesso) {
onSucesso();
}
} catch (error) {
console.error("Erro ao criar solicitação:", error);
toast.error(
error instanceof Error ? error.message : "Erro ao criar solicitação de ausência"
);
} finally {
processando = false;
}
}
function handlePeriodoSelecionado(periodo: { dataInicio: string; dataFim: string }) {
dataInicio = periodo.dataInicio;
dataFim = periodo.dataFim;
}
</script>
<div class="wizard-ausencia">
<!-- Header -->
<div class="mb-6">
<h2 class="text-3xl font-bold text-primary mb-2">Nova Solicitação de Ausência</h2>
<p class="text-base-content/70">Solicite uma ausência para assuntos particulares</p>
</div>
<!-- Indicador de progresso -->
<div class="steps mb-8">
<div class="step {passoAtual >= 1 ? 'step-primary' : ''}">
<div class="step-item">
<div class="step-marker">
{#if passoAtual > 1}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{:else}
{passoAtual}
{/if}
</div>
<div class="step-content">
<div class="step-title">Selecionar Período</div>
<div class="step-description">Escolha as datas no calendário</div>
</div>
</div>
</div>
<div class="step {passoAtual >= 2 ? 'step-primary' : ''}">
<div class="step-item">
<div class="step-marker">
{#if passoAtual > 2}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{:else}
2
{/if}
</div>
<div class="step-content">
<div class="step-title">Informar Motivo</div>
<div class="step-description">Descreva o motivo da ausência</div>
</div>
</div>
</div>
</div>
<!-- Conteúdo dos passos -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
{#if passoAtual === 1}
<!-- Passo 1: Selecionar Período -->
<div class="space-y-6">
<div>
<h3 class="text-2xl font-bold mb-2">Selecione o Período</h3>
<p class="text-base-content/70">
Clique e arraste no calendário para selecionar o período de ausência
</p>
</div>
<CalendarioAusencias
dataInicio={dataInicio}
dataFim={dataFim}
ausenciasExistentes={ausenciasExistentes}
onPeriodoSelecionado={handlePeriodoSelecionado}
/>
{#if dataInicio && dataFim}
<div class="alert alert-success shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<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>
<h4 class="font-bold">Período selecionado!</h4>
<p>
De {new Date(dataInicio).toLocaleDateString("pt-BR")} até{" "}
{new Date(dataFim).toLocaleDateString("pt-BR")} ({totalDias} dias)
</p>
</div>
</div>
{/if}
</div>
{:else if passoAtual === 2}
<!-- Passo 2: Informar Motivo -->
<div class="space-y-6">
<div>
<h3 class="text-2xl font-bold mb-2">Informe o Motivo</h3>
<p class="text-base-content/70">
Descreva o motivo da sua solicitação de ausência (mínimo 10 caracteres)
</p>
</div>
<!-- Resumo do período -->
{#if dataInicio && dataFim}
<div class="card bg-gradient-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 border-2 border-orange-500/30">
<div class="card-body">
<h4 class="card-title text-orange-700 dark:text-orange-400">
<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="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>
Resumo do Período
</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
<div>
<p class="text-sm text-base-content/70">Data Início</p>
<p class="font-bold">{new Date(dataInicio).toLocaleDateString("pt-BR")}</p>
</div>
<div>
<p class="text-sm text-base-content/70">Data Fim</p>
<p class="font-bold">{new Date(dataFim).toLocaleDateString("pt-BR")}</p>
</div>
<div>
<p class="text-sm text-base-content/70">Total de Dias</p>
<p class="font-bold text-xl text-orange-600 dark:text-orange-400">
{totalDias} dias
</p>
</div>
</div>
</div>
</div>
{/if}
<!-- Campo de motivo -->
<div class="form-control">
<label class="label" for="motivo">
<span class="label-text font-bold">Motivo da Ausência</span>
<span class="label-text-alt">
{motivo.trim().length}/10 caracteres mínimos
</span>
</label>
<textarea
id="motivo"
class="textarea textarea-bordered h-32 text-lg"
placeholder="Descreva o motivo da sua solicitação de ausência..."
bind:value={motivo}
maxlength={500}
></textarea>
<label class="label">
<span class="label-text-alt text-base-content/70">
Mínimo 10 caracteres. Seja claro e objetivo.
</span>
</label>
</div>
{#if motivo.trim().length > 0 && motivo.trim().length < 10}
<div class="alert alert-warning shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>O motivo deve ter no mínimo 10 caracteres</span>
</div>
{/if}
</div>
{/if}
<!-- Botões de navegação -->
<div class="card-actions justify-between mt-6">
<button
type="button"
class="btn btn-ghost"
onclick={passoAnterior}
disabled={passoAtual === 1 || processando}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
Voltar
</button>
{#if passoAtual < totalPassos}
<button
type="button"
class="btn btn-primary"
onclick={proximoPasso}
disabled={processando}
>
Próximo
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 ml-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
{:else}
<button
type="button"
class="btn btn-success"
onclick={enviarSolicitacao}
disabled={processando || motivo.trim().length < 10}
>
{#if processando}
<span class="loading loading-spinner"></span>
Enviando...
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Enviar Solicitação
{/if}
</button>
{/if}
</div>
<!-- Botão cancelar -->
<div class="mt-4 text-center">
<button
type="button"
class="btn btn-ghost btn-sm"
onclick={() => {
if (onCancelar) onCancelar();
}}
disabled={processando}
>
Cancelar
</button>
</div>
</div>
</div>
</div>
<style>
.wizard-ausencia {
max-width: 1000px;
margin: 0 auto;
}
</style>

View File

@@ -15,7 +15,8 @@
const countQuery = useQuery(api.chat.contarNotificacoesNaoLidas, {});
let dropdownOpen = $state(false);
let notificacoesFerias = $state<any[]>([]);
let notificacoesFerias = $state<Array<{ _id: string; mensagem: string; tipo: string; _creationTime: number }>>([]);
let notificacoesAusencias = $state<Array<{ _id: string; mensagem: string; tipo: string; _creationTime: number }>>([]);
// Helpers para obter valores das queries
const count = $derived(
@@ -29,7 +30,7 @@
// Atualizar contador no store
$effect(() => {
const totalNotificacoes = count + (notificacoesFerias?.length || 0);
const totalNotificacoes = count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0);
notificacoesCount.set(totalNotificacoes);
});
@@ -42,7 +43,7 @@
const notifsFerias = await client.query(
api.ferias.obterNotificacoesNaoLidas,
{
usuarioId: usuarioStore.usuario._id as any,
usuarioId: usuarioStore.usuario._id,
}
);
notificacoesFerias = notifsFerias || [];
@@ -52,10 +53,46 @@
}
}
// Atualizar notificações de férias periodicamente
// Buscar notificações de ausências
async function buscarNotificacoesAusencias() {
try {
const usuarioStore = authStore;
if (usuarioStore.usuario?._id) {
try {
const notifsAusencias = await client.query(
api.ausencias.obterNotificacoesNaoLidas,
{
usuarioId: usuarioStore.usuario._id,
}
);
notificacoesAusencias = notifsAusencias || [];
} catch (queryError: unknown) {
// Silenciar erro se a função não estiver disponível ainda (Convex não sincronizado)
const errorMessage = queryError instanceof Error ? queryError.message : String(queryError);
if (!errorMessage.includes("Could not find public function")) {
console.error("Erro ao buscar notificações de ausências:", queryError);
}
notificacoesAusencias = [];
}
}
} catch (e) {
// Erro geral - silenciar se for sobre função não encontrada
const errorMessage = e instanceof Error ? e.message : String(e);
if (!errorMessage.includes("Could not find public function")) {
console.error("Erro ao buscar notificações de ausências:", e);
}
}
}
// Atualizar notificações periodicamente
$effect(() => {
buscarNotificacoesFerias();
const interval = setInterval(buscarNotificacoesFerias, 30000); // A cada 30s
buscarNotificacoesAusencias();
const interval = setInterval(() => {
buscarNotificacoesFerias();
buscarNotificacoesAusencias();
}, 30000); // A cada 30s
return () => clearInterval(interval);
});
@@ -78,8 +115,15 @@
notificacaoId: notif._id,
});
}
// Marcar todas as notificações de ausências como lidas
for (const notif of notificacoesAusencias) {
await client.mutation(api.ausencias.marcarComoLida, {
notificacaoId: notif._id,
});
}
dropdownOpen = false;
await buscarNotificacoesFerias();
await buscarNotificacoesAusencias();
}
async function handleClickNotificacao(notificacaoId: string) {
@@ -91,7 +135,7 @@
async function handleClickNotificacaoFerias(notificacaoId: string) {
await client.mutation(api.ferias.marcarComoLida, {
notificacaoId: notificacaoId as any,
notificacaoId: notificacaoId,
});
await buscarNotificacoesFerias();
dropdownOpen = false;
@@ -99,6 +143,16 @@
window.location.href = "/recursos-humanos/ferias";
}
async function handleClickNotificacaoAusencias(notificacaoId: string) {
await client.mutation(api.ausencias.marcarComoLida, {
notificacaoId: notificacaoId,
});
await buscarNotificacoesAusencias();
dropdownOpen = false;
// Redirecionar para a página de perfil na aba de ausências
window.location.href = "/perfil?aba=minhas-ausencias";
}
function toggleDropdown() {
dropdownOpen = !dropdownOpen;
}
@@ -330,8 +384,57 @@
{/each}
{/if}
<!-- Notificações de Ausências -->
{#if notificacoesAusencias.length > 0}
{#if notificacoes.length > 0 || notificacoesFerias.length > 0}
<div class="divider my-2 text-xs">Ausências</div>
{/if}
{#each notificacoesAusencias.slice(0, 5) as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors"
onclick={() => handleClickNotificacaoAusencias(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="flex-shrink-0 mt-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 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>
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content">
{notificacao.mensagem}
</p>
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao._creationTime)}
</p>
</div>
<!-- Badge -->
<div class="flex-shrink-0">
<div class="badge badge-warning badge-xs"></div>
</div>
</div>
</button>
{/each}
{/if}
<!-- Sem notificações -->
{#if notificacoes.length === 0 && notificacoesFerias.length === 0}
{#if notificacoes.length === 0 && notificacoesFerias.length === 0 && notificacoesAusencias.length === 0}
<div class="px-4 py-8 text-center text-base-content/50">
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -94,7 +94,7 @@
const resultado = await convex.mutation(api.autenticacao.alterarSenha, {
token: authStore.token,
senhaAntiga: senhaAtual,
senhaAtual: senhaAtual,
novaSenha: novaSenha,
});

View File

@@ -1,4 +1,54 @@
<script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { goto } from "$app/navigation";
const client = useConvexClient();
// Buscar todas as solicitações de ausências
const ausenciasQuery = useQuery(api.ausencias.listarTodas, {});
const ausencias = $derived(ausenciasQuery?.data || []);
// Estatísticas
const stats = $derived({
total: ausencias.length,
pendentes: ausencias.filter((a) => a.status === "aguardando_aprovacao").length,
aprovadas: ausencias.filter((a) => a.status === "aprovado").length,
reprovadas: ausencias.filter((a) => a.status === "reprovado").length,
});
// Solicitações pendentes (últimas 5)
const pendentes = $derived(
ausencias
.filter((a) => a.status === "aguardando_aprovacao")
.slice(0, 5)
);
function calcularDias(dataInicio: string, dataFim: string): number {
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const diff = fim.getTime() - inicio.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
}
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
aguardando_aprovacao: "badge-warning",
aprovado: "badge-success",
reprovado: "badge-error",
};
return badges[status] || "badge-neutral";
}
function getStatusTexto(status: string) {
const textos: Record<string, string> = {
aguardando_aprovacao: "Aguardando",
aprovado: "Aprovado",
reprovado: "Reprovado",
};
return textos[status] || status;
}
</script>
<main class="container mx-auto px-4 py-4">
@@ -12,37 +62,157 @@
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-teal-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-teal-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-teal-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Secretaria de Gestão de Pessoas</h1>
<h1 class="text-3xl font-bold text-primary">
Secretaria de Gestão de Pessoas
</h1>
<p class="text-base-content/70">Gestão estratégica de pessoas</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<!-- Card: Gestão de Ausências -->
<div class="card bg-base-100 shadow-xl mb-6 border-t-4 border-orange-500">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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" />
<div class="flex items-center justify-between mb-4">
<h2 class="card-title text-2xl">
<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>
Gestão de Ausências
</h2>
<button
type="button"
class="btn btn-sm btn-outline btn-primary"
onclick={() => goto("/recursos-humanos/ausencias")}
>
Ver Todas
</button>
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo da Secretaria de Gestão de Pessoas está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão estratégica de pessoas.
</p>
<div class="badge badge-warning badge-lg gap-2">
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
<!-- Estatísticas -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title">Total</div>
<div class="stat-value text-orange-600">{stats.total}</div>
<div class="stat-desc">Solicitações</div>
</div>
<div class="stat bg-warning/10 rounded-lg">
<div class="stat-title">Pendentes</div>
<div class="stat-value text-warning">{stats.pendentes}</div>
<div class="stat-desc">Aguardando</div>
</div>
<div class="stat bg-success/10 rounded-lg">
<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-error/10 rounded-lg">
<div class="stat-title">Reprovadas</div>
<div class="stat-value text-error">{stats.reprovadas}</div>
<div class="stat-desc">Indeferidas</div>
</div>
</div>
<!-- Lista de Pendentes -->
<div>
<h3 class="font-bold text-lg mb-3">
Solicitações Pendentes de Aprovação
</h3>
{#if pendentes.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>
Em Desenvolvimento
<span>Nenhuma solicitação pendente no momento.</span>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Funcionário</th>
<th>Período</th>
<th>Dias</th>
<th>Status</th>
<th>Solicitado em</th>
</tr>
</thead>
<tbody>
{#each pendentes as ausencia}
<tr>
<td class="font-semibold">
{ausencia.funcionario?.nome || "N/A"}
</td>
<td>
{new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até{" "}
{new Date(ausencia.dataFim).toLocaleDateString("pt-BR")}
</td>
<td class="font-bold">
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
</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 stats.pendentes > 5}
<div class="mt-4 text-center">
<button
type="button"
class="btn btn-sm btn-outline"
onclick={() => goto("/recursos-humanos/ausencias")}
>
Ver todas as {stats.pendentes} pendentes
</button>
</div>
{/if}
{/if}
</div>
</div>
</div>
</main>

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}

View File

@@ -0,0 +1,419 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { authStore } from "$lib/stores/auth.svelte";
import AprovarAusencias from "$lib/components/AprovarAusencias.svelte";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
const client = useConvexClient();
// Buscar TODAS as solicitações de ausências (Dashboard RH)
const todasAusenciasQuery = useQuery(api.ausencias.listarTodas, {});
let filtroStatus = $state<string>("todos");
let solicitacaoSelecionada = $state<Id<"solicitacoesAusencias"> | null>(null);
const ausencias = $derived(todasAusenciasQuery?.data || []);
// Filtrar solicitações
const ausenciasFiltradas = $derived(
ausencias.filter((a) => {
// Filtro de status
if (filtroStatus !== "todos" && a.status !== filtroStatus) return false;
return true;
})
);
// Estatísticas gerais
const stats = $derived({
total: ausencias.length,
aguardando: ausencias.filter((a) => a.status === "aguardando_aprovacao").length,
aprovadas: ausencias.filter((a) => a.status === "aprovado").length,
reprovadas: ausencias.filter((a) => a.status === "reprovado").length,
});
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
aguardando_aprovacao: "badge-warning",
aprovado: "badge-success",
reprovado: "badge-error",
};
return badges[status] || "badge-neutral";
}
function getStatusTexto(status: string) {
const textos: Record<string, string> = {
aguardando_aprovacao: "Aguardando",
aprovado: "Aprovado",
reprovado: "Reprovado",
};
return textos[status] || status;
}
function calcularDias(dataInicio: string, dataFim: string): number {
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const diff = fim.getTime() - inicio.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
}
async function selecionarSolicitacao(solicitacaoId: Id<"solicitacoesAusencias">) {
solicitacaoSelecionada = solicitacaoId;
}
async function recarregar() {
solicitacaoSelecionada = null;
}
</script>
<main class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li>
<a href="/recursos-humanos" class="text-primary hover:underline"
>Recursos Humanos</a
>
</li>
<li>Gestão de Ausências</li>
</ul>
</div>
<!-- Header -->
<div class="mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="p-3 bg-orange-500/20 rounded-xl">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 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>
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Dashboard de Ausências</h1>
<p class="text-base-content/70">
Visão geral de todas as solicitações de ausências
</p>
</div>
</div>
<button
class="btn btn-ghost 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>
<!-- Estatísticas -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<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">{stats.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">Pendentes</div>
<div class="stat-value text-warning">{stats.aguardando}</div>
<div class="stat-desc">Aguardando</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">{stats.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">{stats.reprovadas}</div>
<div class="stat-desc">Indeferidas</div>
</div>
</div>
<!-- Filtros -->
<div class="card bg-base-100 shadow-lg mb-6">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Filtros</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label" for="filtro-status">
<span class="label-text">Status</span>
</label>
<select
id="filtro-status"
class="select select-bordered"
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>
</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">
Todas as 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>Funcionário</th>
<th>Time</th>
<th>Período</th>
<th>Dias</th>
<th>Motivo</th>
<th>Status</th>
<th>Solicitado em</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each ausenciasFiltradas as ausencia}
<tr>
<td class="font-semibold">
{ausencia.funcionario?.nome || "N/A"}
</td>
<td>
{#if ausencia.time}
<div
class="badge badge-sm font-semibold"
style="background-color: {ausencia.time.cor}20; border-color: {ausencia.time.cor}; color: {ausencia.time.cor}"
>
{ausencia.time.nome}
</div>
{:else}
<span class="text-base-content/50">Sem time</span>
{/if}
</td>
<td>
{new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até{" "}
{new Date(ausencia.dataFim).toLocaleDateString("pt-BR")}
</td>
<td class="font-bold">
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} 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>
<td>
{#if ausencia.status === "aguardando_aprovacao"}
<button
type="button"
class="btn btn-primary btn-sm gap-2"
onclick={() => selecionarSolicitacao(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>
Ver Detalhes
</button>
{:else}
<button
type="button"
class="btn btn-ghost btn-sm gap-2"
onclick={() => selecionarSolicitacao(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>
Ver Detalhes
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</main>
<!-- Modal de Aprovação -->
{#if solicitacaoSelecionada && authStore.usuario}
{#await client.query(api.ausencias.obterDetalhes, {
solicitacaoId: solicitacaoSelecionada,
}) then detalhes}
{#if detalhes}
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl">
<AprovarAusencias
solicitacao={detalhes}
gestorId={authStore.usuario._id}
onSucesso={recarregar}
onCancelar={() => (solicitacaoSelecionada = null)}
/>
</div>
<form method="dialog" class="modal-backdrop">
<button
type="button"
onclick={() => (solicitacaoSelecionada = null)}
aria-label="Fechar modal"
>Fechar</button
>
</form>
</dialog>
{/if}
{/await}
{/if}

View File

@@ -1,4 +1,54 @@
<script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { goto } from "$app/navigation";
const client = useConvexClient();
// Buscar todas as solicitações de ausências
const ausenciasQuery = useQuery(api.ausencias.listarTodas, {});
const ausencias = $derived(ausenciasQuery?.data || []);
// Estatísticas
const stats = $derived({
total: ausencias.length,
pendentes: ausencias.filter((a) => a.status === "aguardando_aprovacao").length,
aprovadas: ausencias.filter((a) => a.status === "aprovado").length,
reprovadas: ausencias.filter((a) => a.status === "reprovado").length,
});
// Solicitações pendentes (últimas 5)
const pendentes = $derived(
ausencias
.filter((a) => a.status === "aguardando_aprovacao")
.slice(0, 5)
);
function calcularDias(dataInicio: string, dataFim: string): number {
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const diff = fim.getTime() - inicio.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
}
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
aguardando_aprovacao: "badge-warning",
aprovado: "badge-success",
reprovado: "badge-error",
};
return badges[status] || "badge-neutral";
}
function getStatusTexto(status: string) {
const textos: Record<string, string> = {
aguardando_aprovacao: "Aguardando",
aprovado: "Aprovado",
reprovado: "Reprovado",
};
return textos[status] || status;
}
</script>
<main class="container mx-auto px-4 py-4">
@@ -12,8 +62,19 @@
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-indigo-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-indigo-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div>
@@ -23,26 +84,131 @@
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<!-- Card: Gestão de Ausências -->
<div class="card bg-base-100 shadow-xl mb-6 border-t-4 border-orange-500">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
<div class="flex items-center justify-between mb-4">
<h2 class="card-title text-2xl">
<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>
Gestão de Ausências
</h2>
<button
type="button"
class="btn btn-sm btn-outline btn-primary"
onclick={() => goto("/recursos-humanos/ausencias")}
>
Ver Todas
</button>
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo da Secretaria Executiva está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão executiva e administrativa.
</p>
<div class="badge badge-warning badge-lg gap-2">
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
<!-- Estatísticas -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title">Total</div>
<div class="stat-value text-orange-600">{stats.total}</div>
<div class="stat-desc">Solicitações</div>
</div>
<div class="stat bg-warning/10 rounded-lg">
<div class="stat-title">Pendentes</div>
<div class="stat-value text-warning">{stats.pendentes}</div>
<div class="stat-desc">Aguardando</div>
</div>
<div class="stat bg-success/10 rounded-lg">
<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-error/10 rounded-lg">
<div class="stat-title">Reprovadas</div>
<div class="stat-value text-error">{stats.reprovadas}</div>
<div class="stat-desc">Indeferidas</div>
</div>
</div>
<!-- Lista de Pendentes -->
<div>
<h3 class="font-bold text-lg mb-3">Solicitações Pendentes de Aprovação</h3>
{#if pendentes.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>
Em Desenvolvimento
<span>Nenhuma solicitação pendente no momento.</span>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Funcionário</th>
<th>Período</th>
<th>Dias</th>
<th>Status</th>
<th>Solicitado em</th>
</tr>
</thead>
<tbody>
{#each pendentes as ausencia}
<tr>
<td class="font-semibold">
{ausencia.funcionario?.nome || "N/A"}
</td>
<td>
{new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até{" "}
{new Date(ausencia.dataFim).toLocaleDateString("pt-BR")}
</td>
<td class="font-bold">
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
</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 stats.pendentes > 5}
<div class="mt-4 text-center">
<button
type="button"
class="btn btn-sm btn-outline"
onclick={() => goto("/recursos-humanos/ausencias")}
>
Ver todas as {stats.pendentes} pendentes
</button>
</div>
{/if}
{/if}
</div>
</div>
</div>
</main>

View File

@@ -11,6 +11,7 @@
import type * as actions_email from "../actions/email.js";
import type * as actions_smtp from "../actions/smtp.js";
import type * as atestadosLicencas from "../atestadosLicencas.js";
import type * as ausencias from "../ausencias.js";
import type * as autenticacao from "../autenticacao.js";
import type * as auth_utils from "../auth/utils.js";
import type * as chat from "../chat.js";
@@ -60,6 +61,7 @@ declare const fullApi: ApiFromModules<{
"actions/email": typeof actions_email;
"actions/smtp": typeof actions_smtp;
atestadosLicencas: typeof atestadosLicencas;
ausencias: typeof ausencias;
autenticacao: typeof autenticacao;
"auth/utils": typeof auth_utils;
chat: typeof chat;

View File

@@ -0,0 +1,666 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import type { QueryCtx, MutationCtx } from "./_generated/server";
import { internal, api } from "./_generated/api";
import { Id, Doc } from "./_generated/dataModel";
// Query: Listar todas as solicitações (para RH)
export const listarTodas = query({
args: {},
handler: async (ctx) => {
const solicitacoes = await ctx.db.query("solicitacoesAusencias").collect();
const solicitacoesComDetalhes = await Promise.all(
solicitacoes.map(async (s) => {
const funcionario = await ctx.db.get(s.funcionarioId);
// Buscar time do funcionário
const membroTime = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) =>
q.eq("funcionarioId", s.funcionarioId)
)
.filter((q) => q.eq(q.field("ativo"), true))
.first();
let time = null;
if (membroTime) {
time = await ctx.db.get(membroTime.timeId);
}
return {
...s,
funcionario,
time,
};
})
);
return solicitacoesComDetalhes.sort(
(a, b) => b.criadoEm - a.criadoEm
);
},
});
// Query: Listar solicitações do funcionário
export const listarMinhasSolicitacoes = query({
args: { funcionarioId: v.id("funcionarios") },
handler: async (ctx, args) => {
const solicitacoes = await ctx.db
.query("solicitacoesAusencias")
.withIndex("by_funcionario", (q) =>
q.eq("funcionarioId", args.funcionarioId)
)
.order("desc")
.collect();
// Enriquecer com dados do funcionário e time
const solicitacoesComDetalhes = await Promise.all(
solicitacoes.map(async (s) => {
const funcionario = await ctx.db.get(s.funcionarioId);
// Buscar time do funcionário
const membroTime = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) =>
q.eq("funcionarioId", s.funcionarioId)
)
.filter((q) => q.eq(q.field("ativo"), true))
.first();
let time = null;
if (membroTime) {
time = await ctx.db.get(membroTime.timeId);
}
return {
...s,
funcionario,
time,
};
})
);
return solicitacoesComDetalhes;
},
});
// Query: Listar solicitações dos subordinados (para gestores)
export const listarSolicitacoesSubordinados = query({
args: { gestorId: v.id("usuarios") },
handler: async (ctx, args) => {
// Buscar times onde o usuário é gestor
const timesGestor = await ctx.db
.query("times")
.withIndex("by_gestor", (q) => q.eq("gestorId", args.gestorId))
.filter((q) => q.eq(q.field("ativo"), true))
.collect();
const solicitacoes: Array<Doc<"solicitacoesAusencias"> & {
funcionario: Doc<"funcionarios"> | null;
time: Doc<"times"> | null;
}> = [];
for (const time of timesGestor) {
// Buscar membros do time
const membros = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) =>
q.eq("timeId", time._id).eq("ativo", true)
)
.collect();
// Buscar solicitações de cada membro
for (const membro of membros) {
const solic = await ctx.db
.query("solicitacoesAusencias")
.withIndex("by_funcionario", (q) =>
q.eq("funcionarioId", membro.funcionarioId)
)
.collect();
// Adicionar info do funcionário
for (const s of solic) {
const funcionario = await ctx.db.get(s.funcionarioId);
solicitacoes.push({
...s,
funcionario,
time,
});
}
}
}
return solicitacoes.sort((a, b) => b.criadoEm - a.criadoEm);
},
});
// Query: Obter detalhes completos de uma solicitação
export const obterDetalhes = query({
args: { solicitacaoId: v.id("solicitacoesAusencias") },
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) return null;
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
let gestor = null;
if (solicitacao.gestorId) {
gestor = await ctx.db.get(solicitacao.gestorId);
}
// Buscar time do funcionário
const membroTime = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) =>
q.eq("funcionarioId", solicitacao.funcionarioId)
)
.filter((q) => q.eq(q.field("ativo"), true))
.first();
let time = null;
if (membroTime) {
time = await ctx.db.get(membroTime.timeId);
}
return {
...solicitacao,
funcionario,
gestor,
time,
};
},
});
// Query: Obter notificações não lidas
export const obterNotificacoesNaoLidas = query({
args: { usuarioId: v.id("usuarios") },
handler: async (ctx, args) => {
const notificacoes = await ctx.db
.query("notificacoesAusencias")
.withIndex("by_destinatario_and_lida", (q) =>
q.eq("destinatarioId", args.usuarioId).eq("lida", false)
)
.order("desc")
.collect();
return notificacoes;
},
});
// Query: Contar solicitações pendentes para gestor
export const contarPendentesGestor = query({
args: { gestorId: v.id("usuarios") },
handler: async (ctx, args) => {
// Buscar times onde o usuário é gestor
const timesGestor = await ctx.db
.query("times")
.withIndex("by_gestor", (q) => q.eq("gestorId", args.gestorId))
.filter((q) => q.eq(q.field("ativo"), true))
.collect();
let totalPendentes = 0;
for (const time of timesGestor) {
// Buscar membros do time
const membros = await ctx.db
.query("timesMembros")
.withIndex("by_time_and_ativo", (q) =>
q.eq("timeId", time._id).eq("ativo", true)
)
.collect();
// Contar solicitações pendentes de cada membro
for (const membro of membros) {
const pendentes = await ctx.db
.query("solicitacoesAusencias")
.withIndex("by_funcionario_and_status", (q) =>
q
.eq("funcionarioId", membro.funcionarioId)
.eq("status", "aguardando_aprovacao")
)
.collect();
totalPendentes += pendentes.length;
}
}
return totalPendentes;
},
});
// Helper: Verificar se há sobreposição de datas
function verificarSobreposicao(
inicio1: string,
fim1: string,
inicio2: string,
fim2: string
): boolean {
const d1Inicio = new Date(inicio1);
const d1Fim = new Date(fim1);
const d2Inicio = new Date(inicio2);
const d2Fim = new Date(fim2);
return d1Inicio <= d2Fim && d2Inicio <= d1Fim;
}
// Helper: Encontrar gestor do funcionário
async function encontrarGestorDoFuncionario(
ctx: QueryCtx | MutationCtx,
funcionarioId: Id<"funcionarios">
): Promise<Id<"usuarios"> | null> {
const membroTime = await ctx.db
.query("timesMembros")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", funcionarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.first();
if (!membroTime) return null;
const time = await ctx.db.get(membroTime.timeId);
if (!time) return null;
return time.gestorId;
}
// Mutation: Criar solicitação de ausência
export const criarSolicitacao = mutation({
args: {
funcionarioId: v.id("funcionarios"),
dataInicio: v.string(),
dataFim: v.string(),
motivo: v.string(),
},
returns: v.id("solicitacoesAusencias"),
handler: async (ctx, args) => {
// Validações
if (args.motivo.trim().length < 10) {
throw new Error("O motivo deve ter no mínimo 10 caracteres");
}
const dataInicio = new Date(args.dataInicio);
const dataFim = new Date(args.dataFim);
const hoje = new Date();
hoje.setHours(0, 0, 0, 0);
if (dataInicio < hoje) {
throw new Error("A data de início não pode ser no passado");
}
if (dataFim < dataInicio) {
throw new Error("A data de fim deve ser maior ou igual à data de início");
}
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario) {
throw new Error("Funcionário não encontrado");
}
// Verificar sobreposição com outras solicitações aprovadas ou pendentes
const solicitacoesExistentes = await ctx.db
.query("solicitacoesAusencias")
.withIndex("by_funcionario", (q) =>
q.eq("funcionarioId", args.funcionarioId)
)
.collect();
for (const solic of solicitacoesExistentes) {
if (
solic.status === "aprovado" ||
solic.status === "aguardando_aprovacao"
) {
if (
verificarSobreposicao(
args.dataInicio,
args.dataFim,
solic.dataInicio,
solic.dataFim
)
) {
throw new Error(
"Já existe uma solicitação aprovada ou pendente para este período"
);
}
}
}
// Criar solicitação
const solicitacaoId = await ctx.db.insert("solicitacoesAusencias", {
funcionarioId: args.funcionarioId,
dataInicio: args.dataInicio,
dataFim: args.dataFim,
motivo: args.motivo.trim(),
status: "aguardando_aprovacao",
criadoEm: Date.now(),
});
// Encontrar gestor do funcionário
const gestorId = await encontrarGestorDoFuncionario(
ctx,
args.funcionarioId
);
if (gestorId) {
// Criar notificação in-app para gestor
await ctx.db.insert("notificacoesAusencias", {
destinatarioId: gestorId,
solicitacaoAusenciaId: solicitacaoId,
tipo: "nova_solicitacao",
lida: false,
mensagem: `${funcionario.nome} solicitou uma ausência de ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}`,
});
// Buscar usuário do gestor para enviar email e chat
const gestorUsuario = await ctx.db.get(gestorId);
const funcionarioUsuario = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) =>
q.eq("funcionarioId", args.funcionarioId)
)
.first();
if (gestorUsuario && funcionarioUsuario) {
// Enviar email ao gestor
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: gestorUsuario.email,
destinatarioId: gestorId,
assunto: `Nova Solicitação de Ausência - ${funcionario.nome}`,
corpo: `<p>Olá ${gestorUsuario.nome},</p>
<p>O funcionário <strong>${funcionario.nome}</strong> solicitou uma ausência:</p>
<ul>
<li><strong>Período:</strong> ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${args.motivo}</li>
</ul>
<p>Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.</p>`,
enviadoPorId: funcionarioUsuario._id,
});
// Criar ou obter conversa entre gestor e funcionário
const conversasExistentes = await ctx.db
.query("conversas")
.filter((q) => q.eq(q.field("tipo"), "individual"))
.collect();
let conversaId: Id<"conversas"> | null = null;
for (const conversa of conversasExistentes) {
if (
conversa.participantes.length === 2 &&
conversa.participantes.includes(gestorId) &&
conversa.participantes.includes(funcionarioUsuario._id)
) {
conversaId = conversa._id;
break;
}
}
if (!conversaId) {
conversaId = await ctx.db.insert("conversas", {
tipo: "individual",
participantes: [gestorId, funcionarioUsuario._id],
criadoPor: funcionarioUsuario._id,
criadoEm: Date.now(),
});
}
// Criar mensagem de chat
await ctx.db.insert("mensagens", {
conversaId,
remetenteId: funcionarioUsuario._id,
tipo: "texto",
conteudo: `Solicitei uma ausência de ${new Date(args.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(args.dataFim).toLocaleDateString("pt-BR")}. Motivo: ${args.motivo}`,
enviadaEm: Date.now(),
});
}
}
return solicitacaoId;
},
});
// Mutation: Aprovar ausência
export const aprovar = mutation({
args: {
solicitacaoId: v.id("solicitacoesAusencias"),
gestorId: v.id("usuarios"),
},
returns: v.null(),
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) {
throw new Error("Solicitação não encontrada");
}
if (solicitacao.status !== "aguardando_aprovacao") {
throw new Error("Esta solicitação já foi processada");
}
// Verificar se o gestor tem permissão (é gestor do time do funcionário)
const gestorIdDoFuncionario = await encontrarGestorDoFuncionario(
ctx,
solicitacao.funcionarioId
);
if (gestorIdDoFuncionario !== args.gestorId) {
throw new Error("Você não tem permissão para aprovar esta solicitação");
}
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
if (!funcionario) {
throw new Error("Funcionário não encontrado");
}
// Atualizar solicitação
await ctx.db.patch(args.solicitacaoId, {
status: "aprovado",
gestorId: args.gestorId,
dataAprovacao: Date.now(),
});
// Buscar usuário do funcionário
const funcionarioUsuario = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) =>
q.eq("funcionarioId", solicitacao.funcionarioId)
)
.first();
if (funcionarioUsuario) {
// Criar notificação in-app para funcionário
await ctx.db.insert("notificacoesAusencias", {
destinatarioId: funcionarioUsuario._id,
solicitacaoAusenciaId: args.solicitacaoId,
tipo: "aprovado",
lida: false,
mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")} foi aprovada!`,
});
const gestorUsuario = await ctx.db.get(args.gestorId);
if (gestorUsuario) {
// Enviar email ao funcionário
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
assunto: "Solicitação de Ausência Aprovada",
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
<p>Sua solicitação de ausência foi <strong>aprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
</ul>`,
enviadoPorId: args.gestorId,
});
// Criar ou obter conversa
const conversasExistentes = await ctx.db
.query("conversas")
.filter((q) => q.eq(q.field("tipo"), "individual"))
.collect();
let conversaId: Id<"conversas"> | null = null;
for (const conversa of conversasExistentes) {
if (
conversa.participantes.length === 2 &&
conversa.participantes.includes(args.gestorId) &&
conversa.participantes.includes(funcionarioUsuario._id)
) {
conversaId = conversa._id;
break;
}
}
if (!conversaId) {
conversaId = await ctx.db.insert("conversas", {
tipo: "individual",
participantes: [args.gestorId, funcionarioUsuario._id],
criadoPor: args.gestorId,
criadoEm: Date.now(),
});
}
// Criar mensagem de chat
await ctx.db.insert("mensagens", {
conversaId,
remetenteId: args.gestorId,
tipo: "texto",
conteudo: `Aprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}.`,
enviadaEm: Date.now(),
});
}
}
return null;
},
});
// Mutation: Reprovar ausência
export const reprovar = mutation({
args: {
solicitacaoId: v.id("solicitacoesAusencias"),
gestorId: v.id("usuarios"),
motivoReprovacao: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) {
throw new Error("Solicitação não encontrada");
}
if (solicitacao.status !== "aguardando_aprovacao") {
throw new Error("Esta solicitação já foi processada");
}
// Verificar se o gestor tem permissão
const gestorIdDoFuncionario = await encontrarGestorDoFuncionario(
ctx,
solicitacao.funcionarioId
);
if (gestorIdDoFuncionario !== args.gestorId) {
throw new Error("Você não tem permissão para reprovar esta solicitação");
}
const funcionario = await ctx.db.get(solicitacao.funcionarioId);
if (!funcionario) {
throw new Error("Funcionário não encontrado");
}
// Atualizar solicitação
await ctx.db.patch(args.solicitacaoId, {
status: "reprovado",
gestorId: args.gestorId,
dataReprovacao: Date.now(),
motivoReprovacao: args.motivoReprovacao.trim(),
});
// Buscar usuário do funcionário
const funcionarioUsuario = await ctx.db
.query("usuarios")
.withIndex("by_funcionarioId", (q) =>
q.eq("funcionarioId", solicitacao.funcionarioId)
)
.first();
if (funcionarioUsuario) {
// Criar notificação in-app para funcionário
await ctx.db.insert("notificacoesAusencias", {
destinatarioId: funcionarioUsuario._id,
solicitacaoAusenciaId: args.solicitacaoId,
tipo: "reprovado",
lida: false,
mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")} foi reprovada. Motivo: ${args.motivoReprovacao}`,
});
const gestorUsuario = await ctx.db.get(args.gestorId);
if (gestorUsuario) {
// Enviar email ao funcionário
await ctx.runMutation(api.email.enfileirarEmail, {
destinatario: funcionarioUsuario.email,
destinatarioId: funcionarioUsuario._id,
assunto: "Solicitação de Ausência Reprovada",
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
<p>Sua solicitação de ausência foi <strong>reprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
<ul>
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}</li>
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
<li><strong>Motivo da Reprovação:</strong> ${args.motivoReprovacao}</li>
</ul>`,
enviadoPorId: args.gestorId,
});
// Criar ou obter conversa
const conversasExistentes = await ctx.db
.query("conversas")
.filter((q) => q.eq(q.field("tipo"), "individual"))
.collect();
let conversaId: Id<"conversas"> | null = null;
for (const conversa of conversasExistentes) {
if (
conversa.participantes.length === 2 &&
conversa.participantes.includes(args.gestorId) &&
conversa.participantes.includes(funcionarioUsuario._id)
) {
conversaId = conversa._id;
break;
}
}
if (!conversaId) {
conversaId = await ctx.db.insert("conversas", {
tipo: "individual",
participantes: [args.gestorId, funcionarioUsuario._id],
criadoPor: args.gestorId,
criadoEm: Date.now(),
});
}
// Criar mensagem de chat
await ctx.db.insert("mensagens", {
conversaId,
remetenteId: args.gestorId,
tipo: "texto",
conteudo: `Reprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")} até ${new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}. Motivo: ${args.motivoReprovacao}`,
enviadaEm: Date.now(),
});
}
}
return null;
},
});
// Mutation: Marcar notificação como lida
export const marcarComoLida = mutation({
args: {
notificacaoId: v.id("notificacoesAusencias"),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.notificacaoId, {
lida: true,
});
return null;
},
});

View File

@@ -266,6 +266,42 @@ export default defineSchema({
.index("by_destinatario", ["destinatarioId"])
.index("by_destinatario_and_lida", ["destinatarioId", "lida"]),
// Solicitações de Ausências
solicitacoesAusencias: defineTable({
funcionarioId: v.id("funcionarios"),
dataInicio: v.string(),
dataFim: v.string(),
motivo: v.string(),
status: v.union(
v.literal("aguardando_aprovacao"),
v.literal("aprovado"),
v.literal("reprovado")
),
gestorId: v.optional(v.id("usuarios")),
dataAprovacao: v.optional(v.number()),
dataReprovacao: v.optional(v.number()),
motivoReprovacao: v.optional(v.string()),
observacao: v.optional(v.string()),
criadoEm: v.number(),
})
.index("by_funcionario", ["funcionarioId"])
.index("by_status", ["status"])
.index("by_funcionario_and_status", ["funcionarioId", "status"]),
notificacoesAusencias: defineTable({
destinatarioId: v.id("usuarios"),
solicitacaoAusenciaId: v.id("solicitacoesAusencias"),
tipo: v.union(
v.literal("nova_solicitacao"),
v.literal("aprovado"),
v.literal("reprovado")
),
lida: v.boolean(),
mensagem: v.string(),
})
.index("by_destinatario", ["destinatarioId"])
.index("by_destinatario_and_lida", ["destinatarioId", "lida"]),
// Períodos aquisitivos e saldos de férias
periodosAquisitivos: defineTable({
funcionarioId: v.id("funcionarios"),