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

@@ -1,6 +1,5 @@
import { v } from "convex/values";
import { query, mutation, internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { query } from "./_generated/server";
import { Id } from "./_generated/dataModel";
import type { QueryCtx } from "./_generated/server";
@@ -53,10 +52,11 @@ const REGIMES_CONFIG = {
estatutario_pe: {
nome: "Servidor Público Estadual de Pernambuco",
maxPeriodos: 2,
minDiasPeriodo: 10,
minDiasPeriodo: 15, // Mínimo 15 dias por período
minDiasPeriodoPrincipal: null, // Não há essa regra
abonoPermitido: false,
maxDiasAbono: 0,
periodosPermitidos: [15, 30], // Apenas 15 ou 30 dias por período
},
estatutario_federal: {
nome: "Servidor Público Federal",
@@ -69,10 +69,11 @@ const REGIMES_CONFIG = {
estatutario_municipal: {
nome: "Servidor Público Municipal",
maxPeriodos: 2,
minDiasPeriodo: 10,
minDiasPeriodo: 15, // Mínimo 15 dias por período
minDiasPeriodoPrincipal: null,
abonoPermitido: false,
maxDiasAbono: 0,
periodosPermitidos: [15, 30], // Apenas 15 ou 30 dias por período
},
};
@@ -98,6 +99,103 @@ async function obterRegimeTrabalho(ctx: QueryCtx, funcionarioId: Id<"funcionario
return funcionario?.regimeTrabalho || "clt"; // Default CLT
}
// Helper: Calcular saldo dinamicamente baseado na tabela ferias
async function calcularSaldo(
ctx: QueryCtx,
funcionarioId: Id<"funcionarios">,
anoReferencia: number,
feriasIdExcluir?: Id<"ferias"> // ID do período a excluir do cálculo (para ajustes)
): Promise<{
diasDireito: number;
diasUsados: number;
diasPendentes: number;
diasDisponiveis: number;
diasAbono: number;
dataInicio: string;
dataFim: string;
status: "ativo" | "vencido" | "concluido";
} | null> {
const funcionario = await ctx.db.get(funcionarioId);
if (!funcionario || !funcionario.admissaoData) return null;
const regime = funcionario.regimeTrabalho || "clt";
const config = REGIMES_CONFIG[regime];
// Calcular anos desde admissão
const dataAdmissao = new Date(funcionario.admissaoData);
const anosDesdeAdmissao = anoReferencia - dataAdmissao.getFullYear();
if (anosDesdeAdmissao < 1) return null; // Ainda não tem direito
const dataInicio = calcularDataFimPeriodo(
funcionario.admissaoData,
anosDesdeAdmissao - 1
);
const dataFim = calcularDataFimPeriodo(
funcionario.admissaoData,
anosDesdeAdmissao
);
// Buscar todos os registros de férias para este funcionário e ano
const todasFerias = await ctx.db
.query("ferias")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", funcionarioId).eq("anoReferencia", anoReferencia)
)
.collect();
// Filtrar períodos a excluir (para ajustes)
const feriasFiltradas = feriasIdExcluir
? todasFerias.filter((f) => f._id !== feriasIdExcluir)
: todasFerias;
// Calcular dias usados (aprovado, data_ajustada_aprovada, EmFérias)
const diasUsados = feriasFiltradas
.filter(
(f) =>
f.status === "aprovado" ||
f.status === "data_ajustada_aprovada" ||
f.status === "EmFérias"
)
.reduce((acc, f) => acc + f.diasFerias, 0);
// Calcular dias pendentes (aguardando_aprovacao)
const diasPendentes = feriasFiltradas
.filter((f) => f.status === "aguardando_aprovacao")
.reduce((acc, f) => acc + f.diasFerias, 0);
// Calcular dias de abono
const diasAbono = feriasFiltradas.reduce((acc, f) => acc + f.diasAbono, 0);
// Calcular dias disponíveis
const diasDireito = 30;
const diasDisponiveis = diasDireito - diasUsados - diasPendentes - diasAbono;
// Determinar status do período
const hoje = new Date();
const dataFimPeriodo = new Date(dataFim);
let status: "ativo" | "vencido" | "concluido";
if (diasDireito - diasUsados - diasAbono <= 0) {
status = "concluido";
} else if (hoje > dataFimPeriodo) {
status = "vencido";
} else {
status = "ativo";
}
return {
diasDireito,
diasUsados,
diasPendentes,
diasDisponiveis,
diasAbono,
dataInicio,
dataFim,
status,
};
}
/**
* Query: Obter saldo de férias de um funcionário para um ano específico
*/
@@ -123,67 +221,17 @@ export const obterSaldo = query({
v.null()
),
handler: async (ctx, args) => {
// Buscar período aquisitivo
const periodo = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", args.funcionarioId).eq("anoReferencia", args.anoReferencia)
)
.first();
if (!periodo) {
// Se não existe, calcular e retornar dados previstos sem mutar o banco
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario || !funcionario.admissaoData) return null;
const regime = funcionario.regimeTrabalho || "clt";
const config = REGIMES_CONFIG[regime];
// Calcular anos desde admissão
const dataAdmissao = new Date(funcionario.admissaoData);
const anosDesdeAdmissao = args.anoReferencia - dataAdmissao.getFullYear();
if (anosDesdeAdmissao < 1) return null; // Ainda não tem direito
const dataInicio = calcularDataFimPeriodo(
funcionario.admissaoData,
anosDesdeAdmissao - 1
);
const dataFim = calcularDataFimPeriodo(
funcionario.admissaoData,
anosDesdeAdmissao
);
return {
anoReferencia: args.anoReferencia,
diasDireito: 30,
diasUsados: 0,
diasPendentes: 0,
diasDisponiveis: 30,
diasAbono: 0,
abonoPermitido: config.abonoPermitido,
status: "ativo" as const,
dataInicio,
dataFim,
regimeTrabalho: config.nome,
};
}
const saldo = await calcularSaldo(ctx, args.funcionarioId, args.anoReferencia);
if (!saldo) return null;
const funcionario = await ctx.db.get(args.funcionarioId);
const regime = funcionario?.regimeTrabalho || "clt";
const config = REGIMES_CONFIG[regime];
return {
anoReferencia: periodo.anoReferencia,
diasDireito: periodo.diasDireito,
diasUsados: periodo.diasUsados,
diasPendentes: periodo.diasPendentes,
diasDisponiveis: periodo.diasDisponiveis,
diasAbono: periodo.diasAbono,
anoReferencia: args.anoReferencia,
...saldo,
abonoPermitido: config.abonoPermitido,
status: periodo.status,
dataInicio: periodo.dataInicio,
dataFim: periodo.dataFim,
regimeTrabalho: config.nome,
};
},
@@ -198,7 +246,6 @@ export const listarSaldos = query({
},
returns: v.array(
v.object({
_id: v.id("periodosAquisitivos"),
anoReferencia: v.number(),
diasDireito: v.number(),
diasUsados: v.number(),
@@ -212,24 +259,36 @@ export const listarSaldos = query({
})
),
handler: async (ctx, args) => {
const periodos = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId))
.collect();
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario || !funcionario.admissaoData) return [];
return periodos.map((p) => ({
_id: p._id,
anoReferencia: p.anoReferencia,
diasDireito: p.diasDireito,
diasUsados: p.diasUsados,
diasPendentes: p.diasPendentes,
diasDisponiveis: p.diasDisponiveis,
diasAbono: p.diasAbono,
abonoPermitido: p.abonoPermitido,
status: p.status,
dataInicio: p.dataInicio,
dataFim: p.dataFim,
}));
const regime = funcionario.regimeTrabalho || "clt";
const config = REGIMES_CONFIG[regime];
const dataAdmissao = new Date(funcionario.admissaoData);
const anoAtual = new Date().getFullYear();
const anosDesdeAdmissao = anoAtual - dataAdmissao.getFullYear();
const saldos = [];
// Calcular saldos para os últimos 3 anos (atual, anterior e anterior ao anterior)
for (let i = 0; i < 3; i++) {
const ano = anoAtual - i;
const anosPeriodo = ano - dataAdmissao.getFullYear();
if (anosPeriodo < 1) continue;
const saldo = await calcularSaldo(ctx, args.funcionarioId, ano);
if (saldo) {
saldos.push({
anoReferencia: ano,
...saldo,
abonoPermitido: config.abonoPermitido,
});
}
}
return saldos;
},
});
@@ -246,6 +305,7 @@ export const validarSolicitacao = query({
dataFim: v.string(),
})
),
feriasIdExcluir: v.optional(v.id("ferias")), // ID do período a excluir do cálculo de saldo (para ajustes)
},
returns: v.object({
valido: v.boolean(),
@@ -287,11 +347,48 @@ export const validarSolicitacao = query({
`Período de ${dias} dias é inválido. Mínimo: ${config.minDiasPeriodo} dias corridos (${config.nome})`
);
}
// Validação específica para regime estatutário PE e Municipal
if ((regime === "estatutario_pe" || regime === "estatutario_municipal") && 'periodosPermitidos' in config) {
if (!config.periodosPermitidos.includes(dias)) {
erros.push(
`Para ${config.nome}, os períodos devem ter exatamente 15 ou 30 dias. Período de ${dias} dias não é permitido.`
);
}
}
}
// Validação específica para regime 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 (períodos fracionados)
if ((regime === "estatutario_pe" || regime === "estatutario_municipal")) {
// Verificar se cada período individual é válido (15 ou 30 dias)
for (const dias of diasPorPeriodo) {
if (dias !== 15 && dias !== 30) {
erros.push(
`Para ${config.nome}, cada período deve ter exatamente 15 ou 30 dias. Período de ${dias} dias não é permitido.`
);
}
}
// Total não pode exceder 30 dias
if (totalDias > 30) {
erros.push(
`Para ${config.nome}, o total de dias não pode exceder 30 dias. Total solicitado: ${totalDias} dias.`
);
}
// Máximo de 2 períodos
if (args.periodos.length > 2) {
erros.push(
`Para ${config.nome}, o máximo de períodos permitidos é 2.`
);
}
}
// Validação 3: CLT requer um período com 14+ dias se dividir
if (regime === "clt" && args.periodos.length > 1 && config.minDiasPeriodoPrincipal) {
const temPeriodo14Dias = diasPorPeriodo.some((d) => d >= config.minDiasPeriodoPrincipal);
const temPeriodo14Dias = diasPorPeriodo.some((d) => d >= config.minDiasPeriodoPrincipal!);
if (!temPeriodo14Dias) {
erros.push(
`Ao dividir férias em CLT, um período deve ter no mínimo ${config.minDiasPeriodoPrincipal} dias corridos`
@@ -299,43 +396,40 @@ export const validarSolicitacao = query({
}
}
// Validação 4: Verificar saldo disponível
const periodo = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", args.funcionarioId).eq("anoReferencia", args.anoReferencia)
)
.first();
// Validação 4: Verificar saldo disponível (calculado dinamicamente)
// Se for um ajuste (feriasIdExcluir fornecido), excluir esse período do cálculo
const saldo = await calcularSaldo(ctx, args.funcionarioId, args.anoReferencia, args.feriasIdExcluir);
if (!periodo) {
if (!saldo) {
erros.push(`Você ainda não tem direito a férias referentes ao ano ${args.anoReferencia}`);
} else {
if (totalDias > periodo.diasDisponiveis) {
// Verificar saldo disponível (já excluindo o período original se for ajuste)
if (totalDias > saldo.diasDisponiveis) {
erros.push(
`Total solicitado (${totalDias} dias) excede saldo disponível (${periodo.diasDisponiveis} dias)`
`Total solicitado (${totalDias} dias) excede saldo disponível (${saldo.diasDisponiveis} dias)`
);
}
// Aviso: Saldo baixo
if (periodo.diasDisponiveis < 15 && periodo.diasDisponiveis > totalDias) {
if (saldo.diasDisponiveis < 15 && saldo.diasDisponiveis > totalDias) {
avisos.push(
`Após essa solicitação, restará ${periodo.diasDisponiveis - totalDias} dias de ${args.anoReferencia}`
`Após essa solicitação, restará ${saldo.diasDisponiveis - totalDias} dias de ${args.anoReferencia}`
);
}
// Aviso: Férias vencendo
const hoje = new Date();
const dataFim = new Date(periodo.dataFim);
const dataFim = new Date(saldo.dataFim);
const diasAteVencer = Math.ceil((dataFim.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24));
if (diasAteVencer < 90 && diasAteVencer > 0) {
avisos.push(
`⚠️ Atenção: Seu período aquisitivo ${periodo.anoReferencia} vence em ${diasAteVencer} dias!`
`⚠️ Atenção: Seu período aquisitivo ${args.anoReferencia} vence em ${diasAteVencer} dias!`
);
}
if (diasAteVencer < 0) {
avisos.push(
`⚠️ URGENTE: Seu período aquisitivo ${periodo.anoReferencia} está VENCIDO há ${Math.abs(diasAteVencer)} dias!`
`⚠️ URGENTE: Seu período aquisitivo ${args.anoReferencia} está VENCIDO há ${Math.abs(diasAteVencer)} dias!`
);
}
}
@@ -388,166 +482,3 @@ export const validarSolicitacao = query({
};
},
});
/**
* Internal Mutation: Atualizar saldo após aprovação de férias
*/
export const atualizarSaldoAposAprovacao = internalMutation({
args: {
solicitacaoId: v.id("solicitacoesFerias"),
},
returns: v.null(),
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) return null;
// Buscar período aquisitivo
const periodo = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", solicitacao.funcionarioId).eq("anoReferencia", solicitacao.anoReferencia)
)
.first();
if (!periodo) return null;
// Calcular total de dias
let totalDias = 0;
for (const p of solicitacao.periodos) {
totalDias += p.diasCorridos;
}
// Atualizar saldo
await ctx.db.patch(periodo._id, {
diasPendentes: periodo.diasPendentes - totalDias,
diasUsados: periodo.diasUsados + totalDias,
diasDisponiveis: periodo.diasDireito - (periodo.diasUsados + totalDias) - periodo.diasAbono,
status: periodo.diasDireito - (periodo.diasUsados + totalDias) <= 0 ? "concluido" : periodo.status,
});
return null;
},
});
/**
* Internal Mutation: Reservar dias (ao criar solicitação)
*/
export const reservarDias = internalMutation({
args: {
funcionarioId: v.id("funcionarios"),
anoReferencia: v.number(),
totalDias: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
const periodo = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", args.funcionarioId).eq("anoReferencia", args.anoReferencia)
)
.first();
if (!periodo) return null;
await ctx.db.patch(periodo._id, {
diasPendentes: periodo.diasPendentes + args.totalDias,
diasDisponiveis: periodo.diasDisponiveis - args.totalDias,
});
return null;
},
});
/**
* Internal Mutation: Liberar dias (ao reprovar solicitação)
*/
export const liberarDias = internalMutation({
args: {
solicitacaoId: v.id("solicitacoesFerias"),
},
returns: v.null(),
handler: async (ctx, args) => {
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) return null;
const periodo = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", solicitacao.funcionarioId).eq("anoReferencia", solicitacao.anoReferencia)
)
.first();
if (!periodo) return null;
let totalDias = 0;
for (const p of solicitacao.periodos) {
totalDias += p.diasCorridos;
}
await ctx.db.patch(periodo._id, {
diasPendentes: periodo.diasPendentes - totalDias,
diasDisponiveis: periodo.diasDisponiveis + totalDias,
});
return null;
},
});
/**
* Internal Mutation: Criar períodos aquisitivos para todos os funcionários
*/
export const criarPeriodosAquisitivos = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const funcionarios = await ctx.db.query("funcionarios").collect();
const anoAtual = new Date().getFullYear();
for (const func of funcionarios) {
if (!func.admissaoData) continue;
const regime = func.regimeTrabalho || "clt";
const config = REGIMES_CONFIG[regime];
const dataAdmissao = new Date(func.admissaoData);
const anosDesdeAdmissao = anoAtual - dataAdmissao.getFullYear();
// Criar períodos para os últimos 2 anos (atual e anterior)
for (let i = 0; i < 2; i++) {
const ano = anoAtual - i;
const anosPeriodo = ano - dataAdmissao.getFullYear();
if (anosPeriodo < 1) continue;
// Verificar se já existe
const periodoExistente = await ctx.db
.query("periodosAquisitivos")
.withIndex("by_funcionario_and_ano", (q) =>
q.eq("funcionarioId", func._id).eq("anoReferencia", ano)
)
.first();
if (periodoExistente) continue;
const dataInicio = calcularDataFimPeriodo(func.admissaoData, anosPeriodo - 1);
const dataFim = calcularDataFimPeriodo(func.admissaoData, anosPeriodo);
await ctx.db.insert("periodosAquisitivos", {
funcionarioId: func._id,
anoReferencia: ano,
dataInicio,
dataFim,
diasDireito: 30,
diasUsados: 0,
diasPendentes: 0,
diasDisponiveis: 30,
abonoPermitido: config.abonoPermitido,
diasAbono: 0,
status: "ativo",
});
}
}
return null;
},
});