feat: enhance Banco de Horas management with new reporting features, including adjustments and inconsistencies tracking, advanced filters, and Excel export functionality

This commit is contained in:
2025-12-06 09:32:55 -03:00
parent 72450d1f28
commit aec3201410
14 changed files with 4730 additions and 22 deletions

View File

@@ -15,10 +15,16 @@
Edit,
Settings,
Download,
FileText
FileText,
CheckCircle2,
XCircle,
FileCheck,
FileX,
AlertCircle
} from 'lucide-svelte';
import LineChart from '$lib/components/ti/charts/LineChart.svelte';
import jsPDF from 'jspdf';
import * as ExcelJS from 'exceljs';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
interface Props {
@@ -56,9 +62,35 @@
mes: mesSelecionado
});
// Query para ajustes do banco de horas
const ajustesQuery = useQuery(api.pontos.listarAjustesBancoHoras, {
funcionarioId,
dataInicio: `${mesSelecionado}-01`,
dataFim: (() => {
const data = new Date(mesSelecionado + '-01');
data.setMonth(data.getMonth() + 1);
data.setDate(0); // Último dia do mês
return data.toISOString().split('T')[0]!;
})()
});
// Query para inconsistências
const inconsistenciasQuery = useQuery(api.pontos.verificarInconsistencias, {
funcionarioId,
dataInicio: `${mesSelecionado}-01`,
dataFim: (() => {
const data = new Date(mesSelecionado + '-01');
data.setMonth(data.getMonth() + 1);
data.setDate(0); // Último dia do mês
return data.toISOString().split('T')[0]!;
})()
});
const bancoMensal = $derived(bancoMensalQuery?.data);
const historico = $derived(historicoQuery?.data || []);
const historicoAlteracoes = $derived(historicoAlteracoesQuery?.data || []);
const ajustes = $derived(ajustesQuery?.data || []);
const inconsistencias = $derived(inconsistenciasQuery?.data || []);
// Dados para o gráfico de evolução
const chartData = $derived(() => {
@@ -185,6 +217,24 @@
yPosition += 6;
doc.text(`Déficit: ${formatarMinutos(-bancoMensal.horasDeficit)}`, 20, yPosition);
// Adicionar informações de ajustes se disponíveis
if (bancoMensal.totalAjustes !== undefined && bancoMensal.totalAjustes > 0) {
yPosition += 6;
doc.text(`Total de Ajustes: ${formatarMinutos(bancoMensal.totalAjustes)}`, 20, yPosition);
}
if (bancoMensal.totalAbonos !== undefined && bancoMensal.totalAbonos > 0) {
yPosition += 6;
doc.text(`Total de Abonos: ${formatarMinutos(bancoMensal.totalAbonos)}`, 20, yPosition);
}
if (bancoMensal.totalDescontos !== undefined && bancoMensal.totalDescontos > 0) {
yPosition += 6;
doc.text(`Total de Descontos: ${formatarMinutos(-bancoMensal.totalDescontos)}`, 20, yPosition);
}
if (bancoMensal.inconsistenciasResolvidas !== undefined) {
yPosition += 6;
doc.text(`Inconsistências Resolvidas: ${bancoMensal.inconsistenciasResolvidas}`, 20, yPosition);
}
yPosition += 15;
// Histórico dos Últimos 6 Meses
@@ -226,6 +276,130 @@
}
}
// Ajustes Aplicados
if (ajustes && ajustes.length > 0) {
yPosition += 10;
if (yPosition > 250) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(14);
doc.setTextColor(41, 128, 185);
doc.text('Ajustes Aplicados', 15, yPosition);
yPosition += 8;
// Cabeçalho da tabela
doc.setFontSize(9);
doc.setTextColor(100, 100, 100);
doc.text('Data', 20, yPosition);
doc.text('Tipo', 50, yPosition);
doc.text('Motivo', 80, yPosition);
doc.text('Valor', 150, yPosition);
yPosition += 6;
// Linhas da tabela
doc.setFontSize(8);
doc.setTextColor(0, 0, 0);
for (const ajuste of ajustes.slice(0, 10)) {
if (yPosition > 250) {
doc.addPage();
yPosition = 20;
}
doc.text(
new Date(ajuste.dataAplicacao).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit'
}),
20,
yPosition
);
doc.text(
ajuste.tipo === 'abonar' ? 'Abonar' : ajuste.tipo === 'descontar' ? 'Descontar' : 'Compensar',
50,
yPosition
);
doc.text(
(ajuste.motivoDescricao || ajuste.motivoTipo || 'N/A').substring(0, 30),
80,
yPosition
);
doc.text(formatarMinutos(ajuste.valorMinutos), 150, yPosition);
yPosition += 6;
}
}
// Inconsistências
if (inconsistencias && inconsistencias.length > 0) {
yPosition += 10;
if (yPosition > 250) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(14);
doc.setTextColor(255, 152, 0);
doc.text('Inconsistências Detectadas', 15, yPosition);
yPosition += 8;
// Cabeçalho da tabela
doc.setFontSize(9);
doc.setTextColor(100, 100, 100);
doc.text('Data', 20, yPosition);
doc.text('Tipo', 60, yPosition);
doc.text('Status', 120, yPosition);
yPosition += 6;
// Linhas da tabela
doc.setFontSize(8);
doc.setTextColor(0, 0, 0);
for (const inconsistencia of inconsistencias.slice(0, 10)) {
if (yPosition > 250) {
doc.addPage();
yPosition = 20;
}
doc.text(
new Date(inconsistencia.dataDetectada).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit'
}),
20,
yPosition
);
doc.text(
inconsistencia.tipo === 'ponto_com_atestado'
? 'Ponto + Atestado'
: inconsistencia.tipo === 'ponto_com_licenca'
? 'Ponto + Licença'
: inconsistencia.tipo === 'ponto_com_ausencia'
? 'Ponto + Ausência'
: inconsistencia.tipo,
60,
yPosition
);
doc.text(
inconsistencia.status === 'resolvida'
? 'Resolvida'
: inconsistencia.status === 'ignorada'
? 'Ignorada'
: 'Pendente',
120,
yPosition
);
yPosition += 6;
}
}
// Rodapé
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
@@ -250,6 +424,140 @@
// Salvar PDF
doc.save(`banco-horas-${mesSelecionado}.pdf`);
}
// Função para exportar relatório em Excel
async function exportarExcel() {
if (!bancoMensal || !historico) return;
const workbook = new ExcelJS.Workbook();
workbook.creator = 'SGSE - Sistema de Gerenciamento';
workbook.created = new Date();
// Planilha 1: Resumo Mensal
const resumoSheet = workbook.addWorksheet('Resumo Mensal');
resumoSheet.columns = [
{ header: 'Item', key: 'item', width: 30 },
{ header: 'Valor', key: 'valor', width: 20 }
];
resumoSheet.addRow({ item: 'Mês de Referência', valor: formatarMes(mesSelecionado) });
resumoSheet.addRow({ item: 'Saldo Inicial', valor: `${Math.floor(bancoMensal.saldoInicialMinutos / 60)}h ${Math.abs(bancoMensal.saldoInicialMinutos) % 60}min` });
resumoSheet.addRow({ item: 'Saldo do Mês', valor: `${Math.floor(bancoMensal.saldoMesMinutos / 60)}h ${Math.abs(bancoMensal.saldoMesMinutos) % 60}min` });
resumoSheet.addRow({ item: 'Saldo Final', valor: `${Math.floor(bancoMensal.saldoFinalMinutos / 60)}h ${Math.abs(bancoMensal.saldoFinalMinutos) % 60}min` });
resumoSheet.addRow({ item: 'Dias Trabalhados', valor: `${bancoMensal.diasTrabalhados} dias` });
resumoSheet.addRow({ item: 'Horas Extras', valor: `${Math.floor(bancoMensal.horasExtras / 60)}h ${bancoMensal.horasExtras % 60}min` });
resumoSheet.addRow({ item: 'Déficit', valor: `${Math.floor(bancoMensal.horasDeficit / 60)}h ${bancoMensal.horasDeficit % 60}min` });
if (bancoMensal.totalAjustes !== undefined) {
resumoSheet.addRow({ item: 'Total de Ajustes', valor: `${Math.floor(bancoMensal.totalAjustes / 60)}h ${bancoMensal.totalAjustes % 60}min` });
}
if (bancoMensal.totalAbonos !== undefined) {
resumoSheet.addRow({ item: 'Total de Abonos', valor: `${Math.floor(bancoMensal.totalAbonos / 60)}h ${bancoMensal.totalAbonos % 60}min` });
}
if (bancoMensal.totalDescontos !== undefined) {
resumoSheet.addRow({ item: 'Total de Descontos', valor: `${Math.floor(bancoMensal.totalDescontos / 60)}h ${bancoMensal.totalDescontos % 60}min` });
}
// Planilha 2: Histórico
if (historico.length > 0) {
const historicoSheet = workbook.addWorksheet('Histórico');
historicoSheet.columns = [
{ header: 'Mês', key: 'mes', width: 20 },
{ header: 'Saldo Inicial', key: 'saldoInicial', width: 15 },
{ header: 'Saldo do Mês', key: 'saldoMes', width: 15 },
{ header: 'Saldo Final', key: 'saldoFinal', width: 15 },
{ header: 'Dias Trabalhados', key: 'dias', width: 15 }
];
historico.forEach((item) => {
historicoSheet.addRow({
mes: formatarMes(item.mes),
saldoInicial: `${Math.floor(item.saldoInicialMinutos / 60)}h ${Math.abs(item.saldoInicialMinutos) % 60}min`,
saldoMes: `${Math.floor(item.saldoMesMinutos / 60)}h ${Math.abs(item.saldoMesMinutos) % 60}min`,
saldoFinal: `${Math.floor(item.saldoFinalMinutos / 60)}h ${Math.abs(item.saldoFinalMinutos) % 60}min`,
dias: item.diasTrabalhados
});
});
// Formatação condicional para saldo final
historicoSheet.getColumn('saldoFinal').eachCell((cell, rowNumber) => {
if (rowNumber > 1) {
const item = historico[rowNumber - 2];
if (item && item.saldoFinalMinutos < 0) {
cell.font = { color: { argb: 'FFFF0000' } };
} else if (item && item.saldoFinalMinutos > 0) {
cell.font = { color: { argb: 'FF00FF00' } };
}
}
});
}
// Planilha 3: Ajustes
if (ajustes && ajustes.length > 0) {
const ajustesSheet = workbook.addWorksheet('Ajustes');
ajustesSheet.columns = [
{ header: 'Data', key: 'data', width: 15 },
{ header: 'Tipo', key: 'tipo', width: 15 },
{ header: 'Motivo', key: 'motivo', width: 30 },
{ header: 'Valor', key: 'valor', width: 15 },
{ header: 'Gestor', key: 'gestor', width: 25 },
{ header: 'Status', key: 'status', width: 15 }
];
ajustes.forEach((ajuste) => {
ajustesSheet.addRow({
data: new Date(ajuste.dataAplicacao).toLocaleDateString('pt-BR'),
tipo: ajuste.tipo === 'abonar' ? 'Abonar' : ajuste.tipo === 'descontar' ? 'Descontar' : 'Compensar',
motivo: ajuste.motivoDescricao || ajuste.motivoTipo || 'N/A',
valor: `${Math.floor(Math.abs(ajuste.valorMinutos) / 60)}h ${Math.abs(ajuste.valorMinutos) % 60}min`,
gestor: ajuste.gestor?.nome || 'Sistema',
status: ajuste.aplicado ? 'Aplicado' : 'Pendente'
});
});
}
// Planilha 4: Inconsistências
if (inconsistencias && inconsistencias.length > 0) {
const inconsistenciasSheet = workbook.addWorksheet('Inconsistências');
inconsistenciasSheet.columns = [
{ header: 'Data Detectada', key: 'data', width: 15 },
{ header: 'Tipo', key: 'tipo', width: 25 },
{ header: 'Descrição', key: 'descricao', width: 40 },
{ header: 'Status', key: 'status', width: 15 }
];
inconsistencias.forEach((inconsistencia) => {
inconsistenciasSheet.addRow({
data: new Date(inconsistencia.dataDetectada).toLocaleDateString('pt-BR'),
tipo: inconsistencia.tipo === 'ponto_com_atestado'
? 'Ponto + Atestado'
: inconsistencia.tipo === 'ponto_com_licenca'
? 'Ponto + Licença'
: inconsistencia.tipo === 'ponto_com_ausencia'
? 'Ponto + Ausência'
: inconsistencia.tipo,
descricao: inconsistencia.descricao,
status: inconsistencia.status === 'resolvida'
? 'Resolvida'
: inconsistencia.status === 'ignorada'
? 'Ignorada'
: 'Pendente'
});
});
}
// Salvar arquivo
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `banco-horas-${mesSelecionado}.xlsx`;
link.click();
window.URL.revokeObjectURL(url);
}
</script>
<div class="space-y-6">
@@ -267,6 +575,16 @@
<Download class="h-4 w-4" strokeWidth={2} />
Exportar PDF
</button>
<button
type="button"
class="btn btn-sm btn-success"
onclick={exportarExcel}
disabled={!bancoMensal}
title="Exportar relatório em Excel"
>
<FileText class="h-4 w-4" strokeWidth={2} />
Exportar Excel
</button>
<div class="flex items-center gap-2">
<button
type="button"
@@ -493,6 +811,226 @@
</div>
</div>
</div>
<!-- Ajustes Aplicados -->
{#if ajustes && ajustes.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">
<FileCheck class="h-5 w-5" strokeWidth={2} />
Ajustes Aplicados - {formatarMes(mesSelecionado)}
</h3>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Data</th>
<th>Tipo</th>
<th>Motivo</th>
<th class="text-right">Valor</th>
<th>Gestor</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each ajustes as ajuste}
<tr>
<td>
{new Date(ajuste.dataAplicacao).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</td>
<td>
<span
class={`badge ${
ajuste.tipo === 'abonar'
? 'badge-success'
: ajuste.tipo === 'descontar'
? 'badge-error'
: 'badge-info'
}`}
>
{ajuste.tipo === 'abonar'
? 'Abonar'
: ajuste.tipo === 'descontar'
? 'Descontar'
: 'Compensar'}
</span>
</td>
<td>
<div class="flex flex-col">
<span class="font-medium">
{ajuste.motivoTipo === 'atestado'
? 'Atestado Médico'
: ajuste.motivoTipo === 'licenca'
? 'Licença'
: ajuste.motivoTipo === 'ausencia'
? 'Ausência'
: 'Manual'}
</span>
{#if ajuste.motivoDescricao}
<span class="text-xs text-base-content/60">
{ajuste.motivoDescricao}
</span>
{/if}
</div>
</td>
<td class="text-right">
<span
class={ajuste.valorMinutos >= 0 ? 'text-success' : 'text-error'}
>
{ajuste.valorMinutos >= 0 ? '+' : ''}
{Math.floor(Math.abs(ajuste.valorMinutos) / 60)}h{' '}
{Math.abs(ajuste.valorMinutos) % 60}min
</span>
</td>
<td>{ajuste.gestor?.nome || 'Sistema'}</td>
<td>
{#if ajuste.aplicado}
<span class="badge badge-success badge-sm">
<CheckCircle2 class="h-3 w-3" />
Aplicado
</span>
{:else}
<span class="badge badge-warning badge-sm">
<AlertCircle class="h-3 w-3" />
Pendente
</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
{/if}
<!-- Inconsistências Detectadas -->
{#if inconsistencias && inconsistencias.length > 0}
<div class="card bg-warning/10 border-warning/30 shadow-lg">
<div class="card-body">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold text-warning">
<AlertTriangle class="h-5 w-5" strokeWidth={2} />
Inconsistências Detectadas - {formatarMes(mesSelecionado)}
</h3>
<div class="space-y-3">
{#each inconsistencias as inconsistencia}
<div
class={`rounded-lg border p-4 ${
inconsistencia.status === 'resolvida'
? 'border-success/30 bg-success/5'
: inconsistencia.status === 'ignorada'
? 'border-base-300 bg-base-200'
: 'border-warning/50 bg-warning/10'
}`}
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="mb-2 flex items-center gap-2">
<span
class={`badge ${
inconsistencia.status === 'resolvida'
? 'badge-success'
: inconsistencia.status === 'ignorada'
? 'badge-base-300'
: 'badge-warning'
}`}
>
{inconsistencia.status === 'resolvida'
? 'Resolvida'
: inconsistencia.status === 'ignorada'
? 'Ignorada'
: 'Pendente'}
</span>
<span class="text-sm font-medium">
{inconsistencia.tipo === 'ponto_com_atestado'
? 'Registro de Ponto com Atestado'
: inconsistencia.tipo === 'ponto_com_licenca'
? 'Registro de Ponto com Licença'
: inconsistencia.tipo === 'ponto_com_ausencia'
? 'Registro de Ponto com Ausência'
: inconsistencia.tipo === 'registro_duplicado'
? 'Registro Duplicado'
: inconsistencia.tipo === 'sequencia_invalida'
? 'Sequência Inválida'
: 'Saldo Inconsistente'}
</span>
</div>
<p class="text-sm text-base-content/80">{inconsistencia.descricao}</p>
<p class="mt-2 text-xs text-base-content/60">
Detectada em:{' '}
{new Date(inconsistencia.dataDetectada).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</p>
</div>
{#if inconsistencia.status === 'pendente'}
<XCircle class="h-5 w-5 text-warning" strokeWidth={2} />
{:else if inconsistencia.status === 'resolvida'}
<CheckCircle2 class="h-5 w-5 text-success" strokeWidth={2} />
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Resumo de Ajustes e Inconsistências -->
{#if bancoMensal && (bancoMensal.totalAjustes || bancoMensal.totalAbonos || bancoMensal.totalDescontos || bancoMensal.inconsistenciasResolvidas !== undefined)}
<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} />
Resumo de Ajustes e Inconsistências
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
{#if bancoMensal.totalAjustes !== undefined}
<div>
<p class="text-sm font-medium text-base-content/60">Total de Ajustes</p>
<p class="text-xl font-bold">
{Math.floor(Math.abs(bancoMensal.totalAjustes) / 60)}h{' '}
{Math.abs(bancoMensal.totalAjustes) % 60}min
</p>
</div>
{/if}
{#if bancoMensal.totalAbonos !== undefined}
<div>
<p class="text-sm font-medium text-success">Total de Abonos</p>
<p class="text-xl font-bold text-success">
+{Math.floor(bancoMensal.totalAbonos / 60)}h{' '}
{bancoMensal.totalAbonos % 60}min
</p>
</div>
{/if}
{#if bancoMensal.totalDescontos !== undefined}
<div>
<p class="text-sm font-medium text-error">Total de Descontos</p>
<p class="text-xl font-bold text-error">
-{Math.floor(bancoMensal.totalDescontos / 60)}h{' '}
{bancoMensal.totalDescontos % 60}min
</p>
</div>
{/if}
{#if bancoMensal.inconsistenciasResolvidas !== undefined}
<div>
<p class="text-sm font-medium text-base-content/60">
Inconsistências Resolvidas
</p>
<p class="text-xl font-bold">{bancoMensal.inconsistenciasResolvidas}</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
{:else}
<div class="card bg-base-100 border-base-300 shadow-lg">
<div class="card-body text-center">

View File

@@ -0,0 +1,163 @@
<script lang="ts">
import { ChevronUp, ChevronDown } from 'lucide-svelte';
interface Props {
hours: number;
minutes: number;
onChange: (hours: number, minutes: number) => void;
label?: string;
disabled?: boolean;
}
let { hours, minutes, onChange, label, disabled = false }: Props = $props();
function incrementHours() {
if (disabled) return;
const newHours = hours + 1;
onChange(newHours, minutes);
}
function decrementHours() {
if (disabled) return;
const newHours = Math.max(0, hours - 1);
onChange(newHours, minutes);
}
function incrementMinutes() {
if (disabled) return;
const newMinutes = minutes + 15;
if (newMinutes >= 60) {
const extraHours = Math.floor(newMinutes / 60);
const remainingMinutes = newMinutes % 60;
onChange(hours + extraHours, remainingMinutes);
} else {
onChange(hours, newMinutes);
}
}
function decrementMinutes() {
if (disabled) return;
const newMinutes = minutes - 15;
if (newMinutes < 0) {
if (hours > 0) {
onChange(hours - 1, 60 + newMinutes);
} else {
onChange(0, 0);
}
} else {
onChange(hours, newMinutes);
}
}
function handleHoursInput(e: Event) {
if (disabled) return;
const target = e.target as HTMLInputElement;
const value = parseInt(target.value) || 0;
onChange(Math.max(0, value), minutes);
}
function handleMinutesInput(e: Event) {
if (disabled) return;
const target = e.target as HTMLInputElement;
const value = parseInt(target.value) || 0;
const clampedValue = Math.max(0, Math.min(59, value));
onChange(hours, clampedValue);
}
const totalMinutes = $derived(hours * 60 + minutes);
const displayText = $derived.by(() => {
if (totalMinutes === 0) return '0h 0min';
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
return `${h}h ${m}min`;
});
</script>
<div class="time-picker">
{#if label}
<div class="mb-2 block text-sm font-medium text-gray-700">{label}</div>
{/if}
<div class="flex items-center gap-3">
<!-- Horas -->
<div class="flex flex-col items-center">
<button
type="button"
onclick={incrementHours}
disabled={disabled}
class="flex h-10 w-12 items-center justify-center rounded-t-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronUp class="h-4 w-4 text-gray-600" />
</button>
<input
type="number"
min="0"
value={hours}
oninput={handleHoursInput}
disabled={disabled}
class="h-14 w-12 border-x border-gray-300 bg-white text-center text-xl font-bold text-gray-900 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
<button
type="button"
onclick={decrementHours}
disabled={disabled || hours === 0}
class="flex h-10 w-12 items-center justify-center rounded-b-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronDown class="h-4 w-4 text-gray-600" />
</button>
<span class="mt-1 text-xs text-gray-500">horas</span>
</div>
<!-- Separador -->
<div class="text-2xl font-bold text-gray-400">:</div>
<!-- Minutos -->
<div class="flex flex-col items-center">
<button
type="button"
onclick={incrementMinutes}
disabled={disabled}
class="flex h-10 w-12 items-center justify-center rounded-t-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronUp class="h-4 w-4 text-gray-600" />
</button>
<input
type="number"
min="0"
max="59"
value={minutes}
oninput={handleMinutesInput}
disabled={disabled}
class="h-14 w-12 border-x border-gray-300 bg-white text-center text-xl font-bold text-gray-900 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
<button
type="button"
onclick={decrementMinutes}
disabled={disabled || (hours === 0 && minutes === 0)}
class="flex h-10 w-12 items-center justify-center rounded-b-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronDown class="h-4 w-4 text-gray-600" />
</button>
<span class="mt-1 text-xs text-gray-500">min</span>
</div>
<!-- Total -->
<div class="ml-4 flex flex-col items-center justify-center rounded-lg bg-primary/10 px-4 py-2">
<span class="text-xs text-gray-600">Total</span>
<span class="text-lg font-bold text-primary">{displayText}</span>
</div>
</div>
</div>
<style>
.time-picker input[type='number']::-webkit-inner-spin-button,
.time-picker input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.time-picker input[type='number'] {
-moz-appearance: textfield;
}
</style>

View File

@@ -18,7 +18,8 @@
Info,
ArrowRight,
Clock,
XCircle
XCircle,
TrendingUp
} from 'lucide-svelte';
import type { Component } from 'svelte';
@@ -102,6 +103,12 @@
descricao: 'Gerencie períodos de dispensa de registro de ponto',
href: '/recursos-humanos/controle-ponto/dispensa',
Icon: XCircle
},
{
nome: 'Banco de Horas',
descricao: 'Visão gerencial do banco de horas dos funcionários, com filtros, estatísticas e relatórios',
href: '/recursos-humanos/controle-ponto/banco-horas',
Icon: TrendingUp
}
]
},

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Clock, CheckCircle2, XCircle, ChevronRight } from 'lucide-svelte';
import { Clock, CheckCircle2, XCircle, ChevronRight, TrendingUp } from 'lucide-svelte';
import { resolve } from '$app/paths';
</script>
@@ -16,7 +16,7 @@
</div>
<!-- Grid de Cards -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<!-- Card 1: Gestão de Pontos -->
<a
href={resolve('/(dashboard)/recursos-humanos/registro-pontos')}
@@ -75,7 +75,7 @@
</div>
</a>
<!-- Card 4: Dashboard Banco de Horas -->
<!-- Card 4: 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"
@@ -83,13 +83,13 @@
<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} />
<TrendingUp 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>
<h2 class="card-title mb-2 text-xl">Banco de Horas</h2>
<p class="text-base-content/70">
Visão gerencial do banco de horas, estatísticas e relatórios mensais
Visão gerencial do banco de horas dos funcionários, com filtros, estatísticas e relatórios
</p>
</div>
</a>

View File

@@ -12,18 +12,31 @@
FileText,
Calendar,
Search,
Filter
Filter,
Plus,
CheckCircle2,
XCircle,
Eye,
Edit,
AlertCircle
} from 'lucide-svelte';
import { useConvexClient } from 'convex-svelte';
import LineChart from '$lib/components/ti/charts/LineChart.svelte';
import jsPDF from 'jspdf';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
const client = useConvexClient();
// Estados
let mesSelecionado = $state(
`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
);
let funcionarioFiltro = $state<string>('');
let apenasNegativos = $state(false);
let tipoDiaFiltro = $state<string>('');
let statusInconsistenciaFiltro = $state<string>('');
let mostrarModalAjuste = $state(false);
let funcionarioSelecionado = $state<Id<'funcionarios'> | null>(null);
// Queries
const funcionariosQuery = useQuery(api.funcionarios.listar, {});
@@ -35,7 +48,15 @@
funcionarioId: funcionarioFiltro ? (funcionarioFiltro as Id<'funcionarios'>) : undefined
});
// Query para inconsistências gerais
const inconsistenciasGeraisQuery = useQuery(api.pontos.listarInconsistenciasBancoHoras, {
status: statusInconsistenciaFiltro
? (statusInconsistenciaFiltro as 'pendente' | 'resolvida' | 'ignorada')
: undefined
});
const estatisticas = $derived(estatisticasQuery?.data);
const inconsistenciasGerais = $derived(inconsistenciasGeraisQuery?.data || []);
// Função para formatar mês
function formatarMes(mes: string): string {
@@ -226,10 +247,55 @@
</div>
</div>
<!-- Filtros Avançados -->
<div class="card bg-base-100 mb-6 shadow-lg">
<div class="card-body">
<h3 class="mb-4 flex items-center gap-2 text-lg font-semibold">
<Filter class="h-5 w-5" strokeWidth={2} />
Filtros Avançados
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Filtro por Tipo de Dia -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Tipo de Dia</span>
</label>
<select class="select select-bordered w-full" bind:value={tipoDiaFiltro}>
<option value="">Todos os tipos</option>
<option value="normal">Normal</option>
<option value="atestado">Atestado</option>
<option value="licenca">Licença</option>
<option value="ausencia">Ausência</option>
<option value="abonado">Abonado</option>
<option value="descontado">Descontado</option>
</select>
</div>
<!-- Filtro por Status de Inconsistência -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Status de Inconsistência</span>
</label>
<select class="select select-bordered w-full" bind:value={statusInconsistenciaFiltro}>
<option value="">Todas</option>
<option value="pendente">Pendente</option>
<option value="resolvida">Resolvida</option>
<option value="ignorada">Ignorada</option>
</select>
</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 estatisticasQuery?.error}
<div class="alert alert-error mb-6">
<AlertTriangle class="h-5 w-5" />
<span>Erro ao carregar estatísticas: {estatisticasQuery.error.message || 'Erro desconhecido'}</span>
</div>
{:else if estatisticas}
<!-- Cards de Estatísticas -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-4 mb-6">
@@ -308,11 +374,16 @@
<th class="text-right">Dias Trabalhados</th>
<th class="text-right">Horas Extras</th>
<th class="text-right">Déficit</th>
<th class="text-center">Inconsistências</th>
<th class="text-center">Ações</th>
</tr>
</thead>
<tbody>
{#each estatisticas.funcionarios as item}
{#if !apenasNegativos || item.saldoFinalMinutos < 0}
{@const inconsistenciasFunc = inconsistenciasGerais.filter(
(i) => i.funcionarioId === item.funcionario._id
)}
<tr
class={item.saldoFinalMinutos < 0
? 'bg-error/5 hover:bg-error/10'
@@ -367,6 +438,41 @@
<td class="text-right text-error">
{Math.floor(item.horasDeficit / 60)}h {item.horasDeficit % 60}min
</td>
<td class="text-center">
{#if inconsistenciasFunc.length > 0}
<span class="badge badge-warning badge-sm">
<AlertTriangle class="h-3 w-3" />
{inconsistenciasFunc.length}
</span>
{:else}
<span class="badge badge-success badge-sm">
<CheckCircle2 class="h-3 w-3" />
OK
</span>
{/if}
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-2">
<button
type="button"
class="btn btn-sm btn-primary"
onclick={() => {
funcionarioSelecionado = item.funcionario._id;
mostrarModalAjuste = true;
}}
title="Criar ajuste"
>
<Plus class="h-4 w-4" />
</button>
<a
href={`/recursos-humanos/funcionarios/${item.funcionario._id}`}
class="btn btn-sm btn-ghost"
title="Ver detalhes"
>
<Eye class="h-4 w-4" />
</a>
</div>
</td>
</tr>
{/if}
{/each}
@@ -375,6 +481,105 @@
</div>
</div>
</div>
<!-- Inconsistências Gerais -->
{#if inconsistenciasGerais && inconsistenciasGerais.length > 0}
<div class="card bg-warning/10 border-warning/30 shadow-lg mt-6">
<div class="card-body">
<h2 class="card-title mb-4 text-warning">
<AlertTriangle class="h-5 w-5" strokeWidth={2} />
Inconsistências Detectadas
</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Funcionário</th>
<th>Tipo</th>
<th>Descrição</th>
<th>Data Detectada</th>
<th>Status</th>
<th class="text-center">Ações</th>
</tr>
</thead>
<tbody>
{#each inconsistenciasGerais as inconsistencia}
<tr
class={inconsistencia.status === 'pendente'
? 'bg-warning/10'
: inconsistencia.status === 'resolvida'
? 'bg-success/10'
: ''}
>
<td>
<div class="flex items-center gap-2">
<div class="font-medium">
{inconsistencia.funcionario?.nome || 'N/A'}
</div>
{#if inconsistencia.funcionario?.matricula}
<span class="text-xs text-base-content/60">
({inconsistencia.funcionario.matricula})
</span>
{/if}
</div>
</td>
<td>
<span class="badge badge-warning badge-sm">
{inconsistencia.tipo === 'ponto_com_atestado'
? 'Ponto + Atestado'
: inconsistencia.tipo === 'ponto_com_licenca'
? 'Ponto + Licença'
: inconsistencia.tipo === 'ponto_com_ausencia'
? 'Ponto + Ausência'
: inconsistencia.tipo === 'registro_duplicado'
? 'Duplicado'
: inconsistencia.tipo === 'sequencia_invalida'
? 'Sequência Inválida'
: 'Saldo Inconsistente'}
</span>
</td>
<td class="max-w-xs truncate">{inconsistencia.descricao}</td>
<td>
{new Date(inconsistencia.dataDetectada).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</td>
<td>
<span
class={`badge ${
inconsistencia.status === 'resolvida'
? 'badge-success'
: inconsistencia.status === 'ignorada'
? 'badge-base-300'
: 'badge-warning'
}`}
>
{inconsistencia.status === 'resolvida'
? 'Resolvida'
: inconsistencia.status === 'ignorada'
? 'Ignorada'
: 'Pendente'}
</span>
</td>
<td class="text-center">
<a
href={`/recursos-humanos/funcionarios/${inconsistencia.funcionarioId}`}
class="btn btn-sm btn-ghost"
title="Ver detalhes"
>
<Eye class="h-4 w-4" />
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
{/if}
{:else}
<div class="card bg-base-100 shadow-lg">
<div class="card-body text-center">
@@ -388,6 +593,40 @@
{/if}
</div>
<!-- Modal para Criar Ajuste -->
{#if mostrarModalAjuste && funcionarioSelecionado}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Criar Ajuste de Banco de Horas</h3>
<p class="text-sm text-base-content/60 mb-4">
Funcionário: {funcionarios.find((f) => f._id === funcionarioSelecionado)?.nome || 'N/A'}
</p>
<p class="text-sm text-warning mb-4">
⚠️ Esta funcionalidade requer integração com o formulário de ajuste. Use a página de
Homologação para criar ajustes.
</p>
<div class="modal-action">
<button
type="button"
class="btn"
onclick={() => {
mostrarModalAjuste = false;
funcionarioSelecionado = null;
}}
>
Fechar
</button>
<a
href={`/recursos-humanos/controle-ponto/homologacao?funcionarioId=${funcionarioSelecionado}`}
class="btn btn-primary"
>
Ir para Homologação
</a>
</div>
</div>
</div>
{/if}

View File

@@ -307,6 +307,19 @@
{ label: 'Direitos', variant: 'outline' }
]
},
{
title: 'Configurações de Banco de Horas',
description:
'Configure limites, regras e alertas do sistema de banco de horas. Gerencie configurações gerais e alertas configuráveis.',
ctaLabel: 'Configurar Banco de Horas',
href: '/(dashboard)/ti/configuracoes-banco-horas',
palette: 'primary',
icon: 'clock',
highlightBadges: [
{ label: 'Alertas', variant: 'solid' },
{ label: 'Configurações', variant: 'outline' }
]
},
{
title: 'Configuração de Email',
description:

File diff suppressed because it is too large Load Diff