refactor: update ErrorModal and RegistroPonto components for improved UI and functionality

- Refactored ErrorModal to use a div-based layout with enhanced animations and accessibility features.
- Updated RegistroPonto to include a new loading state and improved modal handling for webcam capture.
- Enhanced styling for better visual consistency and user experience across modals and registration cards.
- Introduced comparative balance calculations in RegistroPonto for better visibility of time discrepancies.
This commit is contained in:
2025-11-23 13:13:24 -03:00
parent db2daacdad
commit 35e7c10ed0
5 changed files with 660 additions and 221 deletions

View File

@@ -23,38 +23,41 @@
details.match(/^\d+\./); // Começa com número (lista numerada)
});
let modalRef: HTMLDialogElement;
function handleClose() {
open = false;
onClose();
}
$effect(() => {
if (open && modalRef) {
modalRef.showModal();
} else if (!open && modalRef) {
modalRef.close();
}
});
</script>
{#if open}
<dialog
bind:this={modalRef}
class="modal"
onclick={(e) => e.target === e.currentTarget && handleClose()}
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-error-title"
>
<div class="modal-box max-w-2xl" onclick={(e) => e.stopPropagation()}>
<!-- Header -->
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 id="modal-title" class="text-error flex items-center gap-2 text-xl font-bold">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-200"
onclick={handleClose}
></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 -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300 flex-shrink-0">
<h2 id="modal-error-title" class="text-error flex items-center gap-2 text-xl font-bold">
<AlertCircle class="h-6 w-6" strokeWidth={2.5} />
{title}
</h2>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost"
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
onclick={handleClose}
aria-label="Fechar"
>
@@ -62,8 +65,8 @@
</button>
</div>
<!-- Content -->
<div class="px-6 py-6">
<!-- Content com rolagem -->
<div class="flex-1 overflow-y-auto px-6 py-6 modal-scroll">
<!-- Mensagem principal -->
<div class="mb-6">
<p class="text-base-content text-base leading-relaxed font-medium">{message}</p>
@@ -96,14 +99,59 @@
{/if}
</div>
<!-- Footer -->
<div class="modal-action border-base-300 border-t px-6 pb-6 pt-4">
<button class="btn btn-primary btn-md" onclick={handleClose}> Entendi, obrigado </button>
<!-- Footer fixo -->
<div class="flex justify-end px-6 py-4 border-t border-base-300 flex-shrink-0">
<button class="btn btn-primary" onclick={handleClose}>Entendi, obrigado</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={handleClose}>fechar</button>
</form>
</dialog>
</div>
{/if}
<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

@@ -13,7 +13,7 @@
getTipoRegistroLabel,
getProximoTipoRegistro
} from '$lib/utils/ponto';
import { LogIn, LogOut, Clock, CheckCircle2, XCircle, TrendingUp, TrendingDown, Printer } from 'lucide-svelte';
import { LogIn, LogOut, Clock, CheckCircle2, XCircle, TrendingUp, TrendingDown, Printer, Camera } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import jsPDF from 'jspdf';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
@@ -64,6 +64,7 @@
let justificativa = $state('');
let mostrandoModalConfirmacao = $state(false);
let dataHoraAtual = $state<{ data: string; hora: string } | null>(null);
let aguardandoProcessamento = $state(false);
const registrosHoje = $derived(registrosHojeQuery?.data || []);
const config = $derived(configQuery?.data);
@@ -312,6 +313,7 @@
}, 1000);
} catch (error) {
console.error('Erro ao registrar ponto:', error);
aguardandoProcessamento = false;
let mensagemErro = 'Erro desconhecido ao registrar ponto';
let detalhesErro = 'Tente novamente em alguns instantes.';
@@ -389,6 +391,7 @@
} finally {
registrando = false;
coletandoInfo = false;
aguardandoProcessamento = false;
}
}
@@ -514,6 +517,7 @@
function confirmarRegistro() {
mostrandoModalConfirmacao = false;
aguardandoProcessamento = true;
registrarPonto();
}
@@ -753,25 +757,7 @@
return !registrando && !coletandoInfo && config !== undefined && !estaDispensado && temFuncionarioAssociado;
});
// 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);
}
});
// Os modais agora são centralizados automaticamente via CSS (fixed inset-0 flex items-center justify-center)
// Solicitar permissões automaticamente ao montar o componente
onMount(async () => {
@@ -922,71 +908,95 @@
</div>
{/if}
<!-- Botões de Registro -->
<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="mb-6 w-full">
<RelogioSincronizado />
<!-- Card de Registro de Ponto Modernizado -->
<div class="card bg-gradient-to-br from-base-100 via-base-100 to-primary/5 border border-base-300 shadow-2xl max-w-2xl mx-auto">
<div class="card-body p-6">
<!-- Cabeçalho -->
<div class="flex items-center justify-center gap-3 mb-6">
<div class="p-2.5 bg-primary/10 rounded-xl">
<Clock class="h-6 w-6 text-primary" strokeWidth={2.5} />
</div>
<h2 class="card-title text-2xl font-black text-base-content">Registrar Ponto</h2>
</div>
<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" />
<span>{sucesso}</span>
</div>
{/if}
<div class="mb-4 text-center">
<p class="text-lg font-semibold">Próximo registro: {tipoLabel}</p>
<!-- Relógio Sincronizado -->
<div class="mb-5 flex justify-center">
<div class="card bg-gradient-to-br from-primary/10 to-primary/5 border-2 border-primary/20 shadow-lg rounded-2xl p-5 w-full max-w-sm">
<RelogioSincronizado />
</div>
</div>
<!-- Campo de Justificativa (Opcional) -->
<div class="w-full">
<label for="justificativa" class="label">
<span class="label-text">Justificativa (Opcional)</span>
</label>
<textarea
id="justificativa"
class="textarea textarea-bordered w-full"
placeholder="Digite uma justificativa para este registro de ponto (opcional)"
bind:value={justificativa}
disabled={registrando}
rows="3"
></textarea>
</div>
<button
class="btn btn-primary btn-lg"
onclick={iniciarRegistroComFoto}
disabled={!podeRegistrar}
title={!temFuncionarioAssociado
? 'Você não possui funcionário associado à sua conta'
: estaDispensado
? 'Você está dispensado de registrar ponto no momento'
: ''}
>
{#if registrando}
<span class="loading loading-spinner loading-sm"></span>
{#if coletandoInfo}
Coletando informações...
{:else}
Registrando...
{/if}
{:else if !temFuncionarioAssociado}
<XCircle class="h-5 w-5" />
Funcionário Não Associado
{:else if estaDispensado}
<XCircle class="h-5 w-5" />
Registro Indisponível
{:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
<LogIn class="h-5 w-5" />
Registrar Entrada
<!-- Botão de Registro -->
<button
class="btn btn-primary w-full shadow-lg hover:shadow-xl transition-all duration-300 font-semibold rounded-xl gap-2 mb-5"
onclick={iniciarRegistroComFoto}
disabled={!podeRegistrar}
title={!temFuncionarioAssociado
? 'Você não possui funcionário associado à sua conta'
: estaDispensado
? 'Você está dispensado de registrar ponto no momento'
: ''}
>
{#if registrando}
<span class="loading loading-spinner loading-sm"></span>
{#if coletandoInfo}
Coletando informações...
{:else}
<LogOut class="h-5 w-5" />
Registrar Saída
Registrando...
{/if}
</button>
{:else if !temFuncionarioAssociado}
<XCircle class="h-5 w-5" />
Funcionário Não Associado
{:else if estaDispensado}
<XCircle class="h-5 w-5" />
Registro Indisponível
{:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
<LogIn class="h-5 w-5" />
Registrar Entrada
{:else}
<LogOut class="h-5 w-5" />
Registrar Saída
{/if}
</button>
<!-- Campo de Justificativa -->
<div class="mb-5">
<label for="justificativa" class="label pb-1.5">
<span class="label-text font-semibold text-xs">Justificativa <span class="text-base-content/50 font-normal">(Opcional)</span></span>
</label>
<textarea
id="justificativa"
class="textarea textarea-bordered w-full focus:textarea-primary focus:ring-2 focus:ring-primary/20 rounded-xl resize-none text-sm"
placeholder="Digite uma justificativa para este registro de ponto (opcional)"
bind:value={justificativa}
disabled={registrando}
rows="2"
></textarea>
</div>
<!-- Mensagem de Sucesso -->
{#if sucesso}
<div class="alert alert-success shadow-lg mb-5 rounded-xl">
<CheckCircle2 class="h-4 w-4" />
<span class="font-semibold text-sm">{sucesso}</span>
</div>
{/if}
<!-- Próximo Registro -->
<div class="card bg-gradient-to-br from-info/10 to-info/5 border-2 border-info/20 shadow-md rounded-xl p-4">
<div class="flex items-center justify-center gap-2">
<div class="p-1.5 bg-info/20 rounded-lg">
{#if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
<LogIn class="h-4 w-4 text-info" strokeWidth={2.5} />
{:else}
<LogOut class="h-4 w-4 text-info" strokeWidth={2.5} />
{/if}
</div>
<div class="text-center">
<p class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-0.5">Próximo Registro</p>
<p class="text-base font-bold text-base-content">{tipoLabel}</p>
</div>
</div>
</div>
</div>
</div>
@@ -1320,49 +1330,111 @@
<!-- Modal Webcam -->
{#if mostrandoWebcam}
<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;"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-webcam-title"
>
<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">Capturar Foto</h3>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-200"
onclick={handleWebcamCancel}
></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 -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300 flex-shrink-0">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<Camera class="h-5 w-5 text-primary" strokeWidth={2} />
</div>
<h3 id="modal-webcam-title" class="text-xl font-bold text-base-content">Capturar Foto</h3>
</div>
<button
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
onclick={handleWebcamCancel}
>
<XCircle class="h-5 w-5" />
</button>
</div>
<div class="min-h-[200px] flex items-center justify-center py-4">
<WebcamCapture
onCapture={handleWebcamCapture}
onCancel={handleWebcamCancel}
onError={handleWebcamError}
autoCapture={false}
fotoObrigatoria={true}
/>
<!-- Conteúdo com rolagem -->
<div class="flex-1 overflow-y-auto px-6 py-4 modal-scroll">
<div class="min-h-[200px] flex items-center justify-center">
<WebcamCapture
onCapture={handleWebcamCapture}
onCancel={handleWebcamCancel}
onError={handleWebcamError}
autoCapture={false}
fotoObrigatoria={true}
/>
</div>
</div>
</div>
<form
method="dialog"
class="modal-backdrop"
onsubmit={(e) => {
e.preventDefault();
handleWebcamCancel();
}}
</div>
{/if}
<!-- Modal de Aguardando Processamento -->
{#if aguardandoProcessamento}
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-aguardando-title"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-200"></div>
<!-- Modal Box -->
<div
class="relative bg-base-100 rounded-2xl shadow-2xl max-w-md w-full z-10 transform transition-all duration-300 p-8"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
>
<button type="submit" aria-label="Fechar modal">fechar</button>
</form>
<div class="flex flex-col items-center gap-4 text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<h3 id="modal-aguardando-title" class="text-xl font-bold text-base-content">Processando Registro</h3>
<p class="text-base-content/70">Por favor, aguarde enquanto processamos seu registro de ponto...</p>
</div>
</div>
</div>
{/if}
<!-- Modal de Confirmação -->
{#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual}
<div class="modal modal-open" style="display: flex; align-items: center; justify-content: center;">
<div class="modal-box max-w-3xl w-[95%] max-h-[85vh] overflow-hidden flex flex-col" style="margin: auto; max-height: 85vh;">
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-confirmacao-title"
>
<!-- Backdrop -->
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-200"
onclick={cancelarRegistro}
></div>
<!-- Modal Box -->
<div
class="relative bg-base-100 rounded-2xl shadow-2xl max-w-3xl 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 -->
<div class="flex items-center justify-between mb-6 pb-4 border-b border-base-300 flex-shrink-0">
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300 flex-shrink-0">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<Clock class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<div>
<h3 class="font-bold text-xl text-base-content">Confirmar Registro de Ponto</h3>
<h3 id="modal-confirmacao-title" class="font-bold text-xl text-base-content">Confirmar Registro de Ponto</h3>
<p class="text-sm text-base-content/70">Verifique as informações antes de confirmar</p>
</div>
</div>
@@ -1376,7 +1448,7 @@
</div>
<!-- Conteúdo com rolagem -->
<div class="flex-1 overflow-y-auto pr-2 space-y-6">
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6 modal-scroll">
<!-- Card da Imagem -->
<div class="card bg-gradient-to-br from-base-200 to-base-300 shadow-lg border-2 border-primary/20">
<div class="card-body p-6">
@@ -1483,9 +1555,9 @@
</div>
<!-- Footer fixo com botões -->
<div class="flex justify-end gap-3 pt-4 mt-4 border-t border-base-300 flex-shrink-0">
<div class="flex justify-end gap-3 px-6 py-4 border-t border-base-300 flex-shrink-0">
<button
class="btn btn-outline btn-lg"
class="btn btn-outline"
onclick={cancelarRegistro}
disabled={registrando}
>
@@ -1493,7 +1565,7 @@
Cancelar
</button>
<button
class="btn btn-primary btn-lg gap-2"
class="btn btn-primary gap-2"
onclick={confirmarRegistro}
disabled={registrando}
>
@@ -1507,7 +1579,6 @@
</button>
</div>
</div>
<div class="modal-backdrop" onclick={cancelarRegistro}></div>
</div>
{/if}
@@ -1524,3 +1595,52 @@
onClose={fecharModalErro}
/>
</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

@@ -113,13 +113,28 @@
});
</script>
<div class="flex flex-col items-center gap-2">
<div class="text-4xl font-bold font-mono text-primary">{horaFormatada}</div>
<div class="text-sm text-base-content/70 capitalize">{dataFormatada}</div>
<div class="flex items-center gap-2 text-xs">
<div class="flex flex-col items-center gap-4 w-full">
<!-- Hora -->
<div class="text-5xl font-black font-mono text-primary tracking-tight drop-shadow-sm">
{horaFormatada}
</div>
<!-- Data -->
<div class="text-base font-semibold text-base-content/80 capitalize">
{dataFormatada}
</div>
<!-- Status de Sincronização -->
<div class="flex items-center gap-2 px-4 py-2 rounded-full {
sincronizado
? 'bg-success/20 text-success border border-success/30'
: erro
? 'bg-warning/20 text-warning border border-warning/30'
: 'bg-base-300/50 text-base-content/60 border border-base-300'
}">
{#if sincronizado}
<CheckCircle2 class="h-4 w-4 text-success" />
<span class="text-success">
<CheckCircle2 class="h-4 w-4" strokeWidth={2.5} />
<span class="text-sm font-semibold">
{#if usandoServidorExterno}
Sincronizado com servidor NTP
{:else}
@@ -127,11 +142,11 @@
{/if}
</span>
{:else if erro}
<AlertCircle class="h-4 w-4 text-warning" />
<span class="text-warning">{erro}</span>
<AlertCircle class="h-4 w-4" strokeWidth={2.5} />
<span class="text-sm font-semibold">{erro}</span>
{:else}
<Clock class="h-4 w-4 text-base-content/50" />
<span class="text-base-content/50">Usando relógio do PC</span>
<Clock class="h-4 w-4" strokeWidth={2.5} />
<span class="text-sm font-semibold">Usando relógio do PC</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
interface Props {
saldo?: {
trabalhadoMinutos: number;
esperadoMinutos: number;
diferencaMinutos: number;
} | null;
size?: 'sm' | 'md' | 'lg';
}
let { saldo, size = 'md' }: Props = $props();
function formatarMinutos(minutos: number): { horas: number; minutos: number } {
const horas = Math.floor(Math.abs(minutos) / 60);
const mins = Math.abs(minutos) % 60;
return { horas, minutos: mins };
}
const sizeClasses = {
sm: 'text-xs px-2 py-1',
md: 'text-sm px-3 py-1.5',
lg: 'text-base px-4 py-2'
};
</script>
{#if saldo}
{@const trabalhado = formatarMinutos(saldo.trabalhadoMinutos)}
{@const diferenca = formatarMinutos(saldo.diferencaMinutos)}
{@const sinalDiferenca = saldo.diferencaMinutos >= 0 ? '+' : '-'}
{@const isNegativo = saldo.diferencaMinutos < 0}
<div class="inline-flex items-center gap-1.5 {sizeClasses[size]} rounded-lg font-semibold shadow-sm border {
isNegativo
? 'bg-red-50 border-red-200 text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-400'
: 'bg-green-50 border-green-200 text-green-700 dark:bg-green-900/20 dark:border-green-800 dark:text-green-400'
}">
<span class="font-bold">+{trabalhado.horas}h {trabalhado.minutos}min</span>
<span class="text-base-content/50">/</span>
<span class={isNegativo ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}>
{sinalDiferenca}{diferenca.horas}h {diferenca.minutos}min
</span>
</div>
{:else}
<span class="badge badge-ghost {sizeClasses[size]}">-</span>
{/if}