Feat controle ponto #32
200
apps/web/src/lib/components/ponto/PrintPontoModal.svelte
Normal file
200
apps/web/src/lib/components/ponto/PrintPontoModal.svelte
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Clock } from 'lucide-svelte';
|
import { Clock, CheckCircle2, XCircle } from 'lucide-svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -62,6 +62,86 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
Info,
|
Info,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Clock,
|
Clock,
|
||||||
|
XCircle,
|
||||||
} from "lucide-svelte";
|
} from "lucide-svelte";
|
||||||
import type { Component } from "svelte";
|
import type { Component } from "svelte";
|
||||||
|
|
||||||
@@ -134,6 +135,18 @@
|
|||||||
href: "/recursos-humanos/registro-pontos",
|
href: "/recursos-humanos/registro-pontos",
|
||||||
Icon: Clock,
|
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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -0,0 +1,736 @@
|
|||||||
|
<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 abaAtiva = $state<'editar' | 'ajustar'>('editar');
|
||||||
|
|
||||||
|
// 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('');
|
||||||
|
|
||||||
|
// Campo de hora unificado (formato HH:mm)
|
||||||
|
let novaHoraFormatada = $state('08:00');
|
||||||
|
|
||||||
|
// Converter hora/minuto para formato HH:mm
|
||||||
|
function horaMinutoParaTime(hora: number, minuto: number): string {
|
||||||
|
return `${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converter formato HH:mm para hora/minuto
|
||||||
|
function timeParaHoraMinuto(time: string): { hora: number; minuto: number } {
|
||||||
|
const [h, m] = time.split(':').map(Number);
|
||||||
|
return { hora: h || 0, minuto: m || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular período entre duas datas/horas em dias, horas e minutos
|
||||||
|
function calcularPeriodo(
|
||||||
|
dataInicio: string,
|
||||||
|
horaInicio: string,
|
||||||
|
dataFim: string,
|
||||||
|
horaFim: string
|
||||||
|
): { dias: number; horas: number; minutos: number } {
|
||||||
|
if (!dataInicio || !horaInicio || !dataFim || !horaFim) {
|
||||||
|
return { dias: 0, horas: 0, minutos: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inicio = new Date(`${dataInicio}T${horaInicio}:00`);
|
||||||
|
const fim = new Date(`${dataFim}T${horaFim}:00`);
|
||||||
|
|
||||||
|
if (fim < inicio) {
|
||||||
|
return { dias: 0, horas: 0, minutos: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = fim.getTime() - inicio.getTime();
|
||||||
|
const diffMinutos = Math.floor(diffMs / (1000 * 60));
|
||||||
|
|
||||||
|
const dias = Math.floor(diffMinutos / (24 * 60));
|
||||||
|
const minutosRestantes = diffMinutos % (24 * 60);
|
||||||
|
const horas = Math.floor(minutosRestantes / 60);
|
||||||
|
const minutos = minutosRestantes % 60;
|
||||||
|
|
||||||
|
return { dias, horas, minutos };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obter registro selecionado
|
||||||
|
const registroEmEdicao = $derived.by(() => {
|
||||||
|
if (!registroSelecionado) return null;
|
||||||
|
return registros.find((r) => r._id === registroSelecionado) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formatar data do registro
|
||||||
|
const dataRegistroFormatada = $derived.by(() => {
|
||||||
|
if (!registroEmEdicao) return '';
|
||||||
|
const data = new Date(registroEmEdicao.data);
|
||||||
|
return data.toLocaleDateString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formulário de ajuste
|
||||||
|
let tipoAjuste = $state<'compensar' | 'abonar' | 'descontar'>('compensar');
|
||||||
|
let dataInicioAjuste = $state(new Date().toISOString().split('T')[0]!);
|
||||||
|
let horaInicioAjuste = $state('08:00');
|
||||||
|
let dataFimAjuste = $state(new Date().toISOString().split('T')[0]!);
|
||||||
|
let horaFimAjuste = $state('18:00');
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
novaHoraFormatada = horaMinutoParaTime(registro.hora, registro.minuto);
|
||||||
|
motivoId = '';
|
||||||
|
motivoTipo = '';
|
||||||
|
motivoDescricao = '';
|
||||||
|
observacoes = '';
|
||||||
|
modoEdicao = true;
|
||||||
|
modoAjuste = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirEdicaoComAjuste(registroId: Id<'registrosPonto'>) {
|
||||||
|
const registro = registros.find((r) => r._id === registroId);
|
||||||
|
if (!registro) return;
|
||||||
|
|
||||||
|
registroSelecionado = registroId;
|
||||||
|
horaNova = registro.hora;
|
||||||
|
minutoNova = registro.minuto;
|
||||||
|
novaHoraFormatada = horaMinutoParaTime(registro.hora, registro.minuto);
|
||||||
|
motivoId = '';
|
||||||
|
motivoTipo = '';
|
||||||
|
motivoDescricao = '';
|
||||||
|
observacoes = '';
|
||||||
|
modoEdicao = true;
|
||||||
|
abaAtiva = 'editar';
|
||||||
|
|
||||||
|
// Resetar campos de ajuste
|
||||||
|
tipoAjuste = 'compensar';
|
||||||
|
const hoje = new Date().toISOString().split('T')[0]!;
|
||||||
|
dataInicioAjuste = hoje;
|
||||||
|
dataFimAjuste = hoje;
|
||||||
|
horaInicioAjuste = '08:00';
|
||||||
|
horaFimAjuste = '18:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelar() {
|
||||||
|
modoEdicao = false;
|
||||||
|
registroSelecionado = '';
|
||||||
|
abaAtiva = 'editar';
|
||||||
|
novaHoraFormatada = '08:00';
|
||||||
|
const hoje = new Date().toISOString().split('T')[0]!;
|
||||||
|
dataInicioAjuste = hoje;
|
||||||
|
dataFimAjuste = hoje;
|
||||||
|
horaInicioAjuste = '08:00';
|
||||||
|
horaFimAjuste = '18:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function salvarEdicao() {
|
||||||
|
if (!registroSelecionado || !funcionarioSelecionado) {
|
||||||
|
toast.error('Selecione um funcionário e um registro');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converter hora formatada para hora/minuto
|
||||||
|
const { hora, minuto } = timeParaHoraMinuto(novaHoraFormatada);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.pontos.editarRegistroPonto, {
|
||||||
|
registroId: registroSelecionado,
|
||||||
|
horaNova: hora,
|
||||||
|
minutoNova: minuto,
|
||||||
|
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 (!dataInicioAjuste || !horaInicioAjuste || !dataFimAjuste || !horaFimAjuste) {
|
||||||
|
toast.error('Preencha todas as datas e horários do período');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular período entre início e fim
|
||||||
|
const { dias, horas, minutos } = calcularPeriodo(
|
||||||
|
dataInicioAjuste,
|
||||||
|
horaInicioAjuste,
|
||||||
|
dataFimAjuste,
|
||||||
|
horaFimAjuste
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dias === 0 && horas === 0 && minutos === 0) {
|
||||||
|
toast.error('A data/hora final deve ser maior que a inicial');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.mutation(api.pontos.ajustarBancoHoras, {
|
||||||
|
funcionarioId: funcionarioSelecionado,
|
||||||
|
tipoAjuste,
|
||||||
|
periodoDias: dias,
|
||||||
|
periodoHoras: horas,
|
||||||
|
periodoMinutos: minutos,
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Formulário Unificado de Edição e Ajuste -->
|
||||||
|
{#if modoEdicao && registroSelecionado}
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6 border-2 border-primary/20">
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Título com data -->
|
||||||
|
<div class="flex items-center justify-between mb-6 pb-4 border-b border-base-300">
|
||||||
|
<div>
|
||||||
|
<h2 class="card-title text-2xl text-primary mb-1">
|
||||||
|
<Edit class="h-6 w-6" strokeWidth={2} />
|
||||||
|
Homologar Registro de Ponto
|
||||||
|
</h2>
|
||||||
|
{#if dataRegistroFormatada}
|
||||||
|
<p class="text-base-content/70 text-sm mt-1">
|
||||||
|
Registro do dia <span class="font-semibold text-primary">{dataRegistroFormatada}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Abas -->
|
||||||
|
<div class="tabs tabs-boxed mb-6 bg-base-200">
|
||||||
|
<button
|
||||||
|
class="tab tab-lg {abaAtiva === 'editar' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => (abaAtiva = 'editar')}
|
||||||
|
>
|
||||||
|
<Edit class="h-4 w-4 mr-2" />
|
||||||
|
Editar Horário
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab tab-lg {abaAtiva === 'ajustar' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => (abaAtiva = 'ajustar')}
|
||||||
|
>
|
||||||
|
<TrendingUp class="h-4 w-4 mr-2" />
|
||||||
|
Ajustar Banco de Horas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conteúdo da Aba: Editar Horário -->
|
||||||
|
{#if abaAtiva === 'editar'}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Hora Unificada -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold text-base">
|
||||||
|
<Clock class="h-4 w-4 inline-block mr-2" />
|
||||||
|
Nova Hora
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="input input-bordered input-primary w-full max-w-xs"
|
||||||
|
bind:value={novaHoraFormatada}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid de Motivos -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Motivo (Tipo)</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="select select-bordered select-primary" 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-semibold">Descrição do Motivo</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-primary"
|
||||||
|
bind:value={motivoDescricao}
|
||||||
|
placeholder="Informe detalhes adicionais sobre o motivo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Observações -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Observações</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered textarea-primary"
|
||||||
|
bind:value={observacoes}
|
||||||
|
rows="4"
|
||||||
|
placeholder="Adicione observações relevantes sobre esta edição..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botões de ação -->
|
||||||
|
<div class="flex gap-3 justify-end mt-8 pt-6 border-t border-base-300">
|
||||||
|
<button class="btn btn-ghost gap-2" onclick={cancelar}>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary gap-2" onclick={salvarEdicao}>
|
||||||
|
<Save class="h-4 w-4" />
|
||||||
|
Salvar Alterações
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Conteúdo da Aba: Ajustar Banco de Horas -->
|
||||||
|
{#if abaAtiva === 'ajustar'}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Tipo de Ajuste -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold text-base">Tipo de Ajuste</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="select select-bordered select-primary w-full max-w-md" bind:value={tipoAjuste}>
|
||||||
|
<option value="compensar">Compensar</option>
|
||||||
|
<option value="abonar">Abonar</option>
|
||||||
|
<option value="descontar">Descontar em Folha</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Período com Data e Hora -->
|
||||||
|
<div class="bg-base-200/50 p-6 rounded-xl border border-base-300">
|
||||||
|
<label class="label mb-4">
|
||||||
|
<span class="label-text font-semibold text-base">
|
||||||
|
<Clock class="h-4 w-4 inline-block mr-2" />
|
||||||
|
Período do Ajuste
|
||||||
|
</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Data e Hora Início -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-sm font-semibold text-base-content/80 mb-3 flex items-center gap-2">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
||||||
|
Início do Período
|
||||||
|
</h4>
|
||||||
|
<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>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-primary"
|
||||||
|
bind:value={dataInicioAjuste}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Hora Início</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="input input-bordered input-primary"
|
||||||
|
bind:value={horaInicioAjuste}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Separador Visual -->
|
||||||
|
<div class="divider my-4">
|
||||||
|
<Clock class="h-4 w-4 text-base-content/40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data e Hora Fim -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-base-content/80 mb-3 flex items-center gap-2">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-secondary"></div>
|
||||||
|
Fim do Período
|
||||||
|
</h4>
|
||||||
|
<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 Fim</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered input-primary"
|
||||||
|
bind:value={dataFimAjuste}
|
||||||
|
min={dataInicioAjuste}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-medium">Hora Fim</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="input input-bordered input-primary"
|
||||||
|
bind:value={horaFimAjuste}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview do Período Calculado -->
|
||||||
|
{#if dataInicioAjuste && horaInicioAjuste && dataFimAjuste && horaFimAjuste}
|
||||||
|
{@const periodoCalculado = calcularPeriodo(dataInicioAjuste, horaInicioAjuste, dataFimAjuste, horaFimAjuste)}
|
||||||
|
{#if periodoCalculado.dias > 0 || periodoCalculado.horas > 0 || periodoCalculado.minutos > 0}
|
||||||
|
<div class="mt-4 p-3 bg-primary/10 rounded-lg border border-primary/20">
|
||||||
|
<div class="text-xs font-semibold text-primary mb-1">Período Calculado:</div>
|
||||||
|
<div class="text-sm text-base-content">
|
||||||
|
{periodoCalculado.dias > 0 ? `${periodoCalculado.dias} dia${periodoCalculado.dias > 1 ? 's' : ''} ` : ''}
|
||||||
|
{periodoCalculado.horas > 0 ? `${periodoCalculado.horas} hora${periodoCalculado.horas > 1 ? 's' : ''} ` : ''}
|
||||||
|
{periodoCalculado.minutos > 0 ? `${periodoCalculado.minutos} minuto${periodoCalculado.minutos > 1 ? 's' : ''}` : ''}
|
||||||
|
{#if periodoCalculado.dias === 0 && periodoCalculado.horas === 0 && periodoCalculado.minutos === 0}
|
||||||
|
<span class="text-base-content/60">Período inválido</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid de Motivos -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Motivo (Tipo)</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<select class="select select-bordered select-primary" 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-semibold">Descrição do Motivo</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-primary"
|
||||||
|
bind:value={motivoDescricao}
|
||||||
|
placeholder="Informe detalhes adicionais sobre o motivo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Observações -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-semibold">Observações</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered textarea-primary"
|
||||||
|
bind:value={observacoes}
|
||||||
|
rows="4"
|
||||||
|
placeholder="Adicione observações relevantes sobre este ajuste..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botões de ação -->
|
||||||
|
<div class="flex gap-3 justify-end mt-8 pt-6 border-t border-base-300">
|
||||||
|
<button class="btn btn-ghost gap-2" onclick={cancelar}>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary gap-2" onclick={salvarAjuste}>
|
||||||
|
<Save class="h-4 w-4" />
|
||||||
|
Salvar Ajuste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Lista de Registros -->
|
||||||
|
{#if funcionarioSelecionado && !modoEdicao}
|
||||||
|
<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={() => abrirEdicaoComAjuste(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}
|
||||||
|
<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>
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
import autoTable from 'jspdf-autotable';
|
import autoTable from 'jspdf-autotable';
|
||||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
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();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@
|
|||||||
let dataFim = $state(new Date().toISOString().split('T')[0]!);
|
let dataFim = $state(new Date().toISOString().split('T')[0]!);
|
||||||
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
|
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
|
||||||
let carregando = $state(false);
|
let carregando = $state(false);
|
||||||
|
let mostrarModalImpressao = $state(false);
|
||||||
|
let funcionarioParaImprimir = $state<Id<'funcionarios'> | ''>('');
|
||||||
|
|
||||||
// Parâmetros reativos para queries
|
// Parâmetros reativos para queries
|
||||||
const registrosParams = $derived({
|
const registrosParams = $derived({
|
||||||
@@ -46,23 +50,76 @@
|
|||||||
{
|
{
|
||||||
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
|
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
|
||||||
funcionarioId: Id<'funcionarios'>;
|
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) {
|
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;
|
const key = registro.funcionarioId;
|
||||||
if (!agrupados[key]) {
|
if (!agrupados[key]) {
|
||||||
agrupados[key] = {
|
agrupados[key] = {
|
||||||
funcionario: registro.funcionario,
|
funcionario: registro.funcionario,
|
||||||
funcionarioId: registro.funcionarioId,
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.values(agrupados);
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Query para banco de horas de cada funcionário
|
||||||
@@ -78,16 +135,91 @@
|
|||||||
return `${sinal}${horas}h ${mins}min`;
|
return `${sinal}${horas}h ${mins}min`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function imprimirFichaPonto(funcionarioId: Id<'funcionarios'>) {
|
// Função para formatar saldo diário
|
||||||
const registrosFuncionario = registros.filter((r) => r.funcionarioId === funcionarioId);
|
function formatarSaldoDiario(saldo?: { saldoMinutos: number; horas: number; minutos: number; positivo: boolean }): string {
|
||||||
if (registrosFuncionario.length === 0) {
|
if (!saldo) return '-';
|
||||||
alert('Nenhum registro encontrado para este funcionário no período selecionado');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
|
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
|
||||||
if (!funcionario) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +255,7 @@
|
|||||||
yPosition += 10;
|
yPosition += 10;
|
||||||
|
|
||||||
// Dados do Funcionário
|
// Dados do Funcionário
|
||||||
|
if (sections.dadosFuncionario) {
|
||||||
doc.setFontSize(12);
|
doc.setFontSize(12);
|
||||||
doc.setTextColor(0, 0, 0);
|
doc.setTextColor(0, 0, 0);
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
@@ -144,67 +277,179 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
yPosition += 5;
|
yPosition += 5;
|
||||||
doc.text(`Período: ${dataInicio} a ${dataFim}`, 15, yPosition);
|
// 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 += 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
|
// Tabela de registros
|
||||||
|
if (sections.registrosPonto) {
|
||||||
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
|
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
|
||||||
const tableData = registrosFuncionario.map((r) => [
|
const tableData: string[][] = [];
|
||||||
r.data,
|
|
||||||
|
// Agrupar por data para incluir saldo diário
|
||||||
|
const registrosPorData: Record<
|
||||||
|
string,
|
||||||
|
Array<{
|
||||||
|
data: string;
|
||||||
|
tipo: string;
|
||||||
|
hora: number;
|
||||||
|
minuto: number;
|
||||||
|
dentroDoPrazo: boolean;
|
||||||
|
}>
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
config
|
||||||
? getTipoRegistroLabel(r.tipo, {
|
? getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida', {
|
||||||
nomeEntrada: config.nomeEntrada,
|
nomeEntrada: config.nomeEntrada,
|
||||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
||||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||||
nomeSaida: config.nomeSaida,
|
nomeSaida: config.nomeSaida,
|
||||||
})
|
})
|
||||||
: getTipoRegistroLabel(r.tipo),
|
: getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'),
|
||||||
formatarHoraPonto(r.hora, r.minuto),
|
formatarHoraPonto(reg.hora, reg.minuto),
|
||||||
r.dentroDoPrazo ? 'Sim' : 'Não',
|
];
|
||||||
]);
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Salvar a posição Y antes da tabela
|
||||||
const yPosAntesTabela = yPosition;
|
const yPosAntesTabela = yPosition;
|
||||||
|
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
startY: yPosition,
|
startY: yPosition,
|
||||||
head: [['Data', 'Tipo', 'Horário', 'Dentro do Prazo']],
|
head: [headers],
|
||||||
body: tableData,
|
body: tableData,
|
||||||
theme: 'grid',
|
theme: 'grid',
|
||||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||||
styles: { fontSize: 9 },
|
styles: { fontSize: 9 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Obter banco de horas do funcionário
|
|
||||||
const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, {
|
|
||||||
funcionarioId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calcular posição Y após a tabela
|
// Calcular posição Y após a tabela
|
||||||
// autoTable armazena a posição final em doc.lastAutoTable.finalY
|
|
||||||
const lastPage = doc.getNumberOfPages();
|
const lastPage = doc.getNumberOfPages();
|
||||||
doc.setPage(lastPage);
|
doc.setPage(lastPage);
|
||||||
const finalY = (doc as any).lastAutoTable?.finalY;
|
const finalY = (doc as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY;
|
||||||
|
|
||||||
// Se não conseguir obter a posição final, estimar baseado no número de linhas
|
|
||||||
if (finalY) {
|
if (finalY) {
|
||||||
yPosition = finalY;
|
yPosition = finalY;
|
||||||
} else {
|
} else {
|
||||||
// Estimativa: cada linha da tabela ocupa aproximadamente 7mm
|
const linhasTabela = tableData.length + 1;
|
||||||
const linhasTabela = tableData.length + 1; // +1 para o cabeçalho
|
yPosition = yPosAntesTabela + linhasTabela * 7 + 10;
|
||||||
yPosition = yPosAntesTabela + (linhasTabela * 7) + 10;
|
}
|
||||||
|
yPosition += 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adicionar espaço antes do resumo
|
// Banco de Horas
|
||||||
yPosition += 10;
|
if (sections.bancoHoras) {
|
||||||
|
|
||||||
// Verificar se precisa de nova página
|
|
||||||
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
|
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
|
||||||
doc.addPage();
|
doc.addPage();
|
||||||
yPosition = 20;
|
yPosition = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resumo do Banco de Horas
|
const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, {
|
||||||
|
funcionarioId,
|
||||||
|
});
|
||||||
|
|
||||||
doc.setFontSize(12);
|
doc.setFontSize(12);
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.setTextColor(41, 128, 185);
|
doc.setTextColor(41, 128, 185);
|
||||||
@@ -222,17 +467,15 @@
|
|||||||
const sinal = saldoMinutos >= 0 ? '+' : '-';
|
const sinal = saldoMinutos >= 0 ? '+' : '-';
|
||||||
const saldoFormatado = `${sinal}${horas}h ${minutos}min`;
|
const saldoFormatado = `${sinal}${horas}h ${minutos}min`;
|
||||||
|
|
||||||
// Saldo Atual
|
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.text('Saldo Atual:', 15, yPosition);
|
doc.text('Saldo Atual:', 15, yPosition);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
doc.text(saldoFormatado, 60, yPosition);
|
doc.text(saldoFormatado, 60, yPosition);
|
||||||
yPosition += 8;
|
yPosition += 8;
|
||||||
|
|
||||||
// Horas Excedentes (se positivo)
|
|
||||||
if (saldoMinutos > 0) {
|
if (saldoMinutos > 0) {
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.setTextColor(0, 128, 0); // Verde
|
doc.setTextColor(0, 128, 0);
|
||||||
doc.text('Horas Excedentes:', 15, yPosition);
|
doc.text('Horas Excedentes:', 15, yPosition);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
doc.text(`${horas}h ${minutos}min`, 75, yPosition);
|
doc.text(`${horas}h ${minutos}min`, 75, yPosition);
|
||||||
@@ -240,10 +483,9 @@
|
|||||||
yPosition += 8;
|
yPosition += 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horas a Pagar (se negativo)
|
|
||||||
if (saldoMinutos < 0) {
|
if (saldoMinutos < 0) {
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.setTextColor(200, 0, 0); // Vermelho
|
doc.setTextColor(200, 0, 0);
|
||||||
doc.text('Horas a Pagar:', 15, yPosition);
|
doc.text('Horas a Pagar:', 15, yPosition);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
doc.text(`${horas}h ${minutos}min`, 70, yPosition);
|
doc.text(`${horas}h ${minutos}min`, 70, yPosition);
|
||||||
@@ -251,13 +493,130 @@
|
|||||||
yPosition += 8;
|
yPosition += 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total de dias registrados
|
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.text('Total de Dias com Registro:', 15, yPosition);
|
doc.text('Total de Dias com Registro:', 15, yPosition);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
doc.text(`${bancoHoras.totalDias} dias`, 95, yPosition);
|
doc.text(`${bancoHoras.totalDias} dias`, 95, yPosition);
|
||||||
|
yPosition += 10;
|
||||||
} else {
|
} else {
|
||||||
doc.text('Banco de horas não disponível', 15, yPosition);
|
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é
|
// Rodapé
|
||||||
@@ -277,9 +636,15 @@
|
|||||||
// Salvar
|
// Salvar
|
||||||
const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`;
|
const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`;
|
||||||
doc.save(nomeArquivo);
|
doc.save(nomeArquivo);
|
||||||
|
|
||||||
|
// Fechar modal após gerar PDF
|
||||||
|
mostrarModalImpressao = false;
|
||||||
|
funcionarioParaImprimir = '';
|
||||||
|
toast.success('PDF gerado com sucesso!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao gerar PDF:', 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
|
<button
|
||||||
class="btn btn-sm btn-primary"
|
class="btn btn-sm btn-primary"
|
||||||
onclick={() => imprimirFichaPonto(grupo.funcionarioId)}
|
onclick={() => abrirModalImpressao(grupo.funcionarioId)}
|
||||||
>
|
>
|
||||||
<Printer class="h-4 w-4" />
|
<Printer class="h-4 w-4" />
|
||||||
Imprimir Ficha
|
Imprimir Ficha
|
||||||
@@ -883,14 +1248,19 @@
|
|||||||
<th>Data</th>
|
<th>Data</th>
|
||||||
<th>Tipo</th>
|
<th>Tipo</th>
|
||||||
<th>Horário</th>
|
<th>Horário</th>
|
||||||
|
<th>Saldo Diário</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Ações</th>
|
<th>Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each grupo.registros as registro}
|
{#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>
|
<tr>
|
||||||
<td>{registro.data}</td>
|
<td>{dataFormatada}</td>
|
||||||
<td>
|
<td>
|
||||||
{config
|
{config
|
||||||
? getTipoRegistroLabel(registro.tipo, {
|
? getTipoRegistroLabel(registro.tipo, {
|
||||||
@@ -902,6 +1272,19 @@
|
|||||||
: getTipoRegistroLabel(registro.tipo)}
|
: getTipoRegistroLabel(registro.tipo)}
|
||||||
</td>
|
</td>
|
||||||
<td>{formatarHoraPonto(registro.hora, registro.minuto)}</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>
|
<td>
|
||||||
<span
|
<span
|
||||||
class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}"
|
class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}"
|
||||||
@@ -921,6 +1304,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -933,3 +1317,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if mostrarModalImpressao && funcionarioParaImprimir}
|
||||||
|
<PrintPontoModal
|
||||||
|
funcionarioId={funcionarioParaImprimir}
|
||||||
|
onClose={() => {
|
||||||
|
mostrarModalImpressao = false;
|
||||||
|
funcionarioParaImprimir = '';
|
||||||
|
}}
|
||||||
|
onGenerate={gerarPDFComSelecao}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,55 @@ export const registrarPonto = mutation({
|
|||||||
throw new Error('Já existe um registro neste minuto');
|
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
|
// Determinar tipo de registro
|
||||||
const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data);
|
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)
|
* Lista registros por período (para RH)
|
||||||
*/
|
*/
|
||||||
@@ -313,8 +401,32 @@ export const listarRegistrosPeriodo = query({
|
|||||||
Array.from(funcionariosIds).map((id) => ctx.db.get(id))
|
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) => {
|
return registrosFiltrados.map((registro) => {
|
||||||
const funcionario = funcionarios.find((f) => f?._id === registro.funcionarioId);
|
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 {
|
return {
|
||||||
...registro,
|
...registro,
|
||||||
funcionario: funcionario
|
funcionario: funcionario
|
||||||
@@ -324,6 +436,12 @@ export const listarRegistrosPeriodo = query({
|
|||||||
descricaoCargo: funcionario.descricaoCargo,
|
descricaoCargo: funcionario.descricaoCargo,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
saldoDiario: {
|
||||||
|
saldoMinutos,
|
||||||
|
horas,
|
||||||
|
minutos,
|
||||||
|
positivo,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -613,11 +731,20 @@ export const obterHistoricoESaldoDia = query({
|
|||||||
const horasTrabalhadas = calcularHorasTrabalhadas(registros);
|
const horasTrabalhadas = calcularHorasTrabalhadas(registros);
|
||||||
const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria;
|
const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria;
|
||||||
|
|
||||||
|
const horas = Math.floor(Math.abs(saldoMinutos) / 60);
|
||||||
|
const minutos = Math.abs(saldoMinutos) % 60;
|
||||||
|
const positivo = saldoMinutos >= 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
registros,
|
registros,
|
||||||
cargaHorariaDiaria,
|
cargaHorariaDiaria,
|
||||||
horasTrabalhadas,
|
horasTrabalhadas,
|
||||||
saldoMinutos,
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1390,6 +1390,10 @@ export default defineSchema({
|
|||||||
// Justificativa opcional para o registro
|
// Justificativa opcional para o registro
|
||||||
justificativa: v.optional(v.string()),
|
justificativa: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Campos para homologação
|
||||||
|
editadoPorGestor: v.optional(v.boolean()),
|
||||||
|
homologacaoId: v.optional(v.id("homologacoesPonto")),
|
||||||
|
|
||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
})
|
})
|
||||||
.index("by_funcionario_data", ["funcionarioId", "data"])
|
.index("by_funcionario_data", ["funcionarioId", "data"])
|
||||||
@@ -1443,4 +1447,60 @@ export default defineSchema({
|
|||||||
.index("by_funcionario_data", ["funcionarioId", "data"])
|
.index("by_funcionario_data", ["funcionarioId", "data"])
|
||||||
.index("by_funcionario", ["funcionarioId"])
|
.index("by_funcionario", ["funcionarioId"])
|
||||||
.index("by_data", ["data"]),
|
.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"]),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user