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 ErrorModal from './ErrorModal.svelte';
|
||||||
import UserAvatar from './chat/UserAvatar.svelte';
|
import UserAvatar from './chat/UserAvatar.svelte';
|
||||||
import { Calendar, FileText, XCircle, X, Check, Clock, User, Info } from 'lucide-svelte';
|
import { Calendar, FileText, XCircle, X, Check, Clock, User, Info } from 'lucide-svelte';
|
||||||
|
import { parseLocalDate } from '$lib/utils/datas';
|
||||||
|
|
||||||
type SolicitacaoAusencia = Doc<'solicitacoesAusencias'> & {
|
type SolicitacaoAusencia = Doc<'solicitacoesAusencias'> & {
|
||||||
funcionario?: Doc<'funcionarios'> | null;
|
funcionario?: Doc<'funcionarios'> | null;
|
||||||
@@ -30,8 +31,8 @@
|
|||||||
let mensagemErroModal = $state('');
|
let mensagemErroModal = $state('');
|
||||||
|
|
||||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||||
const inicio = new Date(dataInicio);
|
const inicio = parseLocalDate(dataInicio);
|
||||||
const fim = new Date(dataFim);
|
const fim = parseLocalDate(dataFim);
|
||||||
const diff = fim.getTime() - inicio.getTime();
|
const diff = fim.getTime() - inicio.getTime();
|
||||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
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-title text-base-content/70">Data Início</div>
|
||||||
<div class="stat-value text-primary text-2xl">
|
<div class="stat-value text-primary text-2xl">
|
||||||
{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
|
{parseLocalDate(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -207,7 +208,7 @@
|
|||||||
>
|
>
|
||||||
<div class="stat-title text-base-content/70">Data Fim</div>
|
<div class="stat-title text-base-content/70">Data Fim</div>
|
||||||
<div class="stat-value text-primary text-2xl">
|
<div class="stat-value text-primary text-2xl">
|
||||||
{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}
|
{parseLocalDate(solicitacao.dataFim).toLocaleDateString('pt-BR')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import multiMonthPlugin from '@fullcalendar/multimonth';
|
import multiMonthPlugin from '@fullcalendar/multimonth';
|
||||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
import { parseLocalDate } from '$lib/utils/datas';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dataInicio?: string;
|
dataInicio?: string;
|
||||||
@@ -723,13 +724,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-base-content/70 text-sm">Data Início</p>
|
<p class="text-base-content/70 text-sm">Data Início</p>
|
||||||
<p class="text-lg font-bold">
|
<p class="text-lg font-bold">
|
||||||
{new Date(dataInicio).toLocaleDateString('pt-BR')}
|
{parseLocalDate(dataInicio).toLocaleDateString('pt-BR')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-base-content/70 text-sm">Data Fim</p>
|
<p class="text-base-content/70 text-sm">Data Fim</p>
|
||||||
<p class="text-lg font-bold">
|
<p class="text-lg font-bold">
|
||||||
{new Date(dataFim).toLocaleDateString('pt-BR')}
|
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { Check, ChevronLeft, ChevronRight, Calendar, Info, AlertTriangle, X, CheckCircle } from 'lucide-svelte';
|
import { Check, ChevronLeft, ChevronRight, Calendar, Info, AlertTriangle, X, CheckCircle } from 'lucide-svelte';
|
||||||
|
import { parseLocalDate } from '$lib/utils/datas';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
funcionarioId: Id<'funcionarios'>;
|
funcionarioId: Id<'funcionarios'>;
|
||||||
@@ -71,14 +72,14 @@
|
|||||||
|
|
||||||
const hoje = new SvelteDate();
|
const hoje = new SvelteDate();
|
||||||
hoje.setHours(0, 0, 0, 0);
|
hoje.setHours(0, 0, 0, 0);
|
||||||
const inicio = new Date(dataInicio);
|
const inicio = parseLocalDate(dataInicio);
|
||||||
|
|
||||||
if (inicio < hoje) {
|
if (inicio < hoje) {
|
||||||
toast.error('A data de início não pode ser no passado');
|
toast.error('A data de início não pode ser no passado');
|
||||||
return;
|
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');
|
toast.error('A data de fim deve ser maior ou igual à data de início');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -134,7 +135,7 @@
|
|||||||
mensagemErro.includes('solicitação aprovada ou pendente')
|
mensagemErro.includes('solicitação aprovada ou pendente')
|
||||||
) {
|
) {
|
||||||
mensagemErroModal = 'Não é possível criar esta solicitação.';
|
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;
|
mostrarModalErro = true;
|
||||||
} else {
|
} else {
|
||||||
// Outros erros continuam usando toast
|
// Outros erros continuam usando toast
|
||||||
@@ -230,8 +231,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<h4 class="font-bold">Período selecionado!</h4>
|
<h4 class="font-bold">Período selecionado!</h4>
|
||||||
<p>
|
<p>
|
||||||
De {new Date(dataInicio).toLocaleDateString('pt-BR')} até
|
De {parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até
|
||||||
{new Date(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)
|
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,13 +260,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-base-content/70 text-sm">Data Início</p>
|
<p class="text-base-content/70 text-sm">Data Início</p>
|
||||||
<p class="font-bold">
|
<p class="font-bold">
|
||||||
{new Date(dataInicio).toLocaleDateString('pt-BR')}
|
{parseLocalDate(dataInicio).toLocaleDateString('pt-BR')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-base-content/70 text-sm">Data Fim</p>
|
<p class="text-base-content/70 text-sm">Data Fim</p>
|
||||||
<p class="font-bold">
|
<p class="font-bold">
|
||||||
{new Date(dataFim).toLocaleDateString('pt-BR')}
|
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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 { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import AprovarAusencias from '$lib/components/AprovarAusencias.svelte';
|
import AprovarAusencias from '$lib/components/AprovarAusencias.svelte';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { parseLocalDate } from '$lib/utils/datas';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||||
@@ -53,8 +54,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||||
const inicio = new Date(dataInicio);
|
const inicio = parseLocalDate(dataInicio);
|
||||||
const fim = new Date(dataFim);
|
const fim = parseLocalDate(dataFim);
|
||||||
const diff = fim.getTime() - inicio.getTime();
|
const diff = fim.getTime() - inicio.getTime();
|
||||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||||
}
|
}
|
||||||
@@ -296,8 +297,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '}
|
{parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '}
|
||||||
{new Date(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
{parseLocalDate(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-bold">
|
<td class="font-bold">
|
||||||
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
|
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import UserAvatar from '$lib/components/chat/UserAvatar.svelte';
|
import UserAvatar from '$lib/components/chat/UserAvatar.svelte';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import type { FunctionReturnType } from 'convex/server';
|
import type { FunctionReturnType } from 'convex/server';
|
||||||
|
import { parseLocalDate } from '$lib/utils/datas';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
Smile
|
Smile
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import RegistroPonto from '$lib/components/ponto/RegistroPonto.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 TicketCard from '$lib/components/chamados/TicketCard.svelte';
|
||||||
import TicketTimeline from '$lib/components/chamados/TicketTimeline.svelte';
|
import TicketTimeline from '$lib/components/chamados/TicketTimeline.svelte';
|
||||||
import { chamadosStore } from '$lib/stores/chamados';
|
import { chamadosStore } from '$lib/stores/chamados';
|
||||||
@@ -1813,14 +1815,14 @@
|
|||||||
{#each ausenciasFiltradas as ausencia (ausencia._id)}
|
{#each ausenciasFiltradas as ausencia (ausencia._id)}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até {new Date(
|
{parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até {parseLocalDate(
|
||||||
ausencia.dataFim
|
ausencia.dataFim
|
||||||
).toLocaleDateString('pt-BR')}
|
).toLocaleDateString('pt-BR')}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-bold">
|
<td class="font-bold">
|
||||||
{Math.ceil(
|
{Math.ceil(
|
||||||
(new Date(ausencia.dataFim).getTime() -
|
(parseLocalDate(ausencia.dataFim).getTime() -
|
||||||
new Date(ausencia.dataInicio).getTime()) /
|
parseLocalDate(ausencia.dataInicio).getTime()) /
|
||||||
(1000 * 60 * 60 * 24)
|
(1000 * 60 * 60 * 24)
|
||||||
) + 1} dias
|
) + 1} dias
|
||||||
</td>
|
</td>
|
||||||
@@ -2058,16 +2060,16 @@
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até
|
{parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até
|
||||||
{new Date(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
{parseLocalDate(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="badge badge-warning badge-lg font-bold shadow-sm">
|
<div class="badge badge-warning badge-lg font-bold shadow-sm">
|
||||||
{Math.ceil(
|
{Math.ceil(
|
||||||
(new Date(ausencia.dataFim).getTime() -
|
(parseLocalDate(ausencia.dataFim).getTime() -
|
||||||
new Date(ausencia.dataInicio).getTime()) /
|
parseLocalDate(ausencia.dataInicio).getTime()) /
|
||||||
(1000 * 60 * 60 * 24)
|
(1000 * 60 * 60 * 24)
|
||||||
) + 1} dias
|
) + 1} dias
|
||||||
</div>
|
</div>
|
||||||
@@ -2407,29 +2409,43 @@
|
|||||||
|
|
||||||
{#if abaAtiva === 'meu-ponto'}
|
{#if abaAtiva === 'meu-ponto'}
|
||||||
<!-- Meu Ponto -->
|
<!-- Meu Ponto -->
|
||||||
<div
|
<div class="space-y-6">
|
||||||
class="card from-base-100 to-base-200 overflow-hidden border-t-4 border-info bg-gradient-to-br shadow-2xl"
|
<!-- Registro de Ponto -->
|
||||||
>
|
<div
|
||||||
<div class="card-body p-6">
|
class="card from-base-100 to-base-200 overflow-hidden border-t-4 border-info bg-gradient-to-br shadow-2xl"
|
||||||
<div class="mb-6 flex items-center justify-between">
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="card-body p-6">
|
||||||
<div
|
<div class="mb-6 flex items-center justify-between">
|
||||||
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"
|
<div class="flex items-center gap-3">
|
||||||
>
|
<div
|
||||||
<Fingerprint class="h-6 w-6 text-info-content" strokeWidth={2.5} />
|
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"
|
||||||
</div>
|
>
|
||||||
<div>
|
<Fingerprint class="h-6 w-6 text-info-content" strokeWidth={2.5} />
|
||||||
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
|
</div>
|
||||||
Meu Ponto
|
<div>
|
||||||
</h2>
|
<h2 class="text-base-content flex items-center gap-2 text-2xl font-bold">
|
||||||
<p class="text-base-content/60 mt-0.5 text-sm">
|
Registro de Ponto
|
||||||
Registre sua entrada, saída e intervalos de trabalho
|
</h2>
|
||||||
</p>
|
<p class="text-base-content/60 mt-0.5 text-sm">
|
||||||
|
Registre sua entrada, saída e intervalos de trabalho
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<RegistroPonto />
|
||||||
</div>
|
</div>
|
||||||
<RegistroPonto />
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import AprovarAusencias from '$lib/components/AprovarAusencias.svelte';
|
import AprovarAusencias from '$lib/components/AprovarAusencias.svelte';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { Clock, ArrowLeft, FileText, CheckCircle, XCircle, Info, Eye } from 'lucide-svelte';
|
import { Clock, ArrowLeft, FileText, CheckCircle, XCircle, Info, Eye } from 'lucide-svelte';
|
||||||
|
import { parseLocalDate } from '$lib/utils/datas';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||||
@@ -54,8 +55,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||||
const inicio = new Date(dataInicio);
|
const inicio = parseLocalDate(dataInicio);
|
||||||
const fim = new Date(dataFim);
|
const fim = parseLocalDate(dataFim);
|
||||||
const diff = fim.getTime() - inicio.getTime();
|
const diff = fim.getTime() - inicio.getTime();
|
||||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||||
}
|
}
|
||||||
@@ -207,8 +208,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '}
|
{parseLocalDate(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '}
|
||||||
{new Date(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
{parseLocalDate(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||||
</td>
|
</td>
|
||||||
<td class="font-bold">
|
<td class="font-bold">
|
||||||
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
|
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
|
||||||
|
|||||||
@@ -74,5 +74,24 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</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>
|
||||||
</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>
|
||||||
|
|
||||||
|
|
||||||
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -83,6 +83,7 @@ import type * as templatesMensagens from "../templatesMensagens.js";
|
|||||||
import type * as times from "../times.js";
|
import type * as times from "../times.js";
|
||||||
import type * as usuarios from "../usuarios.js";
|
import type * as usuarios from "../usuarios.js";
|
||||||
import type * as utils_chatTemplateWrapper from "../utils/chatTemplateWrapper.js";
|
import type * as utils_chatTemplateWrapper from "../utils/chatTemplateWrapper.js";
|
||||||
|
import type * as utils_datas from "../utils/datas.js";
|
||||||
import type * as utils_emailTemplateWrapper from "../utils/emailTemplateWrapper.js";
|
import type * as utils_emailTemplateWrapper from "../utils/emailTemplateWrapper.js";
|
||||||
import type * as utils_getClientIP from "../utils/getClientIP.js";
|
import type * as utils_getClientIP from "../utils/getClientIP.js";
|
||||||
import type * as utils_scanEmailSenders from "../utils/scanEmailSenders.js";
|
import type * as utils_scanEmailSenders from "../utils/scanEmailSenders.js";
|
||||||
@@ -170,6 +171,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
times: typeof times;
|
times: typeof times;
|
||||||
usuarios: typeof usuarios;
|
usuarios: typeof usuarios;
|
||||||
"utils/chatTemplateWrapper": typeof utils_chatTemplateWrapper;
|
"utils/chatTemplateWrapper": typeof utils_chatTemplateWrapper;
|
||||||
|
"utils/datas": typeof utils_datas;
|
||||||
"utils/emailTemplateWrapper": typeof utils_emailTemplateWrapper;
|
"utils/emailTemplateWrapper": typeof utils_emailTemplateWrapper;
|
||||||
"utils/getClientIP": typeof utils_getClientIP;
|
"utils/getClientIP": typeof utils_getClientIP;
|
||||||
"utils/scanEmailSenders": typeof utils_scanEmailSenders;
|
"utils/scanEmailSenders": typeof utils_scanEmailSenders;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mutation, query } from './_generated/server';
|
|||||||
import type { QueryCtx, MutationCtx } from './_generated/server';
|
import type { QueryCtx, MutationCtx } from './_generated/server';
|
||||||
import { internal, api } from './_generated/api';
|
import { internal, api } from './_generated/api';
|
||||||
import { Id, Doc } from './_generated/dataModel';
|
import { Id, Doc } from './_generated/dataModel';
|
||||||
|
import { parseLocalDate, formatarDataBR } from './utils/datas';
|
||||||
|
|
||||||
// Query: Listar todas as solicitações (para RH)
|
// Query: Listar todas as solicitações (para RH)
|
||||||
export const listarTodas = query({
|
export const listarTodas = query({
|
||||||
@@ -257,10 +258,10 @@ function verificarSobreposicao(
|
|||||||
inicio2: string,
|
inicio2: string,
|
||||||
fim2: string
|
fim2: string
|
||||||
): boolean {
|
): boolean {
|
||||||
const d1Inicio = new Date(inicio1);
|
const d1Inicio = parseLocalDate(inicio1);
|
||||||
const d1Fim = new Date(fim1);
|
const d1Fim = parseLocalDate(fim1);
|
||||||
const d2Inicio = new Date(inicio2);
|
const d2Inicio = parseLocalDate(inicio2);
|
||||||
const d2Fim = new Date(fim2);
|
const d2Fim = parseLocalDate(fim2);
|
||||||
|
|
||||||
return d1Inicio <= d2Fim && d2Inicio <= d1Fim;
|
return d1Inicio <= d2Fim && d2Inicio <= d1Fim;
|
||||||
}
|
}
|
||||||
@@ -299,12 +300,14 @@ export const criarSolicitacao = mutation({
|
|||||||
throw new Error('O motivo deve ter no mínimo 10 caracteres');
|
throw new Error('O motivo deve ter no mínimo 10 caracteres');
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataInicio = new Date(args.dataInicio);
|
const dataInicio = parseLocalDate(args.dataInicio);
|
||||||
const dataFim = new Date(args.dataFim);
|
const dataFim = parseLocalDate(args.dataFim);
|
||||||
|
|
||||||
|
// Criar data de hoje em UTC para comparação
|
||||||
const hoje = new Date();
|
const hoje = new Date();
|
||||||
hoje.setHours(0, 0, 0, 0);
|
const hojeUTC = new Date(Date.UTC(hoje.getUTCFullYear(), hoje.getUTCMonth(), hoje.getUTCDate(), 0, 0, 0, 0));
|
||||||
|
|
||||||
if (dataInicio < hoje) {
|
if (dataInicio < hojeUTC) {
|
||||||
throw new Error('A data de início não pode ser no passado');
|
throw new Error('A data de início não pode ser no passado');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +354,7 @@ export const criarSolicitacao = mutation({
|
|||||||
solicitacaoAusenciaId: solicitacaoId,
|
solicitacaoAusenciaId: solicitacaoId,
|
||||||
tipo: 'nova_solicitacao',
|
tipo: 'nova_solicitacao',
|
||||||
lida: false,
|
lida: false,
|
||||||
mensagem: `${funcionario.nome} solicitou uma ausência de ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}`
|
mensagem: `${funcionario.nome} solicitou uma ausência de ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}`
|
||||||
});
|
});
|
||||||
|
|
||||||
// Buscar usuário do gestor para enviar email e chat
|
// Buscar usuário do gestor para enviar email e chat
|
||||||
@@ -377,8 +380,8 @@ export const criarSolicitacao = mutation({
|
|||||||
variaveis: {
|
variaveis: {
|
||||||
gestorNome: gestorUsuario.nome,
|
gestorNome: gestorUsuario.nome,
|
||||||
funcionarioNome: funcionario.nome,
|
funcionarioNome: funcionario.nome,
|
||||||
dataInicio: new Date(args.dataInicio).toLocaleDateString('pt-BR'),
|
dataInicio: formatarDataBR(args.dataInicio),
|
||||||
dataFim: new Date(args.dataFim).toLocaleDateString('pt-BR'),
|
dataFim: formatarDataBR(args.dataFim),
|
||||||
motivo: args.motivo,
|
motivo: args.motivo,
|
||||||
urlSistema
|
urlSistema
|
||||||
},
|
},
|
||||||
@@ -397,7 +400,7 @@ export const criarSolicitacao = mutation({
|
|||||||
corpo: `<p>Olá ${gestorUsuario.nome},</p>
|
corpo: `<p>Olá ${gestorUsuario.nome},</p>
|
||||||
<p>O funcionário <strong>${funcionario.nome}</strong> solicitou uma ausência:</p>
|
<p>O funcionário <strong>${funcionario.nome}</strong> solicitou uma ausência:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Período:</strong> ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}</li>
|
<li><strong>Período:</strong> ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}</li>
|
||||||
<li><strong>Motivo:</strong> ${args.motivo}</li>
|
<li><strong>Motivo:</strong> ${args.motivo}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.</p>`,
|
<p>Por favor, acesse o sistema para aprovar ou reprovar esta solicitação.</p>`,
|
||||||
@@ -437,7 +440,7 @@ export const criarSolicitacao = mutation({
|
|||||||
conversaId,
|
conversaId,
|
||||||
remetenteId: funcionarioUsuario._id,
|
remetenteId: funcionarioUsuario._id,
|
||||||
tipo: 'texto',
|
tipo: 'texto',
|
||||||
conteudo: `Solicitei uma ausência de ${new Date(args.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(args.dataFim).toLocaleDateString('pt-BR')}. Motivo: ${args.motivo}`,
|
conteudo: `Solicitei uma ausência de ${formatarDataBR(args.dataInicio)} até ${formatarDataBR(args.dataFim)}. Motivo: ${args.motivo}`,
|
||||||
enviadaEm: Date.now()
|
enviadaEm: Date.now()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -499,7 +502,7 @@ export const aprovar = mutation({
|
|||||||
solicitacaoAusenciaId: args.solicitacaoId,
|
solicitacaoAusenciaId: args.solicitacaoId,
|
||||||
tipo: 'aprovado',
|
tipo: 'aprovado',
|
||||||
lida: false,
|
lida: false,
|
||||||
mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} foi aprovada!`
|
mensagem: `Sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)} foi aprovada!`
|
||||||
});
|
});
|
||||||
|
|
||||||
const gestorUsuario = await ctx.db.get(args.gestorId);
|
const gestorUsuario = await ctx.db.get(args.gestorId);
|
||||||
@@ -520,8 +523,8 @@ export const aprovar = mutation({
|
|||||||
variaveis: {
|
variaveis: {
|
||||||
funcionarioNome: funcionarioUsuario.nome,
|
funcionarioNome: funcionarioUsuario.nome,
|
||||||
gestorNome: gestorUsuario.nome,
|
gestorNome: gestorUsuario.nome,
|
||||||
dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR'),
|
dataInicio: formatarDataBR(solicitacao.dataInicio),
|
||||||
dataFim: new Date(solicitacao.dataFim).toLocaleDateString('pt-BR'),
|
dataFim: formatarDataBR(solicitacao.dataFim),
|
||||||
motivo: solicitacao.motivo,
|
motivo: solicitacao.motivo,
|
||||||
urlSistema
|
urlSistema
|
||||||
},
|
},
|
||||||
@@ -540,7 +543,7 @@ export const aprovar = mutation({
|
|||||||
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
|
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
|
||||||
<p>Sua solicitação de ausência foi <strong>aprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
|
<p>Sua solicitação de ausência foi <strong>aprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}</li>
|
<li><strong>Período:</strong> ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}</li>
|
||||||
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
|
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
|
||||||
</ul>`,
|
</ul>`,
|
||||||
enviadoPor: args.gestorId
|
enviadoPor: args.gestorId
|
||||||
@@ -579,7 +582,7 @@ export const aprovar = mutation({
|
|||||||
conversaId,
|
conversaId,
|
||||||
remetenteId: args.gestorId,
|
remetenteId: args.gestorId,
|
||||||
tipo: 'texto',
|
tipo: 'texto',
|
||||||
conteudo: `Aprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}.`,
|
conteudo: `Aprovei sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}.`,
|
||||||
enviadaEm: Date.now()
|
enviadaEm: Date.now()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -643,7 +646,7 @@ export const reprovar = mutation({
|
|||||||
solicitacaoAusenciaId: args.solicitacaoId,
|
solicitacaoAusenciaId: args.solicitacaoId,
|
||||||
tipo: 'reprovado',
|
tipo: 'reprovado',
|
||||||
lida: false,
|
lida: false,
|
||||||
mensagem: `Sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} foi reprovada. Motivo: ${args.motivoReprovacao}`
|
mensagem: `Sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)} foi reprovada. Motivo: ${args.motivoReprovacao}`
|
||||||
});
|
});
|
||||||
|
|
||||||
const gestorUsuario = await ctx.db.get(args.gestorId);
|
const gestorUsuario = await ctx.db.get(args.gestorId);
|
||||||
@@ -664,8 +667,8 @@ export const reprovar = mutation({
|
|||||||
variaveis: {
|
variaveis: {
|
||||||
funcionarioNome: funcionarioUsuario.nome,
|
funcionarioNome: funcionarioUsuario.nome,
|
||||||
gestorNome: gestorUsuario.nome,
|
gestorNome: gestorUsuario.nome,
|
||||||
dataInicio: new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR'),
|
dataInicio: formatarDataBR(solicitacao.dataInicio),
|
||||||
dataFim: new Date(solicitacao.dataFim).toLocaleDateString('pt-BR'),
|
dataFim: formatarDataBR(solicitacao.dataFim),
|
||||||
motivo: solicitacao.motivo,
|
motivo: solicitacao.motivo,
|
||||||
motivoReprovacao: args.motivoReprovacao,
|
motivoReprovacao: args.motivoReprovacao,
|
||||||
urlSistema
|
urlSistema
|
||||||
@@ -685,7 +688,7 @@ export const reprovar = mutation({
|
|||||||
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
|
corpo: `<p>Olá ${funcionarioUsuario.nome},</p>
|
||||||
<p>Sua solicitação de ausência foi <strong>reprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
|
<p>Sua solicitação de ausência foi <strong>reprovada</strong> pelo gestor ${gestorUsuario.nome}:</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Período:</strong> ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}</li>
|
<li><strong>Período:</strong> ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}</li>
|
||||||
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
|
<li><strong>Motivo:</strong> ${solicitacao.motivo}</li>
|
||||||
<li><strong>Motivo da Reprovação:</strong> ${args.motivoReprovacao}</li>
|
<li><strong>Motivo da Reprovação:</strong> ${args.motivoReprovacao}</li>
|
||||||
</ul>`,
|
</ul>`,
|
||||||
@@ -725,7 +728,7 @@ export const reprovar = mutation({
|
|||||||
conversaId,
|
conversaId,
|
||||||
remetenteId: args.gestorId,
|
remetenteId: args.gestorId,
|
||||||
tipo: 'texto',
|
tipo: 'texto',
|
||||||
conteudo: `Reprovei sua solicitação de ausência de ${new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} até ${new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}. Motivo: ${args.motivoReprovacao}`,
|
conteudo: `Reprovei sua solicitação de ausência de ${formatarDataBR(solicitacao.dataInicio)} até ${formatarDataBR(solicitacao.dataFim)}. Motivo: ${args.motivoReprovacao}`,
|
||||||
enviadaEm: Date.now()
|
enviadaEm: Date.now()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,4 +50,12 @@ crons.interval(
|
|||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Verificar e enviar notificações de alertas de banco de horas diariamente às 8h
|
||||||
|
crons.interval(
|
||||||
|
'verificar-alertas-banco-horas',
|
||||||
|
{ hours: 24 },
|
||||||
|
internal.pontos.enviarNotificacoesAlertasBancoHoras,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
export default crons;
|
export default crons;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { v } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
import { mutation, query } from './_generated/server';
|
import { mutation, query, internalMutation } from './_generated/server';
|
||||||
import type { MutationCtx, QueryCtx } from './_generated/server';
|
import type { MutationCtx, QueryCtx } from './_generated/server';
|
||||||
import { getCurrentUserFunction } from './auth';
|
import { getCurrentUserFunction } from './auth';
|
||||||
import type { Id } from './_generated/dataModel';
|
import type { Id } from './_generated/dataModel';
|
||||||
import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao';
|
import { validarLocalizacaoGeofencingInternal } from './enderecosMarcacao';
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcula distância entre duas coordenadas (fórmula de Haversine)
|
* Calcula distância entre duas coordenadas (fórmula de Haversine)
|
||||||
@@ -350,6 +351,91 @@ function calcularStatusPonto(
|
|||||||
return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos;
|
return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida sequência de registros antes de permitir novo registro
|
||||||
|
* Retorna erro se a sequência não for válida
|
||||||
|
*/
|
||||||
|
async function validarSequenciaRegistro(
|
||||||
|
ctx: QueryCtx | MutationCtx,
|
||||||
|
funcionarioId: Id<'funcionarios'>,
|
||||||
|
data: string,
|
||||||
|
tipoEsperado: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida'
|
||||||
|
): Promise<{ valido: boolean; motivo?: string }> {
|
||||||
|
const registrosHoje = await ctx.db
|
||||||
|
.query('registrosPonto')
|
||||||
|
.withIndex('by_funcionario_data', (q) => q.eq('funcionarioId', funcionarioId).eq('data', data))
|
||||||
|
.order('desc')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Se não há registros, só pode ser entrada
|
||||||
|
if (registrosHoje.length === 0) {
|
||||||
|
if (tipoEsperado !== 'entrada') {
|
||||||
|
return {
|
||||||
|
valido: false,
|
||||||
|
motivo: 'Primeiro registro do dia deve ser uma entrada'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { valido: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ultimoRegistro = registrosHoje[0];
|
||||||
|
|
||||||
|
// Validar sequência lógica
|
||||||
|
switch (tipoEsperado) {
|
||||||
|
case 'entrada':
|
||||||
|
// Só pode registrar entrada se o último foi saída (novo dia) ou não há registros
|
||||||
|
if (ultimoRegistro.tipo !== 'saida') {
|
||||||
|
return {
|
||||||
|
valido: false,
|
||||||
|
motivo: `Não é possível registrar entrada. Último registro foi: ${ultimoRegistro.tipo}. Esperado: saída do dia anterior.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'saida_almoco':
|
||||||
|
// Só pode registrar saída almoço se o último foi entrada
|
||||||
|
if (ultimoRegistro.tipo !== 'entrada') {
|
||||||
|
return {
|
||||||
|
valido: false,
|
||||||
|
motivo: `Não é possível registrar saída para almoço. Último registro foi: ${ultimoRegistro.tipo}. Deve registrar entrada primeiro.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'retorno_almoco':
|
||||||
|
// Só pode registrar retorno almoço se o último foi saída almoço
|
||||||
|
if (ultimoRegistro.tipo !== 'saida_almoco') {
|
||||||
|
return {
|
||||||
|
valido: false,
|
||||||
|
motivo: `Não é possível registrar retorno do almoço. Último registro foi: ${ultimoRegistro.tipo}. Deve registrar saída para almoço primeiro.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'saida':
|
||||||
|
// Só pode registrar saída se o último foi retorno almoço ou entrada (sem intervalo)
|
||||||
|
if (ultimoRegistro.tipo !== 'retorno_almoco' && ultimoRegistro.tipo !== 'entrada') {
|
||||||
|
return {
|
||||||
|
valido: false,
|
||||||
|
motivo: `Não é possível registrar saída. Último registro foi: ${ultimoRegistro.tipo}. Deve registrar retorno do almoço ou ter apenas entrada.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Se último foi entrada, verificar se não há saída almoço pendente
|
||||||
|
if (ultimoRegistro.tipo === 'entrada') {
|
||||||
|
const temSaidaAlmoco = registrosHoje.some((r) => r.tipo === 'saida_almoco');
|
||||||
|
if (temSaidaAlmoco) {
|
||||||
|
return {
|
||||||
|
valido: false,
|
||||||
|
motivo: 'Não é possível registrar saída. Há saída para almoço registrada, mas falta o retorno do almoço.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valido: true };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determina o tipo de registro baseado na sequência lógica
|
* Determina o tipo de registro baseado na sequência lógica
|
||||||
*/
|
*/
|
||||||
@@ -566,6 +652,18 @@ export const registrarPonto = mutation({
|
|||||||
// Determinar tipo de registro
|
// Determinar tipo de registro
|
||||||
const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data);
|
const tipo = await determinarTipoRegistro(ctx, usuario.funcionarioId, data);
|
||||||
|
|
||||||
|
// Validar sequência de registros
|
||||||
|
const validacaoSequencia = await validarSequenciaRegistro(
|
||||||
|
ctx,
|
||||||
|
usuario.funcionarioId,
|
||||||
|
data,
|
||||||
|
tipo
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validacaoSequencia.valido) {
|
||||||
|
throw new Error(validacaoSequencia.motivo || 'Sequência de registros inválida');
|
||||||
|
}
|
||||||
|
|
||||||
// Calcular horário esperado e tolerância
|
// Calcular horário esperado e tolerância
|
||||||
let horarioConfigurado = '';
|
let horarioConfigurado = '';
|
||||||
switch (tipo) {
|
switch (tipo) {
|
||||||
@@ -1212,6 +1310,7 @@ function calcularCargaHorariaDiaria(config: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcula horas trabalhadas do dia baseado nos registros
|
* Calcula horas trabalhadas do dia baseado nos registros
|
||||||
|
* Trata casos incompletos de forma mais robusta
|
||||||
*/
|
*/
|
||||||
function calcularHorasTrabalhadas(
|
function calcularHorasTrabalhadas(
|
||||||
registros: Array<{
|
registros: Array<{
|
||||||
@@ -1220,6 +1319,10 @@ function calcularHorasTrabalhadas(
|
|||||||
minuto: number;
|
minuto: number;
|
||||||
}>
|
}>
|
||||||
): number {
|
): number {
|
||||||
|
if (registros.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Ordenar registros por timestamp
|
// Ordenar registros por timestamp
|
||||||
const registrosOrdenados = [...registros].sort((a, b) => {
|
const registrosOrdenados = [...registros].sort((a, b) => {
|
||||||
const minutosA = a.hora * 60 + a.minuto;
|
const minutosA = a.hora * 60 + a.minuto;
|
||||||
@@ -1227,35 +1330,78 @@ function calcularHorasTrabalhadas(
|
|||||||
return minutosA - minutosB;
|
return minutosA - minutosB;
|
||||||
});
|
});
|
||||||
|
|
||||||
let horasTrabalhadas = 0;
|
// Procurar registros principais
|
||||||
|
|
||||||
// Procurar entrada e saída
|
|
||||||
const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada');
|
const entrada = registrosOrdenados.find((r) => r.tipo === 'entrada');
|
||||||
|
const saidaAlmoco = registrosOrdenados.find((r) => r.tipo === 'saida_almoco');
|
||||||
|
const retornoAlmoco = registrosOrdenados.find((r) => r.tipo === 'retorno_almoco');
|
||||||
const saida = registrosOrdenados.find((r) => r.tipo === 'saida');
|
const saida = registrosOrdenados.find((r) => r.tipo === 'saida');
|
||||||
|
|
||||||
|
// Caso 1: Tem entrada e saída completas
|
||||||
if (entrada && saida) {
|
if (entrada && saida) {
|
||||||
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
|
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
|
||||||
const minutosSaida = saida.hora * 60 + saida.minuto;
|
const minutosSaida = saida.hora * 60 + saida.minuto;
|
||||||
|
|
||||||
// Procurar saída e retorno do almoço
|
// Caso 1.1: Tem intervalo de almoço completo (saída almoço + retorno almoço)
|
||||||
const saidaAlmoco = registrosOrdenados.find((r) => r.tipo === 'saida_almoco');
|
|
||||||
const retornoAlmoco = registrosOrdenados.find((r) => r.tipo === 'retorno_almoco');
|
|
||||||
|
|
||||||
if (saidaAlmoco && retornoAlmoco) {
|
if (saidaAlmoco && retornoAlmoco) {
|
||||||
// Tem intervalo de almoço: (saída almoço - entrada) + (saída - retorno almoço)
|
|
||||||
const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto;
|
const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto;
|
||||||
const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto;
|
const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto;
|
||||||
|
|
||||||
const horasManha = minutosSaidaAlmoco - minutosEntrada;
|
// Validar ordem lógica
|
||||||
const horasTarde = minutosSaida - minutosRetornoAlmoco;
|
if (
|
||||||
horasTrabalhadas = horasManha + horasTarde;
|
minutosSaidaAlmoco > minutosEntrada &&
|
||||||
} else {
|
minutosRetornoAlmoco > minutosSaidaAlmoco &&
|
||||||
// Sem intervalo de almoço registrado: saída - entrada
|
minutosSaida > minutosRetornoAlmoco
|
||||||
horasTrabalhadas = minutosSaida - minutosEntrada;
|
) {
|
||||||
|
const horasManha = minutosSaidaAlmoco - minutosEntrada;
|
||||||
|
const horasTarde = minutosSaida - minutosRetornoAlmoco;
|
||||||
|
return horasManha + horasTarde;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso 1.2: Tem apenas saída almoço (sem retorno) - considerar apenas manhã
|
||||||
|
if (saidaAlmoco && !retornoAlmoco) {
|
||||||
|
const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto;
|
||||||
|
if (minutosSaidaAlmoco > minutosEntrada) {
|
||||||
|
return minutosSaidaAlmoco - minutosEntrada;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso 1.3: Tem apenas retorno almoço (sem saída) - considerar apenas tarde
|
||||||
|
if (!saidaAlmoco && retornoAlmoco) {
|
||||||
|
const minutosRetornoAlmoco = retornoAlmoco.hora * 60 + retornoAlmoco.minuto;
|
||||||
|
if (minutosSaida > minutosRetornoAlmoco) {
|
||||||
|
return minutosSaida - minutosRetornoAlmoco;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso 1.4: Sem intervalo de almoço registrado - calcular direto
|
||||||
|
if (!saidaAlmoco && !retornoAlmoco) {
|
||||||
|
if (minutosSaida > minutosEntrada) {
|
||||||
|
return minutosSaida - minutosEntrada;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return horasTrabalhadas;
|
// Caso 2: Tem apenas entrada (sem saída) - retornar 0 (dia incompleto)
|
||||||
|
if (entrada && !saida) {
|
||||||
|
// Se tiver saída almoço, considerar até a saída almoço
|
||||||
|
if (saidaAlmoco) {
|
||||||
|
const minutosEntrada = entrada.hora * 60 + entrada.minuto;
|
||||||
|
const minutosSaidaAlmoco = saidaAlmoco.hora * 60 + saidaAlmoco.minuto;
|
||||||
|
if (minutosSaidaAlmoco > minutosEntrada) {
|
||||||
|
return minutosSaidaAlmoco - minutosEntrada;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso 3: Tem apenas saída (sem entrada) - inconsistência, retornar 0
|
||||||
|
if (saida && !entrada) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso 4: Não tem entrada nem saída - retornar 0
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1316,6 +1462,10 @@ async function atualizarBancoHoras(
|
|||||||
calculadoEm: Date.now()
|
calculadoEm: Date.now()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Atualizar banco de horas mensal
|
||||||
|
const mes = data.substring(0, 7); // YYYY-MM
|
||||||
|
await calcularBancoHorasMensal(ctx, funcionarioId, mes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1433,6 +1583,372 @@ export const obterBancoHorasFuncionario = query({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula e atualiza banco de horas mensal para um funcionário
|
||||||
|
* Esta função deve ser chamada após atualizações no banco de horas diário
|
||||||
|
*/
|
||||||
|
async function calcularBancoHorasMensal(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
funcionarioId: Id<'funcionarios'>,
|
||||||
|
mes: string // YYYY-MM
|
||||||
|
): Promise<void> {
|
||||||
|
// Buscar todos os bancoHoras do mês
|
||||||
|
const dataInicio = `${mes}-01`;
|
||||||
|
// Calcular último dia do mês: criar data do primeiro dia do mês seguinte e subtrair 1 dia
|
||||||
|
const [ano, mesNum] = mes.split('-').map(Number);
|
||||||
|
const ultimoDia = new Date(ano, mesNum, 0).getDate(); // Dia 0 do mês seguinte = último dia do mês atual
|
||||||
|
const dataFim = `${mes}-${String(ultimoDia).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const bancosHorasDoMes = await ctx.db
|
||||||
|
.query('bancoHoras')
|
||||||
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', funcionarioId))
|
||||||
|
.filter((q) => {
|
||||||
|
const data = q.field('data');
|
||||||
|
return q.and(q.gte(data, dataInicio), q.lte(data, dataFim));
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Calcular saldo do mês anterior para obter saldo inicial
|
||||||
|
const mesAnterior = new Date(`${mes}-01`);
|
||||||
|
mesAnterior.setMonth(mesAnterior.getMonth() - 1);
|
||||||
|
const mesAnteriorStr = `${mesAnterior.getFullYear()}-${String(mesAnterior.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const bancoMensalAnterior = await ctx.db
|
||||||
|
.query('bancoHorasMensal')
|
||||||
|
.withIndex('by_funcionario_mes', (q) =>
|
||||||
|
q.eq('funcionarioId', funcionarioId).eq('mes', mesAnteriorStr)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const saldoInicialMinutos = bancoMensalAnterior?.saldoFinalMinutos || 0;
|
||||||
|
|
||||||
|
// Calcular estatísticas do mês
|
||||||
|
const diasTrabalhados = bancosHorasDoMes.length;
|
||||||
|
const saldoMesMinutos = bancosHorasDoMes.reduce((acc, bh) => acc + bh.saldoMinutos, 0);
|
||||||
|
const saldoFinalMinutos = saldoInicialMinutos + saldoMesMinutos;
|
||||||
|
|
||||||
|
// Separar horas extras e déficit
|
||||||
|
const horasExtras = bancosHorasDoMes
|
||||||
|
.filter((bh) => bh.saldoMinutos > 0)
|
||||||
|
.reduce((acc, bh) => acc + bh.saldoMinutos, 0);
|
||||||
|
const horasDeficit = Math.abs(
|
||||||
|
bancosHorasDoMes
|
||||||
|
.filter((bh) => bh.saldoMinutos < 0)
|
||||||
|
.reduce((acc, bh) => acc + bh.saldoMinutos, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const agora = Date.now();
|
||||||
|
|
||||||
|
// Buscar ou criar registro mensal
|
||||||
|
const bancoMensalExistente = await ctx.db
|
||||||
|
.query('bancoHorasMensal')
|
||||||
|
.withIndex('by_funcionario_mes', (q) =>
|
||||||
|
q.eq('funcionarioId', funcionarioId).eq('mes', mes)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (bancoMensalExistente) {
|
||||||
|
// Atualizar existente
|
||||||
|
await ctx.db.patch(bancoMensalExistente._id, {
|
||||||
|
saldoInicialMinutos,
|
||||||
|
saldoFinalMinutos,
|
||||||
|
saldoMesMinutos,
|
||||||
|
diasTrabalhados,
|
||||||
|
horasExtras,
|
||||||
|
horasDeficit,
|
||||||
|
atualizadoEm: agora
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Criar novo
|
||||||
|
await ctx.db.insert('bancoHorasMensal', {
|
||||||
|
funcionarioId,
|
||||||
|
mes,
|
||||||
|
saldoInicialMinutos,
|
||||||
|
saldoFinalMinutos,
|
||||||
|
saldoMesMinutos,
|
||||||
|
diasTrabalhados,
|
||||||
|
horasExtras,
|
||||||
|
horasDeficit,
|
||||||
|
calculadoEm: agora,
|
||||||
|
atualizadoEm: agora
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém banco de horas mensal de um funcionário
|
||||||
|
*/
|
||||||
|
export const obterBancoHorasMensal = query({
|
||||||
|
args: {
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
mes: v.string() // YYYY-MM
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é o próprio funcionário ou tem permissão
|
||||||
|
if (usuario.funcionarioId !== args.funcionarioId) {
|
||||||
|
// TODO: Verificar permissão de RH
|
||||||
|
}
|
||||||
|
|
||||||
|
const bancoMensal = await ctx.db
|
||||||
|
.query('bancoHorasMensal')
|
||||||
|
.withIndex('by_funcionario_mes', (q) =>
|
||||||
|
q.eq('funcionarioId', args.funcionarioId).eq('mes', args.mes)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!bancoMensal) {
|
||||||
|
// Retornar valores zerados se não existe
|
||||||
|
return {
|
||||||
|
mes: args.mes,
|
||||||
|
saldoInicialMinutos: 0,
|
||||||
|
saldoFinalMinutos: 0,
|
||||||
|
saldoMesMinutos: 0,
|
||||||
|
diasTrabalhados: 0,
|
||||||
|
horasExtras: 0,
|
||||||
|
horasDeficit: 0,
|
||||||
|
saldoFormatado: {
|
||||||
|
inicial: { horas: 0, minutos: 0, positivo: true },
|
||||||
|
final: { horas: 0, minutos: 0, positivo: true },
|
||||||
|
mes: { horas: 0, minutos: 0, positivo: true }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatar valores
|
||||||
|
const formatarSaldo = (minutos: number) => {
|
||||||
|
const horas = Math.floor(Math.abs(minutos) / 60);
|
||||||
|
const mins = Math.abs(minutos) % 60;
|
||||||
|
return { horas, minutos: mins, positivo: minutos >= 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...bancoMensal,
|
||||||
|
saldoFormatado: {
|
||||||
|
inicial: formatarSaldo(bancoMensal.saldoInicialMinutos),
|
||||||
|
final: formatarSaldo(bancoMensal.saldoFinalMinutos),
|
||||||
|
mes: formatarSaldo(bancoMensal.saldoMesMinutos)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista histórico mensal de banco de horas de um funcionário
|
||||||
|
*/
|
||||||
|
export const listarHistoricoMensal = query({
|
||||||
|
args: {
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
mesInicio: v.optional(v.string()), // YYYY-MM
|
||||||
|
mesFim: v.optional(v.string()) // YYYY-MM
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é o próprio funcionário ou tem permissão
|
||||||
|
if (usuario.funcionarioId !== args.funcionarioId) {
|
||||||
|
// TODO: Verificar permissão de RH
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = ctx.db
|
||||||
|
.query('bancoHorasMensal')
|
||||||
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId));
|
||||||
|
|
||||||
|
// Filtrar por período se fornecido
|
||||||
|
if (args.mesInicio || args.mesFim) {
|
||||||
|
query = query.filter((q) => {
|
||||||
|
const mes = q.field('mes');
|
||||||
|
if (args.mesInicio && args.mesFim) {
|
||||||
|
return q.and(q.gte(mes, args.mesInicio), q.lte(mes, args.mesFim));
|
||||||
|
} else if (args.mesInicio) {
|
||||||
|
return q.gte(mes, args.mesInicio);
|
||||||
|
} else if (args.mesFim) {
|
||||||
|
return q.lte(mes, args.mesFim);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bancosMensais = await query.order('desc').collect();
|
||||||
|
|
||||||
|
// Formatar valores
|
||||||
|
const formatarSaldo = (minutos: number) => {
|
||||||
|
const horas = Math.floor(Math.abs(minutos) / 60);
|
||||||
|
const mins = Math.abs(minutos) % 60;
|
||||||
|
return { horas, minutos: mins, positivo: minutos >= 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
return bancosMensais.map((bm) => ({
|
||||||
|
...bm,
|
||||||
|
saldoFormatado: {
|
||||||
|
inicial: formatarSaldo(bm.saldoInicialMinutos),
|
||||||
|
final: formatarSaldo(bm.saldoFinalMinutos),
|
||||||
|
mes: formatarSaldo(bm.saldoMesMinutos),
|
||||||
|
extras: formatarSaldo(bm.horasExtras),
|
||||||
|
deficit: formatarSaldo(-bm.horasDeficit)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envia notificações push para alertas de banco de horas
|
||||||
|
* Esta função deve ser chamada periodicamente (via cron ou scheduler)
|
||||||
|
*/
|
||||||
|
export const enviarNotificacoesAlertasBancoHoras = internalMutation({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
// Buscar todos os funcionários ativos (sem data de desligamento)
|
||||||
|
const todosFuncionarios = await ctx.db.query('funcionarios').collect();
|
||||||
|
const funcionarios = todosFuncionarios.filter((f) => !f.desligamentoData);
|
||||||
|
|
||||||
|
let notificacoesEnviadas = 0;
|
||||||
|
|
||||||
|
for (const funcionario of funcionarios) {
|
||||||
|
// Buscar usuário associado
|
||||||
|
const usuario = await ctx.db
|
||||||
|
.query('usuarios')
|
||||||
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!usuario) continue;
|
||||||
|
|
||||||
|
// Verificar alertas
|
||||||
|
const hoje = new Date();
|
||||||
|
const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const bancoMensal = await ctx.db
|
||||||
|
.query('bancoHorasMensal')
|
||||||
|
.withIndex('by_funcionario_mes', (q) =>
|
||||||
|
q.eq('funcionarioId', funcionario._id).eq('mes', mesAtual)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (bancoMensal && bancoMensal.saldoFinalMinutos < 0) {
|
||||||
|
const horasNegativas = Math.abs(bancoMensal.saldoFinalMinutos) / 60;
|
||||||
|
const minutosNegativos = Math.abs(bancoMensal.saldoFinalMinutos) % 60;
|
||||||
|
|
||||||
|
// Enviar notificação apenas se saldo negativo for significativo (> 1 hora)
|
||||||
|
if (horasNegativas >= 1) {
|
||||||
|
const titulo = horasNegativas > 8
|
||||||
|
? '⚠️ Alerta Crítico: Saldo Negativo de Banco de Horas'
|
||||||
|
: '⚠️ Atenção: Saldo Negativo de Banco de Horas';
|
||||||
|
const corpo = `Seu saldo acumulado está negativo em ${Math.floor(horasNegativas)}h ${minutosNegativos}min. Considere compensar horas ou entrar em contato com seu gestor.`;
|
||||||
|
|
||||||
|
// Enviar push notification
|
||||||
|
await ctx.scheduler.runAfter(0, internal.pushNotifications.enviarPushNotification, {
|
||||||
|
usuarioId: usuario._id,
|
||||||
|
titulo,
|
||||||
|
corpo,
|
||||||
|
data: {
|
||||||
|
tipo: 'banco_horas_alerta'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
notificacoesEnviadas++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { notificacoesEnviadas };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica alertas de banco de horas (saldo negativo, etc)
|
||||||
|
*/
|
||||||
|
export const verificarAlertasBancoHoras = query({
|
||||||
|
args: {
|
||||||
|
funcionarioId: v.id('funcionarios')
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é o próprio funcionário ou tem permissão
|
||||||
|
if (usuario.funcionarioId !== args.funcionarioId) {
|
||||||
|
// TODO: Verificar permissão de RH
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar banco de horas mensal mais recente
|
||||||
|
const hoje = new Date();
|
||||||
|
const mesAtual = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const bancoMensal = await ctx.db
|
||||||
|
.query('bancoHorasMensal')
|
||||||
|
.withIndex('by_funcionario_mes', (q) =>
|
||||||
|
q.eq('funcionarioId', args.funcionarioId).eq('mes', mesAtual)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const alertas: Array<{
|
||||||
|
tipo: 'saldo_negativo' | 'saldo_negativo_critico' | 'dias_sem_registro';
|
||||||
|
severidade: 'warning' | 'error';
|
||||||
|
mensagem: string;
|
||||||
|
valor?: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (bancoMensal) {
|
||||||
|
// Alerta 1: Saldo negativo acumulado
|
||||||
|
if (bancoMensal.saldoFinalMinutos < 0) {
|
||||||
|
const horasNegativas = Math.abs(bancoMensal.saldoFinalMinutos) / 60;
|
||||||
|
alertas.push({
|
||||||
|
tipo: horasNegativas > 8 ? 'saldo_negativo_critico' : 'saldo_negativo',
|
||||||
|
severidade: horasNegativas > 8 ? 'error' : 'warning',
|
||||||
|
mensagem: `Saldo negativo acumulado de ${Math.floor(Math.abs(bancoMensal.saldoFinalMinutos) / 60)}h ${Math.abs(bancoMensal.saldoFinalMinutos) % 60}min`,
|
||||||
|
valor: bancoMensal.saldoFinalMinutos
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar dias sem registro nos últimos 7 dias
|
||||||
|
const ultimos7Dias: string[] = [];
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const data = new Date();
|
||||||
|
data.setDate(data.getDate() - i);
|
||||||
|
ultimos7Dias.push(data.toISOString().split('T')[0]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrosRecentes = await ctx.db
|
||||||
|
.query('bancoHoras')
|
||||||
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
|
||||||
|
.filter((q) => {
|
||||||
|
const data = q.field('data');
|
||||||
|
return q.or(
|
||||||
|
...ultimos7Dias.map((dia) => q.eq(data, dia))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const diasComRegistro = new Set(registrosRecentes.map((r) => r.data));
|
||||||
|
const diasSemRegistro = ultimos7Dias.filter((dia) => !diasComRegistro.has(dia));
|
||||||
|
|
||||||
|
if (diasSemRegistro.length >= 3) {
|
||||||
|
alertas.push({
|
||||||
|
tipo: 'dias_sem_registro',
|
||||||
|
severidade: 'warning',
|
||||||
|
mensagem: `${diasSemRegistro.length} dias sem registro de ponto nos últimos 7 dias`,
|
||||||
|
valor: diasSemRegistro.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
alertas,
|
||||||
|
temAlertas: alertas.length > 0,
|
||||||
|
bancoMensalAtual: bancoMensal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: Verificar se usuário é gestor do funcionário
|
* Helper: Verificar se usuário é gestor do funcionário
|
||||||
*/
|
*/
|
||||||
@@ -1518,7 +2034,7 @@ export const editarRegistroPonto = mutation({
|
|||||||
homologacaoId
|
homologacaoId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recalcular banco de horas do dia
|
// Recalcular banco de horas do dia (isso já atualiza o mensal automaticamente)
|
||||||
const config = await ctx.db
|
const config = await ctx.db
|
||||||
.query('configuracaoPonto')
|
.query('configuracaoPonto')
|
||||||
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
||||||
@@ -1606,6 +2122,10 @@ export const ajustarBancoHoras = mutation({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recalcular banco de horas mensal após ajuste
|
||||||
|
const mes = hoje.substring(0, 7); // YYYY-MM
|
||||||
|
await calcularBancoHorasMensal(ctx, args.funcionarioId, mes);
|
||||||
|
|
||||||
// Criar registro de homologação
|
// Criar registro de homologação
|
||||||
const homologacaoId = await ctx.db.insert('homologacoesPonto', {
|
const homologacaoId = await ctx.db.insert('homologacoesPonto', {
|
||||||
funcionarioId: args.funcionarioId,
|
funcionarioId: args.funcionarioId,
|
||||||
@@ -1978,6 +2498,163 @@ export const listarDispensas = query({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém estatísticas gerenciais do banco de horas para RH
|
||||||
|
*/
|
||||||
|
export const obterEstatisticasBancoHorasGerencial = query({
|
||||||
|
args: {
|
||||||
|
mes: v.string(), // YYYY-MM
|
||||||
|
funcionarioId: v.optional(v.id('funcionarios'))
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Verificar permissão de RH/TI
|
||||||
|
|
||||||
|
// Buscar todos os bancos de horas do mês
|
||||||
|
let bancosMensais = await ctx.db
|
||||||
|
.query('bancoHorasMensal')
|
||||||
|
.withIndex('by_mes', (q) => q.eq('mes', args.mes))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Filtrar por funcionário se fornecido
|
||||||
|
if (args.funcionarioId) {
|
||||||
|
bancosMensais = bancosMensais.filter((b) => b.funcionarioId === args.funcionarioId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar informações dos funcionários
|
||||||
|
const funcionariosComDetalhes = await Promise.all(
|
||||||
|
bancosMensais.map(async (banco) => {
|
||||||
|
const funcionario = await ctx.db.get(banco.funcionarioId);
|
||||||
|
return {
|
||||||
|
...banco,
|
||||||
|
funcionario: funcionario
|
||||||
|
? {
|
||||||
|
nome: funcionario.nome,
|
||||||
|
matricula: funcionario.matricula
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calcular estatísticas gerais
|
||||||
|
const totalFuncionarios = funcionariosComDetalhes.length;
|
||||||
|
const funcionariosPositivos = funcionariosComDetalhes.filter(
|
||||||
|
(f) => f.saldoFinalMinutos >= 0
|
||||||
|
).length;
|
||||||
|
const funcionariosNegativos = totalFuncionarios - funcionariosPositivos;
|
||||||
|
const totalHorasExtras = funcionariosComDetalhes.reduce(
|
||||||
|
(acc, f) => acc + f.horasExtras,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalDeficit = funcionariosComDetalhes.reduce((acc, f) => acc + f.horasDeficit, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mes: args.mes,
|
||||||
|
totalFuncionarios,
|
||||||
|
funcionariosPositivos,
|
||||||
|
funcionariosNegativos,
|
||||||
|
totalHorasExtras,
|
||||||
|
totalDeficit,
|
||||||
|
funcionarios: funcionariosComDetalhes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista histórico de alterações no banco de horas (homologações e ajustes)
|
||||||
|
*/
|
||||||
|
export const listarHistoricoAlteracoesBancoHoras = query({
|
||||||
|
args: {
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
mes: v.optional(v.string()) // YYYY-MM - se fornecido, filtra por mês
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é o próprio funcionário ou tem permissão
|
||||||
|
if (usuario.funcionarioId !== args.funcionarioId) {
|
||||||
|
// TODO: Verificar permissão de RH
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar homologações do funcionário
|
||||||
|
let homologacoes = await ctx.db
|
||||||
|
.query('homologacoesPonto')
|
||||||
|
.withIndex('by_funcionario', (q) => q.eq('funcionarioId', args.funcionarioId))
|
||||||
|
.order('desc')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Filtrar por mês se fornecido
|
||||||
|
if (args.mes) {
|
||||||
|
const mesHomologacao = args.mes;
|
||||||
|
homologacoes = homologacoes.filter((h) => {
|
||||||
|
const dataHomologacao = new Date(h.criadoEm);
|
||||||
|
const mesHomologacaoStr = `${dataHomologacao.getFullYear()}-${String(dataHomologacao.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
return mesHomologacaoStr === mesHomologacao;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar informações adicionais
|
||||||
|
const historicoComDetalhes = await Promise.all(
|
||||||
|
homologacoes.map(async (h) => {
|
||||||
|
const gestor = await ctx.db.get(h.gestorId);
|
||||||
|
const registro = h.registroId ? await ctx.db.get(h.registroId) : null;
|
||||||
|
|
||||||
|
// Determinar tipo de alteração
|
||||||
|
let tipoAlteracao: 'edicao_registro' | 'ajuste_banco' | 'outro' = 'outro';
|
||||||
|
if (h.registroId && h.horaAnterior !== undefined) {
|
||||||
|
tipoAlteracao = 'edicao_registro';
|
||||||
|
} else if (h.tipoAjuste) {
|
||||||
|
tipoAlteracao = 'ajuste_banco';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular diferença em minutos (se for edição de registro)
|
||||||
|
let diferencaMinutos: number | undefined = undefined;
|
||||||
|
if (h.horaAnterior !== undefined && h.horaNova !== undefined) {
|
||||||
|
const minutosAnterior = h.horaAnterior * 60 + (h.minutoAnterior || 0);
|
||||||
|
const minutosNovo = h.horaNova * 60 + (h.minutoNova || 0);
|
||||||
|
diferencaMinutos = minutosNovo - minutosAnterior;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
tipoAlteracao,
|
||||||
|
diferencaMinutos,
|
||||||
|
gestor: gestor
|
||||||
|
? {
|
||||||
|
nome: gestor.nome
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
registro: registro
|
||||||
|
? {
|
||||||
|
data: registro.data,
|
||||||
|
tipo: registro.tipo,
|
||||||
|
horaAnterior: `${String(h.horaAnterior || 0).padStart(2, '0')}:${String(h.minutoAnterior || 0).padStart(2, '0')}`,
|
||||||
|
horaNova: `${String(h.horaNova || 0).padStart(2, '0')}:${String(h.minutoNova || 0).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
dataFormatada: new Date(h.criadoEm).toLocaleDateString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return historicoComDetalhes;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica se funcionário está dispensado de registrar ponto em uma data/hora específica
|
* Verifica se funcionário está dispensado de registrar ponto em uma data/hora específica
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -210,6 +210,23 @@ export const pontoTables = {
|
|||||||
.index('by_funcionario', ['funcionarioId'])
|
.index('by_funcionario', ['funcionarioId'])
|
||||||
.index('by_data', ['data']),
|
.index('by_data', ['data']),
|
||||||
|
|
||||||
|
// Banco de Horas Mensal - Agregação mensal do banco de horas
|
||||||
|
bancoHorasMensal: defineTable({
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
mes: v.string(), // YYYY-MM (ex: "2024-01")
|
||||||
|
saldoInicialMinutos: v.number(), // Saldo acumulado do mês anterior (pode ser negativo)
|
||||||
|
saldoFinalMinutos: v.number(), // Saldo acumulado ao final do mês
|
||||||
|
saldoMesMinutos: v.number(), // Saldo apenas do mês atual (sem acumular)
|
||||||
|
diasTrabalhados: v.number(), // Quantidade de dias com registros no mês
|
||||||
|
horasExtras: v.number(), // Total de minutos positivos do mês
|
||||||
|
horasDeficit: v.number(), // Total de minutos negativos do mês (valor absoluto)
|
||||||
|
calculadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_funcionario_mes', ['funcionarioId', 'mes'])
|
||||||
|
.index('by_funcionario', ['funcionarioId'])
|
||||||
|
.index('by_mes', ['mes']),
|
||||||
|
|
||||||
// Homologações de Ponto - Edições e ajustes realizados pelo gestor
|
// Homologações de Ponto - Edições e ajustes realizados pelo gestor
|
||||||
homologacoesPonto: defineTable({
|
homologacoesPonto: defineTable({
|
||||||
registroId: v.optional(v.id('registrosPonto')), // ID do registro editado (se for edição)
|
registroId: v.optional(v.id('registrosPonto')), // ID do registro editado (se for edição)
|
||||||
|
|||||||
65
packages/backend/convex/utils/datas.ts
Normal file
65
packages/backend/convex/utils/datas.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Utilitários para manipulação de datas no backend
|
||||||
|
* 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
|
||||||
|
* No ambiente Convex, as datas são tratadas como UTC, então precisamos garantir
|
||||||
|
* que a data seja interpretada corretamente.
|
||||||
|
*
|
||||||
|
* @param dateString - String no formato YYYY-MM-DD
|
||||||
|
* @returns Date objeto representando a data
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parseLocalDate('2024-01-15') // Retorna Date para 15/01/2024
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
|
||||||
|
// No Convex, criar a data usando UTC para evitar problemas de timezone
|
||||||
|
// Usamos UTC para garantir consistência, mas mantemos a data correta
|
||||||
|
const date = new Date(Date.UTC(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usar UTC para garantir consistência
|
||||||
|
const day = dateObj.getUTCDate().toString().padStart(2, '0');
|
||||||
|
const month = (dateObj.getUTCMonth() + 1).toString().padStart(2, '0');
|
||||||
|
const year = dateObj.getUTCFullYear();
|
||||||
|
|
||||||
|
return `${day}/${month}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user