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

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,8 @@
function handleGenerate() {
onGenerate(sections);
onClose();
// Não chamar onClose() aqui - o modal será fechado pelo callback onSuccess
// após a geração do PDF ser concluída com sucesso
}
function handleClose() {

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}

View File

@@ -54,18 +54,17 @@
erro = 'Usando relógio do PC';
}
// 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
// Aplicar GMT offset ao timestamp UTC
// O offset é aplicado manualmente, então usamos UTC como base para evitar conversão dupla
let timestampAjustado: number;
if (gmtOffset !== 0) {
// Aplicar offset configurado
// Aplicar offset configurado ao timestamp UTC
timestampAjustado = timestampBase + gmtOffset * 60 * 60 * 1000;
} else {
// Quando GMT = 0, manter timestamp UTC puro
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
timestampAjustado = timestampBase;
}
// Armazenar o timestamp ajustado (não o Date, para evitar problemas de timezone)
tempoAtual = new Date(timestampAjustado);
} catch (error) {
console.error('Erro ao obter tempo:', error);
@@ -95,20 +94,26 @@
}
});
let horaFormatada = $derived.by(() => {
const horaFormatada = $derived.by(() => {
// Usar UTC como base pois já aplicamos o offset manualmente no timestamp
// Isso evita conversão dupla pelo navegador
return tempoAtual.toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
second: '2-digit',
timeZone: 'UTC' // Usar UTC como base pois já aplicamos o offset manualmente
});
});
let dataFormatada = $derived.by(() => {
const dataFormatada = $derived.by(() => {
// Usar UTC como base pois já aplicamos o offset manualmente no timestamp
// Isso evita conversão dupla pelo navegador
return tempoAtual.toLocaleDateString('pt-BR', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric'
year: 'numeric',
timeZone: 'UTC' // Usar UTC como base pois já aplicamos o offset manualmente
});
});
</script>

View File

@@ -0,0 +1,163 @@
<script lang="ts">
import { ChevronUp, ChevronDown } from 'lucide-svelte';
interface Props {
hours: number;
minutes: number;
onChange: (hours: number, minutes: number) => void;
label?: string;
disabled?: boolean;
}
let { hours, minutes, onChange, label, disabled = false }: Props = $props();
function incrementHours() {
if (disabled) return;
const newHours = hours + 1;
onChange(newHours, minutes);
}
function decrementHours() {
if (disabled) return;
const newHours = Math.max(0, hours - 1);
onChange(newHours, minutes);
}
function incrementMinutes() {
if (disabled) return;
const newMinutes = minutes + 15;
if (newMinutes >= 60) {
const extraHours = Math.floor(newMinutes / 60);
const remainingMinutes = newMinutes % 60;
onChange(hours + extraHours, remainingMinutes);
} else {
onChange(hours, newMinutes);
}
}
function decrementMinutes() {
if (disabled) return;
const newMinutes = minutes - 15;
if (newMinutes < 0) {
if (hours > 0) {
onChange(hours - 1, 60 + newMinutes);
} else {
onChange(0, 0);
}
} else {
onChange(hours, newMinutes);
}
}
function handleHoursInput(e: Event) {
if (disabled) return;
const target = e.target as HTMLInputElement;
const value = parseInt(target.value) || 0;
onChange(Math.max(0, value), minutes);
}
function handleMinutesInput(e: Event) {
if (disabled) return;
const target = e.target as HTMLInputElement;
const value = parseInt(target.value) || 0;
const clampedValue = Math.max(0, Math.min(59, value));
onChange(hours, clampedValue);
}
const totalMinutes = $derived(hours * 60 + minutes);
const displayText = $derived.by(() => {
if (totalMinutes === 0) return '0h 0min';
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
return `${h}h ${m}min`;
});
</script>
<div class="time-picker">
{#if label}
<div class="mb-2 block text-sm font-medium text-gray-700">{label}</div>
{/if}
<div class="flex items-center gap-3">
<!-- Horas -->
<div class="flex flex-col items-center">
<button
type="button"
onclick={incrementHours}
disabled={disabled}
class="flex h-10 w-12 items-center justify-center rounded-t-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronUp class="h-4 w-4 text-gray-600" />
</button>
<input
type="number"
min="0"
value={hours}
oninput={handleHoursInput}
disabled={disabled}
class="h-14 w-12 border-x border-gray-300 bg-white text-center text-xl font-bold text-gray-900 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
<button
type="button"
onclick={decrementHours}
disabled={disabled || hours === 0}
class="flex h-10 w-12 items-center justify-center rounded-b-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronDown class="h-4 w-4 text-gray-600" />
</button>
<span class="mt-1 text-xs text-gray-500">horas</span>
</div>
<!-- Separador -->
<div class="text-2xl font-bold text-gray-400">:</div>
<!-- Minutos -->
<div class="flex flex-col items-center">
<button
type="button"
onclick={incrementMinutes}
disabled={disabled}
class="flex h-10 w-12 items-center justify-center rounded-t-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronUp class="h-4 w-4 text-gray-600" />
</button>
<input
type="number"
min="0"
max="59"
value={minutes}
oninput={handleMinutesInput}
disabled={disabled}
class="h-14 w-12 border-x border-gray-300 bg-white text-center text-xl font-bold text-gray-900 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
<button
type="button"
onclick={decrementMinutes}
disabled={disabled || (hours === 0 && minutes === 0)}
class="flex h-10 w-12 items-center justify-center rounded-b-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronDown class="h-4 w-4 text-gray-600" />
</button>
<span class="mt-1 text-xs text-gray-500">min</span>
</div>
<!-- Total -->
<div class="ml-4 flex flex-col items-center justify-center rounded-lg bg-primary/10 px-4 py-2">
<span class="text-xs text-gray-600">Total</span>
<span class="text-lg font-bold text-primary">{displayText}</span>
</div>
</div>
</div>
<style>
.time-picker input[type='number']::-webkit-inner-spin-button,
.time-picker input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.time-picker input[type='number'] {
-moz-appearance: textfield;
}
</style>

View File

@@ -11,7 +11,7 @@
fotoObrigatoria?: boolean; // Se true, não permite continuar sem foto
}
const {
let {
onCapture,
onCancel,
onError,

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { BarChart3, CheckCircle2, XCircle, Users } from 'lucide-svelte';
interface Estatisticas {
totalRegistros: number;
dentroDoPrazo: number;
foraDoPrazo: number;
totalFuncionarios: number;
funcionariosDentroPrazo: number;
funcionariosForaPrazo: number;
}
interface Props {
estatisticas?: Estatisticas;
}
let { estatisticas = undefined }: Props = $props();
</script>
{#if estatisticas}
<div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<!-- Total de Registros -->
<div
class="card transform border border-blue-500/20 bg-gradient-to-br from-blue-500/10 to-blue-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Total de Registros</p>
<p class="text-base-content text-3xl font-bold">{estatisticas.totalRegistros}</p>
</div>
<div class="rounded-xl bg-blue-500/20 p-3">
<BarChart3 class="h-8 w-8 text-blue-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Dentro do Prazo -->
<div
class="card transform border border-green-500/20 bg-gradient-to-br from-green-500/10 to-green-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Dentro do Prazo</p>
<p class="text-3xl font-bold text-green-600">{estatisticas.dentroDoPrazo}</p>
<p class="text-base-content/60 mt-1 text-xs">
{estatisticas.totalRegistros > 0
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}% do total
</p>
</div>
<div class="rounded-xl bg-green-500/20 p-3">
<CheckCircle2 class="h-8 w-8 text-green-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Fora do Prazo -->
<div
class="card transform border border-red-500/20 bg-gradient-to-br from-red-500/10 to-red-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Fora do Prazo</p>
<p class="text-3xl font-bold text-red-600">{estatisticas.foraDoPrazo}</p>
<p class="text-base-content/60 mt-1 text-xs">
{estatisticas.totalRegistros > 0
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}% do total
</p>
</div>
<div class="rounded-xl bg-red-500/20 p-3">
<XCircle class="h-8 w-8 text-red-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Funcionários -->
<div
class="card transform border border-purple-500/20 bg-gradient-to-br from-purple-500/10 to-purple-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Funcionários</p>
<p class="text-3xl font-bold text-purple-600">{estatisticas.totalFuncionarios}</p>
<p class="text-base-content/60 mt-1 text-xs">
{estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora
</p>
</div>
<div class="rounded-xl bg-purple-500/20 p-3">
<Users class="h-8 w-8 text-purple-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,218 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
import { BarChart3, XCircle, FileText } from 'lucide-svelte';
interface Estatisticas {
totalRegistros: number;
dentroDoPrazo: number;
foraDoPrazo: number;
}
interface Props {
estatisticas?: Estatisticas;
chartData?: {
labels: string[];
datasets: Array<{
label: string;
data: number[];
backgroundColor: string;
borderColor: string;
borderWidth: number;
}>;
} | null;
isLoading?: boolean;
error?: Error | null;
}
let { estatisticas = undefined, chartData = null, isLoading = false, error = null }: Props = $props();
let chartCanvas: HTMLCanvasElement;
let chartInstance: Chart | null = null;
onMount(() => {
Chart.register(...registerables);
const timeoutId = setTimeout(() => {
if (chartCanvas && estatisticas && chartData && !chartInstance) {
try {
criarGrafico();
} catch (err) {
console.error('Erro ao criar gráfico no onMount:', err);
}
}
}, 500);
return () => {
clearTimeout(timeoutId);
};
});
onDestroy(() => {
if (chartInstance) {
chartInstance.destroy();
}
});
function criarGrafico() {
if (!chartCanvas || !estatisticas || !chartData) {
return;
}
const ctx = chartCanvas.getContext('2d');
if (!ctx) {
return;
}
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
try {
chartInstance = new Chart(ctx, {
type: 'bar',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: 'hsl(var(--bc))',
font: {
size: 12,
family: "'Inter', sans-serif"
},
usePointStyle: true,
padding: 15
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.85)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: 'hsl(var(--p))',
borderWidth: 1,
padding: 12,
callbacks: {
label: function (context) {
const label = context.dataset.label || '';
const value = context.parsed.y;
const total = estatisticas!.totalRegistros;
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
return `${label}: ${value} (${percentage}%)`;
}
}
}
},
scales: {
x: {
stacked: true,
grid: {
display: false
},
ticks: {
color: 'hsl(var(--bc))',
font: {
size: 12
}
}
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
},
ticks: {
color: 'hsl(var(--bc))',
font: {
size: 11
},
stepSize: 1
}
}
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
},
interaction: {
mode: 'index',
intersect: false
}
}
});
} catch (error) {
console.error('Erro ao criar gráfico:', error);
}
}
$effect(() => {
if (chartCanvas && estatisticas && chartData) {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
const timeoutId = setTimeout(() => {
try {
criarGrafico();
} catch (err) {
console.error('Erro ao criar gráfico no effect:', err);
}
}, 200);
return () => {
clearTimeout(timeoutId);
};
}
});
</script>
<div class="card bg-base-100/90 border-base-300 mb-8 border shadow-xl backdrop-blur-sm">
<div class="card-body">
<div class="mb-6 flex items-center justify-between">
<h2 class="card-title text-2xl">
<div class="bg-primary/10 rounded-lg p-2">
<BarChart3 class="text-primary h-6 w-6" strokeWidth={2.5} />
</div>
<span>Visão Geral das Estatísticas</span>
</h2>
</div>
<div class="bg-base-200/50 border-base-300 relative h-80 w-full rounded-xl border p-4">
{#if isLoading}
<div class="bg-base-200/30 absolute inset-0 flex items-center justify-center rounded-xl">
<div class="flex flex-col items-center gap-4">
<span class="loading loading-spinner loading-lg text-primary"></span>
<span class="text-base-content/70 font-medium">Carregando estatísticas...</span>
</div>
</div>
{:else if error}
<div class="bg-base-200/30 absolute inset-0 flex items-center justify-center rounded-xl">
<div class="alert alert-error shadow-lg">
<XCircle class="h-6 w-6" />
<div>
<h3 class="font-bold">Erro ao carregar estatísticas</h3>
<div class="mt-1 text-sm">
{error?.message || String(error) || 'Erro desconhecido'}
</div>
</div>
</div>
</div>
{:else if !estatisticas || !chartData}
<div class="bg-base-200/30 absolute inset-0 flex items-center justify-center rounded-xl">
<div class="text-center">
<FileText class="text-base-content/30 mx-auto mb-2 h-12 w-12" />
<p class="text-base-content/70">Nenhuma estatística disponível</p>
</div>
</div>
{:else}
<canvas bind:this={chartCanvas} class="h-full w-full"></canvas>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { Clock } from 'lucide-svelte';
interface Estatisticas {
totalRegistros: number;
totalFuncionarios: number;
dentroDoPrazo: number;
}
interface Props {
estatisticas?: Estatisticas;
}
let { estatisticas = undefined }: Props = $props();
</script>
<section
class="border-base-300 from-primary/10 via-base-100 to-secondary/10 relative mb-8 overflow-hidden rounded-2xl border bg-gradient-to-br p-8 shadow-lg"
>
<div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div class="flex items-center gap-4">
<div
class="bg-primary/20 border-primary/30 rounded-2xl border p-4 shadow-lg backdrop-blur-sm"
>
<Clock class="text-primary h-10 w-10" strokeWidth={2.5} />
</div>
<div class="max-w-3xl space-y-2">
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
Registro de Pontos
</h1>
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
Gerencie e visualize os registros de ponto dos funcionários com informações detalhadas e
relatórios
</p>
</div>
</div>
{#if estatisticas}
<div
class="border-base-200/60 bg-base-100/70 grid grid-cols-2 gap-4 rounded-2xl border p-6 shadow-lg backdrop-blur sm:max-w-sm"
>
<div>
<p class="text-base-content/60 text-sm font-semibold">Total de Registros</p>
<p class="text-base-content mt-2 text-2xl font-bold">{estatisticas.totalRegistros}</p>
</div>
<div class="text-right">
<p class="text-base-content/60 text-sm font-semibold">Funcionários</p>
<p class="text-base-content mt-2 text-xl font-bold">{estatisticas.totalFuncionarios}</p>
</div>
<div
class="via-base-300 col-span-2 h-px bg-gradient-to-r from-transparent to-transparent"
></div>
<div class="text-base-content/70 col-span-2 flex items-center justify-between text-sm">
<span>
{estatisticas.totalRegistros > 0
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}% dentro do prazo
</span>
<span class="badge badge-primary badge-sm">Ativo</span>
</div>
</div>
{/if}
</div>
</section>