feat: enhance RegistroPonto and WebcamCapture components for improved data handling and user experience

- Added a refresh mechanism in the RegistroPonto component to ensure queries are updated after point registration, improving data accuracy.
- Expanded the WebcamCapture component to prevent multiple simultaneous play calls, enhancing video playback reliability.
- Updated the registro-pontos page to default the date range to the last 30 days for better visibility and user convenience.
- Introduced debug logging for queries and data handling to assist in development and troubleshooting.
This commit is contained in:
2025-11-22 23:57:05 -03:00
parent 90e81e4667
commit 467e04b605
5 changed files with 269 additions and 58 deletions

View File

@@ -237,18 +237,36 @@
} }
</script> </script>
<div class="modal modal-open" style="display: flex; align-items: center; justify-content: center;"> <div
<div class="modal-box max-w-2xl w-[95%] max-h-[85vh] overflow-hidden flex flex-col" style="margin: auto; max-height: 85vh;"> class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="animation: fadeIn 0.2s ease-out;"
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-comprovante-title"
>
<!-- Backdrop com blur -->
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-200"
onclick={onClose}
></div>
<!-- Modal Box -->
<div
class="relative bg-base-100 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
onclick={(e) => e.stopPropagation()}
>
<!-- Header fixo --> <!-- Header fixo -->
<div class="flex items-center justify-between mb-4 pb-4 border-b border-base-300 flex-shrink-0"> <div class="flex items-center justify-between mb-4 pb-4 border-b border-base-300 flex-shrink-0">
<h3 class="font-bold text-lg">Comprovante de Registro de Ponto</h3> <h3 id="modal-comprovante-title" class="font-bold text-lg">Comprovante de Registro de Ponto</h3>
<button class="btn btn-sm btn-circle btn-ghost hover:bg-base-300" onclick={onClose}> <button class="btn btn-sm btn-circle btn-ghost hover:bg-base-300" onclick={onClose}>
<X class="h-5 w-5" /> <X class="h-5 w-5" />
</button> </button>
</div> </div>
<!-- Conteúdo com rolagem --> <!-- Conteúdo com rolagem -->
<div class="flex-1 overflow-y-auto pr-2"> <div class="flex-1 overflow-y-auto pr-2 modal-scroll">
{#if registroQuery === undefined} {#if registroQuery === undefined}
<div class="flex justify-center items-center py-8"> <div class="flex justify-center items-center py-8">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
@@ -339,6 +357,54 @@
<button class="btn btn-outline" onclick={onClose}>Fechar</button> <button class="btn btn-outline" onclick={onClose}>Fechar</button>
</div> </div>
</div> </div>
<div class="modal-backdrop" onclick={onClose}></div>
</div> </div>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Scrollbar customizada para os modais */
:global(.modal-scroll) {
scrollbar-width: thin;
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
:global(.modal-scroll::-webkit-scrollbar) {
width: 8px;
}
:global(.modal-scroll::-webkit-scrollbar-track) {
background: transparent;
border-radius: 4px;
}
:global(.modal-scroll::-webkit-scrollbar-thumb) {
background-color: hsl(var(--bc) / 0.3);
border-radius: 4px;
transition: background-color 0.2s ease;
}
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
background-color: hsl(var(--bc) / 0.5);
}
</style>

View File

@@ -21,17 +21,26 @@
const client = useConvexClient(); const client = useConvexClient();
// Chave de refresh para forçar atualização das queries após registro
let refreshKey = $state(0);
// Queries // Queries
const currentUser = useQuery(api.auth.getCurrentUser, {}); const currentUser = useQuery(api.auth.getCurrentUser, {});
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {}); const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
const registrosHojeQuery = useQuery(api.pontos.listarRegistrosDia, {});
// Query para histórico e saldo do dia // Query para histórico e saldo do dia
const funcionarioId = $derived(currentUser?.data?.funcionarioId ?? null); const funcionarioId = $derived(currentUser?.data?.funcionarioId ?? null);
const dataHoje = $derived(new Date().toISOString().split('T')[0]!); const dataHoje = $derived(new Date().toISOString().split('T')[0]!);
// Usar refreshKey para forçar atualização após registro
const registrosHojeQuery = useQuery(
api.pontos.listarRegistrosDia,
{ data: dataHoje, _refresh: refreshKey }
);
const historicoSaldoQuery = useQuery( const historicoSaldoQuery = useQuery(
api.pontos.obterHistoricoESaldoDia, api.pontos.obterHistoricoESaldoDia,
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip' funcionarioId && dataHoje ? { funcionarioId, data: dataHoje, _refresh: refreshKey } : 'skip'
); );
// Query para verificar dispensa ativa // Query para verificar dispensa ativa
@@ -284,6 +293,19 @@
justificativa = ''; // Limpar justificativa após registro justificativa = ''; // Limpar justificativa após registro
mostrandoModalConfirmacao = false; mostrandoModalConfirmacao = false;
// Forçar atualização das queries para mostrar o novo registro
refreshKey++;
if (import.meta.env.DEV) {
console.log('[RegistroPonto] Registro bem-sucedido, refreshKey incrementado:', refreshKey);
}
// Aguardar um pouco para garantir que o backend processou o registro
await new Promise(resolve => setTimeout(resolve, 500));
// Forçar mais uma atualização após o delay para garantir sincronização
refreshKey++;
// Mostrar comprovante após 1 segundo // Mostrar comprovante após 1 segundo
setTimeout(() => { setTimeout(() => {
mostrandoComprovante = true; mostrandoComprovante = true;
@@ -799,7 +821,7 @@
{ tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' } { tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' }
]; ];
return horarios.map((h) => { const resultado = horarios.map((h) => {
const registro = registrosHoje.find((r) => r.tipo === h.tipo); const registro = registrosHoje.find((r) => r.tipo === h.tipo);
return { return {
...h, ...h,
@@ -808,6 +830,17 @@
dentroDoPrazo: registro?.dentroDoPrazo ?? null dentroDoPrazo: registro?.dentroDoPrazo ?? null
}; };
}); });
// Log para debug (apenas em desenvolvimento)
if (import.meta.env.DEV) {
console.log('[RegistroPonto] mapaHorarios atualizado:', {
totalRegistrosHoje: registrosHoje.length,
horariosComRegistro: resultado.filter(h => h.registrado).length,
registrosHoje: registrosHoje.map(r => ({ tipo: r.tipo, hora: `${r.hora}:${r.minuto}` }))
});
}
return resultado;
}); });
// Dados do histórico e saldo // Dados do histórico e saldo

View File

@@ -22,18 +22,29 @@
let previewUrl = $state<string | null>(null); let previewUrl = $state<string | null>(null);
let videoReady = $state(false); let videoReady = $state(false);
// Flag para evitar múltiplas chamadas de play() simultâneas
let playEmAndamento = $state(false);
// Efeito para garantir que o vídeo seja exibido quando o stream for atribuído // Efeito para garantir que o vídeo seja exibido quando o stream for atribuído
$effect(() => { $effect(() => {
if (stream && videoElement) { if (stream && videoElement && !playEmAndamento) {
// Sempre atualizar srcObject quando o stream mudar // Sempre atualizar srcObject quando o stream mudar
if (videoElement.srcObject !== stream) { if (videoElement.srcObject !== stream) {
videoElement.srcObject = stream; videoElement.srcObject = stream;
} }
// Tentar reproduzir se ainda não estiver pronto // Tentar reproduzir se ainda não estiver pronto e não houver outra chamada em andamento
if (!videoReady && videoElement.readyState < 2) { if (!videoReady && videoElement.readyState < 2) {
// Verificar se já não está reproduzindo
if (!videoElement.paused && videoElement.readyState >= 2) {
videoReady = true;
return;
}
playEmAndamento = true;
videoElement.play() videoElement.play()
.then(() => { .then(() => {
playEmAndamento = false;
// Aguardar um pouco para garantir que o vídeo esteja realmente reproduzindo // Aguardar um pouco para garantir que o vídeo esteja realmente reproduzindo
setTimeout(() => { setTimeout(() => {
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) { if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
@@ -42,7 +53,11 @@
}, 300); }, 300);
}) })
.catch((err) => { .catch((err) => {
console.warn('Erro ao reproduzir vídeo no effect:', err); playEmAndamento = false;
// Ignorar AbortError - é esperado quando há uma nova requisição de load
if (err.name !== 'AbortError') {
console.warn('Erro ao reproduzir vídeo no effect:', err);
}
}); });
} else if (videoElement.readyState >= 2 && videoElement.videoWidth > 0) { } else if (videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
videoReady = true; videoReady = true;
@@ -219,35 +234,50 @@
videoElement.addEventListener('playing', onPlaying); videoElement.addEventListener('playing', onPlaying);
videoElement.addEventListener('error', onError); videoElement.addEventListener('error', onError);
// Tentar reproduzir // Tentar reproduzir apenas se não estiver já reproduzindo
videoElement.play() if (videoElement.paused) {
.then(() => { playEmAndamento = true;
console.log('Vídeo iniciado, readyState:', videoElement?.readyState); videoElement.play()
// Se já tiver metadata e dimensões, resolver imediatamente .then(() => {
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) { playEmAndamento = false;
setTimeout(() => { console.log('Vídeo iniciado, readyState:', videoElement?.readyState);
onLoadedMetadata(); // Se já tiver metadata e dimensões, resolver imediatamente
}, 300); if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
} setTimeout(() => {
})
.catch((err) => {
console.warn('Erro ao reproduzir vídeo:', err);
// Continuar mesmo assim se já tiver metadata e dimensões
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
setTimeout(() => {
onLoadedMetadata();
}, 300);
} else {
// Aguardar um pouco mais antes de dar erro
setTimeout(() => {
if (videoElement && videoElement.videoWidth > 0) {
onLoadedMetadata(); onLoadedMetadata();
} else { }, 300);
onError(); }
} })
}, 1000); .catch((err) => {
} playEmAndamento = false;
}); // Ignorar AbortError - é esperado quando há uma nova requisição de load
if (err.name !== 'AbortError') {
console.warn('Erro ao reproduzir vídeo:', err);
}
// Continuar mesmo assim se já tiver metadata e dimensões
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
setTimeout(() => {
onLoadedMetadata();
}, 300);
} else {
// Aguardar um pouco mais antes de dar erro
setTimeout(() => {
if (videoElement && videoElement.videoWidth > 0) {
onLoadedMetadata();
} else {
onError();
}
}, 1000);
}
});
} else {
// Já está reproduzindo, apenas verificar se está pronto
if (videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
setTimeout(() => {
onLoadedMetadata();
}, 300);
}
}
}); });
console.log('Vídeo pronto, dimensões:', videoElement.videoWidth, 'x', videoElement.videoHeight); console.log('Vídeo pronto, dimensões:', videoElement.videoWidth, 'x', videoElement.videoHeight);

View File

@@ -20,8 +20,13 @@
const client = useConvexClient(); const client = useConvexClient();
// Estados // Estados
let dataInicio = $state(new Date().toISOString().split('T')[0]!); // Expandir período padrão para últimos 30 dias para facilitar visualização
let dataFim = $state(new Date().toISOString().split('T')[0]!); const hoje = new Date();
const trintaDiasAtras = new Date(hoje);
trintaDiasAtras.setDate(hoje.getDate() - 30);
let dataInicio = $state(trintaDiasAtras.toISOString().split('T')[0]!);
let dataFim = $state(hoje.toISOString().split('T')[0]!);
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>(''); let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
let statusFiltro = $state<'todos' | 'dentro' | 'fora'>('todos'); let statusFiltro = $state<'todos' | 'dentro' | 'fora'>('todos');
let localizacaoFiltro = $state<'todos' | 'dentro' | 'fora'>('todos'); let localizacaoFiltro = $state<'todos' | 'dentro' | 'fora'>('todos');
@@ -56,6 +61,21 @@
const estatisticas = $derived(estatisticasQuery?.data); const estatisticas = $derived(estatisticasQuery?.data);
const config = $derived(configQuery?.data); const config = $derived(configQuery?.data);
// Debug: Log dos dados recebidos
$effect(() => {
if (registrosQuery !== undefined) {
console.log('[Frontend] registrosQuery:', {
isLoading: registrosQuery?.isLoading,
error: registrosQuery?.error,
dataLength: registrosQuery?.data?.length ?? 0,
params: registrosParams
});
}
if (registros && registros.length > 0) {
console.log('[Frontend] Primeiros registros:', registros.slice(0, 3));
}
});
// Dados do gráfico baseados nas estatísticas // Dados do gráfico baseados nas estatísticas
const chartData = $derived.by(() => { const chartData = $derived.by(() => {
@@ -514,8 +534,12 @@
// Função para limpar todos os filtros // Função para limpar todos os filtros
function limparFiltros() { function limparFiltros() {
dataInicio = new Date().toISOString().split('T')[0]!; const hoje = new Date();
dataFim = new Date().toISOString().split('T')[0]!; const trintaDiasAtras = new Date(hoje);
trintaDiasAtras.setDate(hoje.getDate() - 30);
dataInicio = trintaDiasAtras.toISOString().split('T')[0]!;
dataFim = hoje.toISOString().split('T')[0]!;
funcionarioIdFiltro = ''; funcionarioIdFiltro = '';
statusFiltro = 'todos'; statusFiltro = 'todos';
localizacaoFiltro = 'todos'; localizacaoFiltro = 'todos';

View File

@@ -791,6 +791,7 @@ export const registrarPonto = mutation({
export const listarRegistrosDia = query({ export const listarRegistrosDia = query({
args: { args: {
data: v.optional(v.string()), // YYYY-MM-DD, se não fornecido usa hoje data: v.optional(v.string()), // YYYY-MM-DD, se não fornecido usa hoje
_refresh: v.optional(v.number()), // Parâmetro usado pelo frontend para forçar refresh
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx); const usuario = await getCurrentUserFunction(ctx);
@@ -801,12 +802,22 @@ export const listarRegistrosDia = query({
const funcionarioId = usuario.funcionarioId; // Garantir que não é undefined const funcionarioId = usuario.funcionarioId; // Garantir que não é undefined
const data = args.data || new Date().toISOString().split('T')[0]!; const data = args.data || new Date().toISOString().split('T')[0]!;
console.log('[listarRegistrosDia] Buscando registros:', { funcionarioId, data });
const registros = await ctx.db const registros = await ctx.db
.query('registrosPonto') .query('registrosPonto')
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data)) .withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
.order('asc') .order('asc')
.collect(); .collect();
console.log('[listarRegistrosDia] Registros encontrados:', registros.length, registros.map(r => ({
_id: r._id,
tipo: r.tipo,
data: r.data,
hora: r.hora,
minuto: r.minuto
})));
return registros; return registros;
}, },
}); });
@@ -862,7 +873,7 @@ export const listarRegistrosPeriodo = query({
handler: async (ctx, args) => { handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx); const usuario = await getCurrentUserFunction(ctx);
if (!usuario) { if (!usuario) {
// Retornar array vazio quando não autenticado console.warn('[listarRegistrosPeriodo] Usuário não autenticado');
return []; return [];
} }
@@ -872,15 +883,27 @@ export const listarRegistrosPeriodo = query({
// Validar formato das datas // Validar formato das datas
if (!args.dataInicio || !args.dataFim) { if (!args.dataInicio || !args.dataFim) {
console.warn('[listarRegistrosPeriodo] Datas não fornecidas');
return []; return [];
} }
// Validar formato YYYY-MM-DD // Validar formato YYYY-MM-DD
const dataInicioRegex = /^\d{4}-\d{2}-\d{2}$/; const dataInicioRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dataInicioRegex.test(args.dataInicio) || !dataInicioRegex.test(args.dataFim)) { if (!dataInicioRegex.test(args.dataInicio) || !dataInicioRegex.test(args.dataFim)) {
console.warn('[listarRegistrosPeriodo] Formato de data inválido', {
dataInicio: args.dataInicio,
dataFim: args.dataFim
});
return []; return [];
} }
console.log('[listarRegistrosPeriodo] Buscando registros', {
dataInicio: args.dataInicio,
dataFim: args.dataFim,
funcionarioId: args.funcionarioId,
usuarioId: usuario._id
});
let registrosFiltrados; let registrosFiltrados;
// Se funcionário foi especificado, usar índice por funcionário e data (mais eficiente) // Se funcionário foi especificado, usar índice por funcionário e data (mais eficiente)
@@ -901,21 +924,41 @@ export const listarRegistrosPeriodo = query({
}); });
} else { } else {
// Se não há funcionário especificado, buscar todos e filtrar (menos eficiente, mas necessário) // Se não há funcionário especificado, buscar todos e filtrar (menos eficiente, mas necessário)
// Usar comparação de strings diretamente para datas no formato YYYY-MM-DD try {
const registros = await ctx.db // Tentar usar índice por data primeiro
.query('registrosPonto') const registros = await ctx.db
.withIndex('by_data', (q) => .query('registrosPonto')
q.gte('data', args.dataInicio).lte('data', args.dataFim) .withIndex('by_data', (q) =>
) q.gte('data', args.dataInicio).lte('data', args.dataFim)
.collect(); )
.collect();
// Garantir que as datas estão no formato correto e filtrar novamente para garantir console.log('[listarRegistrosPeriodo] Registros do índice by_data:', registros.length);
registrosFiltrados = registros.filter((r) => {
// Comparação de strings funciona para formato YYYY-MM-DD // Garantir que as datas estão no formato correto e filtrar novamente para garantir
return r.data >= args.dataInicio && r.data <= args.dataFim; registrosFiltrados = registros.filter((r) => {
}); // Comparação de strings funciona para formato YYYY-MM-DD
return r.data >= args.dataInicio && r.data <= args.dataFim;
});
console.log('[listarRegistrosPeriodo] Registros após filtro:', registrosFiltrados.length);
} catch (error) {
console.error('[listarRegistrosPeriodo] Erro ao buscar registros:', error);
// Fallback: buscar todos e filtrar manualmente
const todosRegistros = await ctx.db
.query('registrosPonto')
.collect();
registrosFiltrados = todosRegistros.filter((r) => {
return r.data >= args.dataInicio && r.data <= args.dataFim;
});
console.log('[listarRegistrosPeriodo] Fallback - registros encontrados:', registrosFiltrados.length);
}
} }
console.log('[listarRegistrosPeriodo] Registros encontrados antes de buscar funcionários:', registrosFiltrados.length);
// Buscar informações dos funcionários // Buscar informações dos funcionários
const funcionariosIds = new Set(registrosFiltrados.map((r) => r.funcionarioId)); const funcionariosIds = new Set(registrosFiltrados.map((r) => r.funcionarioId));
const funcionarios = await Promise.all( const funcionarios = await Promise.all(
@@ -940,6 +983,8 @@ export const listarRegistrosPeriodo = query({
} }
} }
console.log('[listarRegistrosPeriodo] Total de registros a retornar:', registrosFiltrados.length);
return registrosFiltrados.map((registro) => { return registrosFiltrados.map((registro) => {
const funcionario = funcionarios.find((f) => f?._id === registro.funcionarioId); const funcionario = funcionarios.find((f) => f?._id === registro.funcionarioId);
const chave = `${registro.funcionarioId}-${registro.data}`; const chave = `${registro.funcionarioId}-${registro.data}`;
@@ -1225,11 +1270,19 @@ export const obterHistoricoESaldoDia = query({
args: { args: {
funcionarioId: v.id('funcionarios'), funcionarioId: v.id('funcionarios'),
data: v.string(), // YYYY-MM-DD data: v.string(), // YYYY-MM-DD
_refresh: v.optional(v.number()), // Parâmetro usado pelo frontend para forçar refresh
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx); const usuario = await getCurrentUserFunction(ctx);
if (!usuario || !usuario.funcionarioId) { if (!usuario || !usuario.funcionarioId) {
throw new Error('Usuário não autenticado'); console.warn('[obterHistoricoESaldoDia] Usuário não autenticado ou sem funcionarioId');
// Retornar dados vazios em vez de lançar erro
return {
registros: [],
cargaHorariaDiaria: 0,
horasTrabalhadas: 0,
saldoMinutos: 0,
};
} }
// Verificar se é o próprio funcionário ou tem permissão // Verificar se é o próprio funcionário ou tem permissão
@@ -1246,6 +1299,11 @@ export const obterHistoricoESaldoDia = query({
.order('asc') .order('asc')
.collect(); .collect();
console.log('[obterHistoricoESaldoDia] Registros encontrados:', registros.length, {
funcionarioId: args.funcionarioId,
data: args.data
});
// Buscar configuração de ponto // Buscar configuração de ponto
const config = await ctx.db const config = await ctx.db
.query('configuracaoPonto') .query('configuracaoPonto')