From 60b53dac7440df5024d68457478cadadb75a7837 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 15 Dec 2025 11:50:51 -0300 Subject: [PATCH 01/21] refactor: update fichaPontoPDF and processamento to enhance legend styling and accumulate saldo for all days, improving report accuracy --- apps/web/src/lib/utils/fichaPontoPDF.ts | 87 +++++++++++++++---- apps/web/src/lib/utils/ponto/processamento.ts | 29 ++++--- apps/web/src/routes/(dashboard)/+error.svelte | 1 + apps/web/src/routes/+error.svelte | 1 + packages/backend/convex/autenticacao.ts | 16 +++- packages/backend/convex/auth.ts | 26 ++++-- 6 files changed, 124 insertions(+), 36 deletions(-) diff --git a/apps/web/src/lib/utils/fichaPontoPDF.ts b/apps/web/src/lib/utils/fichaPontoPDF.ts index 9f98f87..7c0a09c 100644 --- a/apps/web/src/lib/utils/fichaPontoPDF.ts +++ b/apps/web/src/lib/utils/fichaPontoPDF.ts @@ -372,29 +372,59 @@ export function adicionarLegenda(doc: jsPDF, yPosition: number): number { doc.text('LEGENDA', 15, yPosition); yPosition += 10; - const legendaData: Array<[string, string]> = [ - ['Cor de Fundo - Branco', 'Dia normal'], - ['Cor de Fundo - Azul Claro', 'Dia com atestado médico'], - ['Cor de Fundo - Amarelo Claro', 'Dia com ausência aprovada'], - ['Cor de Fundo - Verde Claro', 'Dia abonado'], - ['Cor de Fundo - Cinza Claro', 'Dia não computado (dispensa/férias)'], - ['Cor de Fundo - Laranja Claro', 'Dia com inconsistência'], - ['Texto Verde', 'Saldo positivo / Registro marcado'], - ['Texto Vermelho', 'Saldo negativo / Registro não marcado'], - ['✓', 'Registro marcado'], - ['✗', 'Registro não marcado'], - ['⚠', 'Inconsistência detectada'], - ['🏥', 'Atestado médico'], - ['🚫', 'Ausência'], - ['📋', 'Licença'], - ['✅', 'Abonado'], - ['⏸', 'Não computado'] + // Corpo da legenda com cores aplicadas e siglas intuitivas + const legendaData: Array< + [ + string | { content: string; styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string } }, + string + ] + > = [ + [ + { content: 'Fundo Branco (DN)', styles: { fillColor: [255, 255, 255] } }, + 'Dia normal' + ], + [ + { content: 'Fundo Azul Claro (AT)', styles: { fillColor: [230, 240, 255] } }, + 'Dia com atestado médico' + ], + [ + { content: 'Fundo Amarelo Claro (AUS)', styles: { fillColor: [255, 255, 230] } }, + 'Dia com ausência aprovada' + ], + [ + { content: 'Fundo Verde Claro (ABO)', styles: { fillColor: [230, 255, 230] } }, + 'Dia abonado' + ], + [ + { content: 'Fundo Cinza Claro (NC)', styles: { fillColor: [240, 240, 240] } }, + 'Dia não computado (dispensa/férias)' + ], + [ + { content: 'Fundo Laranja Claro (INC)', styles: { fillColor: [255, 240, 230] } }, + 'Dia com inconsistência' + ], + [ + { content: 'Texto Verde', styles: { textColor: [0, 128, 0], fontStyle: 'bold' } }, + 'Saldo positivo / Registro marcado' + ], + [ + { content: 'Texto Vermelho', styles: { textColor: [200, 0, 0], fontStyle: 'bold' } }, + 'Saldo negativo / Registro não marcado' + ], + ['RM', 'Registro marcado'], + ['RNM', 'Registro não marcado'], + ['INC', 'Inconsistência detectada'], + ['AT', 'Atestado médico'], + ['AUS', 'Ausência'], + ['LIC', 'Licença'], + ['ABO', 'Abonado'], + ['NC', 'Não computado'] ]; autoTable(doc, { startY: yPosition, head: [['Símbolo/Cor', 'Significado']], - body: legendaData, + body: legendaData as unknown as Array>, theme: 'striped', headStyles: { fillColor: [60, 60, 60], @@ -410,7 +440,25 @@ export function adicionarLegenda(doc: jsPDF, yPosition: number): number { 1: { cellWidth: 110 } }, margin: { left: 15, right: 15 }, - styles: { cellPadding: 3 } + styles: { cellPadding: 3 }, + didParseCell: (data) => { + // aplicar estilos de cor/texto definidos nas células da primeira coluna + if (data.section === 'body' && data.column.index === 0) { + const raw = data.row.raw?.[0]; + if (raw && typeof raw === 'object' && 'styles' in raw && raw.styles) { + const styles = raw.styles as { fillColor?: number[]; textColor?: number[]; fontStyle?: string }; + if (styles.fillColor) { + data.cell.styles.fillColor = styles.fillColor; + } + if (styles.textColor) { + data.cell.styles.textColor = styles.textColor; + } + if (styles.fontStyle) { + data.cell.styles.fontStyle = styles.fontStyle; + } + } + } + } }); const finalYLegenda = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10; @@ -446,3 +494,4 @@ export function adicionarRodape(doc: jsPDF): void { + diff --git a/apps/web/src/lib/utils/ponto/processamento.ts b/apps/web/src/lib/utils/ponto/processamento.ts index 63a19a7..8f40993 100644 --- a/apps/web/src/lib/utils/ponto/processamento.ts +++ b/apps/web/src/lib/utils/ponto/processamento.ts @@ -628,25 +628,34 @@ export async function processarDadosFichaPonto( } // Calcular saldo acumulado para cada dia + // Agora consideramos todos os dias que possuem saldo diário, inclusive + // atestados, ausências e dias não computados, para que o resumo do período + // reflita qualquer trabalho realizado e a carga horária esperada. let saldoAcumulado = 0; for (const dia of diasProcessados) { - if (dia.computado && dia.saldoDiario) { + if (dia.saldoDiario) { saldoAcumulado += dia.saldoDiario.diferencaMinutos; } dia.saldoAcumulado = saldoAcumulado; } // Calcular resumo com formatações - const totalHorasTrabalhadas = diasProcessados - .filter((d) => d.computado) - .reduce((acc, d) => acc + (d.saldoDiario?.trabalhadoMinutos || 0), 0); - const totalHorasEsperadas = diasProcessados - .filter((d) => d.computado) - .reduce((acc, d) => acc + (d.saldoDiario?.esperadoMinutos || 0), 0); - const diferencaTotal = diasProcessados - .filter((d) => d.computado) - .reduce((acc, d) => acc + (d.saldoDiario?.diferencaMinutos || 0), 0); + // Total de horas trabalhadas e esperadas passa a considerar todos os dias, + // não apenas os marcados como "computados", para que trechos trabalhados + // em dias de ausência/dispensa também apareçam no resumo. + const totalHorasTrabalhadas = diasProcessados.reduce( + (acc, d) => acc + (d.saldoDiario?.trabalhadoMinutos || 0), + 0 + ); + const totalHorasEsperadas = diasProcessados.reduce( + (acc, d) => acc + (d.saldoDiario?.esperadoMinutos || 0), + 0 + ); + const diferencaTotal = diasProcessados.reduce( + (acc, d) => acc + (d.saldoDiario?.diferencaMinutos || 0), + 0 + ); const saldoPeriodo = diferencaTotal; const saldoFinal = diasProcessados.length > 0 ? diasProcessados[diasProcessados.length - 1]!.saldoAcumulado : 0; diff --git a/apps/web/src/routes/(dashboard)/+error.svelte b/apps/web/src/routes/(dashboard)/+error.svelte index 1c780c7..b17b2df 100644 --- a/apps/web/src/routes/(dashboard)/+error.svelte +++ b/apps/web/src/routes/(dashboard)/+error.svelte @@ -85,3 +85,4 @@ + diff --git a/apps/web/src/routes/+error.svelte b/apps/web/src/routes/+error.svelte index ae3a9ab..daecb76 100644 --- a/apps/web/src/routes/+error.svelte +++ b/apps/web/src/routes/+error.svelte @@ -85,3 +85,4 @@ + diff --git a/packages/backend/convex/autenticacao.ts b/packages/backend/convex/autenticacao.ts index d5b773e..0f1c381 100644 --- a/packages/backend/convex/autenticacao.ts +++ b/packages/backend/convex/autenticacao.ts @@ -1,6 +1,18 @@ import { v } from 'convex/values'; -import { mutation } from './_generated/server'; +import { mutation, type MutationCtx } from './_generated/server'; import { authComponent, updatePassword } from './auth'; +import type { GenericCtx } from '@convex-dev/better-auth'; +import type { DataModel } from './_generated/dataModel'; + +/** + * Helper para converter MutationCtx para GenericCtx do better-auth + * Os tipos são estruturalmente compatíveis, apenas há diferença nas definições de tipo + */ +function toGenericCtx(ctx: MutationCtx): GenericCtx { + // Os tipos são estruturalmente idênticos, apenas há diferença nas definições de tipo + // entre a versão do Convex usada pelo projeto e a usada pelo @convex-dev/better-auth + return ctx as unknown as GenericCtx; +} /** * Alterar senha do usuário autenticado @@ -19,7 +31,7 @@ export const alterarSenha = mutation({ handler: async (ctx, args) => { try { // Verificar se o usuário está autenticado - const authUser = await authComponent.safeGetAuthUser(ctx); + const authUser = await authComponent.safeGetAuthUser(toGenericCtx(ctx)); if (!authUser) { return { sucesso: false as const, diff --git a/packages/backend/convex/auth.ts b/packages/backend/convex/auth.ts index 74bcde5..9c1c877 100644 --- a/packages/backend/convex/auth.ts +++ b/packages/backend/convex/auth.ts @@ -3,7 +3,7 @@ import { convex } from '@convex-dev/better-auth/plugins'; import { betterAuth } from 'better-auth'; import { components } from './_generated/api'; import type { DataModel } from './_generated/dataModel'; -import { type MutationCtx, type QueryCtx, query } from './_generated/server'; +import { type MutationCtx, type QueryCtx, type ActionCtx, query } from './_generated/server'; // Usar SITE_URL se disponível, caso contrário usar CONVEX_SITE_URL ou um valor padrão const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL || 'http://localhost:5173'; @@ -14,6 +14,22 @@ console.log('siteUrl:', siteUrl); // as well as helper methods for general use. export const authComponent = createClient(components.betterAuth); +/** + * Helper type para converter contextos do Convex para GenericCtx do better-auth + * Isso resolve incompatibilidade de tipos entre versões do Convex sem usar 'any' + */ +type ConvexCtx = QueryCtx | MutationCtx | ActionCtx; + +/** + * Função helper para converter contexto do Convex para GenericCtx do better-auth + * Os tipos são estruturalmente compatíveis, apenas há diferença nas definições de tipo + */ +function toGenericCtx(ctx: ConvexCtx): GenericCtx { + // Os tipos são estruturalmente idênticos, apenas há diferença nas definições de tipo + // entre a versão do Convex usada pelo projeto e a usada pelo @convex-dev/better-auth + return ctx as unknown as GenericCtx; +} + export const createAuth = ( ctx: GenericCtx, { optionsOnly } = { optionsOnly: false } @@ -45,7 +61,7 @@ export const getCurrentUser = query({ args: {}, handler: async (ctx) => { try { - const authUser = await authComponent.safeGetAuthUser(ctx); + const authUser = await authComponent.safeGetAuthUser(toGenericCtx(ctx)); if (!authUser) { return; } @@ -83,7 +99,7 @@ export const getCurrentUser = query({ }); export const getCurrentUserFunction = async (ctx: QueryCtx | MutationCtx) => { - const authUser = await authComponent.safeGetAuthUser(ctx); + const authUser = await authComponent.safeGetAuthUser(toGenericCtx(ctx)); if (!authUser) { return; } @@ -102,7 +118,7 @@ export const createAuthUser = async ( ctx: MutationCtx, args: { nome: string; email: string; password: string } ) => { - const { auth, headers } = await authComponent.getAuth(createAuth, ctx); + const { auth, headers } = await authComponent.getAuth(createAuth, toGenericCtx(ctx)); const result = await auth.api.signUpEmail({ headers, @@ -120,7 +136,7 @@ export const updatePassword = async ( ctx: MutationCtx, args: { newPassword: string; currentPassword: string } ) => { - const { auth, headers } = await authComponent.getAuth(createAuth, ctx); + const { auth, headers } = await authComponent.getAuth(createAuth, toGenericCtx(ctx)); await auth.api.changePassword({ headers, -- 2.49.1 From 0cbae42df506291347e36e36b2eec841ed33afcc Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 16 Dec 2025 08:55:06 -0300 Subject: [PATCH 02/21] feat: add mutation to exclude absence requests and enhance point registration by blocking entries during approved absences --- packages/backend/convex/ausencias.ts | 45 ++++++++++++++++++++++++++++ packages/backend/convex/pontos.ts | 9 ++++++ 2 files changed, 54 insertions(+) diff --git a/packages/backend/convex/ausencias.ts b/packages/backend/convex/ausencias.ts index 2155289..bb3f6a8 100644 --- a/packages/backend/convex/ausencias.ts +++ b/packages/backend/convex/ausencias.ts @@ -922,3 +922,48 @@ export const marcarComoLida = mutation({ return null; } }); + +// Mutation: Excluir solicitação de ausência +export const excluirSolicitacao = mutation({ + args: { + solicitacaoId: v.id('solicitacoesAusencias'), + usuarioId: v.id('usuarios') + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'ausencias', + acao: 'reprovar' + }); + + const solicitacao = await ctx.db.get(args.solicitacaoId); + if (!solicitacao) { + throw new Error('Solicitação não encontrada'); + } + + // Apenas solicitações ainda não processadas podem ser excluídas + if (solicitacao.status !== 'aguardando_aprovacao') { + throw new Error('Apenas solicitações pendentes podem ser excluídas'); + } + + // Verificar se o usuário é o criador original da solicitação + const usuario = await ctx.db.get(args.usuarioId); + if (!usuario) { + throw new Error('Usuário não encontrado'); + } + + const usuarioEhFuncionario = usuario.funcionarioId === solicitacao.funcionarioId; + const gestorIdDoFuncionario = await encontrarGestorDoFuncionario( + ctx, + solicitacao.funcionarioId + ); + const usuarioEhGestor = gestorIdDoFuncionario === args.usuarioId; + + if (!usuarioEhFuncionario && !usuarioEhGestor) { + throw new Error('Você não tem permissão para excluir esta solicitação'); + } + + await ctx.db.delete(args.solicitacaoId); + return null; + } +}); diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 7b7f488..332a0b0 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -605,6 +605,15 @@ export const registrarPonto = mutation({ const dia = String(dataObj.getUTCDate()).padStart(2, '0'); const data = `${ano}-${mes}-${dia}`; + // Bloquear registro de ponto quando houver ausência aprovada ativa na data + const ausenciaInfo = await verificarAusenciaAprovada(ctx, usuario.funcionarioId, data); + if (ausenciaInfo.temAusencia) { + throw new Error( + ausenciaInfo.motivo || + 'Não é possível registrar ponto: existe uma ausência aprovada ativa para esta data.' + ); + } + // Verificar se já existe registro no mesmo minuto const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined const registrosMinuto = await ctx.db -- 2.49.1 From d10eddca398ae503ed1ef4236fc1efb5b7851a1b Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Thu, 18 Dec 2025 14:56:00 -0300 Subject: [PATCH 03/21] refactor: update terminology from "PC Local" to "Servidor interno" across components and documentation for consistency in time synchronization references --- .../lib/components/ponto/ComprovantePonto.svelte | 2 +- .../src/lib/components/ponto/RegistroPonto.svelte | 6 +++--- .../components/ponto/RelogioSincronizado.svelte | 14 +++++++------- .../src/lib/utils/ponto/pdf/geradorDetalhesPDF.ts | 2 +- .../recursos-humanos/registro-pontos/+page.svelte | 2 +- apps/web/src/routes/(dashboard)/ti/+page.svelte | 2 +- .../ti/configuracoes-relogio/+page.svelte | 14 +++++++------- packages/backend/convex/configuracaoRelogio.ts | 4 ++-- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte index 4bb6a3c..b10cb5c 100644 --- a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte +++ b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte @@ -228,7 +228,7 @@ ['Data e Hora', dataHora], ['Status', registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'], ['Tolerância', `${registro.toleranciaMinutos} minutos`], - ['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'] + ['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (Servidor interno)'] ]; doc.setFontSize(12); diff --git a/apps/web/src/lib/components/ponto/RegistroPonto.svelte b/apps/web/src/lib/components/ponto/RegistroPonto.svelte index 6371e05..3a76e19 100644 --- a/apps/web/src/lib/components/ponto/RegistroPonto.svelte +++ b/apps/web/src/lib/components/ponto/RegistroPonto.svelte @@ -294,7 +294,7 @@ } } } else { - // Usar relógio do PC (sem sincronização com servidor) + // Usar servidor interno (sem sincronização com servidor) timestampBase = Date.now(); } @@ -496,7 +496,7 @@ } } } else { - // Usar relógio do PC (sem sincronização com servidor) + // Usar servidor interno (sem sincronização com servidor) timestampBase = Date.now(); } @@ -738,7 +738,7 @@ yPosition += 6; doc.text( - `Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`, + `Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (Servidor interno)'}`, 15, yPosition ); diff --git a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte index 9f09cc5..df03dea 100644 --- a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte +++ b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte @@ -55,21 +55,21 @@ timestampBase = obterTempoPC(); sincronizado = false; usandoServidorExterno = false; - erro = 'Usando relógio do PC (falha na sincronização)'; + erro = 'Usando servidor interno (falha na sincronização)'; } else { // Mesmo sem fallback configurado, usar PC como última opção timestampBase = obterTempoPC(); sincronizado = false; usandoServidorExterno = false; - erro = 'Usando relógio do PC (servidor indisponível)'; + erro = 'Usando servidor interno (servidor indisponível)'; } } } else { - // Usar relógio do PC (sem sincronização com servidor) + // Usar servidor interno (sem sincronização com servidor) timestampBase = obterTempoPC(); sincronizado = false; usandoServidorExterno = false; - erro = 'Usando relógio do PC'; + erro = 'Usando servidor interno'; } // Aplicar GMT offset ao timestamp UTC @@ -102,10 +102,10 @@ } onMount(async () => { - // Inicializar com relógio do PC imediatamente para não bloquear a interface + // Inicializar com servidor interno imediatamente para não bloquear a interface tempoAtual = new Date(obterTempoPC()); sincronizado = false; - erro = 'Usando relógio do PC'; + erro = 'Usando servidor interno'; // Atualizar display a cada segundo intervalId = setInterval(atualizarRelogio, 1000); // Sincronizar em background (não bloquear) após um pequeno delay para garantir que a UI está renderizada @@ -194,7 +194,7 @@ {erro} {:else} - Usando relógio do PC + Usando servidor interno {/if} diff --git a/apps/web/src/lib/utils/ponto/pdf/geradorDetalhesPDF.ts b/apps/web/src/lib/utils/ponto/pdf/geradorDetalhesPDF.ts index 3318b34..f68f1f8 100644 --- a/apps/web/src/lib/utils/ponto/pdf/geradorDetalhesPDF.ts +++ b/apps/web/src/lib/utils/ponto/pdf/geradorDetalhesPDF.ts @@ -125,7 +125,7 @@ export async function imprimirDetalhesRegistro( ['Data e Hora', dataHora], ['Status', registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'], ['Tolerância', `${registro.toleranciaMinutos} minutos`], - ['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'] + ['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (Servidor interno)'] ]; if (registro.justificativa) { diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte index 26902f4..4f37d03 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/registro-pontos/+page.svelte @@ -3149,7 +3149,7 @@ ['Data e Hora', dataHora], ['Status', registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'], ['Tolerância', `${registro.toleranciaMinutos} minutos`], - ['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'] + ['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (Servidor interno)'] ]; if (registro.justificativa) { diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index a0cf4f4..0a9b10b 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -272,7 +272,7 @@ { title: 'Configurações de Relógio', description: - 'Configure a sincronização de tempo com servidor NTP ou use o relógio do PC como fallback.', + 'Configure a sincronização de tempo com servidor NTP ou use o servidor interno como fallback.', ctaLabel: 'Configurar Relógio', href: '/(dashboard)/ti/configuracoes-relogio', palette: 'info', diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte index 039f7df..f5ef5ec 100644 --- a/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-relogio/+page.svelte @@ -69,7 +69,7 @@ // Date.now() retorna timestamp UTC (milissegundos desde epoch) timestampUTC = Date.now(); timestampOriginal = timestampUTC; - // Atualizar status para indicar que está usando relógio do PC + // Atualizar status para indicar que está usando servidor interno statusSincronizacao = { ultimaSincronizacao: Date.now(), offsetSegundos: null, @@ -305,7 +305,7 @@ 'success', resultado.usandoServidorExterno ? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s | Horário atual: ${horarioAtual}` - : 'Usando relógio do PC (servidor externo não disponível)' + : 'Usando servidor interno (servidor externo não disponível)' ); } else { mostrarMensagem('error', 'Falha na sincronização'); @@ -424,7 +424,7 @@ {statusSincronizacao?.usandoServidorExterno ? `Servidor NTP (${servidorNTP})` - : 'Relógio do PC'} + : 'Servidor interno'} {#if statusSincronizacao?.offsetSegundos !== null && statusSincronizacao?.offsetSegundos !== undefined} @@ -527,7 +527,7 @@
Sincronizar com servidor de tempo externo (NTP) em vez de usar o relógio do PCSincronizar com servidor de tempo externo (NTP) em vez de usar o servidor interno
@@ -577,11 +577,11 @@
Se marcado, o sistema usará o relógio do PC caso não consiga sincronizar com o servidor + >Se marcado, o sistema usará o servidor interno caso não consiga sincronizar com o servidor externo
@@ -639,7 +639,7 @@
Fonte de Tempo
- {statusSincronizacao.usandoServidorExterno ? 'Servidor NTP' : 'Relógio do PC'} + {statusSincronizacao.usandoServidorExterno ? 'Servidor NTP' : 'Servidor interno'}
diff --git a/packages/backend/convex/configuracaoRelogio.ts b/packages/backend/convex/configuracaoRelogio.ts index 1aff1b6..8426b21 100644 --- a/packages/backend/convex/configuracaoRelogio.ts +++ b/packages/backend/convex/configuracaoRelogio.ts @@ -279,8 +279,8 @@ export const sincronizarTempo = action({ // Sempre usar fallback como última opção, mesmo se desabilitado // Isso evita que o sistema trave completamente se o servidor externo não estiver disponível const aviso: string = config.fallbackParaPC - ? 'Falha ao sincronizar com servidor externo, usando relógio do PC' - : 'Falha ao sincronizar com servidor externo. Fallback desabilitado, mas usando relógio do PC como última opção.'; + ? 'Falha ao sincronizar com servidor externo, usando servidor interno' + : 'Falha ao sincronizar com servidor externo. Fallback desabilitado, mas usando servidor interno como última opção.'; console.warn('Erro ao sincronizar tempo com servidor externo:', error); -- 2.49.1 From 367cda7b953217e8c731e5cc125ff5e56b22df2b Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Thu, 18 Dec 2025 16:21:08 -0300 Subject: [PATCH 04/21] feat: implement almoxarifado features including new category in recursos-humanos, configuration options in TI, and backend support for inventory management, enhancing user navigation and system functionality --- .../components/almoxarifado/AlertaCard.svelte | 89 ++ .../almoxarifado/EstoqueGauge.svelte | 51 + .../almoxarifado/HistoricoTimeline.svelte | 97 ++ .../almoxarifado/MaterialCard.svelte | 68 + .../almoxarifado/MovimentacaoForm.svelte | 219 +++ .../(dashboard)/recursos-humanos/+page.svelte | 57 +- .../almoxarifado/+page.svelte | 196 +++ .../almoxarifado/alertas/+page.svelte | 242 ++++ .../almoxarifado/materiais/+page.svelte | 248 ++++ .../materiais/cadastro/+page.svelte | 319 +++++ .../almoxarifado/movimentacoes/+page.svelte | 550 ++++++++ .../almoxarifado/relatorios/+page.svelte | 313 +++++ .../almoxarifado/requisicoes/+page.svelte | 463 +++++++ .../src/routes/(dashboard)/ti/+page.svelte | 14 + .../configuracoes-almoxarifado/+page.svelte | 357 +++++ packages/backend/convex/_generated/api.d.ts | 2 + packages/backend/convex/almoxarifado.ts | 1179 +++++++++++++++++ .../convex/configuracaoAlmoxarifado.ts | 165 +++ packages/backend/convex/crons.ts | 8 + packages/backend/convex/permissoesAcoes.ts | 43 + packages/backend/convex/schema.ts | 4 +- .../backend/convex/tables/almoxarifado.ts | 149 +++ 22 files changed, 4831 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/components/almoxarifado/AlertaCard.svelte create mode 100644 apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte create mode 100644 apps/web/src/lib/components/almoxarifado/HistoricoTimeline.svelte create mode 100644 apps/web/src/lib/components/almoxarifado/MaterialCard.svelte create mode 100644 apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/alertas/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/cadastro/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/movimentacoes/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/relatorios/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/requisicoes/+page.svelte create mode 100644 apps/web/src/routes/(dashboard)/ti/configuracoes-almoxarifado/+page.svelte create mode 100644 packages/backend/convex/almoxarifado.ts create mode 100644 packages/backend/convex/configuracaoAlmoxarifado.ts create mode 100644 packages/backend/convex/tables/almoxarifado.ts diff --git a/apps/web/src/lib/components/almoxarifado/AlertaCard.svelte b/apps/web/src/lib/components/almoxarifado/AlertaCard.svelte new file mode 100644 index 0000000..2b7d8c3 --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/AlertaCard.svelte @@ -0,0 +1,89 @@ + + +
+
+
+
+
+ +

{materialNome}

+
+ {#if materialCodigo} +

Código: {materialCodigo}

+ {/if} +
+ {getTipoLabel(alerta.tipo)} +
+ +
+ +
+
+

Quantidade Atual

+

{alerta.quantidadeAtual}

+
+
+

Quantidade Mínima

+

{alerta.quantidadeMinima}

+
+
+ +
+

Faltam

+

{diferenca} unidades

+
+ +
+ Criado em: {new Date(alerta.criadoEm).toLocaleString('pt-BR')} +
+
+
+ + diff --git a/apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte b/apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte new file mode 100644 index 0000000..09e2dbb --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte @@ -0,0 +1,51 @@ + + +
+
+ Estoque + + {estoqueAtual} {unidadeMedida} + +
+
+
+
+
+ Mín: {estoqueMinimo} + {#if estoqueMaximo} + Máx: {estoqueMaximo} + {/if} +
+
+ + diff --git a/apps/web/src/lib/components/almoxarifado/HistoricoTimeline.svelte b/apps/web/src/lib/components/almoxarifado/HistoricoTimeline.svelte new file mode 100644 index 0000000..6e34da9 --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/HistoricoTimeline.svelte @@ -0,0 +1,97 @@ + + +
+ {#each historico as item, index} +
+ + {#if index < historico.length - 1} +
+
+ +
+
+
+ {:else} +
+ +
+ {/if} + + +
+
+
+
+
+ + {item.usuarioNome || 'Usuário'} +
+ + {getAcaoLabel(item.acao)} + +
+

+ {new Date(item.timestamp).toLocaleString('pt-BR')} +

+ {#if item.observacoes} +
+ +

{item.observacoes}

+
+ {/if} +
+
+
+
+ {/each} +
+ + diff --git a/apps/web/src/lib/components/almoxarifado/MaterialCard.svelte b/apps/web/src/lib/components/almoxarifado/MaterialCard.svelte new file mode 100644 index 0000000..8225960 --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/MaterialCard.svelte @@ -0,0 +1,68 @@ + + +
+
+
+
+
+ +

{material.nome}

+
+

Código: {material.codigo}

+ {#if material.descricao} +

{material.descricao}

+ {/if} +
+ {material.categoria} + {#if material.ativo} + Ativo + {:else} + Inativo + {/if} +
+
+
+ +
+ +
+
+

Estoque Atual

+
+

+ {material.estoqueAtual} +

+ {material.unidadeMedida} + {#if material.estoqueAtual <= material.estoqueMinimo} + + {/if} +
+
+
+

Mínimo

+

{material.estoqueMinimo} {material.unidadeMedida}

+
+
+
+
+ + diff --git a/apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte b/apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte new file mode 100644 index 0000000..4ce2fca --- /dev/null +++ b/apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte @@ -0,0 +1,219 @@ + + +
{ e.preventDefault(); handleSubmit(); }}> +
+
+ + +
+ + {#if tipo === 'ajuste'} +
+ + +
+ {:else} +
+ + +
+ {/if} + + {#if tipo === 'entrada'} +
+ + +
+ {:else if tipo === 'saida'} +
+ + +
+
+ + +
+ {/if} + +
+ + +
+ +
+ + +
+
+ +
+ +
+
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte index aad3b70..18df4af 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/+page.svelte @@ -19,7 +19,10 @@ ArrowRight, Clock, XCircle, - TrendingUp + TrendingUp, + Package, + ArrowLeftRight, + AlertTriangle } from 'lucide-svelte'; import type { Component } from 'svelte'; @@ -155,6 +158,58 @@ Icon: List } ] + }, + { + categoria: 'Almoxarifado', + descricao: 'Controle de estoque e gestão de materiais', + Icon: Package, + gradient: 'from-amber-500/10 to-amber-600/20', + accentColor: 'text-amber-600', + bgIcon: 'bg-amber-500/20', + opcoes: [ + { + nome: 'Dashboard', + descricao: 'Visão geral do almoxarifado', + href: '/recursos-humanos/almoxarifado', + Icon: BarChart3 + }, + { + nome: 'Cadastrar Material', + descricao: 'Adicionar novo material ao estoque', + href: '/recursos-humanos/almoxarifado/materiais/cadastro', + Icon: Plus + }, + { + nome: 'Listar Materiais', + descricao: 'Visualizar e gerenciar materiais', + href: '/recursos-humanos/almoxarifado/materiais', + Icon: Package + }, + { + nome: 'Movimentações', + descricao: 'Registrar entradas e saídas', + href: '/recursos-humanos/almoxarifado/movimentacoes', + Icon: ArrowLeftRight + }, + { + nome: 'Requisições', + descricao: 'Gerenciar requisições de material', + href: '/recursos-humanos/almoxarifado/requisicoes', + Icon: ClipboardList + }, + { + nome: 'Alertas', + descricao: 'Visualizar alertas de estoque baixo', + href: '/recursos-humanos/almoxarifado/alertas', + Icon: AlertTriangle + }, + { + nome: 'Relatórios', + descricao: 'Relatórios e estatísticas', + href: '/recursos-humanos/almoxarifado/relatorios', + Icon: BarChart3 + } + ] } ]; diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/+page.svelte new file mode 100644 index 0000000..df710ce --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/+page.svelte @@ -0,0 +1,196 @@ + + +
+ +
+

Almoxarifado

+

+ Controle de estoque e gestão de materiais +

+
+ + + {#if statsQuery.data} +
+
+
+
+ +
+
Total de Materiais
+
+ {statsQuery.data.totalMateriais} +
+
Materiais cadastrados
+
+
+ +
+
+
+ +
+
Materiais Ativos
+
+ {statsQuery.data.totalMateriaisAtivos} +
+
Em estoque
+
+
+ +
+
+
+ +
+
Alertas Ativos
+
+ {statsQuery.data.totalAlertasAtivos} +
+
Estoque baixo
+
+
+ +
+
+
+ +
+
Movimentações
+
+ {statsQuery.data.movimentacoesMes} +
+
Este mês
+
+
+
+ {/if} + + +
+
+
+

+ + Alertas de Estoque +

+ {#if alertasQuery.data && alertasQuery.data.length > 0} +
+ + + + + + + + + + + + {#each alertasQuery.data.slice(0, 5) as alerta} + {@const material = materiaisQuery.data?.find(m => m._id === alerta.materialId)} + + + + + + + + {/each} + +
MaterialTipoQuantidade AtualQuantidade MínimaAções
+ {material?.nome || 'Carregando...'} + + {#if alerta.tipo === 'estoque_zerado'} + Zerado + {:else if alerta.tipo === 'estoque_minimo'} + Mínimo + {:else} + Reposição + {/if} + {alerta.quantidadeAtual}{alerta.quantidadeMinima} + +
+
+
+ +
+ {:else} +
+ + Nenhum alerta ativo no momento! +
+ {/if} +
+
+
+ + +
+ + + + + +
+
+ diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/alertas/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/alertas/+page.svelte new file mode 100644 index 0000000..fa60428 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/alertas/+page.svelte @@ -0,0 +1,242 @@ + + +
+ + + + +
+
+
+ +
+
+

Alertas de Estoque

+

Visualize e gerencie alertas de estoque baixo

+
+
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+
+
+
+ + +
+ +
+ + +
+
+
+
+ + +
+
+ {#if alertasQuery.data && alertasQuery.data.length > 0} +
+ + + + + + + + + + + + + + + {#each alertasQuery.data as alerta} + {@const material = materiaisQuery.data?.find(m => m._id === alerta.materialId)} + {@const diferenca = alerta.quantidadeMinima - alerta.quantidadeAtual} + + + + + + + + + + + {/each} + +
MaterialTipoQuantidade AtualQuantidade MínimaDiferençaStatusDataAções
+
{material?.nome || 'Carregando...'}
+
{material?.codigo || ''}
+
+ + {getTipoLabel(alerta.tipo)} + + +
{alerta.quantidadeAtual}
+
+
{alerta.quantidadeMinima}
+
+
-{diferenca}
+
+ {#if alerta.status === 'ativo'} + Ativo + {:else if alerta.status === 'resolvido'} + Resolvido + {:else} + Ignorado + {/if} + {new Date(alerta.criadoEm).toLocaleDateString('pt-BR')} + {#if alerta.status === 'ativo'} +
+ + +
+ {/if} +
+
+ {:else} +
+ +

Nenhum alerta encontrado

+

+ {#if filtroStatus === 'ativo'} + Não há alertas ativos no momento. Todos os materiais estão com estoque adequado! + {:else} + Não há alertas com os filtros selecionados. + {/if} +

+
+ {/if} +
+
+
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/+page.svelte new file mode 100644 index 0000000..091843c --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/+page.svelte @@ -0,0 +1,248 @@ + + +
+ + + + +
+
+
+
+ +
+
+

Materiais

+

Gerencie o cadastro de materiais do almoxarifado

+
+
+ +
+
+ + +
+
+
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + {#if filtered.length === 0} + + + + {:else} + {#each filtered as material} + + + + + + + + + + + {/each} + {/if} + +
CódigoNomeCategoriaEstoque AtualEstoque MínimoUnidadeStatusAções
+
+ +

Nenhum material encontrado

+
+
+
{material.codigo}
+
+
{material.nome}
+ {#if material.descricao} +
{material.descricao}
+ {/if} +
+ {material.categoria} + +
+ {material.estoqueAtual} + {#if material.estoqueAtual <= material.estoqueMinimo} + + {/if} +
+
{material.estoqueMinimo}{material.unidadeMedida} + {#if material.ativo} + Ativo + {:else} + Inativo + {/if} + +
+ + +
+
+
+ + {#if filtered.length > 0} +
+ Mostrando {filtered.length} de {materiais.length} materiais +
+ {/if} +
+
+
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/cadastro/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/cadastro/+page.svelte new file mode 100644 index 0000000..be7f7ca --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/materiais/cadastro/+page.svelte @@ -0,0 +1,319 @@ + + +
+ + + + +
+
+ +
+ +
+
+

Cadastrar Material

+

Adicione um novo material ao almoxarifado

+
+
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+
+
{ e.preventDefault(); handleSubmit(); }}> +
+ +
+ + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + {#each categoriasComuns as cat} + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+
+
+
+
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/movimentacoes/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/movimentacoes/+page.svelte new file mode 100644 index 0000000..fb71cb6 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/movimentacoes/+page.svelte @@ -0,0 +1,550 @@ + + +
+ + + + +
+
+
+ +
+
+

Movimentações de Estoque

+

Registre entradas, saídas e ajustes de estoque

+
+
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+ + + + +
+ + + {#if abaAtiva === 'entrada'} +
+
+

Registrar Entrada de Material

+
{ e.preventDefault(); registrarEntrada(); }}> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ {:else if abaAtiva === 'saida'} +
+
+

Registrar Saída de Material

+
{ e.preventDefault(); registrarSaida(); }}> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ {:else if abaAtiva === 'ajuste'} +
+
+

Ajustar Estoque

+
+ + Ajustes de estoque devem ser justificados e são registrados no histórico. +
+
{ e.preventDefault(); ajustarEstoque(); }}> +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ {:else if abaAtiva === 'historico'} +
+
+

Histórico de Movimentações

+
+ + + + + + + + + + + + + + {#if movimentacoesQuery.data && movimentacoesQuery.data.length > 0} + {#each movimentacoesQuery.data.slice(0, 50) as mov} + {@const material = materiaisQuery.data?.find(m => m._id === mov.materialId)} + + + + + + + + + + {/each} + {:else} + + + + {/if} + +
DataMaterialTipoQuantidadeAnteriorNovaMotivo
{new Date(mov.data).toLocaleString('pt-BR')}{material?.nome || 'Carregando...'} + {#if mov.tipo === 'entrada'} + Entrada + {:else if mov.tipo === 'saida'} + Saída + {:else} + Ajuste + {/if} + {mov.quantidade}{mov.quantidadeAnterior}{mov.quantidadeNova}{mov.motivo}
+
+ +

Nenhuma movimentação registrada

+
+
+
+
+
+ {/if} +
+ + diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/relatorios/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/relatorios/+page.svelte new file mode 100644 index 0000000..042a243 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/relatorios/+page.svelte @@ -0,0 +1,313 @@ + + +
+ + + + +
+
+
+ +
+
+

Relatórios

+

Estatísticas e relatórios do almoxarifado

+
+
+
+ + + {#if statsQuery.data} +
+
+
+
+ +
+
Total de Materiais
+
{statsQuery.data.totalMateriais}
+
Cadastrados no sistema
+
+
+ +
+
+
+ +
+
Materiais Ativos
+
{statsQuery.data.totalMateriaisAtivos}
+
Em estoque
+
+
+ +
+
+
+ +
+
Alertas Ativos
+
{statsQuery.data.totalAlertasAtivos}
+
Estoque baixo
+
+
+ +
+
+
+ +
+
Movimentações
+
{statsQuery.data.movimentacoesMes}
+
Este mês
+
+
+
+ {/if} + + +
+ +
+
+
+

Materiais por Categoria

+ +
+ {#if materiaisQuery.data && Object.keys(materiaisPorCategoria).length > 0} +
+ {#each Object.entries(materiaisPorCategoria) as [categoria, quantidade]} +
+ {categoria} +
+
+
+
+
+
+ {quantidade} +
+
+ {/each} +
+ {:else} +

Nenhum dado disponível

+ {/if} +
+
+ + +
+
+
+

Movimentações do Mês

+ +
+
+
+
+ + Entradas +
+ {movimentacoesMes.entrada} +
+
+
+ + Saídas +
+ {movimentacoesMes.saida} +
+
+
+ + Ajustes +
+ {movimentacoesMes.ajuste} +
+
+
+
+ + +
+
+
+

Materiais com Estoque Baixo

+ +
+ {#if materiaisQuery.data} + {@const estoqueBaixo = materiaisQuery.data.filter(m => m.estoqueAtual <= m.estoqueMinimo)} + {#if estoqueBaixo.length > 0} +
+ + + + + + + + + + {#each estoqueBaixo.slice(0, 10) as material} + + + + + + {/each} + +
MaterialAtualMínimo
+
{material.nome}
+
{material.codigo}
+
+ {material.estoqueAtual} + {material.estoqueMinimo}
+ {#if estoqueBaixo.length > 10} +

+ E mais {estoqueBaixo.length - 10} materiais... +

+ {/if} +
+ {:else} +
+ + Todos os materiais estão com estoque adequado! +
+ {/if} + {/if} +
+
+ + +
+
+
+

Alertas Recentes

+ +
+ {#if alertasQuery.data && alertasQuery.data.length > 0} +
+ {#each alertasQuery.data.slice(0, 5) as alerta} +
+
+
+ {#if materiaisQuery.data} + {@const material = materiaisQuery.data.find(m => m._id === alerta.materialId)} + {material?.nome || 'Carregando...'} + {/if} +
+
+ {alerta.quantidadeAtual} / {alerta.quantidadeMinima} +
+
+ + {#if alerta.tipo === 'estoque_zerado'} + Zerado + {:else} + Mínimo + {/if} + +
+ {/each} +
+ {:else} +
+ + Nenhum alerta ativo +
+ {/if} +
+
+
+
+ diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/requisicoes/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/requisicoes/+page.svelte new file mode 100644 index 0000000..fea6ff4 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/requisicoes/+page.svelte @@ -0,0 +1,463 @@ + + +
+ + + + +
+
+
+
+ +
+
+

Requisições de Material

+

Gerencie requisições de material dos funcionários

+
+
+ +
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+
+
+ + +
+
+
+ + +
+
+
+ + + + + + + + + + + + + {#if requisicoes.length === 0} + + + + {:else} + {#each requisicoes as requisicao} + {@const solicitante = funcionariosQuery.data?.find(f => f._id === requisicao.solicitanteId)} + {@const setor = setoresQuery.data?.find(s => s._id === requisicao.setorId)} + + + + + + + + + {/each} + {/if} + +
NúmeroSolicitanteSetorStatusDataAções
+
+ +

Nenhuma requisição encontrada

+
+
+
{requisicao.numero}
+
{solicitante?.nome || 'Carregando...'}{setor?.nome || 'Carregando...'} + + {getStatusLabel(requisicao.status)} + + {new Date(requisicao.criadoEm).toLocaleDateString('pt-BR')} +
+ {#if requisicao.status === 'pendente'} + + {:else if requisicao.status === 'aprovada'} + + {/if} +
+
+
+
+
+ + + {#if showModalNova} + + {/if} +
+ + diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte index 0a9b10b..05d07dd 100644 --- a/apps/web/src/routes/(dashboard)/ti/+page.svelte +++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte @@ -50,6 +50,7 @@ | '/(dashboard)/ti/configuracoes-ponto' | '/(dashboard)/ti/configuracoes-relogio' | '/(dashboard)/ti/configuracoes-jitsi' + | '/(dashboard)/ti/configuracoes-almoxarifado' | '/(dashboard)/configuracoes/setores'; type FeatureCard = { @@ -278,6 +279,19 @@ palette: 'info', icon: 'clock' }, + { + title: 'Configurações de Almoxarifado', + description: + 'Configure parâmetros do sistema de almoxarifado, alertas e regras de estoque. Acesso restrito à TI.', + ctaLabel: 'Configurar Almoxarifado', + href: '/(dashboard)/ti/configuracoes-almoxarifado', + palette: 'warning', + icon: 'control', + highlightBadges: [ + { label: 'Restrito', variant: 'solid' }, + { label: 'TI Only', variant: 'outline' } + ] + }, { title: 'Monitoramento de Emails', description: diff --git a/apps/web/src/routes/(dashboard)/ti/configuracoes-almoxarifado/+page.svelte b/apps/web/src/routes/(dashboard)/ti/configuracoes-almoxarifado/+page.svelte new file mode 100644 index 0000000..cadc15a --- /dev/null +++ b/apps/web/src/routes/(dashboard)/ti/configuracoes-almoxarifado/+page.svelte @@ -0,0 +1,357 @@ + + +
+ + + + +
+
+
+ +
+
+

Configurações de Almoxarifado

+

+ Configure parâmetros do sistema de almoxarifado. Acesso restrito à TI. +

+
+
+
+ + +
+ +
+

Acesso Restrito

+
+ Esta página é restrita apenas para usuários com permissão de TI. Alterações aqui afetam + o comportamento de todo o sistema de almoxarifado. +
+
+
+ + + {#if mensagem} +
+ {mensagem.texto} +
+ {/if} + + +
+
+
{ e.preventDefault(); salvarConfiguracao(); }}> + +
+

Configurações Gerais

+
+ +
+
+ + + +
+ +
+ + + +
+ +
+ + +
+
+ + +
+

Requisições

+
+ +
+
+ + +
+ +
+ +
+ + Configure as roles no painel de permissões. Roles com permissão + 'almoxarifado.aprovar_requisicao' podem aprovar requisições. +
+
+
+ + +
+

Alertas e Notificações

+
+ +
+
+ + +
+ + {#if emailAlertasAtivo} +
+ +
+ { + if (e.key === 'Enter') { + e.preventDefault(); + adicionarEmail(); + } + }} + /> + +
+ {#if emailsDestinatarios.length > 0} +
+ {#each emailsDestinatarios as email} +
+ {email} + +
+ {/each} +
+ {:else} +
+ + Nenhum email adicionado +
+ {/if} +
+ {/if} +
+ + +
+

Inventário

+
+ +
+
+ + + +
+
+ + +
+ +
+
+
+
+
+ + diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index f2a86ce..7326c9a 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -62,6 +62,7 @@ import type * as security from "../security.js"; import type * as seed from "../seed.js"; import type * as setores from "../setores.js"; import type * as simbolos from "../simbolos.js"; +import type * as tables_almoxarifado from "../tables/almoxarifado.js"; import type * as tables_atas from "../tables/atas.js"; import type * as tables_atestados from "../tables/atestados.js"; import type * as tables_ausencias from "../tables/ausencias.js"; @@ -156,6 +157,7 @@ declare const fullApi: ApiFromModules<{ seed: typeof seed; setores: typeof setores; simbolos: typeof simbolos; + "tables/almoxarifado": typeof tables_almoxarifado; "tables/atas": typeof tables_atas; "tables/atestados": typeof tables_atestados; "tables/ausencias": typeof tables_ausencias; diff --git a/packages/backend/convex/almoxarifado.ts b/packages/backend/convex/almoxarifado.ts new file mode 100644 index 0000000..c3d292d --- /dev/null +++ b/packages/backend/convex/almoxarifado.ts @@ -0,0 +1,1179 @@ +import { v } from 'convex/values'; +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { internal, internalMutation, internalAction, mutation, query } from './_generated/server'; +import { getCurrentUserFunction } from './auth'; +import { + alertaStatus, + alertaTipo, + movimentacaoTipo, + requisicaoStatus +} from './tables/almoxarifado'; + +// ========== QUERIES ========== + +export const listarMateriais = query({ + args: { + categoria: v.optional(v.string()), + ativo: v.optional(v.boolean()), + estoqueBaixo: v.optional(v.boolean()), + busca: v.optional(v.string()) + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return []; + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return []; + } + + let query = ctx.db.query('materiais'); + + if (args.ativo !== undefined) { + query = query.withIndex('by_ativo', (q) => q.eq('ativo', args.ativo)); + } else if (args.categoria) { + query = query.withIndex('by_categoria', (q) => q.eq('categoria', args.categoria)); + } else { + query = query; + } + + let materiais = await query.collect(); + + // Filtros adicionais + if (args.busca) { + const buscaLower = args.busca.toLowerCase(); + materiais = materiais.filter( + (m) => + m.codigo.toLowerCase().includes(buscaLower) || + m.nome.toLowerCase().includes(buscaLower) + ); + } + + if (args.estoqueBaixo) { + materiais = materiais.filter((m) => m.estoqueAtual <= m.estoqueMinimo); + } + + return materiais; + } +}); + +export const obterMaterial = query({ + args: { id: v.id('materiais') }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + + const material = await ctx.db.get(args.id); + if (!material) throw new Error('Material não encontrado'); + return material; + } +}); + +export const listarMovimentacoes = query({ + args: { + materialId: v.optional(v.id('materiais')), + tipo: v.optional(movimentacaoTipo), + dataInicio: v.optional(v.number()), + dataFim: v.optional(v.number()), + funcionarioId: v.optional(v.id('funcionarios')) + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return []; + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return []; + } + + let query = ctx.db.query('movimentacoesEstoque'); + + if (args.materialId) { + query = query.withIndex('by_materialId', (q) => q.eq('materialId', args.materialId)); + } else if (args.tipo) { + query = query.withIndex('by_tipo', (q) => q.eq('tipo', args.tipo)); + } else if (args.funcionarioId) { + query = query.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId)); + } else { + query = query.withIndex('by_data'); + } + + let movimentacoes = await query.collect(); + + // Filtros de data + if (args.dataInicio) { + movimentacoes = movimentacoes.filter((m) => m.data >= args.dataInicio!); + } + if (args.dataFim) { + movimentacoes = movimentacoes.filter((m) => m.data <= args.dataFim!); + } + + // Ordenar por data (mais recente primeiro) + movimentacoes.sort((a, b) => b.data - a.data); + + return movimentacoes; + } +}); + +export const listarRequisicoes = query({ + args: { + status: v.optional(requisicaoStatus), + solicitanteId: v.optional(v.id('funcionarios')), + setorId: v.optional(v.id('setores')) + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return []; + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return []; + } + + let query = ctx.db.query('requisicoesMaterial'); + + if (args.status) { + query = query.withIndex('by_status', (q) => q.eq('status', args.status)); + } else if (args.solicitanteId) { + query = query.withIndex('by_solicitanteId', (q) => q.eq('solicitanteId', args.solicitanteId)); + } else if (args.setorId) { + query = query.withIndex('by_setorId', (q) => q.eq('setorId', args.setorId)); + } + + const requisicoes = await query.collect(); + + // Ordenar por data de criação (mais recente primeiro) + requisicoes.sort((a, b) => b.criadoEm - a.criadoEm); + + return requisicoes; + } +}); + +export const obterRequisicao = query({ + args: { id: v.id('requisicoesMaterial') }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + + const requisicao = await ctx.db.get(args.id); + if (!requisicao) throw new Error('Requisição não encontrada'); + + // Buscar itens da requisição + const itens = await ctx.db + .query('requisicaoItens') + .withIndex('by_requisicaoId', (q) => q.eq('requisicaoId', args.id)) + .collect(); + + return { + ...requisicao, + itens + }; + } +}); + +export const listarAlertas = query({ + args: { + status: v.optional(alertaStatus), + tipo: v.optional(alertaTipo) + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return []; + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return []; + } + + let query = ctx.db.query('alertasEstoque'); + + if (args.status) { + query = query.withIndex('by_status', (q) => q.eq('status', args.status)); + } else if (args.tipo) { + query = query.withIndex('by_tipo', (q) => q.eq('tipo', args.tipo)); + } + + const alertas = await query.collect(); + + // Ordenar por data de criação (mais recente primeiro) + alertas.sort((a, b) => b.criadoEm - a.criadoEm); + + return alertas; + } +}); + +export const obterHistorico = query({ + args: { + tipoEntidade: v.string(), + entidadeId: v.string() + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + + const historico = await ctx.db + .query('historicoAlteracoes') + .withIndex('by_tipoEntidade', (q) => q.eq('tipoEntidade', args.tipoEntidade)) + .filter((q) => q.eq(q.field('entidadeId'), args.entidadeId)) + .collect(); + + // Ordenar por timestamp (mais recente primeiro) + historico.sort((a, b) => b.timestamp - a.timestamp); + + return historico; + } +}); + +export const obterEstatisticas = query({ + args: {}, + handler: async (ctx) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + return { + totalMateriais: 0, + totalMateriaisAtivos: 0, + totalAlertasAtivos: 0, + movimentacoesMes: 0, + materiaisEstoqueBaixo: 0 + }; + } + + try { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + } catch { + return { + totalMateriais: 0, + totalMateriaisAtivos: 0, + totalAlertasAtivos: 0, + movimentacoesMes: 0, + materiaisEstoqueBaixo: 0 + }; + } + + const materiais = await ctx.db.query('materiais').collect(); + const materiaisAtivos = materiais.filter((m) => m.ativo); + const materiaisEstoqueBaixo = materiais.filter((m) => m.estoqueAtual <= m.estoqueMinimo); + + const alertasAtivos = await ctx.db + .query('alertasEstoque') + .withIndex('by_status', (q) => q.eq('status', 'ativo')) + .collect(); + + const agora = Date.now(); + const inicioMes = new Date(agora); + inicioMes.setDate(1); + inicioMes.setHours(0, 0, 0, 0); + + const movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_data') + .collect(); + + const movimentacoesMes = movimentacoes.filter((m) => m.data >= inicioMes.getTime()).length; + + return { + totalMateriais: materiais.length, + totalMateriaisAtivos: materiaisAtivos.length, + totalAlertasAtivos: alertasAtivos.length, + movimentacoesMes, + materiaisEstoqueBaixo: materiaisEstoqueBaixo.length + }; + } +}); + +export const verificarEstoqueBaixo = query({ + args: {}, + handler: async (ctx) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'listar' + }); + + const materiais = await ctx.db + .query('materiais') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .collect(); + + return materiais.filter((m) => m.estoqueAtual <= m.estoqueMinimo); + } +}); + +// ========== MUTATIONS ========== + +async function registrarHistorico( + ctx: MutationCtx, + tipoEntidade: string, + entidadeId: string, + acao: string, + dadosAnteriores?: Record, + dadosNovos?: Record, + observacoes?: string +) { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return; + + await ctx.db.insert('historicoAlteracoes', { + tipoEntidade, + entidadeId, + acao, + usuarioId: usuario._id, + dadosAnteriores: dadosAnteriores ? JSON.stringify(dadosAnteriores) : undefined, + dadosNovos: dadosNovos ? JSON.stringify(dadosNovos) : undefined, + timestamp: Date.now(), + observacoes + }); +} + +async function verificarECriarAlerta(ctx: MutationCtx, materialId: Id<'materiais'>) { + const material = await ctx.db.get(materialId); + if (!material || !material.ativo) return; + + // Verificar se já existe alerta ativo para este material + const alertasExistentes = await ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', materialId)) + .filter((q) => q.eq(q.field('status'), 'ativo')) + .collect(); + + if (alertasExistentes.length > 0) { + // Já existe alerta ativo + return; + } + + // Determinar tipo de alerta + let tipo: 'estoque_minimo' | 'estoque_zerado' | 'reposicao_necessaria' = 'estoque_minimo'; + if (material.estoqueAtual === 0) { + tipo = 'estoque_zerado'; + } else if (material.estoqueAtual < material.estoqueMinimo) { + tipo = 'estoque_minimo'; + } + + // Criar alerta + await ctx.db.insert('alertasEstoque', { + materialId, + tipo, + quantidadeAtual: material.estoqueAtual, + quantidadeMinima: material.estoqueMinimo, + status: 'ativo', + criadoEm: Date.now() + }); +} + +async function resolverAlertasMaterial(ctx: MutationCtx, materialId: Id<'materiais'>) { + const alertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', materialId)) + .filter((q) => q.eq(q.field('status'), 'ativo')) + .collect(); + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return; + + for (const alerta of alertas) { + await ctx.db.patch(alerta._id, { + status: 'resolvido', + resolvidoEm: Date.now(), + resolvidoPor: usuario._id + }); + } +} + +export const criarMaterial = mutation({ + args: { + codigo: v.string(), + nome: v.string(), + descricao: v.optional(v.string()), + categoria: v.string(), + unidadeMedida: v.string(), + estoqueMinimo: v.number(), + estoqueMaximo: v.optional(v.number()), + estoqueAtual: v.optional(v.number()), + localizacao: v.optional(v.string()), + fornecedor: v.optional(v.string()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'criar_material' + }); + + // Verificar se código já existe + const codigoExistente = await ctx.db + .query('materiais') + .withIndex('by_codigo', (q) => q.eq('codigo', args.codigo)) + .unique(); + + if (codigoExistente) { + throw new Error('Código do material já existe'); + } + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + const agora = Date.now(); + const materialId = await ctx.db.insert('materiais', { + ...args, + estoqueAtual: args.estoqueAtual ?? 0, + ativo: true, + criadoPor: usuario._id, + criadoEm: agora, + atualizadoEm: agora + }); + + // Registrar histórico + await registrarHistorico(ctx, 'material', materialId.toString(), 'criacao', undefined, args as Record); + + // Verificar se precisa criar alerta + if (args.estoqueAtual !== undefined && args.estoqueAtual <= args.estoqueMinimo) { + await verificarECriarAlerta(ctx, materialId); + } + + return materialId; + } +}); + +export const editarMaterial = mutation({ + args: { + id: v.id('materiais'), + codigo: v.optional(v.string()), + nome: v.optional(v.string()), + descricao: v.optional(v.string()), + categoria: v.optional(v.string()), + unidadeMedida: v.optional(v.string()), + estoqueMinimo: v.optional(v.number()), + estoqueMaximo: v.optional(v.number()), + localizacao: v.optional(v.string()), + fornecedor: v.optional(v.string()), + ativo: v.optional(v.boolean()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'editar_material' + }); + + const material = await ctx.db.get(args.id); + if (!material) throw new Error('Material não encontrado'); + + // Verificar se código já existe (se foi alterado) + if (args.codigo && args.codigo !== material.codigo) { + const codigoExistente = await ctx.db + .query('materiais') + .withIndex('by_codigo', (q) => q.eq('codigo', args.codigo)) + .unique(); + + if (codigoExistente) { + throw new Error('Código do material já existe'); + } + } + + const dadosAnteriores = { ...material }; + const dadosNovos: Partial> & { atualizadoEm: number } = { + atualizadoEm: Date.now() + }; + + // Atualizar apenas campos fornecidos + if (args.codigo !== undefined) dadosNovos.codigo = args.codigo; + if (args.nome !== undefined) dadosNovos.nome = args.nome; + if (args.descricao !== undefined) dadosNovos.descricao = args.descricao; + if (args.categoria !== undefined) dadosNovos.categoria = args.categoria; + if (args.unidadeMedida !== undefined) dadosNovos.unidadeMedida = args.unidadeMedida; + if (args.estoqueMinimo !== undefined) dadosNovos.estoqueMinimo = args.estoqueMinimo; + if (args.estoqueMaximo !== undefined) dadosNovos.estoqueMaximo = args.estoqueMaximo; + if (args.localizacao !== undefined) dadosNovos.localizacao = args.localizacao; + if (args.fornecedor !== undefined) dadosNovos.fornecedor = args.fornecedor; + if (args.ativo !== undefined) dadosNovos.ativo = args.ativo; + + await ctx.db.patch(args.id, dadosNovos); + + // Registrar histórico + await registrarHistorico(ctx, 'material', args.id.toString(), 'edicao', dadosAnteriores, dadosNovos); + + // Verificar se precisa criar/resolver alertas + if (args.estoqueMinimo !== undefined || args.ativo !== undefined) { + const materialAtualizado = await ctx.db.get(args.id); + if (materialAtualizado) { + if (materialAtualizado.ativo && materialAtualizado.estoqueAtual <= materialAtualizado.estoqueMinimo) { + await verificarECriarAlerta(ctx, args.id); + } else if (materialAtualizado.estoqueAtual > materialAtualizado.estoqueMinimo) { + await resolverAlertasMaterial(ctx, args.id); + } + } + } + } +}); + +export const registrarEntrada = mutation({ + args: { + materialId: v.id('materiais'), + quantidade: v.number(), + motivo: v.string(), + documento: v.optional(v.string()), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'registrar_movimentacao' + }); + + if (args.quantidade <= 0) { + throw new Error('Quantidade deve ser maior que zero'); + } + + const material = await ctx.db.get(args.materialId); + if (!material) throw new Error('Material não encontrado'); + if (!material.ativo) throw new Error('Material está inativo'); + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + const quantidadeAnterior = material.estoqueAtual; + const quantidadeNova = quantidadeAnterior + args.quantidade; + + // Atualizar estoque + await ctx.db.patch(args.materialId, { + estoqueAtual: quantidadeNova, + atualizadoEm: Date.now() + }); + + // Registrar movimentação + const movimentacaoId = await ctx.db.insert('movimentacoesEstoque', { + materialId: args.materialId, + tipo: 'entrada', + quantidade: args.quantidade, + quantidadeAnterior, + quantidadeNova, + motivo: args.motivo, + documento: args.documento, + usuarioId: usuario._id, + data: Date.now(), + observacoes: args.observacoes + }); + + // Registrar histórico + await registrarHistorico(ctx, 'movimentacao', movimentacaoId.toString(), 'movimentacao', undefined, { + tipo: 'entrada', + materialId: args.materialId, + quantidade: args.quantidade + }); + + // Verificar se precisa resolver alertas + if (quantidadeNova > material.estoqueMinimo) { + await resolverAlertasMaterial(ctx, args.materialId); + } + + return movimentacaoId; + } +}); + +export const registrarSaida = mutation({ + args: { + materialId: v.id('materiais'), + quantidade: v.number(), + motivo: v.string(), + funcionarioId: v.optional(v.id('funcionarios')), + setorId: v.optional(v.id('setores')), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'registrar_movimentacao' + }); + + if (args.quantidade <= 0) { + throw new Error('Quantidade deve ser maior que zero'); + } + + const material = await ctx.db.get(args.materialId); + if (!material) throw new Error('Material não encontrado'); + if (!material.ativo) throw new Error('Material está inativo'); + + // Verificar configuração de estoque negativo + const config = await ctx.db + .query('configuracoesAlmoxarifado') + .filter((q) => q.eq(q.field('ativo'), true)) + .first(); + + if (!config?.permitirEstoqueNegativo && material.estoqueAtual < args.quantidade) { + throw new Error('Estoque insuficiente'); + } + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + const quantidadeAnterior = material.estoqueAtual; + const quantidadeNova = Math.max(0, quantidadeAnterior - args.quantidade); + + // Atualizar estoque + await ctx.db.patch(args.materialId, { + estoqueAtual: quantidadeNova, + atualizadoEm: Date.now() + }); + + // Registrar movimentação + const movimentacaoId = await ctx.db.insert('movimentacoesEstoque', { + materialId: args.materialId, + tipo: 'saida', + quantidade: args.quantidade, + quantidadeAnterior, + quantidadeNova, + motivo: args.motivo, + funcionarioId: args.funcionarioId, + setorId: args.setorId, + usuarioId: usuario._id, + data: Date.now(), + observacoes: args.observacoes + }); + + // Registrar histórico + await registrarHistorico(ctx, 'movimentacao', movimentacaoId.toString(), 'movimentacao', undefined, { + tipo: 'saida', + materialId: args.materialId, + quantidade: args.quantidade + }); + + // Verificar se precisa criar alerta + if (quantidadeNova <= material.estoqueMinimo) { + await verificarECriarAlerta(ctx, args.materialId); + } + + return movimentacaoId; + } +}); + +export const ajustarEstoque = mutation({ + args: { + materialId: v.id('materiais'), + quantidadeNova: v.number(), + motivo: v.string(), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'ajustar_estoque' + }); + + if (args.quantidadeNova < 0) { + throw new Error('Quantidade não pode ser negativa'); + } + + const material = await ctx.db.get(args.materialId); + if (!material) throw new Error('Material não encontrado'); + if (!material.ativo) throw new Error('Material está inativo'); + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + const quantidadeAnterior = material.estoqueAtual; + const diferenca = args.quantidadeNova - quantidadeAnterior; + + // Atualizar estoque + await ctx.db.patch(args.materialId, { + estoqueAtual: args.quantidadeNova, + atualizadoEm: Date.now() + }); + + // Registrar movimentação + const movimentacaoId = await ctx.db.insert('movimentacoesEstoque', { + materialId: args.materialId, + tipo: 'ajuste', + quantidade: Math.abs(diferenca), + quantidadeAnterior, + quantidadeNova: args.quantidadeNova, + motivo: args.motivo, + usuarioId: usuario._id, + data: Date.now(), + observacoes: args.observacoes + }); + + // Registrar histórico + await registrarHistorico(ctx, 'movimentacao', movimentacaoId.toString(), 'movimentacao', undefined, { + tipo: 'ajuste', + materialId: args.materialId, + quantidadeAnterior, + quantidadeNova: args.quantidadeNova + }); + + // Verificar se precisa criar/resolver alertas + if (args.quantidadeNova <= material.estoqueMinimo) { + await verificarECriarAlerta(ctx, args.materialId); + } else { + await resolverAlertasMaterial(ctx, args.materialId); + } + + return movimentacaoId; + } +}); + +export const criarRequisicao = mutation({ + args: { + solicitanteId: v.id('funcionarios'), + setorId: v.id('setores'), + itens: v.array( + v.object({ + materialId: v.id('materiais'), + quantidadeSolicitada: v.number(), + observacoes: v.optional(v.string()) + }) + ), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + if (args.itens.length === 0) { + throw new Error('Requisição deve ter pelo menos um item'); + } + + // Gerar número sequencial da requisição + const todasRequisicoes = await ctx.db.query('requisicoesMaterial').collect(); + const proximoNumero = (todasRequisicoes.length + 1).toString().padStart(6, '0'); + const numero = `REQ-${proximoNumero}`; + + const agora = Date.now(); + const requisicaoId = await ctx.db.insert('requisicoesMaterial', { + numero, + solicitanteId: args.solicitanteId, + setorId: args.setorId, + status: 'pendente', + observacoes: args.observacoes, + criadoEm: agora, + atualizadoEm: agora + }); + + // Criar itens da requisição + for (const item of args.itens) { + await ctx.db.insert('requisicaoItens', { + requisicaoId, + materialId: item.materialId, + quantidadeSolicitada: item.quantidadeSolicitada, + observacoes: item.observacoes + }); + } + + // Registrar histórico + await registrarHistorico(ctx, 'requisicao', requisicaoId.toString(), 'criacao', undefined, { + numero, + solicitanteId: args.solicitanteId, + itens: args.itens + }); + + return requisicaoId; + } +}); + +export const aprovarRequisicao = mutation({ + args: { + id: v.id('requisicoesMaterial'), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'aprovar_requisicao' + }); + + const requisicao = await ctx.db.get(args.id); + if (!requisicao) throw new Error('Requisição não encontrada'); + if (requisicao.status !== 'pendente') { + throw new Error('Apenas requisições pendentes podem ser aprovadas'); + } + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + // Buscar funcionário do usuário + const funcionario = await ctx.db + .query('funcionarios') + .filter((q) => q.eq(q.field('email'), usuario.email)) + .first(); + + if (!funcionario) throw new Error('Funcionário não encontrado para o usuário'); + + await ctx.db.patch(args.id, { + status: 'aprovada', + aprovadoPor: funcionario._id, + dataAprovacao: Date.now(), + atualizadoEm: Date.now(), + observacoes: args.observacoes || requisicao.observacoes + }); + + // Registrar histórico + await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, { + status: 'aprovada', + aprovadoPor: funcionario._id + }); + } +}); + +export const atenderRequisicao = mutation({ + args: { + id: v.id('requisicoesMaterial') + }, + handler: async (ctx, args) => { + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'registrar_movimentacao' + }); + + const requisicao = await ctx.db.get(args.id); + if (!requisicao) throw new Error('Requisição não encontrada'); + if (requisicao.status !== 'aprovada') { + throw new Error('Apenas requisições aprovadas podem ser atendidas'); + } + + // Buscar itens da requisição + const itens = await ctx.db + .query('requisicaoItens') + .withIndex('by_requisicaoId', (q) => q.eq('requisicaoId', args.id)) + .collect(); + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + // Processar cada item + for (const item of itens) { + const material = await ctx.db.get(item.materialId); + if (!material) continue; + + const quantidadeAtendida = Math.min(item.quantidadeSolicitada, material.estoqueAtual); + + // Atualizar item com quantidade atendida + await ctx.db.patch(item._id, { + quantidadeAtendida + }); + + if (quantidadeAtendida > 0) { + // Registrar saída + const quantidadeAnterior = material.estoqueAtual; + const quantidadeNova = quantidadeAnterior - quantidadeAtendida; + + await ctx.db.patch(item.materialId, { + estoqueAtual: quantidadeNova, + atualizadoEm: Date.now() + }); + + await ctx.db.insert('movimentacoesEstoque', { + materialId: item.materialId, + tipo: 'saida', + quantidade: quantidadeAtendida, + quantidadeAnterior, + quantidadeNova, + motivo: `Atendimento da requisição ${requisicao.numero}`, + funcionarioId: requisicao.solicitanteId, + setorId: requisicao.setorId, + usuarioId: usuario._id, + data: Date.now(), + observacoes: `Requisição ${requisicao.numero}` + }); + + // Verificar alertas + if (quantidadeNova <= material.estoqueMinimo) { + await verificarECriarAlerta(ctx, item.materialId); + } + } + } + + // Atualizar status da requisição + await ctx.db.patch(args.id, { + status: 'atendida', + atualizadoEm: Date.now() + }); + + // Registrar histórico + await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, { + status: 'atendida' + }); + } +}); + +export const cancelarRequisicao = mutation({ + args: { + id: v.id('requisicoesMaterial'), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + const requisicao = await ctx.db.get(args.id); + if (!requisicao) throw new Error('Requisição não encontrada'); + if (requisicao.status === 'atendida' || requisicao.status === 'cancelada') { + throw new Error('Requisição já foi atendida ou cancelada'); + } + + await ctx.db.patch(args.id, { + status: 'cancelada', + atualizadoEm: Date.now(), + observacoes: args.observacoes || requisicao.observacoes + }); + + // Registrar histórico + await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, { + status: 'cancelada' + }); + } +}); + +export const resolverAlerta = mutation({ + args: { + id: v.id('alertasEstoque') + }, + handler: async (ctx, args) => { + const alerta = await ctx.db.get(args.id); + if (!alerta) throw new Error('Alerta não encontrado'); + if (alerta.status !== 'ativo') { + throw new Error('Apenas alertas ativos podem ser resolvidos'); + } + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + await ctx.db.patch(args.id, { + status: 'resolvido', + resolvidoEm: Date.now(), + resolvidoPor: usuario._id + }); + + // Registrar histórico + await registrarHistorico(ctx, 'configuracao', args.id.toString(), 'edicao', alerta, { + status: 'resolvido' + }); + } +}); + +export const ignorarAlerta = mutation({ + args: { + id: v.id('alertasEstoque') + }, + handler: async (ctx, args) => { + const alerta = await ctx.db.get(args.id); + if (!alerta) throw new Error('Alerta não encontrado'); + if (alerta.status !== 'ativo') { + throw new Error('Apenas alertas ativos podem ser ignorados'); + } + + await ctx.db.patch(args.id, { + status: 'ignorado' + }); + + // Registrar histórico + await registrarHistorico(ctx, 'configuracao', args.id.toString(), 'edicao', alerta, { + status: 'ignorado' + }); + } +}); + +// ========== INTERNAL ACTIONS ========== + +export const registrarHistoricoAlteracao = internalMutation({ + args: { + tipoEntidade: v.string(), + entidadeId: v.string(), + acao: v.string(), + dadosAnteriores: v.optional(v.string()), + dadosNovos: v.optional(v.string()), + observacoes: v.optional(v.string()) + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) return; + + await ctx.db.insert('historicoAlteracoes', { + tipoEntidade: args.tipoEntidade, + entidadeId: args.entidadeId, + acao: args.acao, + usuarioId: usuario._id, + dadosAnteriores: args.dadosAnteriores, + dadosNovos: args.dadosNovos, + timestamp: Date.now(), + observacoes: args.observacoes + }); + } +}); + +export const verificarAlertasAutomatico = internalAction({ + args: {}, + handler: async (ctx) => { + // Buscar todos os materiais ativos + const materiais = await ctx.runQuery(internal.almoxarifado.listarMateriaisInterno, { + ativo: true + }); + + // Buscar configuração + const config = await ctx.runQuery(internal.configuracaoAlmoxarifado.obterConfiguracaoInterno, {}); + + for (const material of materiais) { + // Verificar se precisa criar alerta + if (material.estoqueAtual <= material.estoqueMinimo) { + // Verificar se já existe alerta ativo + const alertasExistentes = await ctx.runQuery( + internal.almoxarifado.listarAlertasPorMaterial, + { + materialId: material._id, + status: 'ativo' as const + } + ); + + if (alertasExistentes.length === 0) { + // Criar novo alerta + let tipo: 'estoque_minimo' | 'estoque_zerado' | 'reposicao_necessaria' = + 'estoque_minimo'; + if (material.estoqueAtual === 0) { + tipo = 'estoque_zerado'; + } + + await ctx.runMutation(internal.almoxarifado.criarAlertaInterno, { + materialId: material._id, + tipo, + quantidadeAtual: material.estoqueAtual, + quantidadeMinima: material.estoqueMinimo + }); + } + } else { + // Resolver alertas se estoque está acima do mínimo + await ctx.runMutation(internal.almoxarifado.resolverAlertasMaterialInterno, { + materialId: material._id + }); + } + } + + // Enviar notificações se configurado + if (config?.emailAlertasAtivo) { + await ctx.runAction(internal.almoxarifado.enviarNotificacoesAlerta, {}); + } + } +}); + +export const enviarNotificacoesAlerta = internalAction({ + args: {}, + handler: async (ctx) => { + // Buscar alertas ativos + const alertas = await ctx.runQuery(internal.almoxarifado.listarAlertasInterno, { + status: 'ativo' + }); + + if (alertas.length === 0) return; + + // Buscar configuração para obter emails + const config = await ctx.runQuery(internal.configuracaoAlmoxarifado.obterConfiguracaoInterno, {}); + + if (!config?.emailAlertasAtivo || config.emailsDestinatarios.length === 0) { + return; + } + + // Aqui você pode integrar com o sistema de email do projeto + // Por enquanto, apenas logamos + console.log(`Enviando notificações de ${alertas.length} alertas para:`, config.emailsDestinatarios); + } +}); + +// ========== INTERNAL QUERIES ========== + +export const listarMateriaisInterno = internalQuery({ + args: { + ativo: v.optional(v.boolean()) + }, + handler: async (ctx, args) => { + let query = ctx.db.query('materiais'); + if (args.ativo !== undefined) { + query = query.withIndex('by_ativo', (q) => q.eq('ativo', args.ativo)); + } + return await query.collect(); + } +}); + +export const listarAlertasPorMaterial = internalQuery({ + args: { + materialId: v.id('materiais'), + status: v.optional(alertaStatus) + }, + handler: async (ctx, args) => { + let query = ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.materialId)); + if (args.status) { + query = query.filter((q) => q.eq(q.field('status'), args.status)); + } + return await query.collect(); + } +}); + +export const listarAlertasInterno = internalQuery({ + args: { + status: v.optional(alertaStatus) + }, + handler: async (ctx, args) => { + let query = ctx.db.query('alertasEstoque'); + if (args.status) { + query = query.withIndex('by_status', (q) => q.eq('status', args.status)); + } + return await query.collect(); + } +}); + +// ========== INTERNAL MUTATIONS ========== + +export const criarAlertaInterno = internalMutation({ + args: { + materialId: v.id('materiais'), + tipo: alertaTipo, + quantidadeAtual: v.number(), + quantidadeMinima: v.number() + }, + handler: async (ctx, args) => { + await ctx.db.insert('alertasEstoque', { + materialId: args.materialId, + tipo: args.tipo, + quantidadeAtual: args.quantidadeAtual, + quantidadeMinima: args.quantidadeMinima, + status: 'ativo', + criadoEm: Date.now() + }); + } +}); + +export const resolverAlertasMaterialInterno = internalMutation({ + args: { + materialId: v.id('materiais') + }, + handler: async (ctx, args) => { + const alertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.materialId)) + .filter((q) => q.eq(q.field('status'), 'ativo')) + .collect(); + + for (const alerta of alertas) { + await ctx.db.patch(alerta._id, { + status: 'resolvido', + resolvidoEm: Date.now() + }); + } + } +}); + diff --git a/packages/backend/convex/configuracaoAlmoxarifado.ts b/packages/backend/convex/configuracaoAlmoxarifado.ts new file mode 100644 index 0000000..6726539 --- /dev/null +++ b/packages/backend/convex/configuracaoAlmoxarifado.ts @@ -0,0 +1,165 @@ +import { v } from 'convex/values'; +import { internal, internalQuery, mutation, query } from './_generated/server'; +import { getCurrentUserFunction } from './auth'; + +export const obterConfiguracao = query({ + args: {}, + handler: async (ctx) => { + // Verificar se usuário tem permissão de TI + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'configurar' + }); + + const config = await ctx.db + .query('configuracoesAlmoxarifado') + .filter((q) => q.eq(q.field('ativo'), true)) + .first(); + + // Se não existe configuração, retornar valores padrão + if (!config) { + return { + estoqueMinimoPadrao: 10, + diasAntecedenciaAlerta: 7, + permitirEstoqueNegativo: false, + requerAprovacaoRequisicao: true, + rolesAprovacao: [], + emailAlertasAtivo: false, + emailsDestinatarios: [], + periodicidadeInventario: 30, + ultimoInventario: undefined, + ativo: true + }; + } + + return config; + } +}); + +export const atualizarConfiguracao = mutation({ + args: { + estoqueMinimoPadrao: v.optional(v.number()), + diasAntecedenciaAlerta: v.optional(v.number()), + permitirEstoqueNegativo: v.optional(v.boolean()), + requerAprovacaoRequisicao: v.optional(v.boolean()), + rolesAprovacao: v.optional(v.array(v.string())), + emailAlertasAtivo: v.optional(v.boolean()), + emailsDestinatarios: v.optional(v.array(v.string())), + periodicidadeInventario: v.optional(v.number()), + ultimoInventario: v.optional(v.number()) + }, + handler: async (ctx, args) => { + // Verificar se usuário tem permissão de TI_MASTER + await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { + recurso: 'almoxarifado', + acao: 'configurar' + }); + + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) throw new Error('Usuário não autenticado'); + + // Buscar configuração existente + let config = await ctx.db + .query('configuracoesAlmoxarifado') + .filter((q) => q.eq(q.field('ativo'), true)) + .first(); + + const dadosAnteriores = config ? { ...config } : undefined; + + if (config) { + // Desativar configuração antiga + await ctx.db.patch(config._id, { ativo: false }); + + // Criar nova configuração + const dadosNovos = { + ...config, + ...args, + ativo: true, + atualizadoPor: usuario._id, + atualizadoEm: Date.now() + }; + + const novaConfigId = await ctx.db.insert('configuracoesAlmoxarifado', dadosNovos); + + // Registrar histórico + if (usuario) { + await ctx.db.insert('historicoAlteracoes', { + tipoEntidade: 'configuracao', + entidadeId: novaConfigId, + acao: 'edicao', + usuarioId: usuario._id, + dadosAnteriores: dadosAnteriores ? JSON.stringify(dadosAnteriores) : undefined, + dadosNovos: JSON.stringify(dadosNovos), + timestamp: Date.now(), + observacoes: 'Atualização de configurações do almoxarifado' + }); + } + + return novaConfigId; + } else { + // Criar primeira configuração + const dadosNovos = { + estoqueMinimoPadrao: args.estoqueMinimoPadrao ?? 10, + diasAntecedenciaAlerta: args.diasAntecedenciaAlerta ?? 7, + permitirEstoqueNegativo: args.permitirEstoqueNegativo ?? false, + requerAprovacaoRequisicao: args.requerAprovacaoRequisicao ?? true, + rolesAprovacao: args.rolesAprovacao ?? [], + emailAlertasAtivo: args.emailAlertasAtivo ?? false, + emailsDestinatarios: args.emailsDestinatarios ?? [], + periodicidadeInventario: args.periodicidadeInventario ?? 30, + ultimoInventario: args.ultimoInventario, + ativo: true, + atualizadoPor: usuario._id, + atualizadoEm: Date.now() + }; + + const novaConfigId = await ctx.db.insert('configuracoesAlmoxarifado', dadosNovos); + + // Registrar histórico + if (usuario) { + await ctx.db.insert('historicoAlteracoes', { + tipoEntidade: 'configuracao', + entidadeId: novaConfigId, + acao: 'criacao', + usuarioId: usuario._id, + dadosAnteriores: undefined, + dadosNovos: JSON.stringify(dadosNovos), + timestamp: Date.now(), + observacoes: 'Criação de configurações do almoxarifado' + }); + } + + return novaConfigId; + } + } +}); + +// ========== INTERNAL QUERIES ========== + +export const obterConfiguracaoInterno = internalQuery({ + args: {}, + handler: async (ctx) => { + const config = await ctx.db + .query('configuracoesAlmoxarifado') + .filter((q) => q.eq(q.field('ativo'), true)) + .first(); + + if (!config) { + return { + estoqueMinimoPadrao: 10, + diasAntecedenciaAlerta: 7, + permitirEstoqueNegativo: false, + requerAprovacaoRequisicao: true, + rolesAprovacao: [], + emailAlertasAtivo: false, + emailsDestinatarios: [], + periodicidadeInventario: 30, + ultimoInventario: undefined, + ativo: true + }; + } + + return config; + } +}); + diff --git a/packages/backend/convex/crons.ts b/packages/backend/convex/crons.ts index 3ef6845..03b83a9 100644 --- a/packages/backend/convex/crons.ts +++ b/packages/backend/convex/crons.ts @@ -58,4 +58,12 @@ crons.interval( {} ); +// Verificar alertas de estoque do almoxarifado diariamente +crons.interval( + 'verificar-alertas-almoxarifado', + { hours: 24 }, + internal.almoxarifado.verificarAlertasAutomatico, + {} +); + export default crons; diff --git a/packages/backend/convex/permissoesAcoes.ts b/packages/backend/convex/permissoesAcoes.ts index d2f1f21..0d5ed12 100644 --- a/packages/backend/convex/permissoesAcoes.ts +++ b/packages/backend/convex/permissoesAcoes.ts @@ -735,6 +735,49 @@ const PERMISSOES_BASE = { recurso: 'config', acao: 'gerenciar_compras', descricao: 'Gerenciar configurações de compras' + }, + // Almoxarifado + { + nome: 'almoxarifado.listar', + recurso: 'almoxarifado', + acao: 'listar', + descricao: 'Listar materiais e movimentações' + }, + { + nome: 'almoxarifado.criar_material', + recurso: 'almoxarifado', + acao: 'criar_material', + descricao: 'Cadastrar novos materiais' + }, + { + nome: 'almoxarifado.editar_material', + recurso: 'almoxarifado', + acao: 'editar_material', + descricao: 'Editar materiais existentes' + }, + { + nome: 'almoxarifado.registrar_movimentacao', + recurso: 'almoxarifado', + acao: 'registrar_movimentacao', + descricao: 'Registrar entradas e saídas' + }, + { + nome: 'almoxarifado.ajustar_estoque', + recurso: 'almoxarifado', + acao: 'ajustar_estoque', + descricao: 'Realizar ajustes manuais de estoque' + }, + { + nome: 'almoxarifado.aprovar_requisicao', + recurso: 'almoxarifado', + acao: 'aprovar_requisicao', + descricao: 'Aprovar requisições de material' + }, + { + nome: 'almoxarifado.configurar', + recurso: 'almoxarifado', + acao: 'configurar', + descricao: 'Configurar sistema de almoxarifado (apenas TI)' } ] } as const; diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 2dec986..8bfc339 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -22,6 +22,7 @@ import { systemTables } from './tables/system'; import { ticketsTables } from './tables/tickets'; import { timesTables } from './tables/times'; import { lgpdTables } from './tables/lgpdTables'; +import { almoxarifadoTables } from './tables/almoxarifado'; export default defineSchema({ ...setoresTables, @@ -46,5 +47,6 @@ export default defineSchema({ ...planejamentosTables, ...objetosTables, ...atasTables, - ...lgpdTables + ...lgpdTables, + ...almoxarifadoTables }); diff --git a/packages/backend/convex/tables/almoxarifado.ts b/packages/backend/convex/tables/almoxarifado.ts new file mode 100644 index 0000000..75c5c64 --- /dev/null +++ b/packages/backend/convex/tables/almoxarifado.ts @@ -0,0 +1,149 @@ +import { defineTable } from 'convex/server'; +import { type Infer, v } from 'convex/values'; + +export const movimentacaoTipo = v.union( + v.literal('entrada'), + v.literal('saida'), + v.literal('ajuste'), + v.literal('transferencia') +); +export type MovimentacaoTipo = Infer; + +export const requisicaoStatus = v.union( + v.literal('pendente'), + v.literal('aprovada'), + v.literal('rejeitada'), + v.literal('atendida'), + v.literal('cancelada') +); +export type RequisicaoStatus = Infer; + +export const alertaTipo = v.union( + v.literal('estoque_minimo'), + v.literal('estoque_zerado'), + v.literal('reposicao_necessaria') +); +export type AlertaTipo = Infer; + +export const alertaStatus = v.union( + v.literal('ativo'), + v.literal('resolvido'), + v.literal('ignorado') +); +export type AlertaStatus = Infer; + +export const almoxarifadoTables = { + materiais: defineTable({ + codigo: v.string(), + nome: v.string(), + descricao: v.optional(v.string()), + categoria: v.string(), + unidadeMedida: v.string(), + estoqueMinimo: v.number(), + estoqueMaximo: v.optional(v.number()), + estoqueAtual: v.number(), + localizacao: v.optional(v.string()), + fornecedor: v.optional(v.string()), + ativo: v.boolean(), + criadoPor: v.id('usuarios'), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_codigo', ['codigo']) + .index('by_categoria', ['categoria']) + .index('by_ativo', ['ativo']) + .index('by_estoqueAtual', ['estoqueAtual']), + + movimentacoesEstoque: defineTable({ + materialId: v.id('materiais'), + tipo: movimentacaoTipo, + quantidade: v.number(), + quantidadeAnterior: v.number(), + quantidadeNova: v.number(), + motivo: v.string(), + documento: v.optional(v.string()), + funcionarioId: v.optional(v.id('funcionarios')), + setorId: v.optional(v.id('setores')), + usuarioId: v.id('usuarios'), + data: v.number(), + observacoes: v.optional(v.string()) + }) + .index('by_materialId', ['materialId']) + .index('by_tipo', ['tipo']) + .index('by_data', ['data']) + .index('by_funcionarioId', ['funcionarioId']) + .index('by_usuarioId', ['usuarioId']), + + requisicoesMaterial: defineTable({ + numero: v.string(), + solicitanteId: v.id('funcionarios'), + setorId: v.id('setores'), + status: requisicaoStatus, + aprovadoPor: v.optional(v.id('funcionarios')), + dataAprovacao: v.optional(v.number()), + observacoes: v.optional(v.string()), + criadoEm: v.number(), + atualizadoEm: v.number() + }) + .index('by_status', ['status']) + .index('by_solicitanteId', ['solicitanteId']) + .index('by_setorId', ['setorId']) + .index('by_numero', ['numero']), + + requisicaoItens: defineTable({ + requisicaoId: v.id('requisicoesMaterial'), + materialId: v.id('materiais'), + quantidadeSolicitada: v.number(), + quantidadeAtendida: v.optional(v.number()), + observacoes: v.optional(v.string()) + }) + .index('by_requisicaoId', ['requisicaoId']) + .index('by_materialId', ['materialId']), + + historicoAlteracoes: defineTable({ + tipoEntidade: v.string(), + entidadeId: v.string(), + acao: v.string(), + usuarioId: v.id('usuarios'), + dadosAnteriores: v.optional(v.string()), + dadosNovos: v.optional(v.string()), + timestamp: v.number(), + ipAddress: v.optional(v.string()), + observacoes: v.optional(v.string()) + }) + .index('by_tipoEntidade', ['tipoEntidade']) + .index('by_entidadeId', ['entidadeId']) + .index('by_usuarioId', ['usuarioId']) + .index('by_timestamp', ['timestamp']), + + alertasEstoque: defineTable({ + materialId: v.id('materiais'), + tipo: alertaTipo, + quantidadeAtual: v.number(), + quantidadeMinima: v.number(), + status: alertaStatus, + criadoEm: v.number(), + resolvidoEm: v.optional(v.number()), + resolvidoPor: v.optional(v.id('usuarios')) + }) + .index('by_materialId', ['materialId']) + .index('by_status', ['status']) + .index('by_tipo', ['tipo']), + + configuracoesAlmoxarifado: defineTable({ + estoqueMinimoPadrao: v.number(), + diasAntecedenciaAlerta: v.number(), + permitirEstoqueNegativo: v.boolean(), + requerAprovacaoRequisicao: v.boolean(), + rolesAprovacao: v.array(v.string()), + emailAlertasAtivo: v.boolean(), + emailsDestinatarios: v.array(v.string()), + periodicidadeInventario: v.number(), + ultimoInventario: v.optional(v.number()), + ativo: v.boolean(), + atualizadoPor: v.id('usuarios'), + atualizadoEm: v.number() + }) +}; + + -- 2.49.1 From d4c7488cab974b3bb46514ce0a93c5156333a47a Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 20 Dec 2025 13:18:04 -0300 Subject: [PATCH 05/21] refactor: update imports in almoxarifado and configuracaoAlmoxarifado files to streamline API usage and enhance code organization --- packages/backend/convex/_generated/api.d.ts | 4 ++++ packages/backend/convex/almoxarifado.ts | 3 ++- packages/backend/convex/configuracaoAlmoxarifado.ts | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts index 7326c9a..d319dd1 100644 --- a/packages/backend/convex/_generated/api.d.ts +++ b/packages/backend/convex/_generated/api.d.ts @@ -14,6 +14,7 @@ import type * as actions_linkPreview from "../actions/linkPreview.js"; import type * as actions_pushNotifications from "../actions/pushNotifications.js"; import type * as actions_smtp from "../actions/smtp.js"; import type * as actions_utils_nodeCrypto from "../actions/utils/nodeCrypto.js"; +import type * as almoxarifado from "../almoxarifado.js"; import type * as atas from "../atas.js"; import type * as atestadosLicencas from "../atestadosLicencas.js"; import type * as ausencias from "../ausencias.js"; @@ -24,6 +25,7 @@ import type * as chamadas from "../chamadas.js"; import type * as chamados from "../chamados.js"; import type * as chat from "../chat.js"; import type * as config from "../config.js"; +import type * as configuracaoAlmoxarifado from "../configuracaoAlmoxarifado.js"; import type * as configuracaoEmail from "../configuracaoEmail.js"; import type * as configuracaoJitsi from "../configuracaoJitsi.js"; import type * as configuracaoPonto from "../configuracaoPonto.js"; @@ -109,6 +111,7 @@ declare const fullApi: ApiFromModules<{ "actions/pushNotifications": typeof actions_pushNotifications; "actions/smtp": typeof actions_smtp; "actions/utils/nodeCrypto": typeof actions_utils_nodeCrypto; + almoxarifado: typeof almoxarifado; atas: typeof atas; atestadosLicencas: typeof atestadosLicencas; ausencias: typeof ausencias; @@ -119,6 +122,7 @@ declare const fullApi: ApiFromModules<{ chamados: typeof chamados; chat: typeof chat; config: typeof config; + configuracaoAlmoxarifado: typeof configuracaoAlmoxarifado; configuracaoEmail: typeof configuracaoEmail; configuracaoJitsi: typeof configuracaoJitsi; configuracaoPonto: typeof configuracaoPonto; diff --git a/packages/backend/convex/almoxarifado.ts b/packages/backend/convex/almoxarifado.ts index c3d292d..e7dccb1 100644 --- a/packages/backend/convex/almoxarifado.ts +++ b/packages/backend/convex/almoxarifado.ts @@ -1,7 +1,8 @@ import { v } from 'convex/values'; import type { Doc, Id } from './_generated/dataModel'; import type { MutationCtx, QueryCtx } from './_generated/server'; -import { internal, internalMutation, internalAction, mutation, query } from './_generated/server'; +import { internalMutation, internalAction, internalQuery, mutation, query } from './_generated/server'; +import { internal } from './_generated/api'; import { getCurrentUserFunction } from './auth'; import { alertaStatus, diff --git a/packages/backend/convex/configuracaoAlmoxarifado.ts b/packages/backend/convex/configuracaoAlmoxarifado.ts index 6726539..2b72548 100644 --- a/packages/backend/convex/configuracaoAlmoxarifado.ts +++ b/packages/backend/convex/configuracaoAlmoxarifado.ts @@ -1,5 +1,6 @@ import { v } from 'convex/values'; -import { internal, internalQuery, mutation, query } from './_generated/server'; +import { internalQuery, mutation, query } from './_generated/server'; +import { internal } from './_generated/api'; import { getCurrentUserFunction } from './auth'; export const obterConfiguracao = query({ -- 2.49.1 From 8dd2674305faef797e4d362ed31c855feb1055f1 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 20 Dec 2025 13:45:21 -0300 Subject: [PATCH 06/21] refactor: optimize database queries in almoxarifado and configuracaoAlmoxarifado files by replacing filter methods with indexed queries for improved performance and clarity --- packages/backend/convex/almoxarifado.ts | 103 +++++++++++------- .../convex/configuracaoAlmoxarifado.ts | 4 +- .../backend/convex/tables/almoxarifado.ts | 6 +- 3 files changed, 70 insertions(+), 43 deletions(-) diff --git a/packages/backend/convex/almoxarifado.ts b/packages/backend/convex/almoxarifado.ts index e7dccb1..2cfda8a 100644 --- a/packages/backend/convex/almoxarifado.ts +++ b/packages/backend/convex/almoxarifado.ts @@ -33,18 +33,21 @@ export const listarMateriais = query({ return []; } - let query = ctx.db.query('materiais'); - + let materiais; if (args.ativo !== undefined) { - query = query.withIndex('by_ativo', (q) => q.eq('ativo', args.ativo)); + materiais = await ctx.db + .query('materiais') + .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) + .collect(); } else if (args.categoria) { - query = query.withIndex('by_categoria', (q) => q.eq('categoria', args.categoria)); + materiais = await ctx.db + .query('materiais') + .withIndex('by_categoria', (q) => q.eq('categoria', args.categoria!)) + .collect(); } else { - query = query; + materiais = await ctx.db.query('materiais').collect(); } - let materiais = await query.collect(); - // Filtros adicionais if (args.busca) { const buscaLower = args.busca.toLowerCase(); @@ -98,20 +101,29 @@ export const listarMovimentacoes = query({ return []; } - let query = ctx.db.query('movimentacoesEstoque'); - + let movimentacoes; if (args.materialId) { - query = query.withIndex('by_materialId', (q) => q.eq('materialId', args.materialId)); + movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_materialId', (q) => q.eq('materialId', args.materialId!)) + .collect(); } else if (args.tipo) { - query = query.withIndex('by_tipo', (q) => q.eq('tipo', args.tipo)); + movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_tipo', (q) => q.eq('tipo', args.tipo!)) + .collect(); } else if (args.funcionarioId) { - query = query.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId)); + movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId!)) + .collect(); } else { - query = query.withIndex('by_data'); + movimentacoes = await ctx.db + .query('movimentacoesEstoque') + .withIndex('by_data') + .collect(); } - let movimentacoes = await query.collect(); - // Filtros de data if (args.dataInicio) { movimentacoes = movimentacoes.filter((m) => m.data >= args.dataInicio!); @@ -146,18 +158,26 @@ export const listarRequisicoes = query({ return []; } - let query = ctx.db.query('requisicoesMaterial'); - + let requisicoes; if (args.status) { - query = query.withIndex('by_status', (q) => q.eq('status', args.status)); + requisicoes = await ctx.db + .query('requisicoesMaterial') + .withIndex('by_status', (q) => q.eq('status', args.status!)) + .collect(); } else if (args.solicitanteId) { - query = query.withIndex('by_solicitanteId', (q) => q.eq('solicitanteId', args.solicitanteId)); + requisicoes = await ctx.db + .query('requisicoesMaterial') + .withIndex('by_solicitanteId', (q) => q.eq('solicitanteId', args.solicitanteId!)) + .collect(); } else if (args.setorId) { - query = query.withIndex('by_setorId', (q) => q.eq('setorId', args.setorId)); + requisicoes = await ctx.db + .query('requisicoesMaterial') + .withIndex('by_setorId', (q) => q.eq('setorId', args.setorId!)) + .collect(); + } else { + requisicoes = await ctx.db.query('requisicoesMaterial').collect(); } - const requisicoes = await query.collect(); - // Ordenar por data de criação (mais recente primeiro) requisicoes.sort((a, b) => b.criadoEm - a.criadoEm); @@ -207,16 +227,21 @@ export const listarAlertas = query({ return []; } - let query = ctx.db.query('alertasEstoque'); - + let alertas; if (args.status) { - query = query.withIndex('by_status', (q) => q.eq('status', args.status)); + alertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_status', (q) => q.eq('status', args.status!)) + .collect(); } else if (args.tipo) { - query = query.withIndex('by_tipo', (q) => q.eq('tipo', args.tipo)); + alertas = await ctx.db + .query('alertasEstoque') + .withIndex('by_tipo', (q) => q.eq('tipo', args.tipo!)) + .collect(); + } else { + alertas = await ctx.db.query('alertasEstoque').collect(); } - const alertas = await query.collect(); - // Ordenar por data de criação (mais recente primeiro) alertas.sort((a, b) => b.criadoEm - a.criadoEm); @@ -486,7 +511,7 @@ export const editarMaterial = mutation({ if (args.codigo && args.codigo !== material.codigo) { const codigoExistente = await ctx.db .query('materiais') - .withIndex('by_codigo', (q) => q.eq('codigo', args.codigo)) + .withIndex('by_codigo', (q) => q.eq('codigo', args.codigo!)) .unique(); if (codigoExistente) { @@ -1100,11 +1125,13 @@ export const listarMateriaisInterno = internalQuery({ ativo: v.optional(v.boolean()) }, handler: async (ctx, args) => { - let query = ctx.db.query('materiais'); if (args.ativo !== undefined) { - query = query.withIndex('by_ativo', (q) => q.eq('ativo', args.ativo)); + return await ctx.db + .query('materiais') + .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) + .collect(); } - return await query.collect(); + return await ctx.db.query('materiais').collect(); } }); @@ -1114,11 +1141,11 @@ export const listarAlertasPorMaterial = internalQuery({ status: v.optional(alertaStatus) }, handler: async (ctx, args) => { - let query = ctx.db + const query = ctx.db .query('alertasEstoque') .withIndex('by_materialId', (q) => q.eq('materialId', args.materialId)); if (args.status) { - query = query.filter((q) => q.eq(q.field('status'), args.status)); + return await query.filter((q) => q.eq(q.field('status'), args.status!)).collect(); } return await query.collect(); } @@ -1129,11 +1156,13 @@ export const listarAlertasInterno = internalQuery({ status: v.optional(alertaStatus) }, handler: async (ctx, args) => { - let query = ctx.db.query('alertasEstoque'); if (args.status) { - query = query.withIndex('by_status', (q) => q.eq('status', args.status)); + return await ctx.db + .query('alertasEstoque') + .withIndex('by_status', (q) => q.eq('status', args.status!)) + .collect(); } - return await query.collect(); + return await ctx.db.query('alertasEstoque').collect(); } }); diff --git a/packages/backend/convex/configuracaoAlmoxarifado.ts b/packages/backend/convex/configuracaoAlmoxarifado.ts index 2b72548..e266dee 100644 --- a/packages/backend/convex/configuracaoAlmoxarifado.ts +++ b/packages/backend/convex/configuracaoAlmoxarifado.ts @@ -14,7 +14,7 @@ export const obterConfiguracao = query({ const config = await ctx.db .query('configuracoesAlmoxarifado') - .filter((q) => q.eq(q.field('ativo'), true)) + .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); // Se não existe configuração, retornar valores padrão @@ -142,7 +142,7 @@ export const obterConfiguracaoInterno = internalQuery({ handler: async (ctx) => { const config = await ctx.db .query('configuracoesAlmoxarifado') - .filter((q) => q.eq(q.field('ativo'), true)) + .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); if (!config) { diff --git a/packages/backend/convex/tables/almoxarifado.ts b/packages/backend/convex/tables/almoxarifado.ts index 75c5c64..bf16bcd 100644 --- a/packages/backend/convex/tables/almoxarifado.ts +++ b/packages/backend/convex/tables/almoxarifado.ts @@ -143,7 +143,5 @@ export const almoxarifadoTables = { ativo: v.boolean(), atualizadoPor: v.id('usuarios'), atualizadoEm: v.number() - }) -}; - - + }).index('by_ativo', ['ativo']) +}; \ No newline at end of file -- 2.49.1 From 8f0452bd870c84b86f1391b24be252bbb7a7fdae Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Sat, 20 Dec 2025 13:52:30 -0300 Subject: [PATCH 07/21] feat: add new 'Almoxarifado' section in sidebar and update navigation links for improved user experience and consistency across the application --- apps/web/src/lib/components/Sidebar.svelte | 12 ++++++++++-- .../almoxarifado/+page.svelte | 10 +++++----- .../almoxarifado/alertas/+page.svelte | 2 +- .../almoxarifado/materiais/+page.svelte | 8 ++++---- .../almoxarifado/materiais/cadastro/+page.svelte | 10 +++++----- .../almoxarifado/movimentacoes/+page.svelte | 2 +- .../almoxarifado/relatorios/+page.svelte | 2 +- .../almoxarifado/requisicoes/+page.svelte | 2 +- .../(dashboard)/recursos-humanos/+page.svelte | 14 +++++++------- 9 files changed, 35 insertions(+), 27 deletions(-) rename apps/web/src/routes/(dashboard)/{recursos-humanos => }/almoxarifado/+page.svelte (93%) rename apps/web/src/routes/(dashboard)/{recursos-humanos => }/almoxarifado/alertas/+page.svelte (98%) rename apps/web/src/routes/(dashboard)/{recursos-humanos => }/almoxarifado/materiais/+page.svelte (95%) rename apps/web/src/routes/(dashboard)/{recursos-humanos => }/almoxarifado/materiais/cadastro/+page.svelte (95%) rename apps/web/src/routes/(dashboard)/{recursos-humanos => }/almoxarifado/movimentacoes/+page.svelte (99%) rename apps/web/src/routes/(dashboard)/{recursos-humanos => }/almoxarifado/relatorios/+page.svelte (99%) rename apps/web/src/routes/(dashboard)/{recursos-humanos => }/almoxarifado/requisicoes/+page.svelte (99%) diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index 45c0c96..1c43f54 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -12,7 +12,8 @@ Tag, Users, Briefcase, - UserPlus + UserPlus, + Package } from 'lucide-svelte'; import { resolve } from '$app/paths'; import { page } from '$app/state'; @@ -141,6 +142,12 @@ } ] }, + { + label: 'Almoxarifado', + icon: 'Package', + link: '/almoxarifado', + permission: { recurso: 'almoxarifado', acao: 'listar' } + }, { label: 'Objetos', icon: 'Tag', @@ -271,7 +278,8 @@ ChevronDown, GitMerge, Settings, - Tag + Tag, + Package }; function getIconComponent(name: string): IconType { diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/+page.svelte similarity index 93% rename from apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/+page.svelte rename to apps/web/src/routes/(dashboard)/almoxarifado/+page.svelte index df710ce..c0efc11 100644 --- a/apps/web/src/routes/(dashboard)/recursos-humanos/almoxarifado/+page.svelte +++ b/apps/web/src/routes/(dashboard)/almoxarifado/+page.svelte @@ -123,7 +123,7 @@ @@ -136,7 +136,7 @@
@@ -155,7 +155,7 @@
@@ -297,7 +297,7 @@ +
+ +
+
+

{material.nome}

+

Detalhes do material

+
+
+ +
+ + + +
+ +
+
+

+ + Informações Básicas +

+
+
+ +

{material.codigo}

+
+
+ +

{material.nome}

+
+ {#if material.descricao} +
+ +

{material.descricao}

+
+ {/if} +
+ +
+ {material.categoria} +
+
+
+ +

{material.unidadeMedida}

+
+
+ +
+ {#if material.ativo} + + + Ativo + + {:else} + + + Inativo + + {/if} +
+
+
+
+
+ + +
+
+

+ + Estoque +

+
+
+ +
+

{material.estoqueAtual}

+ {material.unidadeMedida} + {#if material.estoqueAtual <= material.estoqueMinimo} + + {/if} +
+
+
+ +

{material.estoqueMinimo} {material.unidadeMedida}

+
+ {#if material.estoqueMaximo !== undefined} +
+ +

{material.estoqueMaximo} {material.unidadeMedida}

+
+ {/if} + {#if material.estoqueAtual <= material.estoqueMinimo} +
+ + Estoque abaixo do mínimo! Reposição necessária. +
+ {/if} +
+
+
+
+ + +
+
+

+ + Informações Adicionais +

+
+ {#if material.localizacao} +
+ +

{material.localizacao}

+
+ {/if} + {#if material.fornecedor} +
+ +

{material.fornecedor}

+
+ {/if} +
+
+
+ +{:else} +
+
+ +

Material não encontrado

+ +
+
+{/if} + diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/editar/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/editar/+page.svelte new file mode 100644 index 0000000..e90d4c3 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/[materialId]/editar/+page.svelte @@ -0,0 +1,363 @@ + + +{#if loadingData} +
+ +
+{:else} +
+ + + + +
+
+ +
+ +
+
+

Editar Material

+

Atualize as informações do material

+
+
+
+ + + {#if notice} +
+ {notice.text} +
+ {/if} + + +
+
+
{ e.preventDefault(); handleSubmit(); }}> +
+ +
+ + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + + {#each categoriasComuns as cat} + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+
+
+
+
+{/if} + diff --git a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte index 0151635..7eff9ee 100644 --- a/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte +++ b/apps/web/src/routes/(dashboard)/almoxarifado/materiais/cadastro/+page.svelte @@ -94,14 +94,7 @@