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.
This commit is contained in:
2025-11-18 15:28:26 -03:00
parent f0c6e4468f
commit b01d2d6786
10 changed files with 941 additions and 187 deletions

View File

@@ -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<string>((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<void>((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 @@
</div>
</div>
<!-- Imagem Capturada -->
{#if registro.imagemUrl}
<div class="card bg-base-200">
<div class="card-body">
<h4 class="font-bold mb-2">Foto Capturada</h4>
<div class="flex justify-center">
<img
src={registro.imagemUrl}
alt="Foto do registro de ponto"
class="max-w-full max-h-96 rounded-lg border-2 border-primary object-contain"
onerror={(e) => {
console.error('Erro ao carregar imagem:', e);
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
</div>
</div>
{/if}
<!-- Ações -->
<div class="flex justify-end gap-2">
<button class="btn btn-primary" onclick={gerarPDF} disabled={gerando}>

View File

@@ -5,18 +5,32 @@
import RelogioSincronizado from './RelogioSincronizado.svelte';
import WebcamCapture from './WebcamCapture.svelte';
import ComprovantePonto from './ComprovantePonto.svelte';
import ErrorModal from '../ErrorModal.svelte';
import { obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
import { obterInformacoesDispositivo } from '$lib/utils/deviceInfo';
import { formatarHoraPonto, getTipoRegistroLabel, getProximoTipoRegistro } from '$lib/utils/ponto';
import { LogIn, LogOut, Clock, CheckCircle2, XCircle, Camera, MapPin } from 'lucide-svelte';
import {
formatarHoraPonto,
getTipoRegistroLabel,
getProximoTipoRegistro
} from '$lib/utils/ponto';
import { LogIn, LogOut, Clock, CheckCircle2, XCircle, TrendingUp, TrendingDown } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
const client = useConvexClient();
// Queries
const currentUser = useQuery(api.auth.getCurrentUser, {});
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
const registrosHojeQuery = useQuery(api.pontos.listarRegistrosDia, {});
// Query para histórico e saldo do dia
const funcionarioId = $derived(currentUser?.data?.funcionarioId ?? null);
const dataHoje = $derived(new Date().toISOString().split('T')[0]!);
const historicoSaldoQuery = useQuery(
api.pontos.obterHistoricoESaldoDia,
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
);
// Estados
let mostrandoWebcam = $state(false);
let registrando = $state(false);
@@ -26,6 +40,11 @@
let mostrandoComprovante = $state(false);
let imagemCapturada = $state<Blob | null>(null);
let coletandoInfo = $state(false);
let capturandoAutomaticamente = $state(false);
let mostrarModalErro = $state(false);
let mensagemErroModal = $state('');
let detalhesErroModal = $state('');
let justificativa = $state('');
const registrosHoje = $derived(registrosHojeQuery?.data || []);
const config = $derived(configQuery?.data);
@@ -54,7 +73,7 @@
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file,
body: file
});
if (!response.ok) {
@@ -87,10 +106,16 @@
const timestamp = await obterTempoServidor(client);
const sincronizadoComServidor = true; // Sempre true quando usamos obterTempoServidor
// Upload da imagem se houver
// Upload da imagem se houver (não bloquear se falhar)
let imagemId: Id<'_storage'> | undefined = undefined;
if (imagemCapturada) {
imagemId = await uploadImagem(imagemCapturada);
try {
imagemId = await uploadImagem(imagemCapturada);
} catch (error) {
console.warn('Erro ao fazer upload da imagem, continuando sem foto:', error);
// Continuar sem foto se o upload falhar
imagemId = undefined;
}
}
// Registrar ponto
@@ -99,11 +124,13 @@
informacoesDispositivo,
timestamp,
sincronizadoComServidor,
justificativa: justificativa.trim() || undefined
});
registroId = resultado.registroId;
sucesso = `Ponto registrado com sucesso! Tipo: ${getTipoRegistroLabel(resultado.tipo)}`;
imagemCapturada = null;
justificativa = ''; // Limpar justificativa após registro
// Mostrar comprovante após 1 segundo
setTimeout(() => {
@@ -111,23 +138,71 @@
}, 1000);
} catch (error) {
console.error('Erro ao registrar ponto:', error);
erro = error instanceof Error ? error.message : 'Erro ao registrar ponto';
const mensagemErro = error instanceof Error ? error.message : 'Erro ao registrar ponto';
// Verificar se é erro de registro duplicado
if (
mensagemErro.includes('Já existe um registro neste minuto') ||
mensagemErro.includes('já existe um registro')
) {
mensagemErroModal = 'Registro de ponto duplicado';
detalhesErroModal = `Não é possível registrar o ponto no mesmo minuto.\n\nVocê já possui um registro de ${getTipoRegistroLabel(proximoTipo)} para este minuto.\n\nPor favor, aguarde pelo menos 1 minuto antes de tentar registrar novamente.`;
mostrarModalErro = true;
} else {
// Outros erros também mostram no modal
mensagemErroModal = 'Erro ao registrar ponto';
detalhesErroModal = mensagemErro;
mostrarModalErro = true;
}
erro = mensagemErro;
} finally {
registrando = false;
coletandoInfo = false;
}
}
function handleWebcamCapture(blob: Blob) {
imagemCapturada = blob;
function handleWebcamCapture(blob: Blob | null) {
if (blob) {
imagemCapturada = blob;
}
mostrandoWebcam = false;
// Se estava capturando automaticamente, registrar o ponto após capturar (com ou sem foto)
if (capturandoAutomaticamente) {
capturandoAutomaticamente = false;
// Pequeno delay para garantir que a imagem foi processada (se houver)
setTimeout(() => {
registrarPonto();
}, 100);
}
}
function handleWebcamCancel() {
const estavaCapturando = capturandoAutomaticamente;
mostrandoWebcam = false;
capturandoAutomaticamente = false;
imagemCapturada = null;
// Se estava capturando automaticamente e cancelou, registrar sem foto
if (estavaCapturando) {
registrarPonto();
}
}
function abrirWebcam() {
function handleWebcamError() {
// Em caso de erro na captura, registrar sem foto
mostrandoWebcam = false;
capturandoAutomaticamente = false;
imagemCapturada = null;
// Registrar ponto sem foto
registrarPonto();
}
async function iniciarRegistroComFoto() {
if (registrando || coletandoInfo) return;
// Abrir webcam automaticamente
capturandoAutomaticamente = true;
mostrandoWebcam = true;
}
@@ -136,10 +211,75 @@
registroId = null;
}
function fecharModalErro() {
mostrarModalErro = false;
mensagemErroModal = '';
detalhesErroModal = '';
erro = null;
}
const podeRegistrar = $derived.by(() => {
return !registrando && !coletandoInfo && config !== undefined;
});
// Referência para o modal
let modalRef: HTMLDivElement | null = $state(null);
// Efeito para garantir que o modal fique visível quando abrir
$effect(() => {
if (mostrandoWebcam && modalRef) {
// Aguardar um frame para garantir que o DOM foi atualizado
setTimeout(() => {
if (modalRef) {
modalRef.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Também garantir que o modal-box esteja visível
const modalBox = modalRef.querySelector('.modal-box');
if (modalBox) {
(modalBox as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, 100);
}
});
// Solicitar permissões automaticamente ao montar o componente
onMount(async () => {
// Solicitar permissão de webcam
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
try {
// Solicitar apenas permissão, sem iniciar o stream ainda
await navigator.mediaDevices.getUserMedia({ video: true });
} catch (error) {
// Ignorar erro silenciosamente - a permissão será solicitada quando necessário
console.log('Permissão de webcam não concedida ainda');
}
}
// Solicitar permissão de geolocalização
if (navigator.geolocation) {
try {
// Solicitar permissão de geolocalização (timeout curto para não bloquear)
await new Promise<void>((resolve) => {
const timeoutId = setTimeout(() => resolve(), 2000); // Timeout de 2 segundos
navigator.geolocation.getCurrentPosition(
() => {
clearTimeout(timeoutId);
resolve();
},
() => {
clearTimeout(timeoutId);
resolve(); // Resolver mesmo se negado
},
{ timeout: 2000, maximumAge: 0, enableHighAccuracy: false } // enableHighAccuracy false para ser mais rápido
);
});
} catch (error) {
// Ignorar erro silenciosamente
console.log('Permissão de geolocalização não concedida ainda');
}
}
});
const mapaHorarios = $derived.by(() => {
if (!config) return [];
@@ -147,7 +287,7 @@
{ tipo: 'entrada', horario: config.horarioEntrada, label: 'Entrada' },
{ tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: 'Saída para Almoço' },
{ tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: 'Retorno do Almoço' },
{ tipo: 'saida', horario: config.horarioSaida, label: 'Saída' },
{ tipo: 'saida', horario: config.horarioSaida, label: 'Saída' }
];
return horarios.map((h) => {
@@ -156,10 +296,33 @@
...h,
registrado: !!registro,
horarioRegistrado: registro ? formatarHoraPonto(registro.hora, registro.minuto) : null,
dentroDoPrazo: registro?.dentroDoPrazo ?? null,
dentroDoPrazo: registro?.dentroDoPrazo ?? null
};
});
});
// Dados do histórico e saldo
const historicoSaldo = $derived(historicoSaldoQuery?.data);
const registrosOrdenados = $derived.by(() => {
if (!historicoSaldo?.registros) return [];
return [...historicoSaldo.registros].sort((a, b) => {
const minutosA = a.hora * 60 + a.minuto;
const minutosB = b.hora * 60 + b.minuto;
return minutosA - minutosB;
});
});
// Formatação do saldo
const saldoFormatado = $derived.by(() => {
if (!historicoSaldo) return null;
const minutos = historicoSaldo.saldoMinutos;
const horas = Math.floor(Math.abs(minutos) / 60);
const mins = Math.abs(minutos) % 60;
const sinal = minutos >= 0 ? '+' : '-';
return `${sinal}${horas}h ${mins}min`;
});
const saldoPositivo = $derived(historicoSaldo ? historicoSaldo.saldoMinutos >= 0 : false);
</script>
<div class="space-y-6">
@@ -177,25 +340,27 @@
<Clock class="h-5 w-5" />
Horários do Dia
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
{#each mapaHorarios as horario}
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each mapaHorarios as horario (horario.tipo)}
<div
class="card {horario.registrado ? 'bg-success/10 border-success' : 'bg-base-200'} border-2"
class="card {horario.registrado
? 'bg-success/10 border-success'
: 'bg-base-200'} border-2"
>
<div class="card-body p-4">
<div class="flex items-center justify-between mb-2">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold">{horario.label}</span>
{#if horario.registrado}
{#if horario.dentroDoPrazo}
<CheckCircle2 class="h-5 w-5 text-success" />
<CheckCircle2 class="text-success h-5 w-5" />
{:else}
<XCircle class="h-5 w-5 text-error" />
<XCircle class="text-error h-5 w-5" />
{/if}
{/if}
</div>
<div class="text-2xl font-bold">{horario.horario}</div>
{#if horario.registrado}
<div class="text-sm text-base-content/70">
<div class="text-base-content/70 text-sm">
Registrado: {horario.horarioRegistrado}
</div>
{/if}
@@ -210,14 +375,7 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body items-center">
<h2 class="card-title mb-4">Registrar Ponto</h2>
<div class="flex flex-col items-center gap-4 w-full">
{#if erro}
<div class="alert alert-error w-full">
<XCircle class="h-5 w-5" />
<span>{erro}</span>
</div>
{/if}
<div class="flex w-full flex-col items-center gap-4">
{#if sucesso}
<div class="alert alert-success w-full">
<CheckCircle2 class="h-5 w-5" />
@@ -225,62 +383,166 @@
</div>
{/if}
<div class="text-center mb-4">
<div class="mb-4 text-center">
<p class="text-lg font-semibold">Próximo registro: {tipoLabel}</p>
</div>
<div class="flex gap-4">
{#if !imagemCapturada}
<button class="btn btn-outline btn-primary" onclick={abrirWebcam} disabled={!podeRegistrar}>
<Camera class="h-5 w-5" />
Capturar Foto
</button>
{:else}
<div class="badge badge-primary badge-lg gap-2">
<Camera class="h-4 w-4" />
Foto capturada
</div>
{/if}
<button
class="btn btn-primary btn-lg"
onclick={registrarPonto}
disabled={!podeRegistrar}
>
{#if registrando}
<span class="loading loading-spinner loading-sm"></span>
{#if coletandoInfo}
Coletando informações...
{:else}
Registrando...
{/if}
{:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
<LogIn class="h-5 w-5" />
Registrar Entrada
{:else}
<LogOut class="h-5 w-5" />
Registrar Saída
{/if}
</button>
<!-- Campo de Justificativa (Opcional) -->
<div class="w-full">
<label for="justificativa" class="label">
<span class="label-text">Justificativa (Opcional)</span>
</label>
<textarea
id="justificativa"
class="textarea textarea-bordered w-full"
placeholder="Digite uma justificativa para este registro de ponto (opcional)"
bind:value={justificativa}
disabled={registrando}
rows="3"
></textarea>
</div>
<button
class="btn btn-primary btn-lg"
onclick={iniciarRegistroComFoto}
disabled={!podeRegistrar}
>
{#if registrando}
<span class="loading loading-spinner loading-sm"></span>
{#if coletandoInfo}
Coletando informações...
{:else}
Registrando...
{/if}
{:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
<LogIn class="h-5 w-5" />
Registrar Entrada
{:else}
<LogOut class="h-5 w-5" />
Registrar Saída
{/if}
</button>
</div>
</div>
</div>
<!-- Histórico e Saldo do Dia -->
{#if historicoSaldo && registrosOrdenados.length > 0}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<Clock class="h-5 w-5" />
Histórico do Dia
</h2>
<!-- Saldo de Horas -->
<div class="my-4 rounded-lg border-2 p-4 {saldoPositivo ? 'border-success bg-success/10' : 'border-error bg-error/10'}">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold opacity-70">Saldo de Horas</p>
<p class="text-2xl font-bold">
{saldoFormatado}
</p>
</div>
{#if saldoPositivo}
<TrendingUp class="h-8 w-8 text-success" />
{:else}
<TrendingDown class="h-8 w-8 text-error" />
{/if}
</div>
<div class="mt-2 text-sm opacity-70">
<p>Carga Horária Diária: {Math.floor(historicoSaldo.cargaHorariaDiaria / 60)}h {historicoSaldo.cargaHorariaDiaria % 60}min</p>
<p>Horas Trabalhadas: {Math.floor(historicoSaldo.horasTrabalhadas / 60)}h {historicoSaldo.horasTrabalhadas % 60}min</p>
</div>
</div>
<!-- Lista de Registros -->
<div class="divider"></div>
<div class="space-y-2">
<h3 class="font-semibold">Registros Realizados</h3>
<div class="space-y-3">
{#each registrosOrdenados as registro (registro._id)}
<div class="card bg-base-200">
<div class="card-body p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="mb-1 flex items-center gap-2">
<span class="font-semibold">{getTipoRegistroLabel(registro.tipo)}</span>
{#if registro.dentroDoPrazo}
<CheckCircle2 class="h-4 w-4 text-success" />
{:else}
<XCircle class="h-4 w-4 text-error" />
{/if}
</div>
<p class="text-lg font-bold">
{formatarHoraPonto(registro.hora, registro.minuto)}
</p>
{#if registro.justificativa}
<div class="mt-2 rounded bg-base-300 p-2">
<p class="text-xs font-semibold opacity-70">Justificativa:</p>
<p class="text-sm">{registro.justificativa}</p>
</div>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<!-- Modal Webcam -->
{#if mostrandoWebcam}
<div class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4">Capturar Foto</h3>
<WebcamCapture onCapture={handleWebcamCapture} onCancel={handleWebcamCancel} />
<div
bind:this={modalRef}
class="modal modal-open"
style="display: flex; align-items: center; justify-content: center; position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999;"
>
<div class="modal-box max-w-2xl w-[95%] max-h-[90vh] overflow-y-auto relative" style="margin: auto; position: relative;">
<div class="sticky top-0 bg-base-100 z-10 pb-3 mb-4 border-b border-base-300 -mx-6 px-6">
<h3 class="text-lg font-bold">
{#if capturandoAutomaticamente}
Capturando foto automaticamente...
{:else}
Capturar Foto
{/if}
</h3>
</div>
<div class="min-h-[200px] flex items-center justify-center py-4">
<WebcamCapture
onCapture={handleWebcamCapture}
onCancel={handleWebcamCancel}
onError={handleWebcamError}
autoCapture={capturandoAutomaticamente}
/>
</div>
</div>
<div class="modal-backdrop" onclick={handleWebcamCancel}></div>
<form
method="dialog"
class="modal-backdrop"
onsubmit={(e) => {
e.preventDefault();
handleWebcamCancel();
}}
>
<button type="submit" aria-label="Fechar modal">fechar</button>
</form>
</div>
{/if}
<!-- Modal Comprovante -->
{#if mostrandoComprovante && registroId}
<ComprovantePonto registroId={registroId} onClose={fecharComprovante} />
<ComprovantePonto {registroId} onClose={fecharComprovante} />
{/if}
</div>
<!-- Modal de Erro -->
<ErrorModal
open={mostrarModalErro}
title={mensagemErroModal || 'Erro ao registrar ponto'}
message={detalhesErroModal || mensagemErroModal || 'Ocorreu um erro ao registrar o ponto. Tente novamente.'}
onClose={fecharModalErro}
/>
</div>

View File

@@ -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<string | null>(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 @@
}
</script>
<div class="flex flex-col items-center gap-4 p-6">
<div class="flex flex-col items-center gap-4 p-4 w-full">
{#if !webcamDisponivel && !erro}
<div class="flex items-center gap-2 text-warning">
<div class="text-warning flex items-center gap-2">
<Camera class="h-5 w-5" />
<span>Verificando webcam...</span>
</div>
{:else if erro && !webcamDisponivel}
<div class="alert alert-warning">
{#if !autoCapture}
<div class="flex gap-2">
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
</div>
{/if}
{:else if erro && !webcamDisponivel}
<div class="alert alert-warning max-w-md">
<AlertCircle class="h-5 w-5" />
<span>{erro}</span>
</div>
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
{#if autoCapture}
<div class="text-sm text-base-content/70 text-center">
O registro será feito sem foto.
</div>
{:else}
<div class="flex gap-2">
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
</div>
{/if}
{:else if previewUrl}
<!-- Preview da imagem capturada -->
<div class="flex flex-col items-center gap-4">
<img src={previewUrl} alt="Preview" class="max-w-full max-h-96 rounded-lg border-2 border-primary" />
<div class="flex gap-2">
<button class="btn btn-success" onclick={confirmar}>
<Check class="h-5 w-5" />
Confirmar
</button>
<button class="btn btn-outline" onclick={recapturar}>
<Camera class="h-5 w-5" />
Recapturar
</button>
<button class="btn btn-error" onclick={cancelar}>
<X class="h-5 w-5" />
Cancelar
</button>
</div>
<div class="flex flex-col items-center gap-4 w-full">
{#if autoCapture}
<!-- Modo automático: mostrar apenas preview sem botões -->
<div class="text-sm text-base-content/70 mb-2 text-center">
Foto capturada automaticamente...
</div>
{/if}
<img
src={previewUrl}
alt="Preview"
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain"
/>
{#if !autoCapture}
<!-- Botões apenas se não for automático -->
<div class="flex gap-2 flex-wrap justify-center">
<button class="btn btn-success" onclick={confirmar}>
<Check class="h-5 w-5" />
Confirmar
</button>
<button class="btn btn-outline" onclick={recapturar}>
<Camera class="h-5 w-5" />
Recapturar
</button>
<button class="btn btn-error" onclick={cancelar}>
<X class="h-5 w-5" />
Cancelar
</button>
</div>
{/if}
</div>
{:else}
<!-- Webcam ativa -->
<div class="flex flex-col items-center gap-4">
<div class="relative">
<div class="flex flex-col items-center gap-4 w-full">
{#if autoCapture}
<div class="text-sm text-base-content/70 mb-2 text-center">
Capturando foto automaticamente...
</div>
{/if}
<div class="relative w-full flex justify-center">
<video
bind:this={videoElement}
autoplay
playsinline
class="rounded-lg border-2 border-primary max-w-full max-h-96"
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain"
></video>
<canvas bind:this={canvasElement} class="hidden"></canvas>
</div>
{#if erro}
<div class="alert alert-error">
<div class="alert alert-error max-w-md">
<span>{erro}</span>
</div>
{/if}
<div class="flex gap-2">
<button class="btn btn-primary" onclick={capturar} disabled={capturando}>
{#if capturando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Camera class="h-5 w-5" />
{/if}
Capturar Foto
</button>
<button class="btn btn-outline" onclick={cancelar}>
<X class="h-5 w-5" />
Cancelar
</button>
</div>
{#if !autoCapture}
<!-- Botões apenas se não for automático -->
<div class="flex gap-2 flex-wrap justify-center">
<button class="btn btn-primary" onclick={capturar} disabled={capturando}>
{#if capturando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Camera class="h-5 w-5" />
{/if}
Capturar Foto
</button>
<button class="btn btn-outline" onclick={cancelar}>
<X class="h-5 w-5" />
Cancelar
</button>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -1,68 +1,68 @@
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { Clock, ArrowRight, CheckCircle2, XCircle } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { Clock } from 'lucide-svelte';
import { resolve } from '$app/paths';
// Estatísticas do dia atual
const hoje = new Date().toISOString().split('T')[0]!;
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, {
dataInicio: hoje,
dataFim: hoje,
});
const estatisticas = $derived(estatisticasQuery?.data);
function abrirDashboard() {
goto(resolve('/(dashboard)/recursos-humanos/registro-pontos'));
}
</script>
<div class="card bg-gradient-to-br from-blue-500 to-cyan-600 text-white shadow-xl hover:shadow-2xl transition-all duration-300">
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="p-3 bg-white/20 rounded-xl">
<Clock class="h-6 w-6" strokeWidth={2} />
</div>
<div>
<h3 class="card-title text-white">Gestão de Pontos</h3>
<p class="text-white/80 text-sm">Registros de ponto do dia</p>
<!-- Cabeçalho da Categoria -->
<div class="flex items-start gap-6 mb-6">
<div class="p-4 bg-blue-500/20 rounded-2xl">
<div class="text-blue-600">
<Clock class="h-12 w-12" strokeWidth={2} />
</div>
</div>
<div class="flex-1">
<h2 class="card-title text-2xl mb-2 text-blue-600">
Gestão de Pontos
</h2>
<p class="text-base-content/70">Registros de ponto do dia</p>
</div>
</div>
{#if estatisticas}
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="bg-white/10 rounded-lg p-3">
<div class="flex items-center gap-2 mb-1">
<CheckCircle2 class="h-4 w-4" />
<span class="text-sm text-white/80">Dentro do Prazo</span>
<!-- Grid de Opções -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a
href={resolve('/(dashboard)/recursos-humanos/registro-pontos')}
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-blue-500/10 to-blue-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
>
<div
class="text-blue-600 group-hover:text-white"
>
<Clock class="h-5 w-5" strokeWidth={2} />
</div>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
<div class="text-2xl font-bold">{estatisticas.dentroDoPrazo}</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
>
Gestão de Pontos
</h3>
<p class="text-sm text-base-content/70 flex-1">
Visualizar e gerenciar registros de ponto
</p>
</div>
<div class="bg-white/10 rounded-lg p-3">
<div class="flex items-center gap-2 mb-1">
<XCircle class="h-4 w-4" />
<span class="text-sm text-white/80">Fora do Prazo</span>
</div>
<div class="text-2xl font-bold">{estatisticas.foraDoPrazo}</div>
</div>
</div>
<div class="text-sm text-white/80 mb-4">
Total: {estatisticas.totalRegistros} registros de {estatisticas.totalFuncionarios} funcionários
</div>
{:else}
<div class="text-white/80 text-sm mb-4">Carregando estatísticas...</div>
{/if}
<button class="btn btn-white btn-sm w-full" onclick={abrirDashboard}>
Ver Dashboard Completo
<ArrowRight class="h-4 w-4" />
</button>
</a>
</div>
</div>
</div>

View File

@@ -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
}
);
});

View File

@@ -117,7 +117,7 @@
</div>
<!-- Widget Gestão de Pontos -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
<div class="mt-8">
<WidgetGestaoPontos />
</div>

View File

@@ -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<Id<'funcionarios'> | ''>('');
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 @@
<div class="card bg-base-200">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div>
<div class="flex-1">
<h3 class="font-bold text-lg">
{grupo.funcionario?.nome || 'Funcionário não encontrado'}
</h3>
@@ -307,9 +326,39 @@
</p>
{/if}
</div>
<!-- Banco de Horas -->
{#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}
<div class="mx-4 rounded-lg border-2 p-3 {saldoPositivo ? 'border-success bg-success/10' : 'border-error bg-error/10'}">
<div class="flex items-center gap-2">
{#if saldoPositivo}
<TrendingUp class="h-5 w-5 text-success" />
{:else}
<TrendingDown class="h-5 w-5 text-error" />
{/if}
<div>
<p class="text-xs font-semibold opacity-70">Banco de Horas</p>
<p class="text-lg font-bold">
{formatarSaldoHoras(saldoAcumulado)}
</p>
</div>
</div>
</div>
{/if}
{/key}
<button
class="btn btn-sm btn-primary"
onclick={() => imprimirFichaPonto(grupo.registros[0]!.funcionarioId)}
onclick={() => imprimirFichaPonto(grupo.funcionarioId)}
>
<Printer class="h-4 w-4" />
Imprimir Ficha

View File

@@ -115,7 +115,7 @@
</div>
<!-- Widget Gestão de Pontos -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
<div class="mt-8">
<WidgetGestaoPontos />
</div>

View File

@@ -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<void> {
// 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,
};
},
});

View File

@@ -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"]),
});