-
+
-
-
-
-
Atestados & Licenças
-Registro de atestados médicos e licenças
++ Registro de atestados médicos e licenças +
-
-
-
-
-
+
+ Módulo em Desenvolvimento
-Esta funcionalidade está em desenvolvimento e estará disponível em breve.
-
+ (abaAtiva = "dashboard")}
+ >
+
+
+
+ Dashboard
+
+ (abaAtiva = "atestado")}
+ >
+
+
+
+ Atestado Médico
+
+ (abaAtiva = "declaracao")}
+ >
+ Declaração
+
+ (abaAtiva = "maternidade")}
+ >
+ Licença Maternidade
+
+ (abaAtiva = "paternidade")}
+ >
+ Licença Paternidade
+
-
-
-
+
+ {#if abaAtiva === "dashboard"}
+
+
+ {#if statsQuery?.data}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Atestados Ativos
+
+ {statsQuery.data.totalAtestadosAtivos}
+
+
+
+
+
+
+
+
+
+ Licenças Ativas
+
+ {statsQuery.data.totalLicencasAtivas}
+
+
+
+
+
+
+
+
+
+ Afastados Hoje
+
+ {statsQuery.data.funcionariosAfastadosHoje}
+
+
+
+
+
+
+
+
+ Dias no Mês
+
+ {statsQuery.data.totalDiasAfastamentoMes}
+
+
-
- Registrar Atestado
-Cadastre atestados médicos
-
- Em breve
+
Filtros
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Limpar
+
+
+
+
+
+ {:else if abaAtiva === "atestado"}
+
+ = {
+ atestado_medico: 0,
+ declaracao_comparecimento: 0,
+ maternidade: 0,
+ paternidade: 0,
+ ferias: 0,
+ };
+
+ atestadosFiltrados.forEach((a) => {
+ const dias = calcularDias(a.dataInicio, a.dataFim);
+ if (a.tipo === "atestado_medico") {
+ totalDiasPorTipo.atestado_medico += dias;
+ } else {
+ totalDiasPorTipo.declaracao_comparecimento += dias;
+ }
+ });
+
+ licencasFiltradas.forEach((l) => {
+ const dias = calcularDias(l.dataInicio, l.dataFim);
+ if (l.tipo === "maternidade") {
+ totalDiasPorTipo.maternidade += dias;
+ } else {
+ totalDiasPorTipo.paternidade += dias;
+ }
+ });
+
+ // Buscar férias do período
+ try {
+ const solicitacoesFerias = await ctx.db
+ .query("solicitacoesFerias")
+ .filter((q) =>
+ q.or(
+ q.eq(q.field("status"), "aprovado"),
+ q.eq(q.field("status"), "data_ajustada_aprovada")
+ )
+ )
+ .collect();
+
+ solicitacoesFerias.forEach((s) => {
+ if (s.periodos && Array.isArray(s.periodos)) {
+ s.periodos.forEach((p: { dataInicio: string; dataFim: string }) => {
+ const dias = calcularDias(p.dataInicio, p.dataFim);
+ totalDiasPorTipo.ferias += dias;
+ });
+ }
+ });
+ } catch (error) {
+ console.error("Erro ao buscar férias para gráfico:", error);
+ }
+
+ // 2. Tendências mensais (últimos 6 meses)
+ const meses: Record<
+ string,
+ {
+ atestado_medico: number;
+ declaracao_comparecimento: number;
+ maternidade: number;
+ paternidade: number;
+ ferias: number;
+ }
+ > = {};
+
+ const hoje = new Date();
+ for (let i = 5; i >= 0; i--) {
+ const mesData = new Date(hoje.getFullYear(), hoje.getMonth() - i, 1);
+ const mesKey = mesData.toLocaleDateString("pt-BR", {
+ month: "short",
+ year: "numeric",
+ });
+ meses[mesKey] = {
+ atestado_medico: 0,
+ declaracao_comparecimento: 0,
+ maternidade: 0,
+ paternidade: 0,
+ ferias: 0,
+ };
+ }
+
+ // Processar atestados para tendências mensais (usar todos, não apenas filtrados)
+ atestados.forEach((item) => {
+ try {
+ const mesData = new Date(item.criadoEm);
+ const mesKey = mesData.toLocaleDateString("pt-BR", {
+ month: "short",
+ year: "numeric",
+ });
+
+ if (meses[mesKey]) {
+ const dias = calcularDias(item.dataInicio, item.dataFim);
+ if (item.tipo === "atestado_medico") {
+ meses[mesKey].atestado_medico += dias;
+ } else if (item.tipo === "declaracao_comparecimento") {
+ meses[mesKey].declaracao_comparecimento += dias;
+ }
+ }
+ } catch (error) {
+ console.error("Erro ao processar atestado para tendências:", error);
+ }
+ });
+
+ // Processar licenças para tendências mensais (usar todas, não apenas filtradas)
+ licencas.forEach((item) => {
+ try {
+ const mesData = new Date(item.criadoEm);
+ const mesKey = mesData.toLocaleDateString("pt-BR", {
+ month: "short",
+ year: "numeric",
+ });
+
+ if (meses[mesKey]) {
+ const dias = calcularDias(item.dataInicio, item.dataFim);
+ if (item.tipo === "maternidade") {
+ meses[mesKey].maternidade += dias;
+ } else if (item.tipo === "paternidade") {
+ meses[mesKey].paternidade += dias;
+ }
+ }
+ } catch (error) {
+ console.error("Erro ao processar licença para tendências:", error);
+ }
+ });
+
+ // 3. Funcionários atualmente afastados
+ const hojeStr = new Date().toISOString().split("T")[0];
+ const funcionariosAfastados: Array<{
+ funcionarioId: Id<"funcionarios">;
+ funcionarioNome: string;
+ tipo: string;
+ dataInicio: string;
+ dataFim: string;
+ }> = [];
+
+ // Processar atestados (verificar funcionários atualmente afastados)
+ atestadosFiltrados.forEach((item) => {
+ try {
+ const inicio = new Date(item.dataInicio);
+ const fim = new Date(item.dataFim);
+ const hoje = new Date(hojeStr);
+
+ if (hoje >= inicio && hoje <= fim) {
+ funcionariosAfastados.push({
+ funcionarioId: item.funcionarioId,
+ funcionarioNome: "Carregando...",
+ tipo: item.tipo,
+ dataInicio: item.dataInicio,
+ dataFim: item.dataFim,
+ });
+ }
+ } catch (error) {
+ console.error("Erro ao processar atestado:", error);
+ }
+ });
+
+ // Processar licenças (verificar funcionários atualmente afastados)
+ licencasFiltradas.forEach((item) => {
+ try {
+ const inicio = new Date(item.dataInicio);
+ const fim = new Date(item.dataFim);
+ const hoje = new Date(hojeStr);
+
+ if (hoje >= inicio && hoje <= fim) {
+ funcionariosAfastados.push({
+ funcionarioId: item.funcionarioId,
+ funcionarioNome: "Carregando...",
+ tipo: item.tipo,
+ dataInicio: item.dataInicio,
+ dataFim: item.dataFim,
+ });
+ }
+ } catch (error) {
+ console.error("Erro ao processar licença:", error);
+ }
+ });
+
+ // Buscar nomes dos funcionários
+ const funcionariosAfastadosComNomes = await Promise.all(
+ funcionariosAfastados.map(async (item) => {
+ try {
+ const funcionario = await ctx.db.get(item.funcionarioId);
+ return {
+ ...item,
+ funcionarioNome: funcionario?.nome || "Desconhecido",
+ };
+ } catch (error) {
+ console.error("Erro ao buscar funcionário:", error);
+ return {
+ ...item,
+ funcionarioNome: "Desconhecido",
+ };
+ }
+ })
+ );
+
+ return {
+ totalDiasPorTipo: [
+ { tipo: "Atestado Médico", dias: totalDiasPorTipo.atestado_medico },
+ {
+ tipo: "Declaração",
+ dias: totalDiasPorTipo.declaracao_comparecimento,
+ },
+ { tipo: "Licença Maternidade", dias: totalDiasPorTipo.maternidade },
+ { tipo: "Licença Paternidade", dias: totalDiasPorTipo.paternidade },
+ { tipo: "Férias", dias: totalDiasPorTipo.ferias },
+ ],
+ tendenciasMensais: Object.entries(meses).map(([mes, dados]) => ({
+ mes,
+ ...dados,
+ })),
+ funcionariosAfastados: funcionariosAfastadosComNomes,
+ };
+ } catch (error) {
+ console.error("Erro em obterDadosGraficos:", error);
+ // Retornar dados vazios em caso de erro para não quebrar a página
+ return {
+ totalDiasPorTipo: [
+ { tipo: "Atestado Médico", dias: 0 },
+ { tipo: "Declaração", dias: 0 },
+ { tipo: "Licença Maternidade", dias: 0 },
+ { tipo: "Licença Paternidade", dias: 0 },
+ { tipo: "Férias", dias: 0 },
+ ],
+ tendenciasMensais: [],
+ funcionariosAfastados: [],
+ };
+ }
+ },
+});
+
+/**
+ * Obter estatísticas para dashboard
+ */
+export const obterEstatisticas = query({
+ args: {},
+ handler: async (ctx) => {
+ const hoje = new Date();
+ hoje.setHours(0, 0, 0, 0);
+ const inicioMes = new Date(hoje.getFullYear(), hoje.getMonth(), 1);
+ const fimMes = new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0);
+
+ const [atestados, licencas] = await Promise.all([
+ ctx.db.query("atestados").collect(),
+ ctx.db.query("licencas").collect(),
+ ]);
+
+ // Atestados ativos
+ const atestadosAtivos = atestados.filter(
+ (a) => new Date(a.dataFim) >= hoje
+ );
+
+ // Licenças ativas
+ const licencasAtivas = licencas.filter(
+ (l) => new Date(l.dataFim) >= hoje
+ );
+
+ // Funcionários afastados hoje
+ const funcionariosAfastadosHoje = new Set();
+ [...atestados, ...licencas].forEach((item) => {
+ const inicio = new Date(item.dataInicio);
+ const fim = new Date(item.dataFim);
+ if (hoje >= inicio && hoje <= fim) {
+ funcionariosAfastadosHoje.add(item.funcionarioId);
+ }
+ });
+
+ // Total de dias no mês
+ let totalDiasMes = 0;
+ [...atestados, ...licencas].forEach((item) => {
+ const inicio = new Date(item.dataInicio);
+ const fim = new Date(item.dataFim);
+ if (
+ (inicio >= inicioMes && inicio <= fimMes) ||
+ (fim >= inicioMes && fim <= fimMes) ||
+ (inicio <= inicioMes && fim >= fimMes)
+ ) {
+ const dias = calcularDias(item.dataInicio, item.dataFim);
+ totalDiasMes += dias;
+ }
+ });
+
+ return {
+ totalAtestadosAtivos: atestadosAtivos.length,
+ totalLicencasAtivas: licencasAtivas.length,
+ funcionariosAfastadosHoje: funcionariosAfastadosHoje.size,
+ totalDiasAfastamentoMes: totalDiasMes,
+ };
+ },
+});
+
+/**
+ * Obter eventos formatados para calendário
+ */
+export const obterEventosCalendario = query({
+ args: {
+ dataInicio: v.optional(v.string()),
+ dataFim: v.optional(v.string()),
+ tipoFiltro: v.optional(
+ v.union(
+ v.literal("todos"),
+ v.literal("atestado_medico"),
+ v.literal("declaracao_comparecimento"),
+ v.literal("maternidade"),
+ v.literal("paternidade"),
+ v.literal("ferias")
+ )
+ ),
+ },
+ handler: async (ctx, args) => {
+ const eventos: Array<{
+ id: string;
+ title: string;
+ start: string;
+ end: string;
+ color: string;
+ tipo: string;
+ funcionarioNome: string;
+ funcionarioId: string;
+ }> = [];
+
+ try {
+ // Buscar atestados
+ if (
+ !args.tipoFiltro ||
+ args.tipoFiltro === "todos" ||
+ args.tipoFiltro === "atestado_medico" ||
+ args.tipoFiltro === "declaracao_comparecimento"
+ ) {
+ try {
+ const atestados = await ctx.db.query("atestados").collect();
+ for (const atestado of atestados) {
+ try {
+ if (
+ args.tipoFiltro &&
+ args.tipoFiltro !== "todos" &&
+ atestado.tipo !== args.tipoFiltro
+ ) {
+ continue;
+ }
+
+ const funcionario = await ctx.db.get(atestado.funcionarioId);
+ if (!funcionario) continue;
+
+ if (!atestado.dataInicio || !atestado.dataFim) continue;
+
+ const cor =
+ atestado.tipo === "atestado_medico"
+ ? "#ef4444"
+ : "#f97316"; // vermelho ou laranja
+
+ eventos.push({
+ id: `atestado-${atestado._id}`,
+ title: `${funcionario.nome} - ${
+ atestado.tipo === "atestado_medico"
+ ? "Atestado Médico"
+ : "Declaração"
+ }`,
+ start: atestado.dataInicio,
+ end: atestado.dataFim,
+ color: cor,
+ tipo: atestado.tipo,
+ funcionarioNome: funcionario.nome,
+ funcionarioId: funcionario._id,
+ });
+ } catch (error) {
+ console.error(`Erro ao processar atestado ${atestado._id}:`, error);
+ continue;
+ }
+ }
+ } catch (error) {
+ console.error("Erro ao buscar atestados:", error);
+ }
+ }
+
+ // Buscar licenças
+ if (
+ !args.tipoFiltro ||
+ args.tipoFiltro === "todos" ||
+ args.tipoFiltro === "maternidade" ||
+ args.tipoFiltro === "paternidade"
+ ) {
+ try {
+ const licencas = await ctx.db.query("licencas").collect();
+ for (const licenca of licencas) {
+ try {
+ if (
+ args.tipoFiltro &&
+ args.tipoFiltro !== "todos" &&
+ licenca.tipo !== args.tipoFiltro
+ ) {
+ continue;
+ }
+
+ const funcionario = await ctx.db.get(licenca.funcionarioId);
+ if (!funcionario) continue;
+
+ if (!licenca.dataInicio || !licenca.dataFim) continue;
+
+ const cor =
+ licenca.tipo === "maternidade"
+ ? "#ec4899"
+ : "#3b82f6"; // rosa ou azul
+
+ eventos.push({
+ id: `licenca-${licenca._id}`,
+ title: `${funcionario.nome} - Licença ${
+ licenca.tipo === "maternidade" ? "Maternidade" : "Paternidade"
+ }`,
+ start: licenca.dataInicio,
+ end: licenca.dataFim,
+ color: cor,
+ tipo: licenca.tipo,
+ funcionarioNome: funcionario.nome,
+ funcionarioId: funcionario._id,
+ });
+ } catch (error) {
+ console.error(`Erro ao processar licença ${licenca._id}:`, error);
+ continue;
+ }
+ }
+ } catch (error) {
+ console.error("Erro ao buscar licenças:", error);
+ }
+ }
+ } catch (error) {
+ console.error("Erro geral em obterEventosCalendario:", error);
+ return eventos; // Retorna eventos já coletados mesmo se houver erro
+ }
+
+ // Integrar com férias (se não estiver filtrando por tipo específico)
+ if (!args.tipoFiltro || args.tipoFiltro === "todos" || args.tipoFiltro === "ferias") {
+ try {
+ // Buscar solicitações de férias aprovadas
+ const solicitacoesFerias = await ctx.db
+ .query("solicitacoesFerias")
+ .filter((q) =>
+ q.or(
+ q.eq(q.field("status"), "aprovado"),
+ q.eq(q.field("status"), "data_ajustada_aprovada")
+ )
+ )
+ .collect();
+
+ for (const solicitacao of solicitacoesFerias) {
+ try {
+ const funcionario = await ctx.db.get(solicitacao.funcionarioId);
+ if (!funcionario) continue;
+
+ // Verificar se periodos existe e é um array
+ if (!solicitacao.periodos || !Array.isArray(solicitacao.periodos)) {
+ continue;
+ }
+
+ for (const periodo of solicitacao.periodos) {
+ if (!periodo.dataInicio || !periodo.dataFim) continue;
+
+ eventos.push({
+ id: `ferias-${solicitacao._id}-${periodo.dataInicio}`,
+ title: `${funcionario.nome} - Férias`,
+ start: periodo.dataInicio,
+ end: periodo.dataFim,
+ color: "#10b981", // verde
+ tipo: "ferias",
+ funcionarioNome: funcionario.nome,
+ funcionarioId: funcionario._id,
+ });
+ }
+ } catch (error) {
+ console.error(`Erro ao processar solicitação de férias ${solicitacao._id}:`, error);
+ continue;
+ }
+ }
+ } catch (error) {
+ console.error("Erro ao buscar solicitações de férias:", error);
+ // Continua mesmo se houver erro ao buscar férias
+ }
+ }
+
+ // Filtrar por período se fornecido
+ if (args.dataInicio && args.dataFim) {
+ const inicio = new Date(args.dataInicio);
+ const fim = new Date(args.dataFim);
+ return eventos.filter((e) => {
+ const eventStart = new Date(e.start);
+ const eventEnd = new Date(e.end);
+ return (
+ (eventStart >= inicio && eventStart <= fim) ||
+ (eventEnd >= inicio && eventEnd <= fim) ||
+ (eventStart <= inicio && eventEnd >= fim)
+ );
+ });
+ }
+
+ return eventos;
+ },
+});
+
+// ========== MUTATIONS ==========
+
+/**
+ * Gerar URL para upload de documentos
+ */
+export const generateUploadUrl = mutation({
+ args: {},
+ returns: v.string(),
+ handler: async (ctx) => {
+ const usuario = await getUsuarioAutenticado(ctx);
+ if (!usuario) throw new Error("Não autenticado");
+
+ return await ctx.storage.generateUploadUrl();
+ },
+});
+
+/**
+ * Criar atestado médico
+ */
+export const criarAtestadoMedico = mutation({
+ args: {
+ funcionarioId: v.id("funcionarios"),
+ dataInicio: v.string(),
+ dataFim: v.string(),
+ cid: v.string(),
+ observacoes: v.optional(v.string()),
+ documentoId: v.optional(v.id("_storage")),
+ },
+ returns: v.id("atestados"),
+ handler: async (ctx, args) => {
+ const usuario = await getUsuarioAutenticado(ctx);
+ if (!usuario) throw new Error("Não autenticado");
+
+ // Validar datas
+ if (new Date(args.dataFim) < new Date(args.dataInicio)) {
+ throw new Error("Data fim deve ser maior ou igual à data início");
+ }
+
+ const atestadoId = await ctx.db.insert("atestados", {
+ funcionarioId: args.funcionarioId,
+ tipo: "atestado_medico",
+ dataInicio: args.dataInicio,
+ dataFim: args.dataFim,
+ cid: args.cid,
+ observacoes: args.observacoes,
+ documentoId: args.documentoId,
+ criadoPor: usuario._id,
+ criadoEm: Date.now(),
+ });
+
+ await registrarAtividade(
+ ctx,
+ usuario._id,
+ "criar",
+ "atestados",
+ `Atestado médico criado para funcionário ${args.funcionarioId}`,
+ atestadoId
+ );
+
+ return atestadoId;
+ },
+});
+
+/**
+ * Criar declaração de comparecimento
+ */
+export const criarDeclaracaoComparecimento = mutation({
+ args: {
+ funcionarioId: v.id("funcionarios"),
+ dataInicio: v.string(),
+ dataFim: v.string(),
+ observacoes: v.optional(v.string()),
+ documentoId: v.optional(v.id("_storage")),
+ },
+ returns: v.id("atestados"),
+ handler: async (ctx, args) => {
+ const usuario = await getUsuarioAutenticado(ctx);
+ if (!usuario) throw new Error("Não autenticado");
+
+ // Validar datas
+ if (new Date(args.dataFim) < new Date(args.dataInicio)) {
+ throw new Error("Data fim deve ser maior ou igual à data início");
+ }
+
+ const atestadoId = await ctx.db.insert("atestados", {
+ funcionarioId: args.funcionarioId,
+ tipo: "declaracao_comparecimento",
+ dataInicio: args.dataInicio,
+ dataFim: args.dataFim,
+ observacoes: args.observacoes,
+ documentoId: args.documentoId,
+ criadoPor: usuario._id,
+ criadoEm: Date.now(),
+ });
+
+ await registrarAtividade(
+ ctx,
+ usuario._id,
+ "criar",
+ "atestados",
+ `Declaração de comparecimento criada para funcionário ${args.funcionarioId}`,
+ atestadoId
+ );
+
+ return atestadoId;
+ },
+});
+
+/**
+ * Criar licença maternidade
+ */
+export const criarLicencaMaternidade = mutation({
+ args: {
+ funcionarioId: v.id("funcionarios"),
+ dataInicio: v.string(),
+ dataFim: v.string(),
+ observacoes: v.optional(v.string()),
+ documentoId: v.optional(v.id("_storage")),
+ licencaOriginalId: v.optional(v.id("licencas")),
+ },
+ returns: v.id("licencas"),
+ handler: async (ctx, args) => {
+ const usuario = await getUsuarioAutenticado(ctx);
+ if (!usuario) throw new Error("Não autenticado");
+
+ // Validar datas
+ if (new Date(args.dataFim) < new Date(args.dataInicio)) {
+ throw new Error("Data fim deve ser maior ou igual à data início");
+ }
+
+ const ehProrrogacao = !!args.licencaOriginalId;
+ if (ehProrrogacao && !args.licencaOriginalId) {
+ throw new Error("Licença original é obrigatória para prorrogação");
+ }
+
+ const licencaId = await ctx.db.insert("licencas", {
+ funcionarioId: args.funcionarioId,
+ tipo: "maternidade",
+ dataInicio: args.dataInicio,
+ dataFim: args.dataFim,
+ observacoes: args.observacoes,
+ documentoId: args.documentoId,
+ licencaOriginalId: args.licencaOriginalId,
+ ehProrrogacao,
+ criadoPor: usuario._id,
+ criadoEm: Date.now(),
+ });
+
+ await registrarAtividade(
+ ctx,
+ usuario._id,
+ "criar",
+ "licencas",
+ `Licença maternidade criada para funcionário ${args.funcionarioId}${ehProrrogacao ? " (prorrogação)" : ""}`,
+ licencaId
+ );
+
+ return licencaId;
+ },
+});
+
+/**
+ * Criar licença paternidade
+ */
+export const criarLicencaPaternidade = mutation({
+ args: {
+ funcionarioId: v.id("funcionarios"),
+ dataInicio: v.string(),
+ dataFim: v.string(),
+ observacoes: v.optional(v.string()),
+ documentoId: v.optional(v.id("_storage")),
+ },
+ returns: v.id("licencas"),
+ handler: async (ctx, args) => {
+ const usuario = await getUsuarioAutenticado(ctx);
+ if (!usuario) throw new Error("Não autenticado");
+
+ // Validar datas
+ if (new Date(args.dataFim) < new Date(args.dataInicio)) {
+ throw new Error("Data fim deve ser maior ou igual à data início");
+ }
+
+ const licencaId = await ctx.db.insert("licencas", {
+ funcionarioId: args.funcionarioId,
+ tipo: "paternidade",
+ dataInicio: args.dataInicio,
+ dataFim: args.dataFim,
+ observacoes: args.observacoes,
+ documentoId: args.documentoId,
+ ehProrrogacao: false,
+ criadoPor: usuario._id,
+ criadoEm: Date.now(),
+ });
+
+ await registrarAtividade(
+ ctx,
+ usuario._id,
+ "criar",
+ "licencas",
+ `Licença paternidade criada para funcionário ${args.funcionarioId}`,
+ licencaId
+ );
+
+ return licencaId;
+ },
+});
+
+/**
+ * Prorrogar licença maternidade
+ */
+export const prorrogarLicencaMaternidade = mutation({
+ args: {
+ licencaOriginalId: v.id("licencas"),
+ dataInicio: v.string(),
+ dataFim: v.string(),
+ observacoes: v.optional(v.string()),
+ documentoId: v.optional(v.id("_storage")),
+ },
+ returns: v.id("licencas"),
+ handler: async (ctx, args) => {
+ const usuario = await getUsuarioAutenticado(ctx);
+ if (!usuario) throw new Error("Não autenticado");
+
+ const licencaOriginal = await ctx.db.get(args.licencaOriginalId);
+ if (!licencaOriginal) {
+ throw new Error("Licença original não encontrada");
+ }
+
+ if (licencaOriginal.tipo !== "maternidade") {
+ throw new Error("Apenas licenças de maternidade podem ser prorrogadas");
+ }
+
+ // Validar datas
+ if (new Date(args.dataFim) < new Date(args.dataInicio)) {
+ throw new Error("Data fim deve ser maior ou igual à data início");
+ }
+
+ const prorrogacaoId = await ctx.db.insert("licencas", {
+ funcionarioId: licencaOriginal.funcionarioId,
+ tipo: "maternidade",
+ dataInicio: args.dataInicio,
+ dataFim: args.dataFim,
+ observacoes: args.observacoes,
+ documentoId: args.documentoId,
+ licencaOriginalId: args.licencaOriginalId,
+ ehProrrogacao: true,
+ criadoPor: usuario._id,
+ criadoEm: Date.now(),
+ });
+
+ await registrarAtividade(
+ ctx,
+ usuario._id,
+ "criar",
+ "licencas",
+ `Prorrogação de licença maternidade criada para funcionário ${licencaOriginal.funcionarioId}`,
+ prorrogacaoId
+ );
+
+ return prorrogacaoId;
+ },
+});
+
+/**
+ * Excluir atestado
+ */
+export const excluirAtestado = mutation({
+ args: {
+ id: v.id("atestados"),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getUsuarioAutenticado(ctx);
+ if (!usuario) throw new Error("Não autenticado");
+
+ const atestado = await ctx.db.get(args.id);
+ if (!atestado) throw new Error("Atestado não encontrado");
+
+ await ctx.db.delete(args.id);
+
+ await registrarAtividade(
+ ctx,
+ usuario._id,
+ "excluir",
+ "atestados",
+ `Atestado excluído: ${args.id}`,
+ args.id
+ );
+
+ return null;
+ },
+});
+
+/**
+ * Excluir licença
+ */
+export const excluirLicenca = mutation({
+ args: {
+ id: v.id("licencas"),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getUsuarioAutenticado(ctx);
+ if (!usuario) throw new Error("Não autenticado");
+
+ const licenca = await ctx.db.get(args.id);
+ if (!licenca) throw new Error("Licença não encontrada");
+
+ await ctx.db.delete(args.id);
+
+ await registrarAtividade(
+ ctx,
+ usuario._id,
+ "excluir",
+ "licencas",
+ `Licença excluída: ${args.id}`,
+ args.id
+ );
+
+ return null;
+ },
+});
diff --git a/packages/backend/convex/autenticacao.ts b/packages/backend/convex/autenticacao.ts
index dc6757d..43dc379 100644
--- a/packages/backend/convex/autenticacao.ts
+++ b/packages/backend/convex/autenticacao.ts
@@ -1,5 +1,5 @@
import { v } from "convex/values";
-import { mutation, query } from "./_generated/server";
+import { mutation, query, internalMutation } from "./_generated/server";
import {
hashPassword,
verifyPassword,
@@ -9,7 +9,7 @@ import {
} from "./auth/utils";
import { registrarLogin } from "./logsLogin";
import { Id, Doc } from "./_generated/dataModel";
-import type { QueryCtx } from "./_generated/server";
+import type { QueryCtx, MutationCtx } from "./_generated/server";
/**
* Helper para verificar se usuário está bloqueado
@@ -315,6 +315,280 @@ export const login = mutation({
},
});
+/**
+ * Mutation interna para login via HTTP (com IP extraído do request)
+ * Usada pelo endpoint HTTP /api/login
+ */
+export const loginComIP = internalMutation({
+ args: {
+ matriculaOuEmail: v.string(),
+ senha: v.string(),
+ ipAddress: v.optional(v.string()),
+ userAgent: v.optional(v.string()),
+ },
+ returns: v.union(
+ v.object({
+ sucesso: v.literal(true),
+ token: v.string(),
+ usuario: v.object({
+ _id: v.id("usuarios"),
+ matricula: v.string(),
+ nome: v.string(),
+ email: v.string(),
+ funcionarioId: v.optional(v.id("funcionarios")),
+ role: v.object({
+ _id: v.id("roles"),
+ nome: v.string(),
+ nivel: v.number(),
+ setor: v.optional(v.string()),
+ }),
+ primeiroAcesso: v.boolean(),
+ }),
+ }),
+ v.object({
+ sucesso: v.literal(false),
+ erro: v.string(),
+ })
+ ),
+ handler: async (ctx, args) => {
+ // Reutilizar a mesma lógica da mutation pública
+ // Verificar rate limiting por IP
+ if (args.ipAddress) {
+ const ipBloqueado = await verificarRateLimitIP(ctx, args.ipAddress);
+ if (ipBloqueado) {
+ await registrarLogin(ctx, {
+ matriculaOuEmail: args.matriculaOuEmail,
+ sucesso: false,
+ motivoFalha: "rate_limit_excedido",
+ ipAddress: args.ipAddress,
+ userAgent: args.userAgent,
+ });
+
+ return {
+ sucesso: false as const,
+ erro: "Muitas tentativas de login. Tente novamente em 15 minutos.",
+ };
+ }
+ }
+
+ // Determinar se é email ou matrícula
+ const isEmail = args.matriculaOuEmail.includes("@");
+
+ // Buscar usuário
+ let usuario: Doc<"usuarios"> | null = null;
+ if (isEmail) {
+ usuario = await ctx.db
+ .query("usuarios")
+ .withIndex("by_email", (q) => q.eq("email", args.matriculaOuEmail))
+ .first();
+ } else {
+ const funcionario: Doc<"funcionarios"> | null = await ctx.db.query("funcionarios").withIndex("by_matricula", (q) => q.eq("matricula", args.matriculaOuEmail)).first();
+ if (funcionario) {
+ usuario = await ctx.db
+ .query("usuarios")
+ .withIndex("by_funcionarioId", (q) => q.eq("funcionarioId", funcionario._id))
+ .first();
+ }
+ }
+
+ if (!usuario) {
+ await registrarLogin(ctx, {
+ matriculaOuEmail: args.matriculaOuEmail,
+ sucesso: false,
+ motivoFalha: "usuario_inexistente",
+ ipAddress: args.ipAddress,
+ userAgent: args.userAgent,
+ });
+
+ return {
+ sucesso: false as const,
+ erro: "Credenciais incorretas.",
+ };
+ }
+
+ // Verificar se usuário está bloqueado
+ if (
+ usuario.bloqueado ||
+ (await verificarBloqueioUsuario(ctx, usuario._id))
+ ) {
+ await registrarLogin(ctx, {
+ usuarioId: usuario._id,
+ matriculaOuEmail: args.matriculaOuEmail,
+ sucesso: false,
+ motivoFalha: "usuario_bloqueado",
+ ipAddress: args.ipAddress,
+ userAgent: args.userAgent,
+ });
+
+ return {
+ sucesso: false as const,
+ erro: "Usuário bloqueado. Entre em contato com o TI.",
+ };
+ }
+
+ // Verificar se usuário está ativo
+ if (!usuario.ativo) {
+ await registrarLogin(ctx, {
+ usuarioId: usuario._id,
+ matriculaOuEmail: args.matriculaOuEmail,
+ sucesso: false,
+ motivoFalha: "usuario_inativo",
+ ipAddress: args.ipAddress,
+ userAgent: args.userAgent,
+ });
+
+ return {
+ sucesso: false as const,
+ erro: "Usuário inativo. Entre em contato com o TI.",
+ };
+ }
+
+ // Verificar tentativas de login (bloqueio temporário)
+ const tentativasRecentes = usuario.tentativasLogin || 0;
+ const ultimaTentativa = usuario.ultimaTentativaLogin || 0;
+ const tempoDecorrido = Date.now() - ultimaTentativa;
+ const TEMPO_BLOQUEIO = 30 * 60 * 1000; // 30 minutos
+
+ // Se tentou 5 vezes e ainda não passou o tempo de bloqueio
+ if (tentativasRecentes >= 5 && tempoDecorrido < TEMPO_BLOQUEIO) {
+ await registrarLogin(ctx, {
+ usuarioId: usuario._id,
+ matriculaOuEmail: args.matriculaOuEmail,
+ sucesso: false,
+ motivoFalha: "bloqueio_temporario",
+ ipAddress: args.ipAddress,
+ userAgent: args.userAgent,
+ });
+
+ const minutosRestantes = Math.ceil(
+ (TEMPO_BLOQUEIO - tempoDecorrido) / 60000
+ );
+ return {
+ sucesso: false as const,
+ erro: `Conta temporariamente bloqueada. Tente novamente em ${minutosRestantes} minutos.`,
+ };
+ }
+
+ // Resetar tentativas se passou o tempo de bloqueio
+ if (tempoDecorrido > TEMPO_BLOQUEIO) {
+ await ctx.db.patch(usuario._id, {
+ tentativasLogin: 0,
+ ultimaTentativaLogin: Date.now(),
+ });
+ }
+
+ // Verificar senha
+ const senhaValida = await verifyPassword(args.senha, usuario.senhaHash);
+
+ if (!senhaValida) {
+ // Incrementar tentativas
+ const novasTentativas =
+ tempoDecorrido > TEMPO_BLOQUEIO ? 1 : tentativasRecentes + 1;
+
+ await ctx.db.patch(usuario._id, {
+ tentativasLogin: novasTentativas,
+ ultimaTentativaLogin: Date.now(),
+ });
+
+ await registrarLogin(ctx, {
+ usuarioId: usuario._id,
+ matriculaOuEmail: args.matriculaOuEmail,
+ sucesso: false,
+ motivoFalha: "senha_incorreta",
+ ipAddress: args.ipAddress,
+ userAgent: args.userAgent,
+ });
+
+ const tentativasRestantes = 5 - novasTentativas;
+ if (tentativasRestantes > 0) {
+ return {
+ sucesso: false as const,
+ erro: `Credenciais incorretas. ${tentativasRestantes} tentativas restantes.`,
+ };
+ } else {
+ return {
+ sucesso: false as const,
+ erro: "Conta bloqueada por 30 minutos devido a múltiplas tentativas falhas.",
+ };
+ }
+ }
+
+ // Login bem-sucedido! Resetar tentativas
+ await ctx.db.patch(usuario._id, {
+ tentativasLogin: 0,
+ ultimaTentativaLogin: undefined,
+ });
+
+ // Buscar role do usuário
+ const role: Doc<"roles"> | null = await ctx.db.get(usuario.roleId);
+ if (!role) {
+ return {
+ sucesso: false as const,
+ erro: "Erro ao carregar permissões do usuário.",
+ };
+ }
+
+ // Gerar token de sessão
+ const token = generateToken();
+ const agora = Date.now();
+ const expiraEm = agora + 8 * 60 * 60 * 1000; // 8 horas
+
+ // Criar sessão
+ await ctx.db.insert("sessoes", {
+ usuarioId: usuario._id,
+ token,
+ ipAddress: args.ipAddress,
+ userAgent: args.userAgent,
+ criadoEm: agora,
+ expiraEm,
+ ativo: true,
+ });
+
+ // Atualizar último acesso
+ await ctx.db.patch(usuario._id, {
+ ultimoAcesso: agora,
+ atualizadoEm: agora,
+ });
+
+ // Log de login bem-sucedido
+ await registrarLogin(ctx, {
+ usuarioId: usuario._id,
+ matriculaOuEmail: args.matriculaOuEmail,
+ sucesso: true,
+ ipAddress: args.ipAddress,
+ userAgent: args.userAgent,
+ });
+
+ await ctx.db.insert("logsAcesso", {
+ usuarioId: usuario._id,
+ tipo: "login",
+ ipAddress: args.ipAddress,
+ userAgent: args.userAgent,
+ detalhes: "Login realizado com sucesso",
+ timestamp: agora,
+ });
+
+ return {
+ sucesso: true as const,
+ token,
+ usuario: {
+ _id: usuario._id,
+ matricula: usuario.matricula,
+ nome: usuario.nome,
+ email: usuario.email,
+ funcionarioId: usuario.funcionarioId,
+ role: {
+ _id: role._id,
+ nome: role.nome,
+ nivel: role.nivel,
+ setor: role.setor,
+ },
+ primeiroAcesso: usuario.primeiroAcesso,
+ },
+ };
+ },
+});
+
/**
* Logout do usuário
*/
diff --git a/packages/backend/convex/http.ts b/packages/backend/convex/http.ts
index 185d69c..a4cf87c 100644
--- a/packages/backend/convex/http.ts
+++ b/packages/backend/convex/http.ts
@@ -1,5 +1,150 @@
-import { httpRouter } from "convex/server";
-
-const http = httpRouter();
-
-export default http;
+import { httpRouter } from "convex/server";
+import { httpAction } from "./_generated/server";
+import { internal } from "./_generated/api";
+import { getClientIP } from "./utils/getClientIP";
+import { v } from "convex/values";
+
+const http = httpRouter();
+
+/**
+ * Endpoint de teste para debug - retorna todos os headers disponíveis
+ * GET /api/debug/headers
+ */
+http.route({
+ path: "/api/debug/headers",
+ method: "GET",
+ handler: httpAction(async (ctx, request) => {
+ const headers: Record = {};
+ request.headers.forEach((value, key) => {
+ headers[key] = value;
+ });
+
+ const ip = getClientIP(request);
+
+ return new Response(
+ JSON.stringify({
+ headers,
+ extractedIP: ip,
+ url: request.url,
+ }),
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ }),
+});
+
+/**
+ * Endpoint HTTP para login que captura automaticamente o IP do cliente
+ * POST /api/login
+ * Body: { matriculaOuEmail: string, senha: string }
+ */
+http.route({
+ path: "/api/login",
+ method: "POST",
+ handler: httpAction(async (ctx, request) => {
+ try {
+ // Debug: Log todos os headers disponíveis
+ console.log("=== DEBUG: Headers HTTP ===");
+ const headersEntries: string[] = [];
+ request.headers.forEach((value, key) => {
+ headersEntries.push(`${key}: ${value}`);
+ });
+ console.log("Headers:", headersEntries.join(", "));
+ console.log("Request URL:", request.url);
+
+ // Extrair IP do cliente do request
+ let clientIP = getClientIP(request);
+ console.log("IP extraído:", clientIP);
+
+ // Se não encontrou IP, tentar obter do URL ou usar valor padrão
+ if (!clientIP) {
+ try {
+ const url = new URL(request.url);
+ // Tentar pegar do query param se disponível
+ const ipParam = url.searchParams.get("client_ip");
+ if (ipParam && /^(\d{1,3}\.){3}\d{1,3}$/.test(ipParam)) {
+ clientIP = ipParam;
+ console.log("IP obtido do query param:", clientIP);
+ } else {
+ // Se ainda não tiver IP, usar um identificador baseado no timestamp
+ // Isso pelo menos diferencia requisições
+ console.warn("IP não encontrado nos headers. Usando fallback.");
+ clientIP = undefined; // Deixar como undefined para registrar como não disponível
+ }
+ } catch {
+ console.warn("Erro ao processar URL para IP");
+ }
+ }
+
+ // Extrair User-Agent
+ const userAgent = request.headers.get("user-agent") || undefined;
+
+ // Ler body da requisição
+ const body = await request.json();
+
+ if (!body.matriculaOuEmail || !body.senha) {
+ return new Response(
+ JSON.stringify({
+ sucesso: false,
+ erro: "Matrícula/Email e senha são obrigatórios",
+ }),
+ {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ }
+
+ // Chamar a mutation de login interna com IP e userAgent
+ const resultado = await ctx.runMutation(internal.autenticacao.loginComIP, {
+ matriculaOuEmail: body.matriculaOuEmail,
+ senha: body.senha,
+ ipAddress: clientIP,
+ userAgent: userAgent,
+ });
+
+ return new Response(JSON.stringify(resultado), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ },
+ });
+ } catch (error) {
+ return new Response(
+ JSON.stringify({
+ sucesso: false,
+ erro: error instanceof Error ? error.message : "Erro ao processar login",
+ }),
+ {
+ status: 500,
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ }
+ }),
+});
+
+/**
+ * Endpoint OPTIONS para CORS preflight
+ */
+http.route({
+ path: "/api/login",
+ method: "OPTIONS",
+ handler: httpAction(async () => {
+ return new Response(null, {
+ status: 200,
+ headers: {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ },
+ });
+ }),
+});
+
+export default http;
diff --git a/packages/backend/convex/logsLogin.ts b/packages/backend/convex/logsLogin.ts
index 27de8ac..96259d4 100644
--- a/packages/backend/convex/logsLogin.ts
+++ b/packages/backend/convex/logsLogin.ts
@@ -5,6 +5,35 @@ import { Doc, Id } from "./_generated/dataModel";
/**
* Helper para registrar tentativas de login
*/
+/**
+ * Valida se uma string é um IP válido
+ */
+function validarIP(ip: string | undefined): string | undefined {
+ if (!ip || ip.length < 7) return undefined; // IP mínimo: "1.1.1.1" = 7 chars
+
+ // Validar IPv4
+ const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
+ if (ipv4Regex.test(ip)) {
+ const parts = ip.split('.');
+ if (parts.length === 4 && parts.every(part => {
+ const num = parseInt(part, 10);
+ return !isNaN(num) && num >= 0 && num <= 255;
+ })) {
+ return ip;
+ }
+ }
+
+ // Validar IPv6 básico
+ const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$|^::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,6}$|^[0-9a-fA-F]{0,4}::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,5}$/;
+ if (ipv6Regex.test(ip)) {
+ return ip;
+ }
+
+ // IP inválido - não salvar
+ console.warn(`IP inválido detectado e ignorado: "${ip}"`);
+ return undefined;
+}
+
export async function registrarLogin(
ctx: MutationCtx,
dados: {
@@ -21,12 +50,15 @@ export async function registrarLogin(
const browser = dados.userAgent ? extrairBrowser(dados.userAgent) : undefined;
const sistema = dados.userAgent ? extrairSistema(dados.userAgent) : undefined;
+ // Validar e sanitizar IP antes de salvar
+ const ipAddressValidado = validarIP(dados.ipAddress);
+
await ctx.db.insert("logsLogin", {
usuarioId: dados.usuarioId,
matriculaOuEmail: dados.matriculaOuEmail,
sucesso: dados.sucesso,
motivoFalha: dados.motivoFalha,
- ipAddress: dados.ipAddress,
+ ipAddress: ipAddressValidado,
userAgent: dados.userAgent,
device,
browser,
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index fb337c9..49bf0ad 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -151,11 +151,43 @@ export default defineSchema({
atestados: defineTable({
funcionarioId: v.id("funcionarios"),
+ tipo: v.union(
+ v.literal("atestado_medico"),
+ v.literal("declaracao_comparecimento")
+ ),
dataInicio: v.string(),
dataFim: v.string(),
- cid: v.string(),
- descricao: v.string(),
- }),
+ cid: v.optional(v.string()), // Apenas para atestado médico
+ observacoes: v.optional(v.string()),
+ documentoId: v.optional(v.id("_storage")),
+ criadoPor: v.id("usuarios"),
+ criadoEm: v.number(),
+ })
+ .index("by_funcionario", ["funcionarioId"])
+ .index("by_tipo", ["tipo"])
+ .index("by_data_inicio", ["dataInicio"])
+ .index("by_funcionario_and_tipo", ["funcionarioId", "tipo"]),
+
+ licencas: defineTable({
+ funcionarioId: v.id("funcionarios"),
+ tipo: v.union(
+ v.literal("maternidade"),
+ v.literal("paternidade")
+ ),
+ dataInicio: v.string(),
+ dataFim: v.string(),
+ documentoId: v.optional(v.id("_storage")),
+ observacoes: v.optional(v.string()),
+ licencaOriginalId: v.optional(v.id("licencas")), // Para prorrogações
+ ehProrrogacao: v.boolean(),
+ criadoPor: v.id("usuarios"),
+ criadoEm: v.number(),
+ })
+ .index("by_funcionario", ["funcionarioId"])
+ .index("by_tipo", ["tipo"])
+ .index("by_data_inicio", ["dataInicio"])
+ .index("by_licenca_original", ["licencaOriginalId"])
+ .index("by_funcionario_and_tipo", ["funcionarioId", "tipo"]),
solicitacoesFerias: defineTable({
funcionarioId: v.id("funcionarios"),
diff --git a/packages/backend/convex/utils/getClientIP.ts b/packages/backend/convex/utils/getClientIP.ts
new file mode 100644
index 0000000..7063688
--- /dev/null
+++ b/packages/backend/convex/utils/getClientIP.ts
@@ -0,0 +1,151 @@
+/**
+ * Função utilitária para extrair o IP do cliente de um Request HTTP
+ * Sem usar APIs externas - usa apenas headers HTTP
+ */
+
+/**
+ * Extrai o IP do cliente de um Request HTTP
+ * Considera headers como X-Forwarded-For, X-Real-IP, etc.
+ */
+export function getClientIP(request: Request): string | undefined {
+ // Headers que podem conter o IP do cliente (case-insensitive)
+ const getHeader = (name: string): string | null => {
+ // Tentar diferentes variações de case
+ const variations = [
+ name,
+ name.toLowerCase(),
+ name.toUpperCase(),
+ name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(),
+ ];
+
+ for (const variation of variations) {
+ const value = request.headers.get(variation);
+ if (value) return value;
+ }
+
+ // As variações de case já cobrem a maioria dos casos
+ // Se não encontrou, retorna null
+ return null;
+ };
+
+ const forwardedFor = getHeader("x-forwarded-for");
+ const realIP = getHeader("x-real-ip");
+ const cfConnectingIP = getHeader("cf-connecting-ip"); // Cloudflare
+ const trueClientIP = getHeader("true-client-ip"); // Cloudflare Enterprise
+ const xClientIP = getHeader("x-client-ip");
+ const forwarded = getHeader("forwarded");
+ const remoteAddr = getHeader("remote-addr");
+
+ // Log para debug
+ console.log("Procurando IP nos headers:", {
+ "x-forwarded-for": forwardedFor,
+ "x-real-ip": realIP,
+ "cf-connecting-ip": cfConnectingIP,
+ "true-client-ip": trueClientIP,
+ "x-client-ip": xClientIP,
+ "forwarded": forwarded,
+ "remote-addr": remoteAddr,
+ });
+
+ // Prioridade: X-Forwarded-For pode conter múltiplos IPs (proxy chain)
+ // O primeiro IP é geralmente o IP original do cliente
+ if (forwardedFor) {
+ const ips = forwardedFor.split(",").map((ip) => ip.trim());
+ // Pegar o primeiro IP válido
+ for (const ip of ips) {
+ if (isValidIP(ip)) {
+ console.log("IP encontrado em X-Forwarded-For:", ip);
+ return ip;
+ }
+ }
+ }
+
+ // Forwarded header (RFC 7239)
+ if (forwarded) {
+ // Formato: for=192.0.2.60;proto=http;by=203.0.113.43
+ const forMatch = forwarded.match(/for=([^;,\s]+)/i);
+ if (forMatch && forMatch[1]) {
+ const ip = forMatch[1].replace(/^\[|\]$/g, ''); // Remove brackets de IPv6
+ if (isValidIP(ip)) {
+ console.log("IP encontrado em Forwarded:", ip);
+ return ip;
+ }
+ }
+ }
+
+ // Outros headers com IP único
+ if (realIP && isValidIP(realIP)) {
+ console.log("IP encontrado em X-Real-IP:", realIP);
+ return realIP;
+ }
+
+ if (cfConnectingIP && isValidIP(cfConnectingIP)) {
+ console.log("IP encontrado em CF-Connecting-IP:", cfConnectingIP);
+ return cfConnectingIP;
+ }
+
+ if (trueClientIP && isValidIP(trueClientIP)) {
+ console.log("IP encontrado em True-Client-IP:", trueClientIP);
+ return trueClientIP;
+ }
+
+ if (xClientIP && isValidIP(xClientIP)) {
+ console.log("IP encontrado em X-Client-IP:", xClientIP);
+ return xClientIP;
+ }
+
+ if (remoteAddr && isValidIP(remoteAddr)) {
+ console.log("IP encontrado em Remote-Addr:", remoteAddr);
+ return remoteAddr;
+ }
+
+ // Tentar extrair do URL (último recurso)
+ try {
+ const url = new URL(request.url);
+ // Se o servidor estiver configurado para passar IP via query param
+ const ipFromQuery = url.searchParams.get("ip");
+ if (ipFromQuery && isValidIP(ipFromQuery)) {
+ console.log("IP encontrado em query param:", ipFromQuery);
+ return ipFromQuery;
+ }
+ } catch {
+ // Ignorar erro de parsing do URL
+ }
+
+ console.log("Nenhum IP válido encontrado nos headers");
+ return undefined;
+}
+
+/**
+ * Valida se uma string é um endereço IP válido (IPv4 ou IPv6)
+ */
+function isValidIP(ip: string): boolean {
+ if (!ip || ip.length === 0) {
+ return false;
+ }
+
+ // Validar IPv4
+ const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
+ if (ipv4Regex.test(ip)) {
+ const parts = ip.split(".");
+ return parts.every((part) => {
+ const num = parseInt(part, 10);
+ return num >= 0 && num <= 255;
+ });
+ }
+
+ // Validar IPv6 (formato simplificado)
+ const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
+ if (ipv6Regex.test(ip)) {
+ return true;
+ }
+
+ // Validar IPv6 comprimido (com ::)
+ const ipv6CompressedRegex = /^([0-9a-fA-F]{0,4}:)*::([0-9a-fA-F]{0,4}:)*[0-9a-fA-F]{0,4}$/;
+ if (ipv6CompressedRegex.test(ip)) {
+ return true;
+ }
+
+ return false;
+}
+
-
- Registrar Licença
-Cadastre licenças e afastamentos
-
- Em breve
+
Calendário de Afastamentos
+
+
+
+
+ O calendário interativo completo será implementado em breve. Por
+ enquanto, visualize os eventos na tabela abaixo.
-
+
+ {#if graficosQuery?.data}
+ {@const dados = graficosQuery.data.totalDiasPorTipo}
+ {@const maxDias = Math.max(...dados.map((d) => d.dias), 1)}
+ {@const chartWidth = 800}
+ {@const chartHeight = 350}
+ {@const padding = { top: 20, right: 40, bottom: 80, left: 70 }}
+ {@const barWidth = (chartWidth - padding.left - padding.right) / dados.length - 10}
+ {@const innerHeight = chartHeight - padding.top - padding.bottom}
+ {@const tendencias = graficosQuery.data.tendenciasMensais}
+ {@const tipos = ["atestado_medico", "declaracao_comparecimento", "maternidade", "paternidade", "ferias"]}
+ {@const cores = ["#ef4444", "#f97316", "#ec4899", "#3b82f6", "#10b981"]}
+ {@const nomes = ["Atestado Médico", "Declaração", "Maternidade", "Paternidade", "Férias"]}
+ {@const maxValor = Math.max(
+ ...tendencias.flatMap((t) =>
+ tipos.map((tipo) => t[tipo as keyof typeof t] as number)
+ ),
+ 1
+ )}
+ {@const chartWidth2 = 900}
+ {@const chartHeight2 = 400}
+ {@const padding2 = { top: 20, right: 40, bottom: 80, left: 70 }}
+ {@const innerWidth = chartWidth2 - padding2.left - padding2.right}
+ {@const innerHeight2 = chartHeight2 - padding2.top - padding2.bottom}
+
+
-
- Histórico
-Consulte histórico de atestados e licenças
-
- Em breve
-
-
+
+
+
+
+
+ Total de Dias por Tipo
+
+
+
+ {#each [0, 1, 2, 3, 4, 5] as t}
+ {@const val = Math.round((maxDias / 5) * t)}
+ {@const y = chartHeight - padding.bottom - (val / maxDias) * innerHeight}
+
+
+ {val}
+
+ {/each}
+
+
+
+
+
+
+ {#each dados as item, i}
+ {@const x = padding.left + i * (barWidth + 10) + 5}
+ {@const height = (item.dias / maxDias) * innerHeight}
+ {@const y = chartHeight - padding.bottom - height}
+ {@const colors = ["#ef4444", "#f97316", "#ec4899", "#3b82f6", "#10b981"]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if item.dias > 0}
+
+ {item.dias}
+
+ {/if}
+
+
+
+
+ {/each}
+
+
+
+
+ {item.tipo}
+
+
+
+
+
+
+
+
+ Tendências Mensais (Últimos 6 Meses)
+
+
+
+ {#each [0, 1, 2, 3, 4, 5] as t}
+ {@const val = Math.round((maxValor / 5) * t)}
+ {@const y = chartHeight2 - padding2.bottom - (val / maxValor) * innerHeight2}
+
+
+ {val}
+
+ {/each}
+
+
+
+
+
+
+ {#each tipos as tipo, tipoIdx}
+ {@const cor = cores[tipoIdx]}
+
+
+
+
+
+
+
+
+
+ {@const pontos = tendencias.map((t, i) => {
+ const x = padding2.left + (i / (tendencias.length - 1 || 1)) * innerWidth;
+ const valor = t[tipo as keyof typeof t] as number;
+ const y = chartHeight2 - padding2.bottom - (valor / maxValor) * innerHeight2;
+ return { x, y, valor };
+ })}
+
+
+ {#if pontos.length > 0}
+ {@const pathArea = `M ${pontos[0].x} ${chartHeight2 - padding2.bottom} ` + pontos.map(p => `L ${p.x} ${p.y}`).join(' ') + ` L ${pontos[pontos.length - 1].x} ${chartHeight2 - padding2.bottom} Z`}
+
+ {/if}
+
+
+ {#if pontos.length > 1}
+ `${p.x},${p.y}`).join(' ')}
+ fill="none"
+ stroke={cor}
+ stroke-width="3"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+ {/if}
+
+
+ {#each pontos as ponto, pontoIdx}
+
+
+ {nomes[tipoIdx]}: {ponto.valor} dias em {tendencias[pontoIdx]?.mes || ""}
+ {/each}
+ {/each}
+
+
+ {#each tendencias as t, i}
+ {@const x = padding2.left + (i / (tendencias.length - 1 || 1)) * innerWidth}
+
+
+ {/each}
+
+
+
+
+
+
+ {t.mes}
+
+
+
+ {#each tipos as tipo, idx}
+
+
+
+ {nomes[idx]}
+
+ {/each}
+
+
+ {/if}
+
+
+
+ Funcionários Atualmente Afastados
+ {#if graficosQuery.data.funcionariosAfastados.length > 0} +
+
+
+
+
+ {:else}
+ | Funcionário | +Tipo | +Data Início | +Data Fim | +
|---|---|---|---|
| {item.funcionarioNome} | ++ + {item.tipo === "atestado_medico" + ? "Atestado Médico" + : item.tipo === "declaracao_comparecimento" + ? "Declaração" + : item.tipo === "maternidade" + ? "Licença Maternidade" + : item.tipo === "paternidade" + ? "Licença Paternidade" + : item.tipo} + + | +{formatarData(item.dataInicio)} | +{formatarData(item.dataFim)} | +
+ Nenhum funcionário afastado no momento
+
+ {/if}
+
-
- Estatísticas
-Visualize estatísticas e relatórios
-
- Em breve
+
Registros
+
+
+
+
+ {#if registrosFiltrados.atestados.length === 0 && registrosFiltrados.licencas.length === 0}
+
| Funcionário | +Tipo | +Data Início | +Data Fim | +Dias | +Status | +Ações | +
|---|---|---|---|---|---|---|
| {atestado.funcionario?.nome || "-"} | ++ + {atestado.tipo === "atestado_medico" + ? "Atestado Médico" + : "Declaração"} + + | +{formatarData(atestado.dataInicio)} | +{formatarData(atestado.dataFim)} | +{atestado.dias} | ++ + {atestado.status === "ativo" ? "Ativo" : "Finalizado"} + + | +
+
+ {#if atestado.documentoId}
+
+ |
+
| {licenca.funcionario?.nome || "-"} | ++ + Licença{" "} + {licenca.tipo === "maternidade" + ? "Maternidade" + : "Paternidade"} + {licenca.ehProrrogacao ? " (Prorrogação)" : ""} + + | +{formatarData(licenca.dataInicio)} | +{formatarData(licenca.dataFim)} | +{licenca.dias} | ++ + {licenca.status === "ativo" ? "Ativo" : "Finalizado"} + + | +
+
+ {#if licenca.documentoId}
+
+ |
+
+ Nenhum registro encontrado
+
+ {/if}
+
+ {:else if abaAtiva === "declaracao"}
+
+
+
+ Registrar Atestado Médico
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ atestadoMedico.documentoId = await handleDocumentoUpload(
+ file
+ );
+ }}
+ onRemove={async () => {
+ atestadoMedico.documentoId = undefined;
+ }}
+ />
+
+
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if salvandoAtestado}
+
+ Salvando...
+ {:else}
+ Salvar
+ {/if}
+
+
+
+
+ {:else if abaAtiva === "maternidade"}
+
+
+
+ Registrar Declaração de Comparecimento
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ declaracao.documentoId = await handleDocumentoUpload(file);
+ }}
+ onRemove={async () => {
+ declaracao.documentoId = undefined;
+ }}
+ />
+
+
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if salvandoDeclaracao}
+
+ Salvando...
+ {:else}
+ Salvar
+ {/if}
+
+
+
+
+ {:else if abaAtiva === "paternidade"}
+
+
+
+ Registrar Licença Maternidade
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if licencaMaternidade.ehProrrogacao}
+
+
+
+
+ {/if}
+
+
+ {
+ licencaMaternidade.documentoId = await handleDocumentoUpload(
+ file
+ );
+ }}
+ onRemove={async () => {
+ licencaMaternidade.documentoId = undefined;
+ }}
+ />
+
+
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if salvandoMaternidade}
+
+ Salvando...
+ {:else}
+ Salvar
+ {/if}
+
+
+
+
+ {/if}
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index b3d1c17..a0b2e5f 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -10,6 +10,7 @@
import type * as actions_email from "../actions/email.js";
import type * as actions_smtp from "../actions/smtp.js";
+import type * as atestadosLicencas from "../atestadosLicencas.js";
import type * as autenticacao from "../autenticacao.js";
import type * as auth_utils from "../auth/utils.js";
import type * as chat from "../chat.js";
@@ -38,6 +39,7 @@ import type * as templatesMensagens from "../templatesMensagens.js";
import type * as times from "../times.js";
import type * as todos from "../todos.js";
import type * as usuarios from "../usuarios.js";
+import type * as utils_getClientIP from "../utils/getClientIP.js";
import type * as verificarMatriculas from "../verificarMatriculas.js";
import type {
@@ -57,6 +59,7 @@ import type {
declare const fullApi: ApiFromModules<{
"actions/email": typeof actions_email;
"actions/smtp": typeof actions_smtp;
+ atestadosLicencas: typeof atestadosLicencas;
autenticacao: typeof autenticacao;
"auth/utils": typeof auth_utils;
chat: typeof chat;
@@ -85,6 +88,7 @@ declare const fullApi: ApiFromModules<{
times: typeof times;
todos: typeof todos;
usuarios: typeof usuarios;
+ "utils/getClientIP": typeof utils_getClientIP;
verificarMatriculas: typeof verificarMatriculas;
}>;
declare const fullApiWithMounts: typeof fullApi;
diff --git a/packages/backend/convex/atestadosLicencas.ts b/packages/backend/convex/atestadosLicencas.ts
new file mode 100644
index 0000000..c326445
--- /dev/null
+++ b/packages/backend/convex/atestadosLicencas.ts
@@ -0,0 +1,1048 @@
+import { v } from "convex/values";
+import { mutation, query } from "./_generated/server";
+import { Id, Doc } from "./_generated/dataModel";
+import type { QueryCtx, MutationCtx } from "./_generated/server";
+import { registrarAtividade } from "./logsAtividades";
+
+// ========== HELPERS ==========
+
+/**
+ * Helper function para obter usuário autenticado
+ */
+async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
+ const identity = await ctx.auth.getUserIdentity();
+ let usuarioAtual = null;
+
+ if (identity && identity.email) {
+ usuarioAtual = await ctx.db
+ .query("usuarios")
+ .withIndex("by_email", (q) => q.eq("email", identity.email!))
+ .first();
+ }
+
+ if (!usuarioAtual) {
+ const sessaoAtiva = await ctx.db
+ .query("sessoes")
+ .filter((q) => q.eq(q.field("ativo"), true))
+ .order("desc")
+ .first();
+
+ if (sessaoAtiva) {
+ usuarioAtual = await ctx.db.get(sessaoAtiva.usuarioId);
+ }
+ }
+
+ return usuarioAtual;
+}
+
+/**
+ * Helper para calcular dias entre duas datas
+ */
+function calcularDias(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;
+ return diffDays;
+}
+
+// ========== QUERIES ==========
+
+/**
+ * Listar todos os atestados e licenças com detalhes do funcionário
+ */
+export const listarTodos = query({
+ args: {},
+ handler: async (ctx) => {
+ try {
+ const [atestados, licencas] = await Promise.all([
+ ctx.db.query("atestados").collect(),
+ ctx.db.query("licencas").collect(),
+ ]);
+
+ const atestadosComDetalhes = await Promise.all(
+ atestados.map(async (a) => {
+ try {
+ const funcionario = await ctx.db.get(a.funcionarioId);
+ const criadoPor = await ctx.db.get(a.criadoPor);
+ return {
+ ...a,
+ funcionario,
+ criadoPorNome: criadoPor?.nome || "Sistema",
+ dias: calcularDias(a.dataInicio, a.dataFim),
+ status: new Date(a.dataFim) >= new Date() ? "ativo" : "finalizado",
+ };
+ } catch (error) {
+ console.error("Erro ao buscar detalhes do atestado:", error);
+ return {
+ ...a,
+ funcionario: null,
+ criadoPorNome: "Sistema",
+ dias: calcularDias(a.dataInicio, a.dataFim),
+ status: new Date(a.dataFim) >= new Date() ? "ativo" : "finalizado",
+ };
+ }
+ })
+ );
+
+ const licencasComDetalhes = await Promise.all(
+ licencas.map(async (l) => {
+ try {
+ const funcionario = await ctx.db.get(l.funcionarioId);
+ const criadoPor = await ctx.db.get(l.criadoPor);
+ const licencaOriginal = l.licencaOriginalId
+ ? await ctx.db.get(l.licencaOriginalId)
+ : null;
+ return {
+ ...l,
+ funcionario,
+ criadoPorNome: criadoPor?.nome || "Sistema",
+ licencaOriginal,
+ dias: calcularDias(l.dataInicio, l.dataFim),
+ status: new Date(l.dataFim) >= new Date() ? "ativo" : "finalizado",
+ };
+ } catch (error) {
+ console.error("Erro ao buscar detalhes da licença:", error);
+ return {
+ ...l,
+ funcionario: null,
+ criadoPorNome: "Sistema",
+ licencaOriginal: null,
+ dias: calcularDias(l.dataInicio, l.dataFim),
+ status: new Date(l.dataFim) >= new Date() ? "ativo" : "finalizado",
+ };
+ }
+ })
+ );
+
+ return {
+ atestados: atestadosComDetalhes.sort(
+ (a, b) => b._creationTime - a._creationTime
+ ),
+ licencas: licencasComDetalhes.sort(
+ (a, b) => b._creationTime - a._creationTime
+ ),
+ };
+ } catch (error) {
+ console.error("Erro em listarTodos:", error);
+ return {
+ atestados: [],
+ licencas: [],
+ };
+ }
+ },
+});
+
+/**
+ * Listar por funcionário específico
+ */
+export const listarPorFuncionario = query({
+ args: { funcionarioId: v.id("funcionarios") },
+ handler: async (ctx, args) => {
+ const [atestados, licencas] = await Promise.all([
+ ctx.db
+ .query("atestados")
+ .withIndex("by_funcionario", (q) =>
+ q.eq("funcionarioId", args.funcionarioId)
+ )
+ .collect(),
+ ctx.db
+ .query("licencas")
+ .withIndex("by_funcionario", (q) =>
+ q.eq("funcionarioId", args.funcionarioId)
+ )
+ .collect(),
+ ]);
+
+ return {
+ atestados: atestados.sort((a, b) => b._creationTime - a._creationTime),
+ licencas: licencas.sort((a, b) => b._creationTime - a._creationTime),
+ };
+ },
+});
+
+/**
+ * Listar por período
+ */
+export const listarPorPeriodo = query({
+ args: {
+ dataInicio: v.string(),
+ dataFim: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const dataInicioObj = new Date(args.dataInicio);
+ const dataFimObj = new Date(args.dataFim);
+
+ const atestados = await ctx.db.query("atestados").collect();
+ const licencas = await ctx.db.query("licencas").collect();
+
+ const atestadosFiltrados = atestados.filter((a) => {
+ const inicio = new Date(a.dataInicio);
+ const fim = new Date(a.dataFim);
+ return (
+ (inicio >= dataInicioObj && inicio <= dataFimObj) ||
+ (fim >= dataInicioObj && fim <= dataFimObj) ||
+ (inicio <= dataInicioObj && fim >= dataFimObj)
+ );
+ });
+
+ const licencasFiltradas = licencas.filter((l) => {
+ const inicio = new Date(l.dataInicio);
+ const fim = new Date(l.dataFim);
+ return (
+ (inicio >= dataInicioObj && inicio <= dataFimObj) ||
+ (fim >= dataInicioObj && fim <= dataFimObj) ||
+ (inicio <= dataInicioObj && fim >= dataFimObj)
+ );
+ });
+
+ return {
+ atestados: atestadosFiltrados,
+ licencas: licencasFiltradas,
+ };
+ },
+});
+
+/**
+ * Obter dados para gráficos
+ */
+export const obterDadosGraficos = query({
+ args: {
+ periodo: v.optional(v.number()), // dias (padrão: 30)
+ },
+ handler: async (ctx, args) => {
+ try {
+ const dias = args.periodo || 30;
+ const dataLimite = Date.now() - dias * 24 * 60 * 60 * 1000;
+
+ const [atestados, licencas] = await Promise.all([
+ ctx.db.query("atestados").collect(),
+ ctx.db.query("licencas").collect(),
+ ]);
+
+ // Filtrar por período
+ const atestadosFiltrados = atestados.filter(
+ (a) => new Date(a.criadoEm) >= new Date(dataLimite)
+ );
+ const licencasFiltradas = licencas.filter(
+ (l) => new Date(l.criadoEm) >= new Date(dataLimite)
+ );
+
+ // 1. Total de dias por tipo (para gráfico de barras)
+ const totalDiasPorTipo: Record
+
+ Registrar Licença Paternidade
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ licencaPaternidade.documentoId = await handleDocumentoUpload(
+ file
+ );
+ }}
+ onRemove={async () => {
+ licencaPaternidade.documentoId = undefined;
+ }}
+ />
+
+
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if salvandoPaternidade}
+
+ Salvando...
+ {:else}
+ Salvar
+ {/if}
+
+
+