Feat controle ponto #29
@@ -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é
|
// Rodapé
|
||||||
const pageCount = doc.getNumberOfPages();
|
const pageCount = doc.getNumberOfPages();
|
||||||
for (let i = 1; i <= pageCount; i++) {
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
@@ -248,6 +333,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Ações -->
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button class="btn btn-primary" onclick={gerarPDF} disabled={gerando}>
|
<button class="btn btn-primary" onclick={gerarPDF} disabled={gerando}>
|
||||||
|
|||||||
@@ -5,18 +5,32 @@
|
|||||||
import RelogioSincronizado from './RelogioSincronizado.svelte';
|
import RelogioSincronizado from './RelogioSincronizado.svelte';
|
||||||
import WebcamCapture from './WebcamCapture.svelte';
|
import WebcamCapture from './WebcamCapture.svelte';
|
||||||
import ComprovantePonto from './ComprovantePonto.svelte';
|
import ComprovantePonto from './ComprovantePonto.svelte';
|
||||||
|
import ErrorModal from '../ErrorModal.svelte';
|
||||||
import { obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
|
import { obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
|
||||||
import { obterInformacoesDispositivo } from '$lib/utils/deviceInfo';
|
import { obterInformacoesDispositivo } from '$lib/utils/deviceInfo';
|
||||||
import { formatarHoraPonto, getTipoRegistroLabel, getProximoTipoRegistro } from '$lib/utils/ponto';
|
import {
|
||||||
import { LogIn, LogOut, Clock, CheckCircle2, XCircle, Camera, MapPin } from 'lucide-svelte';
|
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';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||||
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||||
const registrosHojeQuery = useQuery(api.pontos.listarRegistrosDia, {});
|
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
|
// Estados
|
||||||
let mostrandoWebcam = $state(false);
|
let mostrandoWebcam = $state(false);
|
||||||
let registrando = $state(false);
|
let registrando = $state(false);
|
||||||
@@ -26,6 +40,11 @@
|
|||||||
let mostrandoComprovante = $state(false);
|
let mostrandoComprovante = $state(false);
|
||||||
let imagemCapturada = $state<Blob | null>(null);
|
let imagemCapturada = $state<Blob | null>(null);
|
||||||
let coletandoInfo = $state(false);
|
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 registrosHoje = $derived(registrosHojeQuery?.data || []);
|
||||||
const config = $derived(configQuery?.data);
|
const config = $derived(configQuery?.data);
|
||||||
@@ -54,7 +73,7 @@
|
|||||||
const response = await fetch(uploadUrl, {
|
const response = await fetch(uploadUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': file.type },
|
headers: { 'Content-Type': file.type },
|
||||||
body: file,
|
body: file
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -87,10 +106,16 @@
|
|||||||
const timestamp = await obterTempoServidor(client);
|
const timestamp = await obterTempoServidor(client);
|
||||||
const sincronizadoComServidor = true; // Sempre true quando usamos obterTempoServidor
|
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;
|
let imagemId: Id<'_storage'> | undefined = undefined;
|
||||||
if (imagemCapturada) {
|
if (imagemCapturada) {
|
||||||
|
try {
|
||||||
imagemId = await uploadImagem(imagemCapturada);
|
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
|
// Registrar ponto
|
||||||
@@ -99,11 +124,13 @@
|
|||||||
informacoesDispositivo,
|
informacoesDispositivo,
|
||||||
timestamp,
|
timestamp,
|
||||||
sincronizadoComServidor,
|
sincronizadoComServidor,
|
||||||
|
justificativa: justificativa.trim() || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
registroId = resultado.registroId;
|
registroId = resultado.registroId;
|
||||||
sucesso = `Ponto registrado com sucesso! Tipo: ${getTipoRegistroLabel(resultado.tipo)}`;
|
sucesso = `Ponto registrado com sucesso! Tipo: ${getTipoRegistroLabel(resultado.tipo)}`;
|
||||||
imagemCapturada = null;
|
imagemCapturada = null;
|
||||||
|
justificativa = ''; // Limpar justificativa após registro
|
||||||
|
|
||||||
// Mostrar comprovante após 1 segundo
|
// Mostrar comprovante após 1 segundo
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -111,23 +138,71 @@
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao registrar ponto:', 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 {
|
} finally {
|
||||||
registrando = false;
|
registrando = false;
|
||||||
coletandoInfo = false;
|
coletandoInfo = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWebcamCapture(blob: Blob) {
|
function handleWebcamCapture(blob: Blob | null) {
|
||||||
|
if (blob) {
|
||||||
imagemCapturada = blob;
|
imagemCapturada = blob;
|
||||||
|
}
|
||||||
mostrandoWebcam = false;
|
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() {
|
function handleWebcamCancel() {
|
||||||
|
const estavaCapturando = capturandoAutomaticamente;
|
||||||
mostrandoWebcam = false;
|
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;
|
mostrandoWebcam = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +211,75 @@
|
|||||||
registroId = null;
|
registroId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fecharModalErro() {
|
||||||
|
mostrarModalErro = false;
|
||||||
|
mensagemErroModal = '';
|
||||||
|
detalhesErroModal = '';
|
||||||
|
erro = null;
|
||||||
|
}
|
||||||
|
|
||||||
const podeRegistrar = $derived.by(() => {
|
const podeRegistrar = $derived.by(() => {
|
||||||
return !registrando && !coletandoInfo && config !== undefined;
|
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(() => {
|
const mapaHorarios = $derived.by(() => {
|
||||||
if (!config) return [];
|
if (!config) return [];
|
||||||
|
|
||||||
@@ -147,7 +287,7 @@
|
|||||||
{ tipo: 'entrada', horario: config.horarioEntrada, label: 'Entrada' },
|
{ tipo: 'entrada', horario: config.horarioEntrada, label: 'Entrada' },
|
||||||
{ tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: 'Saída para Almoço' },
|
{ tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: 'Saída para Almoço' },
|
||||||
{ tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: 'Retorno do 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) => {
|
return horarios.map((h) => {
|
||||||
@@ -156,10 +296,33 @@
|
|||||||
...h,
|
...h,
|
||||||
registrado: !!registro,
|
registrado: !!registro,
|
||||||
horarioRegistrado: registro ? formatarHoraPonto(registro.hora, registro.minuto) : null,
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -177,25 +340,27 @@
|
|||||||
<Clock class="h-5 w-5" />
|
<Clock class="h-5 w-5" />
|
||||||
Horários do Dia
|
Horários do Dia
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
|
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{#each mapaHorarios as horario}
|
{#each mapaHorarios as horario (horario.tipo)}
|
||||||
<div
|
<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="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>
|
<span class="font-semibold">{horario.label}</span>
|
||||||
{#if horario.registrado}
|
{#if horario.registrado}
|
||||||
{#if horario.dentroDoPrazo}
|
{#if horario.dentroDoPrazo}
|
||||||
<CheckCircle2 class="h-5 w-5 text-success" />
|
<CheckCircle2 class="text-success h-5 w-5" />
|
||||||
{:else}
|
{:else}
|
||||||
<XCircle class="h-5 w-5 text-error" />
|
<XCircle class="text-error h-5 w-5" />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-2xl font-bold">{horario.horario}</div>
|
<div class="text-2xl font-bold">{horario.horario}</div>
|
||||||
{#if horario.registrado}
|
{#if horario.registrado}
|
||||||
<div class="text-sm text-base-content/70">
|
<div class="text-base-content/70 text-sm">
|
||||||
Registrado: {horario.horarioRegistrado}
|
Registrado: {horario.horarioRegistrado}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -210,14 +375,7 @@
|
|||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<div class="card-body items-center">
|
<div class="card-body items-center">
|
||||||
<h2 class="card-title mb-4">Registrar Ponto</h2>
|
<h2 class="card-title mb-4">Registrar Ponto</h2>
|
||||||
<div class="flex flex-col items-center gap-4 w-full">
|
<div class="flex w-full flex-col items-center gap-4">
|
||||||
{#if erro}
|
|
||||||
<div class="alert alert-error w-full">
|
|
||||||
<XCircle class="h-5 w-5" />
|
|
||||||
<span>{erro}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if sucesso}
|
{#if sucesso}
|
||||||
<div class="alert alert-success w-full">
|
<div class="alert alert-success w-full">
|
||||||
<CheckCircle2 class="h-5 w-5" />
|
<CheckCircle2 class="h-5 w-5" />
|
||||||
@@ -225,26 +383,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="text-center mb-4">
|
<div class="mb-4 text-center">
|
||||||
<p class="text-lg font-semibold">Próximo registro: {tipoLabel}</p>
|
<p class="text-lg font-semibold">Próximo registro: {tipoLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-4">
|
<!-- Campo de Justificativa (Opcional) -->
|
||||||
{#if !imagemCapturada}
|
<div class="w-full">
|
||||||
<button class="btn btn-outline btn-primary" onclick={abrirWebcam} disabled={!podeRegistrar}>
|
<label for="justificativa" class="label">
|
||||||
<Camera class="h-5 w-5" />
|
<span class="label-text">Justificativa (Opcional)</span>
|
||||||
Capturar Foto
|
</label>
|
||||||
</button>
|
<textarea
|
||||||
{:else}
|
id="justificativa"
|
||||||
<div class="badge badge-primary badge-lg gap-2">
|
class="textarea textarea-bordered w-full"
|
||||||
<Camera class="h-4 w-4" />
|
placeholder="Digite uma justificativa para este registro de ponto (opcional)"
|
||||||
Foto capturada
|
bind:value={justificativa}
|
||||||
|
disabled={registrando}
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-lg"
|
class="btn btn-primary btn-lg"
|
||||||
onclick={registrarPonto}
|
onclick={iniciarRegistroComFoto}
|
||||||
disabled={!podeRegistrar}
|
disabled={!podeRegistrar}
|
||||||
>
|
>
|
||||||
{#if registrando}
|
{#if registrando}
|
||||||
@@ -265,22 +425,124 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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 -->
|
<!-- Modal Webcam -->
|
||||||
{#if mostrandoWebcam}
|
{#if mostrandoWebcam}
|
||||||
<div class="modal modal-open">
|
<div
|
||||||
<div class="modal-box max-w-2xl">
|
bind:this={modalRef}
|
||||||
<h3 class="font-bold text-lg mb-4">Capturar Foto</h3>
|
class="modal modal-open"
|
||||||
<WebcamCapture onCapture={handleWebcamCapture} onCancel={handleWebcamCancel} />
|
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>
|
||||||
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Modal Comprovante -->
|
<!-- Modal Comprovante -->
|
||||||
{#if mostrandoComprovante && registroId}
|
{#if mostrandoComprovante && registroId}
|
||||||
<ComprovantePonto registroId={registroId} onClose={fecharComprovante} />
|
<ComprovantePonto {registroId} onClose={fecharComprovante} />
|
||||||
{/if}
|
{/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';
|
import { validarWebcamDisponivel, capturarWebcamComPreview } from '$lib/utils/webcam';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onCapture: (blob: Blob) => void;
|
onCapture: (blob: Blob | null) => void;
|
||||||
onCancel: () => 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 videoElement: HTMLVideoElement | null = $state(null);
|
||||||
let canvasElement: HTMLCanvasElement | null = $state(null);
|
let canvasElement: HTMLCanvasElement | null = $state(null);
|
||||||
@@ -19,9 +21,12 @@
|
|||||||
let previewUrl = $state<string | null>(null);
|
let previewUrl = $state<string | null>(null);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
webcamDisponivel = await validarWebcamDisponivel();
|
// Tentar obter permissão de webcam automaticamente
|
||||||
if (!webcamDisponivel) {
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
erro = 'Webcam não disponível';
|
erro = 'Webcam não suportada';
|
||||||
|
if (autoCapture && onError) {
|
||||||
|
onError();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,18 +35,36 @@
|
|||||||
video: {
|
video: {
|
||||||
width: { ideal: 1280 },
|
width: { ideal: 1280 },
|
||||||
height: { ideal: 720 },
|
height: { ideal: 720 },
|
||||||
facingMode: 'user',
|
facingMode: 'user'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
webcamDisponivel = true;
|
||||||
|
|
||||||
if (videoElement) {
|
if (videoElement) {
|
||||||
videoElement.srcObject = stream;
|
videoElement.srcObject = stream;
|
||||||
await videoElement.play();
|
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) {
|
} catch (error) {
|
||||||
console.error('Erro ao acessar webcam:', 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;
|
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() {
|
async function capturar() {
|
||||||
if (!videoElement || !canvasElement) {
|
if (!videoElement || !canvasElement) {
|
||||||
|
if (autoCapture && onError) {
|
||||||
|
onError();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +97,31 @@
|
|||||||
stream.getTracks().forEach((track) => track.stop());
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
stream = null;
|
stream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Se for captura automática, confirmar automaticamente após um pequeno delay
|
||||||
|
if (autoCapture) {
|
||||||
|
setTimeout(() => {
|
||||||
|
confirmar();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
erro = 'Falha ao capturar imagem';
|
erro = 'Falha ao capturar imagem';
|
||||||
|
// Se for captura automática e falhar, continuar sem foto
|
||||||
|
if (autoCapture && onError) {
|
||||||
|
setTimeout(() => {
|
||||||
|
onError();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao capturar:', 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 {
|
} finally {
|
||||||
capturando = false;
|
capturando = false;
|
||||||
}
|
}
|
||||||
@@ -116,8 +161,8 @@
|
|||||||
video: {
|
video: {
|
||||||
width: { ideal: 1280 },
|
width: { ideal: 1280 },
|
||||||
height: { ideal: 720 },
|
height: { ideal: 720 },
|
||||||
facingMode: 'user',
|
facingMode: 'user'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (videoElement) {
|
if (videoElement) {
|
||||||
@@ -131,23 +176,48 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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}
|
{#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" />
|
<Camera class="h-5 w-5" />
|
||||||
<span>Verificando webcam...</span>
|
<span>Verificando webcam...</span>
|
||||||
</div>
|
</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}
|
{:else if erro && !webcamDisponivel}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning max-w-md">
|
||||||
<AlertCircle class="h-5 w-5" />
|
<AlertCircle class="h-5 w-5" />
|
||||||
<span>{erro}</span>
|
<span>{erro}</span>
|
||||||
</div>
|
</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>
|
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else if previewUrl}
|
{:else if previewUrl}
|
||||||
<!-- Preview da imagem capturada -->
|
<!-- Preview da imagem capturada -->
|
||||||
<div class="flex flex-col items-center gap-4">
|
<div class="flex flex-col items-center gap-4 w-full">
|
||||||
<img src={previewUrl} alt="Preview" class="max-w-full max-h-96 rounded-lg border-2 border-primary" />
|
{#if autoCapture}
|
||||||
<div class="flex gap-2">
|
<!-- 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}>
|
<button class="btn btn-success" onclick={confirmar}>
|
||||||
<Check class="h-5 w-5" />
|
<Check class="h-5 w-5" />
|
||||||
Confirmar
|
Confirmar
|
||||||
@@ -161,25 +231,33 @@
|
|||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Webcam ativa -->
|
<!-- Webcam ativa -->
|
||||||
<div class="flex flex-col items-center gap-4">
|
<div class="flex flex-col items-center gap-4 w-full">
|
||||||
<div class="relative">
|
{#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
|
<video
|
||||||
bind:this={videoElement}
|
bind:this={videoElement}
|
||||||
autoplay
|
autoplay
|
||||||
playsinline
|
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>
|
></video>
|
||||||
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
||||||
</div>
|
</div>
|
||||||
{#if erro}
|
{#if erro}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error max-w-md">
|
||||||
<span>{erro}</span>
|
<span>{erro}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}>
|
<button class="btn btn-primary" onclick={capturar} disabled={capturando}>
|
||||||
{#if capturando}
|
{#if capturando}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
@@ -193,7 +271,7 @@
|
|||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +1,68 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery } from 'convex-svelte';
|
import { Clock } from 'lucide-svelte';
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
|
||||||
import { Clock, ArrowRight, CheckCircle2, XCircle } from 'lucide-svelte';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { resolve } from '$app/paths';
|
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>
|
</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="card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<!-- Cabeçalho da Categoria -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-start gap-6 mb-6">
|
||||||
<div class="p-3 bg-white/20 rounded-xl">
|
<div class="p-4 bg-blue-500/20 rounded-2xl">
|
||||||
<Clock class="h-6 w-6" strokeWidth={2} />
|
<div class="text-blue-600">
|
||||||
|
<Clock class="h-12 w-12" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<h3 class="card-title text-white">Gestão de Pontos</h3>
|
<div class="flex-1">
|
||||||
<p class="text-white/80 text-sm">Registros de ponto do dia</p>
|
<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>
|
</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) => {
|
return new Promise((resolve) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
resolve({});
|
resolve({});
|
||||||
}, 10000); // Timeout de 10 segundos
|
}, 5000); // Timeout de 5 segundos (reduzido para não bloquear)
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
async (position) => {
|
async (position) => {
|
||||||
@@ -306,9 +306,9 @@ async function obterLocalizacao(): Promise<{
|
|||||||
resolve({});
|
resolve({});
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enableHighAccuracy: true,
|
enableHighAccuracy: false, // false para ser mais rápido
|
||||||
timeout: 10000,
|
timeout: 5000, // Timeout reduzido para 5 segundos
|
||||||
maximumAge: 0,
|
maximumAge: 60000, // Aceitar localização de até 1 minuto atrás
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,7 +117,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Widget Gestão de Pontos -->
|
<!-- 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 />
|
<WidgetGestaoPontos />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
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 type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto';
|
import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto';
|
||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
@@ -17,18 +17,22 @@
|
|||||||
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
|
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
|
||||||
let carregando = $state(false);
|
let carregando = $state(false);
|
||||||
|
|
||||||
// Queries
|
// Parâmetros reativos para queries
|
||||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
const registrosParams = $derived({
|
||||||
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, {
|
|
||||||
funcionarioId: funcionarioIdFiltro || undefined,
|
funcionarioId: funcionarioIdFiltro || undefined,
|
||||||
dataInicio,
|
dataInicio,
|
||||||
dataFim,
|
dataFim,
|
||||||
});
|
});
|
||||||
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, {
|
const estatisticasParams = $derived({
|
||||||
dataInicio,
|
dataInicio,
|
||||||
dataFim,
|
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 funcionarios = $derived(funcionariosQuery?.data || []);
|
||||||
const registros = $derived(registrosQuery?.data || []);
|
const registros = $derived(registrosQuery?.data || []);
|
||||||
const estatisticas = $derived(estatisticasQuery?.data);
|
const estatisticas = $derived(estatisticasQuery?.data);
|
||||||
@@ -39,6 +43,7 @@
|
|||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
|
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
|
||||||
|
funcionarioId: Id<'funcionarios'>;
|
||||||
registros: typeof registros;
|
registros: typeof registros;
|
||||||
}
|
}
|
||||||
> = {};
|
> = {};
|
||||||
@@ -48,6 +53,7 @@
|
|||||||
if (!agrupados[key]) {
|
if (!agrupados[key]) {
|
||||||
agrupados[key] = {
|
agrupados[key] = {
|
||||||
funcionario: registro.funcionario,
|
funcionario: registro.funcionario,
|
||||||
|
funcionarioId: registro.funcionarioId,
|
||||||
registros: [],
|
registros: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -57,6 +63,19 @@
|
|||||||
return Object.values(agrupados);
|
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'>) {
|
async function imprimirFichaPonto(funcionarioId: Id<'funcionarios'>) {
|
||||||
const registrosFuncionario = registros.filter((r) => r.funcionarioId === funcionarioId);
|
const registrosFuncionario = registros.filter((r) => r.funcionarioId === funcionarioId);
|
||||||
if (registrosFuncionario.length === 0) {
|
if (registrosFuncionario.length === 0) {
|
||||||
@@ -297,7 +316,7 @@
|
|||||||
<div class="card bg-base-200">
|
<div class="card bg-base-200">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div class="flex-1">
|
||||||
<h3 class="font-bold text-lg">
|
<h3 class="font-bold text-lg">
|
||||||
{grupo.funcionario?.nome || 'Funcionário não encontrado'}
|
{grupo.funcionario?.nome || 'Funcionário não encontrado'}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -307,9 +326,39 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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
|
<button
|
||||||
class="btn btn-sm btn-primary"
|
class="btn btn-sm btn-primary"
|
||||||
onclick={() => imprimirFichaPonto(grupo.registros[0]!.funcionarioId)}
|
onclick={() => imprimirFichaPonto(grupo.funcionarioId)}
|
||||||
>
|
>
|
||||||
<Printer class="h-4 w-4" />
|
<Printer class="h-4 w-4" />
|
||||||
Imprimir Ficha
|
Imprimir Ficha
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Widget Gestão de Pontos -->
|
<!-- 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 />
|
<WidgetGestaoPontos />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { v } from 'convex/values';
|
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 type { MutationCtx, QueryCtx } from './_generated/server';
|
||||||
import { getCurrentUserFunction } from './auth';
|
import { getCurrentUserFunction } from './auth';
|
||||||
import type { Id } from './_generated/dataModel';
|
import type { Id } from './_generated/dataModel';
|
||||||
@@ -52,6 +52,7 @@ interface InformacoesDispositivo {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcula se o registro está dentro do prazo baseado na configuração
|
* Calcula se o registro está dentro do prazo baseado na configuração
|
||||||
|
* Se toleranciaMinutos for 0, desconsidera atrasos (sempre retorna true)
|
||||||
*/
|
*/
|
||||||
function calcularStatusPonto(
|
function calcularStatusPonto(
|
||||||
hora: number,
|
hora: number,
|
||||||
@@ -59,6 +60,11 @@ function calcularStatusPonto(
|
|||||||
horarioConfigurado: string,
|
horarioConfigurado: string,
|
||||||
toleranciaMinutos: number
|
toleranciaMinutos: number
|
||||||
): boolean {
|
): 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 [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number);
|
||||||
const totalMinutosRegistro = hora * 60 + minuto;
|
const totalMinutosRegistro = hora * 60 + minuto;
|
||||||
const totalMinutosConfigurado = horaConfig * 60 + minutoConfig;
|
const totalMinutosConfigurado = horaConfig * 60 + minutoConfig;
|
||||||
@@ -141,6 +147,7 @@ export const registrarPonto = mutation({
|
|||||||
),
|
),
|
||||||
timestamp: v.number(),
|
timestamp: v.number(),
|
||||||
sincronizadoComServidor: v.boolean(),
|
sincronizadoComServidor: v.boolean(),
|
||||||
|
justificativa: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const usuario = await getCurrentUserFunction(ctx);
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
@@ -225,6 +232,7 @@ export const registrarPonto = mutation({
|
|||||||
sincronizadoComServidor: args.sincronizadoComServidor,
|
sincronizadoComServidor: args.sincronizadoComServidor,
|
||||||
toleranciaMinutos: config.toleranciaMinutos,
|
toleranciaMinutos: config.toleranciaMinutos,
|
||||||
dentroDoPrazo,
|
dentroDoPrazo,
|
||||||
|
justificativa: args.justificativa,
|
||||||
ipAddress: args.informacoesDispositivo?.ipAddress,
|
ipAddress: args.informacoesDispositivo?.ipAddress,
|
||||||
ipPublico: args.informacoesDispositivo?.ipPublico,
|
ipPublico: args.informacoesDispositivo?.ipPublico,
|
||||||
ipLocal: args.informacoesDispositivo?.ipLocal,
|
ipLocal: args.informacoesDispositivo?.ipLocal,
|
||||||
@@ -257,6 +265,9 @@ export const registrarPonto = mutation({
|
|||||||
criadoEm: Date.now(),
|
criadoEm: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Atualizar banco de horas após registrar
|
||||||
|
await atualizarBancoHoras(ctx, usuario.funcionarioId, data, config);
|
||||||
|
|
||||||
return { registroId, tipo, dentroDoPrazo };
|
return { registroId, tipo, dentroDoPrazo };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -421,8 +432,15 @@ export const obterRegistro = query({
|
|||||||
simbolo = await ctx.db.get(funcionario.simboloId);
|
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 {
|
return {
|
||||||
...registro,
|
...registro,
|
||||||
|
imagemUrl,
|
||||||
funcionario: funcionario
|
funcionario: funcionario
|
||||||
? {
|
? {
|
||||||
nome: funcionario.nome,
|
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()),
|
connectionType: v.optional(v.string()),
|
||||||
memoryInfo: v.optional(v.string()),
|
memoryInfo: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Justificativa opcional para o registro
|
||||||
|
justificativa: v.optional(v.string()),
|
||||||
|
|
||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
})
|
})
|
||||||
.index("by_funcionario_data", ["funcionarioId", "data"])
|
.index("by_funcionario_data", ["funcionarioId", "data"])
|
||||||
@@ -1416,5 +1419,19 @@ export default defineSchema({
|
|||||||
atualizadoPor: v.id("usuarios"),
|
atualizadoPor: v.id("usuarios"),
|
||||||
atualizadoEm: v.number(),
|
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