feat: update ESLint and TypeScript configurations across frontend and backend; enhance component structure and improve data handling in various modules

This commit is contained in:
2025-12-02 16:36:02 -03:00
parent f48d28067c
commit d79e6959c3
215 changed files with 29474 additions and 28173 deletions

View File

@@ -25,27 +25,27 @@
function calcularPosicaoModal() {
// Procurar pelo elemento do card de registro de ponto
const cardRef = document.getElementById('card-registro-ponto-ref');
if (cardRef) {
const rect = cardRef.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Posicionar o modal na mesma altura Y do card (top do card) - mesma posição do texto "Registrar Ponto"
const top = rect.top;
// Garantir que o modal não saia da viewport
// Considerar uma altura mínima do modal (aproximadamente 300px)
const minTop = 20;
const maxTop = viewportHeight - 350; // Deixar espaço para o modal
const finalTop = Math.max(minTop, Math.min(top, maxTop));
// Centralizar horizontalmente
return {
top: finalTop,
left: window.innerWidth / 2
};
}
// Se não encontrar, usar posição padrão (centro da tela)
return null;
}
@@ -53,37 +53,37 @@
// Atualizar posição quando o modal for aberto (quando registroQuery tiver dados)
$effect(() => {
if (registroQuery?.data) {
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
const updatePosition = () => {
requestAnimationFrame(() => {
const pos = calcularPosicaoModal();
if (pos) {
modalPosition = pos;
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
const updatePosition = () => {
requestAnimationFrame(() => {
const pos = calcularPosicaoModal();
if (pos) {
modalPosition = pos;
} else {
// Fallback para centralização
modalPosition = {
top: window.innerHeight / 2,
left: window.innerWidth / 2
};
}
});
};
}
});
};
// Aguardar um pouco para garantir que o DOM está atualizado
setTimeout(updatePosition, 50);
// Adicionar listener de scroll para atualizar posição
const handleScroll = () => {
updatePosition();
};
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleScroll);
};
setTimeout(updatePosition, 50);
// Adicionar listener de scroll para atualizar posição
const handleScroll = () => {
updatePosition();
};
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleScroll);
};
} else {
// Limpar posição quando o modal for fechado
modalPosition = null;
@@ -137,7 +137,9 @@
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(0, 0, 0);
doc.text('GOVERNO DO ESTADO DE PERNAMBUCO', 105, Math.max(yPosition - 10, 20), { align: 'center' });
doc.text('GOVERNO DO ESTADO DE PERNAMBUCO', 105, Math.max(yPosition - 10, 20), {
align: 'center'
});
doc.setFontSize(12);
doc.text('SECRETARIA DE ESPORTES', 105, Math.max(yPosition - 2, 28), { align: 'center' });
@@ -154,7 +156,7 @@
// Informações do Funcionário em tabela
const funcionarioData: string[][] = [];
if (registro.funcionario) {
if (registro.funcionario.matricula) {
funcionarioData.push(['Matrícula', registro.funcionario.matricula]);
@@ -164,10 +166,14 @@
funcionarioData.push(['Cargo/Função', registro.funcionario.descricaoCargo]);
}
if (registro.funcionario.simbolo) {
const simboloTipo = registro.funcionario.simbolo.tipo === 'cargo_comissionado'
? 'Cargo Comissionado'
: 'Função Gratificada';
funcionarioData.push(['Símbolo', `${registro.funcionario.simbolo.nome} (${simboloTipo})`]);
const simboloTipo =
registro.funcionario.simbolo.tipo === 'cargo_comissionado'
? 'Cargo Comissionado'
: 'Função Gratificada';
funcionarioData.push([
'Símbolo',
`${registro.funcionario.simbolo.nome} (${simboloTipo})`
]);
}
}
@@ -202,12 +208,17 @@
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
nomeSaida: config.nomeSaida
})
: getTipoRegistroLabel(registro.tipo);
const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo);
const dataHora = formatarDataHoraCompleta(
registro.data,
registro.hora,
registro.minuto,
registro.segundo
);
const registroData: string[][] = [
['Tipo', tipoLabel],
['Data e Hora', dataHora],
@@ -260,10 +271,10 @@
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 = () => {
@@ -307,7 +318,7 @@
// 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();
@@ -351,46 +362,53 @@
}
</script>
<div
class="fixed inset-0 z-50 pointer-events-none"
<div
class="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-comprovante-title"
>
role="dialog"
aria-modal="true"
aria-labelledby="modal-comprovante-title"
>
<!-- Backdrop leve -->
<div
class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"
<div
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
onclick={onClose}
></div>
<!-- Modal Box -->
<div
class="absolute bg-gradient-to-br from-base-100 via-base-100 to-primary/5 rounded-2xl shadow-2xl border-2 border-primary/20 max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300 pointer-events-auto"
<div
class="from-base-100 via-base-100 to-primary/5 border-primary/20 pointer-events-auto absolute z-10 flex max-h-[90vh] w-full max-w-2xl transform flex-col overflow-hidden rounded-2xl border-2 bg-gradient-to-br shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
onclick={(e) => e.stopPropagation()}
>
<!-- Header Premium com gradiente -->
<div class="flex items-center justify-between px-6 py-5 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-b-2 border-primary/20 flex-shrink-0">
<div
class="from-primary/10 via-primary/5 border-primary/20 flex flex-shrink-0 items-center justify-between border-b-2 bg-gradient-to-r to-transparent px-6 py-5"
>
<div class="flex items-center gap-3">
<div class="p-2.5 bg-primary/20 rounded-xl shadow-lg">
<Clock class="h-6 w-6 text-primary" strokeWidth={2.5} />
<div class="bg-primary/20 rounded-xl p-2.5 shadow-lg">
<Clock class="text-primary h-6 w-6" strokeWidth={2.5} />
</div>
<div>
<h3 id="modal-comprovante-title" class="font-bold text-xl text-base-content">Comprovante de Registro de Ponto</h3>
<p class="text-sm text-base-content/70 mt-0.5">Detalhes do registro realizado</p>
<h3 id="modal-comprovante-title" class="text-base-content text-xl font-bold">
Comprovante de Registro de Ponto
</h3>
<p class="text-base-content/70 mt-0.5 text-sm">Detalhes do registro realizado</p>
</div>
</div>
<button class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-all" onclick={onClose}>
<button
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-all"
onclick={onClose}
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Conteúdo com rolagem -->
<div class="flex-1 overflow-y-auto px-6 py-6 modal-scroll">
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
{#if registroQuery === undefined}
<div class="flex justify-center items-center py-12">
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if !registroQuery?.data}
@@ -402,35 +420,58 @@
{@const registro = registroQuery.data}
<div class="space-y-6">
<!-- Informações do Funcionário -->
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-lg border-2 border-primary/10 hover:shadow-xl transition-all">
<div
class="card from-base-100 to-base-200 border-primary/10 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
>
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded-lg">
<User class="h-5 w-5 text-primary" strokeWidth={2} />
<div class="mb-4 flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<User class="text-primary h-5 w-5" strokeWidth={2} />
</div>
<h4 class="font-bold text-lg text-base-content">Dados do Funcionário</h4>
<h4 class="text-base-content text-lg font-bold">Dados do Funcionário</h4>
</div>
{#if registro.funcionario}
<div class="space-y-3">
{#if registro.funcionario.matricula}
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
<div
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
>
<div class="flex-1">
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Matrícula</span>
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.matricula}</p>
<span
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Matrícula</span
>
<p class="text-base-content mt-1 text-base font-semibold">
{registro.funcionario.matricula}
</p>
</div>
</div>
{/if}
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
<div
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
>
<div class="flex-1">
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Nome</span>
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.nome}</p>
<span
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Nome</span
>
<p class="text-base-content mt-1 text-base font-semibold">
{registro.funcionario.nome}
</p>
</div>
</div>
{#if registro.funcionario.descricaoCargo}
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
<div
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
>
<div class="flex-1">
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Cargo/Função</span>
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.descricaoCargo}</p>
<span
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Cargo/Função</span
>
<p class="text-base-content mt-1 text-base font-semibold">
{registro.funcionario.descricaoCargo}
</p>
</div>
</div>
{/if}
@@ -440,43 +481,60 @@
</div>
<!-- Informações do Registro -->
<div class="card bg-gradient-to-br from-primary/5 to-primary/10 shadow-lg border-2 border-primary/20 hover:shadow-xl transition-all">
<div
class="card from-primary/5 to-primary/10 border-primary/20 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
>
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/20 rounded-lg">
<Clock class="h-5 w-5 text-primary" strokeWidth={2} />
<div class="mb-4 flex items-center gap-3">
<div class="bg-primary/20 rounded-lg p-2">
<Clock class="text-primary h-5 w-5" strokeWidth={2} />
</div>
<h4 class="font-bold text-lg text-base-content">Dados do Registro</h4>
<h4 class="text-base-content text-lg font-bold">Dados do Registro</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Tipo -->
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Tipo</span>
<p class="text-lg font-bold text-primary mt-1">
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Tipo</span
>
<p class="text-primary mt-1 text-lg font-bold">
{configQuery?.data
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: configQuery.data.nomeEntrada,
nomeSaidaAlmoco: configQuery.data.nomeSaidaAlmoco,
nomeRetornoAlmoco: configQuery.data.nomeRetornoAlmoco,
nomeSaida: configQuery.data.nomeSaida,
nomeSaida: configQuery.data.nomeSaida
})
: getTipoRegistroLabel(registro.tipo)}
</p>
</div>
<!-- Data e Hora -->
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Data e Hora</span>
<p class="text-lg font-bold text-base-content mt-1">
{formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo)}
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Data e Hora</span
>
<p class="text-base-content mt-1 text-lg font-bold">
{formatarDataHoraCompleta(
registro.data,
registro.hora,
registro.minuto,
registro.segundo
)}
</p>
</div>
<!-- Status -->
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Status</span>
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Status</span
>
<div class="mt-2">
<span class="badge badge-lg gap-2 {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}">
<span
class="badge badge-lg gap-2 {registro.dentroDoPrazo
? 'badge-success'
: 'badge-error'}"
>
{#if registro.dentroDoPrazo}
<CheckCircle2 class="h-4 w-4" />
{:else}
@@ -488,9 +546,13 @@
</div>
<!-- Tolerância -->
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Tolerância</span>
<p class="text-lg font-bold text-base-content mt-1">{registro.toleranciaMinutos} minutos</p>
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Tolerância</span
>
<p class="text-base-content mt-1 text-lg font-bold">
{registro.toleranciaMinutos} minutos
</p>
</div>
</div>
</div>
@@ -498,13 +560,15 @@
<!-- Imagem Capturada -->
{#if registro.imagemUrl}
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-lg border-2 border-primary/10 hover:shadow-xl transition-all">
<div
class="card from-base-100 to-base-200 border-primary/10 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
>
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-primary/10 rounded-lg">
<div class="mb-4 flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-primary"
class="text-primary h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -517,13 +581,15 @@
/>
</svg>
</div>
<h4 class="font-bold text-lg text-base-content">Foto Capturada</h4>
<h4 class="text-base-content text-lg font-bold">Foto Capturada</h4>
</div>
<div class="flex justify-center bg-base-100 rounded-xl p-4 border-2 border-primary/20">
<div
class="bg-base-100 border-primary/20 flex justify-center rounded-xl border-2 p-4"
>
<img
src={registro.imagemUrl}
alt="Foto do registro de ponto"
class="max-w-full max-h-[300px] rounded-lg shadow-md object-contain"
class="max-h-[300px] max-w-full rounded-lg object-contain shadow-md"
onerror={(e) => {
console.error('Erro ao carregar imagem:', e);
(e.target as HTMLImageElement).style.display = 'none';
@@ -538,12 +604,18 @@
</div>
<!-- Footer fixo com botões -->
<div class="flex justify-end gap-3 px-6 py-4 border-t-2 border-primary/20 bg-base-100/50 backdrop-blur-sm flex-shrink-0">
<div
class="border-primary/20 bg-base-100/50 flex flex-shrink-0 justify-end gap-3 border-t-2 px-6 py-4 backdrop-blur-sm"
>
<button class="btn btn-outline gap-2" onclick={onClose}>
<X class="h-4 w-4" />
Fechar
</button>
<button class="btn btn-primary gap-2 shadow-lg hover:shadow-xl transition-all" onclick={gerarPDF} disabled={gerando}>
<button
class="btn btn-primary gap-2 shadow-lg transition-all hover:shadow-xl"
onclick={gerarPDF}
disabled={gerando}
>
{#if gerando}
<span class="loading loading-spinner loading-sm"></span>
Gerando...
@@ -604,4 +676,3 @@
background-color: hsl(var(--bc) / 0.5);
}
</style>

View File

@@ -11,16 +11,14 @@
{#if dentroRaioPermitido === true}
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Dentro do Raio' : ''}>
<MapPin class="h-5 w-5 text-success" strokeWidth={2.5} />
<MapPin class="text-success h-5 w-5" strokeWidth={2.5} />
</div>
{:else if dentroRaioPermitido === false}
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Fora do Raio' : ''}>
<AlertCircle class="h-5 w-5 text-error" strokeWidth={2.5} />
<AlertCircle class="text-error h-5 w-5" strokeWidth={2.5} />
</div>
{:else}
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Não Validado' : ''}>
<HelpCircle class="h-5 w-5 text-base-content/40" strokeWidth={2.5} />
<HelpCircle class="text-base-content/40 h-5 w-5" strokeWidth={2.5} />
</div>
{/if}

View File

@@ -26,7 +26,7 @@
saldoDiario: true,
bancoHoras: true,
alteracoesGestor: true,
dispensasRegistro: true,
dispensasRegistro: true
});
function selectAll() {
@@ -62,14 +62,14 @@
<dialog bind:this={modalRef} class="modal modal-open">
<div class="modal-box max-w-4xl">
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-2xl">Selecionar Campos para Impressão</h3>
<div class="mb-6 flex items-center justify-between">
<h3 class="text-2xl font-bold">Selecionar Campos para Impressão</h3>
<button class="btn btn-sm btn-circle btn-ghost" onclick={handleClose} aria-label="Fechar">
<X class="h-5 w-5" />
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Seção 1: Dados do Funcionário -->
<div class="card bg-base-200">
<div class="card-body p-4">
@@ -81,7 +81,7 @@
bind:checked={sections.dadosFuncionario}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
<p class="text-base-content/70 mt-2 text-sm">
Nome, matrícula, cargo e informações básicas
</p>
</div>
@@ -98,7 +98,7 @@
bind:checked={sections.registrosPonto}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
<p class="text-base-content/70 mt-2 text-sm">
Data, tipo, horário e status de cada registro
</p>
</div>
@@ -115,7 +115,7 @@
bind:checked={sections.saldoDiario}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
<p class="text-base-content/70 mt-2 text-sm">
Saldo em horas e minutos de cada dia (positivo/negativo)
</p>
</div>
@@ -132,9 +132,7 @@
bind:checked={sections.bancoHoras}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
Saldo acumulado do banco de horas
</p>
<p class="text-base-content/70 mt-2 text-sm">Saldo acumulado do banco de horas</p>
</div>
</div>
@@ -149,7 +147,7 @@
bind:checked={sections.alteracoesGestor}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
<p class="text-base-content/70 mt-2 text-sm">
Edições e ajustes realizados pelo gestor (se houver)
</p>
</div>
@@ -166,7 +164,7 @@
bind:checked={sections.dispensasRegistro}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
<p class="text-base-content/70 mt-2 text-sm">
Períodos onde o funcionário esteve dispensado de registrar ponto
</p>
</div>
@@ -175,18 +173,12 @@
<div class="flex items-center justify-between">
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick={selectAll}>
Selecionar Todos
</button>
<button class="btn btn-sm btn-outline" onclick={deselectAll}>
Desmarcar Todos
</button>
<button class="btn btn-sm btn-outline" onclick={selectAll}> Selecionar Todos </button>
<button class="btn btn-sm btn-outline" onclick={deselectAll}> Desmarcar Todos </button>
</div>
<div class="flex gap-2">
<button class="btn btn-ghost" onclick={handleClose}>
Cancelar
</button>
<button class="btn btn-ghost" onclick={handleClose}> Cancelar </button>
<button class="btn btn-primary gap-2" onclick={handleGenerate}>
<Printer class="h-4 w-4" />
Gerar PDF

File diff suppressed because it is too large Load Diff

View File

@@ -60,7 +60,7 @@
let timestampAjustado: number;
if (gmtOffset !== 0) {
// Aplicar offset configurado
timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
timestampAjustado = timestampBase + gmtOffset * 60 * 60 * 1000;
} else {
// Quando GMT = 0, manter timestamp UTC puro
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
@@ -99,7 +99,7 @@
return tempoAtual.toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
second: '2-digit'
});
});
@@ -108,30 +108,30 @@
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
year: 'numeric'
});
});
</script>
<div class="flex flex-col items-center gap-4 w-full">
<div class="flex w-full flex-col items-center gap-4">
<!-- Hora -->
<div class="text-5xl font-black font-mono text-primary tracking-tight drop-shadow-sm">
<div class="text-primary font-mono text-5xl font-black tracking-tight drop-shadow-sm">
{horaFormatada}
</div>
<!-- Data -->
<div class="text-base font-semibold text-base-content/80 capitalize">
<div class="text-base-content/80 text-base font-semibold 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'
}">
<div
class="flex items-center gap-2 rounded-full px-4 py-2 {sincronizado
? 'bg-success/20 text-success border-success/30 border'
: erro
? 'bg-warning/20 text-warning border-warning/30 border'
: 'bg-base-300/50 text-base-content/60 border-base-300 border'}"
>
{#if sincronizado}
<CheckCircle2 class="h-4 w-4" strokeWidth={2.5} />
<span class="text-sm font-semibold">
@@ -150,4 +150,3 @@
{/if}
</div>
</div>

View File

@@ -28,19 +28,24 @@
{@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 text-green-600 dark:text-green-400">+{trabalhado.horas}h {trabalhado.minutos}min</span>
<div
class="inline-flex items-center gap-1.5 {sizeClasses[
size
]} rounded-lg border font-semibold shadow-sm {isNegativo
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400'
: 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'}"
>
<span class="font-bold text-green-600 dark:text-green-400"
>+{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'}>
<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}

View File

@@ -11,7 +11,13 @@
fotoObrigatoria?: boolean; // Se true, não permite continuar sem foto
}
let { onCapture, onCancel, onError, autoCapture = false, fotoObrigatoria = false }: Props = $props();
let {
onCapture,
onCancel,
onError,
autoCapture = false,
fotoObrigatoria = false
}: Props = $props();
let videoElement: HTMLVideoElement | null = $state(null);
let canvasElement: HTMLCanvasElement | null = $state(null);
@@ -32,7 +38,7 @@
if (videoElement.srcObject !== stream) {
videoElement.srcObject = stream;
}
// Tentar reproduzir se ainda não estiver pronto e não houver outra chamada em andamento
if (!videoReady && videoElement.readyState < 2) {
// Verificar se já não está reproduzindo
@@ -42,7 +48,8 @@
}
playEmAndamento = true;
videoElement.play()
videoElement
.play()
.then(() => {
playEmAndamento = false;
// Aguardar um pouco para garantir que o vídeo esteja realmente reproduzindo
@@ -67,17 +74,17 @@
onMount(async () => {
// Aguardar mais tempo para garantir que os elementos estejam no DOM
await new Promise(resolve => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, 300));
// Verificar suporte
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
// Tentar método alternativo (navegadores antigos)
const getUserMedia =
navigator.getUserMedia ||
(navigator as any).webkitGetUserMedia ||
(navigator as any).mozGetUserMedia ||
const getUserMedia =
navigator.getUserMedia ||
(navigator as any).webkitGetUserMedia ||
(navigator as any).mozGetUserMedia ||
(navigator as any).msGetUserMedia;
if (!getUserMedia) {
erro = 'Webcam não suportada';
if (autoCapture && onError) {
@@ -118,15 +125,15 @@
let ultimoErro: Error | null = null;
let streamObtido = false;
for (const constraint of constraints) {
try {
console.log('Tentando acessar webcam com constraint:', constraint);
const tempStream = await navigator.mediaDevices.getUserMedia(constraint);
// Verificar se o stream tem tracks de vídeo
if (tempStream.getVideoTracks().length === 0) {
tempStream.getTracks().forEach(track => track.stop());
tempStream.getTracks().forEach((track) => track.stop());
continue;
}
@@ -149,14 +156,14 @@
// Agora que temos o stream, aguardar o elemento de vídeo estar disponível
let tentativas = 0;
while (!videoElement && tentativas < 30) {
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
tentativas++;
}
if (!videoElement) {
erro = 'Elemento de vídeo não encontrado';
if (stream) {
stream.getTracks().forEach(track => track.stop());
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
webcamDisponivel = false;
@@ -172,7 +179,7 @@
// Atribuir stream ao elemento de vídeo
if (videoElement && stream) {
videoElement.srcObject = stream;
// Aguardar o vídeo estar pronto com timeout maior
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
@@ -237,7 +244,8 @@
// Tentar reproduzir apenas se não estiver já reproduzindo
if (videoElement.paused) {
playEmAndamento = true;
videoElement.play()
videoElement
.play()
.then(() => {
playEmAndamento = false;
console.log('Vídeo iniciado, readyState:', videoElement?.readyState);
@@ -280,7 +288,12 @@
}
});
console.log('Vídeo pronto, dimensões:', videoElement.videoWidth, 'x', videoElement.videoHeight);
console.log(
'Vídeo pronto, dimensões:',
videoElement.videoWidth,
'x',
videoElement.videoHeight
);
}
// Se for captura automática, aguardar um pouco e capturar
@@ -298,12 +311,15 @@
} catch (error) {
console.error('Erro ao acessar webcam:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
erro = fotoObrigatoria
erro = fotoObrigatoria
? 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.'
: 'Permissão de webcam negada. Continuando sem foto.';
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) {
} else if (
errorMessage.includes('NotFoundError') ||
errorMessage.includes('DevicesNotFoundError')
) {
erro = fotoObrigatoria
? 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.'
: 'Nenhuma webcam encontrada. Continuando sem foto.';
@@ -312,7 +328,7 @@
? 'Erro ao acessar webcam. Verifique as permissões e tente novamente.'
: 'Erro ao acessar webcam. Continuando sem foto.';
}
webcamDisponivel = false;
// Se foto é obrigatória, não chamar onError para permitir continuar sem foto
if (fotoObrigatoria) {
@@ -347,14 +363,23 @@
}
// Verificar se o vídeo está pronto e tem dimensões válidas
if (videoElement.readyState < 2 || videoElement.videoWidth === 0 || videoElement.videoHeight === 0) {
if (
videoElement.readyState < 2 ||
videoElement.videoWidth === 0 ||
videoElement.videoHeight === 0
) {
console.warn('Vídeo ainda não está pronto, aguardando...');
await new Promise<void>((resolve, reject) => {
let tentativas = 0;
const maxTentativas = 50; // 5 segundos
const checkReady = () => {
tentativas++;
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
if (
videoElement &&
videoElement.readyState >= 2 &&
videoElement.videoWidth > 0 &&
videoElement.videoHeight > 0
) {
resolve();
} else if (tentativas >= maxTentativas) {
reject(new Error('Timeout aguardando vídeo ficar pronto'));
@@ -369,7 +394,7 @@
capturando = false;
return; // Retornar aqui para não continuar
});
// Se chegou aqui, o vídeo está pronto, continuar com a captura
}
@@ -379,7 +404,9 @@
try {
// Verificar dimensões do vídeo novamente antes de capturar
if (!videoElement.videoWidth || !videoElement.videoHeight) {
throw new Error('Dimensões do vídeo não disponíveis. Aguarde a câmera carregar completamente.');
throw new Error(
'Dimensões do vídeo não disponíveis. Aguarde a câmera carregar completamente.'
);
}
// Configurar canvas com as dimensões do vídeo
@@ -394,7 +421,7 @@
// Limpar canvas antes de desenhar
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
// Desenhar frame atual do vídeo no canvas
// O vídeo está espelhado no CSS para visualização, mas capturamos normalmente
ctx.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height);
@@ -417,7 +444,7 @@
if (blob && blob.size > 0) {
previewUrl = URL.createObjectURL(blob);
console.log('Imagem capturada com sucesso, tamanho:', blob.size, 'bytes');
// Parar stream para mostrar preview
if (stream) {
stream.getTracks().forEach((track) => track.stop());
@@ -504,7 +531,7 @@
}
</script>
<div class="flex flex-col items-center gap-4 p-4 w-full">
<div class="flex w-full flex-col items-center gap-4 p-4">
{#if !webcamDisponivel && !erro}
<div class="text-warning flex items-center gap-2">
<Camera class="h-5 w-5" />
@@ -520,61 +547,73 @@
<span>A captura de foto é obrigatória para registrar o ponto.</span>
</div>
{/if}
{:else if erro && !webcamDisponivel}
{:else if erro && !webcamDisponivel}
<div class="alert alert-error max-w-md">
<AlertCircle class="h-5 w-5" />
<span>{erro}</span>
</div>
{#if fotoObrigatoria}
<div class="alert alert-warning max-w-md">
<span>Não é possível registrar o ponto sem capturar uma foto. Verifique as permissões da webcam e tente novamente.</span>
<span
>Não é possível registrar o ponto sem capturar uma foto. Verifique as permissões da webcam
e tente novamente.</span
>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" onclick={async () => {
erro = null;
webcamDisponivel = false;
videoReady = false;
// Limpar stream anterior se existir
if (stream) {
stream.getTracks().forEach(track => track.stop());
stream = null;
}
// Tentar reiniciar a webcam
try {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia && videoElement) {
stream = await navigator.mediaDevices.getUserMedia({ video: true });
if (stream.getVideoTracks().length > 0) {
webcamDisponivel = true;
if (videoElement) {
videoElement.srcObject = stream;
await videoElement.play();
<button
class="btn btn-primary"
onclick={async () => {
erro = null;
webcamDisponivel = false;
videoReady = false;
// Limpar stream anterior se existir
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
// Tentar reiniciar a webcam
try {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia && videoElement) {
stream = await navigator.mediaDevices.getUserMedia({ video: true });
if (stream.getVideoTracks().length > 0) {
webcamDisponivel = true;
if (videoElement) {
videoElement.srcObject = stream;
await videoElement.play();
}
} else {
stream.getTracks().forEach((track) => track.stop());
stream = null;
erro =
'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
}
} else {
stream.getTracks().forEach(track => track.stop());
stream = null;
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
erro = 'Webcam não disponível. Verifique as permissões e tente novamente.';
}
} catch (e) {
console.error('Erro ao tentar novamente:', e);
const errorMessage = e instanceof Error ? e.message : String(e);
if (
errorMessage.includes('Permission denied') ||
errorMessage.includes('NotAllowedError')
) {
erro =
'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.';
} else if (
errorMessage.includes('NotFoundError') ||
errorMessage.includes('DevicesNotFoundError')
) {
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
} else {
erro = 'Erro ao acessar webcam. Verifique as permissões e tente novamente.';
}
} else {
erro = 'Webcam não disponível. Verifique as permissões e tente novamente.';
}
} catch (e) {
console.error('Erro ao tentar novamente:', e);
const errorMessage = e instanceof Error ? e.message : String(e);
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
erro = 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.';
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) {
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
} else {
erro = 'Erro ao acessar webcam. Verifique as permissões e tente novamente.';
}
}
}}>Tentar Novamente</button>
}}>Tentar Novamente</button
>
<button class="btn btn-error" onclick={cancelar}>Fechar</button>
</div>
{:else if autoCapture}
<div class="text-sm text-base-content/70 text-center">
O registro será feito sem foto.
</div>
<div class="text-base-content/70 text-center text-sm">O registro será feito sem foto.</div>
{:else}
<div class="flex gap-2">
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
@@ -582,10 +621,10 @@
{/if}
{:else if previewUrl}
<!-- Preview da imagem capturada -->
<div class="flex flex-col items-center gap-4 w-full">
<div class="flex w-full flex-col items-center gap-4">
{#if autoCapture}
<!-- Modo automático: mostrar apenas preview sem botões -->
<div class="text-sm text-base-content/70 mb-2 text-center">
<div class="text-base-content/70 mb-2 text-center text-sm">
Foto capturada automaticamente...
</div>
{/if}
@@ -596,7 +635,7 @@
/>
{#if !autoCapture}
<!-- Botões apenas se não for automático -->
<div class="flex gap-2 flex-wrap justify-center">
<div class="flex flex-wrap justify-center gap-2">
<button class="btn btn-success" onclick={confirmar}>
<Check class="h-5 w-5" />
Confirmar
@@ -614,33 +653,37 @@
</div>
{:else}
<!-- Webcam ativa -->
<div class="flex flex-col items-center gap-4 w-full">
<div class="flex w-full flex-col items-center gap-4">
{#if autoCapture}
<div class="text-sm text-base-content/70 mb-2 text-center">
<div class="text-base-content/70 mb-2 text-center text-sm">
Capturando foto automaticamente...
</div>
{:else}
<div class="text-sm text-base-content/70 mb-2 text-center">
<div class="text-base-content/70 mb-2 text-center text-sm">
Posicione-se na frente da câmera e clique em "Capturar Foto"
</div>
{/if}
<div class="relative w-full flex justify-center">
<div class="relative flex w-full justify-center">
<video
bind:this={videoElement}
autoplay
playsinline
muted
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain bg-black {!videoReady ? 'opacity-50' : ''}"
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 bg-black object-contain {!videoReady
? 'opacity-50'
: ''}"
style="min-width: 320px; min-height: 240px; transform: scaleX(-1);"
></video>
<canvas bind:this={canvasElement} class="hidden"></canvas>
{#if !videoReady && webcamDisponivel}
<div class="absolute inset-0 flex flex-col items-center justify-center bg-black/70 rounded-lg gap-2">
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-lg bg-black/70"
>
<span class="loading loading-spinner loading-lg text-white"></span>
<span class="text-white text-sm">Carregando câmera...</span>
<span class="text-sm text-white">Carregando câmera...</span>
</div>
{:else if videoReady && webcamDisponivel}
<div class="absolute bottom-2 left-1/2 transform -translate-x-1/2">
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 transform">
<div class="badge badge-success gap-2">
<Camera class="h-4 w-4" />
Câmera ativa
@@ -655,10 +698,10 @@
{/if}
{#if !autoCapture}
<!-- Botões sempre visíveis quando não for automático -->
<div class="flex gap-2 flex-wrap justify-center">
<button
class="btn btn-primary btn-lg"
onclick={capturar}
<div class="flex flex-wrap justify-center gap-2">
<button
class="btn btn-primary btn-lg"
onclick={capturar}
disabled={capturando || !videoReady || !webcamDisponivel}
>
{#if capturando}

View File

@@ -3,43 +3,39 @@
import { resolve } from '$app/paths';
</script>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
<div class="card bg-base-100 shadow-xl transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<!-- Cabeçalho da Categoria -->
<div class="flex items-start gap-6 mb-6">
<div class="p-4 bg-blue-500/20 rounded-2xl">
<div class="mb-6 flex items-start gap-6">
<div class="rounded-2xl bg-blue-500/20 p-4">
<div class="text-blue-600">
<Clock class="h-12 w-12" strokeWidth={2} />
</div>
</div>
<div class="flex-1">
<h2 class="card-title text-2xl mb-2 text-blue-600">
Gestão de Pontos
</h2>
<h2 class="card-title mb-2 text-2xl 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">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-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"
class="group border-base-300 hover:border-primary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br from-blue-500/10 to-blue-600/20 p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div class="flex h-full flex-col">
<div class="mb-4 flex items-start justify-between">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
class="bg-base-100 group-hover:bg-primary rounded-lg p-3 transition-colors duration-300 group-hover:text-white"
>
<div
class="text-blue-600 group-hover:text-white"
>
<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"
class="text-base-content/30 group-hover:text-primary h-5 w-5 transition-colors duration-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -53,33 +49,31 @@
</svg>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
class="text-base-content group-hover:text-primary mb-2 text-lg font-bold transition-colors duration-300"
>
Gestão de Pontos
</h3>
<p class="text-sm text-base-content/70 flex-1">
<p class="text-base-content/70 flex-1 text-sm">
Visualizar e gerenciar registros de ponto
</p>
</div>
</a>
<a
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/homologacao')}
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-green-500/10 to-green-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
class="group border-base-300 hover:border-primary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br from-green-500/10 to-green-600/20 p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div class="flex h-full flex-col">
<div class="mb-4 flex items-start justify-between">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
class="bg-base-100 group-hover:bg-primary rounded-lg p-3 transition-colors duration-300 group-hover:text-white"
>
<div
class="text-green-600 group-hover:text-white"
>
<div class="text-green-600 group-hover:text-white">
<CheckCircle2 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"
class="text-base-content/30 group-hover:text-primary h-5 w-5 transition-colors duration-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -93,33 +87,31 @@
</svg>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
class="text-base-content group-hover:text-primary mb-2 text-lg font-bold transition-colors duration-300"
>
Homologação de Registro
</h3>
<p class="text-sm text-base-content/70 flex-1">
<p class="text-base-content/70 flex-1 text-sm">
Edite registros de ponto e ajuste banco de horas
</p>
</div>
</a>
<a
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/dispensa')}
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-orange-500/10 to-orange-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
class="group border-base-300 hover:border-primary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br from-orange-500/10 to-orange-600/20 p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div class="flex h-full flex-col">
<div class="mb-4 flex items-start justify-between">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
class="bg-base-100 group-hover:bg-primary rounded-lg p-3 transition-colors duration-300 group-hover:text-white"
>
<div
class="text-orange-600 group-hover:text-white"
>
<div class="text-orange-600 group-hover:text-white">
<XCircle 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"
class="text-base-content/30 group-hover:text-primary h-5 w-5 transition-colors duration-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -133,11 +125,11 @@
</svg>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
class="text-base-content group-hover:text-primary mb-2 text-lg font-bold transition-colors duration-300"
>
Dispensa de Registro
</h3>
<p class="text-sm text-base-content/70 flex-1">
<p class="text-base-content/70 flex-1 text-sm">
Gerencie períodos de dispensa de registro de ponto
</p>
</div>
@@ -145,4 +137,3 @@
</div>
</div>
</div>