- Added functionality to check for date overlaps with existing absence requests in the absence calendar. - Implemented a modal to display error messages when users attempt to create overlapping absence requests. - Updated the calendar component to visually indicate blocked days due to existing approved or pending absence requests. - Improved user feedback by providing alerts for unavailable periods and enhancing the overall user experience in absence management.
483 lines
15 KiB
Svelte
483 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { useConvexClient, useQuery } from "convex-svelte";
|
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
|
import CalendarioAusencias from "./CalendarioAusencias.svelte";
|
|
import ErrorModal from "../ErrorModal.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);
|
|
|
|
// Estados para modal de erro
|
|
let mostrarModalErro = $state(false);
|
|
let mensagemErroModal = $state("");
|
|
let detalhesErroModal = $state("");
|
|
|
|
// Buscar ausências existentes para exibir no calendário
|
|
const ausenciasExistentesQuery = useQuery(api.ausencias.listarMinhasSolicitacoes, {
|
|
funcionarioId,
|
|
});
|
|
|
|
// Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações)
|
|
const ausenciasExistentes = $derived(
|
|
(ausenciasExistentesQuery?.data || [])
|
|
.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao")
|
|
.map((a) => ({
|
|
dataInicio: a.dataInicio,
|
|
dataFim: a.dataFim,
|
|
status: a.status as "aguardando_aprovacao" | "aprovado",
|
|
}))
|
|
);
|
|
|
|
// 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;
|
|
mostrarModalErro = false;
|
|
mensagemErroModal = "";
|
|
|
|
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);
|
|
const mensagemErro = error instanceof Error ? error.message : String(error);
|
|
|
|
// Verificar se é erro de sobreposição de período
|
|
if (
|
|
mensagemErro.includes("Já existe uma solicitação") ||
|
|
mensagemErro.includes("já existe") ||
|
|
mensagemErro.includes("solicitação aprovada ou pendente")
|
|
) {
|
|
mensagemErroModal = "Não é possível criar esta solicitação.";
|
|
detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${new Date(dataInicio).toLocaleDateString("pt-BR")} até ${new Date(dataFim).toLocaleDateString("pt-BR")}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`;
|
|
mostrarModalErro = true;
|
|
} else {
|
|
// Outros erros continuam usando toast
|
|
toast.error(mensagemErro);
|
|
}
|
|
} finally {
|
|
processando = false;
|
|
}
|
|
}
|
|
|
|
function fecharModalErro() {
|
|
mostrarModalErro = false;
|
|
mensagemErroModal = "";
|
|
detalhesErroModal = "";
|
|
}
|
|
|
|
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>
|
|
|
|
{#if ausenciasExistentesQuery === undefined}
|
|
<div class="flex items-center justify-center py-12">
|
|
<span class="loading loading-spinner loading-lg"></span>
|
|
<span class="ml-4 text-base-content/70">Carregando ausências existentes...</span>
|
|
</div>
|
|
{:else}
|
|
<CalendarioAusencias
|
|
dataInicio={dataInicio}
|
|
dataFim={dataFim}
|
|
ausenciasExistentes={ausenciasExistentes}
|
|
onPeriodoSelecionado={handlePeriodoSelecionado}
|
|
/>
|
|
{/if}
|
|
|
|
{#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>
|
|
|
|
<!-- Modal de Erro -->
|
|
<ErrorModal
|
|
open={mostrarModalErro}
|
|
title="Período Indisponível"
|
|
message={mensagemErroModal || "Já existe uma solicitação para este período."}
|
|
details={detalhesErroModal}
|
|
onClose={fecharModalErro}
|
|
/>
|
|
|
|
<style>
|
|
.wizard-ausencia {
|
|
max-width: 1000px;
|
|
margin: 0 auto;
|
|
}
|
|
</style>
|
|
|