Merge remote-tracking branch 'origin' into feat-pedidos

This commit is contained in:
2025-12-11 10:08:12 -03:00
194 changed files with 30374 additions and 10247 deletions

View File

@@ -1,33 +1,37 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { FunctionReference } from 'convex/server';
import { useConvexClient, useQuery } from 'convex-svelte';
import jsPDF from 'jspdf';
import {
Camera,
CheckCircle2,
Clock,
LogIn,
LogOut,
Printer,
TrendingDown,
TrendingUp,
XCircle
} from 'lucide-svelte';
import { onMount } from 'svelte';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
import { obterInformacoesDispositivo } from '$lib/utils/deviceInfo';
import { obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
import {
formatarDataHoraCompleta,
formatarHoraPonto,
getProximoTipoRegistro,
getTipoRegistroLabel
} from '$lib/utils/ponto';
import { obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
import ErrorModal from '../ErrorModal.svelte';
import ComprovantePonto from './ComprovantePonto.svelte';
import {
LogIn,
LogOut,
Clock,
CheckCircle2,
XCircle,
TrendingUp,
TrendingDown,
Printer,
Camera,
AlertTriangle,
Image as ImageIcon,
Info
} from 'lucide-svelte';
import jsPDF from 'jspdf';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
import { formatarDataHoraCompleta } from '$lib/utils/ponto';
import RelogioSincronizado from './RelogioSincronizado.svelte';
import WebcamCapture from './WebcamCapture.svelte';
import ComprovantePonto from './ComprovantePonto.svelte';
import ErrorModal from '../ErrorModal.svelte';
const client = useConvexClient();
@@ -35,30 +39,45 @@
let refreshKey = $state(0);
// Queries
const currentUser = useQuery(api.auth.getCurrentUser, {});
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>);
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
// Query para histórico e saldo do dia
let funcionarioId = $derived(currentUser?.data?.funcionarioId ?? null);
let dataHoje = $derived(new Date().toISOString().split('T')[0]!);
// Usar refreshKey para forçar atualização após registro
const registrosHojeQuery = useQuery(api.pontos.listarRegistrosDia, {
// Parâmetros reativos para queries de ponto
const registrosHojeParams = $derived({
data: dataHoje,
_refresh: refreshKey
});
const historicoSaldoQuery = useQuery(
api.pontos.obterHistoricoESaldoDia,
const historicoSaldoParams = $derived(
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje, _refresh: refreshKey } : 'skip'
);
// Query para verificar dispensa ativa
const dispensaQuery = useQuery(
api.pontos.verificarDispensaAtiva,
const dispensaParams = $derived(
funcionarioId && dataHoje ? { funcionarioId, data: dataHoje } : 'skip'
);
const registrosHojeQuery = $derived.by(() =>
useQuery(api.pontos.listarRegistrosDia, registrosHojeParams)
);
const historicoSaldoQuery = $derived.by(() =>
useQuery(api.pontos.obterHistoricoESaldoDia, historicoSaldoParams)
);
const dispensaQuery = $derived.by(() =>
useQuery(api.pontos.verificarDispensaAtiva, dispensaParams)
);
// Query para obter status atual do funcionário (férias/licença)
const funcionarioStatusQuery = useQuery(
api.funcionarios.getCurrent,
currentUser?.data ? {} : 'skip'
);
// Estados
let mostrandoWebcam = $state(false);
let registrando = $state(false);
@@ -83,6 +102,12 @@
let registrosHoje = $derived(registrosHojeQuery?.data || []);
let config = $derived(configQuery?.data);
// Status de férias/licença do funcionário
const funcionarioStatus = $derived(funcionarioStatusQuery?.data);
const statusFerias = $derived(funcionarioStatus?.statusFerias ?? 'ativo');
const emFerias = $derived(statusFerias === 'em_ferias');
const emLicenca = $derived(statusFerias === 'em_licenca');
let proximoTipo = $derived.by(() => {
if (registrosHoje.length === 0) {
return 'entrada';
@@ -210,6 +235,16 @@
return;
}
// Verificar se funcionário está em férias ou licença
if (emFerias || emLicenca) {
mensagemErroModal = 'Registro de ponto não permitido';
detalhesErroModal = emFerias
? 'Seu status atual é "Em Férias". Durante o período de férias não é permitido registrar ponto.'
: 'Seu status atual é "Em Licença". Durante o período de licença não é permitido registrar ponto.';
mostrarModalErro = true;
return;
}
// Verificar permissões antes de registrar
const permissoes = await verificarPermissoes();
if (!permissoes.localizacao || !permissoes.webcam) {
@@ -597,9 +632,7 @@
async function imprimirComprovante(registroId: Id<'registrosPonto'>) {
try {
// Buscar dados completos do registro
const registro = await client.query(api.pontos.obterRegistro, {
registroId
});
const registro = await client.query(api.pontos.obterRegistro, { registroId });
if (!registro) {
alert('Registro não encontrado');
@@ -831,6 +864,8 @@
!coletandoInfo &&
config !== undefined &&
!estaDispensado &&
!emFerias &&
!emLicenca &&
temFuncionarioAssociado
);
});
@@ -844,7 +879,7 @@
try {
// Solicitar apenas permissão, sem iniciar o stream ainda
await navigator.mediaDevices.getUserMedia({ video: true });
} catch (error) {
} catch {
// Ignorar erro silenciosamente - a permissão será solicitada quando necessário
console.log('Permissão de webcam não concedida ainda');
}
@@ -868,7 +903,7 @@
{ timeout: 2000, maximumAge: 0, enableHighAccuracy: false } // enableHighAccuracy false para ser mais rápido
);
});
} catch (error) {
} catch {
// Ignorar erro silenciosamente
console.log('Permissão de geolocalização não concedida ainda');
}
@@ -879,11 +914,7 @@
if (!config) return [];
const horarios = [
{
tipo: 'entrada',
horario: config.horarioEntrada,
label: config.nomeEntrada || 'Entrada 1'
},
{ tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' },
{
tipo: 'saida_almoco',
horario: config.horarioSaidaAlmoco,
@@ -894,11 +925,7 @@
horario: config.horarioRetornoAlmoco,
label: config.nomeRetornoAlmoco || 'Entrada 2'
},
{
tipo: 'saida',
horario: config.horarioSaida,
label: config.nomeSaida || 'Saída 2'
}
{ tipo: 'saida', horario: config.horarioSaida, label: config.nomeSaida || 'Saída 2' }
];
const resultado = horarios.map((h) => {
@@ -916,10 +943,7 @@
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}`
}))
registrosHoje: registrosHoje.map((r) => ({ tipo: r.tipo, hora: `${r.hora}:${r.minuto}` }))
});
}
@@ -1041,19 +1065,7 @@
<!-- 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>
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
<div>
<h3 class="font-bold">Funcionário Não Associado</h3>
<div class="text-sm">
@@ -1068,19 +1080,7 @@
<!-- 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>
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
<div>
<h3 class="font-bold">Registro de Ponto Dispensado</h3>
<div class="text-sm">
@@ -1093,9 +1093,28 @@
</div>
{/if}
<!-- Alerta de Status de Férias/Licença -->
{#if temFuncionarioAssociado && (emFerias || emLicenca)}
<div class="alert alert-info shadow-lg">
<Info class="h-6 w-6 shrink-0 stroke-current" />
<div>
<h3 class="font-bold">Status do Funcionário</h3>
<div class="text-sm">
{#if emFerias}
Seu status atual é <strong>Em Férias</strong>. Durante o período de férias não é
permitido registrar ponto.
{:else}
Seu status atual é <strong>Em Licença</strong>. Durante o período de licença não é
permitido registrar ponto.
{/if}
</div>
</div>
</div>
{/if}
<!-- Card de Registro de Ponto Modernizado -->
<div
class="card from-base-100 via-base-100 to-primary/5 border-base-300 mx-auto max-w-2xl border bg-gradient-to-br shadow-2xl"
class="card from-base-100 via-base-100 to-primary/5 border-base-300 mx-auto max-w-2xl border bg-linear-to-br shadow-2xl"
>
<div id="card-registro-ponto-ref" class="card-body p-6">
<!-- Cabeçalho -->
@@ -1110,7 +1129,7 @@
<div class="mb-5 flex justify-center">
<div
id="relogio-sincronizado-ref"
class="card from-primary/10 to-primary/5 border-primary/20 w-full max-w-sm rounded-2xl border-2 bg-gradient-to-br p-5 shadow-lg"
class="card from-primary/10 to-primary/5 border-primary/20 w-full max-w-sm rounded-2xl border-2 bg-linear-to-br p-5 shadow-lg"
>
<RelogioSincronizado />
</div>
@@ -1125,7 +1144,11 @@
? 'Você não possui funcionário associado à sua conta'
: estaDispensado
? 'Você está dispensado de registrar ponto no momento'
: ''}
: emFerias
? 'Você está em férias. Durante o período de férias não é permitido registrar ponto.'
: emLicenca
? 'Você está em licença. Durante o período de licença não é permitido registrar ponto.'
: ''}
>
{#if registrando}
<span class="loading loading-spinner loading-sm"></span>
@@ -1140,6 +1163,12 @@
{:else if estaDispensado}
<XCircle class="h-5 w-5" />
Registro Indisponível
{:else if emFerias}
<XCircle class="h-5 w-5" />
Em Férias
{:else if emLicenca}
<XCircle class="h-5 w-5" />
Em Licença
{:else if proximoTipo === 'entrada' || proximoTipo === 'retorno_almoco'}
<LogIn class="h-5 w-5" />
Registrar Entrada
@@ -1176,7 +1205,7 @@
<!-- Próximo Registro -->
<div
class="card from-info/10 to-info/5 border-info/20 rounded-xl border-2 bg-gradient-to-br p-4 shadow-md"
class="card from-info/10 to-info/5 border-info/20 rounded-xl border-2 bg-linear-to-br p-4 shadow-md"
>
<div class="flex items-center justify-center gap-2">
<div class="bg-info/20 rounded-lg p-1.5">
@@ -1212,9 +1241,9 @@
<div
class="relative h-full rounded-xl border-2 transition-all duration-300 hover:shadow-lg {horario.registrado
? horario.dentroDoPrazo
? 'from-success/20 to-success/10 border-success bg-gradient-to-br shadow-md'
: 'from-error/20 to-error/10 border-error bg-gradient-to-br shadow-md'
: 'from-base-200 to-base-300 border-base-300 bg-gradient-to-br'} p-5"
? 'from-success/20 to-success/10 border-success bg-linear-to-br shadow-md'
: 'from-error/20 to-error/10 border-error bg-linear-to-br shadow-md'
: 'from-base-200 to-base-300 border-base-300 bg-linear-to-br'} p-5"
>
<!-- Status Icon -->
<div class="absolute top-3 right-3">
@@ -1314,7 +1343,7 @@
<div class="relative">
<!-- Linha vertical central da timeline -->
<div
class="from-primary/20 via-base-300 to-secondary/20 absolute top-0 bottom-0 left-1/2 w-1 -translate-x-1/2 transform bg-gradient-to-b"
class="from-primary/20 via-base-300 to-secondary/20 absolute top-0 bottom-0 left-1/2 w-1 -translate-x-1/2 transform bg-linear-to-b"
></div>
<!-- Container com duas colunas -->
@@ -1348,9 +1377,9 @@
<!-- Tipo de registro e status -->
<div class="mb-2 flex items-center gap-2">
{#if registro.dentroDoPrazo}
<CheckCircle2 class="text-success h-4 w-4 flex-shrink-0" />
<CheckCircle2 class="text-success h-4 w-4 shrink-0" />
{:else}
<XCircle class="text-error h-4 w-4 flex-shrink-0" />
<XCircle class="text-error h-4 w-4 shrink-0" />
{/if}
<span class="text-base-content/80 text-sm font-semibold">
{config
@@ -1422,7 +1451,7 @@
<!-- 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}
{#each [{ tipo: 'entrada', horario: config.horarioEntrada, label: config.nomeEntrada || 'Entrada 1' }, { tipo: 'retorno_almoco', horario: config.horarioRetornoAlmoco, label: config.nomeRetornoAlmoco || 'Entrada 2' }] as horarioEsperado (horarioEsperado.tipo)}
{#if !registrosOrdenados.find((r) => r.tipo === horarioEsperado.tipo)}
<div class="relative opacity-50">
<div
@@ -1482,9 +1511,9 @@
: getTipoRegistroLabel(registro.tipo)}
</span>
{#if registro.dentroDoPrazo}
<CheckCircle2 class="text-success h-4 w-4 flex-shrink-0" />
<CheckCircle2 class="text-success h-4 w-4 shrink-0" />
{:else}
<XCircle class="text-error h-4 w-4 flex-shrink-0" />
<XCircle class="text-error h-4 w-4 shrink-0" />
{/if}
</div>
@@ -1548,7 +1577,7 @@
<!-- 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}
{#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 (horarioEsperado.tipo)}
{#if !registrosOrdenados.find((r) => r.tipo === horarioEsperado.tipo)}
<div class="relative opacity-50">
<div
@@ -1589,19 +1618,19 @@
<!-- Backdrop leve -->
<div
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
role="button"
tabindex="0"
onclick={handleWebcamCancel}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && handleWebcamCancel()}
></div>
<!-- Modal Box -->
<div
class="bg-base-100 pointer-events-auto absolute z-10 flex max-h-[90vh] w-full max-w-2xl transform flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
onclick={(e) => e.stopPropagation()}
>
<!-- Header fixo -->
<div
class="border-base-300 flex flex-shrink-0 items-center justify-between border-b px-6 py-4"
>
<div class="border-base-300 flex shrink-0 items-center justify-between border-b px-6 py-4">
<div class="flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<Camera class="text-primary h-5 w-5" strokeWidth={2} />
@@ -1737,19 +1766,19 @@
<!-- Backdrop leve -->
<div
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
role="button"
tabindex="0"
onclick={cancelarRegistro}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && cancelarRegistro()}
></div>
<!-- Modal Box -->
<div
class="bg-base-100 pointer-events-auto absolute z-10 flex max-h-[90vh] w-full max-w-3xl transform flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
onclick={(e) => e.stopPropagation()}
>
<!-- Header fixo -->
<div
class="border-base-300 flex flex-shrink-0 items-center justify-between border-b px-6 py-4"
>
<div class="border-base-300 flex shrink-0 items-center justify-between border-b px-6 py-4">
<div class="flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<Clock class="text-primary h-6 w-6" strokeWidth={2} />
@@ -1776,25 +1805,12 @@
<div class="modal-scroll flex-1 space-y-6 overflow-y-auto px-6 py-4">
<!-- Card da Imagem -->
<div
class="card from-base-200 to-base-300 border-primary/20 border-2 bg-gradient-to-br shadow-lg"
class="card from-base-200 to-base-300 border-primary/20 border-2 bg-linear-to-br shadow-lg"
>
<div class="card-body p-6">
<div class="mb-4 flex items-center gap-2">
<div class="bg-primary/10 rounded-lg p-1.5">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
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>
<ImageIcon class="text-primary h-5 w-5" strokeWidth={2} />
</div>
<h4 class="text-lg font-semibold">Foto Capturada</h4>
</div>
@@ -1812,7 +1828,7 @@
<!-- Card de Informações -->
<div
class="card from-primary/5 to-primary/10 border-primary/20 border-2 bg-gradient-to-br shadow-lg"
class="card from-primary/5 to-primary/10 border-primary/20 border-2 bg-linear-to-br shadow-lg"
>
<div class="card-body p-6">
<div class="mb-4 flex items-center gap-2">
@@ -1863,19 +1879,7 @@
<!-- 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>
<Info class="h-6 w-6 shrink-0 stroke-current" />
<div>
<h3 class="font-bold">Confirme os dados</h3>
<div class="text-sm">
@@ -1886,7 +1890,7 @@
</div>
<!-- Footer fixo com botões -->
<div class="border-base-300 flex flex-shrink-0 justify-end gap-3 border-t px-6 py-4">
<div class="border-base-300 flex shrink-0 justify-end gap-3 border-t px-6 py-4">
<button
class="btn btn-outline"
onclick={cancelarRegistro}