From 196ef9064387a8ba77076d38469f3d1ef0223a0d Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Thu, 11 Dec 2025 11:53:20 -0300 Subject: [PATCH 01/27] chore: add empty lines to improve code readability in fichaPontoPDF and error handling components --- apps/web/src/lib/utils/fichaPontoPDF.ts | 2 ++ apps/web/src/routes/(dashboard)/+error.svelte | 2 ++ apps/web/src/routes/+error.svelte | 2 ++ 3 files changed, 6 insertions(+) diff --git a/apps/web/src/lib/utils/fichaPontoPDF.ts b/apps/web/src/lib/utils/fichaPontoPDF.ts index 77b62c4..9f98f87 100644 --- a/apps/web/src/lib/utils/fichaPontoPDF.ts +++ b/apps/web/src/lib/utils/fichaPontoPDF.ts @@ -444,3 +444,5 @@ export function adicionarRodape(doc: jsPDF): void { + + diff --git a/apps/web/src/routes/(dashboard)/+error.svelte b/apps/web/src/routes/(dashboard)/+error.svelte index 5b7d9a1..1c780c7 100644 --- a/apps/web/src/routes/(dashboard)/+error.svelte +++ b/apps/web/src/routes/(dashboard)/+error.svelte @@ -83,3 +83,5 @@ + + diff --git a/apps/web/src/routes/+error.svelte b/apps/web/src/routes/+error.svelte index 6dcb403..ae3a9ab 100644 --- a/apps/web/src/routes/+error.svelte +++ b/apps/web/src/routes/+error.svelte @@ -83,3 +83,5 @@ + + -- 2.49.1 From 6936a59c21d9ead2b64f8151ddd1bdecec3d7ea0 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Thu, 11 Dec 2025 16:52:07 -0300 Subject: [PATCH 02/27] feat: implement cascading recalculation of monthly hour banks when past months are updated or adjusted --- packages/backend/convex/pontos.ts | 94 +++++++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index a0685d1..65bd06b 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -1800,7 +1800,14 @@ async function atualizarBancoHoras( // Atualizar banco de horas mensal const mes = data.substring(0, 7); // YYYY-MM - await calcularBancoHorasMensal(ctx, funcionarioId, mes); + + // Verificar se estamos editando um mês passado + const hoje = new Date(); + const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; + const estaEditandoMesPassado = mes < mesAtual; + + // Se estamos editando um mês passado, recalcular em cascata para atualizar meses seguintes + await calcularBancoHorasMensal(ctx, funcionarioId, mes, estaEditandoMesPassado); } /** @@ -1918,14 +1925,74 @@ export const obterBancoHorasFuncionario = query({ } }); +/** + * Recalcula meses seguintes em cascata quando um mês anterior é atualizado + * Isso garante que os saldos iniciais dos meses seguintes sejam atualizados corretamente + */ +async function recalcularMesesSeguintes( + ctx: MutationCtx, + funcionarioId: Id<'funcionarios'>, + mesAtualizado: string // YYYY-MM do mês que foi atualizado +): Promise { + const hoje = new Date(); + const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; + + // Se o mês atualizado já é o mês atual ou futuro, não precisa recalcular nada + if (mesAtualizado >= mesAtual) { + return; + } + + // Recalcular todos os meses do mês seguinte ao atualizado até o mês atual + // Calcular primeiro mês a recalcular (mês seguinte ao atualizado) + const [anoAtualizado, mesNumAtualizado] = mesAtualizado.split('-').map(Number); + let anoIter = anoAtualizado; + let mesNumIter = mesNumAtualizado + 1; + if (mesNumIter > 12) { + mesNumIter = 1; + anoIter += 1; + } + + // Continuar enquanto o mês iterado for menor ou igual ao mês atual + while (true) { + const mesIterStr = `${anoIter}-${String(mesNumIter).padStart(2, '0')}`; + + // Se passou do mês atual, parar + if (mesIterStr > mesAtual) { + break; + } + + // Verificar se existe registro mensal para este mês + const bancoMensalExistente = await ctx.db + .query('bancoHorasMensal') + .withIndex('by_funcionario_mes', (q) => + q.eq('funcionarioId', funcionarioId).eq('mes', mesIterStr) + ) + .first(); + + // Se existe registro, recalcular (o saldo inicial mudou porque o mês anterior mudou) + if (bancoMensalExistente) { + await calcularBancoHorasMensal(ctx, funcionarioId, mesIterStr, false); // false = não recalcular cascata novamente + } + + // Avançar para o próximo mês + mesNumIter += 1; + if (mesNumIter > 12) { + mesNumIter = 1; + anoIter += 1; + } + } +} + /** * Calcula e atualiza banco de horas mensal para um funcionário * Esta função deve ser chamada após atualizações no banco de horas diário + * @param recalcularCascata - Se true, recalcula automaticamente os meses seguintes (padrão: true) */ async function calcularBancoHorasMensal( ctx: MutationCtx, funcionarioId: Id<'funcionarios'>, - mes: string // YYYY-MM + mes: string, // YYYY-MM + recalcularCascata: boolean = true // Por padrão, recalcula em cascata ): Promise { // Buscar todos os bancoHoras do mês const dataInicio = `${mes}-01`; @@ -2045,6 +2112,11 @@ async function calcularBancoHorasMensal( atualizadoEm: agora }); } + + // Recalcular meses seguintes em cascata se solicitado + if (recalcularCascata) { + await recalcularMesesSeguintes(ctx, funcionarioId, mes); + } } /** @@ -2534,7 +2606,14 @@ export const ajustarBancoHoras = mutation({ // Recalcular banco de horas mensal após ajuste const mes = hoje.substring(0, 7); // YYYY-MM - await calcularBancoHorasMensal(ctx, args.funcionarioId, mes); + + // Verificar se estamos ajustando um mês passado + const hojeDate = new Date(); + const mesAtual = `${hojeDate.getFullYear()}-${String(hojeDate.getMonth() + 1).padStart(2, '0')}`; + const estaAjustandoMesPassado = mes < mesAtual; + + // Se estamos ajustando um mês passado, recalcular em cascata para atualizar meses seguintes + await calcularBancoHorasMensal(ctx, args.funcionarioId, mes, estaAjustandoMesPassado); // Criar registro de homologação (mantido para compatibilidade) const homologacaoId = await ctx.db.insert('homologacoesPonto', { @@ -3727,7 +3806,14 @@ export const criarAjusteBancoHoras = mutation({ // Recalcular banco de horas mensal const mes = args.dataAplicacao.substring(0, 7); - await calcularBancoHorasMensal(ctx, args.funcionarioId, mes); + + // Verificar se estamos aplicando ajuste em um mês passado + const hoje = new Date(); + const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`; + const estaAplicandoEmMesPassado = mes < mesAtual; + + // Se estamos aplicando em um mês passado, recalcular em cascata para atualizar meses seguintes + await calcularBancoHorasMensal(ctx, args.funcionarioId, mes, estaAplicandoEmMesPassado); return { ajusteId, success: true }; } -- 2.49.1 From 457e89e38620764426f730a478f4cc5c21e0f29e Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Fri, 12 Dec 2025 11:13:56 -0300 Subject: [PATCH 03/27] feat: enhance time synchronization logic with timeout and loading state management --- .../ponto/RelogioSincronizado.svelte | 65 +++++++++++++++---- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte index 181d24f..9f09cc5 100644 --- a/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte +++ b/apps/web/src/lib/components/ponto/RelogioSincronizado.svelte @@ -9,12 +9,21 @@ let tempoAtual = $state(new Date()); let sincronizado = $state(false); + let sincronizando = $state(false); let usandoServidorExterno = $state(false); let offsetSegundos = $state(0); let erro = $state(null); let intervalId: ReturnType | null = null; + let intervaloSincronizacao: ReturnType | null = null; + let sincronizacaoEmAndamento = $state(false); // Flag para evitar múltiplas sincronizações simultâneas async function atualizarTempo() { + // Evitar múltiplas sincronizações simultâneas + if (sincronizacaoEmAndamento) { + return; + } + sincronizacaoEmAndamento = true; + sincronizando = true; try { const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {}); // Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido @@ -25,7 +34,12 @@ if (config.usarServidorExterno) { try { - const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {}); + // Adicionar timeout de 10 segundos para sincronização + const sincronizacaoPromise = client.action(api.configuracaoRelogio.sincronizarTempo, {}); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout na sincronização (10s)')), 10000) + ); + const resultado = await Promise.race([sincronizacaoPromise, timeoutPromise]); if (resultado.sucesso && resultado.timestamp) { timestampBase = resultado.timestamp; sincronizado = true; @@ -43,7 +57,11 @@ usandoServidorExterno = false; erro = 'Usando relógio do PC (falha na sincronização)'; } else { - throw error; + // 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)'; } } } else { @@ -71,6 +89,9 @@ tempoAtual = new Date(obterTempoPC()); sincronizado = false; erro = 'Erro ao obter tempo do servidor'; + } finally { + sincronizando = false; + sincronizacaoEmAndamento = false; } } @@ -81,17 +102,34 @@ } onMount(async () => { - await atualizarTempo(); - // Sincronizar a cada 30 segundos - setInterval(atualizarTempo, 30000); + // Inicializar com relógio do PC imediatamente para não bloquear a interface + tempoAtual = new Date(obterTempoPC()); + sincronizado = false; + erro = 'Usando relógio do PC'; // 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 + setTimeout(() => { + atualizarTempo().catch((error) => { + console.error('Erro ao sincronizar tempo em background:', error); + }); + }, 100); + // Sincronizar a cada 30 segundos + intervaloSincronizacao = setInterval(() => { + atualizarTempo().catch((error) => { + console.error('Erro ao sincronizar tempo periódico:', error); + }); + }, 30000); }); onDestroy(() => { if (intervalId) { clearInterval(intervalId); } + if (intervaloSincronizacao) { + clearInterval(intervaloSincronizacao); + } + sincronizacaoEmAndamento = false; }); const horaFormatada = $derived.by(() => { @@ -131,13 +169,18 @@
- {#if sincronizado} + {#if sincronizando} + + Sincronizando com servidor... + {:else if sincronizado} {#if usandoServidorExterno} -- 2.49.1 From 60b53dac7440df5024d68457478cadadb75a7837 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Mon, 15 Dec 2025 11:50:51 -0300 Subject: [PATCH 04/27] 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 05/27] 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 06/27] 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 011a867aac7d45e1060fd52ceaedf6e312ea0def Mon Sep 17 00:00:00 2001 From: killer-cf Date: Thu, 18 Dec 2025 15:52:57 -0300 Subject: [PATCH 07/27] refactor: update fluxo instance management by enhancing the creation modal and improving the sidebar navigation structure, ensuring better user experience and code maintainability --- apps/web/src/lib/components/Sidebar.svelte | 3 +- .../routes/(dashboard)/fluxos/+page.svelte | 532 ++++------ .../fluxos/[id]-fluxo/+page.svelte | 947 ------------------ .../fluxos/instancias/+page.svelte | 410 -------- .../[id]}/+page.server.ts | 0 .../instancias}/[id]/+page.svelte | 6 +- .../{instancias => templates}/+page.server.ts | 0 .../(dashboard)/fluxos/templates/+page.svelte | 514 ++++++++++ .../(dashboard)/licitacoes/+page.svelte | 4 +- .../licitacoes/fluxos/+page.svelte | 402 -------- .../licitacoes/fluxos/[id]/+page.server.ts | 3 - 11 files changed, 736 insertions(+), 2085 deletions(-) delete mode 100644 apps/web/src/routes/(dashboard)/fluxos/[id]-fluxo/+page.svelte delete mode 100644 apps/web/src/routes/(dashboard)/fluxos/instancias/+page.svelte rename apps/web/src/routes/(dashboard)/fluxos/{[id]-fluxo => instancias/[id]}/+page.server.ts (100%) rename apps/web/src/routes/(dashboard)/{licitacoes/fluxos => fluxos/instancias}/[id]/+page.svelte (99%) rename apps/web/src/routes/(dashboard)/fluxos/{instancias => templates}/+page.server.ts (100%) create mode 100644 apps/web/src/routes/(dashboard)/fluxos/templates/+page.svelte delete mode 100644 apps/web/src/routes/(dashboard)/licitacoes/fluxos/+page.svelte delete mode 100644 apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.server.ts diff --git a/apps/web/src/lib/components/Sidebar.svelte b/apps/web/src/lib/components/Sidebar.svelte index 45c0c96..573f7ac 100644 --- a/apps/web/src/lib/components/Sidebar.svelte +++ b/apps/web/src/lib/components/Sidebar.svelte @@ -174,7 +174,8 @@ { label: 'Meus Processos', link: '/fluxos', - permission: { recurso: 'fluxos_instancias', acao: 'listar' } + permission: { recurso: 'fluxos_instancias', acao: 'listar' }, + exact: true }, { label: 'Modelos de Fluxo', diff --git a/apps/web/src/routes/(dashboard)/fluxos/+page.svelte b/apps/web/src/routes/(dashboard)/fluxos/+page.svelte index 41b48a0..861c710 100644 --- a/apps/web/src/routes/(dashboard)/fluxos/+page.svelte +++ b/apps/web/src/routes/(dashboard)/fluxos/+page.svelte @@ -3,50 +3,52 @@ import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { useConvexClient, useQuery } from 'convex-svelte'; import { goto } from '$app/navigation'; + import { resolve } from '$app/paths'; const client = useConvexClient(); - // Estado do filtro - let statusFilter = $state<'draft' | 'published' | 'archived' | undefined>(undefined); + // Estado dos filtros + let statusFilter = $state<'active' | 'completed' | 'cancelled' | undefined>(undefined); - // Query de templates - const templatesQuery = useQuery(api.flows.listTemplates, () => + // Query de instâncias + const instancesQuery = useQuery(api.flows.listInstances, () => statusFilter ? { status: statusFilter } : {} ); + // Query de templates publicados (para o modal de criação) + const publishedTemplatesQuery = useQuery(api.flows.listTemplates, { + status: 'published' + }); + // Modal de criação let showCreateModal = $state(false); - let newTemplateName = $state(''); - let newTemplateDescription = $state(''); + let selectedTemplateId = $state | ''>(''); + let contratoId = $state | ''>(''); + let managerId = $state | ''>(''); let isCreating = $state(false); let createError = $state(null); - // Modal de confirmação de exclusão - let showDeleteModal = $state(false); - let templateToDelete = $state<{ - _id: Id<'flowTemplates'>; - name: string; - } | null>(null); - let isDeleting = $state(false); - let deleteError = $state(null); + // Query de usuários (para seleção de gerente) + const usuariosQuery = useQuery(api.usuarios.listar, {}); + + // Query de contratos (para seleção) + const contratosQuery = useQuery(api.contratos.listar, {}); function openCreateModal() { - newTemplateName = ''; - newTemplateDescription = ''; + selectedTemplateId = ''; + contratoId = ''; + managerId = ''; createError = null; showCreateModal = true; } function closeCreateModal() { showCreateModal = false; - newTemplateName = ''; - newTemplateDescription = ''; - createError = null; } async function handleCreate() { - if (!newTemplateName.trim()) { - createError = 'O nome é obrigatório'; + if (!selectedTemplateId || !managerId) { + createError = 'Template e gerente são obrigatórios'; return; } @@ -54,72 +56,28 @@ createError = null; try { - const templateId = await client.mutation(api.flows.createTemplate, { - name: newTemplateName.trim(), - description: newTemplateDescription.trim() || undefined + const instanceId = await client.mutation(api.flows.instantiateFlow, { + flowTemplateId: selectedTemplateId as Id<'flowTemplates'>, + contratoId: contratoId ? (contratoId as Id<'contratos'>) : undefined, + managerId: managerId as Id<'usuarios'> }); closeCreateModal(); - // Navegar para o editor - goto(`/fluxos/${templateId}/editor`); + goto(resolve(`/fluxos/instancias/${instanceId}`)); } catch (e) { - createError = e instanceof Error ? e.message : 'Erro ao criar template'; + createError = e instanceof Error ? e.message : 'Erro ao criar instância'; } finally { isCreating = false; } } - function openDeleteModal(template: { _id: Id<'flowTemplates'>; name: string }) { - templateToDelete = template; - deleteError = null; - showDeleteModal = true; - } - - function closeDeleteModal() { - showDeleteModal = false; - templateToDelete = null; - deleteError = null; - } - - async function handleDelete() { - if (!templateToDelete) return; - - isDeleting = true; - deleteError = null; - - try { - await client.mutation(api.flows.deleteTemplate, { - id: templateToDelete._id - }); - closeDeleteModal(); - } catch (e) { - deleteError = e instanceof Error ? e.message : 'Erro ao excluir template'; - } finally { - isDeleting = false; - } - } - - async function handleStatusChange( - templateId: Id<'flowTemplates'>, - newStatus: 'draft' | 'published' | 'archived' - ) { - try { - await client.mutation(api.flows.updateTemplate, { - id: templateId, - status: newStatus - }); - } catch (e) { - console.error('Erro ao atualizar status:', e); - } - } - - function getStatusBadge(status: 'draft' | 'published' | 'archived') { + function getStatusBadge(status: 'active' | 'completed' | 'cancelled') { switch (status) { - case 'draft': - return { class: 'badge-warning', label: 'Rascunho' }; - case 'published': - return { class: 'badge-success', label: 'Publicado' }; - case 'archived': - return { class: 'badge-neutral', label: 'Arquivado' }; + case 'active': + return { class: 'badge-info', label: 'Em Andamento' }; + case 'completed': + return { class: 'badge-success', label: 'Concluído' }; + case 'cancelled': + return { class: 'badge-error', label: 'Cancelado' }; } } @@ -127,43 +85,70 @@ return new Date(timestamp).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', - year: 'numeric' + year: 'numeric', + hour: '2-digit', + minute: '2-digit' }); } + + function getProgressPercentage(completed: number, total: number): number { + if (total === 0) return 0; + return Math.round((completed / total) * 100); + }
-
-
+
+
- - Gestão de Fluxos - +
+ + + Templates + + + Execução + +

- Templates de Fluxo + Instâncias de Fluxo

- Crie e gerencie templates de fluxo de trabalho. Templates definem os passos e - responsabilidades que serão instanciados para projetos ou contratos. + Acompanhe e gerencie as execuções de fluxos de trabalho. Visualize o progresso, documentos + e responsáveis de cada etapa.

-
- +
- {#if templatesQuery.isLoading} + {#if instancesQuery.isLoading}
- +
- {:else if !templatesQuery.data || templatesQuery.data.length === 0} + {:else if !instancesQuery.data || instancesQuery.data.length === 0}
-

Nenhum template encontrado

+

+ Nenhuma instância encontrada +

{statusFilter - ? 'Não há templates com este status.' - : 'Clique em "Novo Template" para criar o primeiro.'} + ? 'Não há instâncias com este status.' + : 'Clique em "Nova Instância" para iniciar um fluxo.'}

{:else} -
- {#each templatesQuery.data as template (template._id)} - {@const statusBadge = getStatusBadge(template.status)} -
-
-
-

{template.name}

- {statusBadge.label} -
- - {#if template.description} -

- {template.description} -

- {/if} - -
- - + + + + + + + + + + + + + {#each instancesQuery.data as instance (instance._id)} + {@const statusBadge = getStatusBadge(instance.status)} + {@const progressPercent = getProgressPercentage( + instance.progress.completed, + instance.progress.total + )} + + + + + + + + + + {/each} + +
TemplateContratoGerenteProgressoStatusIniciado emAções
+
{instance.templateName ?? 'Template desconhecido'}
+
+ {#if instance.contratoId} + {instance.contratoId} + {:else} + - + {/if} + {instance.managerName ?? '-'} +
+ + + {instance.progress.completed}/{instance.progress.total} + +
+
+ {statusBadge.label} + {formatDate(instance.startedAt)} + - - - {template.stepsCount} passos - - - - {formatDate(template.createdAt)} - - - - - - - {/each} + Ver + +
{/if}
- - -
- - - Ver Fluxos de Trabalho - -
{#if showCreateModal}