refactor: update vacation management structure and enhance status handling

- Renamed and refactored vacation-related types and components for clarity, transitioning from 'SolicitacaoFerias' to 'PeriodoFerias'.
- Improved the handling of vacation statuses, including the addition of 'EmFérias' to the status options.
- Streamlined the vacation request and approval components to better reflect individual vacation periods.
- Enhanced data handling in backend queries and schema to support the new structure and ensure accurate status updates.
- Improved user experience by refining UI elements related to vacation periods and their statuses.
This commit is contained in:
2025-11-13 15:54:59 -03:00
parent 4ae5baffcc
commit c058865817
11 changed files with 1150 additions and 949 deletions

View File

@@ -3,13 +3,14 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
type SolicitacaoFerias = Doc<'solicitacoesFerias'> & {
type PeriodoFerias = Doc<'ferias'> & {
funcionario?: Doc<'funcionarios'> | null;
gestor?: Doc<'usuarios'> | null;
time?: Doc<'times'> | null;
};
interface Props {
solicitacao: SolicitacaoFerias;
solicitacao: PeriodoFerias;
usuarioId: Id<'usuarios'>;
onSucesso?: () => void;
onCancelar?: () => void;
@@ -27,7 +28,8 @@
aguardando_aprovacao: 'badge-warning',
aprovado: 'badge-success',
reprovado: 'badge-error',
data_ajustada_aprovada: 'badge-info'
data_ajustada_aprovada: 'badge-info',
EmFérias: 'badge-info'
};
return badges[status] || 'badge-neutral';
}
@@ -37,7 +39,8 @@
aguardando_aprovacao: 'Aguardando Aprovação',
aprovado: 'Aprovado',
reprovado: 'Reprovado',
data_ajustada_aprovada: 'Data Ajustada e Aprovada'
data_ajustada_aprovada: 'Data Ajustada e Aprovada',
EmFérias: 'Em Férias'
};
return textos[status] || status;
}
@@ -48,7 +51,7 @@
erro = '';
await client.mutation(api.ferias.atualizarStatus, {
solicitacaoId: solicitacao._id,
feriasId: solicitacao._id,
novoStatus: 'aguardando_aprovacao',
usuarioId: usuarioId
});
@@ -82,33 +85,28 @@
</div>
</div>
<!-- Períodos Solicitados -->
<!-- Período Solicitado -->
<div class="mt-4">
<h3 class="mb-3 text-lg font-semibold">Períodos Solicitados</h3>
<div class="space-y-2">
{#each solicitacao.periodos as periodo, index (index)}
<div class="bg-base-200 flex items-center gap-4 rounded-lg p-3">
<div class="badge badge-primary">{index + 1}</div>
<div class="grid flex-1 grid-cols-3 gap-2 text-sm">
<div>
<span class="text-base-content/70">Início:</span>
<span class="ml-1 font-semibold"
>{new Date(periodo.dataInicio).toLocaleDateString('pt-BR')}</span
>
</div>
<div>
<span class="text-base-content/70">Fim:</span>
<span class="ml-1 font-semibold"
>{new Date(periodo.dataFim).toLocaleDateString('pt-BR')}</span
>
</div>
<div>
<span class="text-base-content/70">Dias:</span>
<span class="text-primary ml-1 font-bold">{periodo.diasCorridos}</span>
</div>
</div>
<h3 class="mb-3 text-lg font-semibold">Período Solicitado</h3>
<div class="bg-base-200 rounded-lg p-4">
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<span class="text-base-content/70">Início:</span>
<span class="ml-1 font-semibold"
>{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}</span
>
</div>
{/each}
<div>
<span class="text-base-content/70">Fim:</span>
<span class="ml-1 font-semibold"
>{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}</span
>
</div>
<div>
<span class="text-base-content/70">Dias:</span>
<span class="text-primary ml-1 font-bold">{solicitacao.diasFerias}</span>
</div>
</div>
</div>
</div>

View File

@@ -3,59 +3,55 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
interface Periodo {
dataInicio: string;
dataFim: string;
diasCorridos: number;
}
type SolicitacaoFerias = Doc<'solicitacoesFerias'> & {
type PeriodoFerias = Doc<'ferias'> & {
funcionario?: Doc<'funcionarios'> | null;
gestor?: Doc<'usuarios'> | null;
time?: Doc<'times'> | null;
};
interface Props {
solicitacao: SolicitacaoFerias;
periodo: PeriodoFerias;
gestorId: Id<'usuarios'>;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
let { periodo, gestorId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient();
let modoAjuste = $state(false);
let periodos = $state<Periodo[]>([]);
let novaDataInicio = $state(periodo.dataInicio);
let novaDataFim = $state(periodo.dataFim);
let motivoReprovacao = $state('');
let processando = $state(false);
let erro = $state('');
$effect(() => {
if (modoAjuste && periodos.length === 0) {
periodos = solicitacao.periodos.map((p) => ({ ...p }));
}
// Calcular dias do período ajustado
const diasAjustados = $derived.by(() => {
if (!novaDataInicio || !novaDataFim) return 0;
const inicio = new Date(novaDataInicio);
const fim = new Date(novaDataFim);
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return diffDays;
});
function calcularDias(periodo: Periodo) {
if (!periodo.dataInicio || !periodo.dataFim) {
periodo.diasCorridos = 0;
return;
}
function calcularDias(dataInicio: string, dataFim: string): number {
if (!dataInicio || !dataFim) return 0;
const inicio = new Date(periodo.dataInicio);
const fim = new Date(periodo.dataFim);
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
if (fim < inicio) {
erro = 'Data final não pode ser anterior à data inicial';
periodo.diasCorridos = 0;
return;
return 0;
}
const diff = fim.getTime() - inicio.getTime();
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
periodo.diasCorridos = dias;
erro = '';
return dias;
}
async function aprovar() {
@@ -64,19 +60,19 @@
erro = '';
// Validar se as datas e condições estão dentro do regime do funcionário
if (!solicitacao.funcionario?._id) {
if (!periodo.funcionario?._id) {
erro = 'Funcionário não encontrado';
processando = false;
return;
}
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
funcionarioId: solicitacao.funcionario._id,
anoReferencia: solicitacao.anoReferencia,
periodos: solicitacao.periodos.map((p) => ({
dataInicio: p.dataInicio,
dataFim: p.dataFim
}))
funcionarioId: periodo.funcionario._id,
anoReferencia: periodo.anoReferencia,
periodos: [{
dataInicio: periodo.dataInicio,
dataFim: periodo.dataFim
}]
});
if (!validacao.valido) {
@@ -86,7 +82,7 @@
}
await client.mutation(api.ferias.aprovar, {
solicitacaoId: solicitacao._id,
feriasId: periodo._id,
gestorId: gestorId
});
@@ -109,7 +105,7 @@
erro = '';
await client.mutation(api.ferias.reprovar, {
solicitacaoId: solicitacao._id,
feriasId: periodo._id,
gestorId: gestorId,
motivoReprovacao
});
@@ -128,28 +124,27 @@
erro = '';
// Validar se as datas ajustadas e condições estão dentro do regime do funcionário
if (!solicitacao.funcionario?._id) {
if (!periodo.funcionario?._id) {
erro = 'Funcionário não encontrado';
processando = false;
return;
}
// Validar todos os períodos ajustados
for (const periodo of periodos) {
if (!periodo.dataInicio || !periodo.dataFim) {
erro = 'Todos os períodos devem ter data de início e fim';
processando = false;
return;
}
// Validar datas ajustadas
if (!novaDataInicio || !novaDataFim) {
erro = 'Informe as novas datas de início e fim';
processando = false;
return;
}
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
funcionarioId: solicitacao.funcionario._id,
anoReferencia: solicitacao.anoReferencia,
periodos: periodos.map((p) => ({
dataInicio: p.dataInicio,
dataFim: p.dataFim
}))
funcionarioId: periodo.funcionario._id,
anoReferencia: periodo.anoReferencia,
periodos: [{
dataInicio: novaDataInicio,
dataFim: novaDataFim
}],
feriasIdExcluir: periodo._id // Excluir o período original do cálculo de saldo
});
if (!validacao.valido) {
@@ -159,9 +154,10 @@
}
await client.mutation(api.ferias.ajustarEAprovar, {
solicitacaoId: solicitacao._id,
feriasId: periodo._id,
gestorId: gestorId,
novosPeriodos: periodos
novaDataInicio,
novaDataFim
});
if (onSucesso) onSucesso();
@@ -177,7 +173,8 @@
aguardando_aprovacao: 'badge-warning',
aprovado: 'badge-success',
reprovado: 'badge-error',
data_ajustada_aprovada: 'badge-info'
data_ajustada_aprovada: 'badge-info',
EmFérias: 'badge-info'
};
return badges[status] || 'badge-neutral';
}
@@ -187,7 +184,8 @@
aguardando_aprovacao: 'Aguardando Aprovação',
aprovado: 'Aprovado',
reprovado: 'Reprovado',
data_ajustada_aprovada: 'Data Ajustada e Aprovada'
data_ajustada_aprovada: 'Data Ajustada e Aprovada',
EmFérias: 'Em Férias'
};
return textos[status] || status;
}
@@ -195,6 +193,23 @@
function formatarData(data: number) {
return new Date(data).toLocaleString('pt-BR');
}
// Função para formatar data sem problemas de timezone
function formatarDataString(dataString: string): string {
if (!dataString) return '';
// Dividir a string da data (formato YYYY-MM-DD)
const partes = dataString.split('-');
if (partes.length !== 3) return dataString;
// Retornar no formato DD/MM/YYYY
return `${partes[2]}/${partes[1]}/${partes[0]}`;
}
$effect(() => {
if (modoAjuste) {
novaDataInicio = periodo.dataInicio;
novaDataFim = periodo.dataFim;
}
});
</script>
<div class="card bg-base-100 shadow-xl">
@@ -202,63 +217,58 @@
<div class="mb-4 flex items-start justify-between">
<div>
<h2 class="card-title text-2xl">
{solicitacao.funcionario?.nome || 'Funcionário'}
{periodo.funcionario?.nome || 'Funcionário'}
</h2>
<p class="text-base-content/70 mt-1 text-sm">
Ano de Referência: {solicitacao.anoReferencia}
Ano de Referência: {periodo.anoReferencia}
</p>
</div>
<div class={`badge ${getStatusBadge(solicitacao.status)} badge-lg`}>
{getStatusTexto(solicitacao.status)}
<div class={`badge ${getStatusBadge(periodo.status)} badge-lg`}>
{getStatusTexto(periodo.status)}
</div>
</div>
<!-- Períodos Solicitados -->
<!-- Período Solicitado -->
<div class="mt-4">
<h3 class="mb-3 text-lg font-semibold">Períodos Solicitados</h3>
<div class="space-y-2">
{#each solicitacao.periodos as periodo, index}
<div class="bg-base-200 flex items-center gap-4 rounded-lg p-3">
<div class="badge badge-primary">{index + 1}</div>
<div class="grid flex-1 grid-cols-3 gap-2 text-sm">
<div>
<span class="text-base-content/70">Início:</span>
<span class="ml-1 font-semibold"
>{new Date(periodo.dataInicio).toLocaleDateString('pt-BR')}</span
>
</div>
<div>
<span class="text-base-content/70">Fim:</span>
<span class="ml-1 font-semibold"
>{new Date(periodo.dataFim).toLocaleDateString('pt-BR')}</span
>
</div>
<div>
<span class="text-base-content/70">Dias:</span>
<span class="text-primary ml-1 font-bold">{periodo.diasCorridos}</span>
</div>
</div>
<h3 class="mb-3 text-lg font-semibold">Período Solicitado</h3>
<div class="bg-base-200 rounded-lg p-4">
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<span class="text-base-content/70">Início:</span>
<span class="ml-1 font-semibold"
>{formatarDataString(periodo.dataInicio)}</span
>
</div>
{/each}
<div>
<span class="text-base-content/70">Fim:</span>
<span class="ml-1 font-semibold"
>{formatarDataString(periodo.dataFim)}</span
>
</div>
<div>
<span class="text-base-content/70">Dias:</span>
<span class="text-primary ml-1 font-bold">{periodo.diasFerias}</span>
</div>
</div>
</div>
</div>
<!-- Observações -->
{#if solicitacao.observacao}
{#if periodo.observacao}
<div class="mt-4">
<h3 class="mb-2 font-semibold">Observações</h3>
<div class="bg-base-200 rounded-lg p-3 text-sm">
{solicitacao.observacao}
{periodo.observacao}
</div>
</div>
{/if}
<!-- Histórico -->
{#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0}
{#if periodo.historicoAlteracoes && periodo.historicoAlteracoes.length > 0}
<div class="mt-4">
<h3 class="mb-2 font-semibold">Histórico</h3>
<div class="space-y-1">
{#each solicitacao.historicoAlteracoes as hist}
{#each periodo.historicoAlteracoes as hist}
<div class="text-base-content/70 flex items-center gap-2 text-xs">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -284,7 +294,7 @@
{/if}
<!-- Ações (apenas para status aguardando_aprovacao) -->
{#if solicitacao.status === 'aguardando_aprovacao'}
{#if periodo.status === 'aguardando_aprovacao'}
<div class="divider mt-6"></div>
{#if !modoAjuste}
@@ -341,7 +351,7 @@
<!-- Reprovar -->
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="mb-2 text-sm font-semibold">Reprovar Solicitação</h4>
<h4 class="mb-2 text-sm font-semibold">Reprovar Período</h4>
<textarea
class="textarea textarea-bordered textarea-sm mb-2"
placeholder="Motivo da reprovação..."
@@ -376,53 +386,49 @@
{:else}
<!-- Modo Ajuste -->
<div class="space-y-4">
<h4 class="font-semibold">Ajustar Períodos</h4>
{#each periodos as periodo, index}
<div class="card bg-base-200">
<div class="card-body p-4">
<h5 class="mb-2 font-medium">Período {index + 1}</h5>
<div class="grid grid-cols-3 gap-3">
<div class="form-control">
<label class="label" for={`ajuste-inicio-${index}`}>
<span class="label-text text-xs">Início</span>
</label>
<input
id={`ajuste-inicio-${index}`}
type="date"
class="input input-bordered input-sm"
bind:value={periodo.dataInicio}
onchange={() => calcularDias(periodo)}
/>
</div>
<div class="form-control">
<label class="label" for={`ajuste-fim-${index}`}>
<span class="label-text text-xs">Fim</span>
</label>
<input
id={`ajuste-fim-${index}`}
type="date"
class="input input-bordered input-sm"
bind:value={periodo.dataFim}
onchange={() => calcularDias(periodo)}
/>
</div>
<div class="form-control">
<label class="label" for={`ajuste-dias-${index}`}>
<span class="label-text text-xs">Dias</span>
</label>
<div
id={`ajuste-dias-${index}`}
class="bg-base-300 flex h-9 items-center rounded-lg px-3"
role="textbox"
aria-readonly="true"
>
<span class="font-bold">{periodo.diasCorridos}</span>
</div>
<h4 class="font-semibold">Ajustar Período</h4>
<div class="card bg-base-200">
<div class="card-body p-4">
<div class="grid grid-cols-3 gap-3">
<div class="form-control">
<label class="label" for="ajuste-inicio">
<span class="label-text text-xs">Início</span>
</label>
<input
id="ajuste-inicio"
type="date"
class="input input-bordered input-sm"
bind:value={novaDataInicio}
/>
</div>
<div class="form-control">
<label class="label" for="ajuste-fim">
<span class="label-text text-xs">Fim</span>
</label>
<input
id="ajuste-fim"
type="date"
class="input input-bordered input-sm"
bind:value={novaDataFim}
/>
</div>
<div class="form-control">
<label class="label" for="ajuste-dias">
<span class="label-text text-xs">Dias</span>
</label>
<div
id="ajuste-dias"
class="bg-base-300 flex h-9 items-center rounded-lg px-3"
role="textbox"
aria-readonly="true"
>
<span class="font-bold">{diasAjustados}</span>
<span class="ml-2 text-xs opacity-70">dias</span>
</div>
</div>
</div>
</div>
{/each}
</div>
<div class="flex gap-2">
<button
@@ -437,7 +443,7 @@
type="button"
class="btn btn-primary btn-sm gap-2"
onclick={ajustarEAprovar}
disabled={processando}
disabled={processando || !novaDataInicio || !novaDataFim || diasAjustados <= 0}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -461,7 +467,7 @@
{/if}
<!-- Motivo Reprovação (se reprovado) -->
{#if solicitacao.status === 'reprovado' && solicitacao.motivoReprovacao}
{#if periodo.status === 'reprovado' && periodo.motivoReprovacao}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -478,7 +484,7 @@
</svg>
<div>
<div class="font-bold">Motivo da Reprovação:</div>
<div class="text-sm">{solicitacao.motivoReprovacao}</div>
<div class="text-sm">{periodo.motivoReprovacao}</div>
</div>
</div>
{/if}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import CalendarioFerias from './CalendarioFerias.svelte';
import { toast } from 'svelte-sonner';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
@@ -30,7 +29,15 @@
let observacao = $state('');
let processando = $state(false);
// Estados para os selects de data
let dataInicioPeriodo = $state('');
let dataFimPeriodo = $state('');
// Queries
const funcionarioQuery = useQuery(api.funcionarios.getById, { id: funcionarioId });
const funcionario = $derived(funcionarioQuery?.data);
const regimeTrabalho = $derived(funcionario?.regimeTrabalho || 'clt');
const saldoQuery = $derived(
useQuery(api.saldoFerias.obterSaldo, {
funcionarioId,
@@ -62,9 +69,98 @@
return [anoAtual - 1, anoAtual, anoAtual + 1];
});
// Configurações do calendário (baseado no saldo/regime)
const maxPeriodos = $derived(saldo?.regimeTrabalho?.includes('Servidor') ? 2 : 3);
const minDiasPorPeriodo = $derived(saldo?.regimeTrabalho?.includes('Servidor') ? 10 : 5);
// Verificar se é regime estatutário PE ou Municipal
const ehEstatutarioPEOuMunicipal = $derived(
regimeTrabalho === 'estatutario_pe' || regimeTrabalho === 'estatutario_municipal'
);
// Função para calcular dias entre duas datas
function calcularDias(dataInicio: string, dataFim: string): number {
if (!dataInicio || !dataFim) return 0;
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return diffDays;
}
// Função para formatar data sem problemas de timezone
function formatarDataString(dataString: string): string {
if (!dataString) return '';
// Dividir a string da data (formato YYYY-MM-DD)
const partes = dataString.split('-');
if (partes.length !== 3) return dataString;
// Retornar no formato DD/MM/YYYY
return `${partes[2]}/${partes[1]}/${partes[0]}`;
}
// Função para adicionar período
function adicionarPeriodo() {
if (!dataInicioPeriodo || !dataFimPeriodo) {
toast.error('Selecione as datas de início e fim');
return;
}
const dias = calcularDias(dataInicioPeriodo, dataFimPeriodo);
if (dias <= 0) {
toast.error('Data de fim deve ser posterior à data de início');
return;
}
// Validações específicas para estatutário PE e Municipal
// Permite períodos fracionados: cada período deve ser 15 ou 30 dias
// Total não pode exceder 30 dias, mas pode ser menos
if (ehEstatutarioPEOuMunicipal) {
// Verificar se o período individual é válido (15 ou 30 dias)
if (dias !== 15 && dias !== 30) {
toast.error('Para seu regime, cada período deve ter exatamente 15 ou 30 dias');
return;
}
// Verificar se já tem 2 períodos
if (periodosFerias.length >= 2) {
toast.error('Máximo de 2 períodos permitidos para seu regime');
return;
}
// Verificar se o total não excede 30 dias
const novoTotal = totalDiasSelecionados + dias;
if (novoTotal > 30) {
toast.error(`O total não pode exceder 30 dias. Você já tem ${totalDiasSelecionados} dias, adicionando ${dias} dias totalizaria ${novoTotal} dias.`);
return;
}
}
// Verificar se o total não excede o saldo disponível
const novoTotal = totalDiasSelecionados + dias;
if (saldo && novoTotal > saldo.diasDisponiveis) {
toast.error(`Total de dias (${novoTotal}) excede saldo disponível (${saldo.diasDisponiveis})`);
return;
}
periodosFerias = [
...periodosFerias,
{
dataInicio: dataInicioPeriodo,
dataFim: dataFimPeriodo,
dias
}
];
toast.success(`Período de ${dias} dias adicionado! ✅`);
// Limpar campos
dataInicioPeriodo = '';
dataFimPeriodo = '';
}
// Função para remover período
function removerPeriodo(index: number) {
const removido = periodosFerias[index];
periodosFerias = periodosFerias.filter((_, i) => i !== index);
toast.info(`Período de ${removido.dias} dias removido`);
}
// Funções
function proximoPasso() {
@@ -74,7 +170,7 @@
}
if (passoAtual === 2 && periodosFerias.length === 0) {
toast.error('Selecione pelo menos 1 período de férias');
toast.error('Adicione pelo menos 1 período de férias');
return;
}
@@ -124,16 +220,8 @@
}
}
function handlePeriodoAdicionado(periodo: { dataInicio: string; dataFim: string; dias: number }) {
periodosFerias = [...periodosFerias, periodo];
toast.success(`Período de ${periodo.dias} dias adicionado! ✅`);
}
function handlePeriodoRemovido(index: number) {
const removido = periodosFerias[index];
periodosFerias = periodosFerias.filter((_, i) => i !== index);
toast.info(`Período de ${removido.dias} dias removido`);
}
// Calcular dias do período atual
const diasPeriodoAtual = $derived(calcularDias(dataInicioPeriodo, dataFimPeriodo));
</script>
<div class="wizard-ferias-container">
@@ -216,8 +304,12 @@
<button
type="button"
class="btn btn-lg transition-all duration-300 hover:scale-105"
class:btn-primary={anoSelecionado === ano}
class:btn-outline={anoSelecionado !== ano}
style:border-color={anoSelecionado === ano ? '#f97316' : undefined}
style:border-width={anoSelecionado === ano ? '2px' : undefined}
style:color={anoSelecionado === ano ? '#000000' : undefined}
style:background-color={anoSelecionado === ano ? 'transparent' : undefined}
style:box-shadow={anoSelecionado === ano ? '0 0 10px rgba(249, 115, 22, 0.3)' : undefined}
onclick={() => (anoSelecionado = ano)}
>
{ano}
@@ -322,9 +414,14 @@
<div>
<h4 class="font-bold">{saldo.regimeTrabalho}</h4>
<p class="text-sm">
Período aquisitivo: {new Date(saldo.dataInicio).toLocaleDateString('pt-BR')}
a {new Date(saldo.dataFim).toLocaleDateString('pt-BR')}
Período aquisitivo: {formatarDataString(saldo.dataInicio)}
a {formatarDataString(saldo.dataFim)}
</p>
{#if ehEstatutarioPEOuMunicipal}
<p class="mt-2 text-sm font-semibold">
⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode exceder 30 dias.
</p>
{/if}
</div>
</div>
@@ -401,18 +498,131 @@
{totalDiasSelecionados} dias | <strong>Restante:</strong>
{(saldo?.diasDisponiveis || 0) - totalDiasSelecionados} dias
</p>
{#if ehEstatutarioPEOuMunicipal}
<p class="mt-2 text-sm font-semibold">
⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode exceder 30 dias.
</p>
{/if}
</div>
</div>
<!-- Calendário -->
<CalendarioFerias
periodosExistentes={periodosFerias}
onPeriodoAdicionado={handlePeriodoAdicionado}
onPeriodoRemovido={handlePeriodoRemovido}
{maxPeriodos}
{minDiasPorPeriodo}
modoVisualizacao="month"
></CalendarioFerias>
<!-- Formulário para adicionar período -->
<div class="card bg-base-100 shadow-lg mb-6">
<div class="card-body">
<h3 class="card-title mb-4">Adicionar Período</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Data Início</span>
</label>
<input
type="date"
class="input input-bordered"
bind:value={dataInicioPeriodo}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Data Fim</span>
</label>
<input
type="date"
class="input input-bordered"
bind:value={dataFimPeriodo}
min={dataInicioPeriodo || undefined}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Dias</span>
</label>
<div class="input input-bordered flex items-center">
<span class="font-bold text-primary">{diasPeriodoAtual}</span>
<span class="ml-2 text-sm opacity-70">dias</span>
</div>
</div>
</div>
<div class="card-actions mt-4">
<button
type="button"
class="btn btn-primary gap-2"
onclick={adicionarPeriodo}
disabled={!dataInicioPeriodo || !dataFimPeriodo || diasPeriodoAtual <= 0}
>
<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 4v16m8-8H4"
/>
</svg>
Adicionar Período
</button>
</div>
</div>
</div>
<!-- Lista de períodos adicionados -->
{#if periodosFerias.length > 0}
<div class="card bg-base-100 shadow-lg mb-6">
<div class="card-body">
<h3 class="card-title mb-4">Períodos Adicionados ({periodosFerias.length})</h3>
<div class="space-y-3">
{#each periodosFerias as periodo, index (index)}
<div class="bg-base-200 flex items-center gap-4 rounded-lg p-4">
<div
class="badge badge-lg badge-primary flex h-12 w-12 items-center justify-center font-bold text-white"
>
{index + 1}
</div>
<div class="flex-1">
<p class="font-semibold">
{formatarDataString(periodo.dataInicio)}
até
{formatarDataString(periodo.dataFim)}
</p>
<p class="text-base-content/70 text-sm">
{periodo.dias} dias corridos
</p>
</div>
<button
type="button"
class="btn btn-error btn-sm gap-2"
onclick={() => removerPeriodo(index)}
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
Remover
</button>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Validações -->
{#if validacao && periodosFerias.length > 0}
@@ -529,17 +739,9 @@
</div>
<div class="flex-1">
<p class="font-semibold">
{new Date(periodo.dataInicio).toLocaleDateString('pt-BR', {
day: '2-digit',
month: 'long',
year: 'numeric'
})}
{formatarDataString(periodo.dataInicio)}
até
{new Date(periodo.dataFim).toLocaleDateString('pt-BR', {
day: '2-digit',
month: 'long',
year: 'numeric'
})}
{formatarDataString(periodo.dataFim)}
</p>
<p class="text-base-content/70 text-sm">
{periodo.dias} dias corridos

View File

@@ -18,9 +18,7 @@
'meu-perfil' | 'minhas-ferias' | 'minhas-ausencias' | 'aprovar-ferias' | 'aprovar-ausencias'
>('meu-perfil');
let solicitacaoSelecionada = $state<FunctionReturnType<typeof api.ferias.obterDetalhes> | null>(
null
);
let periodoSelecionado = $state<Id<'ferias'> | null>(null);
let mostrarModalFoto = $state(false);
let uploadandoFoto = $state(false);
@@ -192,12 +190,12 @@
}))
);
// Estatísticas das minhas férias
// Estatísticas das minhas férias (períodos individuais)
const statsMinhasFerias = $derived({
total: minhasSolicitacoes.length,
aguardando: minhasSolicitacoes.filter((s) => s.status === 'aguardando_aprovacao').length,
aprovadas: minhasSolicitacoes.filter(
(s) => s.status === 'aprovado' || s.status === 'data_ajustada_aprovada'
(s) => s.status === 'aprovado' || s.status === 'data_ajustada_aprovada' || s.status === 'EmFérias'
).length,
reprovadas: minhasSolicitacoes.filter((s) => s.status === 'reprovado').length,
emFerias: funcionario?.statusFerias === 'em_ferias' ? 1 : 0
@@ -212,14 +210,21 @@
});
async function recarregar() {
solicitacaoSelecionada = null;
periodoSelecionado = null;
}
async function selecionarSolicitacao(solicitacaoId: string) {
const detalhes = await client.query(api.ferias.obterDetalhes, {
solicitacaoId: solicitacaoId as Id<'solicitacoesFerias'>
});
solicitacaoSelecionada = detalhes;
async function selecionarPeriodo(feriasId: Id<'ferias'>) {
periodoSelecionado = feriasId;
}
// Função para formatar data sem problemas de timezone
function formatarDataString(dataString: string): string {
if (!dataString) return '';
// Dividir a string da data (formato YYYY-MM-DD)
const partes = dataString.split('-');
if (partes.length !== 3) return dataString;
// Retornar no formato DD/MM/YYYY
return `${partes[2]}/${partes[1]}/${partes[0]}`;
}
function getStatusBadge(status: string) {
@@ -227,7 +232,8 @@
aguardando_aprovacao: 'badge-warning',
aprovado: 'badge-success',
reprovado: 'badge-error',
data_ajustada_aprovada: 'badge-info'
data_ajustada_aprovada: 'badge-info',
EmFérias: 'badge-info'
};
return badges[status] || 'badge-neutral';
}
@@ -237,7 +243,8 @@
aguardando_aprovacao: 'Aguardando',
aprovado: 'Aprovado',
reprovado: 'Reprovado',
data_ajustada_aprovada: 'Ajustado'
data_ajustada_aprovada: 'Ajustado',
EmFérias: 'Em Férias'
};
return textos[status] || status;
}
@@ -1335,6 +1342,7 @@
<option value="aprovado">Aprovado</option>
<option value="reprovado">Reprovado</option>
<option value="data_ajustada_aprovada">Data Ajustada</option>
<option value="EmFérias">Em Férias</option>
</select>
</div>
</div>
@@ -1371,27 +1379,27 @@
<thead>
<tr>
<th>Ano</th>
<th>Períodos</th>
<th>Total Dias</th>
<th>Período</th>
<th>Dias</th>
<th>Status</th>
<th>Solicitado em</th>
</tr>
</thead>
<tbody>
{#each solicitacoesFiltradas as solicitacao (solicitacao._id)}
{#each solicitacoesFiltradas as periodo (periodo._id)}
<tr>
<td>{solicitacao.anoReferencia}</td>
<td>{solicitacao.periodos.length} período(s)</td>
<td class="font-bold"
>{solicitacao.periodos.reduce((acc, p) => acc + p.diasCorridos, 0)} dias</td
>
<td>{periodo.anoReferencia}</td>
<td>
<div class={`badge ${getStatusBadge(solicitacao.status)}`}>
{getStatusTexto(solicitacao.status)}
{formatarDataString(periodo.dataInicio)} - {formatarDataString(periodo.dataFim)}
</td>
<td class="font-bold">{periodo.diasFerias} dias</td>
<td>
<div class={`badge ${getStatusBadge(periodo.status)}`}>
{getStatusTexto(periodo.status)}
</div>
</td>
<td class="text-xs"
>{new Date(solicitacao._creationTime).toLocaleDateString('pt-BR')}</td
>{new Date(periodo._creationTime).toLocaleDateString('pt-BR')}</td
>
</tr>
{/each}
@@ -1741,50 +1749,50 @@
<th class="font-bold">Funcionário</th>
<th class="font-bold">Time</th>
<th class="font-bold">Ano</th>
<th class="font-bold">Períodos</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 solicitacoesSubordinados as solicitacao (solicitacao._id)}
{#each solicitacoesSubordinados as periodo (periodo._id)}
<tr class="hover:bg-base-200 transition-colors">
<td>
<div class="font-bold">
{solicitacao.funcionario?.nome}
{periodo.funcionario?.nome}
</div>
</td>
<td>
{#if solicitacao.time}
{#if periodo.time}
<div
class="badge badge-lg font-semibold"
style="background-color: {solicitacao.time
.cor}20; border-color: {solicitacao.time.cor}; color: {solicitacao
.time.cor}"
style="background-color: {periodo.time
.cor}20; border-color: {periodo.time.cor}; color: {periodo.time
.cor}"
>
{solicitacao.time.nome}
{periodo.time.nome}
</div>
{/if}
</td>
<td class="font-semibold">{solicitacao.anoReferencia}</td>
<td class="font-semibold">{solicitacao.periodos.length}</td>
<td class="text-lg font-bold"
>{solicitacao.periodos.reduce((acc, p) => acc + p.diasCorridos, 0)}</td
>
<td class="font-semibold">{periodo.anoReferencia}</td>
<td class="font-semibold">
{formatarDataString(periodo.dataInicio)} - {formatarDataString(periodo.dataFim)}
</td>
<td class="text-lg font-bold">{periodo.diasFerias}</td>
<td>
<div
class={`badge badge-lg font-semibold ${getStatusBadge(solicitacao.status)}`}
class={`badge badge-lg font-semibold ${getStatusBadge(periodo.status)}`}
>
{getStatusTexto(solicitacao.status)}
{getStatusTexto(periodo.status)}
</div>
</td>
<td>
{#if solicitacao.status === 'aguardando_aprovacao'}
{#if periodo.status === 'aguardando_aprovacao'}
<button
type="button"
class="btn btn-primary btn-sm gap-2 shadow-lg transition-transform hover:scale-105"
onclick={() => selecionarSolicitacao(solicitacao._id)}
onclick={() => selecionarPeriodo(periodo._id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -1806,7 +1814,7 @@
<button
type="button"
class="btn btn-sm gap-2"
onclick={() => selecionarSolicitacao(solicitacao._id)}
onclick={() => selecionarPeriodo(periodo._id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -1997,26 +2005,28 @@
</div>
<!-- Modal de Aprovação de Férias -->
{#if solicitacaoSelecionada}
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl">
{#if currentUser.data}
<AprovarFerias
solicitacao={solicitacaoSelecionada}
gestorId={currentUser.data._id}
onSucesso={recarregar}
onCancelar={() => (solicitacaoSelecionada = null)}
/>
{/if}
</div>
<form method="dialog" class="modal-backdrop">
<button
type="button"
onclick={() => (solicitacaoSelecionada = null)}
aria-label="Fechar modal">Fechar</button
>
</form>
</dialog>
{#if periodoSelecionado && currentUser.data}
{#await client.query(api.ferias.obterDetalhes, { feriasId: periodoSelecionado }) then detalhes}
{#if detalhes}
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl">
<AprovarFerias
periodo={detalhes}
gestorId={currentUser.data._id}
onSucesso={recarregar}
onCancelar={() => (periodoSelecionado = null)}
/>
</div>
<form method="dialog" class="modal-backdrop">
<button
type="button"
onclick={() => (periodoSelecionado = null)}
aria-label="Fechar modal">Fechar</button
>
</form>
</dialog>
{/if}
{/await}
{/if}
<!-- Modal de Upload de Foto / Escolher Avatar -->

View File

@@ -15,13 +15,13 @@
type TodasSolicitacoes = FunctionReturnType<typeof api.ferias.listarTodas>;
type Solicitacao = TodasSolicitacoes[number];
type PeriodoDetalhado = {
solicitacaoId: Id<'solicitacoesFerias'>;
funcionarioId: Id<'funcionarios'> | null;
funcionarioId: Id<'funcionarios'>;
anoReferencia: number;
feriasId: Id<'ferias'>;
funcionarioNome: string;
matricula?: string | null;
timeNome?: string | null;
timeCor?: string | null;
anoReferencia: number;
status: Solicitacao['status'];
dataInicio: string;
dataFim: string;
@@ -39,8 +39,8 @@
const client = useConvexClient();
const currentUser = useQuery(api.auth.getCurrentUser, {});
// Estado para controlar qual solicitação está selecionada para mudança de status
let solicitacaoSelecionada = $state<Id<'solicitacoesFerias'> | null>(null);
// Estado para controlar qual período está selecionado para mudança de status
let periodoSelecionado = $state<Id<'ferias'> | null>(null);
// Estados de loading e error
const isLoading = $derived(todasSolicitacoesQuery?.isLoading ?? true);
@@ -106,10 +106,10 @@
let dataInicioRelatorio = $state<string>('');
let dataFimRelatorio = $state<string>('');
// Filtrar solicitações
// Filtrar períodos individuais
const solicitacoesFiltradas = $derived(
solicitacoes.filter((solicitacao) => {
if (filtroStatus !== 'todos' && solicitacao.status !== filtroStatus) {
solicitacoes.filter((periodo) => {
if (filtroStatus !== 'todos' && periodo.status !== filtroStatus) {
return false;
}
@@ -118,7 +118,7 @@
const emailFiltro = normalizarTexto(filtroEmail.trim());
if (nomeFiltro || matriculaFiltro || emailFiltro) {
const funcionario = solicitacao.funcionario;
const funcionario = periodo.funcionario;
if (!funcionario) return false;
const contato = funcionario as {
@@ -165,27 +165,25 @@
const inicioComparacao = inicioFiltro ?? new SvelteDate(-8640000000000000);
const fimComparacao = fimFiltro ?? new SvelteDate(8640000000000000);
return solicitacao.periodos.some((periodo) => {
const inicioPeriodo = criarDataHora(periodo.dataInicio, 'inicio');
const fimPeriodo = criarDataHora(periodo.dataFim, 'fim');
if (!inicioPeriodo || !fimPeriodo) {
const inicioPeriodo = criarDataHora(periodo.dataInicio, 'inicio');
const fimPeriodo = criarDataHora(periodo.dataFim, 'fim');
if (!inicioPeriodo || !fimPeriodo) {
return false;
}
if (intervaloMes) {
if (fimPeriodo < intervaloMes.inicio || inicioPeriodo > intervaloMes.fim) {
return false;
}
}
if (intervaloMes) {
if (fimPeriodo < intervaloMes.inicio || inicioPeriodo > intervaloMes.fim) {
return false;
}
if (inicioFiltro || fimFiltro) {
if (fimPeriodo < inicioComparacao || inicioPeriodo > fimComparacao) {
return false;
}
}
if (inicioFiltro || fimFiltro) {
if (fimPeriodo < inicioComparacao || inicioPeriodo > fimComparacao) {
return false;
}
}
return true;
});
return true;
})
);
@@ -203,27 +201,25 @@
const solicitacoesAprovadas = $derived(
solicitacoesFiltradas.filter(
(s) => s.status === 'aprovado' || s.status === 'data_ajustada_aprovada'
(p) => p.status === 'aprovado' || p.status === 'data_ajustada_aprovada' || p.status === 'EmFérias'
)
);
const periodosDetalhados = $derived<Array<PeriodoDetalhado>>(
solicitacoesAprovadas
.flatMap((solicitacao) =>
solicitacao.periodos.map((periodo) => ({
solicitacaoId: solicitacao._id,
funcionarioId: solicitacao.funcionarioId ?? null,
funcionarioNome: solicitacao.funcionario?.nome ?? 'Funcionário não encontrado',
matricula: solicitacao.funcionario?.matricula ?? null,
timeNome: solicitacao.time?.nome ?? null,
timeCor: solicitacao.time?.cor ?? null,
anoReferencia: solicitacao.anoReferencia,
status: solicitacao.status,
dataInicio: periodo.dataInicio,
dataFim: periodo.dataFim,
diasCorridos: periodo.diasCorridos
}))
)
.map((periodo) => ({
feriasId: periodo._id,
funcionarioId: periodo.funcionarioId,
anoReferencia: periodo.anoReferencia,
funcionarioNome: periodo.funcionario?.nome ?? 'Funcionário não encontrado',
matricula: periodo.funcionario?.matricula ?? null,
timeNome: periodo.time?.nome ?? null,
timeCor: periodo.time?.cor ?? null,
status: periodo.status,
dataInicio: periodo.dataInicio,
dataFim: periodo.dataFim,
diasCorridos: periodo.diasFerias
}))
.sort(
(a, b) => new SvelteDate(a.dataInicio).getTime() - new SvelteDate(b.dataInicio).getTime()
)
@@ -310,7 +306,7 @@
for (const solicitacao of solicitacoesAprovadas) {
const totalDias = solicitacao.periodos.reduce(
(acc, periodo) => acc + periodo.diasCorridos,
(acc, periodo) => acc + periodo.diasFerias,
0
);
const existente = agregados.get(solicitacao.anoReferencia) ?? {
@@ -441,7 +437,7 @@
periodosDetalhados.map((periodo, indice) => {
const corBase = periodo.timeCor ?? coresCalendario[indice % coresCalendario.length];
return {
id: `${String(periodo.solicitacaoId)}-${indice}`,
id: `${String(periodo.feriasId)}-${indice}`,
title: `${periodo.funcionarioNome} (${periodo.diasCorridos} dia${periodo.diasCorridos === 1 ? '' : 's'})`,
start: periodo.dataInicio,
end: adicionarDias(periodo.dataFim, 1),
@@ -692,6 +688,16 @@
return textos[status] || status;
}
// Função para formatar data sem problemas de timezone
function formatarDataString(dataString: string): string {
if (!dataString) return '';
// Dividir a string da data (formato YYYY-MM-DD)
const partes = dataString.split('-');
if (partes.length !== 3) return dataString;
// Retornar no formato DD/MM/YYYY
return `${partes[2]}/${partes[1]}/${partes[0]}`;
}
function formatarData(data: string | number | Date) {
const instancia = data instanceof Date ? data : new SvelteDate(data);
return instancia.toLocaleDateString('pt-BR');
@@ -771,16 +777,12 @@
filtroPeriodoFim = '';
}
function totalDiasSolicitacao(solicitacao: Solicitacao): number {
return solicitacao.periodos.reduce((acc, periodo) => acc + periodo.diasCorridos, 0);
}
async function selecionarSolicitacao(solicitacaoId: Id<'solicitacoesFerias'>) {
solicitacaoSelecionada = solicitacaoId;
async function selecionarPeriodo(feriasId: Id<'ferias'>) {
periodoSelecionado = feriasId;
}
async function recarregar() {
solicitacaoSelecionada = null;
periodoSelecionado = null;
}
let chartContainer: HTMLDivElement | null = null;
@@ -1678,61 +1680,61 @@
<tr class="text-base-content/70 text-sm tracking-wide uppercase">
<th>Funcionário</th>
<th>Time</th>
<th>Ano</th>
<th>Períodos</th>
<th>Ano</th>
<th>Período</th>
<th>Dias</th>
<th>Status</th>
<th>Solicitado em</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
<tbody>
{#each solicitacoesFiltradas as periodo (periodo._id)}
<tr>
<td>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content w-10 rounded-full">
<span class="text-xs"
<span class="text-xs"
>{periodo.funcionario?.nome.substring(0, 2).toUpperCase()}</span
>
</div>
</div>
<div>
<div>
<div class="font-bold">{periodo.funcionario?.nome}</div>
<div class="text-xs opacity-50">
<div class="text-xs opacity-50">
{periodo.funcionario?.matricula || 'S/N'}
</div>
</div>
</div>
</td>
<td>
<td>
{#if periodo.time}
<div
class="badge badge-outline"
class="badge badge-outline"
style="border-color: {periodo.time.cor}"
>
>
{periodo.time.nome}
</div>
{:else}
<span class="text-base-content/50 text-xs">Sem time</span>
{/if}
</td>
<td>{solicitacao.anoReferencia}</td>
<td>{solicitacao.periodos.length} período(s)</td>
<td class="text-base-content font-bold"
>{totalDiasSolicitacao(solicitacao)} dia(s)</td
</td>
<td>{periodo.anoReferencia}</td>
<td>
<div class={`badge ${getStatusBadge(solicitacao.status)}`}>
<td>
{formatarDataString(periodo.dataInicio)} - {formatarDataString(periodo.dataFim)}
</td>
<td class="text-base-content font-bold">{periodo.diasFerias} dia(s)</td>
<td>
<div class={`badge ${getStatusBadge(periodo.status)}`}>
{getStatusTexto(periodo.status)}
</div>
</td>
</td>
<td class="text-xs">{formatarData(periodo._creationTime)}</td>
<td>
<button
type="button"
class="btn btn-primary btn-sm gap-2"
class="btn btn-primary btn-sm gap-2"
onclick={() => selecionarPeriodo(periodo._id)}
>
<svg
@@ -1917,8 +1919,8 @@
{/if}
</main>
<!-- Modal de Mudança de Status -->
{#if solicitacaoSelecionada && currentUser.data}
<!-- Modal de Mudança de Status -->
{#if periodoSelecionado && currentUser.data}
{#await client.query( api.ferias.obterDetalhes, { feriasId: periodoSelecionado } ) then detalhes}
{#if detalhes}
<dialog class="modal modal-open">
@@ -1926,13 +1928,13 @@
<AlterarStatusFerias
solicitacao={detalhes}
usuarioId={currentUser.data._id}
onSucesso={recarregar}
onSucesso={recarregar}
onCancelar={() => (periodoSelecionado = null)}
/>
</div>
<form method="dialog" class="modal-backdrop">
<button
type="button"
type="button"
onclick={() => (periodoSelecionado = null)}
aria-label="Fechar modal"
>