Merge remote-tracking branch 'origin' into feat-pedidos
This commit is contained in:
1253
apps/web/src/lib/components/ponto/BancoHorasMensal.svelte
Normal file
1253
apps/web/src/lib/components/ponto/BancoHorasMensal.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
163
apps/web/src/lib/components/ponto/TimePicker.svelte
Normal file
163
apps/web/src/lib/components/ponto/TimePicker.svelte
Normal 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>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
fotoObrigatoria?: boolean; // Se true, não permite continuar sem foto
|
||||
}
|
||||
|
||||
const {
|
||||
let {
|
||||
onCapture,
|
||||
onCancel,
|
||||
onError,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user