Files
sgse-app/apps/web/src/lib/components/ausencias/WizardSolicitacaoAusencia.svelte
killer-cf 11eef4aa2a refactor: improve Svelte components and enhance user experience
- Updated various Svelte components to improve code readability and maintainability.
- Standardized button classes across components for a consistent user interface.
- Enhanced error handling and user feedback in modals and forms.
- Cleaned up unnecessary imports and optimized component structure for better performance.
2025-11-12 16:36:29 -03:00

487 lines
13 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">
<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="mb-2 text-2xl font-bold">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="text-base-content/70 ml-4">Carregando ausências existentes...</span>
</div>
{:else}
<CalendarioAusencias
{dataInicio}
{dataFim}
{ausenciasExistentes}
onPeriodoSelecionado={handlePeriodoSelecionado}
/>
{/if}
{#if dataInicio && dataFim}
<div class="alert alert-success shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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="mb-2 text-2xl font-bold">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 border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950"
>
<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="mt-2 grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<p class="text-base-content/70 text-sm">Data Início</p>
<p class="font-bold">
{new Date(dataInicio).toLocaleDateString('pt-BR')}
</p>
</div>
<div>
<p class="text-base-content/70 text-sm">Data Fim</p>
<p class="font-bold">
{new Date(dataFim).toLocaleDateString('pt-BR')}
</p>
</div>
<div>
<p class="text-base-content/70 text-sm">Total de Dias</p>
<p class="text-xl font-bold 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="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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 mt-6 justify-between">
<button
type="button"
class="btn"
onclick={passoAnterior}
disabled={passoAtual === 1 || processando}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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="ml-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 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="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="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-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>