feat: implement date parsing utility across absence management components for improved date handling and consistency
This commit is contained in:
@@ -74,5 +74,24 @@
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Card 4: Dashboard Banco de Horas -->
|
||||
<a
|
||||
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/banco-horas')}
|
||||
class="card bg-base-100 transform shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div class="rounded-2xl bg-purple-500/20 p-4">
|
||||
<Clock class="h-8 w-8 text-purple-600" strokeWidth={2} />
|
||||
</div>
|
||||
<ChevronRight class="text-base-content/30 h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
<h2 class="card-title mb-2 text-xl">Dashboard Banco de Horas</h2>
|
||||
<p class="text-base-content/70">
|
||||
Visão gerencial do banco de horas, estatísticas e relatórios mensais
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user