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:
@@ -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}>
|
||||
|
||||
@@ -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) {
|
||||
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) {
|
||||
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,26 +383,28 @@
|
||||
</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
|
||||
<!-- 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>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="btn btn-primary btn-lg"
|
||||
onclick={registrarPonto}
|
||||
onclick={iniciarRegistroComFoto}
|
||||
disabled={!podeRegistrar}
|
||||
>
|
||||
{#if registrando}
|
||||
@@ -265,22 +425,124 @@
|
||||
</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="modal-backdrop" onclick={handleWebcamCancel}></div>
|
||||
<div class="min-h-[200px] flex items-center justify-center py-4">
|
||||
<WebcamCapture
|
||||
onCapture={handleWebcamCapture}
|
||||
onCancel={handleWebcamCancel}
|
||||
onError={handleWebcamError}
|
||||
autoCapture={capturandoAutomaticamente}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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,23 +176,48 @@
|
||||
}
|
||||
</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>
|
||||
{#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">
|
||||
<div class="alert alert-warning max-w-md">
|
||||
<AlertCircle class="h-5 w-5" />
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{#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">
|
||||
<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
|
||||
@@ -161,25 +231,33 @@
|
||||
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">
|
||||
{#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>
|
||||
@@ -193,7 +271,7 @@
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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} />
|
||||
<!-- 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>
|
||||
<h3 class="card-title text-white">Gestão de Pontos</h3>
|
||||
<p class="text-white/80 text-sm">Registros de ponto do dia</p>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<div class="text-2xl font-bold">{estatisticas.dentroDoPrazo}</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user