Feat controle ponto #35
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user