- Added functionality to capture and display images during point registration, improving user experience. - Implemented error handling for image uploads and webcam access, ensuring smoother operation. - Introduced a justification field for point registration, allowing users to provide context for their entries. - Enhanced the backend to support new features, including image handling and justification storage. - Updated UI components for better layout and responsiveness, improving overall usability.
405 lines
12 KiB
Svelte
405 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
|
import { Clock, Filter, Download, Printer, BarChart3, Users, CheckCircle2, XCircle, TrendingUp, TrendingDown } from 'lucide-svelte';
|
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
|
import { formatarHoraPonto, getTipoRegistroLabel } from '$lib/utils/ponto';
|
|
import jsPDF from 'jspdf';
|
|
import autoTable from 'jspdf-autotable';
|
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
|
|
|
const client = useConvexClient();
|
|
|
|
// Estados
|
|
let dataInicio = $state(new Date().toISOString().split('T')[0]!);
|
|
let dataFim = $state(new Date().toISOString().split('T')[0]!);
|
|
let funcionarioIdFiltro = $state<Id<'funcionarios'> | ''>('');
|
|
let carregando = $state(false);
|
|
|
|
// Parâmetros reativos para queries
|
|
const registrosParams = $derived({
|
|
funcionarioId: funcionarioIdFiltro || undefined,
|
|
dataInicio,
|
|
dataFim,
|
|
});
|
|
const estatisticasParams = $derived({
|
|
dataInicio,
|
|
dataFim,
|
|
});
|
|
|
|
// Queries
|
|
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
|
const registrosQuery = useQuery(api.pontos.listarRegistrosPeriodo, registrosParams);
|
|
const estatisticasQuery = useQuery(api.pontos.obterEstatisticas, estatisticasParams);
|
|
|
|
const funcionarios = $derived(funcionariosQuery?.data || []);
|
|
const registros = $derived(registrosQuery?.data || []);
|
|
const estatisticas = $derived(estatisticasQuery?.data);
|
|
|
|
// Agrupar registros por funcionário e data
|
|
const registrosAgrupados = $derived.by(() => {
|
|
const agrupados: Record<
|
|
string,
|
|
{
|
|
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
|
|
funcionarioId: Id<'funcionarios'>;
|
|
registros: typeof registros;
|
|
}
|
|
> = {};
|
|
|
|
for (const registro of registros) {
|
|
const key = registro.funcionarioId;
|
|
if (!agrupados[key]) {
|
|
agrupados[key] = {
|
|
funcionario: registro.funcionario,
|
|
funcionarioId: registro.funcionarioId,
|
|
registros: [],
|
|
};
|
|
}
|
|
agrupados[key]!.registros.push(registro);
|
|
}
|
|
|
|
return Object.values(agrupados);
|
|
});
|
|
|
|
// Query para banco de horas de cada funcionário
|
|
const funcionariosComBancoHoras = $derived.by(() => {
|
|
return registrosAgrupados.map((grupo) => grupo.funcionarioId);
|
|
});
|
|
|
|
// Função para formatar saldo de horas
|
|
function formatarSaldoHoras(minutos: number): string {
|
|
const horas = Math.floor(Math.abs(minutos) / 60);
|
|
const mins = Math.abs(minutos) % 60;
|
|
const sinal = minutos >= 0 ? '+' : '-';
|
|
return `${sinal}${horas}h ${mins}min`;
|
|
}
|
|
|
|
async function imprimirFichaPonto(funcionarioId: Id<'funcionarios'>) {
|
|
const registrosFuncionario = registros.filter((r) => r.funcionarioId === funcionarioId);
|
|
if (registrosFuncionario.length === 0) {
|
|
alert('Nenhum registro encontrado para este funcionário no período selecionado');
|
|
return;
|
|
}
|
|
|
|
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
|
|
if (!funcionario) {
|
|
alert('Funcionário não encontrado');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const doc = new jsPDF();
|
|
|
|
// Logo
|
|
let yPosition = 20;
|
|
try {
|
|
const logoImg = new Image();
|
|
logoImg.src = logoGovPE;
|
|
await new Promise<void>((resolve, reject) => {
|
|
logoImg.onload = () => resolve();
|
|
logoImg.onerror = () => reject();
|
|
setTimeout(() => reject(), 3000);
|
|
});
|
|
|
|
const logoWidth = 25;
|
|
const aspectRatio = logoImg.height / logoImg.width;
|
|
const logoHeight = logoWidth * aspectRatio;
|
|
|
|
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
|
yPosition = Math.max(20, 10 + logoHeight / 2);
|
|
} catch (err) {
|
|
console.warn('Não foi possível carregar a logo:', err);
|
|
}
|
|
|
|
// Cabeçalho
|
|
doc.setFontSize(16);
|
|
doc.setTextColor(41, 128, 185);
|
|
doc.text('FICHA DE PONTO', 105, yPosition, { align: 'center' });
|
|
|
|
yPosition += 10;
|
|
|
|
// Dados do Funcionário
|
|
doc.setFontSize(12);
|
|
doc.setTextColor(0, 0, 0);
|
|
doc.setFont('helvetica', 'bold');
|
|
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
|
|
doc.setFont('helvetica', 'normal');
|
|
|
|
yPosition += 8;
|
|
doc.setFontSize(10);
|
|
|
|
if (funcionario.matricula) {
|
|
doc.text(`Matrícula: ${funcionario.matricula}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
doc.text(`Nome: ${funcionario.nome}`, 15, yPosition);
|
|
yPosition += 6;
|
|
if (funcionario.descricaoCargo) {
|
|
doc.text(`Cargo/Função: ${funcionario.descricaoCargo}`, 15, yPosition);
|
|
yPosition += 6;
|
|
}
|
|
|
|
yPosition += 5;
|
|
doc.text(`Período: ${dataInicio} a ${dataFim}`, 15, yPosition);
|
|
yPosition += 10;
|
|
|
|
// Tabela de registros
|
|
const tableData = registrosFuncionario.map((r) => [
|
|
r.data,
|
|
getTipoRegistroLabel(r.tipo),
|
|
formatarHoraPonto(r.hora, r.minuto),
|
|
r.dentroDoPrazo ? 'Sim' : 'Não',
|
|
]);
|
|
|
|
autoTable(doc, {
|
|
startY: yPosition,
|
|
head: [['Data', 'Tipo', 'Horário', 'Dentro do Prazo']],
|
|
body: tableData,
|
|
theme: 'grid',
|
|
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
|
styles: { fontSize: 9 },
|
|
});
|
|
|
|
// Rodapé
|
|
const pageCount = doc.getNumberOfPages();
|
|
for (let i = 1; i <= pageCount; i++) {
|
|
doc.setPage(i);
|
|
doc.setFontSize(8);
|
|
doc.setTextColor(128, 128, 128);
|
|
doc.text(
|
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
|
doc.internal.pageSize.getWidth() / 2,
|
|
doc.internal.pageSize.getHeight() - 10,
|
|
{ align: 'center' }
|
|
);
|
|
}
|
|
|
|
// Salvar
|
|
const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`;
|
|
doc.save(nomeArquivo);
|
|
} catch (error) {
|
|
console.error('Erro ao gerar PDF:', error);
|
|
alert('Erro ao gerar ficha de ponto. Tente novamente.');
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="container mx-auto px-4 py-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-6">
|
|
<div class="flex items-center gap-4">
|
|
<div class="p-3 bg-primary/10 rounded-xl">
|
|
<Clock class="h-8 w-8 text-primary" strokeWidth={2} />
|
|
</div>
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-base-content">Registro de Pontos</h1>
|
|
<p class="text-base-content/60 mt-1">Gerencie e visualize os registros de ponto dos funcionários</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filtros -->
|
|
<div class="card bg-base-100 shadow-xl mb-6">
|
|
<div class="card-body">
|
|
<h2 class="card-title mb-4">
|
|
<Filter class="h-5 w-5" />
|
|
Filtros
|
|
</h2>
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div class="form-control">
|
|
<label class="label" for="data-inicio">
|
|
<span class="label-text font-medium">Data Início</span>
|
|
</label>
|
|
<input
|
|
id="data-inicio"
|
|
type="date"
|
|
bind:value={dataInicio}
|
|
class="input input-bordered"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label" for="data-fim">
|
|
<span class="label-text font-medium">Data Fim</span>
|
|
</label>
|
|
<input
|
|
id="data-fim"
|
|
type="date"
|
|
bind:value={dataFim}
|
|
class="input input-bordered"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-control">
|
|
<label class="label" for="funcionario">
|
|
<span class="label-text font-medium">Funcionário</span>
|
|
</label>
|
|
<select
|
|
id="funcionario"
|
|
bind:value={funcionarioIdFiltro}
|
|
class="select select-bordered"
|
|
>
|
|
<option value="">Todos</option>
|
|
{#each funcionarios as funcionario}
|
|
<option value={funcionario._id}>{funcionario.nome}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Estatísticas -->
|
|
{#if estatisticas}
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
|
<div class="stat-figure text-primary">
|
|
<BarChart3 class="h-8 w-8" />
|
|
</div>
|
|
<div class="stat-title">Total de Registros</div>
|
|
<div class="stat-value text-primary">{estatisticas.totalRegistros}</div>
|
|
</div>
|
|
|
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
|
<div class="stat-figure text-success">
|
|
<CheckCircle2 class="h-8 w-8" />
|
|
</div>
|
|
<div class="stat-title">Dentro do Prazo</div>
|
|
<div class="stat-value text-success">{estatisticas.dentroDoPrazo}</div>
|
|
<div class="stat-desc">
|
|
{estatisticas.totalRegistros > 0
|
|
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
|
: 0}%
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
|
<div class="stat-figure text-error">
|
|
<XCircle class="h-8 w-8" />
|
|
</div>
|
|
<div class="stat-title">Fora do Prazo</div>
|
|
<div class="stat-value text-error">{estatisticas.foraDoPrazo}</div>
|
|
<div class="stat-desc">
|
|
{estatisticas.totalRegistros > 0
|
|
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
|
: 0}%
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stat bg-base-100 shadow-lg rounded-lg">
|
|
<div class="stat-figure text-info">
|
|
<Users class="h-8 w-8" />
|
|
</div>
|
|
<div class="stat-title">Funcionários</div>
|
|
<div class="stat-value text-info">{estatisticas.totalFuncionarios}</div>
|
|
<div class="stat-desc">
|
|
{estatisticas.funcionariosDentroPrazo} dentro do prazo, {estatisticas.funcionariosForaPrazo} fora
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Lista de Registros -->
|
|
<div class="card bg-base-100 shadow-xl">
|
|
<div class="card-body">
|
|
<h2 class="card-title mb-4">Registros</h2>
|
|
|
|
{#if registrosAgrupados.length === 0}
|
|
<div class="alert alert-info">
|
|
<span>Nenhum registro encontrado para o período selecionado</span>
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-4">
|
|
{#each registrosAgrupados as grupo}
|
|
<div class="card bg-base-200">
|
|
<div class="card-body">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex-1">
|
|
<h3 class="font-bold text-lg">
|
|
{grupo.funcionario?.nome || 'Funcionário não encontrado'}
|
|
</h3>
|
|
{#if grupo.funcionario?.matricula}
|
|
<p class="text-sm text-base-content/70">
|
|
Matrícula: {grupo.funcionario.matricula}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Banco de Horas -->
|
|
{#key grupo.funcionarioId}
|
|
{@const bancoHorasQuery = useQuery(
|
|
api.pontos.obterBancoHorasFuncionario,
|
|
{ funcionarioId: grupo.funcionarioId }
|
|
)}
|
|
{@const bancoHoras = bancoHorasQuery?.data}
|
|
{@const saldoAcumulado = bancoHoras?.saldoAcumuladoMinutos ?? 0}
|
|
{@const saldoPositivo = saldoAcumulado >= 0}
|
|
|
|
{#if bancoHoras}
|
|
<div class="mx-4 rounded-lg border-2 p-3 {saldoPositivo ? 'border-success bg-success/10' : 'border-error bg-error/10'}">
|
|
<div class="flex items-center gap-2">
|
|
{#if saldoPositivo}
|
|
<TrendingUp class="h-5 w-5 text-success" />
|
|
{:else}
|
|
<TrendingDown class="h-5 w-5 text-error" />
|
|
{/if}
|
|
<div>
|
|
<p class="text-xs font-semibold opacity-70">Banco de Horas</p>
|
|
<p class="text-lg font-bold">
|
|
{formatarSaldoHoras(saldoAcumulado)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/key}
|
|
|
|
<button
|
|
class="btn btn-sm btn-primary"
|
|
onclick={() => imprimirFichaPonto(grupo.funcionarioId)}
|
|
>
|
|
<Printer class="h-4 w-4" />
|
|
Imprimir Ficha
|
|
</button>
|
|
</div>
|
|
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra">
|
|
<thead>
|
|
<tr>
|
|
<th>Data</th>
|
|
<th>Tipo</th>
|
|
<th>Horário</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each grupo.registros as registro}
|
|
<tr>
|
|
<td>{registro.data}</td>
|
|
<td>{getTipoRegistroLabel(registro.tipo)}</td>
|
|
<td>{formatarHoraPonto(registro.hora, registro.minuto)}</td>
|
|
<td>
|
|
<span
|
|
class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}"
|
|
>
|
|
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|