feat: implement customizable point registration labels and GMT offset adjustment

- Added functionality to customize labels for point registration types (Entrada, Saída, etc.) in the configuration settings.
- Introduced a GMT offset adjustment feature to account for time zone differences during point registration.
- Updated the backend to ensure default values for custom labels and GMT offset are set correctly.
- Enhanced the UI to allow users to input and save personalized names for each type of point registration.
- Improved the point registration process to utilize the new configuration settings for displaying labels consistently across the application.
This commit is contained in:
2025-11-19 06:22:07 -03:00
parent f465bd973e
commit 7cdc726781
10 changed files with 311 additions and 93 deletions

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { useQuery } from 'convex-svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
import { Printer, X } from 'lucide-svelte'; import { Printer, X } from 'lucide-svelte';
@@ -15,8 +14,8 @@
let { registroId, onClose }: Props = $props(); let { registroId, onClose }: Props = $props();
const client = useConvexClient();
const registroQuery = useQuery(api.pontos.obterRegistro, { registroId }); const registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
let gerando = $state(false); let gerando = $state(false);
@@ -98,7 +97,16 @@
yPosition += 8; yPosition += 8;
doc.setFontSize(10); doc.setFontSize(10);
doc.text(`Tipo: ${getTipoRegistroLabel(registro.tipo)}`, 15, yPosition); const config = configQuery?.data;
const tipoLabel = config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo);
doc.text(`Tipo: ${tipoLabel}`, 15, yPosition);
yPosition += 6; yPosition += 6;
const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo); const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo);
@@ -118,30 +126,6 @@
); );
yPosition += 10; yPosition += 10;
// Detalhes de localização removidos do comprovante (serão exibidos apenas no relatório detalhado)
// Informações do Dispositivo (resumido)
if (registro.browser || registro.sistemaOperacional) {
doc.setFont('helvetica', 'bold');
doc.text('DISPOSITIVO', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 8;
doc.setFontSize(10);
if (registro.browser) {
doc.text(`Navegador: ${registro.browser}${registro.browserVersion ? ` ${registro.browserVersion}` : ''}`, 15, yPosition);
yPosition += 6;
}
if (registro.sistemaOperacional) {
doc.text(`Sistema: ${registro.sistemaOperacional}${registro.osVersion ? ` ${registro.osVersion}` : ''}`, 15, yPosition);
yPosition += 6;
}
if (registro.ipAddress) {
doc.text(`IP: ${registro.ipAddress}`, 15, yPosition);
yPosition += 6;
}
}
// Imagem capturada (se disponível) // Imagem capturada (se disponível)
if (registro.imagemUrl) { if (registro.imagemUrl) {
yPosition += 10; yPosition += 10;
@@ -294,7 +278,17 @@
<div class="card bg-base-200"> <div class="card bg-base-200">
<div class="card-body"> <div class="card-body">
<h4 class="font-bold">Dados do Registro</h4> <h4 class="font-bold">Dados do Registro</h4>
<p><strong>Tipo:</strong> {getTipoRegistroLabel(registro.tipo)}</p> <p>
<strong>Tipo:</strong>
{configQuery?.data
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: configQuery.data.nomeEntrada,
nomeSaidaAlmoco: configQuery.data.nomeSaidaAlmoco,
nomeRetornoAlmoco: configQuery.data.nomeRetornoAlmoco,
nomeSaida: configQuery.data.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo)}
</p>
<p> <p>
<strong>Data e Hora:</strong> <strong>Data e Hora:</strong>
{formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo)} {formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo)}

View File

@@ -37,7 +37,6 @@
// Estados // Estados
let mostrandoWebcam = $state(false); let mostrandoWebcam = $state(false);
let registrando = $state(false); let registrando = $state(false);
let erro = $state<string | null>(null);
let sucesso = $state<string | null>(null); let sucesso = $state<string | null>(null);
let registroId = $state<Id<'registrosPonto'> | null>(null); let registroId = $state<Id<'registrosPonto'> | null>(null);
let mostrandoComprovante = $state(false); let mostrandoComprovante = $state(false);
@@ -63,6 +62,14 @@
}); });
const tipoLabel = $derived.by(() => { const tipoLabel = $derived.by(() => {
if (config) {
return getTipoRegistroLabel(proximoTipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
});
}
return getTipoRegistroLabel(proximoTipo); return getTipoRegistroLabel(proximoTipo);
}); });
@@ -153,7 +160,6 @@
} }
registrando = true; registrando = true;
erro = null;
sucesso = null; sucesso = null;
coletandoInfo = true; coletandoInfo = true;
@@ -189,7 +195,15 @@
}); });
registroId = resultado.registroId; registroId = resultado.registroId;
sucesso = `Ponto registrado com sucesso! Tipo: ${getTipoRegistroLabel(resultado.tipo)}`; const tipoLabelSucesso = config
? getTipoRegistroLabel(resultado.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(resultado.tipo);
sucesso = `Ponto registrado com sucesso! Tipo: ${tipoLabelSucesso}`;
imagemCapturada = null; imagemCapturada = null;
justificativa = ''; // Limpar justificativa após registro justificativa = ''; // Limpar justificativa após registro
mostrandoModalConfirmacao = false; mostrandoModalConfirmacao = false;
@@ -208,7 +222,15 @@
mensagemErro.includes('já existe um registro') mensagemErro.includes('já existe um registro')
) { ) {
mensagemErroModal = 'Registro de ponto duplicado'; mensagemErroModal = 'Registro de ponto duplicado';
detalhesErroModal = `Não é possível registrar o ponto no mesmo minuto.\n\nVocê já possui um registro de ${getTipoRegistroLabel(proximoTipo)} para este minuto.\n\nPor favor, aguarde pelo menos 1 minuto antes de tentar registrar novamente.`; const tipoLabelErro = config
? getTipoRegistroLabel(proximoTipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(proximoTipo);
detalhesErroModal = `Não é possível registrar o ponto no mesmo minuto.\n\nVocê já possui um registro de ${tipoLabelErro} para este minuto.\n\nPor favor, aguarde pelo menos 1 minuto antes de tentar registrar novamente.`;
mostrarModalErro = true; mostrarModalErro = true;
} else { } else {
// Outros erros também mostram no modal // Outros erros também mostram no modal
@@ -216,8 +238,6 @@
detalhesErroModal = mensagemErro; detalhesErroModal = mensagemErro;
mostrarModalErro = true; mostrarModalErro = true;
} }
erro = mensagemErro;
} finally { } finally {
registrando = false; registrando = false;
coletandoInfo = false; coletandoInfo = false;
@@ -309,7 +329,6 @@
mostrarModalErro = false; mostrarModalErro = false;
mensagemErroModal = ''; mensagemErroModal = '';
detalhesErroModal = ''; detalhesErroModal = '';
erro = null;
} }
async function imprimirComprovante(registroId: Id<'registrosPonto'>) { async function imprimirComprovante(registroId: Id<'registrosPonto'>) {
@@ -322,6 +341,9 @@
return; return;
} }
// Buscar configuração para usar nomes personalizados
const configComprovante = await client.query(api.configuracaoPonto.obterConfiguracao, {});
const doc = new jsPDF(); const doc = new jsPDF();
// Logo // Logo
@@ -385,7 +407,15 @@
yPosition += 8; yPosition += 8;
doc.setFontSize(10); doc.setFontSize(10);
doc.text(`Tipo: ${getTipoRegistroLabel(registro.tipo)}`, 15, yPosition); const tipoLabelComprovante = configComprovante
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: configComprovante.nomeEntrada,
nomeSaidaAlmoco: configComprovante.nomeSaidaAlmoco,
nomeRetornoAlmoco: configComprovante.nomeRetornoAlmoco,
nomeSaida: configComprovante.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo);
doc.text(`Tipo: ${tipoLabelComprovante}`, 15, yPosition);
yPosition += 6; yPosition += 6;
const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo); const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo);
@@ -578,10 +608,10 @@
if (!config) return []; if (!config) return [];
const horarios = [ const horarios = [
{ tipo: 'entrada', horario: config.horarioEntrada, label: 'Entrada' }, { tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' },
{ tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: 'Saída para Almoço' }, { tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: config.nomeSaidaAlmoco || 'Saída 1' },
{ tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: 'Retorno do Almoço' }, { tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: config.nomeRetornoAlmoco || 'Entrada 2' },
{ tipo: 'saida', horario: config.horarioSaida, label: 'Saída' } { tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' }
]; ];
return horarios.map((h) => { return horarios.map((h) => {
@@ -761,7 +791,16 @@
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex-1"> <div class="flex-1">
<div class="mb-1 flex items-center gap-2"> <div class="mb-1 flex items-center gap-2">
<span class="font-semibold">{getTipoRegistroLabel(registro.tipo)}</span> <span class="font-semibold">
{config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo)}
</span>
{#if registro.dentroDoPrazo} {#if registro.dentroDoPrazo}
<CheckCircle2 class="h-4 w-4 text-success" /> <CheckCircle2 class="h-4 w-4 text-success" />
{:else} {:else}

View File

@@ -66,13 +66,34 @@ export function verificarDentroDoPrazo(
/** /**
* Obtém label do tipo de registro * Obtém label do tipo de registro
* Se config fornecida, usa os nomes personalizados, senão usa os padrões
*/ */
export function getTipoRegistroLabel(tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'): string { export function getTipoRegistroLabel(
tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida',
config?: {
nomeEntrada?: string;
nomeSaidaAlmoco?: string;
nomeRetornoAlmoco?: string;
nomeSaida?: string;
}
): string {
// Se config fornecida, usar nomes personalizados
if (config) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
entrada: 'Entrada', entrada: config.nomeEntrada || 'Entrada 1',
saida_almoco: 'Saída para Almoço', saida_almoco: config.nomeSaidaAlmoco || 'Saída 1',
retorno_almoco: 'Retorno do Almoço', retorno_almoco: config.nomeRetornoAlmoco || 'Entrada 2',
saida: 'Saída', saida: config.nomeSaida || 'Saída 2',
};
return labels[tipo] || tipo;
}
// Valores padrão
const labels: Record<string, string> = {
entrada: 'Entrada 1',
saida_almoco: 'Saída 1',
retorno_almoco: 'Entrada 2',
saida: 'Saída 2',
}; };
return labels[tipo] || tipo; return labels[tipo] || tipo;
} }

View File

@@ -32,10 +32,12 @@
const funcionariosQuery = useQuery(api.funcionarios.getAll, {}); const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams); const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams);
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, estatisticasParams); const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, estatisticasParams);
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
const funcionarios = $derived(funcionariosQuery?.data || []); const funcionarios = $derived(funcionariosQuery?.data || []);
const registros = $derived(registrosQuery?.data || []); const registros = $derived(registrosQuery?.data || []);
const estatisticas = $derived(estatisticasQuery?.data); const estatisticas = $derived(estatisticasQuery?.data);
const config = $derived(configQuery?.data);
// Agrupar registros por funcionário e data // Agrupar registros por funcionário e data
const registrosAgrupados = $derived.by(() => { const registrosAgrupados = $derived.by(() => {
@@ -146,9 +148,17 @@
yPosition += 10; yPosition += 10;
// Tabela de registros // Tabela de registros
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
const tableData = registrosFuncionario.map((r) => [ const tableData = registrosFuncionario.map((r) => [
r.data, r.data,
getTipoRegistroLabel(r.tipo), config
? getTipoRegistroLabel(r.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(r.tipo),
formatarHoraPonto(r.hora, r.minuto), formatarHoraPonto(r.hora, r.minuto),
r.dentroDoPrazo ? 'Sim' : 'Não', r.dentroDoPrazo ? 'Sim' : 'Não',
]); ]);
@@ -258,7 +268,16 @@
yPosition += 8; yPosition += 8;
doc.setFontSize(10); doc.setFontSize(10);
doc.text(`Tipo: ${getTipoRegistroLabel(registro.tipo)}`, 15, yPosition); const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
const tipoLabel = config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo);
doc.text(`Tipo: ${tipoLabel}`, 15, yPosition);
yPosition += 6; yPosition += 6;
const dataHora = `${registro.data} ${registro.hora.toString().padStart(2, '0')}:${registro.minuto.toString().padStart(2, '0')}:${registro.segundo.toString().padStart(2, '0')}`; const dataHora = `${registro.data} ${registro.hora.toString().padStart(2, '0')}:${registro.minuto.toString().padStart(2, '0')}:${registro.segundo.toString().padStart(2, '0')}`;
@@ -784,7 +803,16 @@
{#each grupo.registros as registro} {#each grupo.registros as registro}
<tr> <tr>
<td>{registro.data}</td> <td>{registro.data}</td>
<td>{getTipoRegistroLabel(registro.tipo)}</td> <td>
{config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida,
})
: getTipoRegistroLabel(registro.tipo)}
</td>
<td>{formatarHoraPonto(registro.hora, registro.minuto)}</td> <td>{formatarHoraPonto(registro.hora, registro.minuto)}</td>
<td> <td>
<span <span

View File

@@ -11,6 +11,10 @@
let horarioRetornoAlmoco = $state('13:00'); let horarioRetornoAlmoco = $state('13:00');
let horarioSaida = $state('17:00'); let horarioSaida = $state('17:00');
let toleranciaMinutos = $state(15); let toleranciaMinutos = $state(15);
let nomeEntrada = $state('Entrada 1');
let nomeSaidaAlmoco = $state('Saída 1');
let nomeRetornoAlmoco = $state('Entrada 2');
let nomeSaida = $state('Saída 2');
let processando = $state(false); let processando = $state(false);
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null); let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
@@ -21,6 +25,10 @@
horarioRetornoAlmoco = configQuery.data.horarioRetornoAlmoco; horarioRetornoAlmoco = configQuery.data.horarioRetornoAlmoco;
horarioSaida = configQuery.data.horarioSaida; horarioSaida = configQuery.data.horarioSaida;
toleranciaMinutos = configQuery.data.toleranciaMinutos; toleranciaMinutos = configQuery.data.toleranciaMinutos;
nomeEntrada = configQuery.data.nomeEntrada || 'Entrada 1';
nomeSaidaAlmoco = configQuery.data.nomeSaidaAlmoco || 'Saída 1';
nomeRetornoAlmoco = configQuery.data.nomeRetornoAlmoco || 'Entrada 2';
nomeSaida = configQuery.data.nomeSaida || 'Saída 2';
} }
}); });
@@ -43,6 +51,12 @@
return; return;
} }
// Validação dos nomes
if (!nomeEntrada.trim() || !nomeSaidaAlmoco.trim() || !nomeRetornoAlmoco.trim() || !nomeSaida.trim()) {
mostrarMensagem('error', 'Preencha todos os nomes dos registros');
return;
}
processando = true; processando = true;
try { try {
await client.mutation(api.configuracaoPonto.salvarConfiguracao, { await client.mutation(api.configuracaoPonto.salvarConfiguracao, {
@@ -51,6 +65,10 @@
horarioRetornoAlmoco, horarioRetornoAlmoco,
horarioSaida, horarioSaida,
toleranciaMinutos, toleranciaMinutos,
nomeEntrada: nomeEntrada.trim(),
nomeSaidaAlmoco: nomeSaidaAlmoco.trim(),
nomeRetornoAlmoco: nomeRetornoAlmoco.trim(),
nomeSaida: nomeSaida.trim(),
}); });
mostrarMensagem('success', 'Configuração salva com sucesso!'); mostrarMensagem('success', 'Configuração salva com sucesso!');
@@ -170,6 +188,84 @@
</div> </div>
</div> </div>
<div class="divider"></div>
<!-- Nomes Personalizados dos Registros -->
<h2 class="card-title mb-4">Nomes dos Registros</h2>
<p class="text-sm text-base-content/70 mb-4">
Personalize os nomes exibidos para cada tipo de registro de ponto
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Nome Entrada -->
<div class="form-control">
<label class="label" for="nome-entrada">
<span class="label-text font-medium">Nome do Registro de Entrada *</span>
</label>
<input
id="nome-entrada"
type="text"
bind:value={nomeEntrada}
placeholder="Ex: Entrada 1"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Nome exibido para o primeiro registro do dia</span>
</div>
</div>
<!-- Nome Saída Almoço -->
<div class="form-control">
<label class="label" for="nome-saida-almoco">
<span class="label-text font-medium">Nome do Registro de Saída para Almoço *</span>
</label>
<input
id="nome-saida-almoco"
type="text"
bind:value={nomeSaidaAlmoco}
placeholder="Ex: Saída 1"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Nome exibido para a saída para almoço</span>
</div>
</div>
<!-- Nome Retorno Almoço -->
<div class="form-control">
<label class="label" for="nome-retorno-almoco">
<span class="label-text font-medium">Nome do Registro de Retorno do Almoço *</span>
</label>
<input
id="nome-retorno-almoco"
type="text"
bind:value={nomeRetornoAlmoco}
placeholder="Ex: Entrada 2"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Nome exibido para o retorno do almoço</span>
</div>
</div>
<!-- Nome Saída -->
<div class="form-control">
<label class="label" for="nome-saida">
<span class="label-text font-medium">Nome do Registro de Saída *</span>
</label>
<input
id="nome-saida"
type="text"
bind:value={nomeSaida}
placeholder="Ex: Saída 2"
class="input input-bordered"
/>
<div class="label">
<span class="label-text-alt">Nome exibido para a saída final do dia</span>
</div>
</div>
</div>
<!-- Ações --> <!-- Ações -->
<div class="card-actions justify-end mt-6"> <div class="card-actions justify-end mt-6">
<button <button

View File

@@ -10,6 +10,7 @@
let portaNTP = $state(123); let portaNTP = $state(123);
let usarServidorExterno = $state(false); let usarServidorExterno = $state(false);
let fallbackParaPC = $state(true); let fallbackParaPC = $state(true);
let gmtOffset = $state(0);
let processando = $state(false); let processando = $state(false);
let testando = $state(false); let testando = $state(false);
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null); let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
@@ -20,6 +21,7 @@
portaNTP = configQuery.data.portaNTP || 123; portaNTP = configQuery.data.portaNTP || 123;
usarServidorExterno = configQuery.data.usarServidorExterno || false; usarServidorExterno = configQuery.data.usarServidorExterno || false;
fallbackParaPC = configQuery.data.fallbackParaPC !== undefined ? configQuery.data.fallbackParaPC : true; fallbackParaPC = configQuery.data.fallbackParaPC !== undefined ? configQuery.data.fallbackParaPC : true;
gmtOffset = configQuery.data.gmtOffset ?? 0;
} }
}); });
@@ -49,6 +51,7 @@
portaNTP: usarServidorExterno ? portaNTP : undefined, portaNTP: usarServidorExterno ? portaNTP : undefined,
usarServidorExterno, usarServidorExterno,
fallbackParaPC, fallbackParaPC,
gmtOffset,
}); });
mostrarMensagem('success', 'Configuração salva com sucesso!'); mostrarMensagem('success', 'Configuração salva com sucesso!');
@@ -194,6 +197,35 @@
</div> </div>
</div> </div>
<div class="divider"></div>
<!-- Ajuste de Fuso Horário (GMT) -->
<h2 class="card-title mb-4">Ajuste de Fuso Horário (GMT)</h2>
<p class="text-sm text-base-content/70 mb-4">
Configure o fuso horário para ajustar o horário de registro. Use valores negativos para fusos a oeste de UTC e positivos para fusos a leste.
</p>
<div class="form-control">
<label class="label" for="gmt-offset">
<span class="label-text font-medium">GMT Offset (horas) *</span>
</label>
<select
id="gmt-offset"
bind:value={gmtOffset}
class="select select-bordered"
>
{#each Array.from({ length: 49 }, (_, i) => i - 12) as offset}
<option value={offset} selected={gmtOffset === offset}>
GMT{offset >= 0 ? '+' : ''}{offset}{offset === -3 ? ' (Brasil - Brasília)' : offset === 0 ? ' (UTC)' : ''}
</option>
{/each}
</select>
<div class="label">
<span class="label-text-alt"
>Ajuste em horas em relação ao UTC. Exemplo: -3 para horário de Brasília (GMT-3)</span
>
</div>
</div>
<!-- Ações --> <!-- Ações -->
<div class="card-actions justify-end mt-6 gap-3"> <div class="card-actions justify-end mt-6 gap-3">
{#if usarServidorExterno} {#if usarServidorExterno}

View File

@@ -30,11 +30,22 @@ export const obterConfiguracao = query({
horarioRetornoAlmoco: '13:00', horarioRetornoAlmoco: '13:00',
horarioSaida: '17:00', horarioSaida: '17:00',
toleranciaMinutos: 15, toleranciaMinutos: 15,
nomeEntrada: 'Entrada 1',
nomeSaidaAlmoco: 'Saída 1',
nomeRetornoAlmoco: 'Entrada 2',
nomeSaida: 'Saída 2',
ativo: false, ativo: false,
}; };
} }
return config; // Garantir que os nomes padrão estejam definidos
return {
...config,
nomeEntrada: config.nomeEntrada || 'Entrada 1',
nomeSaidaAlmoco: config.nomeSaidaAlmoco || 'Saída 1',
nomeRetornoAlmoco: config.nomeRetornoAlmoco || 'Entrada 2',
nomeSaida: config.nomeSaida || 'Saída 2',
};
}, },
}); });
@@ -48,6 +59,10 @@ export const salvarConfiguracao = mutation({
horarioRetornoAlmoco: v.string(), horarioRetornoAlmoco: v.string(),
horarioSaida: v.string(), horarioSaida: v.string(),
toleranciaMinutos: v.number(), toleranciaMinutos: v.number(),
nomeEntrada: v.optional(v.string()),
nomeSaidaAlmoco: v.optional(v.string()),
nomeRetornoAlmoco: v.optional(v.string()),
nomeSaida: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx); const usuario = await getCurrentUserFunction(ctx);
@@ -115,6 +130,10 @@ export const salvarConfiguracao = mutation({
horarioRetornoAlmoco: args.horarioRetornoAlmoco, horarioRetornoAlmoco: args.horarioRetornoAlmoco,
horarioSaida: args.horarioSaida, horarioSaida: args.horarioSaida,
toleranciaMinutos: args.toleranciaMinutos, toleranciaMinutos: args.toleranciaMinutos,
nomeEntrada: args.nomeEntrada || 'Entrada 1',
nomeSaidaAlmoco: args.nomeSaidaAlmoco || 'Saída 1',
nomeRetornoAlmoco: args.nomeRetornoAlmoco || 'Entrada 2',
nomeSaida: args.nomeSaida || 'Saída 2',
ativo: true, ativo: true,
atualizadoPor: usuario._id as Id<'usuarios'>, atualizadoPor: usuario._id as Id<'usuarios'>,
atualizadoEm: Date.now(), atualizadoEm: Date.now(),

View File

@@ -24,10 +24,14 @@ export const obterConfiguracao = query({
fallbackParaPC: true, fallbackParaPC: true,
ultimaSincronizacao: null, ultimaSincronizacao: null,
offsetSegundos: null, offsetSegundos: null,
gmtOffset: 0,
}; };
} }
return config; return {
...config,
gmtOffset: config.gmtOffset ?? 0,
};
}, },
}); });
@@ -40,6 +44,7 @@ export const salvarConfiguracao = mutation({
portaNTP: v.optional(v.number()), portaNTP: v.optional(v.number()),
usarServidorExterno: v.boolean(), usarServidorExterno: v.boolean(),
fallbackParaPC: v.boolean(), fallbackParaPC: v.boolean(),
gmtOffset: v.optional(v.number()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx); const usuario = await getCurrentUserFunction(ctx);
@@ -72,6 +77,7 @@ export const salvarConfiguracao = mutation({
portaNTP: args.portaNTP, portaNTP: args.portaNTP,
usarServidorExterno: args.usarServidorExterno, usarServidorExterno: args.usarServidorExterno,
fallbackParaPC: args.fallbackParaPC, fallbackParaPC: args.fallbackParaPC,
gmtOffset: args.gmtOffset ?? 0,
atualizadoPor: usuario._id as Id<'usuarios'>, atualizadoPor: usuario._id as Id<'usuarios'>,
atualizadoEm: Date.now(), atualizadoEm: Date.now(),
}); });
@@ -83,6 +89,7 @@ export const salvarConfiguracao = mutation({
portaNTP: args.portaNTP, portaNTP: args.portaNTP,
usarServidorExterno: args.usarServidorExterno, usarServidorExterno: args.usarServidorExterno,
fallbackParaPC: args.fallbackParaPC, fallbackParaPC: args.fallbackParaPC,
gmtOffset: args.gmtOffset ?? 0,
atualizadoPor: usuario._id as Id<'usuarios'>, atualizadoPor: usuario._id as Id<'usuarios'>,
atualizadoEm: Date.now(), atualizadoEm: Date.now(),
}); });

View File

@@ -1,5 +1,5 @@
import { v } from 'convex/values'; import { v } from 'convex/values';
import { internalMutation, mutation, query } from './_generated/server'; import { mutation, query } from './_generated/server';
import type { MutationCtx, QueryCtx } from './_generated/server'; import type { MutationCtx, QueryCtx } from './_generated/server';
import { getCurrentUserFunction } from './auth'; import { getCurrentUserFunction } from './auth';
import type { Id } from './_generated/dataModel'; import type { Id } from './_generated/dataModel';
@@ -18,38 +18,6 @@ export const generateUploadUrl = mutation({
}, },
}); });
interface InformacoesDispositivo {
ipAddress?: string;
ipPublico?: string;
ipLocal?: string;
userAgent?: string;
browser?: string;
browserVersion?: string;
engine?: string;
sistemaOperacional?: string;
osVersion?: string;
arquitetura?: string;
plataforma?: string;
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
timezone?: string;
deviceType?: string;
deviceModel?: string;
screenResolution?: string;
coresTela?: number;
idioma?: string;
isMobile?: boolean;
isTablet?: boolean;
isDesktop?: boolean;
connectionType?: string;
memoryInfo?: string;
}
/** /**
* Calcula se o registro está dentro do prazo baseado na configuração * Calcula se o registro está dentro do prazo baseado na configuração
* Se toleranciaMinutos for 0, desconsidera atrasos (sempre retorna true) * Se toleranciaMinutos for 0, desconsidera atrasos (sempre retorna true)
@@ -175,12 +143,20 @@ export const registrarPonto = mutation({
throw new Error('Configuração de ponto não encontrada'); throw new Error('Configuração de ponto não encontrada');
} }
// Converter timestamp para data/hora // Obter configuração de ponto para GMT offset (buscar configuração ativa)
const dataObj = new Date(args.timestamp); const configPonto = await ctx.db
.query('configuracaoPonto')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
// Converter timestamp para data/hora com ajuste de GMT
const gmtOffset = configPonto?.gmtOffset ?? 0;
const timestampAjustado = args.timestamp + (gmtOffset * 60 * 60 * 1000);
const dataObj = new Date(timestampAjustado);
const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD const data = dataObj.toISOString().split('T')[0]!; // YYYY-MM-DD
const hora = dataObj.getHours(); const hora = dataObj.getUTCHours();
const minuto = dataObj.getMinutes(); const minuto = dataObj.getUTCMinutes();
const segundo = dataObj.getSeconds(); const segundo = dataObj.getUTCSeconds();
// Verificar se já existe registro no mesmo minuto // Verificar se já existe registro no mesmo minuto
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
@@ -317,7 +293,6 @@ export const listarRegistrosPeriodo = query({
// Por enquanto, permitir se tiver funcionarioId ou for admin // Por enquanto, permitir se tiver funcionarioId ou for admin
// TODO: Implementar verificação de permissão adequada // TODO: Implementar verificação de permissão adequada
const dataInicio = new Date(args.dataInicio);
const dataFim = new Date(args.dataFim); const dataFim = new Date(args.dataFim);
dataFim.setHours(23, 59, 59, 999); dataFim.setHours(23, 59, 59, 999);

View File

@@ -1403,6 +1403,11 @@ export default defineSchema({
horarioRetornoAlmoco: v.string(), // HH:mm horarioRetornoAlmoco: v.string(), // HH:mm
horarioSaida: v.string(), // HH:mm horarioSaida: v.string(), // HH:mm
toleranciaMinutos: v.number(), toleranciaMinutos: v.number(),
// Nomes personalizados dos tipos de registro
nomeEntrada: v.optional(v.string()), // Padrão: "Entrada 1"
nomeSaidaAlmoco: v.optional(v.string()), // Padrão: "Saída 1"
nomeRetornoAlmoco: v.optional(v.string()), // Padrão: "Entrada 2"
nomeSaida: v.optional(v.string()), // Padrão: "Saída 2"
ativo: v.boolean(), ativo: v.boolean(),
atualizadoPor: v.id("usuarios"), atualizadoPor: v.id("usuarios"),
atualizadoEm: v.number(), atualizadoEm: v.number(),
@@ -1416,6 +1421,8 @@ export default defineSchema({
fallbackParaPC: v.boolean(), fallbackParaPC: v.boolean(),
ultimaSincronizacao: v.optional(v.number()), ultimaSincronizacao: v.optional(v.number()),
offsetSegundos: v.optional(v.number()), offsetSegundos: v.optional(v.number()),
// Ajuste de fuso horário (GMT offset em horas)
gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC)
atualizadoPor: v.id("usuarios"), atualizadoPor: v.id("usuarios"),
atualizadoEm: v.number(), atualizadoEm: v.number(),
}) })