1742 lines
61 KiB
Svelte
1742 lines
61 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
|
import RelogioSincronizado from './RelogioSincronizado.svelte';
|
|
import WebcamCapture from './WebcamCapture.svelte';
|
|
import ComprovantePonto from './ComprovantePonto.svelte';
|
|
import ErrorModal from '../ErrorModal.svelte';
|
|
import { obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
|
|
import { obterInformacoesDispositivo } from '$lib/utils/deviceInfo';
|
|
import {
|
|
formatarHoraPonto,
|
|
getTipoRegistroLabel,
|
|
getProximoTipoRegistro
|
|
} from '$lib/utils/ponto';
|
|
import { LogIn, LogOut, Clock, CheckCircle2, XCircle, TrendingUp, TrendingDown, Printer, Camera } from 'lucide-svelte';
|
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
|
import jsPDF from 'jspdf';
|
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
|
import { formatarDataHoraCompleta } from '$lib/utils/ponto';
|
|
|
|
const client = useConvexClient();
|
|
|
|
// Chave de refresh para forçar atualização das queries após registro
|
|
let refreshKey = $state(0);
|
|
|
|
// Queries
|
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
|
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
|
|
|
// Query para histórico e saldo do dia
|
|
const funcionarioId = $derived(currentUser?.data?.funcionarioId ?? null);
|
|
const dataHoje = $derived(new Date().toISOString().split('T')[0]!);
|
|
|
|
// Usar refreshKey para forçar atualização após registro
|
|
const registrosHojeQuery = useQuery(
|
|
api.pontos.listarRegistrosDia,
|
|
{ data: dataHoje, _refresh: refreshKey }
|
|
);
|
|
|
|
const historicoSaldoQuery = useQuery(
|
|
api.pontos.obterHistoricoESaldoDia,
|
|
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje, _refresh: refreshKey } : 'skip'
|
|
);
|
|
|
|
// Query para verificar dispensa ativa
|
|
const dispensaQuery = useQuery(
|
|
api.pontos.verificarDispensaAtiva,
|
|
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
|
|
);
|
|
|
|
// Estados
|
|
let mostrandoWebcam = $state(false);
|
|
let registrando = $state(false);
|
|
let sucesso = $state<string | null>(null);
|
|
let registroId = $state<Id<'registrosPonto'> | null>(null);
|
|
let mostrandoComprovante = $state(false);
|
|
let imagemCapturada = $state<Blob | null>(null);
|
|
let coletandoInfo = $state(false);
|
|
let capturandoAutomaticamente = $state(false);
|
|
let mostrarModalErro = $state(false);
|
|
let mensagemErroModal = $state('');
|
|
let detalhesErroModal = $state('');
|
|
let justificativa = $state('');
|
|
let mostrandoModalConfirmacao = $state(false);
|
|
let mostrandoTransicao = $state(false); // Novo estado para transição
|
|
let dataHoraAtual = $state<{ data: string; hora: string } | null>(null);
|
|
let aguardandoProcessamento = $state(false);
|
|
let etapaProcessamento = $state<'coletando' | 'sincronizando' | 'upload' | 'registrando' | null>(null);
|
|
|
|
const registrosHoje = $derived(registrosHojeQuery?.data || []);
|
|
const config = $derived(configQuery?.data);
|
|
|
|
const proximoTipo = $derived.by(() => {
|
|
if (registrosHoje.length === 0) {
|
|
return 'entrada';
|
|
}
|
|
const ultimoRegistro = registrosHoje[registrosHoje.length - 1];
|
|
return getProximoTipoRegistro(ultimoRegistro?.tipo || null);
|
|
});
|
|
|
|
const tipoLabel = $derived.by(() => {
|
|
if (config) {
|
|
return getTipoRegistroLabel(proximoTipo, {
|
|
nomeEntrada: config.nomeEntrada,
|
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
|
nomeSaida: config.nomeSaida,
|
|
});
|
|
}
|
|
return getTipoRegistroLabel(proximoTipo);
|
|
});
|
|
|
|
async function uploadImagem(blob: Blob): Promise<Id<'_storage'> | undefined> {
|
|
try {
|
|
// Obter URL de upload
|
|
const uploadUrl = await client.mutation(api.pontos.generateUploadUrl, {});
|
|
|
|
// Criar File a partir do Blob
|
|
const file = new File([blob], 'ponto.jpg', { type: 'image/jpeg' });
|
|
|
|
// Fazer upload
|
|
const response = await fetch(uploadUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': file.type },
|
|
body: file
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Falha no upload da imagem');
|
|
}
|
|
|
|
// A resposta do Convex storage retorna JSON com storageId
|
|
const { storageId } = (await response.json()) as { storageId: string };
|
|
return storageId as Id<'_storage'>;
|
|
} catch (error) {
|
|
console.error('Erro ao fazer upload da imagem:', error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Verificar permissões de localização e webcam
|
|
async function verificarPermissoes(): Promise<{
|
|
localizacao: boolean;
|
|
webcam: boolean;
|
|
permissoesNecessarias: string[];
|
|
}> {
|
|
let localizacaoAutorizada = false;
|
|
let webcamAutorizada = false;
|
|
const permissoesNecessarias: string[] = [];
|
|
|
|
// Verificar permissão de geolocalização
|
|
if (navigator.geolocation) {
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timeoutId = setTimeout(() => {
|
|
reject(new Error('Timeout'));
|
|
}, 5000);
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
() => {
|
|
clearTimeout(timeoutId);
|
|
localizacaoAutorizada = true;
|
|
resolve();
|
|
},
|
|
(error) => {
|
|
clearTimeout(timeoutId);
|
|
if (error.code === error.PERMISSION_DENIED) {
|
|
permissoesNecessarias.push('localização');
|
|
}
|
|
reject(new Error('Permissão de localização negada'));
|
|
},
|
|
{ timeout: 5000, maximumAge: 0, enableHighAccuracy: false }
|
|
);
|
|
});
|
|
} catch (error) {
|
|
console.warn('Permissão de localização não concedida:', error);
|
|
}
|
|
}
|
|
|
|
// Verificar permissão de webcam
|
|
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
webcamAutorizada = true;
|
|
// Parar o stream imediatamente, apenas verificamos a permissão
|
|
stream.getTracks().forEach(track => track.stop());
|
|
} catch (error) {
|
|
console.warn('Permissão de webcam não concedida:', error);
|
|
permissoesNecessarias.push('câmera');
|
|
}
|
|
}
|
|
|
|
return { localizacao: localizacaoAutorizada, webcam: webcamAutorizada, permissoesNecessarias };
|
|
}
|
|
|
|
async function registrarPonto() {
|
|
if (registrando) return;
|
|
|
|
// Verificar se tem funcionário associado
|
|
if (!temFuncionarioAssociado) {
|
|
mensagemErroModal = 'Usuário não possui funcionário associado';
|
|
detalhesErroModal = 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.';
|
|
mostrarModalErro = true;
|
|
return;
|
|
}
|
|
|
|
// Verificar se está dispensado antes de registrar
|
|
if (estaDispensado) {
|
|
mensagemErroModal = 'Registro dispensado pelo gestor';
|
|
detalhesErroModal = motivoDispensa || 'Você está dispensado de registrar ponto no momento.';
|
|
mostrarModalErro = true;
|
|
return;
|
|
}
|
|
|
|
// Verificar permissões antes de registrar
|
|
const permissoes = await verificarPermissoes();
|
|
if (!permissoes.localizacao || !permissoes.webcam) {
|
|
mensagemErroModal = 'Permissões necessárias';
|
|
const permissoesLista = permissoes.permissoesNecessarias.join(', ');
|
|
detalhesErroModal = `Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.\n\nPermissões negadas: ${permissoesLista || 'localização e/ou câmera'}`;
|
|
mostrarModalErro = true;
|
|
return;
|
|
}
|
|
|
|
registrando = true;
|
|
sucesso = null;
|
|
coletandoInfo = true;
|
|
aguardandoProcessamento = true;
|
|
etapaProcessamento = 'coletando';
|
|
|
|
try {
|
|
// Coletar informações do dispositivo
|
|
etapaProcessamento = 'coletando';
|
|
const informacoesDispositivo = await obterInformacoesDispositivo();
|
|
// Nota: A permissão de sensor não é impeditiva - apenas câmera e localização são obrigatórias
|
|
|
|
coletandoInfo = false;
|
|
|
|
// Obter tempo sincronizado e aplicar GMT offset (igual ao relógio)
|
|
etapaProcessamento = 'sincronizando';
|
|
const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
|
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
|
|
const gmtOffset = configRelogio.gmtOffset ?? 0;
|
|
|
|
let timestampBase: number;
|
|
|
|
if (configRelogio.usarServidorExterno) {
|
|
try {
|
|
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
|
if (resultado.sucesso && resultado.timestamp) {
|
|
timestampBase = resultado.timestamp;
|
|
} else {
|
|
timestampBase = await obterTempoServidor(client);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Erro ao sincronizar com servidor externo:', error);
|
|
if (configRelogio.fallbackParaPC) {
|
|
timestampBase = Date.now();
|
|
} else {
|
|
timestampBase = await obterTempoServidor(client);
|
|
}
|
|
}
|
|
} else {
|
|
// Usar relógio do PC (sem sincronização com servidor)
|
|
timestampBase = Date.now();
|
|
}
|
|
|
|
// Aplicar GMT offset ao timestamp
|
|
// Quando GMT é 0, compensar o timezone local do navegador para que o timestamp
|
|
// represente o horário local (não UTC), evitando que apareça 3 horas a mais
|
|
let timestamp: number;
|
|
if (gmtOffset !== 0) {
|
|
// Aplicar offset configurado
|
|
timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000);
|
|
} else {
|
|
// Quando GMT = 0, ajustar para horário local do navegador
|
|
// getTimezoneOffset() retorna minutos POSITIVOS para fusos ATRÁS de UTC
|
|
// Exemplo: Brasil (UTC-3) retorna 180 minutos
|
|
// Subtrair esses minutos para que o timestamp represente o horário local
|
|
const timezoneOffset = new Date().getTimezoneOffset(); // Offset em minutos
|
|
timestamp = timestampBase - (timezoneOffset * 60 * 1000); // Subtrair minutos em milissegundos
|
|
}
|
|
// Sincronizado apenas se usar servidor externo e sincronização foi bem-sucedida
|
|
const sincronizadoComServidor = configRelogio.usarServidorExterno && timestampBase !== Date.now();
|
|
|
|
// Upload da imagem (obrigatória agora)
|
|
let imagemId: Id<'_storage'> | undefined = undefined;
|
|
if (imagemCapturada) {
|
|
try {
|
|
etapaProcessamento = 'upload';
|
|
imagemId = await uploadImagem(imagemCapturada);
|
|
} catch (error) {
|
|
console.error('Erro ao fazer upload da imagem:', error);
|
|
throw new Error('Erro ao fazer upload da imagem. Tente novamente.');
|
|
}
|
|
} else {
|
|
throw new Error('É necessário capturar uma foto para registrar o ponto.');
|
|
}
|
|
|
|
// Registrar ponto
|
|
etapaProcessamento = 'registrando';
|
|
const resultado = await client.mutation(api.pontos.registrarPonto, {
|
|
imagemId,
|
|
informacoesDispositivo,
|
|
timestamp,
|
|
sincronizadoComServidor,
|
|
justificativa: justificativa.trim() || undefined
|
|
});
|
|
|
|
registroId = resultado.registroId;
|
|
const tipoLabelSucesso = config
|
|
? getTipoRegistroLabel(resultado.tipo, {
|
|
nomeEntrada: config.nomeEntrada,
|
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
|
nomeSaida: config.nomeSaida,
|
|
})
|
|
: getTipoRegistroLabel(resultado.tipo);
|
|
sucesso = `Ponto registrado com sucesso! Tipo: ${tipoLabelSucesso}`;
|
|
imagemCapturada = null;
|
|
justificativa = ''; // Limpar justificativa após registro
|
|
mostrandoModalConfirmacao = false;
|
|
|
|
// Forçar atualização das queries para mostrar o novo registro
|
|
refreshKey++;
|
|
|
|
if (import.meta.env.DEV) {
|
|
console.log('[RegistroPonto] Registro bem-sucedido, refreshKey incrementado:', refreshKey);
|
|
}
|
|
|
|
// Aguardar um pouco para garantir que o backend processou o registro
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
// Forçar mais uma atualização após o delay para garantir sincronização
|
|
refreshKey++;
|
|
|
|
// Mostrar comprovante após 1 segundo
|
|
setTimeout(() => {
|
|
mostrandoComprovante = true;
|
|
}, 1000);
|
|
} catch (error) {
|
|
console.error('Erro ao registrar ponto:', error);
|
|
aguardandoProcessamento = false;
|
|
etapaProcessamento = null;
|
|
let mensagemErro = 'Erro desconhecido ao registrar ponto';
|
|
let detalhesErro = 'Tente novamente em alguns instantes.';
|
|
|
|
if (error instanceof Error) {
|
|
const erroMessage = error.message || '';
|
|
|
|
// Erro de registro duplicado
|
|
if (
|
|
erroMessage.includes('Já existe um registro neste minuto') ||
|
|
erroMessage.includes('já existe um registro')
|
|
) {
|
|
mensagemErro = 'Registro de ponto duplicado';
|
|
const tipoLabelErro = config
|
|
? getTipoRegistroLabel(proximoTipo, {
|
|
nomeEntrada: config.nomeEntrada,
|
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
|
nomeSaida: config.nomeSaida,
|
|
})
|
|
: getTipoRegistroLabel(proximoTipo);
|
|
detalhesErro = `Não é possível registrar o ponto no mesmo minuto.\n\nVocê já possui um registro de ${tipoLabelErro} para este minuto.\n\nPor favor, aguarde pelo menos 1 minuto antes de tentar registrar novamente.`;
|
|
}
|
|
// Erro de validação de argumentos
|
|
else if (
|
|
erroMessage.includes('ArgumentValidationError') ||
|
|
erroMessage.includes('Object contains extra field') ||
|
|
erroMessage.includes('validation')
|
|
) {
|
|
mensagemErro = 'Erro na validação dos dados';
|
|
detalhesErro = 'Ocorreu um erro ao validar as informações do dispositivo.\n\nPor favor, tente novamente ou recarregue a página.';
|
|
}
|
|
// Erro de autenticação
|
|
else if (
|
|
erroMessage.includes('não autenticado') ||
|
|
erroMessage.includes('autenticado') ||
|
|
erroMessage.includes('auth')
|
|
) {
|
|
mensagemErro = 'Erro de autenticação';
|
|
detalhesErro = 'Sua sessão pode ter expirado. Por favor, faça login novamente.';
|
|
}
|
|
// Erro de permissão/validação de localização
|
|
else if (
|
|
erroMessage.includes('localização') ||
|
|
erroMessage.includes('Localização') ||
|
|
erroMessage.includes('location')
|
|
) {
|
|
mensagemErro = 'Erro na validação de localização';
|
|
detalhesErro = 'Não foi possível validar sua localização.\n\nPor favor, verifique se você autorizou o compartilhamento de localização e tente novamente.';
|
|
}
|
|
// Erro genérico do servidor
|
|
else if (erroMessage.includes('Server Error') || erroMessage.includes('Server')) {
|
|
mensagemErro = 'Erro no servidor';
|
|
detalhesErro = 'Ocorreu um erro no servidor ao processar seu registro.\n\nPor favor, tente novamente em alguns instantes.';
|
|
}
|
|
// Outros erros - mostrar mensagem simplificada
|
|
else {
|
|
mensagemErro = 'Erro ao registrar ponto';
|
|
// Se a mensagem de erro for muito técnica, mostrar mensagem genérica
|
|
if (
|
|
erroMessage.includes('Error:') ||
|
|
erroMessage.includes('TypeError') ||
|
|
erroMessage.includes('ReferenceError') ||
|
|
erroMessage.length > 200
|
|
) {
|
|
detalhesErro = 'Ocorreu um erro ao processar o registro.\n\nPor favor, tente novamente ou recarregue a página.';
|
|
} else {
|
|
detalhesErro = erroMessage;
|
|
}
|
|
}
|
|
}
|
|
|
|
mensagemErroModal = mensagemErro;
|
|
detalhesErroModal = detalhesErro;
|
|
mostrarModalErro = true;
|
|
} finally {
|
|
registrando = false;
|
|
coletandoInfo = false;
|
|
aguardandoProcessamento = false;
|
|
etapaProcessamento = null;
|
|
}
|
|
}
|
|
|
|
async function handleWebcamCapture(blob: Blob | null) {
|
|
if (blob) {
|
|
imagemCapturada = blob;
|
|
}
|
|
mostrandoWebcam = false;
|
|
|
|
// Se capturou a foto, mostrar modal de confirmação
|
|
if (blob && capturandoAutomaticamente) {
|
|
capturandoAutomaticamente = false;
|
|
// Obter data e hora sincronizada do servidor com GMT offset (igual ao relógio)
|
|
try {
|
|
const configRelogio = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
|
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
|
|
const gmtOffset = configRelogio.gmtOffset ?? 0;
|
|
|
|
let timestampBase: number;
|
|
|
|
if (configRelogio.usarServidorExterno) {
|
|
try {
|
|
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
|
if (resultado.sucesso && resultado.timestamp) {
|
|
timestampBase = resultado.timestamp;
|
|
} else {
|
|
timestampBase = await obterTempoServidor(client);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Erro ao sincronizar com servidor externo:', error);
|
|
if (configRelogio.fallbackParaPC) {
|
|
timestampBase = Date.now();
|
|
} else {
|
|
timestampBase = await obterTempoServidor(client);
|
|
}
|
|
}
|
|
} else {
|
|
// Usar relógio do PC (sem sincronização com servidor)
|
|
timestampBase = Date.now();
|
|
}
|
|
|
|
// Aplicar GMT offset ao timestamp
|
|
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
|
|
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
|
|
let timestamp: number;
|
|
if (gmtOffset !== 0) {
|
|
// Aplicar offset configurado
|
|
timestamp = timestampBase + (gmtOffset * 60 * 60 * 1000);
|
|
} else {
|
|
// Quando GMT = 0, manter timestamp UTC puro
|
|
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
|
|
timestamp = timestampBase;
|
|
}
|
|
const dataObj = new Date(timestamp);
|
|
const data = dataObj.toLocaleDateString('pt-BR');
|
|
const hora = dataObj.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
dataHoraAtual = { data, hora };
|
|
} catch (error) {
|
|
console.warn('Erro ao obter tempo do servidor, usando tempo local:', error);
|
|
atualizarDataHoraAtual();
|
|
}
|
|
|
|
// Mostrar transição antes da confirmação
|
|
mostrandoTransicao = true;
|
|
setTimeout(() => {
|
|
mostrandoTransicao = false;
|
|
mostrandoModalConfirmacao = true;
|
|
}, 1500);
|
|
}
|
|
}
|
|
|
|
function handleWebcamCancel() {
|
|
mostrandoWebcam = false;
|
|
capturandoAutomaticamente = false;
|
|
imagemCapturada = null;
|
|
mostrandoModalConfirmacao = false;
|
|
}
|
|
|
|
function handleWebcamError() {
|
|
// Em caso de erro na captura, fechar tudo
|
|
mostrandoWebcam = false;
|
|
capturandoAutomaticamente = false;
|
|
imagemCapturada = null;
|
|
mostrandoModalConfirmacao = false;
|
|
mensagemErroModal = 'Erro ao capturar foto';
|
|
detalhesErroModal = 'Não foi possível acessar a webcam. Verifique as permissões do navegador.';
|
|
mostrarModalErro = true;
|
|
}
|
|
|
|
function atualizarDataHoraAtual() {
|
|
const agora = new Date();
|
|
const data = agora.toLocaleDateString('pt-BR');
|
|
const hora = agora.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
dataHoraAtual = { data, hora };
|
|
}
|
|
|
|
async function iniciarRegistroComFoto() {
|
|
if (registrando || coletandoInfo) return;
|
|
|
|
// Verificar se tem funcionário associado
|
|
if (!temFuncionarioAssociado) {
|
|
mensagemErroModal = 'Usuário não possui funcionário associado';
|
|
detalhesErroModal = 'Você não possui um funcionário associado à sua conta. Entre em contato com o administrador do sistema.';
|
|
mostrarModalErro = true;
|
|
return;
|
|
}
|
|
|
|
// Verificar se está dispensado antes de abrir webcam
|
|
if (estaDispensado) {
|
|
mensagemErroModal = 'Registro dispensado pelo gestor';
|
|
detalhesErroModal = motivoDispensa || 'Você está dispensado de registrar ponto no momento.';
|
|
mostrarModalErro = true;
|
|
return;
|
|
}
|
|
|
|
// Verificar permissões antes de abrir webcam
|
|
const permissoes = await verificarPermissoes();
|
|
if (!permissoes.localizacao || !permissoes.webcam) {
|
|
mensagemErroModal = 'Permissões necessárias';
|
|
detalhesErroModal = 'Para registrar o ponto, é necessário autorizar o compartilhamento de localização e a captura de foto.';
|
|
mostrarModalErro = true;
|
|
return;
|
|
}
|
|
|
|
// Abrir webcam
|
|
capturandoAutomaticamente = true;
|
|
mostrandoWebcam = true;
|
|
}
|
|
|
|
function confirmarRegistro() {
|
|
mostrandoModalConfirmacao = false;
|
|
mostrandoTransicao = true; // Mostrar transição antes do processamento
|
|
|
|
setTimeout(() => {
|
|
mostrandoTransicao = false;
|
|
aguardandoProcessamento = true;
|
|
etapaProcessamento = 'coletando';
|
|
// Usar setTimeout para garantir que o modal de processamento apareça antes de iniciar o registro
|
|
setTimeout(() => {
|
|
registrarPonto();
|
|
}, 100);
|
|
}, 1500);
|
|
}
|
|
|
|
function cancelarRegistro() {
|
|
mostrandoModalConfirmacao = false;
|
|
imagemCapturada = null;
|
|
}
|
|
|
|
function fecharComprovante() {
|
|
mostrandoComprovante = false;
|
|
registroId = null;
|
|
}
|
|
|
|
function fecharModalErro() {
|
|
mostrarModalErro = false;
|
|
mensagemErroModal = '';
|
|
detalhesErroModal = '';
|
|
}
|
|
|
|
async function imprimirComprovante(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;
|
|
}
|
|
|
|
// Buscar configuração para usar nomes personalizados
|
|
const configComprovante = await client.query(api.configuracaoPonto.obterConfiguracao, {});
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 tipoLabelComprovante = configComprovante
|
|
? getTipoRegistroLabel(registro.tipo, {
|
|
nomeEntrada: configComprovante.nomeEntrada,
|
|
nomeSaidaAlmoco: configComprovante.nomeSaidaAlmoco,
|
|
nomeRetornoAlmoco: configComprovante.nomeRetornoAlmoco,
|
|
nomeSaida: configComprovante.nomeSaida,
|
|
})
|
|
: getTipoRegistroLabel(registro.tipo);
|
|
doc.text(`Tipo: ${tipoLabelComprovante}`, 15, yPosition);
|
|
yPosition += 6;
|
|
|
|
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) {
|
|
// 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.toString().padStart(2, '0')}${registro.minuto.toString().padStart(2, '0')}.pdf`;
|
|
doc.save(nomeArquivo);
|
|
} catch (error) {
|
|
console.error('Erro ao gerar comprovante PDF:', error);
|
|
alert('Erro ao gerar comprovante PDF. Tente novamente.');
|
|
}
|
|
}
|
|
|
|
const dispensaAtiva = $derived(dispensaQuery?.data);
|
|
const estaDispensado = $derived(dispensaAtiva?.dispensado ?? false);
|
|
const motivoDispensa = $derived(dispensaAtiva?.motivo ?? null);
|
|
const temFuncionarioAssociado = $derived(funcionarioId !== null);
|
|
|
|
const podeRegistrar = $derived.by(() => {
|
|
return !registrando && !coletandoInfo && config !== undefined && !estaDispensado && temFuncionarioAssociado;
|
|
});
|
|
|
|
// Os modais agora são centralizados automaticamente via CSS (fixed inset-0 flex items-center justify-center)
|
|
|
|
// Solicitar permissões automaticamente ao montar o componente
|
|
onMount(async () => {
|
|
// Solicitar permissão de webcam
|
|
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
try {
|
|
// Solicitar apenas permissão, sem iniciar o stream ainda
|
|
await navigator.mediaDevices.getUserMedia({ video: true });
|
|
} catch (error) {
|
|
// Ignorar erro silenciosamente - a permissão será solicitada quando necessário
|
|
console.log('Permissão de webcam não concedida ainda');
|
|
}
|
|
}
|
|
|
|
// Solicitar permissão de geolocalização
|
|
if (navigator.geolocation) {
|
|
try {
|
|
// Solicitar permissão de geolocalização (timeout curto para não bloquear)
|
|
await new Promise<void>((resolve) => {
|
|
const timeoutId = setTimeout(() => resolve(), 2000); // Timeout de 2 segundos
|
|
navigator.geolocation.getCurrentPosition(
|
|
() => {
|
|
clearTimeout(timeoutId);
|
|
resolve();
|
|
},
|
|
() => {
|
|
clearTimeout(timeoutId);
|
|
resolve(); // Resolver mesmo se negado
|
|
},
|
|
{ timeout: 2000, maximumAge: 0, enableHighAccuracy: false } // enableHighAccuracy false para ser mais rápido
|
|
);
|
|
});
|
|
} catch (error) {
|
|
// Ignorar erro silenciosamente
|
|
console.log('Permissão de geolocalização não concedida ainda');
|
|
}
|
|
}
|
|
});
|
|
|
|
const mapaHorarios = $derived.by(() => {
|
|
if (!config) return [];
|
|
|
|
const horarios = [
|
|
{ tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' },
|
|
{ tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: config.nomeSaidaAlmoco || 'Saída 1' },
|
|
{ tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: config.nomeRetornoAlmoco || 'Entrada 2' },
|
|
{ tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' }
|
|
];
|
|
|
|
const resultado = horarios.map((h) => {
|
|
const registro = registrosHoje.find((r) => r.tipo === h.tipo);
|
|
return {
|
|
...h,
|
|
registrado: !!registro,
|
|
horarioRegistrado: registro ? formatarHoraPonto(registro.hora, registro.minuto) : null,
|
|
dentroDoPrazo: registro?.dentroDoPrazo ?? null
|
|
};
|
|
});
|
|
|
|
// Log para debug (apenas em desenvolvimento)
|
|
if (import.meta.env.DEV) {
|
|
console.log('[RegistroPonto] mapaHorarios atualizado:', {
|
|
totalRegistrosHoje: registrosHoje.length,
|
|
horariosComRegistro: resultado.filter(h => h.registrado).length,
|
|
registrosHoje: registrosHoje.map(r => ({ tipo: r.tipo, hora: `${r.hora}:${r.minuto}` }))
|
|
});
|
|
}
|
|
|
|
return resultado;
|
|
});
|
|
|
|
// Dados do histórico e saldo
|
|
const historicoSaldo = $derived(historicoSaldoQuery?.data);
|
|
const registrosOrdenados = $derived.by(() => {
|
|
if (!historicoSaldo?.registros) return [];
|
|
return [...historicoSaldo.registros].sort((a, b) => {
|
|
const minutosA = a.hora * 60 + a.minuto;
|
|
const minutosB = b.hora * 60 + b.minuto;
|
|
return minutosA - minutosB;
|
|
});
|
|
});
|
|
|
|
// Formatação do saldo
|
|
const saldoFormatado = $derived.by(() => {
|
|
if (!historicoSaldo) return null;
|
|
const minutos = historicoSaldo.saldoMinutos;
|
|
const horas = Math.floor(Math.abs(minutos) / 60);
|
|
const mins = Math.abs(minutos) % 60;
|
|
const sinal = minutos >= 0 ? '+' : '-';
|
|
return `${sinal}${horas}h ${mins}min`;
|
|
});
|
|
|
|
const saldoPositivo = $derived(historicoSaldo ? historicoSaldo.saldoMinutos >= 0 : false);
|
|
|
|
// Posicionamento dos modais
|
|
// Posicionamento dos modais
|
|
// Removido modalPosition pois agora será centralizado via CSS fixo
|
|
|
|
// Função para obter estilo do modal (centralizado)
|
|
function getModalStyle() {
|
|
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 800px;';
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Alerta de Funcionário Não Associado -->
|
|
{#if !temFuncionarioAssociado}
|
|
<div class="alert alert-error shadow-lg">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-6 w-6 shrink-0 stroke-current"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<div>
|
|
<h3 class="font-bold">Funcionário Não Associado</h3>
|
|
<div class="text-sm">
|
|
Você não possui um funcionário associado à sua conta.
|
|
<br />
|
|
Entre em contato com o administrador do sistema para associar um funcionário à sua conta.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Alerta de Dispensa -->
|
|
{#if estaDispensado && motivoDispensa && temFuncionarioAssociado}
|
|
<div class="alert alert-warning shadow-lg">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-6 w-6 shrink-0 stroke-current"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
/>
|
|
</svg>
|
|
<div>
|
|
<h3 class="font-bold">Registro de Ponto Dispensado</h3>
|
|
<div class="text-sm">
|
|
Você está dispensado de registrar ponto no momento.
|
|
<br />
|
|
<strong>Motivo:</strong> {motivoDispensa}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Card de Registro de Ponto Modernizado -->
|
|
<div class="card bg-gradient-to-br from-base-100 via-base-100 to-primary/5 border border-base-300 shadow-2xl max-w-2xl mx-auto">
|
|
<div id="card-registro-ponto-ref" class="card-body p-6">
|
|
<!-- Cabeçalho -->
|
|
<div class="flex items-center justify-center gap-3 mb-6">
|
|
<div class="p-2.5 bg-primary/10 rounded-xl">
|
|
<Clock class="h-6 w-6 text-primary" strokeWidth={2.5} />
|
|
</div>
|
|
<h2 class="card-title text-2xl font-black text-base-content">Registrar Ponto</h2>
|
|
</div>
|
|
|
|
<!-- Relógio Sincronizado -->
|
|
<div class="mb-5 flex justify-center">
|
|
<div id="relogio-sincronizado-ref" class="card bg-gradient-to-br from-primary/10 to-primary/5 border-2 border-primary/20 shadow-lg rounded-2xl p-5 w-full max-w-sm">
|
|
<RelogioSincronizado />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Botão de Registro -->
|
|
<button
|
|
class="btn btn-primary w-full shadow-lg hover:shadow-xl transition-all duration-300 font-semibold rounded-xl gap-2 mb-5"
|
|
onclick={iniciarRegistroComFoto}
|
|
disabled={!podeRegistrar}
|
|
title={!temFuncionarioAssociado
|
|
? 'Você não possui funcionário associado à sua conta'
|
|
: estaDispensado
|
|
? 'Você está dispensado de registrar ponto no momento'
|
|
: ''}
|
|
>
|
|
{#if registrando}
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
{#if coletandoInfo}
|
|
Coletando informações...
|
|
{:else}
|
|
Registrando...
|
|
{/if}
|
|
{:else if !temFuncionarioAssociado}
|
|
<XCircle class="h-5 w-5" />
|
|
Funcionário Não Associado
|
|
{:else if estaDispensado}
|
|
<XCircle class="h-5 w-5" />
|
|
Registro Indisponível
|
|
{:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
|
|
<LogIn class="h-5 w-5" />
|
|
Registrar Entrada
|
|
{:else}
|
|
<LogOut class="h-5 w-5" />
|
|
Registrar Saída
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Campo de Justificativa -->
|
|
<div class="mb-5">
|
|
<label for="justificativa" class="label pb-1.5">
|
|
<span class="label-text font-semibold text-xs">Justificativa <span class="text-base-content/50 font-normal">(Opcional)</span></span>
|
|
</label>
|
|
<textarea
|
|
id="justificativa"
|
|
class="textarea textarea-bordered w-full focus:textarea-primary focus:ring-2 focus:ring-primary/20 rounded-xl resize-none text-sm"
|
|
placeholder="Digite uma justificativa para este registro de ponto (opcional)"
|
|
bind:value={justificativa}
|
|
disabled={registrando}
|
|
rows="2"
|
|
></textarea>
|
|
</div>
|
|
|
|
<!-- Mensagem de Sucesso -->
|
|
{#if sucesso}
|
|
<div class="alert alert-success shadow-lg mb-5 rounded-xl">
|
|
<CheckCircle2 class="h-4 w-4" />
|
|
<span class="font-semibold text-sm">{sucesso}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Próximo Registro -->
|
|
<div class="card bg-gradient-to-br from-info/10 to-info/5 border-2 border-info/20 shadow-md rounded-xl p-4">
|
|
<div class="flex items-center justify-center gap-2">
|
|
<div class="p-1.5 bg-info/20 rounded-lg">
|
|
{#if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
|
|
<LogIn class="h-4 w-4 text-info" strokeWidth={2.5} />
|
|
{:else}
|
|
<LogOut class="h-4 w-4 text-info" strokeWidth={2.5} />
|
|
{/if}
|
|
</div>
|
|
<div class="text-center">
|
|
<p class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-0.5">Próximo Registro</p>
|
|
<p class="text-base font-bold text-base-content">{tipoLabel}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mapa de Horários -->
|
|
<div class="card bg-base-100 shadow-xl">
|
|
<div class="card-body">
|
|
<h2 class="card-title mb-6">
|
|
<Clock class="h-5 w-5" />
|
|
Horário Padrão
|
|
</h2>
|
|
|
|
<!-- Linha horizontal com espaçamento uniforme -->
|
|
<div class="flex flex-wrap items-stretch justify-between gap-4 md:gap-6">
|
|
{#each mapaHorarios as horario (horario.tipo)}
|
|
<div class="flex-1 min-w-[140px] max-w-[220px] mx-auto">
|
|
<div
|
|
class="relative h-full rounded-xl border-2 transition-all duration-300 hover:shadow-lg {horario.registrado
|
|
? horario.dentroDoPrazo
|
|
? 'bg-gradient-to-br from-success/20 to-success/10 border-success shadow-md'
|
|
: 'bg-gradient-to-br from-error/20 to-error/10 border-error shadow-md'
|
|
: 'bg-gradient-to-br from-base-200 to-base-300 border-base-300'} p-5"
|
|
>
|
|
<!-- Status Icon -->
|
|
<div class="absolute top-3 right-3">
|
|
{#if horario.registrado}
|
|
{#if horario.dentroDoPrazo}
|
|
<CheckCircle2 class="h-5 w-5 text-success" />
|
|
{:else}
|
|
<XCircle class="h-5 w-5 text-error" />
|
|
{/if}
|
|
{:else}
|
|
<Clock class="h-5 w-5 text-base-content/30" />
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Label -->
|
|
<div class="mb-3">
|
|
<span class="text-sm font-semibold text-base-content/80 uppercase tracking-wide">
|
|
{horario.label}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Horário Padrão -->
|
|
<div class="mb-2">
|
|
<div class="text-3xl font-bold text-primary font-mono">
|
|
{horario.horario}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Horário Registrado (se houver) -->
|
|
{#if horario.registrado}
|
|
<div class="mt-3 pt-3 border-t border-base-content/10">
|
|
<div class="flex items-center gap-2">
|
|
<div class="text-xs font-medium text-base-content/60">
|
|
Registrado:
|
|
</div>
|
|
<div class="text-sm font-bold text-base-content">
|
|
{horario.horarioRegistrado}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="mt-3 pt-3 border-t border-base-content/10">
|
|
<div class="text-xs text-base-content/40 italic">
|
|
Aguardando registro
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Histórico e Saldo do Dia -->
|
|
{#if historicoSaldo && registrosOrdenados.length > 0}
|
|
<div class="card bg-base-100 shadow-xl">
|
|
<div class="card-body">
|
|
<h2 class="card-title">
|
|
<Clock class="h-5 w-5" />
|
|
Histórico do Dia
|
|
</h2>
|
|
|
|
<!-- Saldo de Horas -->
|
|
<div class="my-4 rounded-lg border-2 p-4 {saldoPositivo ? 'border-success bg-success/10' : 'border-error bg-error/10'}">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-sm font-semibold opacity-70">Saldo de Horas</p>
|
|
<p class="text-2xl font-bold">
|
|
{saldoFormatado}
|
|
</p>
|
|
</div>
|
|
{#if saldoPositivo}
|
|
<TrendingUp class="h-8 w-8 text-success" />
|
|
{:else}
|
|
<TrendingDown class="h-8 w-8 text-error" />
|
|
{/if}
|
|
</div>
|
|
<div class="mt-2 text-sm opacity-70">
|
|
<p>Carga Horária Diária: {Math.floor(historicoSaldo.cargaHorariaDiaria / 60)}h {historicoSaldo.cargaHorariaDiaria % 60}min</p>
|
|
<p>Horas Trabalhadas: {Math.floor(historicoSaldo.horasTrabalhadas / 60)}h {historicoSaldo.horasTrabalhadas % 60}min</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Timeline de Registros -->
|
|
<div class="divider"></div>
|
|
<div class="space-y-4">
|
|
<h3 class="font-semibold">Timeline do Dia</h3>
|
|
|
|
<!-- Timeline Visual com horários padrão e registros reais -->
|
|
<div class="relative">
|
|
<!-- Linha vertical central da timeline -->
|
|
<div class="absolute left-1/2 top-0 bottom-0 w-1 bg-gradient-to-b from-primary/20 via-base-300 to-secondary/20 transform -translate-x-1/2"></div>
|
|
|
|
<!-- Container com duas colunas -->
|
|
<div class="grid grid-cols-2 gap-4 relative">
|
|
<!-- Coluna Entrada -->
|
|
<div class="space-y-4 pr-2">
|
|
<div class="sticky top-0 z-10 bg-base-100 pb-3 mb-2 border-b border-primary/20">
|
|
<h4 class="text-lg font-bold text-primary text-center flex items-center justify-center gap-2">
|
|
<LogIn class="h-5 w-5" />
|
|
Entradas
|
|
</h4>
|
|
</div>
|
|
|
|
{#each registrosOrdenados.filter(r => r.tipo === 'entrada' || r.tipo === 'retorno_almoco') as registro (registro._id)}
|
|
<div class="relative">
|
|
<!-- Linha horizontal conectando à timeline -->
|
|
<div class="absolute right-0 top-6 w-full h-0.5 bg-base-300/50" style="width: calc(100% - 0.5rem);"></div>
|
|
|
|
<!-- Card do registro -->
|
|
<div class="card {registro.dentroDoPrazo ? 'bg-success/5 border-success/30' : 'bg-error/5 border-error/30'} border-2 shadow-md hover:shadow-lg transition-all">
|
|
<div class="card-body p-4">
|
|
<!-- Tipo de registro e status -->
|
|
<div class="flex items-center gap-2 mb-2">
|
|
{#if registro.dentroDoPrazo}
|
|
<CheckCircle2 class="h-4 w-4 text-success flex-shrink-0" />
|
|
{:else}
|
|
<XCircle class="h-4 w-4 text-error flex-shrink-0" />
|
|
{/if}
|
|
<span class="text-sm font-semibold text-base-content/80">
|
|
{config
|
|
? getTipoRegistroLabel(registro.tipo, {
|
|
nomeEntrada: config.nomeEntrada,
|
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
|
})
|
|
: getTipoRegistroLabel(registro.tipo)}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Horário registrado -->
|
|
<p class="text-3xl font-bold text-primary mb-1">
|
|
{formatarHoraPonto(registro.hora, registro.minuto)}
|
|
</p>
|
|
|
|
<!-- Comparação com horário esperado -->
|
|
{#if config}
|
|
{@const horarioEsperado = registro.tipo === 'entrada' ? config.horarioEntrada : config.horarioRetornoAlmoco}
|
|
{@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)}
|
|
{@const minutosEsperados = horaEsperada * 60 + minutoEsperado}
|
|
{@const minutosRegistrados = registro.hora * 60 + registro.minuto}
|
|
{@const diferenca = minutosRegistrados - minutosEsperados}
|
|
{@const diferencaAbs = Math.abs(diferenca)}
|
|
{@const diferencaTexto = diferencaAbs >= 60
|
|
? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min`
|
|
: `${diferencaAbs}min`}
|
|
|
|
<div class="flex items-center gap-2 text-xs mb-3">
|
|
<span class="text-base-content/50">Esperado:</span>
|
|
<span class="font-semibold">{horarioEsperado}</span>
|
|
{#if diferencaAbs > 0}
|
|
<span class="badge badge-xs {diferenca > 0 ? 'badge-warning' : 'badge-info'}">
|
|
{diferenca > 0 ? '+' : '-'}{diferencaTexto}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if registro.justificativa}
|
|
<div class="mt-2 rounded-lg bg-base-300/50 p-2 text-xs mb-3">
|
|
<p class="font-semibold opacity-70 mb-1">Justificativa:</p>
|
|
<p class="text-base-content/80">{registro.justificativa}</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<button
|
|
class="btn btn-sm btn-outline btn-primary gap-2 w-full"
|
|
onclick={() => imprimirComprovante(registro._id)}
|
|
title="Imprimir Comprovante"
|
|
>
|
|
<Printer class="h-4 w-4" />
|
|
Imprimir Comprovante
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
|
|
<!-- Mostrar horários esperados que não foram registrados -->
|
|
{#if config}
|
|
{#each [
|
|
{ tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' },
|
|
{ tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: config.nomeRetornoAlmoco || 'Entrada 2' }
|
|
] as horarioEsperado}
|
|
{#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)}
|
|
<div class="relative opacity-50">
|
|
<div class="absolute right-0 top-6 w-full h-0.5 bg-base-300/30 border-dashed" style="width: calc(100% - 0.5rem);"></div>
|
|
<div class="card bg-base-200/50 border border-dashed border-base-300">
|
|
<div class="card-body p-3">
|
|
<p class="text-xs text-base-content/50 mb-1">{horarioEsperado.label} (não registrado)</p>
|
|
<p class="text-xl font-bold text-base-content/40">{horarioEsperado.horario}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Coluna Saída -->
|
|
<div class="space-y-4 pl-2">
|
|
<div class="sticky top-0 z-10 bg-base-100 pb-3 mb-2 border-b border-secondary/20">
|
|
<h4 class="text-lg font-bold text-secondary text-center flex items-center justify-center gap-2">
|
|
<LogOut class="h-5 w-5" />
|
|
Saídas
|
|
</h4>
|
|
</div>
|
|
|
|
{#each registrosOrdenados.filter(r => r.tipo === 'saida_almoco' || r.tipo === 'saida') as registro (registro._id)}
|
|
<div class="relative">
|
|
<!-- Linha horizontal conectando à timeline -->
|
|
<div class="absolute left-0 top-6 w-full h-0.5 bg-base-300/50" style="width: calc(100% - 0.5rem);"></div>
|
|
|
|
<!-- Card do registro -->
|
|
<div class="card {registro.dentroDoPrazo ? 'bg-success/5 border-success/30' : 'bg-error/5 border-error/30'} border-2 shadow-md hover:shadow-lg transition-all">
|
|
<div class="card-body p-4">
|
|
<!-- Tipo de registro e status -->
|
|
<div class="flex items-center gap-2 mb-2 justify-end">
|
|
<span class="text-sm font-semibold text-base-content/80">
|
|
{config
|
|
? getTipoRegistroLabel(registro.tipo, {
|
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
|
nomeSaida: config.nomeSaida,
|
|
})
|
|
: getTipoRegistroLabel(registro.tipo)}
|
|
</span>
|
|
{#if registro.dentroDoPrazo}
|
|
<CheckCircle2 class="h-4 w-4 text-success flex-shrink-0" />
|
|
{:else}
|
|
<XCircle class="h-4 w-4 text-error flex-shrink-0" />
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Horário registrado -->
|
|
<p class="text-3xl font-bold text-secondary mb-1 text-right">
|
|
{formatarHoraPonto(registro.hora, registro.minuto)}
|
|
</p>
|
|
|
|
<!-- Comparação com horário esperado -->
|
|
{#if config}
|
|
{@const horarioEsperado = registro.tipo === 'saida_almoco' ? config.horarioSaidaAlmoco : config.horarioSaida}
|
|
{@const [horaEsperada, minutoEsperado] = horarioEsperado.split(':').map(Number)}
|
|
{@const minutosEsperados = horaEsperada * 60 + minutoEsperado}
|
|
{@const minutosRegistrados = registro.hora * 60 + registro.minuto}
|
|
{@const diferenca = minutosRegistrados - minutosEsperados}
|
|
{@const diferencaAbs = Math.abs(diferenca)}
|
|
{@const diferencaTexto = diferencaAbs >= 60
|
|
? `${Math.floor(diferencaAbs / 60)}h ${diferencaAbs % 60}min`
|
|
: `${diferencaAbs}min`}
|
|
|
|
<div class="flex items-center gap-2 text-xs mb-3 justify-end">
|
|
{#if diferencaAbs > 0}
|
|
<span class="badge badge-xs {diferenca > 0 ? 'badge-warning' : 'badge-info'}">
|
|
{diferenca > 0 ? '+' : '-'}{diferencaTexto}
|
|
</span>
|
|
{/if}
|
|
<span class="font-semibold">{horarioEsperado}</span>
|
|
<span class="text-base-content/50">Esperado:</span>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if registro.justificativa}
|
|
<div class="mt-2 rounded-lg bg-base-300/50 p-2 text-xs mb-3">
|
|
<p class="font-semibold opacity-70 mb-1">Justificativa:</p>
|
|
<p class="text-base-content/80">{registro.justificativa}</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<button
|
|
class="btn btn-sm btn-outline btn-primary gap-2 w-full"
|
|
onclick={() => imprimirComprovante(registro._id)}
|
|
title="Imprimir Comprovante"
|
|
>
|
|
<Printer class="h-4 w-4" />
|
|
Imprimir Comprovante
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
|
|
<!-- Mostrar horários esperados que não foram registrados -->
|
|
{#if config}
|
|
{#each [
|
|
{ tipo: 'saida_almoco', horario: config.horarioSaidaAlmoco, label: config.nomeSaidaAlmoco || 'Saída 1' },
|
|
{ tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' }
|
|
] as horarioEsperado}
|
|
{#if !registrosOrdenados.find(r => r.tipo === horarioEsperado.tipo)}
|
|
<div class="relative opacity-50">
|
|
<div class="absolute left-0 top-6 w-full h-0.5 bg-base-300/30 border-dashed" style="width: calc(100% - 0.5rem);"></div>
|
|
<div class="card bg-base-200/50 border border-dashed border-base-300">
|
|
<div class="card-body p-3">
|
|
<p class="text-xs text-base-content/50 mb-1 text-right">{horarioEsperado.label} (não registrado)</p>
|
|
<p class="text-xl font-bold text-base-content/40 text-right">{horarioEsperado.horario}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Modal Webcam -->
|
|
{#if mostrandoWebcam}
|
|
<div
|
|
class="fixed inset-0 z-50 pointer-events-none"
|
|
style="animation: fadeIn 0.2s ease-out;"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="modal-webcam-title"
|
|
>
|
|
<!-- Backdrop leve -->
|
|
<div
|
|
class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"
|
|
onclick={handleWebcamCancel}
|
|
></div>
|
|
|
|
<!-- Modal Box -->
|
|
<div
|
|
class="absolute bg-base-100 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300 pointer-events-auto"
|
|
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
|
|
onclick={(e) => e.stopPropagation()}
|
|
>
|
|
<!-- Header fixo -->
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300 flex-shrink-0">
|
|
<div class="flex items-center gap-3">
|
|
<div class="p-2 bg-primary/10 rounded-lg">
|
|
<Camera class="h-5 w-5 text-primary" strokeWidth={2} />
|
|
</div>
|
|
<h3 id="modal-webcam-title" class="text-xl font-bold text-base-content">Capturar Foto</h3>
|
|
</div>
|
|
<button
|
|
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
|
|
onclick={handleWebcamCancel}
|
|
>
|
|
<XCircle class="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Conteúdo com rolagem -->
|
|
<div class="flex-1 overflow-y-auto px-6 py-4 modal-scroll">
|
|
<div class="min-h-[200px] flex items-center justify-center">
|
|
<WebcamCapture
|
|
onCapture={handleWebcamCapture}
|
|
onCancel={handleWebcamCancel}
|
|
onError={handleWebcamError}
|
|
autoCapture={false}
|
|
fotoObrigatoria={true}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Modal de Transição -->
|
|
{#if mostrandoTransicao}
|
|
<div
|
|
class="fixed inset-0 z-50 pointer-events-none"
|
|
style="animation: fadeIn 0.2s ease-out;"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="modal-transicao-title"
|
|
>
|
|
<!-- Backdrop leve -->
|
|
<div class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"></div>
|
|
|
|
<!-- Modal Box -->
|
|
<div
|
|
class="absolute bg-base-100 rounded-2xl shadow-2xl z-10 transform transition-all duration-300 p-8 pointer-events-auto flex flex-col items-center justify-center min-h-[300px]"
|
|
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
|
|
>
|
|
<div class="flex flex-col items-center gap-6 text-center">
|
|
<div class="relative">
|
|
<div class="absolute inset-0 bg-primary/20 rounded-full animate-ping"></div>
|
|
<div class="relative p-4 bg-primary/10 rounded-full">
|
|
<Clock class="h-12 w-12 text-primary animate-pulse" strokeWidth={2} />
|
|
</div>
|
|
</div>
|
|
|
|
<h3 id="modal-transicao-title" class="text-2xl font-black text-base-content">
|
|
Registro de Ponto em Andamento
|
|
</h3>
|
|
|
|
<div class="flex gap-2">
|
|
<span class="loading loading-dots loading-md text-primary"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Modal de Aguardando Processamento -->
|
|
{#if aguardandoProcessamento}
|
|
<div
|
|
class="fixed inset-0 z-50 pointer-events-none"
|
|
style="animation: fadeIn 0.2s ease-out;"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="modal-aguardando-title"
|
|
>
|
|
<!-- Backdrop leve -->
|
|
<div class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"></div>
|
|
|
|
<!-- Modal Box -->
|
|
<div
|
|
class="absolute bg-base-100 rounded-2xl shadow-2xl max-w-md w-full z-10 transform transition-all duration-300 p-8 pointer-events-auto"
|
|
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
|
|
>
|
|
<div class="flex flex-col items-center gap-4 text-center">
|
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
<h3 id="modal-aguardando-title" class="text-xl font-bold text-base-content">
|
|
{#if etapaProcessamento === 'coletando'}
|
|
Coletando Informações
|
|
{:else if etapaProcessamento === 'sincronizando'}
|
|
Sincronizando Horário
|
|
{:else if etapaProcessamento === 'upload'}
|
|
Enviando Foto
|
|
{:else if etapaProcessamento === 'registrando'}
|
|
Registrando Ponto
|
|
{:else}
|
|
Processando Registro
|
|
{/if}
|
|
</h3>
|
|
<p class="text-base-content/70">
|
|
{#if etapaProcessamento === 'coletando'}
|
|
Coletando informações do dispositivo e localização...
|
|
{:else if etapaProcessamento === 'sincronizando'}
|
|
Sincronizando o horário com o servidor...
|
|
{:else if etapaProcessamento === 'upload'}
|
|
Enviando a foto capturada para o servidor...
|
|
{:else if etapaProcessamento === 'registrando'}
|
|
Finalizando o registro de ponto no sistema...
|
|
{:else}
|
|
Por favor, aguarde enquanto processamos seu registro de ponto...
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Modal de Confirmação -->
|
|
{#if mostrandoModalConfirmacao && imagemCapturada && dataHoraAtual}
|
|
<div
|
|
class="fixed inset-0 z-50 pointer-events-none"
|
|
style="animation: fadeIn 0.2s ease-out;"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="modal-confirmacao-title"
|
|
>
|
|
<!-- Backdrop leve -->
|
|
<div
|
|
class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"
|
|
onclick={cancelarRegistro}
|
|
></div>
|
|
|
|
<!-- Modal Box -->
|
|
<div
|
|
class="absolute bg-base-100 rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300 pointer-events-auto"
|
|
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
|
|
onclick={(e) => e.stopPropagation()}
|
|
>
|
|
<!-- Header fixo -->
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300 flex-shrink-0">
|
|
<div class="flex items-center gap-3">
|
|
<div class="p-2 bg-primary/10 rounded-lg">
|
|
<Clock class="h-6 w-6 text-primary" strokeWidth={2} />
|
|
</div>
|
|
<div>
|
|
<h3 id="modal-confirmacao-title" class="font-bold text-xl text-base-content">Confirmar Registro de Ponto</h3>
|
|
<p class="text-sm text-base-content/70">Verifique as informações antes de confirmar</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
|
|
onclick={cancelarRegistro}
|
|
disabled={registrando || aguardandoProcessamento}
|
|
>
|
|
<XCircle class="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Conteúdo com rolagem -->
|
|
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6 modal-scroll">
|
|
<!-- Card da Imagem -->
|
|
<div class="card bg-gradient-to-br from-base-200 to-base-300 shadow-lg border-2 border-primary/20">
|
|
<div class="card-body p-6">
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<div class="p-1.5 bg-primary/10 rounded-lg">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-5 w-5 text-primary"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<h4 class="font-semibold text-lg">Foto Capturada</h4>
|
|
</div>
|
|
<div class="flex justify-center bg-base-100 rounded-xl p-4 border-2 border-primary/30">
|
|
<img
|
|
src={URL.createObjectURL(imagemCapturada)}
|
|
alt="Foto capturada do registro de ponto"
|
|
class="max-w-full max-h-[250px] rounded-lg shadow-md object-contain"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Card de Informações -->
|
|
<div class="card bg-gradient-to-br from-primary/5 to-primary/10 shadow-lg border-2 border-primary/20">
|
|
<div class="card-body p-6">
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<div class="p-1.5 bg-primary/20 rounded-lg">
|
|
<CheckCircle2 class="h-5 w-5 text-primary" strokeWidth={2} />
|
|
</div>
|
|
<h4 class="font-semibold text-lg">Informações do Registro</h4>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<!-- Tipo de Registro -->
|
|
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
|
<p class="text-sm font-medium text-base-content/70 mb-1">Tipo de Registro</p>
|
|
<p class="text-lg font-bold text-primary">{tipoLabel}</p>
|
|
</div>
|
|
|
|
<!-- Data -->
|
|
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
|
<p class="text-sm font-medium text-base-content/70 mb-1">Data</p>
|
|
<p class="text-lg font-bold text-base-content">{dataHoraAtual.data}</p>
|
|
</div>
|
|
|
|
<!-- Hora -->
|
|
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
|
<p class="text-sm font-medium text-base-content/70 mb-1">Horário</p>
|
|
<p class="text-lg font-bold text-base-content">{dataHoraAtual.hora}</p>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
|
<p class="text-sm font-medium text-base-content/70 mb-1">Status</p>
|
|
<div class="badge badge-success badge-lg gap-2">
|
|
<CheckCircle2 class="h-4 w-4" />
|
|
Pronto para Registrar
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Justificativa (se houver) -->
|
|
{#if justificativa.trim()}
|
|
<div class="mt-4 bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
|
<p class="text-sm font-medium text-base-content/70 mb-2">Justificativa</p>
|
|
<p class="text-base text-base-content whitespace-pre-wrap">{justificativa}</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Aviso -->
|
|
<div class="alert alert-info shadow-lg">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-6 w-6 shrink-0 stroke-current"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<div>
|
|
<h3 class="font-bold">Confirme os dados</h3>
|
|
<div class="text-sm">
|
|
Verifique se a foto, data e horário estão corretos antes de confirmar o registro.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer fixo com botões -->
|
|
<div class="flex justify-end gap-3 px-6 py-4 border-t border-base-300 flex-shrink-0">
|
|
<button
|
|
class="btn btn-outline"
|
|
onclick={cancelarRegistro}
|
|
disabled={registrando || aguardandoProcessamento}
|
|
>
|
|
<XCircle class="h-5 w-5" />
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
class="btn btn-primary gap-2"
|
|
onclick={confirmarRegistro}
|
|
disabled={registrando || aguardandoProcessamento}
|
|
>
|
|
{#if registrando || aguardandoProcessamento}
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
Processando...
|
|
{:else}
|
|
<CheckCircle2 class="h-5 w-5" />
|
|
Confirmar Registro
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Modal Comprovante -->
|
|
{#if mostrandoComprovante && registroId}
|
|
<ComprovantePonto {registroId} onClose={fecharComprovante} />
|
|
{/if}
|
|
|
|
<!-- Modal de Erro -->
|
|
<ErrorModal
|
|
open={mostrarModalErro}
|
|
title={mensagemErroModal || 'Erro ao registrar ponto'}
|
|
message={detalhesErroModal || mensagemErroModal || 'Ocorreu um erro ao registrar o ponto. Tente novamente.'}
|
|
onClose={fecharModalErro}
|
|
/>
|
|
</div>
|
|
|
|
<style>
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(20px) scale(0.95);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
}
|
|
|
|
/* Scrollbar customizada para os modais */
|
|
:global(.modal-scroll) {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
|
|
scroll-behavior: smooth;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
:global(.modal-scroll::-webkit-scrollbar) {
|
|
width: 8px;
|
|
}
|
|
|
|
:global(.modal-scroll::-webkit-scrollbar-track) {
|
|
background: transparent;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
:global(.modal-scroll::-webkit-scrollbar-thumb) {
|
|
background-color: hsl(var(--bc) / 0.3);
|
|
border-radius: 4px;
|
|
transition: background-color 0.2s ease;
|
|
}
|
|
|
|
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
|
|
background-color: hsl(var(--bc) / 0.5);
|
|
}
|
|
</style>
|