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,700 @@
<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,
Calendar,
AlertTriangle,
Info,
ChevronLeft,
ChevronRight,
History,
Edit,
Settings,
Download,
FileText
} 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';
interface Props {
funcionarioId: Id<'funcionarios'>;
}
let { funcionarioId }: Props = $props();
// Estado para mês selecionado
const hoje = new Date();
let mesSelecionado = $state(
`${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`
);
// Query para banco de horas mensal
const bancoMensalQuery = useQuery(api.pontos.obterBancoHorasMensal, {
funcionarioId,
mes: mesSelecionado
});
// Query para histórico mensal (últimos 6 meses)
const historicoQuery = useQuery(api.pontos.listarHistoricoMensal, {
funcionarioId,
mesInicio: (() => {
const data = new Date(mesSelecionado + '-01');
data.setMonth(data.getMonth() - 5);
return `${data.getFullYear()}-${String(data.getMonth() + 1).padStart(2, '0')}`;
})(),
mesFim: mesSelecionado
});
// Query para histórico de alterações
const historicoAlteracoesQuery = useQuery(api.pontos.listarHistoricoAlteracoesBancoHoras, {
funcionarioId,
mes: mesSelecionado
});
const bancoMensal = $derived(bancoMensalQuery?.data);
const historico = $derived(historicoQuery?.data || []);
const historicoAlteracoes = $derived(historicoAlteracoesQuery?.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 navegar entre meses
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')}`;
}
// Verificar se há saldo negativo acumulado
const saldoNegativo = $derived(
bancoMensal?.saldoFinalMinutos !== undefined && bancoMensal.saldoFinalMinutos < 0
);
// Função para exportar relatório em PDF
async function exportarPDF() {
if (!bancoMensal || !historico) return;
const doc = new jsPDF('portrait', 'mm', 'a4');
let yPosition = 20;
// Logo (se disponível)
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 DE BANCO DE HORAS', 105, yPosition, { align: 'center' });
yPosition += 10;
// Mês de referência
doc.setFontSize(11);
doc.setTextColor(0, 0, 0);
doc.text(`Mês de Referência: ${formatarMes(mesSelecionado)}`, 105, yPosition, {
align: 'center'
});
yPosition += 15;
// Resumo do Mês
doc.setFontSize(14);
doc.setTextColor(41, 128, 185);
doc.text('Resumo do Mês', 15, yPosition);
yPosition += 8;
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
const formatarMinutos = (minutos: number) => {
const horas = Math.floor(Math.abs(minutos) / 60);
const mins = Math.abs(minutos) % 60;
const sinal = minutos >= 0 ? '+' : '-';
return `${sinal}${horas}h ${mins}min`;
};
doc.text(`Saldo Inicial: ${formatarMinutos(bancoMensal.saldoInicialMinutos)}`, 20, yPosition);
yPosition += 6;
doc.text(`Saldo do Mês: ${formatarMinutos(bancoMensal.saldoMesMinutos)}`, 20, yPosition);
yPosition += 6;
doc.text(`Saldo Final: ${formatarMinutos(bancoMensal.saldoFinalMinutos)}`, 20, yPosition);
yPosition += 6;
doc.text(`Dias Trabalhados: ${bancoMensal.diasTrabalhados} dias`, 20, yPosition);
yPosition += 6;
doc.text(`Horas Extras: ${formatarMinutos(bancoMensal.horasExtras)}`, 20, yPosition);
yPosition += 6;
doc.text(`Déficit: ${formatarMinutos(-bancoMensal.horasDeficit)}`, 20, yPosition);
yPosition += 15;
// Histórico dos Últimos 6 Meses
if (historico.length > 0) {
doc.setFontSize(14);
doc.setTextColor(41, 128, 185);
doc.text('Histórico dos Últimos 6 Meses', 15, yPosition);
yPosition += 8;
// Cabeçalho da tabela
doc.setFontSize(9);
doc.setTextColor(100, 100, 100);
doc.text('Mês', 20, yPosition);
doc.text('Saldo Inicial', 60, yPosition);
doc.text('Saldo Mês', 100, yPosition);
doc.text('Saldo Final', 140, yPosition);
doc.text('Dias', 180, yPosition);
yPosition += 6;
// Linhas da tabela
doc.setFontSize(8);
doc.setTextColor(0, 0, 0);
for (const item of historico.slice(0, 6)) {
if (yPosition > 250) {
doc.addPage();
yPosition = 20;
}
doc.text(formatarMes(item.mes), 20, yPosition);
doc.text(formatarMinutos(item.saldoInicialMinutos), 60, yPosition);
doc.text(formatarMinutos(item.saldoMesMinutos), 100, yPosition);
doc.text(formatarMinutos(item.saldoFinalMinutos), 140, yPosition);
doc.text(item.diasTrabalhados.toString(), 180, yPosition);
yPosition += 6;
}
}
// 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'
})}`,
105,
285,
{ align: 'center' }
);
doc.text(`Página ${i} de ${pageCount}`, 105, 290, { align: 'center' });
}
// Salvar PDF
doc.save(`banco-horas-${mesSelecionado}.pdf`);
}
</script>
<div class="space-y-6">
<!-- Header com navegação de mês -->
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold">Banco de Horas Mensal</h2>
<div class="flex items-center gap-4">
<button
type="button"
class="btn btn-sm btn-primary"
onclick={exportarPDF}
disabled={!bancoMensal}
title="Exportar relatório em PDF"
>
<Download class="h-4 w-4" strokeWidth={2} />
Exportar PDF
</button>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-sm btn-circle"
onclick={() => mudarMes('anterior')}
aria-label="Mês anterior"
>
<ChevronLeft class="h-5 w-5" strokeWidth={2} />
</button>
<span class="min-w-[200px] text-center text-lg font-semibold">
{formatarMes(mesSelecionado)}
</span>
<button
type="button"
class="btn btn-sm btn-circle"
onclick={() => mudarMes('proximo')}
aria-label="Próximo mês"
disabled={mesSelecionado >= `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`}
>
<ChevronRight class="h-5 w-5" strokeWidth={2} />
</button>
</div>
</div>
</div>
<!-- Alerta de saldo negativo -->
{#if saldoNegativo && bancoMensal}
<div class="alert alert-warning shadow-lg">
<AlertTriangle class="h-6 w-6 shrink-0" strokeWidth={2} />
<div>
<h3 class="font-bold">Atenção: Saldo Negativo Acumulado</h3>
<div class="text-sm">
Seu saldo acumulado está negativo em{' '}
<strong>
{Math.abs(bancoMensal.saldoFormatado.final.horas)}h{' '}
{Math.abs(bancoMensal.saldoFormatado.final.minutos)}min
</strong>
. Considere compensar horas ou entrar em contato com seu gestor.
</div>
</div>
</div>
{/if}
{#if bancoMensalQuery?.isLoading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if bancoMensal}
<!-- Cards de Resumo -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<!-- Saldo Inicial -->
<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">Saldo Inicial</p>
<p
class={`text-2xl font-bold ${
bancoMensal.saldoFormatado.inicial.positivo
? 'text-success'
: 'text-error'
}`}
>
{bancoMensal.saldoFormatado.inicial.positivo ? '+' : '-'}
{bancoMensal.saldoFormatado.inicial.horas}h{' '}
{bancoMensal.saldoFormatado.inicial.minutos}min
</p>
</div>
<div
class={`rounded-full p-3 ${
bancoMensal.saldoFormatado.inicial.positivo
? 'bg-success/20'
: 'bg-error/20'
}`}
>
{#if bancoMensal.saldoFormatado.inicial.positivo}
<TrendingUp class="h-6 w-6 text-success" strokeWidth={2} />
{:else}
<TrendingDown class="h-6 w-6 text-error" strokeWidth={2} />
{/if}
</div>
</div>
</div>
</div>
<!-- Saldo do Mês -->
<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">Saldo do Mês</p>
<p
class={`text-2xl font-bold ${
bancoMensal.saldoFormatado.mes.positivo
? 'text-success'
: 'text-error'
}`}
>
{bancoMensal.saldoFormatado.mes.positivo ? '+' : '-'}
{bancoMensal.saldoFormatado.mes.horas}h{' '}
{bancoMensal.saldoFormatado.mes.minutos}min
</p>
</div>
<div
class={`rounded-full p-3 ${
bancoMensal.saldoFormatado.mes.positivo
? 'bg-success/20'
: 'bg-error/20'
}`}
>
{#if bancoMensal.saldoFormatado.mes.positivo}
<TrendingUp class="h-6 w-6 text-success" strokeWidth={2} />
{:else}
<TrendingDown class="h-6 w-6 text-error" strokeWidth={2} />
{/if}
</div>
</div>
</div>
</div>
<!-- Saldo Final -->
<div
class={`card border-base-300 shadow-xl ${
saldoNegativo ? 'border-error/50 bg-error/5' : 'bg-base-100'
}`}
>
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-base-content/60">Saldo Final</p>
<p
class={`text-2xl font-bold ${
bancoMensal.saldoFormatado.final.positivo
? 'text-success'
: 'text-error'
}`}
>
{bancoMensal.saldoFormatado.final.positivo ? '+' : '-'}
{bancoMensal.saldoFormatado.final.horas}h{' '}
{bancoMensal.saldoFormatado.final.minutos}min
</p>
</div>
<div
class={`rounded-full p-3 ${
bancoMensal.saldoFormatado.final.positivo
? 'bg-success/20'
: 'bg-error/20'
}`}
>
{#if bancoMensal.saldoFormatado.final.positivo}
<TrendingUp class="h-6 w-6 text-success" strokeWidth={2} />
{:else}
<TrendingDown class="h-6 w-6 text-error" strokeWidth={2} />
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- Estatísticas Detalhadas -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<!-- Horas Extras -->
<div class="card bg-success/10 border-success/30 shadow-lg">
<div class="card-body">
<div class="flex items-center gap-4">
<div class="rounded-full bg-success/20 p-4">
<TrendingUp class="h-8 w-8 text-success" strokeWidth={2} />
</div>
<div>
<p class="text-sm font-medium text-base-content/60">Horas Extras</p>
<p class="text-2xl font-bold text-success">
{Math.floor(bancoMensal.horasExtras / 60)}h{' '}
{bancoMensal.horasExtras % 60}min
</p>
</div>
</div>
</div>
</div>
<!-- Déficit de Horas -->
<div class="card bg-error/10 border-error/30 shadow-lg">
<div class="card-body">
<div class="flex items-center gap-4">
<div class="rounded-full bg-error/20 p-4">
<TrendingDown class="h-8 w-8 text-error" strokeWidth={2} />
</div>
<div>
<p class="text-sm font-medium text-base-content/60">Déficit de Horas</p>
<p class="text-2xl font-bold text-error">
{Math.floor(bancoMensal.horasDeficit / 60)}h{' '}
{bancoMensal.horasDeficit % 60}min
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Informações Adicionais -->
<div class="card bg-base-100 border-base-300 shadow-lg">
<div class="card-body">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<Info class="h-5 w-5" strokeWidth={2} />
Informações do Mês
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<p class="text-sm font-medium text-base-content/60">Dias Trabalhados</p>
<p class="text-xl font-bold">{bancoMensal.diasTrabalhados} dias</p>
</div>
<div>
<p class="text-sm font-medium text-base-content/60">Última Atualização</p>
<p class="text-xl font-bold">
{new Date(bancoMensal.atualizadoEm).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</div>
</div>
{:else}
<div class="card bg-base-100 border-base-300 shadow-lg">
<div class="card-body text-center">
<Calendar 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 para este mês</p>
<p class="text-sm text-base-content/60">
Não há registros de banco de horas para {formatarMes(mesSelecionado)}.
</p>
</div>
</div>
{/if}
<!-- Gráfico de Evolução -->
{#if chartData}
<div class="card bg-base-100 border-base-300 shadow-lg">
<div class="card-body">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<TrendingUp class="h-5 w-5" strokeWidth={2} />
Evolução do Banco de Horas
</h3>
<div class="h-64 w-full">
<LineChart data={chartData} title="Evolução do Banco de Horas" height={256} />
</div>
</div>
</div>
{/if}
<!-- Gráfico de Evolução -->
{#if chartData}
<div class="card bg-base-100 border-base-300 shadow-lg">
<div class="card-body">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<TrendingUp class="h-5 w-5" strokeWidth={2} />
Evolução do Banco de Horas
</h3>
<div class="h-64 w-full">
<LineChart data={chartData} title="Evolução do Banco de Horas" height={256} />
</div>
</div>
</div>
{/if}
<!-- Histórico dos Últimos 6 Meses -->
{#if historico && historico.length > 0}
<div class="card bg-base-100 border-base-300 shadow-lg">
<div class="card-body">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<Clock class="h-5 w-5" strokeWidth={2} />
Histórico dos Últimos 6 Meses
</h3>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Mês</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</th>
</tr>
</thead>
<tbody>
{#each historico as item}
<tr
class={item.mes === mesSelecionado ? 'bg-primary/10 font-semibold' : ''}
>
<td>{formatarMes(item.mes)}</td>
<td class="text-right">
<span
class={item.saldoFormatado.inicial.positivo
? 'text-success'
: 'text-error'}
>
{item.saldoFormatado.inicial.positivo ? '+' : '-'}
{item.saldoFormatado.inicial.horas}h{' '}
{item.saldoFormatado.inicial.minutos}min
</span>
</td>
<td class="text-right">
<span
class={item.saldoFormatado.mes.positivo
? 'text-success'
: 'text-error'}
>
{item.saldoFormatado.mes.positivo ? '+' : '-'}
{item.saldoFormatado.mes.horas}h{' '}
{item.saldoFormatado.mes.minutos}min
</span>
</td>
<td class="text-right">
<span
class={item.saldoFormatado.final.positivo
? 'text-success'
: 'text-error'}
>
{item.saldoFormatado.final.positivo ? '+' : '-'}
{item.saldoFormatado.final.horas}h{' '}
{item.saldoFormatado.final.minutos}min
</span>
</td>
<td class="text-right">{item.diasTrabalhados}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
{/if}
<!-- Histórico de Alterações -->
{#if historicoAlteracoes && historicoAlteracoes.length > 0}
<div class="card bg-base-100 border-base-300 shadow-lg">
<div class="card-body">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<History class="h-5 w-5" strokeWidth={2} />
Histórico de Alterações - {formatarMes(mesSelecionado)}
</h3>
<div class="space-y-4">
{#each historicoAlteracoes as alteracao}
<div class="border-base-300 rounded-lg border p-4 shadow-sm">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="mb-2 flex items-center gap-2">
{#if alteracao.tipoAlteracao === 'edicao_registro'}
<Edit class="h-4 w-4 text-info" strokeWidth={2} />
<span class="badge badge-info badge-sm">Edição de Registro</span>
{:else if alteracao.tipoAlteracao === 'ajuste_banco'}
<Settings class="h-4 w-4 text-warning" strokeWidth={2} />
<span class="badge badge-warning badge-sm">
Ajuste de Banco de Horas
</span>
{:else}
<Info class="h-4 w-4 text-base-content/60" strokeWidth={2} />
<span class="badge badge-ghost badge-sm">Outro</span>
{/if}
<span class="text-xs text-base-content/60">
{alteracao.dataFormatada}
</span>
</div>
{#if alteracao.tipoAlteracao === 'edicao_registro' && alteracao.registro}
<div class="text-sm">
<p>
<strong>Registro:</strong> {alteracao.registro.tipo} em{' '}
{alteracao.registro.data}
</p>
<p>
<strong>Alteração:</strong>{' '}
<span class="text-error">
{alteracao.registro.horaAnterior}
</span>{' '}
{' '}
<span class="text-success">
{alteracao.registro.horaNova}
</span>
</p>
{#if alteracao.diferencaMinutos !== undefined}
<p>
<strong>Diferença:</strong>{' '}
<span
class={alteracao.diferencaMinutos >= 0
? 'text-success'
: 'text-error'}
>
{alteracao.diferencaMinutos >= 0 ? '+' : ''}
{Math.floor(Math.abs(alteracao.diferencaMinutos) / 60)}h{' '}
{Math.abs(alteracao.diferencaMinutos) % 60}min
</span>
</p>
{/if}
</div>
{:else if alteracao.tipoAlteracao === 'ajuste_banco'}
<div class="text-sm">
<p>
<strong>Tipo:</strong> {alteracao.tipoAjuste === 'compensar'
? 'Compensar'
: alteracao.tipoAjuste === 'abonar'
? 'Abonar'
: 'Descontar'}
</p>
{#if alteracao.ajusteMinutos !== undefined}
<p>
<strong>Ajuste:</strong>{' '}
<span
class={alteracao.ajusteMinutos >= 0
? 'text-success'
: 'text-error'}
>
{alteracao.ajusteMinutos >= 0 ? '+' : ''}
{Math.floor(Math.abs(alteracao.ajusteMinutos) / 60)}h{' '}
{Math.abs(alteracao.ajusteMinutos) % 60}min
</span>
</p>
{/if}
</div>
{/if}
{#if alteracao.motivoDescricao}
<p class="mt-2 text-sm">
<strong>Motivo:</strong> {alteracao.motivoDescricao}
</p>
{/if}
{#if alteracao.observacoes}
<p class="mt-1 text-sm text-base-content/70">
<strong>Observações:</strong> {alteracao.observacoes}
</p>
{/if}
{#if alteracao.gestor}
<p class="mt-1 text-xs text-base-content/60">
Alterado por: <strong>{alteracao.gestor.nome}</strong>
</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
{:else if historicoAlteracoesQuery?.data && historicoAlteracoesQuery.data.length === 0}
<div class="card bg-base-100 border-base-300 shadow-lg">
<div class="card-body text-center">
<History class="mx-auto mb-4 h-12 w-12 text-base-content/40" strokeWidth={2} />
<p class="text-lg font-semibold">Nenhuma alteração registrada</p>
<p class="text-sm text-base-content/60">
Não há histórico de alterações para {formatarMes(mesSelecionado)}.
</p>
</div>
</div>
{/if}
</div>