feat: implement date parsing utility across absence management components for improved date handling and consistency

This commit is contained in:
2025-12-05 11:57:15 -03:00
parent 4a1f48300f
commit 66f995cb08
16 changed files with 2053 additions and 87 deletions

View File

@@ -0,0 +1,391 @@
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import {
Clock,
TrendingUp,
TrendingDown,
Users,
AlertTriangle,
Download,
FileText,
Calendar,
Search,
Filter
} from 'lucide-svelte';
import LineChart from '$lib/components/ti/charts/LineChart.svelte';
import jsPDF from 'jspdf';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
// Estados
let mesSelecionado = $state(
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
);
let funcionarioFiltro = $state<string>('');
let apenasNegativos = $state(false);
// Queries
const funcionariosQuery = useQuery(api.funcionarios.listar, {});
const funcionarios = $derived(funcionariosQuery?.data || []);
// Query para estatísticas gerais do mês
const estatisticasQuery = useQuery(api.pontos.obterEstatisticasBancoHorasGerencial, {
mes: mesSelecionado,
funcionarioId: funcionarioFiltro ? (funcionarioFiltro as Id<'funcionarios'>) : undefined
});
const estatisticas = $derived(estatisticasQuery?.data);
// Função para formatar mês
function formatarMes(mes: string): string {
const [ano, mesNum] = mes.split('-');
const data = new Date(parseInt(ano), parseInt(mesNum) - 1, 1);
return data.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
}
// Função para mudar mês
function mudarMes(direcao: 'anterior' | 'proximo') {
const data = new Date(mesSelecionado + '-01');
if (direcao === 'anterior') {
data.setMonth(data.getMonth() - 1);
} else {
data.setMonth(data.getMonth() + 1);
}
mesSelecionado = `${data.getFullYear()}-${String(data.getMonth() + 1).padStart(2, '0')}`;
}
// Função para exportar relatório gerencial
async function exportarRelatorioGerencial() {
if (!estatisticas) return;
const doc = new jsPDF('landscape', 'mm', 'a4');
let yPosition = 20;
// Logo
try {
const logoImg = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = logoGovPE;
});
const logoWidth = 30;
const aspectRatio = logoImg.height / logoImg.width;
const logoHeight = logoWidth * aspectRatio;
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
yPosition = Math.max(25, 10 + logoHeight / 2);
} catch (err) {
console.warn('Não foi possível carregar a logo:', err);
}
// Título
doc.setFontSize(18);
doc.setTextColor(41, 128, 185);
doc.text('RELATÓRIO GERENCIAL - BANCO DE HORAS', 148, yPosition, { align: 'center' });
yPosition += 10;
// Mês
doc.setFontSize(11);
doc.setTextColor(0, 0, 0);
doc.text(`Mês: ${formatarMes(mesSelecionado)}`, 148, yPosition, { align: 'center' });
yPosition += 15;
// Estatísticas Gerais
doc.setFontSize(14);
doc.setTextColor(41, 128, 185);
doc.text('Estatísticas Gerais', 15, yPosition);
yPosition += 8;
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.text(`Total de Funcionários: ${estatisticas.totalFuncionarios}`, 20, yPosition);
yPosition += 6;
doc.text(`Funcionários com Saldo Positivo: ${estatisticas.funcionariosPositivos}`, 20, yPosition);
yPosition += 6;
doc.text(`Funcionários com Saldo Negativo: ${estatisticas.funcionariosNegativos}`, 20, yPosition);
yPosition += 6;
doc.text(`Total de Horas Extras: ${Math.floor(estatisticas.totalHorasExtras / 60)}h ${estatisticas.totalHorasExtras % 60}min`, 20, yPosition);
yPosition += 6;
doc.text(`Total de Déficit: ${Math.floor(estatisticas.totalDeficit / 60)}h ${estatisticas.totalDeficit % 60}min`, 20, yPosition);
// Rodapé
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(100, 100, 100);
doc.text(
`Gerado em: ${new Date().toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}`,
148,
200,
{ align: 'center' }
);
}
doc.save(`relatorio-banco-horas-${mesSelecionado}.pdf`);
}
</script>
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="bg-primary/10 rounded-xl p-3">
<Clock class="text-primary h-8 w-8" strokeWidth={2} />
</div>
<div>
<h1 class="text-base-content text-3xl font-bold">Dashboard - Banco de Horas</h1>
<p class="text-base-content/60 mt-1">Visão gerencial do banco de horas dos funcionários</p>
</div>
</div>
<button
type="button"
class="btn btn-primary"
onclick={exportarRelatorioGerencial}
disabled={!estatisticas}
>
<Download class="h-5 w-5" strokeWidth={2} />
Exportar Relatório
</button>
</div>
<!-- Filtros -->
<div class="card bg-base-100 mb-6 shadow-lg">
<div class="card-body">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<!-- Seleção de Mês -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Mês de Referência</span>
</label>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-sm btn-circle"
onclick={() => mudarMes('anterior')}
>
</button>
<span class="flex-1 text-center font-semibold">
{formatarMes(mesSelecionado)}
</span>
<button
type="button"
class="btn btn-sm btn-circle"
onclick={() => mudarMes('proximo')}
>
</button>
</div>
</div>
<!-- Filtro por Funcionário -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Funcionário</span>
</label>
<select
class="select select-bordered w-full"
bind:value={funcionarioFiltro}
>
<option value="">Todos os funcionários</option>
{#each funcionarios as func}
<option value={func._id}>{func.nome} - {func.matricula}</option>
{/each}
</select>
</div>
<!-- Filtro Apenas Negativos -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Filtros</span>
</label>
<label class="label cursor-pointer">
<span class="label-text">Apenas saldos negativos</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={apenasNegativos}
/>
</label>
</div>
</div>
</div>
</div>
{#if estatisticasQuery?.isLoading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if estatisticas}
<!-- Cards de Estatísticas -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-4 mb-6">
<!-- Total de Funcionários -->
<div class="card bg-base-100 border-base-300 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-base-content/60">Total de Funcionários</p>
<p class="text-3xl font-bold">{estatisticas.totalFuncionarios}</p>
</div>
<Users class="h-12 w-12 text-info opacity-50" strokeWidth={2} />
</div>
</div>
</div>
<!-- Funcionários com Saldo Positivo -->
<div class="card bg-success/10 border-success/30 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-base-content/60">Saldo Positivo</p>
<p class="text-3xl font-bold text-success">
{estatisticas.funcionariosPositivos}
</p>
</div>
<TrendingUp class="h-12 w-12 text-success opacity-50" strokeWidth={2} />
</div>
</div>
</div>
<!-- Funcionários com Saldo Negativo -->
<div class="card bg-error/10 border-error/30 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-base-content/60">Saldo Negativo</p>
<p class="text-3xl font-bold text-error">
{estatisticas.funcionariosNegativos}
</p>
</div>
<TrendingDown class="h-12 w-12 text-error opacity-50" strokeWidth={2} />
</div>
</div>
</div>
<!-- Total de Horas Extras -->
<div class="card bg-warning/10 border-warning/30 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-base-content/60">Total Horas Extras</p>
<p class="text-3xl font-bold text-warning">
{Math.floor(estatisticas.totalHorasExtras / 60)}h{' '}
{estatisticas.totalHorasExtras % 60}min
</p>
</div>
<Clock class="h-12 w-12 text-warning opacity-50" strokeWidth={2} />
</div>
</div>
</div>
</div>
<!-- Lista de Funcionários -->
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title mb-4">Detalhamento por Funcionário</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Funcionário</th>
<th class="text-right">Saldo Inicial</th>
<th class="text-right">Saldo do Mês</th>
<th class="text-right">Saldo Final</th>
<th class="text-right">Dias Trabalhados</th>
<th class="text-right">Horas Extras</th>
<th class="text-right">Déficit</th>
</tr>
</thead>
<tbody>
{#each estatisticas.funcionarios as item}
{#if !apenasNegativos || item.saldoFinalMinutos < 0}
<tr
class={item.saldoFinalMinutos < 0
? 'bg-error/5 hover:bg-error/10'
: ''}
>
<td>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-10">
<span class="text-xs">
{item.funcionario.nome.substring(0, 2).toUpperCase()}
</span>
</div>
</div>
<div>
<div class="font-bold">{item.funcionario.nome}</div>
<div class="text-sm opacity-50">{item.funcionario.matricula}</div>
</div>
</div>
</td>
<td class="text-right">
<span
class={item.saldoInicialMinutos >= 0 ? 'text-success' : 'text-error'}
>
{item.saldoInicialMinutos >= 0 ? '+' : ''}
{Math.floor(Math.abs(item.saldoInicialMinutos) / 60)}h{' '}
{Math.abs(item.saldoInicialMinutos) % 60}min
</span>
</td>
<td class="text-right">
<span
class={item.saldoMesMinutos >= 0 ? 'text-success' : 'text-error'}
>
{item.saldoMesMinutos >= 0 ? '+' : ''}
{Math.floor(Math.abs(item.saldoMesMinutos) / 60)}h{' '}
{Math.abs(item.saldoMesMinutos) % 60}min
</span>
</td>
<td class="text-right">
<span
class={item.saldoFinalMinutos >= 0 ? 'text-success' : 'text-error'}
>
{item.saldoFinalMinutos >= 0 ? '+' : ''}
{Math.floor(Math.abs(item.saldoFinalMinutos) / 60)}h{' '}
{Math.abs(item.saldoFinalMinutos) % 60}min
</span>
</td>
<td class="text-right">{item.diasTrabalhados}</td>
<td class="text-right text-success">
{Math.floor(item.horasExtras / 60)}h {item.horasExtras % 60}min
</td>
<td class="text-right text-error">
{Math.floor(item.horasDeficit / 60)}h {item.horasDeficit % 60}min
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
</div>
</div>
{:else}
<div class="card bg-base-100 shadow-lg">
<div class="card-body text-center">
<FileText class="mx-auto mb-4 h-12 w-12 text-base-content/40" strokeWidth={2} />
<p class="text-lg font-semibold">Nenhum dado disponível</p>
<p class="text-sm text-base-content/60">
Não há dados de banco de horas para {formatarMes(mesSelecionado)}.
</p>
</div>
</div>
{/if}
</div>