import { v } from "convex/values"; import { query, mutation, internalMutation } from "./_generated/server"; import { internal } from "./_generated/api"; import { Id } from "./_generated/dataModel"; /** * SISTEMA DE CÁLCULO DE SALDO DE FÉRIAS * Suporte a múltiplos regimes de trabalho * * ============================================ * REGRAS CLT (Consolidação das Leis do Trabalho): * ============================================ * - 30 dias de férias por ano trabalhado * - Período aquisitivo: 12 meses de trabalho * - Período concessivo: 12 meses após aquisitivo * - Pode dividir em até 3 períodos * - Um período deve ter no mínimo 14 dias * - Demais períodos: mínimo 5 dias cada * - Abono pecuniário: vender 1/3 das férias (10 dias) - OPCIONAL * * ============================================ * REGRAS SERVIDOR PÚBLICO ESTADUAL DE PERNAMBUCO * Lei nº 6.123/1968 - Estatuto dos Funcionários Públicos Civis do Estado de PE * ============================================ * - 30 dias de férias por ano de exercício * - Pode dividir em até 2 períodos (NÃO 3) * - Nenhum período pode ser inferior a 10 dias (NÃO 5) * - NÃO permite abono pecuniário (venda de férias) * - Férias devem ser gozadas no ano subsequente * - Servidor com mais de 10 anos: pode acumular até 2 períodos * - Preferência: férias no período de 20/12 a 10/01 para docentes * - Gestante: pode antecipar ou prorrogar férias */ type RegimeTrabalho = "clt" | "estatutario_pe" | "estatutario_federal" | "estatutario_municipal"; // Configurações por regime const REGIMES_CONFIG = { clt: { nome: "CLT - Consolidação das Leis do Trabalho", maxPeriodos: 3, minDiasPeriodo: 5, minDiasPeriodoPrincipal: 14, abonoPermitido: true, maxDiasAbono: 10, }, estatutario_pe: { nome: "Servidor Público Estadual de Pernambuco", maxPeriodos: 2, minDiasPeriodo: 10, minDiasPeriodoPrincipal: null, // Não há essa regra abonoPermitido: false, maxDiasAbono: 0, }, estatutario_federal: { nome: "Servidor Público Federal", maxPeriodos: 3, minDiasPeriodo: 5, minDiasPeriodoPrincipal: 14, abonoPermitido: true, maxDiasAbono: 10, }, estatutario_municipal: { nome: "Servidor Público Municipal", maxPeriodos: 3, minDiasPeriodo: 10, minDiasPeriodoPrincipal: null, abonoPermitido: false, maxDiasAbono: 0, }, }; // Helper: Calcular dias entre duas datas function calcularDiasEntreDatas(dataInicio: string, dataFim: string): number { 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; // +1 para incluir ambos os dias return diffDays; } // Helper: Calcular data de fim do período aquisitivo function calcularDataFimPeriodo(dataAdmissao: string, anosPassados: number): string { const dataInicio = new Date(dataAdmissao); dataInicio.setFullYear(dataInicio.getFullYear() + anosPassados); return dataInicio.toISOString().split('T')[0]; } // Helper: Obter regime de trabalho do funcionário async function obterRegimeTrabalho(ctx: any, funcionarioId: Id<"funcionarios">): Promise { const funcionario = await ctx.db.get(funcionarioId); return funcionario?.regimeTrabalho || "clt"; // Default CLT } /** * Query: Obter saldo de férias de um funcionário para um ano específico */ export const obterSaldo = query({ args: { funcionarioId: v.id("funcionarios"), anoReferencia: v.number(), }, returns: v.union( v.object({ anoReferencia: v.number(), diasDireito: v.number(), diasUsados: v.number(), diasPendentes: v.number(), diasDisponiveis: v.number(), diasAbono: v.number(), abonoPermitido: v.boolean(), status: v.union(v.literal("ativo"), v.literal("vencido"), v.literal("concluido")), dataInicio: v.string(), dataFim: v.string(), regimeTrabalho: v.string(), }), 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, criar automaticamente 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); // Criar período aquisitivo await ctx.db.insert("periodosAquisitivos", { funcionarioId: args.funcionarioId, anoReferencia: args.anoReferencia, dataInicio, dataFim, diasDireito: 30, diasUsados: 0, diasPendentes: 0, diasDisponiveis: 30, abonoPermitido: config.abonoPermitido, diasAbono: 0, status: "ativo", }); 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 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, abonoPermitido: config.abonoPermitido, status: periodo.status, dataInicio: periodo.dataInicio, dataFim: periodo.dataFim, regimeTrabalho: config.nome, }; }, }); /** * Query: Listar todos os saldos de um funcionário */ export const listarSaldos = query({ args: { funcionarioId: v.id("funcionarios"), }, returns: v.array( v.object({ _id: v.id("periodosAquisitivos"), anoReferencia: v.number(), diasDireito: v.number(), diasUsados: v.number(), diasPendentes: v.number(), diasDisponiveis: v.number(), diasAbono: v.number(), abonoPermitido: v.boolean(), status: v.union(v.literal("ativo"), v.literal("vencido"), v.literal("concluido")), dataInicio: v.string(), dataFim: v.string(), }) ), handler: async (ctx, args) => { const periodos = await ctx.db .query("periodosAquisitivos") .withIndex("by_funcionario", (q) => q.eq("funcionarioId", args.funcionarioId)) .collect(); 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, })); }, }); /** * Query: Validar solicitação de férias (regras CLT ou Servidor Público PE) */ export const validarSolicitacao = query({ args: { funcionarioId: v.id("funcionarios"), anoReferencia: v.number(), periodos: v.array( v.object({ dataInicio: v.string(), dataFim: v.string(), }) ), }, returns: v.object({ valido: v.boolean(), erros: v.array(v.string()), avisos: v.array(v.string()), totalDias: v.number(), regimeTrabalho: v.string(), }), handler: async (ctx, args) => { const erros: string[] = []; const avisos: string[] = []; let totalDias = 0; // Obter regime de trabalho const regime = await obterRegimeTrabalho(ctx, args.funcionarioId); const config = REGIMES_CONFIG[regime]; // Validação 1: Número de períodos if (args.periodos.length === 0) { erros.push("É necessário adicionar pelo menos 1 período de férias"); } if (args.periodos.length > config.maxPeriodos) { erros.push( `Máximo de ${config.maxPeriodos} períodos permitidos para ${config.nome}` ); } // Calcular dias de cada período e validar const diasPorPeriodo: number[] = []; for (const periodo of args.periodos) { const dias = calcularDiasEntreDatas(periodo.dataInicio, periodo.dataFim); diasPorPeriodo.push(dias); totalDias += dias; // Validação 2: Mínimo de dias por período if (dias < config.minDiasPeriodo) { erros.push( `Período de ${dias} dias é inválido. Mínimo: ${config.minDiasPeriodo} dias corridos (${config.nome})` ); } } // 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); if (!temPeriodo14Dias) { erros.push( `Ao dividir férias em CLT, um período deve ter no mínimo ${config.minDiasPeriodoPrincipal} dias corridos` ); } } // 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(); if (!periodo) { erros.push(`Você ainda não tem direito a férias referentes ao ano ${args.anoReferencia}`); } else { if (totalDias > periodo.diasDisponiveis) { erros.push( `Total solicitado (${totalDias} dias) excede saldo disponível (${periodo.diasDisponiveis} dias)` ); } // Aviso: Saldo baixo if (periodo.diasDisponiveis < 15 && periodo.diasDisponiveis > totalDias) { avisos.push( `Após essa solicitação, restará ${periodo.diasDisponiveis - totalDias} dias de ${args.anoReferencia}` ); } // Aviso: Férias vencendo const hoje = new Date(); const dataFim = new Date(periodo.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!` ); } if (diasAteVencer < 0) { avisos.push( `⚠️ URGENTE: Seu período aquisitivo ${periodo.anoReferencia} está VENCIDO há ${Math.abs(diasAteVencer)} dias!` ); } } // Validação 5: Verificar conflitos de datas (sobreposição) for (let i = 0; i < args.periodos.length; i++) { for (let j = i + 1; j < args.periodos.length; j++) { const inicio1 = new Date(args.periodos[i].dataInicio); const fim1 = new Date(args.periodos[i].dataFim); const inicio2 = new Date(args.periodos[j].dataInicio); const fim2 = new Date(args.periodos[j].dataFim); if ( (inicio1 <= fim2 && fim1 >= inicio2) || (inicio2 <= fim1 && fim2 >= inicio1) ) { erros.push("Os períodos não podem se sobrepor"); } } } // Validação 6: Datas no futuro (aviso) const hoje = new Date(); hoje.setHours(0, 0, 0, 0); for (const periodo of args.periodos) { const inicio = new Date(periodo.dataInicio); if (inicio < hoje) { avisos.push("⚠️ Período(s) com data de início no passado ou hoje"); break; } } // Validação 7: Servidor PE - aviso sobre período preferencial para docentes if (regime === "estatutario_pe") { for (const periodo of args.periodos) { const mes = new Date(periodo.dataInicio).getMonth() + 1; if (mes === 12 || mes === 1) { avisos.push("📅 Período preferencial para docentes (20/12 a 10/01)"); break; } } } return { valido: erros.length === 0, erros, avisos, totalDias, regimeTrabalho: config.nome, }; }, }); /** * 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; }, });