Feat controle ponto #35

Merged
deyvisonwanderley merged 7 commits from feat-controle-ponto into master 2025-11-21 15:48:44 +00:00
56 changed files with 2649 additions and 2965 deletions
Showing only changes of commit 8ea5c0316b - Show all commits

View File

@@ -34,6 +34,12 @@
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
);
// Query para verificar dispensa ativa
const dispensaQuery = useQuery(
api.pontos.verificarDispensaAtiva,
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
);
// Estados
let mostrandoWebcam = $state(false);
let registrando = $state(false);
@@ -150,6 +156,22 @@
async function registrarPonto() {
if (registrando) return;
// Verificar se tem funcionário associado
if (!temFuncionarioAssociado) {
mensagemErroModal = 'Usuário não possui funcionário associado';
detalhesErroModal = 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.';
mostrarModalErro = true;
return;
}
// Verificar se está dispensado antes de registrar
if (estaDispensado) {
mensagemErroModal = 'Registro dispensado pelo gestor';
detalhesErroModal = motivoDispensa || 'Você está dispensado de registrar ponto no momento.';
mostrarModalErro = true;
return;
}
// Verificar permissões antes de registrar
const permissoes = await verificarPermissoes();
if (!permissoes.localizacao || !permissoes.webcam) {
@@ -296,6 +318,22 @@
async function iniciarRegistroComFoto() {
if (registrando || coletandoInfo) return;
// Verificar se tem funcionário associado
if (!temFuncionarioAssociado) {
mensagemErroModal = 'Usuário não possui funcionário associado';
detalhesErroModal = 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.';
mostrarModalErro = true;
return;
}
// Verificar se está dispensado antes de abrir webcam
if (estaDispensado) {
mensagemErroModal = 'Registro dispensado pelo gestor';
detalhesErroModal = motivoDispensa || 'Você está dispensado de registrar ponto no momento.';
mostrarModalErro = true;
return;
}
// Verificar permissões antes de abrir webcam
const permissoes = await verificarPermissoes();
if (!permissoes.localizacao || !permissoes.webcam) {
@@ -542,8 +580,13 @@
}
}
const dispensaAtiva = $derived(dispensaQuery?.data);
const estaDispensado = $derived(dispensaAtiva?.dispensado ?? false);
const motivoDispensa = $derived(dispensaAtiva?.motivo ?? null);
const temFuncionarioAssociado = $derived(funcionarioId !== null);
const podeRegistrar = $derived.by(() => {
return !registrando && !coletandoInfo && config !== undefined;
return !registrando && !coletandoInfo && config !== undefined && !estaDispensado && temFuncionarioAssociado;
});
// Referência para o modal
@@ -650,6 +693,60 @@
</script>
<div class="space-y-6">
<!-- Alerta de Funcionário Não Associado -->
{#if !temFuncionarioAssociado}
<div class="alert alert-error shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h3 class="font-bold">Funcionário Não Associado</h3>
<div class="text-sm">
Você não possui um funcionário associado à sua conta.
<br />
Entre em contato com o administrador do sistema para associar um funcionário à sua conta.
</div>
</div>
</div>
{/if}
<!-- Alerta de Dispensa -->
{#if estaDispensado && motivoDispensa && temFuncionarioAssociado}
<div class="alert alert-warning shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div>
<h3 class="font-bold">Registro de Ponto Dispensado</h3>
<div class="text-sm">
Você está dispensado de registrar ponto no momento.
<br />
<strong>Motivo:</strong> {motivoDispensa}
</div>
</div>
</div>
{/if}
<!-- Relógio Sincronizado -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body items-center">
@@ -730,6 +827,11 @@
class="btn btn-primary btn-lg"
onclick={iniciarRegistroComFoto}
disabled={!podeRegistrar}
title={!temFuncionarioAssociado
? 'Você não possui funcionário associado à sua conta'
: estaDispensado
? 'Você está dispensado de registrar ponto no momento'
: ''}
>
{#if registrando}
<span class="loading loading-spinner loading-sm"></span>
@@ -738,6 +840,12 @@
{:else}
Registrando...
{/if}
{:else if !temFuncionarioAssociado}
<XCircle class="h-5 w-5" />
Funcionário Não Associado
{:else if estaDispensado}
<XCircle class="h-5 w-5" />
Registro Indisponível
{:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
<LogIn class="h-5 w-5" />
Registrar Entrada

View File

@@ -2,7 +2,7 @@
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 { Clock, Edit, TrendingUp, TrendingDown, Save, X, Trash2, Eye, MoreVertical } 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';
@@ -14,6 +14,10 @@
let registroSelecionado = $state<Id<'registrosPonto'> | ''>('');
let modoEdicao = $state(false);
let abaAtiva = $state<'editar' | 'ajustar'>('editar');
let homologacaoDetalhada = $state<Id<'homologacoesPonto'> | null>(null);
let homologacaoParaExcluir = $state<Id<'homologacoesPonto'> | null>(null);
let mostrandoModalDetalhes = $state(false);
let mostrandoModalExcluir = $state(false);
// Formulário de edição
let horaNova = $state(8);
@@ -112,6 +116,9 @@
const homologacoes = $derived(homologacoesQuery?.data || []);
const registros = $derived(registrosQuery?.data || []);
// Verificar se é gestor (tem subordinados)
const isGestor = $derived(subordinados.length > 0);
// Lista de funcionários do time
const funcionarios = $derived.by(() => {
const funcs: Array<{ _id: Id<'funcionarios'>; nome: string; matricula?: string }> = [];
@@ -142,7 +149,7 @@
motivoDescricao = '';
observacoes = '';
modoEdicao = true;
modoAjuste = false;
abaAtiva = 'editar';
}
function abrirEdicaoComAjuste(registroId: Id<'registrosPonto'>) {
@@ -253,6 +260,61 @@
toast.error(`Erro ao ajustar banco de horas: ${errorMessage}`);
}
}
function abrirDetalhes(homologacaoId: Id<'homologacoesPonto'>) {
homologacaoDetalhada = homologacaoId;
mostrandoModalDetalhes = true;
}
function fecharDetalhes() {
mostrandoModalDetalhes = false;
homologacaoDetalhada = null;
}
function abrirModalExcluir(homologacaoId: Id<'homologacoesPonto'>) {
homologacaoParaExcluir = homologacaoId;
mostrandoModalExcluir = true;
}
function fecharModalExcluir() {
mostrandoModalExcluir = false;
homologacaoParaExcluir = null;
}
async function excluirHomologacao() {
if (!homologacaoParaExcluir) return;
try {
await client.mutation(api.pontos.excluirHomologacao, {
homologacaoId: homologacaoParaExcluir,
});
toast.success('Homologação excluída com sucesso');
fecharModalExcluir();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
toast.error(`Erro ao excluir homologação: ${errorMessage}`);
}
}
function editarHomologacao(homologacaoId: Id<'homologacoesPonto'>) {
const homologacao = homologacoes.find((h) => h._id === homologacaoId);
if (!homologacao) return;
// Se for edição de registro, abrir edição do registro
if (homologacao.registroId) {
funcionarioSelecionado = homologacao.funcionarioId;
abrirEdicaoComAjuste(homologacao.registroId);
} else {
// Se for ajuste de banco de horas, não há como editar diretamente
toast.info('Ajustes de banco de horas não podem ser editados. Crie um novo ajuste para corrigir.');
}
}
const homologacaoSelecionada = $derived.by(() => {
if (!homologacaoDetalhada) return null;
return homologacoes.find((h) => h._id === homologacaoDetalhada) || null;
});
</script>
<div class="container mx-auto px-4 py-6">
@@ -667,6 +729,9 @@
<th>Detalhes</th>
<th>Motivo</th>
<th>Observações</th>
{#if isGestor}
<th>Ações</th>
{/if}
</tr>
</thead>
<tbody>
@@ -723,6 +788,35 @@
{homologacao.observacoes || '-'}
</div>
</td>
{#if isGestor}
<td>
<div class="flex gap-1">
<button
class="btn btn-sm btn-outline btn-info btn-square hover:btn-info hover:scale-110 transition-transform"
onclick={() => abrirDetalhes(homologacao._id)}
title="Ver detalhes"
>
<Eye class="h-4 w-4" />
</button>
{#if homologacao.registroId}
<button
class="btn btn-sm btn-square bg-primary/10 text-primary border-primary/20 hover:bg-primary/20 hover:border-primary/40 hover:shadow-md transition-all"
onclick={() => editarHomologacao(homologacao._id)}
title="Editar"
>
<Edit class="h-4 w-4" />
</button>
{/if}
<button
class="btn btn-sm btn-square bg-error/10 text-error border-error/20 hover:bg-error/20 hover:border-error/40 hover:shadow-md transition-all"
onclick={() => abrirModalExcluir(homologacao._id)}
title="Excluir"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</td>
{/if}
</tr>
{/each}
</tbody>
@@ -732,5 +826,167 @@
</div>
</div>
{/if}
<!-- Modal de Detalhes da Homologação -->
{#if mostrandoModalDetalhes && homologacaoSelecionada}
<div class="modal modal-open">
<div class="modal-box max-w-3xl">
<h3 class="font-bold text-lg mb-4">Detalhes da Homologação</h3>
<div class="space-y-4">
<!-- Informações Gerais -->
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="font-semibold mb-3">Informações Gerais</h4>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium">Data:</span>{' '}
{new Date(homologacaoSelecionada.criadoEm).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
<div>
<span class="font-medium">Funcionário:</span>{' '}
{homologacaoSelecionada.funcionario?.nome || '-'}
{#if homologacaoSelecionada.funcionario?.matricula}
<br />
<span class="text-xs text-base-content/70">
Mat: {homologacaoSelecionada.funcionario.matricula}
</span>
{/if}
</div>
<div>
<span class="font-medium">Gestor:</span>{' '}
{homologacaoSelecionada.gestor?.nome || '-'}
</div>
<div>
<span class="font-medium">Tipo:</span>{' '}
{#if homologacaoSelecionada.registroId}
<span class="badge badge-info">Edição de Registro</span>
{:else if homologacaoSelecionada.tipoAjuste}
<span class="badge badge-warning">
Ajuste: {homologacaoSelecionada.tipoAjuste}
</span>
{/if}
</div>
</div>
</div>
</div>
<!-- Detalhes do Registro ou Ajuste -->
{#if homologacaoSelecionada.registroId}
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="font-semibold mb-3">Edição de Registro</h4>
<div class="text-sm space-y-2">
<div>
<span class="font-medium">Horário Anterior:</span>{' '}
<span class="line-through opacity-70">
{formatarHoraPonto(homologacaoSelecionada.horaAnterior || 0, homologacaoSelecionada.minutoAnterior || 0)}
</span>
</div>
<div>
<span class="font-medium">Horário Novo:</span>{' '}
<span>
{formatarHoraPonto(homologacaoSelecionada.horaNova || 0, homologacaoSelecionada.minutoNova || 0)}
</span>
</div>
{#if homologacaoSelecionada.registro}
<div>
<span class="font-medium">Data do Registro:</span>{' '}
{new Date(homologacaoSelecionada.registro.data).toLocaleDateString('pt-BR')}
</div>
<div>
<span class="font-medium">Tipo de Registro:</span>{' '}
{getTipoRegistroLabel(homologacaoSelecionada.registro.tipo)}
</div>
{/if}
</div>
</div>
</div>
{:else if homologacaoSelecionada.tipoAjuste}
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="font-semibold mb-3">Ajuste de Banco de Horas</h4>
<div class="text-sm space-y-2">
<div>
<span class="font-medium">Tipo de Ajuste:</span>{' '}
{homologacaoSelecionada.tipoAjuste}
</div>
<div>
<span class="font-medium">Período:</span>{' '}
{homologacaoSelecionada.periodoDias || 0}d {homologacaoSelecionada.periodoHoras || 0}h{' '}
{homologacaoSelecionada.periodoMinutos || 0}min
</div>
{#if homologacaoSelecionada.ajusteMinutos}
<div>
<span class="font-medium">Ajuste Total:</span>{' '}
{homologacaoSelecionada.ajusteMinutos > 0 ? '+' : ''}
{Math.floor(Math.abs(homologacaoSelecionada.ajusteMinutos) / 60)}h{' '}
{Math.abs(homologacaoSelecionada.ajusteMinutos) % 60}min
</div>
{/if}
</div>
</div>
</div>
{/if}
<!-- Motivo e Observações -->
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="font-semibold mb-3">Motivo e Observações</h4>
<div class="text-sm space-y-2">
<div>
<span class="font-medium">Tipo de Motivo:</span>{' '}
{homologacaoSelecionada.motivoTipo || '-'}
</div>
<div>
<span class="font-medium">Descrição:</span>{' '}
{homologacaoSelecionada.motivoDescricao || '-'}
</div>
{#if homologacaoSelecionada.observacoes}
<div>
<span class="font-medium">Observações:</span>
<div class="mt-1 p-2 bg-base-100 rounded">
{homologacaoSelecionada.observacoes}
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="modal-action">
<button class="btn" onclick={fecharDetalhes}>Fechar</button>
</div>
</div>
<div class="modal-backdrop" onclick={fecharDetalhes}></div>
</div>
{/if}
<!-- Modal de Confirmação de Exclusão -->
{#if mostrandoModalExcluir && homologacaoParaExcluir}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Confirmar Exclusão</h3>
<p class="mb-4">
Tem certeza que deseja excluir esta homologação? Esta ação não pode ser desfeita.
</p>
<div class="modal-action">
<button class="btn btn-ghost" onclick={fecharModalExcluir}>Cancelar</button>
<button class="btn btn-error" onclick={excluirHomologacao}>
<Trash2 class="h-4 w-4 mr-2" />
Excluir
</button>
</div>
</div>
<div class="modal-backdrop" onclick={fecharModalExcluir}></div>
</div>
{/if}
</div>

View File

@@ -1052,6 +1052,48 @@ export const listarHomologacoes = query({
},
});
/**
* Exclui uma homologação (apenas para gestores)
*/
export const excluirHomologacao = mutation({
args: {
homologacaoId: v.id('homologacoesPonto'),
},
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
const homologacao = await ctx.db.get(args.homologacaoId);
if (!homologacao) {
throw new Error('Homologação não encontrada');
}
// Verificar se é gestor do funcionário
const isGestor = await verificarGestorDoFuncionario(ctx, usuario._id, homologacao.funcionarioId);
if (!isGestor && homologacao.gestorId !== usuario._id) {
throw new Error('Você não tem permissão para excluir esta homologação');
}
// Se a homologação estiver vinculada a um registro, remover a referência
if (homologacao.registroId) {
const registro = await ctx.db.get(homologacao.registroId);
if (registro && registro.homologacaoId === args.homologacaoId) {
await ctx.db.patch(homologacao.registroId, {
homologacaoId: undefined,
editadoPorGestor: false,
});
}
}
// Excluir homologação
await ctx.db.delete(args.homologacaoId);
return { success: true };
},
});
/**
* Obtém opções de motivos de atestados/declarações
*/