Feat controle ponto #29
344
apps/web/src/lib/components/ponto/ComprovantePonto.svelte
Normal file
344
apps/web/src/lib/components/ponto/ComprovantePonto.svelte
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import { Printer, X } from 'lucide-svelte';
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto';
|
||||||
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
registroId: Id<'registrosPonto'>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { registroId, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
|
||||||
|
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||||
|
|
||||||
|
let gerando = $state(false);
|
||||||
|
|
||||||
|
async function gerarPDF() {
|
||||||
|
if (!registroQuery?.data) return;
|
||||||
|
|
||||||
|
gerando = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registro = registroQuery.data;
|
||||||
|
const doc = new jsPDF();
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
let yPosition = 20;
|
||||||
|
try {
|
||||||
|
const logoImg = new Image();
|
||||||
|
logoImg.src = logoGovPE;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
logoImg.onload = () => resolve();
|
||||||
|
logoImg.onerror = () => reject();
|
||||||
|
setTimeout(() => reject(), 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoWidth = 25;
|
||||||
|
const aspectRatio = logoImg.height / logoImg.width;
|
||||||
|
const logoHeight = logoWidth * aspectRatio;
|
||||||
|
|
||||||
|
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||||
|
yPosition = Math.max(20, 10 + logoHeight / 2);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Não foi possível carregar a logo:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cabeçalho
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setTextColor(41, 128, 185);
|
||||||
|
doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, { align: 'center' });
|
||||||
|
|
||||||
|
yPosition += 15;
|
||||||
|
|
||||||
|
// Informações do Funcionário
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
if (registro.funcionario) {
|
||||||
|
if (registro.funcionario.matricula) {
|
||||||
|
doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
if (registro.funcionario.descricaoCargo) {
|
||||||
|
doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
if (registro.funcionario.simbolo) {
|
||||||
|
doc.text(
|
||||||
|
`Símbolo: ${registro.funcionario.simbolo.nome} (${registro.funcionario.simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'})`,
|
||||||
|
15,
|
||||||
|
yPosition
|
||||||
|
);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// Informações do Registro
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('DADOS DO REGISTRO', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo);
|
||||||
|
doc.text(`Data e Hora: ${dataHora}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(
|
||||||
|
`Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`,
|
||||||
|
15,
|
||||||
|
yPosition
|
||||||
|
);
|
||||||
|
yPosition += 10;
|
||||||
|
|
||||||
|
// Imagem capturada (se disponível)
|
||||||
|
if (registro.imagemUrl) {
|
||||||
|
yPosition += 10;
|
||||||
|
// Verificar se precisa de nova página
|
||||||
|
if (yPosition > 200) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' });
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 10;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Carregar imagem usando fetch para evitar problemas de CORS
|
||||||
|
const response = await fetch(registro.imagemUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Erro ao carregar imagem');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
// Converter blob para base64
|
||||||
|
const base64 = await new Promise<string>((resolve, reject) => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
resolve(reader.result);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Erro ao converter imagem'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Erro ao ler imagem'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar elemento de imagem para obter dimensões
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => reject(new Error('Erro ao processar imagem'));
|
||||||
|
img.src = base64;
|
||||||
|
setTimeout(() => reject(new Error('Timeout ao processar imagem')), 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular dimensões para caber na página (largura máxima 80mm, manter proporção)
|
||||||
|
const maxWidth = 80;
|
||||||
|
const maxHeight = 60;
|
||||||
|
let imgWidth = img.width;
|
||||||
|
let imgHeight = img.height;
|
||||||
|
const aspectRatio = imgWidth / imgHeight;
|
||||||
|
|
||||||
|
if (imgWidth > maxWidth || imgHeight > maxHeight) {
|
||||||
|
if (aspectRatio > 1) {
|
||||||
|
// Imagem horizontal
|
||||||
|
imgWidth = maxWidth;
|
||||||
|
imgHeight = maxWidth / aspectRatio;
|
||||||
|
} else {
|
||||||
|
// Imagem vertical
|
||||||
|
imgHeight = maxHeight;
|
||||||
|
imgWidth = maxHeight * aspectRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centralizar imagem
|
||||||
|
const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2;
|
||||||
|
|
||||||
|
// Verificar se cabe na página atual
|
||||||
|
if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar imagem ao PDF usando base64
|
||||||
|
doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight);
|
||||||
|
yPosition += imgHeight + 10;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao adicionar imagem ao PDF:', error);
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.text('Foto não disponível para impressão', 105, yPosition, { align: 'center' });
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rodapé
|
||||||
|
const pageCount = doc.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(128, 128, 128);
|
||||||
|
doc.text(
|
||||||
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||||
|
doc.internal.pageSize.getWidth() / 2,
|
||||||
|
doc.internal.pageSize.getHeight() - 10,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salvar
|
||||||
|
const nomeArquivo = `comprovante-ponto-${registro.data}-${registro.hora}${registro.minuto.toString().padStart(2, '0')}.pdf`;
|
||||||
|
doc.save(nomeArquivo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar PDF:', error);
|
||||||
|
alert('Erro ao gerar comprovante PDF. Tente novamente.');
|
||||||
|
} finally {
|
||||||
|
gerando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="modal modal-open" style="display: flex; align-items: center; justify-content: center;">
|
||||||
|
<div class="modal-box max-w-2xl w-[95%] max-h-[85vh] overflow-hidden flex flex-col" style="margin: auto; max-height: 85vh;">
|
||||||
|
<!-- Header fixo -->
|
||||||
|
<div class="flex items-center justify-between mb-4 pb-4 border-b border-base-300 flex-shrink-0">
|
||||||
|
<h3 class="font-bold text-lg">Comprovante de Registro de Ponto</h3>
|
||||||
|
<button class="btn btn-sm btn-circle btn-ghost hover:bg-base-300" onclick={onClose}>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conteúdo com rolagem -->
|
||||||
|
<div class="flex-1 overflow-y-auto pr-2">
|
||||||
|
{#if registroQuery === undefined}
|
||||||
|
<div class="flex justify-center items-center py-8">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
{:else if !registroQuery?.data}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>Erro ao carregar registro</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@const registro = registroQuery.data}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Informações do Funcionário -->
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="font-bold">Dados do Funcionário</h4>
|
||||||
|
{#if registro.funcionario}
|
||||||
|
<p><strong>Matrícula:</strong> {registro.funcionario.matricula || 'N/A'}</p>
|
||||||
|
<p><strong>Nome:</strong> {registro.funcionario.nome}</p>
|
||||||
|
{#if registro.funcionario.descricaoCargo}
|
||||||
|
<p><strong>Cargo/Função:</strong> {registro.funcionario.descricaoCargo}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informações do Registro -->
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="font-bold">Dados do Registro</h4>
|
||||||
|
<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>
|
||||||
|
<strong>Data e Hora:</strong>
|
||||||
|
{formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Status:</strong>
|
||||||
|
<span class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}">
|
||||||
|
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p><strong>Tolerância:</strong> {registro.toleranciaMinutos} minutos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Imagem Capturada -->
|
||||||
|
{#if registro.imagemUrl}
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="font-bold mb-2">Foto Capturada</h4>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<img
|
||||||
|
src={registro.imagemUrl}
|
||||||
|
alt="Foto do registro de ponto"
|
||||||
|
class="max-w-full max-h-[250px] rounded-lg border-2 border-primary object-contain"
|
||||||
|
onerror={(e) => {
|
||||||
|
console.error('Erro ao carregar imagem:', e);
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer fixo com botões -->
|
||||||
|
<div class="flex justify-end gap-2 pt-4 mt-4 border-t border-base-300 flex-shrink-0">
|
||||||
|
<button class="btn btn-primary gap-2" onclick={gerarPDF} disabled={gerando}>
|
||||||
|
{#if gerando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
<Printer class="h-5 w-5" />
|
||||||
|
{/if}
|
||||||
|
Imprimir Comprovante
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline" onclick={onClose}>Fechar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" onclick={onClose}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
1046
apps/web/src/lib/components/ponto/RegistroPonto.svelte
Normal file
1046
apps/web/src/lib/components/ponto/RegistroPonto.svelte
Normal file
File diff suppressed because it is too large
Load Diff
118
apps/web/src/lib/components/ponto/RelogioSincronizado.svelte
Normal file
118
apps/web/src/lib/components/ponto/RelogioSincronizado.svelte
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import { obterTempoServidor, obterTempoPC } from '$lib/utils/sincronizacaoTempo';
|
||||||
|
import { CheckCircle2, AlertCircle, Clock } from 'lucide-svelte';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
let tempoAtual = $state<Date>(new Date());
|
||||||
|
let sincronizado = $state(false);
|
||||||
|
let usandoServidorExterno = $state(false);
|
||||||
|
let offsetSegundos = $state(0);
|
||||||
|
let erro = $state<string | null>(null);
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async function atualizarTempo() {
|
||||||
|
try {
|
||||||
|
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||||
|
|
||||||
|
if (config.usarServidorExterno) {
|
||||||
|
try {
|
||||||
|
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||||
|
if (resultado.sucesso && resultado.timestamp) {
|
||||||
|
tempoAtual = new Date(resultado.timestamp);
|
||||||
|
sincronizado = true;
|
||||||
|
usandoServidorExterno = resultado.usandoServidorExterno || false;
|
||||||
|
offsetSegundos = resultado.offsetSegundos || 0;
|
||||||
|
erro = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao sincronizar:', error);
|
||||||
|
if (config.fallbackParaPC) {
|
||||||
|
tempoAtual = new Date(obterTempoPC());
|
||||||
|
sincronizado = false;
|
||||||
|
usandoServidorExterno = false;
|
||||||
|
erro = 'Usando relógio do PC (falha na sincronização)';
|
||||||
|
} else {
|
||||||
|
erro = 'Falha ao sincronizar tempo';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Usar tempo do servidor Convex
|
||||||
|
const tempoServidor = await obterTempoServidor(client);
|
||||||
|
tempoAtual = new Date(tempoServidor);
|
||||||
|
sincronizado = true;
|
||||||
|
usandoServidorExterno = false;
|
||||||
|
erro = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao obter tempo:', error);
|
||||||
|
tempoAtual = new Date(obterTempoPC());
|
||||||
|
sincronizado = false;
|
||||||
|
erro = 'Erro ao obter tempo do servidor';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function atualizarRelogio() {
|
||||||
|
// Atualizar segundo a segundo
|
||||||
|
const agora = new Date(tempoAtual.getTime() + 1000);
|
||||||
|
tempoAtual = agora;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await atualizarTempo();
|
||||||
|
// Sincronizar a cada 30 segundos
|
||||||
|
setInterval(atualizarTempo, 30000);
|
||||||
|
// Atualizar display a cada segundo
|
||||||
|
intervalId = setInterval(atualizarRelogio, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const horaFormatada = $derived.by(() => {
|
||||||
|
return tempoAtual.toLocaleTimeString('pt-BR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataFormatada = $derived.by(() => {
|
||||||
|
return tempoAtual.toLocaleDateString('pt-BR', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<div class="text-4xl font-bold font-mono text-primary">{horaFormatada}</div>
|
||||||
|
<div class="text-sm text-base-content/70 capitalize">{dataFormatada}</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
{#if sincronizado}
|
||||||
|
<CheckCircle2 class="h-4 w-4 text-success" />
|
||||||
|
<span class="text-success">
|
||||||
|
{#if usandoServidorExterno}
|
||||||
|
Sincronizado com servidor NTP
|
||||||
|
{:else}
|
||||||
|
Sincronizado com servidor
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{:else if erro}
|
||||||
|
<AlertCircle class="h-4 w-4 text-warning" />
|
||||||
|
<span class="text-warning">{erro}</span>
|
||||||
|
{:else}
|
||||||
|
<Clock class="h-4 w-4 text-base-content/50" />
|
||||||
|
<span class="text-base-content/50">Sincronizando...</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
650
apps/web/src/lib/components/ponto/WebcamCapture.svelte
Normal file
650
apps/web/src/lib/components/ponto/WebcamCapture.svelte
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { Camera, X, Check, AlertCircle } from 'lucide-svelte';
|
||||||
|
import { validarWebcamDisponivel, capturarWebcamComPreview } from '$lib/utils/webcam';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onCapture: (blob: Blob | null) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onError?: () => void;
|
||||||
|
autoCapture?: boolean;
|
||||||
|
fotoObrigatoria?: boolean; // Se true, não permite continuar sem foto
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onCapture, onCancel, onError, autoCapture = false, fotoObrigatoria = false }: Props = $props();
|
||||||
|
|
||||||
|
let videoElement: HTMLVideoElement | null = $state(null);
|
||||||
|
let canvasElement: HTMLCanvasElement | null = $state(null);
|
||||||
|
let stream: MediaStream | null = $state(null);
|
||||||
|
let webcamDisponivel = $state(false);
|
||||||
|
let capturando = $state(false);
|
||||||
|
let erro = $state<string | null>(null);
|
||||||
|
let previewUrl = $state<string | null>(null);
|
||||||
|
let videoReady = $state(false);
|
||||||
|
|
||||||
|
// Efeito para garantir que o vídeo seja exibido quando o stream for atribuído
|
||||||
|
$effect(() => {
|
||||||
|
if (stream && videoElement) {
|
||||||
|
// Sempre atualizar srcObject quando o stream mudar
|
||||||
|
if (videoElement.srcObject !== stream) {
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar reproduzir se ainda não estiver pronto
|
||||||
|
if (!videoReady && videoElement.readyState < 2) {
|
||||||
|
videoElement.play()
|
||||||
|
.then(() => {
|
||||||
|
// Aguardar um pouco para garantir que o vídeo esteja realmente reproduzindo
|
||||||
|
setTimeout(() => {
|
||||||
|
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||||
|
videoReady = true;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('Erro ao reproduzir vídeo no effect:', err);
|
||||||
|
});
|
||||||
|
} else if (videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||||
|
videoReady = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Aguardar mais tempo para garantir que os elementos estejam no DOM
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// Verificar suporte
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
// Tentar método alternativo (navegadores antigos)
|
||||||
|
const getUserMedia =
|
||||||
|
navigator.getUserMedia ||
|
||||||
|
(navigator as any).webkitGetUserMedia ||
|
||||||
|
(navigator as any).mozGetUserMedia ||
|
||||||
|
(navigator as any).msGetUserMedia;
|
||||||
|
|
||||||
|
if (!getUserMedia) {
|
||||||
|
erro = 'Webcam não suportada';
|
||||||
|
if (autoCapture && onError) {
|
||||||
|
onError();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primeiro, tentar acessar a webcam antes de verificar o elemento
|
||||||
|
// Isso garante que temos permissão antes de tentar renderizar o vídeo
|
||||||
|
try {
|
||||||
|
// Tentar diferentes configurações de webcam
|
||||||
|
const constraints = [
|
||||||
|
{
|
||||||
|
video: {
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
facingMode: 'user'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
video: {
|
||||||
|
width: { ideal: 640 },
|
||||||
|
height: { ideal: 480 },
|
||||||
|
facingMode: 'user'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
video: {
|
||||||
|
facingMode: 'user'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
video: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let ultimoErro: Error | null = null;
|
||||||
|
let streamObtido = false;
|
||||||
|
|
||||||
|
for (const constraint of constraints) {
|
||||||
|
try {
|
||||||
|
console.log('Tentando acessar webcam com constraint:', constraint);
|
||||||
|
const tempStream = await navigator.mediaDevices.getUserMedia(constraint);
|
||||||
|
|
||||||
|
// Verificar se o stream tem tracks de vídeo
|
||||||
|
if (tempStream.getVideoTracks().length === 0) {
|
||||||
|
tempStream.getTracks().forEach(track => track.stop());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Webcam acessada com sucesso');
|
||||||
|
stream = tempStream;
|
||||||
|
webcamDisponivel = true;
|
||||||
|
streamObtido = true;
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Falha ao acessar webcam com constraint:', constraint, err);
|
||||||
|
ultimoErro = err instanceof Error ? err : new Error(String(err));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!streamObtido) {
|
||||||
|
throw ultimoErro || new Error('Não foi possível acessar a webcam');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agora que temos o stream, aguardar o elemento de vídeo estar disponível
|
||||||
|
let tentativas = 0;
|
||||||
|
while (!videoElement && tentativas < 30) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
tentativas++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoElement) {
|
||||||
|
erro = 'Elemento de vídeo não encontrado';
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
stream = null;
|
||||||
|
}
|
||||||
|
webcamDisponivel = false;
|
||||||
|
if (fotoObrigatoria) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (autoCapture && onError) {
|
||||||
|
onError();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atribuir stream ao elemento de vídeo
|
||||||
|
if (videoElement && stream) {
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
|
||||||
|
// Aguardar o vídeo estar pronto com timeout maior
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
// Se o vídeo tem dimensões, considerar pronto mesmo sem eventos
|
||||||
|
if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
|
||||||
|
videoReady = true;
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error('Timeout ao carregar vídeo'));
|
||||||
|
}
|
||||||
|
}, 15000); // Aumentar timeout para 15 segundos
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
videoElement?.removeEventListener('playing', onPlaying);
|
||||||
|
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||||
|
videoElement?.removeEventListener('error', onError);
|
||||||
|
// Aguardar um pouco mais para garantir que o vídeo esteja realmente visível
|
||||||
|
setTimeout(() => {
|
||||||
|
videoReady = true;
|
||||||
|
resolve();
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoadedData = () => {
|
||||||
|
if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
videoElement?.removeEventListener('playing', onPlaying);
|
||||||
|
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||||
|
videoElement?.removeEventListener('error', onError);
|
||||||
|
videoReady = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPlaying = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
videoElement?.removeEventListener('playing', onPlaying);
|
||||||
|
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||||
|
videoElement?.removeEventListener('error', onError);
|
||||||
|
videoReady = true;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
videoElement?.removeEventListener('playing', onPlaying);
|
||||||
|
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||||
|
videoElement?.removeEventListener('error', onError);
|
||||||
|
reject(new Error('Erro ao carregar vídeo'));
|
||||||
|
};
|
||||||
|
|
||||||
|
videoElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
videoElement.addEventListener('loadeddata', onLoadedData);
|
||||||
|
videoElement.addEventListener('playing', onPlaying);
|
||||||
|
videoElement.addEventListener('error', onError);
|
||||||
|
|
||||||
|
// Tentar reproduzir
|
||||||
|
videoElement.play()
|
||||||
|
.then(() => {
|
||||||
|
console.log('Vídeo iniciado, readyState:', videoElement?.readyState);
|
||||||
|
// Se já tiver metadata e dimensões, resolver imediatamente
|
||||||
|
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
onLoadedMetadata();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('Erro ao reproduzir vídeo:', err);
|
||||||
|
// Continuar mesmo assim se já tiver metadata e dimensões
|
||||||
|
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
onLoadedMetadata();
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
// Aguardar um pouco mais antes de dar erro
|
||||||
|
setTimeout(() => {
|
||||||
|
if (videoElement && videoElement.videoWidth > 0) {
|
||||||
|
onLoadedMetadata();
|
||||||
|
} else {
|
||||||
|
onError();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Vídeo pronto, dimensões:', videoElement.videoWidth, 'x', videoElement.videoHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se for captura automática, aguardar um pouco e capturar
|
||||||
|
if (autoCapture) {
|
||||||
|
// Aguardar 1.5 segundos para o vídeo estabilizar
|
||||||
|
setTimeout(() => {
|
||||||
|
if (videoElement && canvasElement && !capturando && !previewUrl && webcamDisponivel) {
|
||||||
|
capturar();
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sucesso, sair do try
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao acessar webcam:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
|
||||||
|
erro = fotoObrigatoria
|
||||||
|
? 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.'
|
||||||
|
: 'Permissão de webcam negada. Continuando sem foto.';
|
||||||
|
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) {
|
||||||
|
erro = fotoObrigatoria
|
||||||
|
? 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.'
|
||||||
|
: 'Nenhuma webcam encontrada. Continuando sem foto.';
|
||||||
|
} else {
|
||||||
|
erro = fotoObrigatoria
|
||||||
|
? 'Erro ao acessar webcam. Verifique as permissões e tente novamente.'
|
||||||
|
: 'Erro ao acessar webcam. Continuando sem foto.';
|
||||||
|
}
|
||||||
|
|
||||||
|
webcamDisponivel = false;
|
||||||
|
// Se foto é obrigatória, não chamar onError para permitir continuar sem foto
|
||||||
|
if (fotoObrigatoria) {
|
||||||
|
// Apenas mostrar o erro e aguardar o usuário fechar ou tentar novamente
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Se for captura automática e houver erro, chamar onError para continuar sem foto
|
||||||
|
if (autoCapture && onError) {
|
||||||
|
setTimeout(() => {
|
||||||
|
onError();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function capturar() {
|
||||||
|
if (!videoElement || !canvasElement) {
|
||||||
|
console.error('Elementos de vídeo ou canvas não disponíveis');
|
||||||
|
if (autoCapture && onError) {
|
||||||
|
onError();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o vídeo está pronto e tem dimensões válidas
|
||||||
|
if (videoElement.readyState < 2 || videoElement.videoWidth === 0 || videoElement.videoHeight === 0) {
|
||||||
|
console.warn('Vídeo ainda não está pronto, aguardando...');
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let tentativas = 0;
|
||||||
|
const maxTentativas = 50; // 5 segundos
|
||||||
|
const checkReady = () => {
|
||||||
|
tentativas++;
|
||||||
|
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
|
||||||
|
resolve();
|
||||||
|
} else if (tentativas >= maxTentativas) {
|
||||||
|
reject(new Error('Timeout aguardando vídeo ficar pronto'));
|
||||||
|
} else {
|
||||||
|
setTimeout(checkReady, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkReady();
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Erro ao aguardar vídeo:', error);
|
||||||
|
erro = 'Vídeo não está pronto. Aguarde um momento e tente novamente.';
|
||||||
|
capturando = false;
|
||||||
|
return; // Retornar aqui para não continuar
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se chegou aqui, o vídeo está pronto, continuar com a captura
|
||||||
|
}
|
||||||
|
|
||||||
|
capturando = true;
|
||||||
|
erro = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verificar dimensões do vídeo novamente antes de capturar
|
||||||
|
if (!videoElement.videoWidth || !videoElement.videoHeight) {
|
||||||
|
throw new Error('Dimensões do vídeo não disponíveis. Aguarde a câmera carregar completamente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configurar canvas com as dimensões do vídeo
|
||||||
|
canvasElement.width = videoElement.videoWidth;
|
||||||
|
canvasElement.height = videoElement.videoHeight;
|
||||||
|
|
||||||
|
// Obter contexto do canvas
|
||||||
|
const ctx = canvasElement.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Não foi possível obter contexto do canvas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpar canvas antes de desenhar
|
||||||
|
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
|
||||||
|
|
||||||
|
// Desenhar frame atual do vídeo no canvas
|
||||||
|
// O vídeo está espelhado no CSS para visualização, mas capturamos normalmente
|
||||||
|
ctx.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height);
|
||||||
|
|
||||||
|
// Converter para blob
|
||||||
|
const blob = await new Promise<Blob | null>((resolve, reject) => {
|
||||||
|
canvasElement.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Falha ao converter canvas para blob'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'image/jpeg',
|
||||||
|
0.92 // Qualidade ligeiramente reduzida para melhor compatibilidade
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (blob && blob.size > 0) {
|
||||||
|
previewUrl = URL.createObjectURL(blob);
|
||||||
|
console.log('Imagem capturada com sucesso, tamanho:', blob.size, 'bytes');
|
||||||
|
|
||||||
|
// Parar stream para mostrar preview
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
stream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se for captura automática, confirmar automaticamente após um pequeno delay
|
||||||
|
if (autoCapture) {
|
||||||
|
setTimeout(() => {
|
||||||
|
confirmar();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Blob vazio ou inválido');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao capturar:', error);
|
||||||
|
erro = fotoObrigatoria
|
||||||
|
? 'Erro ao capturar imagem. Tente novamente.'
|
||||||
|
: 'Erro ao capturar imagem. Continuando sem foto.';
|
||||||
|
// Se foto é obrigatória, não chamar onError para permitir continuar sem foto
|
||||||
|
if (fotoObrigatoria) {
|
||||||
|
// Apenas mostrar o erro e permitir que o usuário tente novamente
|
||||||
|
capturando = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Se for captura automática e houver erro, continuar sem foto
|
||||||
|
if (autoCapture && onError) {
|
||||||
|
setTimeout(() => {
|
||||||
|
onError();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
capturando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmar() {
|
||||||
|
if (previewUrl) {
|
||||||
|
// Converter preview URL de volta para blob
|
||||||
|
fetch(previewUrl)
|
||||||
|
.then((res) => res.blob())
|
||||||
|
.then((blob) => {
|
||||||
|
onCapture(blob);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Erro ao converter preview:', error);
|
||||||
|
erro = 'Erro ao processar imagem';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelar() {
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
previewUrl = null;
|
||||||
|
}
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recapturar() {
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
previewUrl = null;
|
||||||
|
}
|
||||||
|
// Reiniciar webcam
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
facingMode: 'user'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
await videoElement.play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao reiniciar webcam:', error);
|
||||||
|
erro = 'Erro ao reiniciar webcam';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-4 p-4 w-full">
|
||||||
|
{#if !webcamDisponivel && !erro}
|
||||||
|
<div class="text-warning flex items-center gap-2">
|
||||||
|
<Camera class="h-5 w-5" />
|
||||||
|
<span>Verificando webcam...</span>
|
||||||
|
</div>
|
||||||
|
{#if !autoCapture && !fotoObrigatoria}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||||
|
</div>
|
||||||
|
{:else if fotoObrigatoria}
|
||||||
|
<div class="alert alert-info max-w-md">
|
||||||
|
<AlertCircle class="h-5 w-5" />
|
||||||
|
<span>A captura de foto é obrigatória para registrar o ponto.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if erro && !webcamDisponivel}
|
||||||
|
<div class="alert alert-error max-w-md">
|
||||||
|
<AlertCircle class="h-5 w-5" />
|
||||||
|
<span>{erro}</span>
|
||||||
|
</div>
|
||||||
|
{#if fotoObrigatoria}
|
||||||
|
<div class="alert alert-warning max-w-md">
|
||||||
|
<span>Não é possível registrar o ponto sem capturar uma foto. Verifique as permissões da webcam e tente novamente.</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-primary" onclick={async () => {
|
||||||
|
erro = null;
|
||||||
|
webcamDisponivel = false;
|
||||||
|
videoReady = false;
|
||||||
|
// Limpar stream anterior se existir
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
stream = null;
|
||||||
|
}
|
||||||
|
// Tentar reiniciar a webcam
|
||||||
|
try {
|
||||||
|
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia && videoElement) {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||||
|
if (stream.getVideoTracks().length > 0) {
|
||||||
|
webcamDisponivel = true;
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
await videoElement.play();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
stream = null;
|
||||||
|
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
erro = 'Webcam não disponível. Verifique as permissões e tente novamente.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erro ao tentar novamente:', e);
|
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
|
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
|
||||||
|
erro = 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.';
|
||||||
|
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) {
|
||||||
|
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||||
|
} else {
|
||||||
|
erro = 'Erro ao acessar webcam. Verifique as permissões e tente novamente.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}>Tentar Novamente</button>
|
||||||
|
<button class="btn btn-error" onclick={cancelar}>Fechar</button>
|
||||||
|
</div>
|
||||||
|
{:else if autoCapture}
|
||||||
|
<div class="text-sm text-base-content/70 text-center">
|
||||||
|
O registro será feito sem foto.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if previewUrl}
|
||||||
|
<!-- Preview da imagem capturada -->
|
||||||
|
<div class="flex flex-col items-center gap-4 w-full">
|
||||||
|
{#if autoCapture}
|
||||||
|
<!-- Modo automático: mostrar apenas preview sem botões -->
|
||||||
|
<div class="text-sm text-base-content/70 mb-2 text-center">
|
||||||
|
Foto capturada automaticamente...
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Preview"
|
||||||
|
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain"
|
||||||
|
/>
|
||||||
|
{#if !autoCapture}
|
||||||
|
<!-- Botões apenas se não for automático -->
|
||||||
|
<div class="flex gap-2 flex-wrap justify-center">
|
||||||
|
<button class="btn btn-success" onclick={confirmar}>
|
||||||
|
<Check class="h-5 w-5" />
|
||||||
|
Confirmar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline" onclick={recapturar}>
|
||||||
|
<Camera class="h-5 w-5" />
|
||||||
|
Recapturar
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-error" onclick={cancelar}>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Webcam ativa -->
|
||||||
|
<div class="flex flex-col items-center gap-4 w-full">
|
||||||
|
{#if autoCapture}
|
||||||
|
<div class="text-sm text-base-content/70 mb-2 text-center">
|
||||||
|
Capturando foto automaticamente...
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-sm text-base-content/70 mb-2 text-center">
|
||||||
|
Posicione-se na frente da câmera e clique em "Capturar Foto"
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="relative w-full flex justify-center">
|
||||||
|
<video
|
||||||
|
bind:this={videoElement}
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain bg-black {!videoReady ? 'opacity-50' : ''}"
|
||||||
|
style="min-width: 320px; min-height: 240px; transform: scaleX(-1);"
|
||||||
|
></video>
|
||||||
|
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
||||||
|
{#if !videoReady && webcamDisponivel}
|
||||||
|
<div class="absolute inset-0 flex flex-col items-center justify-center bg-black/70 rounded-lg gap-2">
|
||||||
|
<span class="loading loading-spinner loading-lg text-white"></span>
|
||||||
|
<span class="text-white text-sm">Carregando câmera...</span>
|
||||||
|
</div>
|
||||||
|
{:else if videoReady && webcamDisponivel}
|
||||||
|
<div class="absolute bottom-2 left-1/2 transform -translate-x-1/2">
|
||||||
|
<div class="badge badge-success gap-2">
|
||||||
|
<Camera class="h-4 w-4" />
|
||||||
|
Câmera ativa
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if erro}
|
||||||
|
<div class="alert alert-error max-w-md">
|
||||||
|
<span>{erro}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !autoCapture}
|
||||||
|
<!-- Botões sempre visíveis quando não for automático -->
|
||||||
|
<div class="flex gap-2 flex-wrap justify-center">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-lg"
|
||||||
|
onclick={capturar}
|
||||||
|
disabled={capturando || !videoReady || !webcamDisponivel}
|
||||||
|
>
|
||||||
|
{#if capturando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Capturando...
|
||||||
|
{:else}
|
||||||
|
<Camera class="h-5 w-5" />
|
||||||
|
Capturar Foto
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline" onclick={cancelar}>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
68
apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte
Normal file
68
apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Clock } from 'lucide-svelte';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Cabeçalho da Categoria -->
|
||||||
|
<div class="flex items-start gap-6 mb-6">
|
||||||
|
<div class="p-4 bg-blue-500/20 rounded-2xl">
|
||||||
|
<div class="text-blue-600">
|
||||||
|
<Clock class="h-12 w-12" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="card-title text-2xl mb-2 text-blue-600">
|
||||||
|
Gestão de Pontos
|
||||||
|
</h2>
|
||||||
|
<p class="text-base-content/70">Registros de ponto do dia</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid de Opções -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<a
|
||||||
|
href={resolve('/(dashboard)/recursos-humanos/registro-pontos')}
|
||||||
|
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-blue-500/10 to-blue-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-blue-600 group-hover:text-white"
|
||||||
|
>
|
||||||
|
<Clock 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"
|
||||||
|
>
|
||||||
|
Gestão de Pontos
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-base-content/70 flex-1">
|
||||||
|
Visualizar e gerenciar registros de ponto
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
452
apps/web/src/lib/utils/deviceInfo.ts
Normal file
452
apps/web/src/lib/utils/deviceInfo.ts
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import { getLocalIP } from './browserInfo';
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta informações do navegador
|
||||||
|
*/
|
||||||
|
function detectarNavegador(): { browser: string; browserVersion: string; engine: string } {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return { browser: 'Desconhecido', browserVersion: '', engine: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
let browser = 'Desconhecido';
|
||||||
|
let browserVersion = '';
|
||||||
|
let engine = '';
|
||||||
|
|
||||||
|
// Detectar engine
|
||||||
|
if (ua.includes('Edg/')) {
|
||||||
|
engine = 'EdgeHTML';
|
||||||
|
} else if (ua.includes('Chrome/')) {
|
||||||
|
engine = 'Blink';
|
||||||
|
} else if (ua.includes('Firefox/')) {
|
||||||
|
engine = 'Gecko';
|
||||||
|
} else if (ua.includes('Safari/') && !ua.includes('Chrome/')) {
|
||||||
|
engine = 'WebKit';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detectar navegador
|
||||||
|
if (ua.includes('Edg/')) {
|
||||||
|
browser = 'Edge';
|
||||||
|
const match = ua.match(/Edg\/(\d+)/);
|
||||||
|
browserVersion = match ? match[1]! : '';
|
||||||
|
} else if (ua.includes('Chrome/') && !ua.includes('Edg/')) {
|
||||||
|
browser = 'Chrome';
|
||||||
|
const match = ua.match(/Chrome\/(\d+)/);
|
||||||
|
browserVersion = match ? match[1]! : '';
|
||||||
|
} else if (ua.includes('Firefox/')) {
|
||||||
|
browser = 'Firefox';
|
||||||
|
const match = ua.match(/Firefox\/(\d+)/);
|
||||||
|
browserVersion = match ? match[1]! : '';
|
||||||
|
} else if (ua.includes('Safari/') && !ua.includes('Chrome/')) {
|
||||||
|
browser = 'Safari';
|
||||||
|
const match = ua.match(/Version\/(\d+)/);
|
||||||
|
browserVersion = match ? match[1]! : '';
|
||||||
|
} else if (ua.includes('Opera/') || ua.includes('OPR/')) {
|
||||||
|
browser = 'Opera';
|
||||||
|
const match = ua.match(/(?:Opera|OPR)\/(\d+)/);
|
||||||
|
browserVersion = match ? match[1]! : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { browser, browserVersion, engine };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta informações do sistema operacional
|
||||||
|
*/
|
||||||
|
function detectarSistemaOperacional(): {
|
||||||
|
sistemaOperacional: string;
|
||||||
|
osVersion: string;
|
||||||
|
arquitetura: string;
|
||||||
|
plataforma: string;
|
||||||
|
} {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return {
|
||||||
|
sistemaOperacional: 'Desconhecido',
|
||||||
|
osVersion: '',
|
||||||
|
arquitetura: '',
|
||||||
|
plataforma: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const platform = navigator.platform || '';
|
||||||
|
let sistemaOperacional = 'Desconhecido';
|
||||||
|
let osVersion = '';
|
||||||
|
let arquitetura = '';
|
||||||
|
const plataforma = platform;
|
||||||
|
|
||||||
|
// Detectar OS
|
||||||
|
if (ua.includes('Windows NT')) {
|
||||||
|
sistemaOperacional = 'Windows';
|
||||||
|
const match = ua.match(/Windows NT (\d+\.\d+)/);
|
||||||
|
if (match) {
|
||||||
|
const version = match[1]!;
|
||||||
|
const versions: Record<string, string> = {
|
||||||
|
'10.0': '10/11',
|
||||||
|
'6.3': '8.1',
|
||||||
|
'6.2': '8',
|
||||||
|
'6.1': '7',
|
||||||
|
};
|
||||||
|
osVersion = versions[version] || version;
|
||||||
|
}
|
||||||
|
} else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) {
|
||||||
|
sistemaOperacional = 'macOS';
|
||||||
|
const match = ua.match(/Mac OS X (\d+[._]\d+)/);
|
||||||
|
if (match) {
|
||||||
|
osVersion = match[1]!.replace('_', '.');
|
||||||
|
}
|
||||||
|
} else if (ua.includes('Linux')) {
|
||||||
|
sistemaOperacional = 'Linux';
|
||||||
|
osVersion = 'Linux';
|
||||||
|
} else if (ua.includes('Android')) {
|
||||||
|
sistemaOperacional = 'Android';
|
||||||
|
const match = ua.match(/Android (\d+(?:\.\d+)?)/);
|
||||||
|
osVersion = match ? match[1]! : '';
|
||||||
|
} else if (ua.includes('iPhone') || ua.includes('iPad')) {
|
||||||
|
sistemaOperacional = 'iOS';
|
||||||
|
const match = ua.match(/OS (\d+[._]\d+)/);
|
||||||
|
if (match) {
|
||||||
|
osVersion = match[1]!.replace('_', '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detectar arquitetura (se disponível)
|
||||||
|
if ('cpuClass' in navigator) {
|
||||||
|
arquitetura = (navigator as unknown as { cpuClass: string }).cpuClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sistemaOperacional, osVersion, arquitetura, plataforma };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta tipo de dispositivo
|
||||||
|
*/
|
||||||
|
function detectarTipoDispositivo(): {
|
||||||
|
deviceType: string;
|
||||||
|
isMobile: boolean;
|
||||||
|
isTablet: boolean;
|
||||||
|
isDesktop: boolean;
|
||||||
|
} {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
return {
|
||||||
|
deviceType: 'Desconhecido',
|
||||||
|
isMobile: false,
|
||||||
|
isTablet: false,
|
||||||
|
isDesktop: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
const isMobile = /Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
|
||||||
|
const isTablet = /iPad|Android(?!.*Mobile)|Tablet/i.test(ua);
|
||||||
|
const isDesktop = !isMobile && !isTablet;
|
||||||
|
|
||||||
|
let deviceType = 'Desktop';
|
||||||
|
if (isTablet) {
|
||||||
|
deviceType = 'Tablet';
|
||||||
|
} else if (isMobile) {
|
||||||
|
deviceType = 'Mobile';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deviceType, isMobile, isTablet, isDesktop };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém informações da tela
|
||||||
|
*/
|
||||||
|
function obterInformacoesTela(): { screenResolution: string; coresTela: number } {
|
||||||
|
if (typeof screen === 'undefined') {
|
||||||
|
return { screenResolution: 'Desconhecido', coresTela: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenResolution = `${screen.width}x${screen.height}`;
|
||||||
|
const coresTela = screen.colorDepth || 24;
|
||||||
|
|
||||||
|
return { screenResolution, coresTela };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém informações de conexão
|
||||||
|
*/
|
||||||
|
async function obterInformacoesConexao(): Promise<string> {
|
||||||
|
if (typeof navigator === 'undefined' || !('connection' in navigator)) {
|
||||||
|
return 'Desconhecido';
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = (navigator as unknown as { connection?: { effectiveType?: string } }).connection;
|
||||||
|
if (connection?.effectiveType) {
|
||||||
|
return connection.effectiveType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Desconhecido';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém informações de memória (se disponível)
|
||||||
|
*/
|
||||||
|
function obterInformacoesMemoria(): string {
|
||||||
|
if (typeof navigator === 'undefined' || !('deviceMemory' in navigator)) {
|
||||||
|
return 'Desconhecido';
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceMemory = (navigator as unknown as { deviceMemory?: number }).deviceMemory;
|
||||||
|
if (deviceMemory) {
|
||||||
|
return `${deviceMemory} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Desconhecido';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém localização via GPS com múltiplas tentativas
|
||||||
|
*/
|
||||||
|
async function obterLocalizacao(): Promise<{
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
precisao?: number;
|
||||||
|
endereco?: string;
|
||||||
|
cidade?: string;
|
||||||
|
estado?: string;
|
||||||
|
pais?: string;
|
||||||
|
}> {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||||
|
console.warn('Geolocalização não suportada');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar múltiplas estratégias
|
||||||
|
const estrategias = [
|
||||||
|
// Estratégia 1: Alta precisão (mais lento, mas mais preciso)
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
maximumAge: 0
|
||||||
|
},
|
||||||
|
// Estratégia 2: Precisão média (balanceado)
|
||||||
|
{
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
timeout: 8000,
|
||||||
|
maximumAge: 30000
|
||||||
|
},
|
||||||
|
// Estratégia 3: Rápido (usa cache)
|
||||||
|
{
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
timeout: 5000,
|
||||||
|
maximumAge: 60000
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const options of estrategias) {
|
||||||
|
try {
|
||||||
|
const resultado = await new Promise<{
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
precisao?: number;
|
||||||
|
endereco?: string;
|
||||||
|
cidade?: string;
|
||||||
|
estado?: string;
|
||||||
|
pais?: string;
|
||||||
|
}>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
resolve({});
|
||||||
|
}, options.timeout + 1000);
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
async (position) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const { latitude, longitude, accuracy } = position.coords;
|
||||||
|
|
||||||
|
// Validar coordenadas
|
||||||
|
if (isNaN(latitude) || isNaN(longitude) || latitude === 0 || longitude === 0) {
|
||||||
|
resolve({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar obter endereço via reverse geocoding
|
||||||
|
let endereco = '';
|
||||||
|
let cidade = '';
|
||||||
|
let estado = '';
|
||||||
|
let pais = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'SGSE-App/1.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
address?: {
|
||||||
|
road?: string;
|
||||||
|
house_number?: string;
|
||||||
|
city?: string;
|
||||||
|
town?: string;
|
||||||
|
state?: string;
|
||||||
|
country?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
if (data.address) {
|
||||||
|
const addr = data.address;
|
||||||
|
if (addr.road) {
|
||||||
|
endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`;
|
||||||
|
}
|
||||||
|
cidade = addr.city || addr.town || '';
|
||||||
|
estado = addr.state || '';
|
||||||
|
pais = addr.country || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao obter endereço:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
precisao: accuracy,
|
||||||
|
endereco,
|
||||||
|
cidade,
|
||||||
|
estado,
|
||||||
|
pais,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.warn('Erro ao obter localização:', error.code, error.message);
|
||||||
|
resolve({});
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se obteve localização, retornar
|
||||||
|
if (resultado.latitude && resultado.longitude) {
|
||||||
|
console.log('Localização obtida com sucesso:', resultado);
|
||||||
|
return resultado;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro na estratégia de geolocalização:', error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se todas as estratégias falharam, retornar vazio
|
||||||
|
console.warn('Não foi possível obter localização após todas as tentativas');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém IP público
|
||||||
|
*/
|
||||||
|
async function obterIPPublico(): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.ipify.org?format=json');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as { ip: string };
|
||||||
|
return data.ip;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao obter IP público:', error);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém todas as informações do dispositivo
|
||||||
|
*/
|
||||||
|
export async function obterInformacoesDispositivo(): Promise<InformacoesDispositivo> {
|
||||||
|
const informacoes: InformacoesDispositivo = {};
|
||||||
|
|
||||||
|
// Informações básicas
|
||||||
|
if (typeof navigator !== 'undefined') {
|
||||||
|
informacoes.userAgent = navigator.userAgent;
|
||||||
|
informacoes.idioma = navigator.language || navigator.languages?.[0];
|
||||||
|
informacoes.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Informações do navegador
|
||||||
|
const navegador = detectarNavegador();
|
||||||
|
informacoes.browser = navegador.browser;
|
||||||
|
informacoes.browserVersion = navegador.browserVersion;
|
||||||
|
informacoes.engine = navegador.engine;
|
||||||
|
|
||||||
|
// Informações do sistema
|
||||||
|
const sistema = detectarSistemaOperacional();
|
||||||
|
informacoes.sistemaOperacional = sistema.sistemaOperacional;
|
||||||
|
informacoes.osVersion = sistema.osVersion;
|
||||||
|
informacoes.arquitetura = sistema.arquitetura;
|
||||||
|
informacoes.plataforma = sistema.plataforma;
|
||||||
|
|
||||||
|
// Tipo de dispositivo
|
||||||
|
const dispositivo = detectarTipoDispositivo();
|
||||||
|
informacoes.deviceType = dispositivo.deviceType;
|
||||||
|
informacoes.isMobile = dispositivo.isMobile;
|
||||||
|
informacoes.isTablet = dispositivo.isTablet;
|
||||||
|
informacoes.isDesktop = dispositivo.isDesktop;
|
||||||
|
|
||||||
|
// Informações da tela
|
||||||
|
const tela = obterInformacoesTela();
|
||||||
|
informacoes.screenResolution = tela.screenResolution;
|
||||||
|
informacoes.coresTela = tela.coresTela;
|
||||||
|
|
||||||
|
// Informações de conexão e memória (assíncronas)
|
||||||
|
const [connectionType, memoryInfo, ipPublico, ipLocal, localizacao] = await Promise.all([
|
||||||
|
obterInformacoesConexao(),
|
||||||
|
Promise.resolve(obterInformacoesMemoria()),
|
||||||
|
obterIPPublico(),
|
||||||
|
getLocalIP(),
|
||||||
|
obterLocalizacao(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
informacoes.connectionType = connectionType;
|
||||||
|
informacoes.memoryInfo = memoryInfo;
|
||||||
|
informacoes.ipPublico = ipPublico;
|
||||||
|
informacoes.ipLocal = ipLocal;
|
||||||
|
informacoes.latitude = localizacao.latitude;
|
||||||
|
informacoes.longitude = localizacao.longitude;
|
||||||
|
informacoes.precisao = localizacao.precisao;
|
||||||
|
informacoes.endereco = localizacao.endereco;
|
||||||
|
informacoes.cidade = localizacao.cidade;
|
||||||
|
informacoes.estado = localizacao.estado;
|
||||||
|
informacoes.pais = localizacao.pais;
|
||||||
|
|
||||||
|
// IP address (usar público se disponível, senão local)
|
||||||
|
informacoes.ipAddress = ipPublico || ipLocal;
|
||||||
|
|
||||||
|
return informacoes;
|
||||||
|
}
|
||||||
|
|
||||||
124
apps/web/src/lib/utils/ponto.ts
Normal file
124
apps/web/src/lib/utils/ponto.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Formata hora no formato HH:mm
|
||||||
|
*/
|
||||||
|
export function formatarHoraPonto(hora: number, minuto: number): string {
|
||||||
|
return `${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formata data e hora completa
|
||||||
|
*/
|
||||||
|
export function formatarDataHoraCompleta(
|
||||||
|
data: string,
|
||||||
|
hora: number,
|
||||||
|
minuto: number,
|
||||||
|
segundo: number
|
||||||
|
): string {
|
||||||
|
const dataObj = new Date(`${data}T${formatarHoraPonto(hora, minuto)}:${segundo.toString().padStart(2, '0')}`);
|
||||||
|
return dataObj.toLocaleString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula tempo trabalhado entre dois registros
|
||||||
|
*/
|
||||||
|
export function calcularTempoTrabalhado(
|
||||||
|
horaInicio: number,
|
||||||
|
minutoInicio: number,
|
||||||
|
horaFim: number,
|
||||||
|
minutoFim: number
|
||||||
|
): { horas: number; minutos: number } {
|
||||||
|
const minutosInicio = horaInicio * 60 + minutoInicio;
|
||||||
|
const minutosFim = horaFim * 60 + minutoFim;
|
||||||
|
const diferencaMinutos = minutosFim - minutosInicio;
|
||||||
|
|
||||||
|
if (diferencaMinutos < 0) {
|
||||||
|
return { horas: 0, minutos: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const horas = Math.floor(diferencaMinutos / 60);
|
||||||
|
const minutos = diferencaMinutos % 60;
|
||||||
|
|
||||||
|
return { horas, minutos };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se está dentro do prazo baseado na configuração
|
||||||
|
*/
|
||||||
|
export function verificarDentroDoPrazo(
|
||||||
|
hora: number,
|
||||||
|
minuto: number,
|
||||||
|
horarioConfigurado: string,
|
||||||
|
toleranciaMinutos: number
|
||||||
|
): boolean {
|
||||||
|
const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number);
|
||||||
|
const totalMinutosRegistro = hora * 60 + minuto;
|
||||||
|
const totalMinutosConfigurado = horaConfig * 60 + minutoConfig;
|
||||||
|
const diferenca = totalMinutosRegistro - totalMinutosConfigurado;
|
||||||
|
return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
config?: {
|
||||||
|
nomeEntrada?: string;
|
||||||
|
nomeSaidaAlmoco?: string;
|
||||||
|
nomeRetornoAlmoco?: string;
|
||||||
|
nomeSaida?: string;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
// Se config fornecida, usar nomes personalizados
|
||||||
|
if (config) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
entrada: config.nomeEntrada || 'Entrada 1',
|
||||||
|
saida_almoco: config.nomeSaidaAlmoco || 'Saída 1',
|
||||||
|
retorno_almoco: config.nomeRetornoAlmoco || 'Entrada 2',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém próximo tipo de registro esperado
|
||||||
|
*/
|
||||||
|
export function getProximoTipoRegistro(
|
||||||
|
ultimoTipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' | null
|
||||||
|
): 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' {
|
||||||
|
if (!ultimoTipo) {
|
||||||
|
return 'entrada';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ultimoTipo) {
|
||||||
|
case 'entrada':
|
||||||
|
return 'saida_almoco';
|
||||||
|
case 'saida_almoco':
|
||||||
|
return 'retorno_almoco';
|
||||||
|
case 'retorno_almoco':
|
||||||
|
return 'saida';
|
||||||
|
case 'saida':
|
||||||
|
return 'entrada'; // Novo dia
|
||||||
|
default:
|
||||||
|
return 'entrada';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
56
apps/web/src/lib/utils/sincronizacaoTempo.ts
Normal file
56
apps/web/src/lib/utils/sincronizacaoTempo.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { ConvexClient } from 'convex/browser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém tempo do servidor (sincronizado)
|
||||||
|
*/
|
||||||
|
export async function obterTempoServidor(client: ConvexClient): Promise<number> {
|
||||||
|
try {
|
||||||
|
// Tentar obter configuração e sincronizar se necessário
|
||||||
|
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||||
|
|
||||||
|
if (config.usarServidorExterno) {
|
||||||
|
try {
|
||||||
|
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||||
|
if (resultado.sucesso && resultado.timestamp) {
|
||||||
|
return resultado.timestamp;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao sincronizar com servidor externo:', error);
|
||||||
|
if (config.fallbackParaPC) {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar tempo do servidor Convex
|
||||||
|
const tempoServidor = await client.query(api.configuracaoRelogio.obterTempoServidor, {});
|
||||||
|
return tempoServidor.timestamp;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao obter tempo do servidor, usando tempo local:', error);
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém tempo do PC (fallback)
|
||||||
|
*/
|
||||||
|
export function obterTempoPC(): number {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula offset entre dois timestamps
|
||||||
|
*/
|
||||||
|
export function calcularOffset(timestampServidor: number, timestampLocal: number): number {
|
||||||
|
return timestampServidor - timestampLocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplica offset a um timestamp
|
||||||
|
*/
|
||||||
|
export function aplicarOffset(timestamp: number, offsetSegundos: number): number {
|
||||||
|
return timestamp + offsetSegundos * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
150
apps/web/src/lib/utils/webcam.ts
Normal file
150
apps/web/src/lib/utils/webcam.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Verifica se webcam está disponível
|
||||||
|
*/
|
||||||
|
export async function validarWebcamDisponivel(): Promise<boolean> {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
return devices.some((device) => device.kind === 'videoinput');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captura imagem da webcam
|
||||||
|
*/
|
||||||
|
export async function capturarWebcam(): Promise<Blob | null> {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream: MediaStream | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Solicitar acesso à webcam
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
facingMode: 'user',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar elemento de vídeo temporário
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.srcObject = stream;
|
||||||
|
video.play();
|
||||||
|
|
||||||
|
// Aguardar vídeo estar pronto
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
video.width = video.videoWidth;
|
||||||
|
video.height = video.videoHeight;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
video.onerror = reject;
|
||||||
|
setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capturar frame
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Não foi possível obter contexto do canvas');
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(video, 0, 0);
|
||||||
|
|
||||||
|
// Converter para blob
|
||||||
|
return await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
resolve(blob);
|
||||||
|
},
|
||||||
|
'image/jpeg',
|
||||||
|
0.9
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao capturar webcam:', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
// Parar stream
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captura imagem da webcam com preview
|
||||||
|
*/
|
||||||
|
export async function capturarWebcamComPreview(
|
||||||
|
videoElement: HTMLVideoElement,
|
||||||
|
canvasElement: HTMLCanvasElement
|
||||||
|
): Promise<Blob | null> {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream: MediaStream | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Solicitar acesso à webcam
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
facingMode: 'user',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
await videoElement.play();
|
||||||
|
|
||||||
|
// Aguardar vídeo estar pronto
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
videoElement.onloadedmetadata = () => {
|
||||||
|
canvasElement.width = videoElement.videoWidth;
|
||||||
|
canvasElement.height = videoElement.videoHeight;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
videoElement.onerror = reject;
|
||||||
|
setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capturar frame
|
||||||
|
const ctx = canvasElement.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Não foi possível obter contexto do canvas');
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(videoElement, 0, 0);
|
||||||
|
|
||||||
|
// Converter para blob
|
||||||
|
return await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvasElement.toBlob(
|
||||||
|
(blob) => {
|
||||||
|
resolve(blob);
|
||||||
|
},
|
||||||
|
'image/jpeg',
|
||||||
|
0.9
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao capturar webcam:', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
// Parar stream
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import WidgetGestaoPontos from '$lib/components/ponto/WidgetGestaoPontos.svelte';
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
categoria: "Gestão de Ausências",
|
categoria: "Gestão de Ausências",
|
||||||
@@ -115,6 +116,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Widget Gestão de Pontos -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<WidgetGestaoPontos />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Card de Ajuda -->
|
<!-- Card de Ajuda -->
|
||||||
<div class="alert alert-info shadow-lg mt-8">
|
<div class="alert alert-info shadow-lg mt-8">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -11,7 +11,28 @@
|
|||||||
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
|
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import type { FunctionReturnType } from 'convex/server';
|
import type { FunctionReturnType } from 'convex/server';
|
||||||
import { X, Calendar, Users, Clock, CheckCircle2, Eye, FileCheck, CalendarDays, User, Mail, Shield, Briefcase, Hash, CreditCard, Building2, CheckCircle, ListChecks, Info } from 'lucide-svelte';
|
import {
|
||||||
|
X,
|
||||||
|
Calendar,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
Eye,
|
||||||
|
FileCheck,
|
||||||
|
CalendarDays,
|
||||||
|
User,
|
||||||
|
Mail,
|
||||||
|
Shield,
|
||||||
|
Briefcase,
|
||||||
|
Hash,
|
||||||
|
CreditCard,
|
||||||
|
Building2,
|
||||||
|
CheckCircle,
|
||||||
|
ListChecks,
|
||||||
|
Info,
|
||||||
|
Fingerprint
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import RegistroPonto from '$lib/components/ponto/RegistroPonto.svelte';
|
||||||
import TicketCard from '$lib/components/chamados/TicketCard.svelte';
|
import TicketCard from '$lib/components/chamados/TicketCard.svelte';
|
||||||
import TicketTimeline from '$lib/components/chamados/TicketTimeline.svelte';
|
import TicketTimeline from '$lib/components/chamados/TicketTimeline.svelte';
|
||||||
import { chamadosStore } from '$lib/stores/chamados';
|
import { chamadosStore } from '$lib/stores/chamados';
|
||||||
@@ -45,6 +66,7 @@
|
|||||||
| 'minhas-ausencias'
|
| 'minhas-ausencias'
|
||||||
| 'aprovar-ferias'
|
| 'aprovar-ferias'
|
||||||
| 'aprovar-ausencias'
|
| 'aprovar-ausencias'
|
||||||
|
| 'meu-ponto'
|
||||||
>('meu-perfil');
|
>('meu-perfil');
|
||||||
|
|
||||||
let periodoSelecionado = $state<Id<'ferias'> | null>(null);
|
let periodoSelecionado = $state<Id<'ferias'> | null>(null);
|
||||||
@@ -216,11 +238,7 @@
|
|||||||
if (!selectedTicketId && chamadosQuery.data.length > 0) {
|
if (!selectedTicketId && chamadosQuery.data.length > 0) {
|
||||||
selectedTicketId = chamadosQuery.data[0]._id;
|
selectedTicketId = chamadosQuery.data[0]._id;
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (chamadosQuery !== undefined && chamadosQuery?.data === null && !gestorIdDisponivel) {
|
||||||
chamadosQuery !== undefined &&
|
|
||||||
chamadosQuery?.data === null &&
|
|
||||||
!gestorIdDisponivel
|
|
||||||
) {
|
|
||||||
chamadosEstaveis = [];
|
chamadosEstaveis = [];
|
||||||
chamadosStore.setTickets([]);
|
chamadosStore.setTickets([]);
|
||||||
}
|
}
|
||||||
@@ -703,12 +721,12 @@
|
|||||||
<!-- Tabs PREMIUM -->
|
<!-- Tabs PREMIUM -->
|
||||||
<div
|
<div
|
||||||
role="tablist"
|
role="tablist"
|
||||||
class="tabs tabs-boxed from-base-200 to-base-300 mb-8 bg-gradient-to-r p-2 shadow-xl rounded-xl border border-base-300"
|
class="tabs tabs-boxed from-base-200 to-base-300 border-base-300 mb-8 rounded-xl border bg-gradient-to-r p-2 shadow-xl"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'meu-perfil' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'meu-perfil' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||||
onclick={() => (abaAtiva = 'meu-perfil')}
|
onclick={() => (abaAtiva = 'meu-perfil')}
|
||||||
>
|
>
|
||||||
<User class="h-5 w-5" strokeWidth={2} />
|
<User class="h-5 w-5" strokeWidth={2} />
|
||||||
@@ -718,7 +736,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'meus-chamados' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'meus-chamados' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||||
onclick={() => (abaAtiva = 'meus-chamados')}
|
onclick={() => (abaAtiva = 'meus-chamados')}
|
||||||
aria-label="Meus Chamados"
|
aria-label="Meus Chamados"
|
||||||
>
|
>
|
||||||
@@ -730,7 +748,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'minhas-ferias' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'minhas-ferias' ? 'tab-active scale-105 bg-gradient-to-r from-purple-600 to-blue-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||||
onclick={() => (abaAtiva = 'minhas-ferias')}
|
onclick={() => (abaAtiva = 'minhas-ferias')}
|
||||||
>
|
>
|
||||||
<Calendar class="h-5 w-5" strokeWidth={2} />
|
<Calendar class="h-5 w-5" strokeWidth={2} />
|
||||||
@@ -740,7 +758,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'minhas-ausencias' ? 'tab-active scale-105 bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'minhas-ausencias' ? 'tab-active scale-105 bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||||
onclick={() => (abaAtiva = 'minhas-ausencias')}
|
onclick={() => (abaAtiva = 'minhas-ausencias')}
|
||||||
>
|
>
|
||||||
<Clock class="h-5 w-5" strokeWidth={2} />
|
<Clock class="h-5 w-5" strokeWidth={2} />
|
||||||
@@ -751,7 +769,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'aprovar-ferias' ? 'tab-active scale-105 bg-gradient-to-r from-green-600 to-emerald-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ferias' ? 'tab-active scale-105 bg-gradient-to-r from-green-600 to-emerald-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||||
onclick={() => (abaAtiva = 'aprovar-ferias')}
|
onclick={() => (abaAtiva = 'aprovar-ferias')}
|
||||||
>
|
>
|
||||||
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
|
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
|
||||||
@@ -768,7 +786,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
class={`tab tab-lg font-semibold transition-all duration-300 gap-2 ${abaAtiva === 'aprovar-ausencias' ? 'tab-active scale-105 bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ausencias' ? 'tab-active scale-105 bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||||
onclick={() => (abaAtiva = 'aprovar-ausencias')}
|
onclick={() => (abaAtiva = 'aprovar-ausencias')}
|
||||||
>
|
>
|
||||||
<Clock class="h-5 w-5" strokeWidth={2} />
|
<Clock class="h-5 w-5" strokeWidth={2} />
|
||||||
@@ -782,6 +800,16 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'meu-ponto' ? 'tab-active scale-105 bg-gradient-to-r from-blue-600 to-cyan-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
|
||||||
|
onclick={() => (abaAtiva = 'meu-ponto')}
|
||||||
|
>
|
||||||
|
<Fingerprint class="h-5 w-5" strokeWidth={2} />
|
||||||
|
Meu Ponto
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Conteúdo das Abas -->
|
<!-- Conteúdo das Abas -->
|
||||||
@@ -915,19 +943,21 @@
|
|||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<!-- Informações Pessoais PREMIUM -->
|
<!-- Informações Pessoais PREMIUM -->
|
||||||
<div
|
<div
|
||||||
class="card bg-gradient-to-br from-base-100 to-base-200 hover:shadow-3xl border-t-4 border-purple-500 shadow-2xl transition-shadow overflow-hidden"
|
class="card from-base-100 to-base-200 hover:shadow-3xl overflow-hidden border-t-4 border-purple-500 bg-gradient-to-br shadow-2xl transition-shadow"
|
||||||
>
|
>
|
||||||
<div class="card-body p-6">
|
<div class="card-body p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 shadow-lg ring-2 ring-purple-500/20">
|
<div
|
||||||
|
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 shadow-lg ring-2 ring-purple-500/20"
|
||||||
|
>
|
||||||
<User class="h-6 w-6 text-white" strokeWidth={2.5} />
|
<User class="h-6 w-6 text-white" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-base-content flex items-center gap-2">
|
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
|
||||||
Informações Pessoais
|
Informações Pessoais
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-base-content/60 mt-0.5">
|
<p class="text-base-content/60 mt-0.5 text-sm">
|
||||||
Seus dados pessoais e de acesso
|
Seus dados pessoais e de acesso
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -935,45 +965,54 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
|
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500/20 to-purple-600/20">
|
<div
|
||||||
<User class="text-purple-600 h-5 w-5" strokeWidth={2} />
|
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-purple-500/20 to-purple-600/20"
|
||||||
|
>
|
||||||
|
<User class="h-5 w-5 text-purple-600" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide"
|
<span
|
||||||
|
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||||
>Nome Completo</span
|
>Nome Completo</span
|
||||||
>
|
>
|
||||||
<p class="text-base-content text-base font-semibold mt-1">
|
<p class="text-base-content mt-1 text-base font-semibold">
|
||||||
{currentUser.data?.nome}
|
{currentUser.data?.nome}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
|
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/20">
|
<div
|
||||||
<Mail class="text-blue-600 h-5 w-5" strokeWidth={2} />
|
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/20"
|
||||||
|
>
|
||||||
|
<Mail class="h-5 w-5 text-blue-600" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide"
|
<span
|
||||||
|
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||||
>E-mail Institucional</span
|
>E-mail Institucional</span
|
||||||
>
|
>
|
||||||
<p class="text-base-content text-base font-semibold break-all mt-1">
|
<p class="text-base-content mt-1 text-base font-semibold break-all">
|
||||||
{currentUser.data?.email}
|
{currentUser.data?.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
|
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary/20 to-primary/30">
|
<div
|
||||||
|
class="from-primary/20 to-primary/30 flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br"
|
||||||
|
>
|
||||||
<Shield class="text-primary h-5 w-5" strokeWidth={2} />
|
<Shield class="text-primary h-5 w-5" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide"
|
<span
|
||||||
|
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||||
>Perfil de Acesso</span
|
>Perfil de Acesso</span
|
||||||
>
|
>
|
||||||
<div class="badge badge-primary badge-lg mt-2 font-bold shadow-sm">
|
<div class="badge badge-primary badge-lg mt-2 font-bold shadow-sm">
|
||||||
@@ -988,19 +1027,21 @@
|
|||||||
<!-- Dados Funcionais PREMIUM -->
|
<!-- Dados Funcionais PREMIUM -->
|
||||||
{#if funcionario}
|
{#if funcionario}
|
||||||
<div
|
<div
|
||||||
class="card bg-gradient-to-br from-base-100 to-base-200 hover:shadow-3xl border-t-4 border-blue-500 shadow-2xl transition-shadow overflow-hidden"
|
class="card from-base-100 to-base-200 hover:shadow-3xl overflow-hidden border-t-4 border-blue-500 bg-gradient-to-br shadow-2xl transition-shadow"
|
||||||
>
|
>
|
||||||
<div class="card-body p-6">
|
<div class="card-body p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg ring-2 ring-blue-500/20">
|
<div
|
||||||
|
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg ring-2 ring-blue-500/20"
|
||||||
|
>
|
||||||
<Briefcase class="h-6 w-6 text-white" strokeWidth={2.5} />
|
<Briefcase class="h-6 w-6 text-white" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-base-content flex items-center gap-2">
|
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
|
||||||
Dados Funcionais
|
Dados Funcionais
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-base-content/60 mt-0.5">
|
<p class="text-base-content/60 mt-0.5 text-sm">
|
||||||
Informações profissionais e organizacionais
|
Informações profissionais e organizacionais
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1008,43 +1049,56 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div
|
<div
|
||||||
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
|
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/20">
|
<div
|
||||||
<Hash class="text-blue-600 h-5 w-5" strokeWidth={2} />
|
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/20"
|
||||||
|
>
|
||||||
|
<Hash class="h-5 w-5 text-blue-600" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide"
|
<span
|
||||||
|
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||||
>Matrícula</span
|
>Matrícula</span
|
||||||
>
|
>
|
||||||
<p class="text-base-content text-base font-semibold mt-1">
|
<p class="text-base-content mt-1 text-base font-semibold">
|
||||||
{funcionario.matricula || 'Não informada'}
|
{funcionario.matricula || 'Não informada'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
|
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-green-500/20 to-green-600/20">
|
<div
|
||||||
<CreditCard class="text-green-600 h-5 w-5" strokeWidth={2} />
|
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-green-500/20 to-green-600/20"
|
||||||
|
>
|
||||||
|
<CreditCard class="h-5 w-5 text-green-600" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide">CPF</span>
|
<span
|
||||||
<p class="text-base-content text-base font-semibold mt-1">
|
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||||
|
>CPF</span
|
||||||
|
>
|
||||||
|
<p class="text-base-content mt-1 text-base font-semibold">
|
||||||
{funcionario.cpf}
|
{funcionario.cpf}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
|
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500/20 to-orange-600/20">
|
<div
|
||||||
<Building2 class="text-orange-600 h-5 w-5" strokeWidth={2} />
|
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500/20 to-orange-600/20"
|
||||||
|
>
|
||||||
|
<Building2 class="h-5 w-5 text-orange-600" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide">Time</span>
|
<span
|
||||||
|
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||||
|
>Time</span
|
||||||
|
>
|
||||||
{#if meuTime}
|
{#if meuTime}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div
|
<div
|
||||||
@@ -1064,13 +1118,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="hover:bg-base-200/60 flex items-start gap-3 rounded-lg border border-base-300/50 p-4 transition-all shadow-sm hover:shadow-md"
|
class="hover:bg-base-200/60 border-base-300/50 flex items-start gap-3 rounded-lg border p-4 shadow-sm transition-all hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-success/20 to-success/30">
|
<div
|
||||||
|
class="from-success/20 to-success/30 flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br"
|
||||||
|
>
|
||||||
<CheckCircle class="text-success h-5 w-5" strokeWidth={2} />
|
<CheckCircle class="text-success h-5 w-5" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="label-text text-base-content/60 text-xs font-semibold uppercase tracking-wide"
|
<span
|
||||||
|
class="label-text text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||||
>Status Atual</span
|
>Status Atual</span
|
||||||
>
|
>
|
||||||
{#if funcionario.statusFerias === 'em_ferias'}
|
{#if funcionario.statusFerias === 'em_ferias'}
|
||||||
@@ -1078,7 +1135,9 @@
|
|||||||
🏖️ Em Férias
|
🏖️ Em Férias
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="badge badge-success badge-lg mt-2 font-bold shadow-sm">✅ Ativo</div>
|
<div class="badge badge-success badge-lg mt-2 font-bold shadow-sm">
|
||||||
|
✅ Ativo
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1091,12 +1150,10 @@
|
|||||||
<!-- Times Gerenciados PREMIUM -->
|
<!-- Times Gerenciados PREMIUM -->
|
||||||
{#if ehGestor}
|
{#if ehGestor}
|
||||||
<div
|
<div
|
||||||
class="card border-t-4 border-warning bg-gradient-to-br from-warning/10 via-base-100 to-warning/5 shadow-2xl"
|
class="card border-warning from-warning/10 via-base-100 to-warning/5 border-t-4 bg-gradient-to-br shadow-2xl"
|
||||||
>
|
>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2
|
<h2 class="card-title text-warning mb-6 flex items-center gap-2 text-2xl">
|
||||||
class="card-title mb-6 flex items-center gap-2 text-2xl text-warning"
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-7 w-7"
|
class="h-7 w-7"
|
||||||
@@ -1609,18 +1666,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lista de Solicitações -->
|
<!-- Lista de Solicitações -->
|
||||||
<div class="card bg-gradient-to-br from-base-100 to-base-200 border-t-4 border-primary shadow-2xl overflow-hidden">
|
<div
|
||||||
|
class="card from-base-100 to-base-200 border-primary overflow-hidden border-t-4 bg-gradient-to-br shadow-2xl"
|
||||||
|
>
|
||||||
<div class="card-body p-6">
|
<div class="card-body p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-primary/80 shadow-lg ring-2 ring-primary/20">
|
<div
|
||||||
|
class="from-primary to-primary/80 ring-primary/20 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br shadow-lg ring-2"
|
||||||
|
>
|
||||||
<ListChecks class="h-6 w-6 text-white" strokeWidth={2.5} />
|
<ListChecks class="h-6 w-6 text-white" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-base-content flex items-center gap-2">
|
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
|
||||||
Minhas Solicitações
|
Minhas Solicitações
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-base-content/60 mt-0.5">
|
<p class="text-base-content/60 mt-0.5 text-sm">
|
||||||
Histórico de solicitações de férias
|
Histórico de solicitações de férias
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1632,32 +1693,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if solicitacoesFiltradas.length === 0}
|
{#if solicitacoesFiltradas.length === 0}
|
||||||
<div class="alert alert-info shadow-lg border border-info/20">
|
<div class="alert alert-info border-info/20 border shadow-lg">
|
||||||
<Info class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
<Info class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||||
<span class="font-semibold">Nenhuma solicitação encontrada com os filtros aplicados.</span>
|
<span class="font-semibold"
|
||||||
|
>Nenhuma solicitação encontrada com os filtros aplicados.</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-x-auto rounded-lg border border-base-300 shadow-inner">
|
<div class="border-base-300 overflow-x-auto rounded-lg border shadow-inner">
|
||||||
<table class="table table-zebra">
|
<table class="table-zebra table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gradient-to-r from-base-200 to-base-300">
|
<tr class="from-base-200 to-base-300 bg-gradient-to-r">
|
||||||
<th class="font-bold text-base-content">Ano</th>
|
<th class="text-base-content font-bold">Ano</th>
|
||||||
<th class="font-bold text-base-content">Período</th>
|
<th class="text-base-content font-bold">Período</th>
|
||||||
<th class="font-bold text-base-content">Dias</th>
|
<th class="text-base-content font-bold">Dias</th>
|
||||||
<th class="font-bold text-base-content">Status</th>
|
<th class="text-base-content font-bold">Status</th>
|
||||||
<th class="font-bold text-base-content">Solicitado em</th>
|
<th class="text-base-content font-bold">Solicitado em</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each solicitacoesFiltradas as periodo (periodo._id)}
|
{#each solicitacoesFiltradas as periodo (periodo._id)}
|
||||||
<tr class="hover:bg-base-200/50 transition-all duration-200 border-b border-base-300">
|
<tr
|
||||||
<td class="font-semibold text-base-content/80">{periodo.anoReferencia}</td>
|
class="hover:bg-base-200/50 border-base-300 border-b transition-all duration-200"
|
||||||
<td class="font-medium text-base-content/70">
|
>
|
||||||
|
<td class="text-base-content/80 font-semibold">{periodo.anoReferencia}</td
|
||||||
|
>
|
||||||
|
<td class="text-base-content/70 font-medium">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<CalendarDays class="h-3.5 w-3.5 text-base-content/50" strokeWidth={2} />
|
<CalendarDays
|
||||||
<span>{formatarDataString(periodo.dataInicio)} - {formatarDataString(
|
class="text-base-content/50 h-3.5 w-3.5"
|
||||||
periodo.dataFim
|
strokeWidth={2}
|
||||||
)}</span>
|
/>
|
||||||
|
<span
|
||||||
|
>{formatarDataString(periodo.dataInicio)} - {formatarDataString(
|
||||||
|
periodo.dataFim
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -1666,11 +1737,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class={`badge badge-sm font-semibold shadow-sm ${getStatusBadge(periodo.status)}`}>
|
<div
|
||||||
|
class={`badge badge-sm font-semibold shadow-sm ${getStatusBadge(periodo.status)}`}
|
||||||
|
>
|
||||||
{getStatusTexto(periodo.status)}
|
{getStatusTexto(periodo.status)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs text-base-content/60 font-medium"
|
<td class="text-base-content/60 text-xs font-medium"
|
||||||
>{new Date(periodo._creationTime).toLocaleDateString('pt-BR')}</td
|
>{new Date(periodo._creationTime).toLocaleDateString('pt-BR')}</td
|
||||||
>
|
>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1973,18 +2046,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if abaAtiva === 'aprovar-ferias'}
|
{:else if abaAtiva === 'aprovar-ferias'}
|
||||||
<!-- Aprovar Férias (Gestores) PREMIUM -->
|
<!-- Aprovar Férias (Gestores) PREMIUM -->
|
||||||
<div class="card bg-gradient-to-br from-base-100 to-base-200 border-t-4 border-green-500 shadow-2xl overflow-hidden">
|
<div
|
||||||
|
class="card from-base-100 to-base-200 overflow-hidden border-t-4 border-green-500 bg-gradient-to-br shadow-2xl"
|
||||||
|
>
|
||||||
<div class="card-body p-6">
|
<div class="card-body p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-green-600 shadow-lg ring-2 ring-green-500/20">
|
<div
|
||||||
|
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-green-500 to-green-600 shadow-lg ring-2 ring-green-500/20"
|
||||||
|
>
|
||||||
<Users class="h-6 w-6 text-white" strokeWidth={2.5} />
|
<Users class="h-6 w-6 text-white" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-base-content flex items-center gap-2">
|
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
|
||||||
Solicitações da Equipe
|
Solicitações da Equipe
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-base-content/60 mt-0.5">
|
<p class="text-base-content/60 mt-0.5 text-sm">
|
||||||
Gerencie as solicitações de férias da sua equipe
|
Gerencie as solicitações de férias da sua equipe
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1996,29 +2073,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if solicitacoesSubordinados.length === 0}
|
{#if solicitacoesSubordinados.length === 0}
|
||||||
<div class="alert alert-success shadow-lg border border-success/20">
|
<div class="alert alert-success border-success/20 border shadow-lg">
|
||||||
<CheckCircle2 class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
<CheckCircle2 class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||||
<span class="font-semibold">Nenhuma solicitação pendente no momento.</span>
|
<span class="font-semibold">Nenhuma solicitação pendente no momento.</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-x-auto rounded-lg border border-base-300 shadow-inner">
|
<div class="border-base-300 overflow-x-auto rounded-lg border shadow-inner">
|
||||||
<table class="table table-zebra">
|
<table class="table-zebra table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gradient-to-r from-base-200 to-base-300">
|
<tr class="from-base-200 to-base-300 bg-gradient-to-r">
|
||||||
<th class="font-bold text-base-content">Funcionário</th>
|
<th class="text-base-content font-bold">Funcionário</th>
|
||||||
<th class="font-bold text-base-content">Time</th>
|
<th class="text-base-content font-bold">Time</th>
|
||||||
<th class="font-bold text-base-content">Ano</th>
|
<th class="text-base-content font-bold">Ano</th>
|
||||||
<th class="font-bold text-base-content">Período</th>
|
<th class="text-base-content font-bold">Período</th>
|
||||||
<th class="font-bold text-base-content">Dias</th>
|
<th class="text-base-content font-bold">Dias</th>
|
||||||
<th class="font-bold text-base-content">Status</th>
|
<th class="text-base-content font-bold">Status</th>
|
||||||
<th class="font-bold text-base-content text-center">Ações</th>
|
<th class="text-base-content text-center font-bold">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each solicitacoesSubordinados as periodo (periodo._id)}
|
{#each solicitacoesSubordinados as periodo (periodo._id)}
|
||||||
<tr class="hover:bg-base-200/50 transition-all duration-200 border-b border-base-300">
|
<tr
|
||||||
|
class="hover:bg-base-200/50 border-base-300 border-b transition-all duration-200"
|
||||||
|
>
|
||||||
<td>
|
<td>
|
||||||
<div class="font-semibold text-base-content">
|
<div class="text-base-content font-semibold">
|
||||||
{periodo.funcionario?.nome}
|
{periodo.funcionario?.nome}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -2033,13 +2112,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-semibold text-base-content/80">{periodo.anoReferencia}</td>
|
<td class="text-base-content/80 font-semibold">{periodo.anoReferencia}</td>
|
||||||
<td class="font-medium text-base-content/70">
|
<td class="text-base-content/70 font-medium">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<CalendarDays class="h-3.5 w-3.5 text-base-content/50" strokeWidth={2} />
|
<CalendarDays
|
||||||
<span>{formatarDataString(periodo.dataInicio)} - {formatarDataString(
|
class="text-base-content/50 h-3.5 w-3.5"
|
||||||
periodo.dataFim
|
strokeWidth={2}
|
||||||
)}</span>
|
/>
|
||||||
|
<span
|
||||||
|
>{formatarDataString(periodo.dataInicio)} - {formatarDataString(
|
||||||
|
periodo.dataFim
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -2068,7 +2152,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-sm gap-2 hover:bg-base-300 transition-all duration-200"
|
class="btn btn-ghost btn-sm hover:bg-base-300 gap-2 transition-all duration-200"
|
||||||
onclick={() => selecionarPeriodo(periodo._id)}
|
onclick={() => selecionarPeriodo(periodo._id)}
|
||||||
>
|
>
|
||||||
<Eye class="h-4 w-4" strokeWidth={2} />
|
<Eye class="h-4 w-4" strokeWidth={2} />
|
||||||
@@ -2087,18 +2171,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if abaAtiva === 'aprovar-ausencias'}
|
{:else if abaAtiva === 'aprovar-ausencias'}
|
||||||
<!-- Aprovar Ausências (Gestores) -->
|
<!-- Aprovar Ausências (Gestores) -->
|
||||||
<div class="card bg-gradient-to-br from-base-100 to-base-200 border-t-4 border-orange-500 shadow-2xl overflow-hidden">
|
<div
|
||||||
|
class="card from-base-100 to-base-200 overflow-hidden border-t-4 border-orange-500 bg-gradient-to-br shadow-2xl"
|
||||||
|
>
|
||||||
<div class="card-body p-6">
|
<div class="card-body p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 shadow-lg ring-2 ring-orange-500/20">
|
<div
|
||||||
|
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 shadow-lg ring-2 ring-orange-500/20"
|
||||||
|
>
|
||||||
<Clock class="h-6 w-6 text-white" strokeWidth={2.5} />
|
<Clock class="h-6 w-6 text-white" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-base-content flex items-center gap-2">
|
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
|
||||||
Solicitações de Ausências da Equipe
|
Solicitações de Ausências da Equipe
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-base-content/60 mt-0.5">
|
<p class="text-base-content/60 mt-0.5 text-sm">
|
||||||
Gerencie as solicitações de ausências da sua equipe
|
Gerencie as solicitações de ausências da sua equipe
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2110,28 +2198,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if ausenciasSubordinados.length === 0}
|
{#if ausenciasSubordinados.length === 0}
|
||||||
<div class="alert alert-success shadow-lg border border-success/20">
|
<div class="alert alert-success border-success/20 border shadow-lg">
|
||||||
<CheckCircle2 class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
<CheckCircle2 class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||||
<span class="font-semibold">Nenhuma solicitação pendente no momento.</span>
|
<span class="font-semibold">Nenhuma solicitação pendente no momento.</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-x-auto rounded-lg border border-base-300 shadow-inner">
|
<div class="border-base-300 overflow-x-auto rounded-lg border shadow-inner">
|
||||||
<table class="table table-zebra">
|
<table class="table-zebra table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-gradient-to-r from-base-200 to-base-300">
|
<tr class="from-base-200 to-base-300 bg-gradient-to-r">
|
||||||
<th class="font-bold text-base-content">Funcionário</th>
|
<th class="text-base-content font-bold">Funcionário</th>
|
||||||
<th class="font-bold text-base-content">Time</th>
|
<th class="text-base-content font-bold">Time</th>
|
||||||
<th class="font-bold text-base-content">Período</th>
|
<th class="text-base-content font-bold">Período</th>
|
||||||
<th class="font-bold text-base-content">Dias</th>
|
<th class="text-base-content font-bold">Dias</th>
|
||||||
<th class="font-bold text-base-content">Status</th>
|
<th class="text-base-content font-bold">Status</th>
|
||||||
<th class="font-bold text-base-content text-center">Ações</th>
|
<th class="text-base-content text-center font-bold">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each ausenciasSubordinados as ausencia (ausencia._id)}
|
{#each ausenciasSubordinados as ausencia (ausencia._id)}
|
||||||
<tr class="hover:bg-base-200/50 transition-all duration-200 border-b border-base-300">
|
<tr
|
||||||
|
class="hover:bg-base-200/50 border-base-300 border-b transition-all duration-200"
|
||||||
|
>
|
||||||
<td>
|
<td>
|
||||||
<div class="font-semibold text-base-content">
|
<div class="text-base-content font-semibold">
|
||||||
{ausencia.funcionario?.nome || 'N/A'}
|
{ausencia.funcionario?.nome || 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -2147,9 +2237,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-medium text-base-content/70">
|
<td class="text-base-content/70 font-medium">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<CalendarDays class="h-3.5 w-3.5 text-base-content/50" strokeWidth={2} />
|
<CalendarDays
|
||||||
|
class="text-base-content/50 h-3.5 w-3.5"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
<span>
|
<span>
|
||||||
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até
|
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até
|
||||||
{new Date(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
{new Date(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||||
@@ -2186,7 +2279,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-sm gap-2 hover:bg-base-300 transition-all duration-200"
|
class="btn btn-ghost btn-sm hover:bg-base-300 gap-2 transition-all duration-200"
|
||||||
onclick={() => (solicitacaoAusenciaAprovar = ausencia._id)}
|
onclick={() => (solicitacaoAusenciaAprovar = ausencia._id)}
|
||||||
>
|
>
|
||||||
<Eye class="h-4 w-4" strokeWidth={2} />
|
<Eye class="h-4 w-4" strokeWidth={2} />
|
||||||
@@ -2465,6 +2558,34 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if abaAtiva === 'meu-ponto'}
|
||||||
|
<!-- Meu Ponto -->
|
||||||
|
<div
|
||||||
|
class="card from-base-100 to-base-200 overflow-hidden border-t-4 border-blue-500 bg-gradient-to-br shadow-2xl"
|
||||||
|
>
|
||||||
|
<div class="card-body p-6">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg ring-2 ring-blue-500/20"
|
||||||
|
>
|
||||||
|
<Fingerprint class="h-6 w-6 text-white" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
|
||||||
|
Meu Ponto
|
||||||
|
</h2>
|
||||||
|
<p class="text-base-content/60 mt-0.5 text-sm">
|
||||||
|
Registre sua entrada, saída e intervalos de trabalho
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RegistroPonto />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Info,
|
Info,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
Clock,
|
||||||
} from "lucide-svelte";
|
} from "lucide-svelte";
|
||||||
import type { Component } from "svelte";
|
import type { Component } from "svelte";
|
||||||
|
|
||||||
@@ -119,6 +120,22 @@
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
categoria: "Controle de Ponto",
|
||||||
|
descricao: "Gerencie registros de ponto dos funcionários",
|
||||||
|
Icon: Clock,
|
||||||
|
gradient: "from-cyan-500/10 to-cyan-600/20",
|
||||||
|
accentColor: "text-cyan-600",
|
||||||
|
bgIcon: "bg-cyan-500/20",
|
||||||
|
opcoes: [
|
||||||
|
{
|
||||||
|
nome: "Registro de Pontos",
|
||||||
|
descricao: "Visualizar e gerenciar registros de ponto",
|
||||||
|
href: "/recursos-humanos/registro-pontos",
|
||||||
|
Icon: Clock,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,935 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle, TrendingUp, TrendingDown, FileText } from 'lucide-svelte';
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
// Estados
|
||||||
|
let dataInicio = $state(new Date().toISOString().split('T')[0]!);
|
||||||
|
let dataFim = $state(new Date().toISOString().split('T')[0]!);
|
||||||
|
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
|
||||||
|
let carregando = $state(false);
|
||||||
|
|
||||||
|
// Parâmetros reativos para queries
|
||||||
|
const registrosParams = $derived({
|
||||||
|
funcionarioId: funcionarioIdFiltro || undefined,
|
||||||
|
dataInicio,
|
||||||
|
dataFim,
|
||||||
|
});
|
||||||
|
const estatisticasParams = $derived({
|
||||||
|
dataInicio,
|
||||||
|
dataFim,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||||
|
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams);
|
||||||
|
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, estatisticasParams);
|
||||||
|
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||||
|
|
||||||
|
const funcionarios = $derived(funcionariosQuery?.data || []);
|
||||||
|
const registros = $derived(registrosQuery?.data || []);
|
||||||
|
const estatisticas = $derived(estatisticasQuery?.data);
|
||||||
|
const config = $derived(configQuery?.data);
|
||||||
|
|
||||||
|
// Agrupar registros por funcionário e data
|
||||||
|
const registrosAgrupados = $derived.by(() => {
|
||||||
|
const agrupados: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
|
||||||
|
funcionarioId: Id<'funcionarios'>;
|
||||||
|
registros: typeof registros;
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const registro of registros) {
|
||||||
|
const key = registro.funcionarioId;
|
||||||
|
if (!agrupados[key]) {
|
||||||
|
agrupados[key] = {
|
||||||
|
funcionario: registro.funcionario,
|
||||||
|
funcionarioId: registro.funcionarioId,
|
||||||
|
registros: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
agrupados[key]!.registros.push(registro);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(agrupados);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query para banco de horas de cada funcionário
|
||||||
|
const funcionariosComBancoHoras = $derived.by(() => {
|
||||||
|
return registrosAgrupados.map((grupo) => grupo.funcionarioId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Função para formatar saldo de horas
|
||||||
|
function formatarSaldoHoras(minutos: number): string {
|
||||||
|
const horas = Math.floor(Math.abs(minutos) / 60);
|
||||||
|
const mins = Math.abs(minutos) % 60;
|
||||||
|
const sinal = minutos >= 0 ? '+' : '-';
|
||||||
|
return `${sinal}${horas}h ${mins}min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imprimirFichaPonto(funcionarioId: Id<'funcionarios'>) {
|
||||||
|
const registrosFuncionario = registros.filter((r) => r.funcionarioId === funcionarioId);
|
||||||
|
if (registrosFuncionario.length === 0) {
|
||||||
|
alert('Nenhum registro encontrado para este funcionário no período selecionado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
|
||||||
|
if (!funcionario) {
|
||||||
|
alert('Funcionário não encontrado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = new jsPDF();
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
let yPosition = 20;
|
||||||
|
try {
|
||||||
|
const logoImg = new Image();
|
||||||
|
logoImg.src = logoGovPE;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
logoImg.onload = () => resolve();
|
||||||
|
logoImg.onerror = () => reject();
|
||||||
|
setTimeout(() => reject(), 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoWidth = 25;
|
||||||
|
const aspectRatio = logoImg.height / logoImg.width;
|
||||||
|
const logoHeight = logoWidth * aspectRatio;
|
||||||
|
|
||||||
|
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||||
|
yPosition = Math.max(20, 10 + logoHeight / 2);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Não foi possível carregar a logo:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cabeçalho
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setTextColor(41, 128, 185);
|
||||||
|
doc.text('FICHA DE PONTO', 105, yPosition, { align: 'center' });
|
||||||
|
|
||||||
|
yPosition += 10;
|
||||||
|
|
||||||
|
// Dados do Funcionário
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
if (funcionario.matricula) {
|
||||||
|
doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
doc.text(`Nome: ${funcionario.nome}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
if (funcionario.descricaoCargo) {
|
||||||
|
doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 5;
|
||||||
|
doc.text(`Período: ${dataInicio} a ${dataFim}`, 15, yPosition);
|
||||||
|
yPosition += 10;
|
||||||
|
|
||||||
|
// Tabela de registros
|
||||||
|
const config = await client.query(api.configuracaoPonto.obterConfiguracao, {});
|
||||||
|
const tableData = registrosFuncionario.map((r) => [
|
||||||
|
r.data,
|
||||||
|
config
|
||||||
|
? getTipoRegistroLabel(r.tipo, {
|
||||||
|
nomeEntrada: config.nomeEntrada,
|
||||||
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
||||||
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||||
|
nomeSaida: config.nomeSaida,
|
||||||
|
})
|
||||||
|
: getTipoRegistroLabel(r.tipo),
|
||||||
|
formatarHoraPonto(r.hora, r.minuto),
|
||||||
|
r.dentroDoPrazo ? 'Sim' : 'Não',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Salvar a posição Y antes da tabela
|
||||||
|
const yPosAntesTabela = yPosition;
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPosition,
|
||||||
|
head: [['Data', 'Tipo', 'Horário', 'Dentro do Prazo']],
|
||||||
|
body: tableData,
|
||||||
|
theme: 'grid',
|
||||||
|
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||||
|
styles: { fontSize: 9 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obter banco de horas do funcionário
|
||||||
|
const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, {
|
||||||
|
funcionarioId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular posição Y após a tabela
|
||||||
|
// autoTable armazena a posição final em doc.lastAutoTable.finalY
|
||||||
|
const lastPage = doc.getNumberOfPages();
|
||||||
|
doc.setPage(lastPage);
|
||||||
|
const finalY = (doc as any).lastAutoTable?.finalY;
|
||||||
|
|
||||||
|
// Se não conseguir obter a posição final, estimar baseado no número de linhas
|
||||||
|
if (finalY) {
|
||||||
|
yPosition = finalY;
|
||||||
|
} else {
|
||||||
|
// Estimativa: cada linha da tabela ocupa aproximadamente 7mm
|
||||||
|
const linhasTabela = tableData.length + 1; // +1 para o cabeçalho
|
||||||
|
yPosition = yPosAntesTabela + (linhasTabela * 7) + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar espaço antes do resumo
|
||||||
|
yPosition += 10;
|
||||||
|
|
||||||
|
// Verificar se precisa de nova página
|
||||||
|
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resumo do Banco de Horas
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(41, 128, 185);
|
||||||
|
doc.text('RESUMO DO BANCO DE HORAS', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
|
||||||
|
yPosition += 10;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
if (bancoHoras) {
|
||||||
|
const saldoMinutos = bancoHoras.saldoAcumuladoMinutos;
|
||||||
|
const horas = Math.floor(Math.abs(saldoMinutos) / 60);
|
||||||
|
const minutos = Math.abs(saldoMinutos) % 60;
|
||||||
|
const sinal = saldoMinutos >= 0 ? '+' : '-';
|
||||||
|
const saldoFormatado = `${sinal}${horas}h ${minutos}min`;
|
||||||
|
|
||||||
|
// Saldo Atual
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Saldo Atual:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(saldoFormatado, 60, yPosition);
|
||||||
|
yPosition += 8;
|
||||||
|
|
||||||
|
// Horas Excedentes (se positivo)
|
||||||
|
if (saldoMinutos > 0) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(0, 128, 0); // Verde
|
||||||
|
doc.text('Horas Excedentes:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(`${horas}h ${minutos}min`, 75, yPosition);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
yPosition += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horas a Pagar (se negativo)
|
||||||
|
if (saldoMinutos < 0) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.setTextColor(200, 0, 0); // Vermelho
|
||||||
|
doc.text('Horas a Pagar:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(`${horas}h ${minutos}min`, 70, yPosition);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
yPosition += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total de dias registrados
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Total de Dias com Registro:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(`${bancoHoras.totalDias} dias`, 95, yPosition);
|
||||||
|
} else {
|
||||||
|
doc.text('Banco de horas não disponível', 15, yPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rodapé
|
||||||
|
const pageCount = doc.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(128, 128, 128);
|
||||||
|
doc.text(
|
||||||
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||||
|
doc.internal.pageSize.getWidth() / 2,
|
||||||
|
doc.internal.pageSize.getHeight() - 10,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salvar
|
||||||
|
const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`;
|
||||||
|
doc.save(nomeArquivo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar PDF:', error);
|
||||||
|
alert('Erro ao gerar ficha de ponto. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imprimirDetalhesRegistro(registroId: Id<'registrosPonto'>) {
|
||||||
|
try {
|
||||||
|
// Buscar dados completos do registro
|
||||||
|
const registro = await client.query(api.pontos.obterRegistro, { registroId });
|
||||||
|
|
||||||
|
if (!registro) {
|
||||||
|
alert('Registro não encontrado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new jsPDF();
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
let yPosition = 20;
|
||||||
|
try {
|
||||||
|
const logoImg = new Image();
|
||||||
|
logoImg.src = logoGovPE;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
logoImg.onload = () => resolve();
|
||||||
|
logoImg.onerror = () => reject();
|
||||||
|
setTimeout(() => reject(), 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoWidth = 25;
|
||||||
|
const aspectRatio = logoImg.height / logoImg.width;
|
||||||
|
const logoHeight = logoWidth * aspectRatio;
|
||||||
|
|
||||||
|
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||||
|
yPosition = Math.max(20, 10 + logoHeight / 2);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Não foi possível carregar a logo:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cabeçalho
|
||||||
|
doc.setFontSize(16);
|
||||||
|
doc.setTextColor(41, 128, 185);
|
||||||
|
doc.text('DETALHES DO REGISTRO DE PONTO', 105, yPosition, { align: 'center' });
|
||||||
|
|
||||||
|
yPosition += 15;
|
||||||
|
|
||||||
|
// Informações do Funcionário
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
if (registro.funcionario) {
|
||||||
|
if (registro.funcionario.matricula) {
|
||||||
|
doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
if (registro.funcionario.descricaoCargo) {
|
||||||
|
doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// Informações do Registro
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('DADOS DO REGISTRO', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const dataHora = `${registro.data} ${registro.hora.toString().padStart(2, '0')}:${registro.minuto.toString().padStart(2, '0')}:${registro.segundo.toString().padStart(2, '0')}`;
|
||||||
|
doc.text(`Data e Hora: ${dataHora}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(
|
||||||
|
`Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`,
|
||||||
|
15,
|
||||||
|
yPosition
|
||||||
|
);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.justificativa) {
|
||||||
|
doc.text(`Justificativa: ${registro.justificativa}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 5;
|
||||||
|
|
||||||
|
// Localização
|
||||||
|
if (registro.latitude && registro.longitude) {
|
||||||
|
// Verificar se precisa de nova página
|
||||||
|
if (yPosition > 200) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('LOCALIZAÇÃO', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
doc.text(`Latitude: ${registro.latitude.toFixed(6)}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
doc.text(`Longitude: ${registro.longitude.toFixed(6)}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.precisao) {
|
||||||
|
doc.text(`Precisão: ${registro.precisao.toFixed(2)} metros`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.endereco) {
|
||||||
|
doc.text(`Endereço: ${registro.endereco}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.cidade) {
|
||||||
|
doc.text(`Cidade: ${registro.cidade}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.estado) {
|
||||||
|
doc.text(`Estado: ${registro.estado}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.pais) {
|
||||||
|
doc.text(`País: ${registro.pais}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.timezone) {
|
||||||
|
doc.text(`Fuso Horário: ${registro.timezone}`, 15, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dados Técnicos
|
||||||
|
// Verificar se precisa de nova página
|
||||||
|
if (yPosition > 200) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('DADOS TÉCNICOS', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
yPosition += 8;
|
||||||
|
doc.setFontSize(10);
|
||||||
|
|
||||||
|
// Informações de Rede
|
||||||
|
if (registro.ipAddress || registro.ipPublico || registro.ipLocal) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Rede:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.ipAddress) {
|
||||||
|
doc.text(` IP: ${registro.ipAddress}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.ipPublico) {
|
||||||
|
doc.text(` IP Público: ${registro.ipPublico}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.ipLocal) {
|
||||||
|
doc.text(` IP Local: ${registro.ipLocal}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Informações do Navegador
|
||||||
|
if (registro.browser || registro.userAgent) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Navegador:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.browser) {
|
||||||
|
doc.text(` Navegador: ${registro.browser}${registro.browserVersion ? ` ${registro.browserVersion}` : ''}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.engine) {
|
||||||
|
doc.text(` Engine: ${registro.engine}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.userAgent) {
|
||||||
|
// Quebrar user agent em múltiplas linhas se necessário
|
||||||
|
const userAgentLines = doc.splitTextToSize(` User Agent: ${registro.userAgent}`, 170);
|
||||||
|
doc.text(userAgentLines, 20, yPosition);
|
||||||
|
yPosition += userAgentLines.length * 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Informações do Sistema
|
||||||
|
if (registro.sistemaOperacional || registro.arquitetura) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Sistema:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.sistemaOperacional) {
|
||||||
|
doc.text(` SO: ${registro.sistemaOperacional}${registro.osVersion ? ` ${registro.osVersion}` : ''}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.arquitetura) {
|
||||||
|
doc.text(` Arquitetura: ${registro.arquitetura}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.plataforma) {
|
||||||
|
doc.text(` Plataforma: ${registro.plataforma}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Informações do Dispositivo
|
||||||
|
if (registro.deviceType || registro.screenResolution) {
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Dispositivo:', 15, yPosition);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 6;
|
||||||
|
|
||||||
|
if (registro.deviceType) {
|
||||||
|
doc.text(` Tipo: ${registro.deviceType}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.deviceModel) {
|
||||||
|
doc.text(` Modelo: ${registro.deviceModel}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.screenResolution) {
|
||||||
|
doc.text(` Resolução: ${registro.screenResolution}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.coresTela) {
|
||||||
|
doc.text(` Cores: ${registro.coresTela}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.isMobile || registro.isTablet || registro.isDesktop) {
|
||||||
|
const tipoDispositivo = registro.isMobile ? 'Mobile' : registro.isTablet ? 'Tablet' : 'Desktop';
|
||||||
|
doc.text(` Categoria: ${tipoDispositivo}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.idioma) {
|
||||||
|
doc.text(` Idioma: ${registro.idioma}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.connectionType) {
|
||||||
|
doc.text(` Conexão: ${registro.connectionType}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registro.memoryInfo) {
|
||||||
|
doc.text(` Memória: ${registro.memoryInfo}`, 20, yPosition);
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
yPosition += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imagem capturada (se disponível)
|
||||||
|
if (registro.imagemUrl) {
|
||||||
|
yPosition += 10;
|
||||||
|
// Verificar se precisa de nova página
|
||||||
|
if (yPosition > 200) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' });
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
yPosition += 10;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Carregar imagem usando fetch para evitar problemas de CORS
|
||||||
|
const response = await fetch(registro.imagemUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Erro ao carregar imagem');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
// Converter blob para base64
|
||||||
|
const base64 = await new Promise<string>((resolve, reject) => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
resolve(reader.result);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Erro ao converter imagem'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Erro ao ler imagem'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar elemento de imagem para obter dimensões
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => reject(new Error('Erro ao processar imagem'));
|
||||||
|
img.src = base64;
|
||||||
|
setTimeout(() => reject(new Error('Timeout ao processar imagem')), 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular dimensões para caber na página (largura máxima 80mm, manter proporção)
|
||||||
|
const maxWidth = 80;
|
||||||
|
const maxHeight = 60;
|
||||||
|
let imgWidth = img.width;
|
||||||
|
let imgHeight = img.height;
|
||||||
|
const aspectRatio = imgWidth / imgHeight;
|
||||||
|
|
||||||
|
if (imgWidth > maxWidth || imgHeight > maxHeight) {
|
||||||
|
if (aspectRatio > 1) {
|
||||||
|
// Imagem horizontal
|
||||||
|
imgWidth = maxWidth;
|
||||||
|
imgHeight = maxWidth / aspectRatio;
|
||||||
|
} else {
|
||||||
|
// Imagem vertical
|
||||||
|
imgHeight = maxHeight;
|
||||||
|
imgWidth = maxHeight * aspectRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centralizar imagem
|
||||||
|
const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2;
|
||||||
|
|
||||||
|
// Verificar se cabe na página atual
|
||||||
|
if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) {
|
||||||
|
doc.addPage();
|
||||||
|
yPosition = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar imagem ao PDF usando base64
|
||||||
|
doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight);
|
||||||
|
yPosition += imgHeight + 10;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao adicionar imagem ao PDF:', error);
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.text('Foto não disponível para impressão', 105, yPosition, { align: 'center' });
|
||||||
|
yPosition += 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rodapé
|
||||||
|
const pageCount = doc.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(128, 128, 128);
|
||||||
|
doc.text(
|
||||||
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||||
|
doc.internal.pageSize.getWidth() / 2,
|
||||||
|
doc.internal.pageSize.getHeight() - 10,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salvar
|
||||||
|
const nomeArquivo = `detalhes-ponto-${registro.data}-${registro.hora.toString().padStart(2, '0')}${registro.minuto.toString().padStart(2, '0')}.pdf`;
|
||||||
|
doc.save(nomeArquivo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar PDF detalhado:', error);
|
||||||
|
alert('Erro ao gerar relatório detalhado. Tente novamente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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">Registro de Pontos</h1>
|
||||||
|
<p class="text-base-content/60 mt-1">Gerencie e visualize os registros de ponto dos funcionários</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-4">
|
||||||
|
<Filter class="h-5 w-5" />
|
||||||
|
Filtros
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="data-inicio">
|
||||||
|
<span class="label-text font-medium">Data Início</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="data-inicio"
|
||||||
|
type="date"
|
||||||
|
bind:value={dataInicio}
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="data-fim">
|
||||||
|
<span class="label-text font-medium">Data Fim</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="data-fim"
|
||||||
|
type="date"
|
||||||
|
bind:value={dataFim}
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="funcionario">
|
||||||
|
<span class="label-text font-medium">Funcionário</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="funcionario"
|
||||||
|
bind:value={funcionarioIdFiltro}
|
||||||
|
class="select select-bordered"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{#each funcionarios as funcionario}
|
||||||
|
<option value={funcionario._id}>{funcionario.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Estatísticas -->
|
||||||
|
{#if estatisticas}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
||||||
|
<div class="stat-figure text-primary">
|
||||||
|
<BarChart3 class="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-title">Total de Registros</div>
|
||||||
|
<div class="stat-value text-primary">{estatisticas.totalRegistros}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
||||||
|
<div class="stat-figure text-success">
|
||||||
|
<CheckCircle2 class="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-title">Dentro do Prazo</div>
|
||||||
|
<div class="stat-value text-success">{estatisticas.dentroDoPrazo}</div>
|
||||||
|
<div class="stat-desc">
|
||||||
|
{estatisticas.totalRegistros > 0
|
||||||
|
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
||||||
|
: 0}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
||||||
|
<div class="stat-figure text-error">
|
||||||
|
<XCircle class="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-title">Fora do Prazo</div>
|
||||||
|
<div class="stat-value text-error">{estatisticas.foraDoPrazo}</div>
|
||||||
|
<div class="stat-desc">
|
||||||
|
{estatisticas.totalRegistros > 0
|
||||||
|
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
||||||
|
: 0}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
||||||
|
<div class="stat-figure text-info">
|
||||||
|
<Users class="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-title">Funcionários</div>
|
||||||
|
<div class="stat-value text-info">{estatisticas.totalFuncionarios}</div>
|
||||||
|
<div class="stat-desc">
|
||||||
|
{estatisticas.funcionariosDentroPrazo} dentro do prazo, {estatisticas.funcionariosForaPrazo} fora
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Lista de Registros -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-4">Registros</h2>
|
||||||
|
|
||||||
|
{#if registrosAgrupados.length === 0}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<span>Nenhum registro encontrado para o período selecionado</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each registrosAgrupados as grupo}
|
||||||
|
<div class="card bg-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-bold text-lg">
|
||||||
|
{grupo.funcionario?.nome || 'Funcionário não encontrado'}
|
||||||
|
</h3>
|
||||||
|
{#if grupo.funcionario?.matricula}
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Matrícula: {grupo.funcionario.matricula}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Banco de Horas -->
|
||||||
|
{#key grupo.funcionarioId}
|
||||||
|
{@const bancoHorasQuery = useQuery(
|
||||||
|
api.pontos.obterBancoHorasFuncionario,
|
||||||
|
{ funcionarioId: grupo.funcionarioId }
|
||||||
|
)}
|
||||||
|
{@const bancoHoras = bancoHorasQuery?.data}
|
||||||
|
{@const saldoAcumulado = bancoHoras?.saldoAcumuladoMinutos ?? 0}
|
||||||
|
{@const saldoPositivo = saldoAcumulado >= 0}
|
||||||
|
|
||||||
|
{#if bancoHoras}
|
||||||
|
<div class="mx-4 rounded-lg border-2 p-3 {saldoPositivo ? 'border-success bg-success/10' : 'border-error bg-error/10'}">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if saldoPositivo}
|
||||||
|
<TrendingUp class="h-5 w-5 text-success" />
|
||||||
|
{:else}
|
||||||
|
<TrendingDown class="h-5 w-5 text-error" />
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold opacity-70">Banco de Horas</p>
|
||||||
|
<p class="text-lg font-bold">
|
||||||
|
{formatarSaldoHoras(saldoAcumulado)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
onclick={() => imprimirFichaPonto(grupo.funcionarioId)}
|
||||||
|
>
|
||||||
|
<Printer class="h-4 w-4" />
|
||||||
|
Imprimir Ficha
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 grupo.registros as registro}
|
||||||
|
<tr>
|
||||||
|
<td>{registro.data}</td>
|
||||||
|
<td>
|
||||||
|
{config
|
||||||
|
? getTipoRegistroLabel(registro.tipo, {
|
||||||
|
nomeEntrada: config.nomeEntrada,
|
||||||
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
||||||
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||||
|
nomeSaida: config.nomeSaida,
|
||||||
|
})
|
||||||
|
: getTipoRegistroLabel(registro.tipo)}
|
||||||
|
</td>
|
||||||
|
<td>{formatarHoraPonto(registro.hora, registro.minuto)}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}"
|
||||||
|
>
|
||||||
|
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-primary gap-2"
|
||||||
|
onclick={() => imprimirDetalhesRegistro(registro._id)}
|
||||||
|
title="Imprimir Detalhes"
|
||||||
|
>
|
||||||
|
<FileText class="h-4 w-4" />
|
||||||
|
Imprimir Detalhes
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import WidgetGestaoPontos from '$lib/components/ponto/WidgetGestaoPontos.svelte';
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
categoria: "Gestão de Ausências",
|
categoria: "Gestão de Ausências",
|
||||||
@@ -113,6 +114,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Widget Gestão de Pontos -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<WidgetGestaoPontos />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Card de Ajuda -->
|
<!-- Card de Ajuda -->
|
||||||
<div class="alert alert-info shadow-lg mt-8">
|
<div class="alert alert-info shadow-lg mt-8">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -258,6 +258,24 @@
|
|||||||
palette: 'secondary',
|
palette: 'secondary',
|
||||||
icon: 'envelope'
|
icon: 'envelope'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Configurações de Ponto',
|
||||||
|
description:
|
||||||
|
'Configure os horários de trabalho, intervalos e tolerâncias para o sistema de controle de ponto.',
|
||||||
|
ctaLabel: 'Configurar Ponto',
|
||||||
|
href: '/(dashboard)/ti/configuracoes-ponto',
|
||||||
|
palette: 'primary',
|
||||||
|
icon: 'clock'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Configurações de Relógio',
|
||||||
|
description:
|
||||||
|
'Configure a sincronização de tempo com servidor NTP ou use o relógio do PC como fallback.',
|
||||||
|
ctaLabel: 'Configurar Relógio',
|
||||||
|
href: '/(dashboard)/ti/configuracoes-relogio',
|
||||||
|
palette: 'info',
|
||||||
|
icon: 'clock'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Monitoramento de Emails',
|
title: 'Monitoramento de Emails',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import { Clock, Save, CheckCircle2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||||
|
|
||||||
|
let horarioEntrada = $state('08:00');
|
||||||
|
let horarioSaidaAlmoco = $state('12:00');
|
||||||
|
let horarioRetornoAlmoco = $state('13:00');
|
||||||
|
let horarioSaida = $state('17:00');
|
||||||
|
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 mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (configQuery?.data) {
|
||||||
|
horarioEntrada = configQuery.data.horarioEntrada;
|
||||||
|
horarioSaidaAlmoco = configQuery.data.horarioSaidaAlmoco;
|
||||||
|
horarioRetornoAlmoco = configQuery.data.horarioRetornoAlmoco;
|
||||||
|
horarioSaida = configQuery.data.horarioSaida;
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
|
||||||
|
mensagem = { tipo, texto };
|
||||||
|
setTimeout(() => {
|
||||||
|
mensagem = null;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function salvarConfiguracao() {
|
||||||
|
// Validação básica
|
||||||
|
if (!horarioEntrada || !horarioSaidaAlmoco || !horarioRetornoAlmoco || !horarioSaida) {
|
||||||
|
mostrarMensagem('error', 'Preencha todos os horários');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toleranciaMinutos < 0 || toleranciaMinutos > 60) {
|
||||||
|
mostrarMensagem('error', 'Tolerância deve estar entre 0 e 60 minutos');
|
||||||
|
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;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.configuracaoPonto.salvarConfiguracao, {
|
||||||
|
horarioEntrada,
|
||||||
|
horarioSaidaAlmoco,
|
||||||
|
horarioRetornoAlmoco,
|
||||||
|
horarioSaida,
|
||||||
|
toleranciaMinutos,
|
||||||
|
nomeEntrada: nomeEntrada.trim(),
|
||||||
|
nomeSaidaAlmoco: nomeSaidaAlmoco.trim(),
|
||||||
|
nomeRetornoAlmoco: nomeRetornoAlmoco.trim(),
|
||||||
|
nomeSaida: nomeSaida.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mostrarMensagem('success', 'Configuração salva com sucesso!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao salvar configuração:', error);
|
||||||
|
mostrarMensagem('error', error instanceof Error ? error.message : 'Erro ao salvar configuração');
|
||||||
|
} finally {
|
||||||
|
processando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||||
|
<!-- 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">Configurações de Ponto</h1>
|
||||||
|
<p class="text-base-content/60 mt-1">Configure os horários de trabalho e tolerâncias</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensagens -->
|
||||||
|
{#if mensagem}
|
||||||
|
<div
|
||||||
|
class="alert mb-6"
|
||||||
|
class:alert-success={mensagem.tipo === 'success'}
|
||||||
|
class:alert-error={mensagem.tipo === 'error'}
|
||||||
|
>
|
||||||
|
<CheckCircle2 class="h-6 w-6" />
|
||||||
|
<span>{mensagem.texto}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Formulário -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-4">Horários de Trabalho</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Entrada -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="horario-entrada">
|
||||||
|
<span class="label-text font-medium">Horário de Entrada *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="horario-entrada"
|
||||||
|
type="time"
|
||||||
|
bind:value={horarioEntrada}
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Saída para Almoço -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="horario-saida-almoco">
|
||||||
|
<span class="label-text font-medium">Saída para Almoço *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="horario-saida-almoco"
|
||||||
|
type="time"
|
||||||
|
bind:value={horarioSaidaAlmoco}
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Retorno do Almoço -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="horario-retorno-almoco">
|
||||||
|
<span class="label-text font-medium">Retorno do Almoço *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="horario-retorno-almoco"
|
||||||
|
type="time"
|
||||||
|
bind:value={horarioRetornoAlmoco}
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Saída -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="horario-saida">
|
||||||
|
<span class="label-text font-medium">Horário de Saída *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="horario-saida"
|
||||||
|
type="time"
|
||||||
|
bind:value={horarioSaida}
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- Tolerância -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="tolerancia">
|
||||||
|
<span class="label-text font-medium">Tolerância (minutos) *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="tolerancia"
|
||||||
|
type="number"
|
||||||
|
bind:value={toleranciaMinutos}
|
||||||
|
min="0"
|
||||||
|
max="60"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt"
|
||||||
|
>Tempo de tolerância para registros antes ou depois do horário configurado</span
|
||||||
|
>
|
||||||
|
</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 -->
|
||||||
|
<div class="card-actions justify-end mt-6">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={salvarConfiguracao}
|
||||||
|
disabled={processando}
|
||||||
|
>
|
||||||
|
{#if processando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
<Save class="h-5 w-5" />
|
||||||
|
{/if}
|
||||||
|
Salvar Configuração
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import { Clock, Save, CheckCircle2, AlertCircle, RefreshCw } from 'lucide-svelte';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
const configQuery = useQuery(api.configuracaoRelogio.obterConfiguracao, {});
|
||||||
|
|
||||||
|
let servidorNTP = $state('pool.ntp.org');
|
||||||
|
let portaNTP = $state(123);
|
||||||
|
let usarServidorExterno = $state(false);
|
||||||
|
let fallbackParaPC = $state(true);
|
||||||
|
let gmtOffset = $state(0);
|
||||||
|
let processando = $state(false);
|
||||||
|
let testando = $state(false);
|
||||||
|
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (configQuery?.data) {
|
||||||
|
servidorNTP = configQuery.data.servidorNTP || 'pool.ntp.org';
|
||||||
|
portaNTP = configQuery.data.portaNTP || 123;
|
||||||
|
usarServidorExterno = configQuery.data.usarServidorExterno || false;
|
||||||
|
fallbackParaPC = configQuery.data.fallbackParaPC !== undefined ? configQuery.data.fallbackParaPC : true;
|
||||||
|
gmtOffset = configQuery.data.gmtOffset ?? 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
|
||||||
|
mensagem = { tipo, texto };
|
||||||
|
setTimeout(() => {
|
||||||
|
mensagem = null;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function salvarConfiguracao() {
|
||||||
|
if (usarServidorExterno) {
|
||||||
|
if (!servidorNTP || servidorNTP.trim() === '') {
|
||||||
|
mostrarMensagem('error', 'Servidor NTP é obrigatório quando usar servidor externo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (portaNTP < 1 || portaNTP > 65535) {
|
||||||
|
mostrarMensagem('error', 'Porta NTP deve estar entre 1 e 65535');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processando = true;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.configuracaoRelogio.salvarConfiguracao, {
|
||||||
|
servidorNTP: usarServidorExterno ? servidorNTP : undefined,
|
||||||
|
portaNTP: usarServidorExterno ? portaNTP : undefined,
|
||||||
|
usarServidorExterno,
|
||||||
|
fallbackParaPC,
|
||||||
|
gmtOffset,
|
||||||
|
});
|
||||||
|
|
||||||
|
mostrarMensagem('success', 'Configuração salva com sucesso!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao salvar configuração:', error);
|
||||||
|
mostrarMensagem('error', error instanceof Error ? error.message : 'Erro ao salvar configuração');
|
||||||
|
} finally {
|
||||||
|
processando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testarSincronizacao() {
|
||||||
|
testando = true;
|
||||||
|
mensagem = null;
|
||||||
|
try {
|
||||||
|
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
mostrarMensagem(
|
||||||
|
'success',
|
||||||
|
resultado.usandoServidorExterno
|
||||||
|
? `Sincronização bem-sucedida! Offset: ${resultado.offsetSegundos}s`
|
||||||
|
: 'Usando relógio do PC (servidor externo não disponível)'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
mostrarMensagem('error', 'Falha na sincronização');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao testar sincronização:', error);
|
||||||
|
mostrarMensagem('error', error instanceof Error ? error.message : 'Erro ao testar sincronização');
|
||||||
|
} finally {
|
||||||
|
testando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||||
|
<!-- 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">Configurações de Relógio</h1>
|
||||||
|
<p class="text-base-content/60 mt-1">Configure a sincronização de tempo do sistema</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mensagens -->
|
||||||
|
{#if mensagem}
|
||||||
|
<div
|
||||||
|
class="alert mb-6"
|
||||||
|
class:alert-success={mensagem.tipo === 'success'}
|
||||||
|
class:alert-error={mensagem.tipo === 'error'}
|
||||||
|
>
|
||||||
|
{#if mensagem.tipo === 'success'}
|
||||||
|
<CheckCircle2 class="h-6 w-6" />
|
||||||
|
{:else}
|
||||||
|
<AlertCircle class="h-6 w-6" />
|
||||||
|
{/if}
|
||||||
|
<span>{mensagem.texto}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Formulário -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-4">Sincronização de Tempo</h2>
|
||||||
|
|
||||||
|
<!-- Usar Servidor Externo -->
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label cursor-pointer gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={usarServidorExterno}
|
||||||
|
class="checkbox checkbox-primary"
|
||||||
|
/>
|
||||||
|
<span class="label-text font-medium">Usar servidor NTP externo</span>
|
||||||
|
</label>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt"
|
||||||
|
>Sincronizar com servidor de tempo externo (NTP) em vez de usar o relógio do PC</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if usarServidorExterno}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<!-- Servidor NTP -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="servidor-ntp">
|
||||||
|
<span class="label-text font-medium">Servidor NTP *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="servidor-ntp"
|
||||||
|
type="text"
|
||||||
|
bind:value={servidorNTP}
|
||||||
|
placeholder="pool.ntp.org"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Ex: pool.ntp.org, time.google.com, time.windows.com</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Porta NTP -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="porta-ntp">
|
||||||
|
<span class="label-text font-medium">Porta NTP *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="porta-ntp"
|
||||||
|
type="number"
|
||||||
|
bind:value={portaNTP}
|
||||||
|
placeholder="123"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
class="input input-bordered"
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt">Porta padrão: 123</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Fallback para PC -->
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label cursor-pointer gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={fallbackParaPC}
|
||||||
|
class="checkbox checkbox-primary"
|
||||||
|
/>
|
||||||
|
<span class="label-text font-medium">Usar relógio do PC se servidor externo falhar</span>
|
||||||
|
</label>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt"
|
||||||
|
>Se marcado, o sistema usará o relógio do PC caso não consiga sincronizar com o servidor
|
||||||
|
externo</span
|
||||||
|
>
|
||||||
|
</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 -->
|
||||||
|
<div class="card-actions justify-end mt-6 gap-3">
|
||||||
|
{#if usarServidorExterno}
|
||||||
|
<button
|
||||||
|
class="btn btn-outline btn-info"
|
||||||
|
onclick={testarSincronizacao}
|
||||||
|
disabled={testando || processando}
|
||||||
|
>
|
||||||
|
{#if testando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
<RefreshCw class="h-5 w-5" />
|
||||||
|
{/if}
|
||||||
|
Testar Sincronização
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={salvarConfiguracao}
|
||||||
|
disabled={processando || testando}
|
||||||
|
>
|
||||||
|
{#if processando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else}
|
||||||
|
<Save class="h-5 w-5" />
|
||||||
|
{/if}
|
||||||
|
Salvar Configuração
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informações -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mt-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-4">Informações</h2>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<AlertCircle class="h-6 w-6" />
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<strong>Nota:</strong> O sistema usa uma API HTTP para sincronização de tempo como
|
||||||
|
aproximação do protocolo NTP. Para sincronização NTP real, seria necessário uma biblioteca
|
||||||
|
específica.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mt-1">
|
||||||
|
Servidores NTP recomendados: pool.ntp.org, time.google.com, time.windows.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
6
packages/backend/convex/_generated/api.d.ts
vendored
6
packages/backend/convex/_generated/api.d.ts
vendored
@@ -21,6 +21,8 @@ import type * as auth_utils from "../auth/utils.js";
|
|||||||
import type * as chamados from "../chamados.js";
|
import type * as chamados from "../chamados.js";
|
||||||
import type * as chat from "../chat.js";
|
import type * as chat from "../chat.js";
|
||||||
import type * as configuracaoEmail from "../configuracaoEmail.js";
|
import type * as configuracaoEmail from "../configuracaoEmail.js";
|
||||||
|
import type * as configuracaoPonto from "../configuracaoPonto.js";
|
||||||
|
import type * as configuracaoRelogio from "../configuracaoRelogio.js";
|
||||||
import type * as crons from "../crons.js";
|
import type * as crons from "../crons.js";
|
||||||
import type * as cursos from "../cursos.js";
|
import type * as cursos from "../cursos.js";
|
||||||
import type * as dashboard from "../dashboard.js";
|
import type * as dashboard from "../dashboard.js";
|
||||||
@@ -35,6 +37,7 @@ import type * as logsAtividades from "../logsAtividades.js";
|
|||||||
import type * as logsLogin from "../logsLogin.js";
|
import type * as logsLogin from "../logsLogin.js";
|
||||||
import type * as monitoramento from "../monitoramento.js";
|
import type * as monitoramento from "../monitoramento.js";
|
||||||
import type * as permissoesAcoes from "../permissoesAcoes.js";
|
import type * as permissoesAcoes from "../permissoesAcoes.js";
|
||||||
|
import type * as pontos from "../pontos.js";
|
||||||
import type * as preferenciasNotificacao from "../preferenciasNotificacao.js";
|
import type * as preferenciasNotificacao from "../preferenciasNotificacao.js";
|
||||||
import type * as pushNotifications from "../pushNotifications.js";
|
import type * as pushNotifications from "../pushNotifications.js";
|
||||||
import type * as roles from "../roles.js";
|
import type * as roles from "../roles.js";
|
||||||
@@ -70,6 +73,8 @@ declare const fullApi: ApiFromModules<{
|
|||||||
chamados: typeof chamados;
|
chamados: typeof chamados;
|
||||||
chat: typeof chat;
|
chat: typeof chat;
|
||||||
configuracaoEmail: typeof configuracaoEmail;
|
configuracaoEmail: typeof configuracaoEmail;
|
||||||
|
configuracaoPonto: typeof configuracaoPonto;
|
||||||
|
configuracaoRelogio: typeof configuracaoRelogio;
|
||||||
crons: typeof crons;
|
crons: typeof crons;
|
||||||
cursos: typeof cursos;
|
cursos: typeof cursos;
|
||||||
dashboard: typeof dashboard;
|
dashboard: typeof dashboard;
|
||||||
@@ -84,6 +89,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
logsLogin: typeof logsLogin;
|
logsLogin: typeof logsLogin;
|
||||||
monitoramento: typeof monitoramento;
|
monitoramento: typeof monitoramento;
|
||||||
permissoesAcoes: typeof permissoesAcoes;
|
permissoesAcoes: typeof permissoesAcoes;
|
||||||
|
pontos: typeof pontos;
|
||||||
preferenciasNotificacao: typeof preferenciasNotificacao;
|
preferenciasNotificacao: typeof preferenciasNotificacao;
|
||||||
pushNotifications: typeof pushNotifications;
|
pushNotifications: typeof pushNotifications;
|
||||||
roles: typeof roles;
|
roles: typeof roles;
|
||||||
|
|||||||
145
packages/backend/convex/configuracaoPonto.ts
Normal file
145
packages/backend/convex/configuracaoPonto.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { v } from 'convex/values';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
import type { Id } from './_generated/dataModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida formato de horário HH:mm
|
||||||
|
*/
|
||||||
|
function validarHorario(horario: string): boolean {
|
||||||
|
const regex = /^([0-1][0-9]|2[0-3]):[0-5][0-9]$/;
|
||||||
|
return regex.test(horario);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém a configuração ativa de ponto
|
||||||
|
*/
|
||||||
|
export const obterConfiguracao = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const config = await ctx.db
|
||||||
|
.query('configuracaoPonto')
|
||||||
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
// Retornar configuração padrão se não houver
|
||||||
|
return {
|
||||||
|
horarioEntrada: '08:00',
|
||||||
|
horarioSaidaAlmoco: '12:00',
|
||||||
|
horarioRetornoAlmoco: '13:00',
|
||||||
|
horarioSaida: '17:00',
|
||||||
|
toleranciaMinutos: 15,
|
||||||
|
nomeEntrada: 'Entrada 1',
|
||||||
|
nomeSaidaAlmoco: 'Saída 1',
|
||||||
|
nomeRetornoAlmoco: 'Entrada 2',
|
||||||
|
nomeSaida: 'Saída 2',
|
||||||
|
ativo: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salva configuração de ponto (apenas TI)
|
||||||
|
*/
|
||||||
|
export const salvarConfiguracao = mutation({
|
||||||
|
args: {
|
||||||
|
horarioEntrada: v.string(),
|
||||||
|
horarioSaidaAlmoco: v.string(),
|
||||||
|
horarioRetornoAlmoco: v.string(),
|
||||||
|
horarioSaida: v.string(),
|
||||||
|
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) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Verificar se usuário tem permissão de TI
|
||||||
|
// Por enquanto, permitir se tiver roleId
|
||||||
|
|
||||||
|
// Validar horários
|
||||||
|
if (!validarHorario(args.horarioEntrada)) {
|
||||||
|
throw new Error('Horário de entrada inválido (formato: HH:mm)');
|
||||||
|
}
|
||||||
|
if (!validarHorario(args.horarioSaidaAlmoco)) {
|
||||||
|
throw new Error('Horário de saída para almoço inválido (formato: HH:mm)');
|
||||||
|
}
|
||||||
|
if (!validarHorario(args.horarioRetornoAlmoco)) {
|
||||||
|
throw new Error('Horário de retorno do almoço inválido (formato: HH:mm)');
|
||||||
|
}
|
||||||
|
if (!validarHorario(args.horarioSaida)) {
|
||||||
|
throw new Error('Horário de saída inválido (formato: HH:mm)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar tolerância
|
||||||
|
if (args.toleranciaMinutos < 0 || args.toleranciaMinutos > 60) {
|
||||||
|
throw new Error('Tolerância deve estar entre 0 e 60 minutos');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar sequência lógica de horários
|
||||||
|
const [horaEntrada, minutoEntrada] = args.horarioEntrada.split(':').map(Number);
|
||||||
|
const [horaSaidaAlmoco, minutoSaidaAlmoco] = args.horarioSaidaAlmoco.split(':').map(Number);
|
||||||
|
const [horaRetornoAlmoco, minutoRetornoAlmoco] = args.horarioRetornoAlmoco.split(':').map(Number);
|
||||||
|
const [horaSaida, minutoSaida] = args.horarioSaida.split(':').map(Number);
|
||||||
|
|
||||||
|
const minutosEntrada = horaEntrada * 60 + minutoEntrada;
|
||||||
|
const minutosSaidaAlmoco = horaSaidaAlmoco * 60 + minutoSaidaAlmoco;
|
||||||
|
const minutosRetornoAlmoco = horaRetornoAlmoco * 60 + minutoRetornoAlmoco;
|
||||||
|
const minutosSaida = horaSaida * 60 + minutoSaida;
|
||||||
|
|
||||||
|
if (minutosEntrada >= minutosSaidaAlmoco) {
|
||||||
|
throw new Error('Horário de entrada deve ser anterior à saída para almoço');
|
||||||
|
}
|
||||||
|
if (minutosSaidaAlmoco >= minutosRetornoAlmoco) {
|
||||||
|
throw new Error('Horário de saída para almoço deve ser anterior ao retorno');
|
||||||
|
}
|
||||||
|
if (minutosRetornoAlmoco >= minutosSaida) {
|
||||||
|
throw new Error('Horário de retorno do almoço deve ser anterior à saída');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desativar configurações antigas
|
||||||
|
const configsAntigas = await ctx.db
|
||||||
|
.query('configuracaoPonto')
|
||||||
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const configAntiga of configsAntigas) {
|
||||||
|
await ctx.db.patch(configAntiga._id, { ativo: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar nova configuração
|
||||||
|
const configId = await ctx.db.insert('configuracaoPonto', {
|
||||||
|
horarioEntrada: args.horarioEntrada,
|
||||||
|
horarioSaidaAlmoco: args.horarioSaidaAlmoco,
|
||||||
|
horarioRetornoAlmoco: args.horarioRetornoAlmoco,
|
||||||
|
horarioSaida: args.horarioSaida,
|
||||||
|
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,
|
||||||
|
atualizadoPor: usuario._id as Id<'usuarios'>,
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { configId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
209
packages/backend/convex/configuracaoRelogio.ts
Normal file
209
packages/backend/convex/configuracaoRelogio.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { v } from 'convex/values';
|
||||||
|
import { action, internalMutation, internalQuery, mutation, query } from './_generated/server';
|
||||||
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
import type { Id } from './_generated/dataModel';
|
||||||
|
import { api, internal } from './_generated/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém a configuração do relógio
|
||||||
|
*/
|
||||||
|
export const obterConfiguracao = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const config = await ctx.db
|
||||||
|
.query('configuracaoRelogio')
|
||||||
|
.withIndex('by_ativo', (q) => q.eq('usarServidorExterno', true))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
// Retornar configuração padrão
|
||||||
|
return {
|
||||||
|
servidorNTP: 'pool.ntp.org',
|
||||||
|
portaNTP: 123,
|
||||||
|
usarServidorExterno: false,
|
||||||
|
fallbackParaPC: true,
|
||||||
|
ultimaSincronizacao: null,
|
||||||
|
offsetSegundos: null,
|
||||||
|
gmtOffset: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
gmtOffset: config.gmtOffset ?? 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salva configuração do relógio (apenas TI)
|
||||||
|
*/
|
||||||
|
export const salvarConfiguracao = mutation({
|
||||||
|
args: {
|
||||||
|
servidorNTP: v.optional(v.string()),
|
||||||
|
portaNTP: v.optional(v.number()),
|
||||||
|
usarServidorExterno: v.boolean(),
|
||||||
|
fallbackParaPC: v.boolean(),
|
||||||
|
gmtOffset: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Verificar se usuário tem permissão de TI
|
||||||
|
|
||||||
|
// Validar servidor NTP se usar servidor externo
|
||||||
|
if (args.usarServidorExterno) {
|
||||||
|
if (!args.servidorNTP || args.servidorNTP.trim() === '') {
|
||||||
|
throw new Error('Servidor NTP é obrigatório quando usar servidor externo');
|
||||||
|
}
|
||||||
|
if (!args.portaNTP || args.portaNTP < 1 || args.portaNTP > 65535) {
|
||||||
|
throw new Error('Porta NTP deve estar entre 1 e 65535');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar configuração existente
|
||||||
|
const configExistente = await ctx.db
|
||||||
|
.query('configuracaoRelogio')
|
||||||
|
.withIndex('by_ativo', (q) => q.eq('usarServidorExterno', args.usarServidorExterno))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (configExistente) {
|
||||||
|
// Atualizar configuração existente
|
||||||
|
await ctx.db.patch(configExistente._id, {
|
||||||
|
servidorNTP: args.servidorNTP,
|
||||||
|
portaNTP: args.portaNTP,
|
||||||
|
usarServidorExterno: args.usarServidorExterno,
|
||||||
|
fallbackParaPC: args.fallbackParaPC,
|
||||||
|
gmtOffset: args.gmtOffset ?? 0,
|
||||||
|
atualizadoPor: usuario._id as Id<'usuarios'>,
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
return { configId: configExistente._id };
|
||||||
|
} else {
|
||||||
|
// Criar nova configuração
|
||||||
|
const configId = await ctx.db.insert('configuracaoRelogio', {
|
||||||
|
servidorNTP: args.servidorNTP,
|
||||||
|
portaNTP: args.portaNTP,
|
||||||
|
usarServidorExterno: args.usarServidorExterno,
|
||||||
|
fallbackParaPC: args.fallbackParaPC,
|
||||||
|
gmtOffset: args.gmtOffset ?? 0,
|
||||||
|
atualizadoPor: usuario._id as Id<'usuarios'>,
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
return { configId };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém tempo do servidor (timestamp atual)
|
||||||
|
*/
|
||||||
|
export const obterTempoServidor = query({
|
||||||
|
args: {},
|
||||||
|
handler: async () => {
|
||||||
|
return {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
data: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincroniza tempo com servidor NTP (via action)
|
||||||
|
* Nota: NTP real requer biblioteca específica, aqui fazemos uma aproximação
|
||||||
|
*/
|
||||||
|
export const sincronizarTempo = action({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
// Buscar configuração diretamente do banco usando query pública
|
||||||
|
const config = await ctx.runQuery(api.configuracaoRelogio.obterConfiguracao, {});
|
||||||
|
|
||||||
|
if (!config.usarServidorExterno) {
|
||||||
|
return {
|
||||||
|
sucesso: true,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
usandoServidorExterno: false,
|
||||||
|
offsetSegundos: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar obter tempo de um servidor NTP público via HTTP
|
||||||
|
// Nota: Esta é uma aproximação. Para NTP real, seria necessário usar uma biblioteca específica
|
||||||
|
try {
|
||||||
|
// Usar API pública de tempo como fallback
|
||||||
|
const response = await fetch('https://worldtimeapi.org/api/timezone/America/Recife');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Falha ao obter tempo do servidor');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { datetime: string };
|
||||||
|
const serverTime = new Date(data.datetime).getTime();
|
||||||
|
const localTime = Date.now();
|
||||||
|
const offsetSegundos = Math.floor((serverTime - localTime) / 1000);
|
||||||
|
|
||||||
|
// Atualizar configuração com offset
|
||||||
|
// Buscar configuração diretamente usando query interna
|
||||||
|
const configs = await ctx.runQuery(internal.configuracaoRelogio.listarConfiguracoes, {});
|
||||||
|
const configExistente = configs.find(
|
||||||
|
(c: { usarServidorExterno: boolean; _id: Id<'configuracaoRelogio'> }) =>
|
||||||
|
c.usarServidorExterno === config.usarServidorExterno
|
||||||
|
);
|
||||||
|
if (configExistente) {
|
||||||
|
await ctx.runMutation(internal.configuracaoRelogio.atualizarSincronizacao, {
|
||||||
|
configId: configExistente._id,
|
||||||
|
offsetSegundos,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sucesso: true,
|
||||||
|
timestamp: serverTime,
|
||||||
|
usandoServidorExterno: true,
|
||||||
|
offsetSegundos,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Se falhar e fallbackParaPC estiver ativo, usar tempo local
|
||||||
|
if (config.fallbackParaPC) {
|
||||||
|
return {
|
||||||
|
sucesso: true,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
usandoServidorExterno: false,
|
||||||
|
offsetSegundos: 0,
|
||||||
|
aviso: 'Falha ao sincronizar com servidor externo, usando relógio do PC',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Falha ao sincronizar tempo e fallback desabilitado');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista configurações (internal)
|
||||||
|
*/
|
||||||
|
export const listarConfiguracoes = internalQuery({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
return await ctx.db.query('configuracaoRelogio').collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualiza informações de sincronização (internal)
|
||||||
|
*/
|
||||||
|
export const atualizarSincronizacao = internalMutation({
|
||||||
|
args: {
|
||||||
|
configId: v.id('configuracaoRelogio'),
|
||||||
|
offsetSegundos: v.number(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.configId, {
|
||||||
|
ultimaSincronizacao: Date.now(),
|
||||||
|
offsetSegundos: args.offsetSegundos,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
660
packages/backend/convex/pontos.ts
Normal file
660
packages/backend/convex/pontos.ts
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
import { v } from 'convex/values';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import type { MutationCtx, QueryCtx } from './_generated/server';
|
||||||
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
import type { Id } from './_generated/dataModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera URL para upload de imagem do ponto
|
||||||
|
*/
|
||||||
|
export const generateUploadUrl = mutation({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
return await ctx.storage.generateUploadUrl();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula se o registro está dentro do prazo baseado na configuração
|
||||||
|
* Se toleranciaMinutos for 0, desconsidera atrasos (sempre retorna true)
|
||||||
|
*/
|
||||||
|
function calcularStatusPonto(
|
||||||
|
hora: number,
|
||||||
|
minuto: number,
|
||||||
|
horarioConfigurado: string,
|
||||||
|
toleranciaMinutos: number
|
||||||
|
): boolean {
|
||||||
|
// Se tolerância for 0, desconsiderar atrasos (qualquer registro é válido)
|
||||||
|
if (toleranciaMinutos === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number);
|
||||||
|
const totalMinutosRegistro = hora * 60 + minuto;
|
||||||
|
const totalMinutosConfigurado = horaConfig * 60 + minutoConfig;
|
||||||
|
const diferenca = totalMinutosRegistro - totalMinutosConfigurado;
|
||||||
|
return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determina o tipo de registro baseado na sequência lógica
|
||||||
|
*/
|
||||||
|
async function determinarTipoRegistro(
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
funcionarioId: Id<'funcionarios'>,
|
||||||
|
data: string
|
||||||
|
): Promise<'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'> {
|
||||||
|
const registrosHoje = await ctx.db
|
||||||
|
.query('registrosPonto')
|
||||||
|
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
|
||||||
|
.order('desc')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if (registrosHoje.length === 0) {
|
||||||
|
return 'entrada';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ultimoRegistro = registrosHoje[0];
|
||||||
|
switch (ultimoRegistro.tipo) {
|
||||||
|
case 'entrada':
|
||||||
|
return 'saida_almoco';
|
||||||
|
case 'saida_almoco':
|
||||||
|
return 'retorno_almoco';
|
||||||
|
case 'retorno_almoco':
|
||||||
|
return 'saida';
|
||||||
|
case 'saida':
|
||||||
|
// Se já saiu, próximo registro é entrada (novo dia)
|
||||||
|
return 'entrada';
|
||||||
|
default:
|
||||||
|
return 'entrada';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra um ponto (entrada, saída, etc.)
|
||||||
|
*/
|
||||||
|
export const registrarPonto = mutation({
|
||||||
|
args: {
|
||||||
|
imagemId: v.optional(v.id('_storage')),
|
||||||
|
informacoesDispositivo: v.optional(
|
||||||
|
v.object({
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
ipPublico: v.optional(v.string()),
|
||||||
|
ipLocal: v.optional(v.string()),
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
browser: v.optional(v.string()),
|
||||||
|
browserVersion: v.optional(v.string()),
|
||||||
|
engine: v.optional(v.string()),
|
||||||
|
sistemaOperacional: v.optional(v.string()),
|
||||||
|
osVersion: v.optional(v.string()),
|
||||||
|
arquitetura: v.optional(v.string()),
|
||||||
|
plataforma: v.optional(v.string()),
|
||||||
|
latitude: v.optional(v.number()),
|
||||||
|
longitude: v.optional(v.number()),
|
||||||
|
precisao: v.optional(v.number()),
|
||||||
|
endereco: v.optional(v.string()),
|
||||||
|
cidade: v.optional(v.string()),
|
||||||
|
estado: v.optional(v.string()),
|
||||||
|
pais: v.optional(v.string()),
|
||||||
|
timezone: v.optional(v.string()),
|
||||||
|
deviceType: v.optional(v.string()),
|
||||||
|
deviceModel: v.optional(v.string()),
|
||||||
|
screenResolution: v.optional(v.string()),
|
||||||
|
coresTela: v.optional(v.number()),
|
||||||
|
idioma: v.optional(v.string()),
|
||||||
|
isMobile: v.optional(v.boolean()),
|
||||||
|
isTablet: v.optional(v.boolean()),
|
||||||
|
isDesktop: v.optional(v.boolean()),
|
||||||
|
connectionType: v.optional(v.string()),
|
||||||
|
memoryInfo: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
timestamp: v.number(),
|
||||||
|
sincronizadoComServidor: v.boolean(),
|
||||||
|
justificativa: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usuario.funcionarioId) {
|
||||||
|
throw new Error('Usuário não possui funcionário associado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se funcionário está ativo
|
||||||
|
const funcionario = await ctx.db.get(usuario.funcionarioId);
|
||||||
|
if (!funcionario) {
|
||||||
|
throw new Error('Funcionário não encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obter configuração de ponto
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obter configuração de ponto para GMT offset (buscar configuração ativa)
|
||||||
|
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 hora = dataObj.getUTCHours();
|
||||||
|
const minuto = dataObj.getUTCMinutes();
|
||||||
|
const segundo = dataObj.getUTCSeconds();
|
||||||
|
|
||||||
|
// Verificar se já existe registro no mesmo minuto
|
||||||
|
const funcionarioId = usuario.funcionarioId; // Já verificado acima, não é undefined
|
||||||
|
const registrosMinuto = await ctx.db
|
||||||
|
.query('registrosPonto')
|
||||||
|
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const registroDuplicado = registrosMinuto.find(
|
||||||
|
(r) => r.hora === hora && r.minuto === minuto
|
||||||
|
);
|
||||||
|
|
||||||
|
if (registroDuplicado) {
|
||||||
|
throw new Error('Já existe um registro neste minuto');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinar tipo de registro
|
||||||
|
const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data);
|
||||||
|
|
||||||
|
// Calcular horário esperado e tolerância
|
||||||
|
let horarioConfigurado = '';
|
||||||
|
switch (tipo) {
|
||||||
|
case 'entrada':
|
||||||
|
horarioConfigurado = config.horarioEntrada;
|
||||||
|
break;
|
||||||
|
case 'saida_almoco':
|
||||||
|
horarioConfigurado = config.horarioSaidaAlmoco;
|
||||||
|
break;
|
||||||
|
case 'retorno_almoco':
|
||||||
|
horarioConfigurado = config.horarioRetornoAlmoco;
|
||||||
|
break;
|
||||||
|
case 'saida':
|
||||||
|
horarioConfigurado = config.horarioSaida;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dentroDoPrazo = calcularStatusPonto(hora, minuto, horarioConfigurado, config.toleranciaMinutos);
|
||||||
|
|
||||||
|
// Criar registro
|
||||||
|
const registroId = await ctx.db.insert('registrosPonto', {
|
||||||
|
funcionarioId: usuario.funcionarioId,
|
||||||
|
tipo,
|
||||||
|
data,
|
||||||
|
hora,
|
||||||
|
minuto,
|
||||||
|
segundo,
|
||||||
|
timestamp: args.timestamp,
|
||||||
|
imagemId: args.imagemId,
|
||||||
|
sincronizadoComServidor: args.sincronizadoComServidor,
|
||||||
|
toleranciaMinutos: config.toleranciaMinutos,
|
||||||
|
dentroDoPrazo,
|
||||||
|
justificativa: args.justificativa,
|
||||||
|
ipAddress: args.informacoesDispositivo?.ipAddress,
|
||||||
|
ipPublico: args.informacoesDispositivo?.ipPublico,
|
||||||
|
ipLocal: args.informacoesDispositivo?.ipLocal,
|
||||||
|
userAgent: args.informacoesDispositivo?.userAgent,
|
||||||
|
browser: args.informacoesDispositivo?.browser,
|
||||||
|
browserVersion: args.informacoesDispositivo?.browserVersion,
|
||||||
|
engine: args.informacoesDispositivo?.engine,
|
||||||
|
sistemaOperacional: args.informacoesDispositivo?.sistemaOperacional,
|
||||||
|
osVersion: args.informacoesDispositivo?.osVersion,
|
||||||
|
arquitetura: args.informacoesDispositivo?.arquitetura,
|
||||||
|
plataforma: args.informacoesDispositivo?.plataforma,
|
||||||
|
latitude: args.informacoesDispositivo?.latitude,
|
||||||
|
longitude: args.informacoesDispositivo?.longitude,
|
||||||
|
precisao: args.informacoesDispositivo?.precisao,
|
||||||
|
endereco: args.informacoesDispositivo?.endereco,
|
||||||
|
cidade: args.informacoesDispositivo?.cidade,
|
||||||
|
estado: args.informacoesDispositivo?.estado,
|
||||||
|
pais: args.informacoesDispositivo?.pais,
|
||||||
|
timezone: args.informacoesDispositivo?.timezone,
|
||||||
|
deviceType: args.informacoesDispositivo?.deviceType,
|
||||||
|
deviceModel: args.informacoesDispositivo?.deviceModel,
|
||||||
|
screenResolution: args.informacoesDispositivo?.screenResolution,
|
||||||
|
coresTela: args.informacoesDispositivo?.coresTela,
|
||||||
|
idioma: args.informacoesDispositivo?.idioma,
|
||||||
|
isMobile: args.informacoesDispositivo?.isMobile,
|
||||||
|
isTablet: args.informacoesDispositivo?.isTablet,
|
||||||
|
isDesktop: args.informacoesDispositivo?.isDesktop,
|
||||||
|
connectionType: args.informacoesDispositivo?.connectionType,
|
||||||
|
memoryInfo: args.informacoesDispositivo?.memoryInfo,
|
||||||
|
criadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atualizar banco de horas após registrar
|
||||||
|
await atualizarBancoHoras(ctx, usuario.funcionarioId, data, config);
|
||||||
|
|
||||||
|
return { registroId, tipo, dentroDoPrazo };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista registros do dia atual do funcionário
|
||||||
|
*/
|
||||||
|
export const listarRegistrosDia = query({
|
||||||
|
args: {
|
||||||
|
data: v.optional(v.string()), // YYYY-MM-DD, se não fornecido usa hoje
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario || !usuario.funcionarioId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const funcionarioId = usuario.funcionarioId; // Garantir que não é undefined
|
||||||
|
const data = args.data || new Date().toISOString().split('T')[0]!;
|
||||||
|
|
||||||
|
const registros = await ctx.db
|
||||||
|
.query('registrosPonto')
|
||||||
|
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
|
||||||
|
.order('asc')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return registros;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista registros por período (para RH)
|
||||||
|
*/
|
||||||
|
export const listarRegistrosPeriodo = query({
|
||||||
|
args: {
|
||||||
|
funcionarioId: v.optional(v.id('funcionarios')),
|
||||||
|
dataInicio: v.string(), // YYYY-MM-DD
|
||||||
|
dataFim: v.string(), // YYYY-MM-DD
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar permissão (RH ou TI)
|
||||||
|
// Por enquanto, permitir se tiver funcionarioId ou for admin
|
||||||
|
// TODO: Implementar verificação de permissão adequada
|
||||||
|
|
||||||
|
const dataFim = new Date(args.dataFim);
|
||||||
|
dataFim.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const registros = await ctx.db
|
||||||
|
.query('registrosPonto')
|
||||||
|
.withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Filtrar por funcionário se especificado
|
||||||
|
let registrosFiltrados = registros;
|
||||||
|
if (args.funcionarioId) {
|
||||||
|
registrosFiltrados = registros.filter((r) => r.funcionarioId === args.funcionarioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar informações dos funcionários
|
||||||
|
const funcionariosIds = new Set(registrosFiltrados.map((r) => r.funcionarioId));
|
||||||
|
const funcionarios = await Promise.all(
|
||||||
|
Array.from(funcionariosIds).map((id) => ctx.db.get(id))
|
||||||
|
);
|
||||||
|
|
||||||
|
return registrosFiltrados.map((registro) => {
|
||||||
|
const funcionario = funcionarios.find((f) => f?._id === registro.funcionarioId);
|
||||||
|
return {
|
||||||
|
...registro,
|
||||||
|
funcionario: funcionario
|
||||||
|
? {
|
||||||
|
nome: funcionario.nome,
|
||||||
|
matricula: funcionario.matricula,
|
||||||
|
descricaoCargo: funcionario.descricaoCargo,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém estatísticas de pontos (para gráficos)
|
||||||
|
*/
|
||||||
|
export const obterEstatisticas = query({
|
||||||
|
args: {
|
||||||
|
dataInicio: v.string(), // YYYY-MM-DD
|
||||||
|
dataFim: v.string(), // YYYY-MM-DD
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Verificar permissão (RH ou TI)
|
||||||
|
|
||||||
|
const registros = await ctx.db
|
||||||
|
.query('registrosPonto')
|
||||||
|
.withIndex('by_data', (q) => q.gte('data', args.dataInicio).lte('data', args.dataFim))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const totalRegistros = registros.length;
|
||||||
|
const dentroDoPrazo = registros.filter((r) => r.dentroDoPrazo).length;
|
||||||
|
const foraDoPrazo = totalRegistros - dentroDoPrazo;
|
||||||
|
|
||||||
|
// Agrupar por funcionário
|
||||||
|
const funcionariosUnicos = new Set(registros.map((r) => r.funcionarioId));
|
||||||
|
const totalFuncionarios = funcionariosUnicos.size;
|
||||||
|
|
||||||
|
// Funcionários com registros dentro do prazo
|
||||||
|
const funcionariosDentroPrazo = new Set(
|
||||||
|
registros.filter((r) => r.dentroDoPrazo).map((r) => r.funcionarioId)
|
||||||
|
).size;
|
||||||
|
|
||||||
|
// Funcionários com registros fora do prazo
|
||||||
|
const funcionariosForaPrazo = totalFuncionarios - funcionariosDentroPrazo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRegistros,
|
||||||
|
dentroDoPrazo,
|
||||||
|
foraDoPrazo,
|
||||||
|
totalFuncionarios,
|
||||||
|
funcionariosDentroPrazo,
|
||||||
|
funcionariosForaPrazo,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém um registro específico (para comprovante)
|
||||||
|
*/
|
||||||
|
export const obterRegistro = query({
|
||||||
|
args: {
|
||||||
|
registroId: v.id('registrosPonto'),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const registro = await ctx.db.get(args.registroId);
|
||||||
|
if (!registro) {
|
||||||
|
throw new Error('Registro não encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o usuário tem permissão (próprio registro ou RH/TI)
|
||||||
|
if (registro.funcionarioId !== usuario.funcionarioId) {
|
||||||
|
// TODO: Verificar se é RH ou TI
|
||||||
|
// Por enquanto, permitir
|
||||||
|
}
|
||||||
|
|
||||||
|
const funcionario = await ctx.db.get(registro.funcionarioId);
|
||||||
|
let simbolo = null;
|
||||||
|
if (funcionario) {
|
||||||
|
simbolo = await ctx.db.get(funcionario.simboloId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obter URL da imagem se existir
|
||||||
|
let imagemUrl = null;
|
||||||
|
if (registro.imagemId) {
|
||||||
|
imagemUrl = await ctx.storage.getUrl(registro.imagemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...registro,
|
||||||
|
imagemUrl,
|
||||||
|
funcionario: funcionario
|
||||||
|
? {
|
||||||
|
nome: funcionario.nome,
|
||||||
|
matricula: funcionario.matricula,
|
||||||
|
descricaoCargo: funcionario.descricaoCargo,
|
||||||
|
simbolo: simbolo
|
||||||
|
? {
|
||||||
|
nome: simbolo.nome,
|
||||||
|
tipo: simbolo.tipo,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula carga horária diária esperada em minutos
|
||||||
|
*/
|
||||||
|
function calcularCargaHorariaDiaria(config: {
|
||||||
|
horarioEntrada: string;
|
||||||
|
horarioSaidaAlmoco: string;
|
||||||
|
horarioRetornoAlmoco: string;
|
||||||
|
horarioSaida: string;
|
||||||
|
}): number {
|
||||||
|
const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number);
|
||||||
|
const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number);
|
||||||
|
const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number);
|
||||||
|
const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number);
|
||||||
|
|
||||||
|
const minutosEntrada = horaEntrada * 60 + minutoEntrada;
|
||||||
|
const minutosSaidaAlmoco = horaSaidaAlmoco * 60 + minutoSaidaAlmoco;
|
||||||
|
const minutosRetornoAlmoco = horaRetornoAlmoco * 60 + minutoRetornoAlmoco;
|
||||||
|
const minutosSaida = horaSaida * 60 + minutoSaida;
|
||||||
|
|
||||||
|
// Calcular horas trabalhadas: (saída almoço - entrada) + (saída - retorno almoço)
|
||||||
|
const horasManha = minutosSaidaAlmoco - minutosEntrada;
|
||||||
|
const horasTarde = minutosSaida - minutosRetornoAlmoco;
|
||||||
|
|
||||||
|
return horasManha + horasTarde;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula horas trabalhadas do dia baseado nos registros
|
||||||
|
*/
|
||||||
|
function calcularHorasTrabalhadas(registros: Array<{
|
||||||
|
tipo: string;
|
||||||
|
hora: number;
|
||||||
|
minuto: number;
|
||||||
|
}>): number {
|
||||||
|
// Ordenar registros por timestamp
|
||||||
|
const registrosOrdenados = [...registros].sort((a, b) => {
|
||||||
|
const minutosA = a.hora * 60 + a.minuto;
|
||||||
|
const minutosB = b.hora * 60 + b.minuto;
|
||||||
|
return minutosA - minutosB;
|
||||||
|
});
|
||||||
|
|
||||||
|
let horasTrabalhadas = 0;
|
||||||
|
|
||||||
|
// Procurar entrada e saída
|
||||||
|
const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada');
|
||||||
|
const saida = registrosOrdenados.find((r) => r.tipo === 'saida');
|
||||||
|
|
||||||
|
if (entrada && saida) {
|
||||||
|
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
|
||||||
|
const minutosSaida = saida.hora * 60 + saida.minuto;
|
||||||
|
|
||||||
|
// Procurar saída e retorno do almoço
|
||||||
|
const saidaAlmoco = registrosOrdenados.find((r) => r.tipo === 'saida_almoco');
|
||||||
|
const retornoAlmoco = registrosOrdenados.find((r) => r.tipo === 'retorno_almoco');
|
||||||
|
|
||||||
|
if (saidaAlmoco && retornoAlmoco) {
|
||||||
|
// Tem intervalo de almoço: (saída almoço - entrada) + (saída - retorno almoço)
|
||||||
|
const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto;
|
||||||
|
const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto;
|
||||||
|
|
||||||
|
const horasManha = minutosSaidaAlmoco - minutosEntrada;
|
||||||
|
const horasTarde = minutosSaida - minutosRetornoAlmoco;
|
||||||
|
horasTrabalhadas = horasManha + horasTarde;
|
||||||
|
} else {
|
||||||
|
// Sem intervalo de almoço registrado: saída - entrada
|
||||||
|
horasTrabalhadas = minutosSaida - minutosEntrada;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return horasTrabalhadas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualiza ou cria registro de banco de horas para o dia
|
||||||
|
*/
|
||||||
|
async function atualizarBancoHoras(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
funcionarioId: Id<'funcionarios'>,
|
||||||
|
data: string,
|
||||||
|
config: {
|
||||||
|
horarioEntrada: string;
|
||||||
|
horarioSaidaAlmoco: string;
|
||||||
|
horarioRetornoAlmoco: string;
|
||||||
|
horarioSaida: string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
// Buscar todos os registros do dia
|
||||||
|
const registrosDoDia = await ctx.db
|
||||||
|
.query('registrosPonto')
|
||||||
|
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Calcular carga horária esperada
|
||||||
|
const cargaHorariaDiaria = calcularCargaHorariaDiaria(config);
|
||||||
|
|
||||||
|
// Calcular horas trabalhadas
|
||||||
|
const horasTrabalhadas = calcularHorasTrabalhadas(registrosDoDia);
|
||||||
|
|
||||||
|
// Calcular saldo (positivo = horas extras, negativo = déficit)
|
||||||
|
const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria;
|
||||||
|
|
||||||
|
// Buscar banco de horas existente
|
||||||
|
const bancoHorasExistente = await ctx.db
|
||||||
|
.query('bancoHoras')
|
||||||
|
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const registrosPontoIds = registrosDoDia.map((r) => r._id);
|
||||||
|
|
||||||
|
if (bancoHorasExistente) {
|
||||||
|
// Atualizar existente
|
||||||
|
await ctx.db.patch(bancoHorasExistente._id, {
|
||||||
|
cargaHorariaDiaria,
|
||||||
|
horasTrabalhadas,
|
||||||
|
saldoMinutos,
|
||||||
|
registrosPontoIds,
|
||||||
|
calculadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Criar novo
|
||||||
|
await ctx.db.insert('bancoHoras', {
|
||||||
|
funcionarioId,
|
||||||
|
data,
|
||||||
|
cargaHorariaDiaria,
|
||||||
|
horasTrabalhadas,
|
||||||
|
saldoMinutos,
|
||||||
|
registrosPontoIds,
|
||||||
|
calculadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém histórico e saldo do dia
|
||||||
|
*/
|
||||||
|
export const obterHistoricoESaldoDia = query({
|
||||||
|
args: {
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
data: v.string(), // YYYY-MM-DD
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario || !usuario.funcionarioId) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é o próprio funcionário ou tem permissão
|
||||||
|
if (usuario.funcionarioId !== args.funcionarioId) {
|
||||||
|
// TODO: Verificar permissão de RH
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar registros do dia
|
||||||
|
const registros = await ctx.db
|
||||||
|
.query('registrosPonto')
|
||||||
|
.withIndex('by_funcionario_data', (q) =>
|
||||||
|
q.eq('funcionarioId', args.funcionarioId).eq('data', args.data)
|
||||||
|
)
|
||||||
|
.order('asc')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Buscar configuração de ponto
|
||||||
|
const config = await ctx.db
|
||||||
|
.query('configuracaoPonto')
|
||||||
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
registros: [],
|
||||||
|
cargaHorariaDiaria: 0,
|
||||||
|
horasTrabalhadas: 0,
|
||||||
|
saldoMinutos: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular valores
|
||||||
|
const cargaHorariaDiaria = calcularCargaHorariaDiaria(config);
|
||||||
|
const horasTrabalhadas = calcularHorasTrabalhadas(registros);
|
||||||
|
const saldoMinutos = horasTrabalhadas - cargaHorariaDiaria;
|
||||||
|
|
||||||
|
return {
|
||||||
|
registros,
|
||||||
|
cargaHorariaDiaria,
|
||||||
|
horasTrabalhadas,
|
||||||
|
saldoMinutos,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém banco de horas acumulado do funcionário
|
||||||
|
*/
|
||||||
|
export const obterBancoHorasFuncionario = query({
|
||||||
|
args: {
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é o próprio funcionário ou tem permissão
|
||||||
|
if (usuario.funcionarioId !== args.funcionarioId) {
|
||||||
|
// TODO: Verificar permissão de RH
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar todos os registros de banco de horas do funcionário
|
||||||
|
const bancosHoras = await ctx.db
|
||||||
|
.query('bancoHoras')
|
||||||
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
|
||||||
|
.order('desc')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Calcular saldo acumulado
|
||||||
|
const saldoAcumuladoMinutos = bancosHoras.reduce((acc, bh) => acc + bh.saldoMinutos, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bancosHoras,
|
||||||
|
saldoAcumuladoMinutos,
|
||||||
|
totalDias: bancosHoras.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -1325,5 +1325,122 @@ export default defineSchema({
|
|||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
atualizadoEm: v.number(),
|
atualizadoEm: v.number(),
|
||||||
})
|
})
|
||||||
.index("by_criadoEm", ["criadoEm"])
|
.index("by_criadoEm", ["criadoEm"]),
|
||||||
|
|
||||||
|
// Sistema de Controle de Ponto
|
||||||
|
registrosPonto: defineTable({
|
||||||
|
funcionarioId: v.id("funcionarios"),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal("entrada"),
|
||||||
|
v.literal("saida_almoco"),
|
||||||
|
v.literal("retorno_almoco"),
|
||||||
|
v.literal("saida")
|
||||||
|
),
|
||||||
|
data: v.string(), // YYYY-MM-DD
|
||||||
|
hora: v.number(),
|
||||||
|
minuto: v.number(),
|
||||||
|
segundo: v.number(),
|
||||||
|
timestamp: v.number(), // Timestamp completo para ordenação
|
||||||
|
imagemId: v.optional(v.id("_storage")),
|
||||||
|
sincronizadoComServidor: v.boolean(),
|
||||||
|
toleranciaMinutos: v.number(),
|
||||||
|
dentroDoPrazo: v.boolean(),
|
||||||
|
|
||||||
|
// Informações de Rede
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
ipPublico: v.optional(v.string()),
|
||||||
|
ipLocal: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Informações do Navegador
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
browser: v.optional(v.string()),
|
||||||
|
browserVersion: v.optional(v.string()),
|
||||||
|
engine: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Informações do Sistema
|
||||||
|
sistemaOperacional: v.optional(v.string()),
|
||||||
|
osVersion: v.optional(v.string()),
|
||||||
|
arquitetura: v.optional(v.string()),
|
||||||
|
plataforma: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Informações de Localização
|
||||||
|
latitude: v.optional(v.number()),
|
||||||
|
longitude: v.optional(v.number()),
|
||||||
|
precisao: v.optional(v.number()),
|
||||||
|
endereco: v.optional(v.string()),
|
||||||
|
cidade: v.optional(v.string()),
|
||||||
|
estado: v.optional(v.string()),
|
||||||
|
pais: v.optional(v.string()),
|
||||||
|
timezone: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Informações do Dispositivo
|
||||||
|
deviceType: v.optional(v.string()),
|
||||||
|
deviceModel: v.optional(v.string()),
|
||||||
|
screenResolution: v.optional(v.string()),
|
||||||
|
coresTela: v.optional(v.number()),
|
||||||
|
idioma: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Informações Adicionais
|
||||||
|
isMobile: v.optional(v.boolean()),
|
||||||
|
isTablet: v.optional(v.boolean()),
|
||||||
|
isDesktop: v.optional(v.boolean()),
|
||||||
|
connectionType: v.optional(v.string()),
|
||||||
|
memoryInfo: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Justificativa opcional para o registro
|
||||||
|
justificativa: v.optional(v.string()),
|
||||||
|
|
||||||
|
criadoEm: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_funcionario_data", ["funcionarioId", "data"])
|
||||||
|
.index("by_data", ["data"])
|
||||||
|
.index("by_dentro_prazo", ["dentroDoPrazo", "data"])
|
||||||
|
.index("by_funcionario_timestamp", ["funcionarioId", "timestamp"]),
|
||||||
|
|
||||||
|
configuracaoPonto: defineTable({
|
||||||
|
horarioEntrada: v.string(), // HH:mm
|
||||||
|
horarioSaidaAlmoco: v.string(), // HH:mm
|
||||||
|
horarioRetornoAlmoco: v.string(), // HH:mm
|
||||||
|
horarioSaida: v.string(), // HH:mm
|
||||||
|
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"
|
||||||
|
// Ajuste de fuso horário (GMT offset em horas)
|
||||||
|
gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC)
|
||||||
|
ativo: v.boolean(),
|
||||||
|
atualizadoPor: v.id("usuarios"),
|
||||||
|
atualizadoEm: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_ativo", ["ativo"]),
|
||||||
|
|
||||||
|
configuracaoRelogio: defineTable({
|
||||||
|
servidorNTP: v.optional(v.string()),
|
||||||
|
portaNTP: v.optional(v.number()),
|
||||||
|
usarServidorExterno: v.boolean(),
|
||||||
|
fallbackParaPC: v.boolean(),
|
||||||
|
ultimaSincronizacao: 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"),
|
||||||
|
atualizadoEm: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_ativo", ["usarServidorExterno"]),
|
||||||
|
|
||||||
|
// Banco de Horas - Saldo diário de horas trabalhadas
|
||||||
|
bancoHoras: defineTable({
|
||||||
|
funcionarioId: v.id("funcionarios"),
|
||||||
|
data: v.string(), // YYYY-MM-DD
|
||||||
|
cargaHorariaDiaria: v.number(), // Horas esperadas do dia (em minutos)
|
||||||
|
horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos)
|
||||||
|
saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit)
|
||||||
|
registrosPontoIds: v.array(v.id("registrosPonto")), // IDs dos registros do dia
|
||||||
|
calculadoEm: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_funcionario_data", ["funcionarioId", "data"])
|
||||||
|
.index("by_funcionario", ["funcionarioId"])
|
||||||
|
.index("by_data", ["data"]),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user