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 { 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 { 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 => 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; }