feat: implement date parsing utility across absence management components for improved date handling and consistency
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
import ErrorModal from './ErrorModal.svelte';
|
||||
import UserAvatar from './chat/UserAvatar.svelte';
|
||||
import { Calendar, FileText, XCircle, X, Check, Clock, User, Info } from 'lucide-svelte';
|
||||
import { parseLocalDate } from '$lib/utils/datas';
|
||||
|
||||
type SolicitacaoAusencia = Doc<'solicitacoesAusencias'> & {
|
||||
funcionario?: Doc<'funcionarios'> | null;
|
||||
@@ -30,8 +31,8 @@
|
||||
let mensagemErroModal = $state('');
|
||||
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const inicio = parseLocalDate(dataInicio);
|
||||
const fim = parseLocalDate(dataFim);
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
@@ -199,7 +200,7 @@
|
||||
>
|
||||
<div class="stat-title text-base-content/70">Data Início</div>
|
||||
<div class="stat-value text-primary text-2xl">
|
||||
{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
|
||||
{parseLocalDate(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -207,7 +208,7 @@
|
||||
>
|
||||
<div class="stat-title text-base-content/70">Data Fim</div>
|
||||
<div class="stat-value text-primary text-2xl">
|
||||
{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}
|
||||
{parseLocalDate(solicitacao.dataFim).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import multiMonthPlugin from '@fullcalendar/multimonth';
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { parseLocalDate } from '$lib/utils/datas';
|
||||
|
||||
interface Props {
|
||||
dataInicio?: string;
|
||||
@@ -723,13 +724,13 @@
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Data Início</p>
|
||||
<p class="text-lg font-bold">
|
||||
{new Date(dataInicio).toLocaleDateString('pt-BR')}
|
||||
{parseLocalDate(dataInicio).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Data Fim</p>
|
||||
<p class="text-lg font-bold">
|
||||
{new Date(dataFim).toLocaleDateString('pt-BR')}
|
||||
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { Check, ChevronLeft, ChevronRight, Calendar, Info, AlertTriangle, X, CheckCircle } from 'lucide-svelte';
|
||||
import { parseLocalDate } from '$lib/utils/datas';
|
||||
|
||||
interface Props {
|
||||
funcionarioId: Id<'funcionarios'>;
|
||||
@@ -71,14 +72,14 @@
|
||||
|
||||
const hoje = new SvelteDate();
|
||||
hoje.setHours(0, 0, 0, 0);
|
||||
const inicio = new Date(dataInicio);
|
||||
const inicio = parseLocalDate(dataInicio);
|
||||
|
||||
if (inicio < hoje) {
|
||||
toast.error('A data de início não pode ser no passado');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(dataFim) < new Date(dataInicio)) {
|
||||
if (parseLocalDate(dataFim) < parseLocalDate(dataInicio)) {
|
||||
toast.error('A data de fim deve ser maior ou igual à data de início');
|
||||
return;
|
||||
}
|
||||
@@ -134,7 +135,7 @@
|
||||
mensagemErro.includes('solicitação aprovada ou pendente')
|
||||
) {
|
||||
mensagemErroModal = 'Não é possível criar esta solicitação.';
|
||||
detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${new Date(dataInicio).toLocaleDateString('pt-BR')} até ${new Date(dataFim).toLocaleDateString('pt-BR')}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`;
|
||||
detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até ${parseLocalDate(dataFim).toLocaleDateString('pt-BR')}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`;
|
||||
mostrarModalErro = true;
|
||||
} else {
|
||||
// Outros erros continuam usando toast
|
||||
@@ -230,8 +231,8 @@
|
||||
<div>
|
||||
<h4 class="font-bold">Período selecionado!</h4>
|
||||
<p>
|
||||
De {new Date(dataInicio).toLocaleDateString('pt-BR')} até
|
||||
{new Date(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)
|
||||
De {parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até
|
||||
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,13 +260,13 @@
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Data Início</p>
|
||||
<p class="font-bold">
|
||||
{new Date(dataInicio).toLocaleDateString('pt-BR')}
|
||||
{parseLocalDate(dataInicio).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Data Fim</p>
|
||||
<p class="font-bold">
|
||||
{new Date(dataFim).toLocaleDateString('pt-BR')}
|
||||
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
700
apps/web/src/lib/components/ponto/BancoHorasMensal.svelte
Normal file
700
apps/web/src/lib/components/ponto/BancoHorasMensal.svelte
Normal 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>
|
||||
|
||||
63
apps/web/src/lib/utils/datas.ts
Normal file
63
apps/web/src/lib/utils/datas.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Utilitários para manipulação de datas
|
||||
* Resolve problemas de timezone ao trabalhar com datas no formato YYYY-MM-DD
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converte uma string de data no formato YYYY-MM-DD para um objeto Date local
|
||||
* (sem considerar timezone, garantindo que a data seja interpretada como data local)
|
||||
*
|
||||
* @param dateString - String no formato YYYY-MM-DD
|
||||
* @returns Date objeto representando a data local (meia-noite no timezone local)
|
||||
*
|
||||
* @example
|
||||
* parseLocalDate('2024-01-15') // Retorna Date para 15/01/2024 00:00:00 no timezone local
|
||||
*/
|
||||
export function parseLocalDate(dateString: string): Date {
|
||||
if (!dateString || typeof dateString !== 'string') {
|
||||
throw new Error('dateString deve ser uma string válida');
|
||||
}
|
||||
|
||||
// Validar formato YYYY-MM-DD
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(dateString)) {
|
||||
throw new Error('dateString deve estar no formato YYYY-MM-DD');
|
||||
}
|
||||
|
||||
// Extrair ano, mês e dia
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
|
||||
// Criar data local (sem considerar timezone)
|
||||
// O mês no Date é 0-indexed, então subtraímos 1
|
||||
const date = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
|
||||
// Validar se a data é válida
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new Error(`Data inválida: ${dateString}`);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata uma data para o formato brasileiro (DD/MM/YYYY)
|
||||
*
|
||||
* @param date - Date objeto ou string no formato YYYY-MM-DD
|
||||
* @returns String formatada no formato DD/MM/YYYY
|
||||
*/
|
||||
export function formatarDataBR(date: Date | string): string {
|
||||
let dateObj: Date;
|
||||
|
||||
if (typeof date === 'string') {
|
||||
dateObj = parseLocalDate(date);
|
||||
} else {
|
||||
dateObj = date;
|
||||
}
|
||||
|
||||
const day = dateObj.getDate().toString().padStart(2, '0');
|
||||
const month = (dateObj.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = dateObj.getFullYear();
|
||||
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import AprovarAusencias from '$lib/components/AprovarAusencias.svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { parseLocalDate } from '$lib/utils/datas';
|
||||
|
||||
const client = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
@@ -53,8 +54,8 @@
|
||||
}
|
||||
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const inicio = parseLocalDate(dataInicio);
|
||||
const fim = parseLocalDate(dataFim);
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
@@ -296,8 +297,8 @@
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '}
|
||||
{new Date(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||
{parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '}
|
||||
{parseLocalDate(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
<td class="font-bold">
|
||||
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import UserAvatar from '$lib/components/chat/UserAvatar.svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import type { FunctionReturnType } from 'convex/server';
|
||||
import { parseLocalDate } from '$lib/utils/datas';
|
||||
import {
|
||||
X,
|
||||
Calendar,
|
||||
@@ -40,6 +41,7 @@
|
||||
Smile
|
||||
} from 'lucide-svelte';
|
||||
import RegistroPonto from '$lib/components/ponto/RegistroPonto.svelte';
|
||||
import BancoHorasMensal from '$lib/components/ponto/BancoHorasMensal.svelte';
|
||||
import TicketCard from '$lib/components/chamados/TicketCard.svelte';
|
||||
import TicketTimeline from '$lib/components/chamados/TicketTimeline.svelte';
|
||||
import { chamadosStore } from '$lib/stores/chamados';
|
||||
@@ -1813,14 +1815,14 @@
|
||||
{#each ausenciasFiltradas as ausencia (ausencia._id)}
|
||||
<tr>
|
||||
<td>
|
||||
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até {new Date(
|
||||
{parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até {parseLocalDate(
|
||||
ausencia.dataFim
|
||||
).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
<td class="font-bold">
|
||||
{Math.ceil(
|
||||
(new Date(ausencia.dataFim).getTime() -
|
||||
new Date(ausencia.dataInicio).getTime()) /
|
||||
(parseLocalDate(ausencia.dataFim).getTime() -
|
||||
parseLocalDate(ausencia.dataInicio).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
) + 1} dias
|
||||
</td>
|
||||
@@ -2058,16 +2060,16 @@
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<span>
|
||||
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até
|
||||
{new Date(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||
{parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até
|
||||
{parseLocalDate(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-warning badge-lg font-bold shadow-sm">
|
||||
{Math.ceil(
|
||||
(new Date(ausencia.dataFim).getTime() -
|
||||
new Date(ausencia.dataInicio).getTime()) /
|
||||
(parseLocalDate(ausencia.dataFim).getTime() -
|
||||
parseLocalDate(ausencia.dataInicio).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
) + 1} dias
|
||||
</div>
|
||||
@@ -2407,29 +2409,43 @@
|
||||
|
||||
{#if abaAtiva === 'meu-ponto'}
|
||||
<!-- Meu Ponto -->
|
||||
<div
|
||||
class="card from-base-100 to-base-200 overflow-hidden border-t-4 border-info bg-gradient-to-br shadow-2xl"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-info to-info/80 shadow-lg ring-2 ring-info/20"
|
||||
>
|
||||
<Fingerprint class="h-6 w-6 text-info-content" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
|
||||
Meu Ponto
|
||||
</h2>
|
||||
<p class="text-base-content/60 mt-0.5 text-sm">
|
||||
Registre sua entrada, saída e intervalos de trabalho
|
||||
</p>
|
||||
<div class="space-y-6">
|
||||
<!-- Registro de Ponto -->
|
||||
<div
|
||||
class="card from-base-100 to-base-200 overflow-hidden border-t-4 border-info bg-gradient-to-br shadow-2xl"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-info to-info/80 shadow-lg ring-2 ring-info/20"
|
||||
>
|
||||
<Fingerprint class="h-6 w-6 text-info-content" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
|
||||
Registro de Ponto
|
||||
</h2>
|
||||
<p class="text-base-content/60 mt-0.5 text-sm">
|
||||
Registre sua entrada, saída e intervalos de trabalho
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RegistroPonto />
|
||||
</div>
|
||||
<RegistroPonto />
|
||||
</div>
|
||||
|
||||
<!-- Banco de Horas Mensal -->
|
||||
{#if funcionarioIdDisponivel}
|
||||
<div
|
||||
class="card from-base-100 to-base-200 overflow-hidden border-t-4 border-primary bg-gradient-to-br shadow-2xl"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<BancoHorasMensal funcionarioId={funcionarioIdDisponivel} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import AprovarAusencias from '$lib/components/AprovarAusencias.svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { Clock, ArrowLeft, FileText, CheckCircle, XCircle, Info, Eye } from 'lucide-svelte';
|
||||
import { parseLocalDate } from '$lib/utils/datas';
|
||||
|
||||
const client = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
@@ -54,8 +55,8 @@
|
||||
}
|
||||
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const inicio = parseLocalDate(dataInicio);
|
||||
const fim = parseLocalDate(dataFim);
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
@@ -207,8 +208,8 @@
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '}
|
||||
{new Date(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||
{parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '}
|
||||
{parseLocalDate(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
<td class="font-bold">
|
||||
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
|
||||
|
||||
@@ -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