From b01d2d6786de42e6031389e30b9a1f2be1b24393 Mon Sep 17 00:00:00 2001 From: deyvisonwanderley Date: Tue, 18 Nov 2025 15:28:26 -0300 Subject: [PATCH] feat: enhance point registration and management features - Added functionality to capture and display images during point registration, improving user experience. - Implemented error handling for image uploads and webcam access, ensuring smoother operation. - Introduced a justification field for point registration, allowing users to provide context for their entries. - Enhanced the backend to support new features, including image handling and justification storage. - Updated UI components for better layout and responsiveness, improving overall usability. --- .../components/ponto/ComprovantePonto.svelte | 105 +++++ .../lib/components/ponto/RegistroPonto.svelte | 396 +++++++++++++++--- .../lib/components/ponto/WebcamCapture.svelte | 180 +++++--- .../ponto/WidgetGestaoPontos.svelte | 108 ++--- apps/web/src/lib/utils/deviceInfo.ts | 8 +- .../(dashboard)/gestao-pessoas/+page.svelte | 2 +- .../registro-pontos/+page.svelte | 63 ++- .../secretaria-executiva/+page.svelte | 2 +- packages/backend/convex/pontos.ts | 245 ++++++++++- packages/backend/convex/schema.ts | 19 +- 10 files changed, 941 insertions(+), 187 deletions(-) diff --git a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte index 3e149e9..cecf612 100644 --- a/apps/web/src/lib/components/ponto/ComprovantePonto.svelte +++ b/apps/web/src/lib/components/ponto/ComprovantePonto.svelte @@ -169,6 +169,91 @@ } } + // Imagem capturada (se disponível) + if (registro.imagemUrl) { + yPosition += 10; + // Verificar se precisa de nova página + if (yPosition > 200) { + doc.addPage(); + yPosition = 20; + } + + doc.setFont('helvetica', 'bold'); + doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' }); + doc.setFont('helvetica', 'normal'); + yPosition += 10; + + try { + // Carregar imagem usando fetch para evitar problemas de CORS + const response = await fetch(registro.imagemUrl); + if (!response.ok) { + throw new Error('Erro ao carregar imagem'); + } + + const blob = await response.blob(); + const reader = new FileReader(); + + // Converter blob para base64 + const base64 = await new Promise((resolve, reject) => { + reader.onloadend = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + } else { + reject(new Error('Erro ao converter imagem')); + } + }; + reader.onerror = () => reject(new Error('Erro ao ler imagem')); + reader.readAsDataURL(blob); + }); + + // Criar elemento de imagem para obter dimensões + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error('Erro ao processar imagem')); + img.src = base64; + setTimeout(() => reject(new Error('Timeout ao processar imagem')), 10000); + }); + + // Calcular dimensões para caber na página (largura máxima 80mm, manter proporção) + const maxWidth = 80; + const maxHeight = 60; + let imgWidth = img.width; + let imgHeight = img.height; + const aspectRatio = imgWidth / imgHeight; + + if (imgWidth > maxWidth || imgHeight > maxHeight) { + if (aspectRatio > 1) { + // Imagem horizontal + imgWidth = maxWidth; + imgHeight = maxWidth / aspectRatio; + } else { + // Imagem vertical + imgHeight = maxHeight; + imgWidth = maxHeight * aspectRatio; + } + } + + // Centralizar imagem + const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2; + + // Verificar se cabe na página atual + if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) { + doc.addPage(); + yPosition = 20; + } + + // Adicionar imagem ao PDF usando base64 + doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight); + yPosition += imgHeight + 10; + } catch (error) { + console.warn('Erro ao adicionar imagem ao PDF:', error); + doc.setFontSize(10); + doc.text('Foto não disponível para impressão', 105, yPosition, { align: 'center' }); + yPosition += 6; + } + } + // Rodapé const pageCount = doc.getNumberOfPages(); for (let i = 1; i <= pageCount; i++) { @@ -248,6 +333,26 @@ + + {#if registro.imagemUrl} +
+
+

Foto Capturada

+
+ Foto do registro de ponto { + console.error('Erro ao carregar imagem:', e); + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+
+
+ {/if} +
- {:else} -
- - Foto capturada -
- {/if} - - + +
+ +
+ +
+ + {#if historicoSaldo && registrosOrdenados.length > 0} +
+
+

+ + Histórico do Dia +

+ + +
+
+
+

Saldo de Horas

+

+ {saldoFormatado} +

+
+ {#if saldoPositivo} + + {:else} + + {/if} +
+
+

Carga Horária Diária: {Math.floor(historicoSaldo.cargaHorariaDiaria / 60)}h {historicoSaldo.cargaHorariaDiaria % 60}min

+

Horas Trabalhadas: {Math.floor(historicoSaldo.horasTrabalhadas / 60)}h {historicoSaldo.horasTrabalhadas % 60}min

+
+
+ + +
+
+

Registros Realizados

+
+ {#each registrosOrdenados as registro (registro._id)} +
+
+
+
+
+ {getTipoRegistroLabel(registro.tipo)} + {#if registro.dentroDoPrazo} + + {:else} + + {/if} +
+

+ {formatarHoraPonto(registro.hora, registro.minuto)} +

+ {#if registro.justificativa} +
+

Justificativa:

+

{registro.justificativa}

+
+ {/if} +
+
+
+
+ {/each} +
+
+
+
+ {/if} + {#if mostrandoWebcam} - diff --git a/apps/web/src/lib/components/ponto/WebcamCapture.svelte b/apps/web/src/lib/components/ponto/WebcamCapture.svelte index ee3ac88..ff983ec 100644 --- a/apps/web/src/lib/components/ponto/WebcamCapture.svelte +++ b/apps/web/src/lib/components/ponto/WebcamCapture.svelte @@ -4,11 +4,13 @@ import { validarWebcamDisponivel, capturarWebcamComPreview } from '$lib/utils/webcam'; interface Props { - onCapture: (blob: Blob) => void; + onCapture: (blob: Blob | null) => void; onCancel: () => void; + onError?: () => void; + autoCapture?: boolean; } - let { onCapture, onCancel }: Props = $props(); + let { onCapture, onCancel, onError, autoCapture = false }: Props = $props(); let videoElement: HTMLVideoElement | null = $state(null); let canvasElement: HTMLCanvasElement | null = $state(null); @@ -19,9 +21,12 @@ let previewUrl = $state(null); onMount(async () => { - webcamDisponivel = await validarWebcamDisponivel(); - if (!webcamDisponivel) { - erro = 'Webcam não disponível'; + // Tentar obter permissão de webcam automaticamente + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + erro = 'Webcam não suportada'; + if (autoCapture && onError) { + onError(); + } return; } @@ -30,18 +35,36 @@ video: { width: { ideal: 1280 }, height: { ideal: 720 }, - facingMode: 'user', - }, + facingMode: 'user' + } }); + webcamDisponivel = true; + if (videoElement) { videoElement.srcObject = stream; await videoElement.play(); + + // Se for captura automática, aguardar um pouco e capturar + if (autoCapture) { + // Aguardar 1 segundo para o usuário se posicionar + setTimeout(() => { + if (videoElement && canvasElement && !capturando && !previewUrl) { + capturar(); + } + }, 1000); + } } } catch (error) { console.error('Erro ao acessar webcam:', error); - erro = 'Erro ao acessar webcam. Verifique as permissões.'; + erro = 'Erro ao acessar webcam. Continuando sem foto.'; webcamDisponivel = false; + // Se for captura automática e houver erro, chamar onError para continuar sem foto + if (autoCapture && onError) { + setTimeout(() => { + onError(); + }, 500); + } } }); @@ -56,6 +79,9 @@ async function capturar() { if (!videoElement || !canvasElement) { + if (autoCapture && onError) { + onError(); + } return; } @@ -71,12 +97,31 @@ stream.getTracks().forEach((track) => track.stop()); stream = null; } + + // Se for captura automática, confirmar automaticamente após um pequeno delay + if (autoCapture) { + setTimeout(() => { + confirmar(); + }, 500); + } } else { erro = 'Falha ao capturar imagem'; + // Se for captura automática e falhar, continuar sem foto + if (autoCapture && onError) { + setTimeout(() => { + onError(); + }, 500); + } } } catch (error) { console.error('Erro ao capturar:', error); - erro = 'Erro ao capturar imagem'; + erro = 'Erro ao capturar imagem. Continuando sem foto.'; + // Se for captura automática e houver erro, continuar sem foto + if (autoCapture && onError) { + setTimeout(() => { + onError(); + }, 500); + } } finally { capturando = false; } @@ -116,8 +161,8 @@ video: { width: { ideal: 1280 }, height: { ideal: 720 }, - facingMode: 'user', - }, + facingMode: 'user' + } }); if (videoElement) { @@ -131,69 +176,102 @@ } -
+
{#if !webcamDisponivel && !erro} -
+
Verificando webcam...
- {:else if erro && !webcamDisponivel} -
+ {#if !autoCapture} +
+ +
+ {/if} + {:else if erro && !webcamDisponivel} +
{erro}
- + {#if autoCapture} +
+ O registro será feito sem foto. +
+ {:else} +
+ +
+ {/if} {:else if previewUrl} -
- Preview -
- - - -
+
+ {#if autoCapture} + +
+ Foto capturada automaticamente... +
+ {/if} + Preview + {#if !autoCapture} + +
+ + + +
+ {/if}
{:else} -
-
+
+ {#if autoCapture} +
+ Capturando foto automaticamente... +
+ {/if} +
{#if erro} -
+
{erro}
{/if} -
- - -
+ {#if !autoCapture} + +
+ + +
+ {/if}
{/if}
- diff --git a/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte b/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte index 183d11a..ce48be3 100644 --- a/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte +++ b/apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte @@ -1,68 +1,68 @@ -
+
-
-
-
- -
-
-

Gestão de Pontos

-

Registros de ponto do dia

+ +
+
+
+
+
+

+ Gestão de Pontos +

+

Registros de ponto do dia

+
- {#if estatisticas} - diff --git a/apps/web/src/lib/utils/deviceInfo.ts b/apps/web/src/lib/utils/deviceInfo.ts index 5d939fc..c91d645 100644 --- a/apps/web/src/lib/utils/deviceInfo.ts +++ b/apps/web/src/lib/utils/deviceInfo.ts @@ -248,7 +248,7 @@ async function obterLocalizacao(): Promise<{ return new Promise((resolve) => { const timeout = setTimeout(() => { resolve({}); - }, 10000); // Timeout de 10 segundos + }, 5000); // Timeout de 5 segundos (reduzido para não bloquear) navigator.geolocation.getCurrentPosition( async (position) => { @@ -306,9 +306,9 @@ async function obterLocalizacao(): Promise<{ resolve({}); }, { - enableHighAccuracy: true, - timeout: 10000, - maximumAge: 0, + enableHighAccuracy: false, // false para ser mais rápido + timeout: 5000, // Timeout reduzido para 5 segundos + maximumAge: 60000, // Aceitar localização de até 1 minuto atrás } ); }); diff --git a/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte b/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte index ce0a86b..f36c385 100644 --- a/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte +++ b/apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte @@ -117,7 +117,7 @@
-
+
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 d88081a..6f97ff6 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 @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { useQuery, useConvexClient } from 'convex-svelte'; import { api } from '@sgse-app/backend/convex/_generated/api'; - import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle } from 'lucide-svelte'; + import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle, TrendingUp, TrendingDown } from 'lucide-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto'; import jsPDF from 'jspdf'; @@ -17,18 +17,22 @@ let funcionarioIdFiltro = $state | ''>(''); let carregando = $state(false); - // Queries - const funcionariosQuery = useQuery(api.funcionarios.getAll, {}); - const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, { + // Parâmetros reativos para queries + const registrosParams = $derived({ funcionarioId: funcionarioIdFiltro || undefined, dataInicio, dataFim, }); - const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, { + const estatisticasParams = $derived({ dataInicio, dataFim, }); + // Queries + const funcionariosQuery = useQuery(api.funcionarios.getAll, {}); + const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams); + const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, estatisticasParams); + const funcionarios = $derived(funcionariosQuery?.data || []); const registros = $derived(registrosQuery?.data || []); const estatisticas = $derived(estatisticasQuery?.data); @@ -39,6 +43,7 @@ string, { funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null; + funcionarioId: Id<'funcionarios'>; registros: typeof registros; } > = {}; @@ -48,6 +53,7 @@ if (!agrupados[key]) { agrupados[key] = { funcionario: registro.funcionario, + funcionarioId: registro.funcionarioId, registros: [], }; } @@ -57,6 +63,19 @@ return Object.values(agrupados); }); + // Query para banco de horas de cada funcionário + const funcionariosComBancoHoras = $derived.by(() => { + return registrosAgrupados.map((grupo) => grupo.funcionarioId); + }); + + // Função para formatar saldo de horas + function formatarSaldoHoras(minutos: number): string { + const horas = Math.floor(Math.abs(minutos) / 60); + const mins = Math.abs(minutos) % 60; + const sinal = minutos >= 0 ? '+' : '-'; + return `${sinal}${horas}h ${mins}min`; + } + async function imprimirFichaPonto(funcionarioId: Id<'funcionarios'>) { const registrosFuncionario = registros.filter((r) => r.funcionarioId === funcionarioId); if (registrosFuncionario.length === 0) { @@ -297,7 +316,7 @@
-
+

{grupo.funcionario?.nome || 'Funcionário não encontrado'}

@@ -307,9 +326,39 @@

{/if}
+ + + {#key grupo.funcionarioId} + {@const bancoHorasQuery = useQuery( + api.pontos.obterBancoHorasFuncionario, + { funcionarioId: grupo.funcionarioId } + )} + {@const bancoHoras = bancoHorasQuery?.data} + {@const saldoAcumulado = bancoHoras?.saldoAcumuladoMinutos ?? 0} + {@const saldoPositivo = saldoAcumulado >= 0} + + {#if bancoHoras} +
+
+ {#if saldoPositivo} + + {:else} + + {/if} +
+

Banco de Horas

+

+ {formatarSaldoHoras(saldoAcumulado)} +

+
+
+
+ {/if} + {/key} +
-
+
diff --git a/packages/backend/convex/pontos.ts b/packages/backend/convex/pontos.ts index 8165a5d..78e0afe 100644 --- a/packages/backend/convex/pontos.ts +++ b/packages/backend/convex/pontos.ts @@ -1,5 +1,5 @@ import { v } from 'convex/values'; -import { mutation, query } from './_generated/server'; +import { internalMutation, mutation, query } from './_generated/server'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { getCurrentUserFunction } from './auth'; import type { Id } from './_generated/dataModel'; @@ -52,6 +52,7 @@ interface InformacoesDispositivo { /** * Calcula se o registro está dentro do prazo baseado na configuração + * Se toleranciaMinutos for 0, desconsidera atrasos (sempre retorna true) */ function calcularStatusPonto( hora: number, @@ -59,6 +60,11 @@ function calcularStatusPonto( horarioConfigurado: string, toleranciaMinutos: number ): boolean { + // Se tolerância for 0, desconsiderar atrasos (qualquer registro é válido) + if (toleranciaMinutos === 0) { + return true; + } + const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number); const totalMinutosRegistro = hora * 60 + minuto; const totalMinutosConfigurado = horaConfig * 60 + minutoConfig; @@ -141,6 +147,7 @@ export const registrarPonto = mutation({ ), timestamp: v.number(), sincronizadoComServidor: v.boolean(), + justificativa: v.optional(v.string()), }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); @@ -225,6 +232,7 @@ export const registrarPonto = mutation({ sincronizadoComServidor: args.sincronizadoComServidor, toleranciaMinutos: config.toleranciaMinutos, dentroDoPrazo, + justificativa: args.justificativa, ipAddress: args.informacoesDispositivo?.ipAddress, ipPublico: args.informacoesDispositivo?.ipPublico, ipLocal: args.informacoesDispositivo?.ipLocal, @@ -257,6 +265,9 @@ export const registrarPonto = mutation({ criadoEm: Date.now(), }); + // Atualizar banco de horas após registrar + await atualizarBancoHoras(ctx, usuario.funcionarioId, data, config); + return { registroId, tipo, dentroDoPrazo }; }, }); @@ -421,8 +432,15 @@ export const obterRegistro = query({ simbolo = await ctx.db.get(funcionario.simboloId); } + // Obter URL da imagem se existir + let imagemUrl = null; + if (registro.imagemId) { + imagemUrl = await ctx.storage.getUrl(registro.imagemId); + } + return { ...registro, + imagemUrl, funcionario: funcionario ? { nome: funcionario.nome, @@ -440,3 +458,228 @@ export const obterRegistro = query({ }, }); +/** + * Calcula carga horária diária esperada em minutos + */ +function calcularCargaHorariaDiaria(config: { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; +}): number { + const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number); + const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number); + const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number); + const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number); + + const minutosEntrada = horaEntrada * 60 + minutoEntrada; + const minutosSaidaAlmoco = horaSaidaAlmoco * 60 + minutoSaidaAlmoco; + const minutosRetornoAlmoco = horaRetornoAlmoco * 60 + minutoRetornoAlmoco; + const minutosSaida = horaSaida * 60 + minutoSaida; + + // Calcular horas trabalhadas: (saída almoço - entrada) + (saída - retorno almoço) + const horasManha = minutosSaidaAlmoco - minutosEntrada; + const horasTarde = minutosSaida - minutosRetornoAlmoco; + + return horasManha + horasTarde; +} + +/** + * Calcula horas trabalhadas do dia baseado nos registros + */ +function calcularHorasTrabalhadas(registros: Array<{ + tipo: string; + hora: number; + minuto: number; +}>): number { + // Ordenar registros por timestamp + const registrosOrdenados = [...registros].sort((a, b) => { + const minutosA = a.hora * 60 + a.minuto; + const minutosB = b.hora * 60 + b.minuto; + return minutosA - minutosB; + }); + + let horasTrabalhadas = 0; + + // Procurar entrada e saída + const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada'); + const saida = registrosOrdenados.find((r) => r.tipo === 'saida'); + + if (entrada && saida) { + const minutosEntrada = entrada.hora * 60 + entrada.minuto; + const minutosSaida = saida.hora * 60 + saida.minuto; + + // Procurar saída e retorno do almoço + const saidaAlmoco = registrosOrdenados.find((r) => r.tipo === 'saida_almoco'); + const retornoAlmoco = registrosOrdenados.find((r) => r.tipo === 'retorno_almoco'); + + if (saidaAlmoco && retornoAlmoco) { + // Tem intervalo de almoço: (saída almoço - entrada) + (saída - retorno almoço) + const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto; + const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto; + + const horasManha = minutosSaidaAlmoco - minutosEntrada; + const horasTarde = minutosSaida - minutosRetornoAlmoco; + horasTrabalhadas = horasManha + horasTarde; + } else { + // Sem intervalo de almoço registrado: saída - entrada + horasTrabalhadas = minutosSaida - minutosEntrada; + } + } + + return horasTrabalhadas; +} + +/** + * Atualiza ou cria registro de banco de horas para o dia + */ +async function atualizarBancoHoras( + ctx: MutationCtx, + funcionarioId: Id<'funcionarios'>, + data: string, + config: { + horarioEntrada: string; + horarioSaidaAlmoco: string; + horarioRetornoAlmoco: string; + horarioSaida: string; + } +): Promise { + // Buscar todos os registros do dia + const registrosDoDia = await ctx.db + .query('registrosPonto') + .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) + .collect(); + + // Calcular carga horária esperada + const cargaHorariaDiaria = calcularCargaHorariaDiaria(config); + + // Calcular horas trabalhadas + const horasTrabalhadas = calcularHorasTrabalhadas(registrosDoDia); + + // Calcular saldo (positivo = horas extras, negativo = déficit) + const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria; + + // Buscar banco de horas existente + const bancoHorasExistente = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) + .first(); + + const registrosPontoIds = registrosDoDia.map((r) => r._id); + + if (bancoHorasExistente) { + // Atualizar existente + await ctx.db.patch(bancoHorasExistente._id, { + cargaHorariaDiaria, + horasTrabalhadas, + saldoMinutos, + registrosPontoIds, + calculadoEm: Date.now(), + }); + } else { + // Criar novo + await ctx.db.insert('bancoHoras', { + funcionarioId, + data, + cargaHorariaDiaria, + horasTrabalhadas, + saldoMinutos, + registrosPontoIds, + calculadoEm: Date.now(), + }); + } +} + +/** + * Obtém histórico e saldo do dia + */ +export const obterHistoricoESaldoDia = query({ + args: { + funcionarioId: v.id('funcionarios'), + data: v.string(), // YYYY-MM-DD + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario || !usuario.funcionarioId) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é o próprio funcionário ou tem permissão + if (usuario.funcionarioId !== args.funcionarioId) { + // TODO: Verificar permissão de RH + } + + // Buscar registros do dia + const registros = await ctx.db + .query('registrosPonto') + .withIndex('by_funcionario_data', (q) => + q.eq('funcionarioId', args.funcionarioId).eq('data', args.data) + ) + .order('asc') + .collect(); + + // Buscar configuração de ponto + const config = await ctx.db + .query('configuracaoPonto') + .withIndex('by_ativo', (q) => q.eq('ativo', true)) + .first(); + + if (!config) { + return { + registros: [], + cargaHorariaDiaria: 0, + horasTrabalhadas: 0, + saldoMinutos: 0, + }; + } + + // Calcular valores + const cargaHorariaDiaria = calcularCargaHorariaDiaria(config); + const horasTrabalhadas = calcularHorasTrabalhadas(registros); + const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria; + + return { + registros, + cargaHorariaDiaria, + horasTrabalhadas, + saldoMinutos, + }; + }, +}); + +/** + * Obtém banco de horas acumulado do funcionário + */ +export const obterBancoHorasFuncionario = query({ + args: { + funcionarioId: v.id('funcionarios'), + }, + handler: async (ctx, args) => { + const usuario = await getCurrentUserFunction(ctx); + if (!usuario) { + throw new Error('Usuário não autenticado'); + } + + // Verificar se é o próprio funcionário ou tem permissão + if (usuario.funcionarioId !== args.funcionarioId) { + // TODO: Verificar permissão de RH + } + + // Buscar todos os registros de banco de horas do funcionário + const bancosHoras = await ctx.db + .query('bancoHoras') + .withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId)) + .order('desc') + .collect(); + + // Calcular saldo acumulado + const saldoAcumuladoMinutos = bancosHoras.reduce((acc, bh) => acc + bh.saldoMinutos, 0); + + return { + bancosHoras, + saldoAcumuladoMinutos, + totalDias: bancosHoras.length, + }; + }, +}); + diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 472c3d7..4cc0a51 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -1387,6 +1387,9 @@ export default defineSchema({ connectionType: v.optional(v.string()), memoryInfo: v.optional(v.string()), + // Justificativa opcional para o registro + justificativa: v.optional(v.string()), + criadoEm: v.number(), }) .index("by_funcionario_data", ["funcionarioId", "data"]) @@ -1416,5 +1419,19 @@ export default defineSchema({ atualizadoPor: v.id("usuarios"), atualizadoEm: v.number(), }) - .index("by_ativo", ["usarServidorExterno"]) + .index("by_ativo", ["usarServidorExterno"]), + + // Banco de Horas - Saldo diário de horas trabalhadas + bancoHoras: defineTable({ + funcionarioId: v.id("funcionarios"), + data: v.string(), // YYYY-MM-DD + cargaHorariaDiaria: v.number(), // Horas esperadas do dia (em minutos) + horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos) + saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit) + registrosPontoIds: v.array(v.id("registrosPonto")), // IDs dos registros do dia + calculadoEm: v.number(), + }) + .index("by_funcionario_data", ["funcionarioId", "data"]) + .index("by_funcionario", ["funcionarioId"]) + .index("by_data", ["data"]), });