Files
sgse-app/apps/web/src/lib/utils/ponto/pdf/geradorPDF.ts

613 lines
17 KiB
TypeScript

import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { ConvexClient } from 'convex-svelte';
import { formatarHoraPonto, formatarDataDDMMAAAA, getTipoRegistroLabel } from '../../ponto';
import type { DiaFichaPonto, ResumoPeriodo, TipoDia } from '../tipos';
import { processarDadosFichaPonto } from '../processamento';
import {
adicionarLogo,
adicionarCabecalho,
adicionarDadosFuncionario,
adicionarResumoPeriodo,
adicionarSaldosPeriodo,
adicionarLegenda,
adicionarRodape,
type SectionsPDF
} from '../../fichaPontoPDF';
import { formatarHoras, formatarMinutos } from '../formatacao';
import { validarPeriodo } from '../validacao';
/**
* Gera PDF com seleção de seções
*/
export async function gerarPDFComSelecao(
client: ConvexClient,
sections: SectionsPDF,
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string,
funcionarios: Array<{ _id: Id<'funcionarios'>; nome: string; matricula?: string }>,
logoGovPE: string,
onError: (message: string) => void,
onSuccess: () => void,
setCarregando: (value: boolean) => void
): Promise<void> {
console.log('[gerarPDFComSelecao] Iniciando geração de PDF', {
funcionarioId,
dataInicio,
dataFim,
sections
});
// Verificar se pelo menos uma seção foi selecionada
if (!Object.values(sections).some((v) => v)) {
console.error('[gerarPDFComSelecao] Nenhuma seção selecionada');
onError('Selecione pelo menos uma seção para imprimir');
return;
}
// Validar período
const validacaoPeriodo = validarPeriodo(dataInicio, dataFim);
if (!validacaoPeriodo.valido) {
console.error('[gerarPDFComSelecao] Período inválido', validacaoPeriodo);
onError(validacaoPeriodo.erro || 'Período inválido');
return;
}
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
if (!funcionario) {
console.error('[gerarPDFComSelecao] Funcionário não encontrado', funcionarioId);
onError('Funcionário não encontrado');
return;
}
try {
setCarregando(true);
console.log('[gerarPDFComSelecao] Processando dados...');
// Processar todos os dados necessários
const { dias, resumo, config: configPonto } = await processarDadosFichaPonto(
client,
funcionarioId,
dataInicio,
dataFim
);
console.log('[gerarPDFComSelecao] Dados processados', {
diasCount: dias.length,
resumo,
config: configPonto
});
if (dias.length === 0) {
console.error('[gerarPDFComSelecao] Nenhum dado encontrado');
onError('Nenhum dado encontrado para este funcionário no período selecionado');
setCarregando(false);
return;
}
const doc = new jsPDF();
// Logo e cabeçalho
let yPosition = await adicionarLogo(doc, logoGovPE);
yPosition = adicionarCabecalho(doc, yPosition);
// Dados do Funcionário
if (sections.dadosFuncionario) {
yPosition = adicionarDadosFuncionario(doc, yPosition, funcionario, dataInicio, dataFim);
}
// SEÇÃO: TABELA PRINCIPAL DE REGISTROS (PRIMEIRO)
if (sections.registrosPonto) {
yPosition = gerarTabelaRegistrosPDF(doc, yPosition, dias, configPonto, sections);
}
// Resumo do Período
yPosition = adicionarResumoPeriodo(doc, yPosition, resumo, formatarHoras, formatarMinutos);
// Saldos do Período
yPosition = adicionarSaldosPeriodo(doc, yPosition, resumo, formatarMinutos);
// Legenda
yPosition = adicionarLegenda(doc, yPosition);
// SEÇÃO: BANCO DE HORAS
if (sections.bancoHoras) {
yPosition = await gerarSecaoBancoHorasPDF(
doc,
yPosition,
client,
funcionarioId,
dataInicio,
dataFim,
configPonto
);
}
// SEÇÃO: INCONSISTÊNCIAS (usando alteracoesGestor)
if (sections.alteracoesGestor) {
yPosition = gerarSecaoInconsistenciasPDF(doc, yPosition, dias);
}
// SEÇÃO: AJUSTES (usando alteracoesGestor)
if (sections.alteracoesGestor) {
yPosition = gerarSecaoAjustesPDF(doc, yPosition, dias);
}
// SEÇÃO: DISPENSAS
if (sections.dispensasRegistro) {
yPosition = gerarSecaoDispensasPDF(doc, yPosition, dias);
}
// Rodapé
adicionarRodape(doc);
// Salvar
const nomeArquivo = `ficha-ponto-${funcionario.matricula || funcionario.nome}-${dataInicio}-${dataFim}.pdf`;
console.log('[gerarPDFComSelecao] Salvando PDF:', nomeArquivo);
doc.save(nomeArquivo);
console.log('[gerarPDFComSelecao] PDF gerado com sucesso');
onSuccess();
} catch (error) {
console.error('Erro ao gerar PDF:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
// Mensagens de erro mais específicas
if (errorMessage.includes('Configuração de ponto não encontrada')) {
onError('Configuração de ponto não encontrada. Entre em contato com o administrador.');
} else if (errorMessage.includes('Nenhum dado encontrado')) {
onError('Nenhum dado encontrado para este funcionário no período selecionado.');
} else if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) {
onError('Tempo de geração excedido. Tente um período menor (máximo 90 dias).');
} else {
onError(`Erro ao gerar ficha de ponto: ${errorMessage}`);
}
} finally {
setCarregando(false);
}
}
/**
* Gera tabela de registros de ponto no PDF
*/
function gerarTabelaRegistrosPDF(
doc: jsPDF,
yPosition: number,
dias: DiaFichaPonto[],
config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
nomeEntrada?: string;
nomeSaidaAlmoco?: string;
nomeRetornoAlmoco?: string;
nomeSaida?: string;
},
sections: SectionsPDF
): number {
if (yPosition > 250) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(14);
doc.setTextColor(41, 128, 185);
doc.setFont('helvetica', 'bold');
doc.text('REGISTROS DE PONTO', 15, yPosition);
yPosition += 10;
// Função auxiliar para obter cor de fundo baseada no tipo de dia
const obterCorFundoTipoDia = (tipoDia: TipoDia): number[] => {
switch (tipoDia) {
case 'atestado':
return [230, 240, 255]; // Azul claro
case 'ausencia':
return [255, 255, 230]; // Amarelo claro
case 'abonado':
return [230, 255, 230]; // Verde claro
case 'nao_computado':
return [240, 240, 240]; // Cinza claro
case 'inconsistente':
return [255, 240, 230]; // Laranja claro
default:
return [255, 255, 255]; // Branco
}
};
// Função auxiliar para obter ícone do tipo de dia
const obterIconeTipoDia = (dia: DiaFichaPonto): string => {
if (dia.atestado) return '🏥';
if (dia.ausencia) return '🚫';
if (dia.licenca) return '📋';
if (dia.tipoDia === 'abonado') return '✅';
if (dia.tipoDia === 'nao_computado') return '⏸';
if (dia.inconsistencias.length > 0) return '⚠';
return '';
};
// Preparar dados da tabela
const tableData: Array<
Array<
| string
| {
content: string;
styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string };
}
>
> = [];
for (const dia of dias) {
const dataFormatada = dia.dataFormatada;
const todosRegistros = [
...dia.registros.map((r) => ({ ...r, real: true })),
...dia.registrosEsperados
.filter((re) => !dia.registros.some((r) => r.tipo === re.tipo))
.map((re) => ({ ...re, real: false }))
].sort((a, b) => {
if (a.hora !== b.hora) return a.hora - b.hora;
return a.minuto - b.minuto;
});
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
const linha: Array<
string | { content: string; styles?: { fillColor?: number[]; textColor?: number[]; fontStyle?: string } }
> = [];
// Coluna Data (apenas na primeira linha)
if (i === 0) {
linha.push({
content: `${dataFormatada} ${obterIconeTipoDia(dia)}`,
styles: {
fillColor: obterCorFundoTipoDia(dia.tipoDia),
fontStyle: 'bold'
}
});
} else {
linha.push('');
}
// Coluna Tipo
const tipoLabel = config
? getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida', {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida
})
: getTipoRegistroLabel(reg.tipo as 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida');
if (!('real' in reg) || reg.real) {
linha.push(tipoLabel);
} else {
linha.push({
content: tipoLabel,
styles: { textColor: [200, 0, 0] } // Vermelho para não marcado
});
}
// Coluna Horário
const horario = formatarHoraPonto(reg.hora, reg.minuto);
if (!('real' in reg) || reg.real) {
linha.push(horario);
} else {
linha.push({
content: horario,
styles: { textColor: [200, 0, 0] } // Vermelho para não marcado
});
}
// Coluna Saldo Diário (se seção selecionada)
if (sections.saldoDiario) {
if (i === 0 && dia.saldoDiario) {
const saldoFormatado = formatarMinutos(dia.saldoDiario.diferencaMinutos);
const corSaldo = dia.saldoDiario.diferencaMinutos < 0 ? [200, 0, 0] : [0, 128, 0];
linha.push({
content: saldoFormatado,
styles: { textColor: corSaldo, fontStyle: 'bold' }
});
} else {
linha.push('');
}
}
// Coluna Observações (apenas na primeira linha)
if (i === 0) {
const observacoes: string[] = [];
if (dia.atestado) {
observacoes.push(`Atestado: ${dia.atestado.tipo}`);
}
if (dia.ausencia) {
observacoes.push(`Ausência: ${dia.ausencia.motivo}`);
}
if (dia.licenca) {
observacoes.push(`Licença: ${dia.licenca.tipo}`);
}
if (dia.dispensa) {
observacoes.push(`Dispensa: ${dia.dispensa.motivo}`);
}
if (dia.inconsistencias.length > 0) {
observacoes.push(`Inconsistências: ${dia.inconsistencias.length}`);
}
if (dia.ajustes.length > 0) {
observacoes.push(
`Ajustes: ${dia.ajustes.map((a) => `${a.tipo} ${formatarMinutos(a.valorMinutos)}`).join(', ')}`
);
}
linha.push(observacoes.join('; ') || '-');
} else {
linha.push('');
}
// Coluna Dentro do Prazo
if ('real' in reg && reg.real && 'dentroDoPrazo' in reg) {
linha.push(reg.dentroDoPrazo ? 'Sim' : 'Não');
} else {
linha.push('Não marcado');
}
tableData.push(linha);
}
}
// Cabeçalhos da tabela
const headers = ['Data', 'Tipo', 'Horário'];
if (sections.saldoDiario) {
headers.push('Saldo Diário');
}
headers.push('Observações', 'Dentro do Prazo');
// Adicionar tabela
autoTable(doc, {
startY: yPosition,
head: [headers],
body: tableData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
didParseCell: function (data: any) {
if (data.section === 'body' && data.cell.raw) {
const cellData = data.cell.raw;
if (typeof cellData === 'object' && cellData.styles) {
if (cellData.styles.fillColor) {
data.cell.styles.fillColor = cellData.styles.fillColor;
}
if (cellData.styles.textColor) {
data.cell.styles.textColor = cellData.styles.textColor;
}
if (cellData.styles.fontStyle) {
data.cell.styles.fontStyle = cellData.styles.fontStyle;
}
}
}
}
});
// Calcular nova posição Y
const lastPage = doc.getNumberOfPages();
doc.setPage(lastPage);
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY;
return finalY ? finalY + 10 : yPosition + tableData.length * 7 + 10;
}
/**
* Gera seção de banco de horas no PDF
*/
async function gerarSecaoBancoHorasPDF(
doc: jsPDF,
yPosition: number,
client: ConvexClient,
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string,
config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
}
): Promise<number> {
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('BANCO DE HORAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
// Buscar banco de horas
const { api } = await import('@sgse-app/backend/convex/_generated/api');
const bancoHoras = await client.query(api.pontos.obterBancoHorasFuncionario, {
funcionarioId
});
if (bancoHoras) {
const bancoData = [
['Saldo Atual', formatarMinutos(bancoHoras.saldoAtualMinutos || 0)],
['Saldo Inicial', formatarMinutos(bancoHoras.saldoInicialMinutos || 0)],
['Saldo Final', formatarMinutos(bancoHoras.saldoFinalMinutos || 0)]
];
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Valor']],
body: bancoData,
theme: 'striped',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 10 }
});
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY;
return finalY ? finalY + 10 : yPosition + bancoData.length * 7 + 10;
}
return yPosition;
}
/**
* Gera seção de inconsistências no PDF
*/
function gerarSecaoInconsistenciasPDF(doc: jsPDF, yPosition: number, dias: DiaFichaPonto[]): number {
const todasInconsistencias = dias.flatMap((dia) =>
dia.inconsistencias.map((inc) => ({
...inc,
data: dia.data,
dataFormatada: dia.dataFormatada
}))
);
if (todasInconsistencias.length === 0) {
return yPosition;
}
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('INCONSISTÊNCIAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const inconsistenciasData = todasInconsistencias.map((inc) => [
formatarDataDDMMAAAA(inc.data),
inc.tipo,
inc.descricao,
inc.status
]);
autoTable(doc, {
startY: yPosition,
head: [['Data', 'Tipo', 'Descrição', 'Status']],
body: inconsistenciasData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY;
return finalY ? finalY + 10 : yPosition + inconsistenciasData.length * 7 + 10;
}
/**
* Gera seção de ajustes no PDF
*/
function gerarSecaoAjustesPDF(doc: jsPDF, yPosition: number, dias: DiaFichaPonto[]): number {
const todosAjustes = dias.flatMap((dia) =>
dia.ajustes.map((ajuste) => ({
...ajuste,
data: dia.data,
dataFormatada: dia.dataFormatada
}))
);
if (todosAjustes.length === 0) {
return yPosition;
}
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('AJUSTES DE BANCO DE HORAS', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const ajustesData = todosAjustes.map((ajuste) => [
formatarDataDDMMAAAA(ajuste.data),
ajuste.tipo === 'abonar' ? 'Abonar' : ajuste.tipo === 'descontar' ? 'Descontar' : 'Compensar',
formatarMinutos(ajuste.valorMinutos),
ajuste.motivoDescricao || '-'
]);
autoTable(doc, {
startY: yPosition,
head: [['Data', 'Tipo', 'Valor', 'Motivo']],
body: ajustesData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY;
return finalY ? finalY + 10 : yPosition + ajustesData.length * 7 + 10;
}
/**
* Gera seção de dispensas no PDF
*/
function gerarSecaoDispensasPDF(doc: jsPDF, yPosition: number, dias: DiaFichaPonto[]): number {
const dispensas = dias
.map((dia) => dia.dispensa)
.filter((d): d is NonNullable<typeof d> => d !== null)
.filter((d, index, self) => index === self.findIndex((disp) => disp._id === d._id));
if (dispensas.length === 0) {
return yPosition;
}
if (yPosition > doc.internal.pageSize.getHeight() - 60) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(41, 128, 185);
doc.text('DISPENSAS DE REGISTRO', 15, yPosition);
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
yPosition += 10;
const dispensasData = dispensas.map((d) => [
`${formatarDataDDMMAAAA(d.dataInicio)} a ${formatarDataDDMMAAAA(d.dataFim)}`,
d.motivo,
d.ativo ? 'Ativa' : 'Inativa'
]);
autoTable(doc, {
startY: yPosition,
head: [['Período', 'Motivo', 'Status']],
body: dispensasData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY;
return finalY ? finalY + 10 : yPosition + dispensasData.length * 7 + 10;
}