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,200 @@
<script lang="ts">
import { CheckCircle2, X, Printer } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
interface Props {
funcionarioId: Id<'funcionarios'>;
onClose: () => void;
onGenerate: (sections: {
dadosFuncionario: boolean;
registrosPonto: boolean;
saldoDiario: boolean;
bancoHoras: boolean;
alteracoesGestor: boolean;
dispensasRegistro: boolean;
}) => void;
}
let { funcionarioId, onClose, onGenerate }: Props = $props();
let modalRef: HTMLDialogElement;
// Seções selecionáveis
let sections = $state({
dadosFuncionario: true,
registrosPonto: true,
saldoDiario: true,
bancoHoras: true,
alteracoesGestor: true,
dispensasRegistro: true,
});
function selectAll() {
Object.keys(sections).forEach((key) => {
sections[key as keyof typeof sections] = true;
});
}
function deselectAll() {
Object.keys(sections).forEach((key) => {
sections[key as keyof typeof sections] = false;
});
}
function handleGenerate() {
onGenerate(sections);
onClose();
}
function handleClose() {
if (modalRef) {
modalRef.close();
}
onClose();
}
$effect(() => {
if (modalRef) {
modalRef.showModal();
}
});
</script>
<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>
<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">
<!-- Seção 1: Dados do Funcionário -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Dados do Funcionário</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.dadosFuncionario}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
Nome, matrícula, cargo e informações básicas
</p>
</div>
</div>
<!-- Seção 2: Registros de Ponto -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Registros de Ponto</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.registrosPonto}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
Data, tipo, horário e status de cada registro
</p>
</div>
</div>
<!-- Seção 3: Saldo Diário -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Saldo Diário</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.saldoDiario}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
Saldo em horas e minutos de cada dia (positivo/negativo)
</p>
</div>
</div>
<!-- Seção 4: Banco de Horas -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Banco de Horas</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.bancoHoras}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
Saldo acumulado do banco de horas
</p>
</div>
</div>
<!-- Seção 5: Alterações pelo Gestor -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Alterações pelo Gestor</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.alteracoesGestor}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
Edições e ajustes realizados pelo gestor (se houver)
</p>
</div>
</div>
<!-- Seção 6: Dispensas de Registro -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Dispensas de Registro</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.dispensasRegistro}
/>
</label>
<p class="text-sm text-base-content/70 mt-2">
Períodos onde o funcionário esteve dispensado de registrar ponto
</p>
</div>
</div>
</div>
<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>
</div>
<div class="flex gap-2">
<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
</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop" onsubmit={handleClose}>
<button type="submit">fechar</button>
</form>
</dialog>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Clock } from 'lucide-svelte';
import { Clock, CheckCircle2, XCircle } from 'lucide-svelte';
import { resolve } from '$app/paths';
</script>
@@ -62,6 +62,86 @@
</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"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
>
<div
class="text-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"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
>
Homologação de Registro
</h3>
<p class="text-sm text-base-content/70 flex-1">
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"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
>
<div
class="text-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"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
>
Dispensa de Registro
</h3>
<p class="text-sm text-base-content/70 flex-1">
Gerencie períodos de dispensa de registro de ponto
</p>
</div>
</a>
</div>
</div>
</div>

View File

@@ -18,6 +18,7 @@
Info,
ArrowRight,
Clock,
XCircle,
} from "lucide-svelte";
import type { Component } from "svelte";
@@ -134,6 +135,18 @@
href: "/recursos-humanos/registro-pontos",
Icon: Clock,
},
{
nome: "Homologação de Registro",
descricao: "Edite registros de ponto e ajuste banco de horas",
href: "/recursos-humanos/controle-ponto/homologacao",
Icon: CheckCircle2,
},
{
nome: "Dispensa de Registro",
descricao: "Gerencie períodos de dispensa de registro de ponto",
href: "/recursos-humanos/controle-ponto/dispensa",
Icon: XCircle,
},
],
},
];

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import { Clock, CheckCircle2, XCircle } from 'lucide-svelte';
import { resolve } from '$app/paths';
</script>
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex items-center gap-4 mb-6">
<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">Controle de Ponto</h1>
<p class="text-base-content/60 mt-1">Gerencie registros, homologações e dispensas de ponto</p>
</div>
</div>
<!-- Grid de Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Card 1: Gestão de Pontos -->
<a
href={resolve('/(dashboard)/recursos-humanos/registro-pontos')}
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1"
>
<div class="card-body">
<div class="flex items-start justify-between mb-4">
<div class="p-4 bg-blue-500/20 rounded-2xl">
<Clock class="h-8 w-8 text-blue-600" strokeWidth={2} />
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-base-content/30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
<h2 class="card-title text-xl mb-2">Gestão de Pontos</h2>
<p class="text-base-content/70">
Visualizar e gerenciar registros de ponto dos funcionários, relatórios e histórico
</p>
</div>
</a>
<!-- Card 2: Homologação de Registro -->
<a
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/homologacao')}
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1"
>
<div class="card-body">
<div class="flex items-start justify-between mb-4">
<div class="p-4 bg-green-500/20 rounded-2xl">
<CheckCircle2 class="h-8 w-8 text-green-600" strokeWidth={2} />
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-base-content/30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
<h2 class="card-title text-xl mb-2">Homologação de Registro</h2>
<p class="text-base-content/70">
Edite registros de ponto do seu time, ajuste banco de horas (compensar, abonar ou descontar)
</p>
</div>
</a>
<!-- Card 3: Dispensa de Registro -->
<a
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/dispensa')}
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1"
>
<div class="card-body">
<div class="flex items-start justify-between mb-4">
<div class="p-4 bg-orange-500/20 rounded-2xl">
<XCircle class="h-8 w-8 text-orange-600" strokeWidth={2} />
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-base-content/30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
<h2 class="card-title text-xl mb-2">Dispensa de Registro</h2>
<p class="text-base-content/70">
Gerencie períodos onde funcionários estão dispensados de registrar ponto
</p>
</div>
</a>
</div>
</div>

View File

@@ -0,0 +1,360 @@
<script lang="ts">
import { onMount } from 'svelte';
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { Clock, Plus, X, Trash2 } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { toast } from 'svelte-sonner';
const client = useConvexClient();
// Estados
let funcionariosSelecionados = $state<Id<'funcionarios'>[]>([]);
let modoCriacao = $state(false);
// Formulário
let dataInicio = $state(new Date().toISOString().split('T')[0]!);
let horaInicio = $state(8);
let minutoInicio = $state(0);
let dataFim = $state(new Date().toISOString().split('T')[0]!);
let horaFim = $state(18);
let minutoFim = $state(0);
let motivo = $state('');
let isento = $state(false);
// Queries
const subordinadosQuery = useQuery(api.times.listarSubordinadosDoGestorAtual, {});
const dispensasQuery = useQuery(api.pontos.listarDispensas, {
apenasAtivas: false, // Mostrar todas para o gestor ver histórico
});
const subordinados = $derived(subordinadosQuery?.data || []);
const dispensas = $derived(dispensasQuery?.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 abrirCriacao() {
modoCriacao = true;
funcionariosSelecionados = [];
dataInicio = new Date().toISOString().split('T')[0]!;
horaInicio = 8;
minutoInicio = 0;
dataFim = new Date().toISOString().split('T')[0]!;
horaFim = 18;
minutoFim = 0;
motivo = '';
isento = false;
}
function cancelar() {
modoCriacao = false;
funcionariosSelecionados = [];
}
function toggleFuncionario(funcionarioId: Id<'funcionarios'>) {
if (funcionariosSelecionados.includes(funcionarioId)) {
funcionariosSelecionados = funcionariosSelecionados.filter((id) => id !== funcionarioId);
} else {
funcionariosSelecionados = [...funcionariosSelecionados, funcionarioId];
}
}
async function salvarDispensa() {
if (funcionariosSelecionados.length === 0) {
toast.error('Selecione pelo menos um funcionário');
return;
}
if (!motivo.trim()) {
toast.error('Informe o motivo da dispensa');
return;
}
const dataInicioObj = new Date(dataInicio);
const dataFimObj = new Date(dataFim);
if (dataFimObj < dataInicioObj) {
toast.error('Data fim deve ser maior ou igual à data início');
return;
}
try {
// Criar dispensa para cada funcionário selecionado
const promises = funcionariosSelecionados.map((funcionarioId) =>
client.mutation(api.pontos.criarDispensaRegistro, {
funcionarioId,
dataInicio,
horaInicio,
minutoInicio,
dataFim,
horaFim,
minutoFim,
motivo,
isento,
})
);
await Promise.all(promises);
toast.success(
`Dispensa criada com sucesso para ${funcionariosSelecionados.length} funcionário(s)`
);
cancelar();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
toast.error(`Erro ao criar dispensa: ${errorMessage}`);
}
}
async function removerDispensa(dispensaId: Id<'dispensasRegistro'>) {
if (!confirm('Deseja realmente remover esta dispensa?')) return;
try {
await client.mutation(api.pontos.removerDispensaRegistro, {
dispensaId,
});
toast.success('Dispensa removida com sucesso');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
toast.error(`Erro ao remover dispensa: ${errorMessage}`);
}
}
function formatarDataHora(data: string, hora: number, minuto: number): string {
return `${new Date(data).toLocaleDateString('pt-BR')} ${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`;
}
</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">Dispensa de Registro</h1>
<p class="text-base-content/60 mt-1">Gerencie períodos de dispensa de registro de ponto</p>
</div>
</div>
{#if !modoCriacao}
<button class="btn btn-primary gap-2" onclick={abrirCriacao}>
<Plus class="h-4 w-4" />
Nova Dispensa
</button>
{/if}
</div>
<!-- Formulário de Criação -->
{#if modoCriacao}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title mb-4">Criar Dispensa de Registro</h2>
<!-- Seleção de Funcionários -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">Funcionários</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2 max-h-60 overflow-y-auto border border-base-300 rounded-lg p-4">
{#each funcionarios as funcionario}
<label class="label cursor-pointer">
<span class="label-text">
{funcionario.nome} {funcionario.matricula ? `(${funcionario.matricula})` : ''}
</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={funcionariosSelecionados.includes(funcionario._id)}
onchange={() => toggleFuncionario(funcionario._id)}
/>
</label>
{/each}
</div>
</div>
<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">Data Início</span>
</label>
<input type="date" class="input input-bordered" bind:value={dataInicio} />
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Hora Início</span>
</label>
<div class="flex gap-2">
<input
type="number"
min="0"
max="23"
class="input input-bordered flex-1"
bind:value={horaInicio}
/>
<span class="self-center">:</span>
<input
type="number"
min="0"
max="59"
class="input input-bordered flex-1"
bind:value={minutoInicio}
/>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Data Fim</span>
</label>
<input type="date" class="input input-bordered" bind:value={dataFim} />
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Hora Fim</span>
</label>
<div class="flex gap-2">
<input
type="number"
min="0"
max="23"
class="input input-bordered flex-1"
bind:value={horaFim}
/>
<span class="self-center">:</span>
<input
type="number"
min="0"
max="59"
class="input input-bordered flex-1"
bind:value={minutoFim}
/>
</div>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-medium">Motivo</span>
</label>
<textarea class="textarea textarea-bordered" bind:value={motivo} rows="3"></textarea>
</div>
<div class="form-control md:col-span-2">
<label class="label cursor-pointer">
<span class="label-text font-medium">Isento de Registro (caso excepcional - sem expiração)</span>
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={isento} />
</label>
<p class="text-sm text-base-content/70 mt-1">
Se marcado, o funcionário ficará permanentemente dispensado de registrar ponto
</p>
</div>
</div>
<div class="flex gap-2 mt-4">
<button class="btn btn-primary gap-2" onclick={salvarDispensa}>
<Plus class="h-4 w-4" />
Criar Dispensa
</button>
<button class="btn btn-ghost gap-2" onclick={cancelar}>
<X class="h-4 w-4" />
Cancelar
</button>
</div>
</div>
</div>
{/if}
<!-- Lista de Dispensas -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4">Dispensas Ativas</h2>
{#if dispensas.length === 0}
<div class="alert alert-info">
<span>Nenhuma dispensa ativa encontrada</span>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Funcionário</th>
<th>Período</th>
<th>Motivo</th>
<th>Status</th>
<th>Gestor</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each dispensas as dispensa}
<tr>
<td>
{dispensa.funcionario?.nome || '-'}
{#if dispensa.funcionario?.matricula}
<br />
<span class="text-sm text-base-content/70">
Mat: {dispensa.funcionario.matricula}
</span>
{/if}
</td>
<td>
<div class="text-sm">
<div>
<strong>Início:</strong>{' '}
{formatarDataHora(dispensa.dataInicio, dispensa.horaInicio, dispensa.minutoInicio)}
</div>
<div>
<strong>Fim:</strong>{' '}
{formatarDataHora(dispensa.dataFim, dispensa.horaFim, dispensa.minutoFim)}
</div>
</div>
</td>
<td>{dispensa.motivo}</td>
<td>
{#if dispensa.isento}
<span class="badge badge-warning">Isento (sem expiração)</span>
{:else if dispensa.expirada}
<span class="badge badge-error">Expirada</span>
{:else}
<span class="badge badge-success">Ativa</span>
{/if}
</td>
<td>{dispensa.gestor?.nome || '-'}</td>
<td>
<button
class="btn btn-sm btn-error gap-2"
onclick={() => removerDispensa(dispensa._id)}
>
<Trash2 class="h-4 w-4" />
Remover
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</div>

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>

View File

@@ -8,6 +8,8 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
import PrintPontoModal from '$lib/components/ponto/PrintPontoModal.svelte';
import { toast } from 'svelte-sonner';
const client = useConvexClient();
@@ -16,6 +18,8 @@
let dataFim = $state(new Date().toISOString().split('T')[0]!);
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
let carregando = $state(false);
let mostrarModalImpressao = $state(false);
let funcionarioParaImprimir = $state<Id<'funcionarios'> | ''>('');
// Parâmetros reativos para queries
const registrosParams = $derived({
@@ -46,23 +50,76 @@
{
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
funcionarioId: Id<'funcionarios'>;
registros: typeof registros;
registrosPorData: Record<
string,
{
data: string;
registros: Array<typeof registros[number]>;
saldoDiario?: { saldoMinutos: number; horas: number; minutos: number; positivo: boolean };
}
>;
}
> = {};
// Usar Set para evitar registros duplicados
const registrosProcessados = new Set<string>();
for (const registro of registros) {
// Criar chave única para evitar duplicatas
const chaveUnica = `${registro._id}`;
if (registrosProcessados.has(chaveUnica)) {
continue; // Pular se já foi processado
}
registrosProcessados.add(chaveUnica);
const key = registro.funcionarioId;
if (!agrupados[key]) {
agrupados[key] = {
funcionario: registro.funcionario,
funcionarioId: registro.funcionarioId,
registros: [],
registrosPorData: {},
};
}
agrupados[key]!.registros.push(registro);
const dataKey = registro.data;
if (!agrupados[key]!.registrosPorData[dataKey]) {
agrupados[key]!.registrosPorData[dataKey] = {
data: dataKey,
registros: [],
saldoDiario: undefined,
};
}
// Verificar se o registro já não está no array antes de adicionar
const jaExiste = agrupados[key]!.registrosPorData[dataKey]!.registros.some(
(r) => r._id === registro._id
);
if (!jaExiste) {
agrupados[key]!.registrosPorData[dataKey]!.registros.push(registro);
}
}
return Object.values(agrupados);
// Ordenar registros por data e hora dentro de cada grupo e calcular saldo diário
const resultado = Object.values(agrupados);
for (const grupo of resultado) {
for (const dataKey in grupo.registrosPorData) {
const grupoData = grupo.registrosPorData[dataKey];
if (grupoData) {
// Ordenar por hora e minuto
grupoData.registros.sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
// Calcular saldo diário como diferença entre saída e entrada
grupoData.saldoDiario = calcularSaldoDiario(grupoData.registros);
}
}
}
return resultado;
});
// Query para banco de horas de cada funcionário
@@ -78,16 +135,91 @@
return `${sinal}${horas}h ${mins}min`;
}
async function imprimirFichaPonto(funcionarioId: Id<'funcionarios'>) {
const registrosFuncionario = registros.filter((r) => r.funcionarioId === funcionarioId);
if (registrosFuncionario.length === 0) {
alert('Nenhum registro encontrado para este funcionário no período selecionado');
// Função para formatar saldo diário
function formatarSaldoDiario(saldo?: { saldoMinutos: number; horas: number; minutos: number; positivo: boolean }): string {
if (!saldo) return '-';
const sinal = saldo.positivo ? '+' : '-';
return `${sinal}${saldo.horas}h ${saldo.minutos}min`;
}
// Função para calcular saldo diário como diferença entre saída e entrada
function calcularSaldoDiario(registros: Array<{ tipo: string; hora: number; minuto: number }>): { saldoMinutos: number; horas: number; minutos: number; positivo: boolean } | null {
if (registros.length === 0) return null;
// Ordenar registros por hora e minuto
const registrosOrdenados = [...registros].sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
// Buscar entrada (primeiro registro do tipo 'entrada')
const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada');
// Buscar saída (último registro do tipo 'saida')
const saida = registrosOrdenados.filter((r) => r.tipo === 'saida').pop();
if (!entrada || !saida) return null;
// Calcular diferença em minutos
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
const minutosSaida = saida.hora * 60 + saida.minuto;
// Se a saída for no dia seguinte (após meia-noite), adicionar 24 horas
let saldoMinutos = minutosSaida - minutosEntrada;
if (saldoMinutos < 0) {
saldoMinutos += 24 * 60; // Adicionar um dia em minutos
}
const horas = Math.floor(saldoMinutos / 60);
const minutos = saldoMinutos % 60;
return {
saldoMinutos,
horas,
minutos,
positivo: true, // Sempre positivo, pois é tempo trabalhado
};
}
function abrirModalImpressao(funcionarioId: Id<'funcionarios'>) {
funcionarioParaImprimir = funcionarioId;
mostrarModalImpressao = true;
}
async function gerarPDFComSelecao(sections: {
dadosFuncionario: boolean;
registrosPonto: boolean;
saldoDiario: boolean;
bancoHoras: boolean;
alteracoesGestor: boolean;
dispensasRegistro: boolean;
}) {
if (!funcionarioParaImprimir) return;
const funcionarioId = funcionarioParaImprimir;
// Verificar se pelo menos uma seção foi selecionada
if (!Object.values(sections).some((v) => v)) {
toast.error('Selecione pelo menos uma seção para imprimir');
return;
}
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
if (!funcionario) {
alert('Funcionário não encontrado');
toast.error('Funcionário não encontrado');
return;
}
// Buscar registros do funcionário no período selecionado
const registrosFuncionario = await client.query(api.pontos.listarRegistrosPeriodo, {
funcionarioId,
dataInicio,
dataFim,
});
if (!registrosFuncionario || registrosFuncionario.length === 0) {
toast.error('Nenhum registro encontrado para este funcionário no período selecionado');
return;
}
@@ -123,141 +255,368 @@
yPosition += 10;
// Dados do Funcionário
doc.setFontSize(12);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
doc.setFont('helvetica', 'normal');
if (sections.dadosFuncionario) {
doc.setFontSize(12);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 8;
doc.setFontSize(10);
yPosition += 8;
doc.setFontSize(10);
if (funcionario.matricula) {
doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition);
yPosition += 6;
}
doc.text(`Nome: ${funcionario.nome}`, 15, yPosition);
yPosition += 6;
if (funcionario.descricaoCargo) {
doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition);
if (funcionario.matricula) {
doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition);
yPosition += 6;
}
doc.text(`Nome: ${funcionario.nome}`, 15, yPosition);
yPosition += 6;
if (funcionario.descricaoCargo) {
doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition);
yPosition += 6;
}
yPosition += 5;
// Formatar período para exibição
const dataInicioParts = dataInicio.split('-');
const dataFimParts = dataFim.split('-');
const periodoFormatado = `${dataInicioParts[2]}/${dataInicioParts[1]}/${dataInicioParts[0]} a ${dataFimParts[2]}/${dataFimParts[1]}/${dataFimParts[0]}`;
doc.text(`Período: ${periodoFormatado}`, 15, yPosition);
yPosition += 10;
}
yPosition += 5;
doc.text(`Período: ${dataInicio} a ${dataFim}`, 15, yPosition);
yPosition += 10;
// Buscar homologações e dispensas
let homologacoes: Array<{
_id: Id<'homologacoesPonto'>;
criadoEm: number;
registroId?: Id<'registrosPonto'>;
horaAnterior?: number;
minutoAnterior?: number;
horaNova?: number;
minutoNova?: number;
tipoAjuste?: 'compensar' | 'abonar' | 'descontar';
periodoDias?: number;
periodoHoras?: number;
periodoMinutos?: number;
motivoDescricao?: string;
motivoTipo?: string;
observacoes?: string;
}> = [];
let dispensas: Array<{
dataInicio: string;
dataFim: string;
horaInicio: number;
minutoInicio: number;
horaFim: number;
minutoFim: number;
motivo: string;
isento: boolean;
}> = [];
if (sections.alteracoesGestor) {
try {
homologacoes = await client.query(api.pontos.listarHomologacoes, {
funcionarioId,
}) || [];
} catch (error) {
console.warn('Erro ao buscar homologações:', error);
// Continuar mesmo se houver erro ao buscar homologações
}
}
if (sections.dispensasRegistro) {
try {
dispensas = await client.query(api.pontos.listarDispensas, {
funcionarioId,
apenasAtivas: false,
}) || [];
} catch (error) {
console.warn('Erro ao buscar dispensas:', error);
// Continuar mesmo se houver erro ao buscar dispensas
}
}
// Tabela de registros
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
const tableData = registrosFuncionario.map((r) => [
r.data,
config
? getTipoRegistroLabel(r.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(r.tipo),
formatarHoraPonto(r.hora, r.minuto),
r.dentroDoPrazo ? 'Sim' : 'Não',
]);
if (sections.registrosPonto) {
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
const tableData: string[][] = [];
// Salvar a posição Y antes da tabela
const yPosAntesTabela = yPosition;
autoTable(doc, {
startY: yPosition,
head: [['Data', 'Tipo', 'Horário', 'Dentro do Prazo']],
body: tableData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
// Agrupar por data para incluir saldo diário
const registrosPorData: Record<
string,
Array<{
data: string;
tipo: string;
hora: number;
minuto: number;
dentroDoPrazo: boolean;
}>
> = {};
// Obter banco de horas do funcionário
const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, {
funcionarioId,
});
// Calcular posição Y após a tabela
// autoTable armazena a posição final em doc.lastAutoTable.finalY
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as any).lastAutoTable?.finalY;
// Se não conseguir obter a posição final, estimar baseado no número de linhas
if (finalY) {
yPosition = finalY;
} else {
// Estimativa: cada linha da tabela ocupa aproximadamente 7mm
const linhasTabela = tableData.length + 1; // +1 para o cabeçalho
yPosition = yPosAntesTabela + (linhasTabela * 7) + 10;
}
// Adicionar espaço antes do resumo
yPosition += 10;
// Verificar se precisa de nova página
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
// Resumo do Banco de Horas
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('RESUMO DO BANCO DE HORAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
doc.setFontSize(10);
if (bancoHoras) {
const saldoMinutos = bancoHoras.saldoAcumuladoMinutos;
const horas = Math.floor(Math.abs(saldoMinutos) / 60);
const minutos = Math.abs(saldoMinutos) % 60;
const sinal = saldoMinutos >= 0 ? '+' : '-';
const saldoFormatado = `${sinal}${horas}h ${minutos}min`;
// Saldo Atual
doc.setFont('helvetica', 'bold');
doc.text('Saldo Atual:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(saldoFormatado, 60, yPosition);
yPosition += 8;
// Horas Excedentes (se positivo)
if (saldoMinutos > 0) {
doc.setFont('helvetica', 'bold');
doc.setTextColor(0, 128, 0); // Verde
doc.text('Horas Excedentes:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${horas}h ${minutos}min`, 75, yPosition);
doc.setTextColor(0, 0, 0);
yPosition += 8;
for (const r of registrosFuncionario) {
const dataKey = r.data;
if (!registrosPorData[dataKey]) {
registrosPorData[dataKey] = [];
}
registrosPorData[dataKey]!.push({
data: r.data,
tipo: r.tipo,
hora: r.hora,
minuto: r.minuto,
dentroDoPrazo: r.dentroDoPrazo,
});
}
// Horas a Pagar (se negativo)
if (saldoMinutos < 0) {
doc.setFont('helvetica', 'bold');
doc.setTextColor(200, 0, 0); // Vermelho
doc.text('Horas a Pagar:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${horas}h ${minutos}min`, 70, yPosition);
doc.setTextColor(0, 0, 0);
yPosition += 8;
// Criar dados da tabela com saldo diário
for (const [data, regs] of Object.entries(registrosPorData)) {
// Formatar data para exibição (DD/MM/YYYY)
const dataParts = data.split('-');
const dataFormatada = `${dataParts[2]}/${dataParts[1]}/${dataParts[0]}`;
// Calcular saldo diário como diferença entre saída e entrada
const saldoDiarioDia = calcularSaldoDiario(regs);
for (const reg of regs) {
const linha: string[] = [
dataFormatada,
config
? getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida', {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'),
formatarHoraPonto(reg.hora, reg.minuto),
];
// Saldo Diário sempre após Horário
if (sections.saldoDiario) {
if (saldoDiarioDia) {
const sinal = saldoDiarioDia.positivo ? '+' : '-';
linha.push(`${sinal}${saldoDiarioDia.horas}h ${saldoDiarioDia.minutos}min`);
} else {
linha.push('-');
}
}
linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não');
tableData.push(linha);
}
}
// Total de dias registrados
const headers = ['Data', 'Tipo', 'Horário'];
if (sections.saldoDiario) {
headers.push('Saldo Diário');
}
headers.push('Dentro do Prazo');
// Salvar a posição Y antes da tabela
const yPosAntesTabela = yPosition;
autoTable(doc, {
startY: yPosition,
head: [headers],
body: tableData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
// Calcular posição Y após a tabela
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY;
} else {
const linhasTabela = tableData.length + 1;
yPosition = yPosAntesTabela + linhasTabela * 7 + 10;
}
yPosition += 10;
}
// Banco de Horas
if (sections.bancoHoras) {
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, {
funcionarioId,
});
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.text('Total de Dias com Registro:', 15, yPosition);
doc.setTextColor(41, 128, 185);
doc.text('RESUMO DO BANCO DE HORAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${bancoHoras.totalDias} dias`, 95, yPosition);
} else {
doc.text('Banco de horas não disponível', 15, yPosition);
doc.setTextColor(0, 0, 0);
yPosition += 10;
doc.setFontSize(10);
if (bancoHoras) {
const saldoMinutos = bancoHoras.saldoAcumuladoMinutos;
const horas = Math.floor(Math.abs(saldoMinutos) / 60);
const minutos = Math.abs(saldoMinutos) % 60;
const sinal = saldoMinutos >= 0 ? '+' : '-';
const saldoFormatado = `${sinal}${horas}h ${minutos}min`;
doc.setFont('helvetica', 'bold');
doc.text('Saldo Atual:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(saldoFormatado, 60, yPosition);
yPosition += 8;
if (saldoMinutos > 0) {
doc.setFont('helvetica', 'bold');
doc.setTextColor(0, 128, 0);
doc.text('Horas Excedentes:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${horas}h ${minutos}min`, 75, yPosition);
doc.setTextColor(0, 0, 0);
yPosition += 8;
}
if (saldoMinutos < 0) {
doc.setFont('helvetica', 'bold');
doc.setTextColor(200, 0, 0);
doc.text('Horas a Pagar:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${horas}h ${minutos}min`, 70, yPosition);
doc.setTextColor(0, 0, 0);
yPosition += 8;
}
doc.setFont('helvetica', 'bold');
doc.text('Total de Dias com Registro:', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.text(`${bancoHoras.totalDias} dias`, 95, yPosition);
yPosition += 10;
} else {
doc.text('Banco de horas não disponível', 15, yPosition);
yPosition += 10;
}
}
// Alterações pelo Gestor
if (sections.alteracoesGestor && homologacoes.length > 0) {
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('ALTERAÇÕES PELO GESTOR', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const homologacoesData = homologacoes.map((h) => {
// Formatar data de criação
const dataCriacao = new Date(h.criadoEm);
const dataFormatada = `${dataCriacao.getDate().toString().padStart(2, '0')}/${(dataCriacao.getMonth() + 1).toString().padStart(2, '0')}/${dataCriacao.getFullYear()}`;
if (h.registroId && h.horaAnterior !== undefined) {
return [
dataFormatada,
'Edição de Registro',
h.horaAnterior !== undefined
? `${formatarHoraPonto(h.horaAnterior, h.minutoAnterior || 0)}${formatarHoraPonto(h.horaNova || 0, h.minutoNova || 0)}`
: '-',
h.motivoDescricao || h.motivoTipo || '-',
h.observacoes || '-',
];
} else if (h.tipoAjuste) {
const tipoAjusteLabel = h.tipoAjuste === 'compensar' ? 'Compensar' : h.tipoAjuste === 'abonar' ? 'Abonar' : 'Descontar';
return [
dataFormatada,
`Ajuste: ${tipoAjusteLabel}`,
`${h.periodoDias || 0}d ${h.periodoHoras || 0}h ${h.periodoMinutos || 0}min`,
h.motivoDescricao || h.motivoTipo || '-',
h.observacoes || '-',
];
}
return [];
}).filter((row) => row.length > 0);
if (homologacoesData.length > 0) {
autoTable(doc, {
startY: yPosition,
head: [['Data', 'Tipo', 'Detalhes', 'Motivo', 'Observações']],
body: homologacoesData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY + 10;
} else {
yPosition += homologacoesData.length * 7 + 10;
}
}
}
// Dispensas de Registro
if (sections.dispensasRegistro && dispensas.length > 0) {
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('DISPENSAS DE REGISTRO', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const dispensasData = dispensas.map((d) => {
// Formatar data de início
const dataInicioParts = d.dataInicio.split('-');
const dataInicioFormatada = `${dataInicioParts[2]}/${dataInicioParts[1]}/${dataInicioParts[0]}`;
// Formatar data de fim
const dataFimParts = d.dataFim.split('-');
const dataFimFormatada = `${dataFimParts[2]}/${dataFimParts[1]}/${dataFimParts[0]}`;
return [
`${dataInicioFormatada} ${d.horaInicio.toString().padStart(2, '0')}:${d.minutoInicio.toString().padStart(2, '0')}`,
`${dataFimFormatada} ${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}`,
d.motivo,
d.isento ? 'Isento (sem expiração)' : 'Temporária',
];
});
autoTable(doc, {
startY: yPosition,
head: [['Início', 'Fim', 'Motivo', 'Tipo']],
body: dispensasData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
if (finalY) {
yPosition = finalY + 10;
} else {
yPosition += dispensasData.length * 7 + 10;
}
}
// Rodapé
@@ -277,9 +636,15 @@
// Salvar
const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`;
doc.save(nomeArquivo);
// Fechar modal após gerar PDF
mostrarModalImpressao = false;
funcionarioParaImprimir = '';
toast.success('PDF gerado com sucesso!');
} catch (error) {
console.error('Erro ao gerar PDF:', error);
alert('Erro ao gerar ficha de ponto. Tente novamente.');
const errorMessage = error instanceof Error ? error.message : String(error);
toast.error(`Erro ao gerar ficha de ponto: ${errorMessage}`);
}
}
@@ -869,7 +1234,7 @@
<button
class="btn btn-sm btn-primary"
onclick={() => imprimirFichaPonto(grupo.funcionarioId)}
onclick={() => abrirModalImpressao(grupo.funcionarioId)}
>
<Printer class="h-4 w-4" />
Imprimir Ficha
@@ -883,43 +1248,62 @@
<th>Data</th>
<th>Tipo</th>
<th>Horário</th>
<th>Saldo Diário</th>
<th>Status</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each grupo.registros as registro}
<tr>
<td>{registro.data}</td>
<td>
{config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: 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={() => imprimirDetalhesRegistro(registro._id)}
title="Imprimir Detalhes"
>
<FileText class="h-4 w-4" />
Imprimir Detalhes
</button>
</td>
</tr>
{#each Object.values(grupo.registrosPorData) as grupoData}
{@const totalRegistros = grupoData.registros.length}
{@const dataParts = grupoData.data.split('-')}
{@const dataFormatada = `${dataParts[2]}/${dataParts[1]}/${dataParts[0]}`}
{#each grupoData.registros as registro, index}
<tr>
<td>{dataFormatada}</td>
<td>
{config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo)}
</td>
<td>{formatarHoraPonto(registro.hora, registro.minuto)}</td>
{#if index === 0}
<td rowspan={totalRegistros}>
{#if grupoData.saldoDiario}
<span
class="badge {grupoData.saldoDiario.positivo ? 'badge-success' : 'badge-error'}"
>
{formatarSaldoDiario(grupoData.saldoDiario)}
</span>
{:else}
<span class="badge badge-ghost">-</span>
{/if}
</td>
{/if}
<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={() => imprimirDetalhesRegistro(registro._id)}
title="Imprimir Detalhes"
>
<FileText class="h-4 w-4" />
Imprimir Detalhes
</button>
</td>
</tr>
{/each}
{/each}
</tbody>
</table>
@@ -933,3 +1317,14 @@
</div>
</div>
{#if mostrarModalImpressao && funcionarioParaImprimir}
<PrintPontoModal
funcionarioId={funcionarioParaImprimir}
onClose={() => {
mostrarModalImpressao = false;
funcionarioParaImprimir = '';
}}
onGenerate={gerarPDFComSelecao}
/>
{/if}