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}

View File

@@ -173,6 +173,55 @@ export const registrarPonto = mutation({
throw new Error('Já existe um registro neste minuto');
}
// Verificar se funcionário está dispensado de registrar ponto
const dispensas = await ctx.db
.query('dispensasRegistro')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => q.eq(q.field('ativo'), true))
.collect();
const dataConsulta = new Date(data);
for (const dispensa of dispensas) {
// Se for isento, sempre está dispensado
if (dispensa.isento) {
throw new Error('Registro dispensado pelo gestor: Isento de registro (caso excepcional)');
}
// Verificar se está no período
const dataInicio = new Date(dispensa.dataInicio);
const dataFim = new Date(dispensa.dataFim);
if (dataConsulta >= dataInicio && dataConsulta <= dataFim) {
// Verificar hora e minuto se necessário
const timestampConsulta = new Date(
`${data}T${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}:00`
).getTime();
const timestampInicio = new Date(
`${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00`
).getTime();
const timestampFim = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) {
throw new Error(`Registro dispensado pelo gestor: ${dispensa.motivo}`);
}
}
// Verificar se expirou (desativar na mutation de registro)
const agora = new Date();
const dataFimTimestamp = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
if (agora.getTime() > dataFimTimestamp && !dispensa.isento) {
// Desativar dispensa expirada (mutation pode fazer isso)
await ctx.db.patch(dispensa._id, {
ativo: false,
});
}
}
// Determinar tipo de registro
const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data);
@@ -274,6 +323,45 @@ export const listarRegistrosDia = query({
},
});
/**
* Obtém saldo diário de um funcionário para uma data específica
*/
export const obterSaldoDiario = query({
args: {
funcionarioId: v.id('funcionarios'),
data: v.string(), // YYYY-MM-DD
},
handler: async (ctx, args) => {
// Buscar banco de horas do dia
const bancoHoras = await ctx.db
.query('bancoHoras')
.withIndex('by_funcionario_data', (q) =>
q.eq('funcionarioId', args.funcionarioId).eq('data', args.data)
)
.first();
if (!bancoHoras) {
return {
saldoMinutos: 0,
horas: 0,
minutos: 0,
positivo: true,
};
}
const horas = Math.floor(Math.abs(bancoHoras.saldoMinutos) / 60);
const minutos = Math.abs(bancoHoras.saldoMinutos) % 60;
const positivo = bancoHoras.saldoMinutos >= 0;
return {
saldoMinutos: bancoHoras.saldoMinutos,
horas,
minutos,
positivo,
};
},
});
/**
* Lista registros por período (para RH)
*/
@@ -313,8 +401,32 @@ export const listarRegistrosPeriodo = query({
Array.from(funcionariosIds).map((id) => ctx.db.get(id))
);
// Buscar saldos diários para cada data/funcionário
const saldosPorDataFuncionario: Record<string, number> = {};
const datasUnicas = new Set(registrosFiltrados.map((r) => `${r.funcionarioId}-${r.data}`));
for (const chave of datasUnicas) {
const [funcId, data] = chave.split('-');
const bancoHoras = await ctx.db
.query('bancoHoras')
.withIndex('by_funcionario_data', (q) =>
q.eq('funcionarioId', funcId as Id<'funcionarios'>).eq('data', data)
)
.first();
if (bancoHoras) {
saldosPorDataFuncionario[chave] = bancoHoras.saldoMinutos;
}
}
return registrosFiltrados.map((registro) => {
const funcionario = funcionarios.find((f) => f?._id === registro.funcionarioId);
const chave = `${registro.funcionarioId}-${registro.data}`;
const saldoMinutos = saldosPorDataFuncionario[chave] || 0;
const horas = Math.floor(Math.abs(saldoMinutos) / 60);
const minutos = Math.abs(saldoMinutos) % 60;
const positivo = saldoMinutos >= 0;
return {
...registro,
funcionario: funcionario
@@ -324,6 +436,12 @@ export const listarRegistrosPeriodo = query({
descricaoCargo: funcionario.descricaoCargo,
}
: null,
saldoDiario: {
saldoMinutos,
horas,
minutos,
positivo,
},
};
});
},
@@ -613,11 +731,20 @@ export const obterHistoricoESaldoDia = query({
const horasTrabalhadas = calcularHorasTrabalhadas(registros);
const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria;
const horas = Math.floor(Math.abs(saldoMinutos) / 60);
const minutos = Math.abs(saldoMinutos) % 60;
const positivo = saldoMinutos >= 0;
return {
registros,
cargaHorariaDiaria,
horasTrabalhadas,
saldoMinutos,
saldoFormatado: {
horas,
minutos,
positivo,
},
};
},
});
@@ -658,3 +785,550 @@ export const obterBancoHorasFuncionario = query({
},
});
/**
* Helper: Verificar se usuário é gestor do funcionário
*/
async function verificarGestorDoFuncionario(
ctx: QueryCtx | MutationCtx,
gestorId: Id<'usuarios'>,
funcionarioId: Id<'funcionarios'>
): Promise<boolean> {
const membroTime = await ctx.db
.query('timesMembros')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => q.eq(q.field('ativo'), true))
.first();
if (!membroTime) return false;
const time = await ctx.db.get(membroTime.timeId);
if (!time) return false;
return time.gestorId === gestorId;
}
/**
* Edita um registro de ponto (homologação pelo gestor)
*/
export const editarRegistroPonto = mutation({
args: {
registroId: v.id('registrosPonto'),
horaNova: v.number(),
minutoNova: v.number(),
motivoId: v.optional(v.string()),
motivoTipo: v.optional(v.string()),
motivoDescricao: v.optional(v.string()),
observacoes: v.optional(v.string()),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Buscar registro
const registro = await ctx.db.get(args.registroId);
if (!registro) {
throw new Error('Registro não encontrado');
}
// Verificar se é gestor do funcionário
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, registro.funcionarioId);
if (!isGestor) {
throw new Error('Você não tem permissão para editar este registro');
}
// Salvar dados anteriores
const horaAnterior = registro.hora;
const minutoAnterior = registro.minuto;
// Atualizar registro
await ctx.db.patch(args.registroId, {
hora: args.horaNova,
minuto: args.minutoNova,
editadoPorGestor: true,
});
// Criar registro de homologação
const homologacaoId = await ctx.db.insert('homologacoesPonto', {
registroId: args.registroId,
funcionarioId: registro.funcionarioId,
gestorId: usuario._id,
horaAnterior,
minutoAnterior,
horaNova: args.horaNova,
minutoNova: args.minutoNova,
motivoId: args.motivoId,
motivoTipo: args.motivoTipo,
motivoDescricao: args.motivoDescricao,
observacoes: args.observacoes,
criadoEm: Date.now(),
});
// Atualizar registro com ID da homologação
await ctx.db.patch(args.registroId, {
homologacaoId,
});
// Recalcular banco de horas do dia
const config = await ctx.db
.query('configuracaoPonto')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
if (config) {
await atualizarBancoHoras(ctx, registro.funcionarioId, registro.data, config);
}
return { success: true, homologacaoId };
},
});
/**
* Ajusta banco de horas (compensar, abonar ou descontar)
*/
export const ajustarBancoHoras = mutation({
args: {
funcionarioId: v.id('funcionarios'),
tipoAjuste: v.union(v.literal('compensar'), v.literal('abonar'), v.literal('descontar')),
periodoDias: v.number(),
periodoHoras: v.number(),
periodoMinutos: v.number(),
motivoId: v.optional(v.string()),
motivoTipo: v.optional(v.string()),
motivoDescricao: v.optional(v.string()),
observacoes: v.optional(v.string()),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Verificar se é gestor do funcionário
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId);
if (!isGestor) {
throw new Error('Você não tem permissão para ajustar banco de horas deste funcionário');
}
// Calcular ajuste em minutos
const ajusteMinutos =
args.periodoDias * 24 * 60 + args.periodoHoras * 60 + args.periodoMinutos;
// Aplicar sinal baseado no tipo de ajuste
let ajusteFinal = ajusteMinutos;
if (args.tipoAjuste === 'descontar') {
ajusteFinal = -ajusteMinutos;
}
// Buscar banco de horas mais recente ou criar um registro de ajuste
const hoje = new Date().toISOString().split('T')[0]!;
const bancoHorasAtual = await ctx.db
.query('bancoHoras')
.withIndex('by_funcionario_data', (q) =>
q.eq('funcionarioId', args.funcionarioId).eq('data', hoje)
)
.first();
if (bancoHorasAtual) {
// Atualizar saldo do dia atual
await ctx.db.patch(bancoHorasAtual._id, {
saldoMinutos: bancoHorasAtual.saldoMinutos + ajusteFinal,
});
} else {
// Criar novo registro de banco de horas para o ajuste
const config = await ctx.db
.query('configuracaoPonto')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
if (!config) {
throw new Error('Configuração de ponto não encontrada');
}
const cargaHorariaDiaria = calcularCargaHorariaDiaria(config);
await ctx.db.insert('bancoHoras', {
funcionarioId: args.funcionarioId,
data: hoje,
cargaHorariaDiaria,
horasTrabalhadas: 0,
saldoMinutos: ajusteFinal,
registrosPontoIds: [],
calculadoEm: Date.now(),
});
}
// Criar registro de homologação
const homologacaoId = await ctx.db.insert('homologacoesPonto', {
funcionarioId: args.funcionarioId,
gestorId: usuario._id,
motivoId: args.motivoId,
motivoTipo: args.motivoTipo,
motivoDescricao: args.motivoDescricao,
observacoes: args.observacoes,
tipoAjuste: args.tipoAjuste,
periodoDias: args.periodoDias,
periodoHoras: args.periodoHoras,
periodoMinutos: args.periodoMinutos,
ajusteMinutos: ajusteFinal,
criadoEm: Date.now(),
});
return { success: true, homologacaoId, ajusteMinutos: ajusteFinal };
},
});
/**
* Lista homologações de um funcionário ou time
*/
export const listarHomologacoes = query({
args: {
funcionarioId: v.optional(v.id('funcionarios')),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
let homologacoes;
if (args.funcionarioId) {
// Verificar se é gestor do funcionário ou o próprio funcionário
const funcionarioId = args.funcionarioId; // Garantir que não é undefined
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, funcionarioId);
const isProprioFuncionario = usuario.funcionarioId === funcionarioId;
if (!isGestor && !isProprioFuncionario) {
throw new Error('Você não tem permissão para ver estas homologações');
}
homologacoes = await ctx.db
.query('homologacoesPonto')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
.order('desc')
.collect();
} else {
// Listar homologações do gestor
homologacoes = await ctx.db
.query('homologacoesPonto')
.withIndex('by_gestor', (q) => q.eq('gestorId', usuario._id))
.order('desc')
.collect();
}
// Buscar informações adicionais
const homologacoesComDetalhes = await Promise.all(
homologacoes.map(async (h) => {
const funcionario = await ctx.db.get(h.funcionarioId);
const gestor = await ctx.db.get(h.gestorId);
const registro = h.registroId ? await ctx.db.get(h.registroId) : null;
return {
...h,
funcionario: funcionario
? {
nome: funcionario.nome,
matricula: funcionario.matricula,
}
: null,
gestor: gestor
? {
nome: gestor.nome,
}
: null,
registro: registro
? {
data: registro.data,
tipo: registro.tipo,
}
: null,
};
})
);
return homologacoesComDetalhes;
},
});
/**
* Obtém opções de motivos de atestados/declarações
*/
export const obterMotivosAtestados = query({
args: {},
handler: async (ctx) => {
// Buscar tipos de atestados e declarações
const atestados = await ctx.db.query('atestados').collect();
const tiposUnicos = new Set<string>();
atestados.forEach((a) => {
if (a.cid) tiposUnicos.add(`CID: ${a.cid}`);
if (a.observacoes) tiposUnicos.add(a.observacoes);
});
return {
tipos: Array.from(tiposUnicos),
opcoesPadrao: [
'Atestado Médico',
'Declaração',
'Ajuste Administrativo',
'Compensação de Horas',
'Abono',
'Desconto em Folha',
],
};
},
});
/**
* Cria uma dispensa de registro de ponto
*/
export const criarDispensaRegistro = mutation({
args: {
funcionarioId: v.id('funcionarios'),
dataInicio: v.string(), // YYYY-MM-DD
horaInicio: v.number(),
minutoInicio: v.number(),
dataFim: v.string(), // YYYY-MM-DD
horaFim: v.number(),
minutoFim: v.number(),
motivo: v.string(),
isento: v.boolean(),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Verificar se é gestor do funcionário
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, args.funcionarioId);
if (!isGestor) {
throw new Error('Você não tem permissão para criar dispensa para este funcionário');
}
// Validar datas
const dataInicioObj = new Date(args.dataInicio);
const dataFimObj = new Date(args.dataFim);
if (dataFimObj < dataInicioObj) {
throw new Error('Data fim deve ser maior ou igual à data início');
}
// Criar dispensa
const dispensaId = await ctx.db.insert('dispensasRegistro', {
funcionarioId: args.funcionarioId,
gestorId: usuario._id,
dataInicio: args.dataInicio,
horaInicio: args.horaInicio,
minutoInicio: args.minutoInicio,
dataFim: args.dataFim,
horaFim: args.horaFim,
minutoFim: args.minutoFim,
motivo: args.motivo,
isento: args.isento,
ativo: true,
criadoEm: Date.now(),
});
return { success: true, dispensaId };
},
});
/**
* Remove uma dispensa de registro (cancela)
*/
export const removerDispensaRegistro = mutation({
args: {
dispensaId: v.id('dispensasRegistro'),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
const dispensa = await ctx.db.get(args.dispensaId);
if (!dispensa) {
throw new Error('Dispensa não encontrada');
}
// Verificar se é gestor do funcionário
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, dispensa.funcionarioId);
if (!isGestor && dispensa.gestorId !== usuario._id) {
throw new Error('Você não tem permissão para remover esta dispensa');
}
// Desativar dispensa
await ctx.db.patch(args.dispensaId, {
ativo: false,
});
return { success: true };
},
});
/**
* Lista dispensas de registro
*/
export const listarDispensas = query({
args: {
funcionarioId: v.optional(v.id('funcionarios')),
apenasAtivas: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
let dispensas;
if (args.funcionarioId) {
// Verificar se é gestor do funcionário ou o próprio funcionário
const funcionarioId = args.funcionarioId; // Garantir que não é undefined
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, funcionarioId);
const isProprioFuncionario = usuario.funcionarioId === funcionarioId;
if (!isGestor && !isProprioFuncionario) {
throw new Error('Você não tem permissão para ver estas dispensas');
}
dispensas = await ctx.db
.query('dispensasRegistro')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => {
if (args.apenasAtivas !== undefined && args.apenasAtivas) {
return q.eq(q.field('ativo'), true);
}
return true; // Retornar todas se apenasAtivas não for especificado
})
.order('desc')
.collect();
} else {
// Listar dispensas do gestor
dispensas = await ctx.db
.query('dispensasRegistro')
.withIndex('by_gestor', (q) => q.eq('gestorId', usuario._id))
.filter((q) => {
if (args.apenasAtivas !== undefined && args.apenasAtivas) {
return q.eq(q.field('ativo'), true);
}
return true; // Retornar todas se apenasAtivas não for especificado
})
.order('desc')
.collect();
}
// Buscar informações adicionais
const dispensasComDetalhes = await Promise.all(
dispensas.map(async (d) => {
const funcionario = await ctx.db.get(d.funcionarioId);
const gestor = await ctx.db.get(d.gestorId);
// Verificar se expirou (se não for isento)
let expirada = false;
if (!d.isento) {
const agora = new Date();
const dataFimTimestamp = new Date(
`${d.dataFim}T${d.horaFim.toString().padStart(2, '0')}:${d.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
expirada = agora.getTime() > dataFimTimestamp;
}
return {
...d,
funcionario: funcionario
? {
nome: funcionario.nome,
matricula: funcionario.matricula,
}
: null,
gestor: gestor
? {
nome: gestor.nome,
}
: null,
expirada,
};
})
);
return dispensasComDetalhes;
},
});
/**
* Verifica se funcionário está dispensado de registrar ponto em uma data/hora específica
*/
export const verificarDispensaAtiva = query({
args: {
funcionarioId: v.id('funcionarios'),
data: v.string(), // YYYY-MM-DD
hora: v.optional(v.number()),
minuto: v.optional(v.number()),
},
handler: async (ctx, args) => {
const dispensas = await ctx.db
.query('dispensasRegistro')
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
.filter((q) => q.eq(q.field('ativo'), true))
.collect();
const dataConsulta = new Date(args.data);
for (const dispensa of dispensas) {
// Se for isento, sempre está dispensado
if (dispensa.isento) {
return {
dispensado: true,
dispensa,
motivo: 'Isento de registro (caso excepcional)',
};
}
// Verificar se está no período
const dataInicio = new Date(dispensa.dataInicio);
const dataFim = new Date(dispensa.dataFim);
// Se a data está dentro do período
if (dataConsulta >= dataInicio && dataConsulta <= dataFim) {
// Se hora e minuto foram fornecidos, verificar também
if (args.hora !== undefined && args.minuto !== undefined) {
const timestampConsulta = new Date(
`${args.data}T${args.hora.toString().padStart(2, '0')}:${args.minuto.toString().padStart(2, '0')}:00`
).getTime();
const timestampInicio = new Date(
`${dispensa.dataInicio}T${dispensa.horaInicio.toString().padStart(2, '0')}:${dispensa.minutoInicio.toString().padStart(2, '0')}:00`
).getTime();
const timestampFim = new Date(
`${dispensa.dataFim}T${dispensa.horaFim.toString().padStart(2, '0')}:${dispensa.minutoFim.toString().padStart(2, '0')}:00`
).getTime();
if (timestampConsulta >= timestampInicio && timestampConsulta <= timestampFim) {
return {
dispensado: true,
dispensa,
motivo: dispensa.motivo,
};
}
} else {
// Apenas verificar data
return {
dispensado: true,
dispensa,
motivo: dispensa.motivo,
};
}
}
}
return {
dispensado: false,
dispensa: null,
motivo: null,
};
},
});

View File

@@ -1390,6 +1390,10 @@ export default defineSchema({
// Justificativa opcional para o registro
justificativa: v.optional(v.string()),
// Campos para homologação
editadoPorGestor: v.optional(v.boolean()),
homologacaoId: v.optional(v.id("homologacoesPonto")),
criadoEm: v.number(),
})
.index("by_funcionario_data", ["funcionarioId", "data"])
@@ -1443,4 +1447,60 @@ export default defineSchema({
.index("by_funcionario_data", ["funcionarioId", "data"])
.index("by_funcionario", ["funcionarioId"])
.index("by_data", ["data"]),
// Homologações de Ponto - Edições e ajustes realizados pelo gestor
homologacoesPonto: defineTable({
registroId: v.optional(v.id("registrosPonto")), // ID do registro editado (se for edição)
funcionarioId: v.id("funcionarios"),
gestorId: v.id("usuarios"),
// Dados do registro original (se for edição)
horaAnterior: v.optional(v.number()),
minutoAnterior: v.optional(v.number()),
// Dados do registro novo (se for edição)
horaNova: v.optional(v.number()),
minutoNova: v.optional(v.number()),
// Motivo e observações
motivoId: v.optional(v.string()), // ID do motivo (referência a atestados/declarações)
motivoTipo: v.optional(v.string()), // Tipo do motivo (atestado, declaracao, etc)
motivoDescricao: v.optional(v.string()), // Descrição do motivo
observacoes: v.optional(v.string()),
// Tipo de ajuste (se for ajuste de banco de horas)
tipoAjuste: v.optional(v.union(
v.literal("compensar"),
v.literal("abonar"),
v.literal("descontar")
)),
// Período do ajuste (se for ajuste de banco de horas)
periodoDias: v.optional(v.number()),
periodoHoras: v.optional(v.number()),
periodoMinutos: v.optional(v.number()),
// Ajuste em minutos (calculado)
ajusteMinutos: v.optional(v.number()),
criadoEm: v.number(),
})
.index("by_funcionario", ["funcionarioId"])
.index("by_gestor", ["gestorId"])
.index("by_registro", ["registroId"])
.index("by_data", ["criadoEm"]),
// Dispensas de Registro - Períodos onde funcionário está dispensado de registrar ponto
dispensasRegistro: defineTable({
funcionarioId: v.id("funcionarios"),
gestorId: v.id("usuarios"),
dataInicio: v.string(), // YYYY-MM-DD
horaInicio: v.number(),
minutoInicio: v.number(),
dataFim: v.string(), // YYYY-MM-DD
horaFim: v.number(),
minutoFim: v.number(),
motivo: v.string(),
isento: v.boolean(), // Se true, não expira (casos excepcionais)
ativo: v.boolean(),
criadoEm: v.number(),
})
.index("by_funcionario", ["funcionarioId"])
.index("by_gestor", ["gestorId"])
.index("by_ativo", ["ativo"])
.index("by_data_inicio", ["dataInicio"])
.index("by_data_fim", ["dataFim"]),
});