feat: add new features for point management and registration

- Introduced "Homologação de Registro" and "Dispensa de Registro" sections in the dashboard for enhanced point management.
- Updated the WidgetGestaoPontos component to include new links and icons for the added features.
- Enhanced backend functionality to support the new features, including querying and managing dispensas and homologações.
- Improved the PDF generation process to include daily balance calculations for employee time records.
- Implemented checks for active dispensas to prevent unauthorized point registrations.
This commit is contained in:
2025-11-19 16:37:31 -03:00
parent ed5695cf28
commit db61df1fb4
9 changed files with 2602 additions and 164 deletions

View File

@@ -0,0 +1,538 @@
<script lang="ts">
import { onMount } from 'svelte';
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { Clock, Edit, TrendingUp, TrendingDown, Save, X } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto';
import { toast } from 'svelte-sonner';
const client = useConvexClient();
// Estados
let funcionarioSelecionado = $state<Id<'funcionarios'> | ''>('');
let registroSelecionado = $state<Id<'registrosPonto'> | ''>('');
let modoEdicao = $state(false);
let modoAjuste = $state(false);
// Formulário de edição
let horaNova = $state(8);
let minutoNova = $state(0);
let motivoId = $state('');
let motivoTipo = $state('');
let motivoDescricao = $state('');
let observacoes = $state('');
// Formulário de ajuste
let tipoAjuste = $state<'compensar' | 'abonar' | 'descontar'>('compensar');
let periodoDias = $state(0);
let periodoHoras = $state(0);
let periodoMinutos = $state(0);
// Queries
const subordinadosQuery = useQuery(api.times.listarSubordinadosDoGestorAtual, {});
const motivosQuery = useQuery(api.pontos.obterMotivosAtestados, {});
// Parâmetros reativos para queries
const homologacoesParams = $derived({
funcionarioId: funcionarioSelecionado || undefined,
});
const registrosQueryParams = $derived({
funcionarioId: funcionarioSelecionado || undefined,
dataInicio: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]!,
dataFim: new Date().toISOString().split('T')[0]!,
});
const homologacoesQuery = useQuery(api.pontos.listarHomologacoes, homologacoesParams);
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosQueryParams);
const subordinados = $derived(subordinadosQuery?.data || []);
const motivos = $derived(motivosQuery?.data);
const homologacoes = $derived(homologacoesQuery?.data || []);
const registros = $derived(registrosQuery?.data || []);
// Lista de funcionários do time
const funcionarios = $derived.by(() => {
const funcs: Array<{ _id: Id<'funcionarios'>; nome: string; matricula?: string }> = [];
for (const time of subordinados) {
for (const membro of time.membros) {
if (membro.funcionario && !funcs.find((f) => f._id === membro.funcionario._id)) {
funcs.push({
_id: membro.funcionario._id,
nome: membro.funcionario.nome,
matricula: membro.funcionario.matricula,
});
}
}
}
return funcs;
});
function abrirEdicao(registroId: Id<'registrosPonto'>) {
const registro = registros.find((r) => r._id === registroId);
if (!registro) return;
registroSelecionado = registroId;
horaNova = registro.hora;
minutoNova = registro.minuto;
motivoId = '';
motivoTipo = '';
motivoDescricao = '';
observacoes = '';
modoEdicao = true;
modoAjuste = false;
}
function abrirAjuste() {
modoAjuste = true;
modoEdicao = false;
registroSelecionado = '';
tipoAjuste = 'compensar';
periodoDias = 0;
periodoHoras = 0;
periodoMinutos = 0;
motivoId = '';
motivoTipo = '';
motivoDescricao = '';
observacoes = '';
}
function cancelar() {
modoEdicao = false;
modoAjuste = false;
registroSelecionado = '';
}
async function salvarEdicao() {
if (!registroSelecionado || !funcionarioSelecionado) {
toast.error('Selecione um funcionário e um registro');
return;
}
try {
await client.mutation(api.pontos.editarRegistroPonto, {
registroId: registroSelecionado,
horaNova,
minutoNova,
motivoId: motivoId || undefined,
motivoTipo: motivoTipo || undefined,
motivoDescricao: motivoDescricao || undefined,
observacoes: observacoes || undefined,
});
toast.success('Registro editado com sucesso');
cancelar();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
toast.error(`Erro ao editar registro: ${errorMessage}`);
}
}
async function salvarAjuste() {
if (!funcionarioSelecionado) {
toast.error('Selecione um funcionário');
return;
}
if (periodoDias === 0 && periodoHoras === 0 && periodoMinutos === 0) {
toast.error('Informe pelo menos um período (dias, horas ou minutos)');
return;
}
try {
await client.mutation(api.pontos.ajustarBancoHoras, {
funcionarioId: funcionarioSelecionado,
tipoAjuste,
periodoDias,
periodoHoras,
periodoMinutos,
motivoId: motivoId || undefined,
motivoTipo: motivoTipo || undefined,
motivoDescricao: motivoDescricao || undefined,
observacoes: observacoes || undefined,
});
toast.success('Banco de horas ajustado com sucesso');
cancelar();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
toast.error(`Erro ao ajustar banco de horas: ${errorMessage}`);
}
}
</script>
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<div class="p-3 bg-primary/10 rounded-xl">
<Clock class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Homologação de Registro</h1>
<p class="text-base-content/60 mt-1">Edite registros de ponto e ajuste banco de horas</p>
</div>
</div>
</div>
<!-- Seleção de Funcionário -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title mb-4">Selecionar Funcionário</h2>
<select
class="select select-bordered w-full"
bind:value={funcionarioSelecionado}
disabled={modoEdicao || modoAjuste}
>
<option value="">Selecione um funcionário</option>
{#each funcionarios as funcionario}
<option value={funcionario._id}>
{funcionario.nome} {funcionario.matricula ? `(${funcionario.matricula})` : ''}
</option>
{/each}
</select>
</div>
</div>
<!-- Botões de Ação -->
{#if funcionarioSelecionado && !modoEdicao && !modoAjuste}
<div class="flex gap-4 mb-6">
<button class="btn btn-primary gap-2" onclick={abrirAjuste}>
<TrendingUp class="h-4 w-4" />
Ajustar Banco de Horas
</button>
</div>
{/if}
<!-- Formulário de Edição -->
{#if modoEdicao && registroSelecionado}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title mb-4">Editar Registro de Ponto</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Nova Hora</span>
</label>
<input
type="number"
min="0"
max="23"
class="input input-bordered"
bind:value={horaNova}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Novo Minuto</span>
</label>
<input
type="number"
min="0"
max="59"
class="input input-bordered"
bind:value={minutoNova}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Motivo (Tipo)</span>
</label>
<select class="select select-bordered" bind:value={motivoTipo}>
<option value="">Selecione um tipo</option>
{#if motivos?.opcoesPadrao}
{#each motivos.opcoesPadrao as opcao}
<option value={opcao}>{opcao}</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Descrição do Motivo</span>
</label>
<input type="text" class="input input-bordered" bind:value={motivoDescricao} />
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Observações</span>
</label>
<textarea class="textarea textarea-bordered" bind:value={observacoes} rows="3"></textarea>
</div>
</div>
<div class="flex gap-2 mt-4">
<button class="btn btn-primary gap-2" onclick={salvarEdicao}>
<Save class="h-4 w-4" />
Salvar
</button>
<button class="btn btn-ghost gap-2" onclick={cancelar}>
<X class="h-4 w-4" />
Cancelar
</button>
</div>
</div>
</div>
{/if}
<!-- Formulário de Ajuste -->
{#if modoAjuste}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title mb-4">Ajustar Banco de Horas</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Tipo de Ajuste</span>
</label>
<select class="select select-bordered" bind:value={tipoAjuste}>
<option value="compensar">Compensar</option>
<option value="abonar">Abonar</option>
<option value="descontar">Descontar em Folha</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Dias</span>
</label>
<input
type="number"
min="0"
class="input input-bordered"
bind:value={periodoDias}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Horas</span>
</label>
<input
type="number"
min="0"
max="23"
class="input input-bordered"
bind:value={periodoHoras}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Minutos</span>
</label>
<input
type="number"
min="0"
max="59"
class="input input-bordered"
bind:value={periodoMinutos}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Motivo (Tipo)</span>
</label>
<select class="select select-bordered" bind:value={motivoTipo}>
<option value="">Selecione um tipo</option>
{#if motivos?.opcoesPadrao}
{#each motivos.opcoesPadrao as opcao}
<option value={opcao}>{opcao}</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Descrição do Motivo</span>
</label>
<input type="text" class="input input-bordered" bind:value={motivoDescricao} />
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Observações</span>
</label>
<textarea class="textarea textarea-bordered" bind:value={observacoes} rows="3"></textarea>
</div>
</div>
<div class="flex gap-2 mt-4">
<button class="btn btn-primary gap-2" onclick={salvarAjuste}>
<Save class="h-4 w-4" />
Salvar
</button>
<button class="btn btn-ghost gap-2" onclick={cancelar}>
<X class="h-4 w-4" />
Cancelar
</button>
</div>
</div>
</div>
{/if}
<!-- Lista de Registros -->
{#if funcionarioSelecionado && !modoEdicao && !modoAjuste}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title mb-4">Registros do Funcionário</h2>
{#if registros.length === 0}
<div class="alert alert-info">
<span>Nenhum registro encontrado</span>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Data</th>
<th>Tipo</th>
<th>Horário</th>
<th>Status</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each registros as registro}
<tr>
<td>{registro.data}</td>
<td>
{getTipoRegistroLabel(registro.tipo)}
</td>
<td>{formatarHoraPonto(registro.hora, registro.minuto)}</td>
<td>
<span
class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}"
>
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
</span>
</td>
<td>
<button
class="btn btn-sm btn-outline btn-primary gap-2"
onclick={() => abrirEdicao(registro._id)}
>
<Edit class="h-4 w-4" />
Editar
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
{/if}
<!-- Histórico de Homologações -->
{#if !modoEdicao && !modoAjuste}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">
Histórico de Homologações
{#if funcionarioSelecionado}
<span class="text-sm font-normal text-base-content/70">
- Funcionário selecionado
</span>
{:else}
<span class="text-sm font-normal text-base-content/70">
- Todas as homologações do seu time
</span>
{/if}
</h2>
{#if homologacoes.length === 0}
<div class="alert alert-info">
<span>Nenhuma homologação encontrada</span>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Data</th>
{#if !funcionarioSelecionado}
<th>Funcionário</th>
{/if}
<th>Tipo</th>
<th>Detalhes</th>
<th>Motivo</th>
<th>Observações</th>
</tr>
</thead>
<tbody>
{#each homologacoes as homologacao}
<tr>
<td>
{new Date(homologacao.criadoEm).toLocaleDateString('pt-BR')}
</td>
{#if !funcionarioSelecionado}
<td>
{homologacao.funcionario?.nome || '-'}
{#if homologacao.funcionario?.matricula}
<br />
<span class="text-xs text-base-content/70">
Mat: {homologacao.funcionario.matricula}
</span>
{/if}
</td>
{/if}
<td>
{#if homologacao.registroId}
<span class="badge badge-info">Edição de Registro</span>
{:else if homologacao.tipoAjuste}
<span class="badge badge-warning">
Ajuste: {homologacao.tipoAjuste}
</span>
{/if}
</td>
<td>
{#if homologacao.horaAnterior !== undefined}
<div class="text-sm">
<span class="line-through opacity-70">
{formatarHoraPonto(homologacao.horaAnterior, homologacao.minutoAnterior || 0)}
</span>
{' → '}
<span>
{formatarHoraPonto(homologacao.horaNova || 0, homologacao.minutoNova || 0)}
</span>
</div>
{:else if homologacao.ajusteMinutos}
<div class="text-sm">
{homologacao.periodoDias || 0}d {homologacao.periodoHoras || 0}h{' '}
{homologacao.periodoMinutos || 0}min
</div>
{/if}
</td>
<td>
<div class="text-sm">
{homologacao.motivoDescricao || homologacao.motivoTipo || '-'}
</div>
</td>
<td>
<div class="text-sm max-w-xs truncate" title={homologacao.observacoes || ''}>
{homologacao.observacoes || '-'}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
{/if}
</div>