From c00a5ff4b78f6eea9e3aea60ff580e4c220e7aba Mon Sep 17 00:00:00 2001 From: Gabriel Menezes Date: Sun, 1 Nov 2026 11:14:10 -0400 Subject: [PATCH] feat(reports): add SIGNATURE section type inserting user default signature into docx --- .../config/ReportUseCaseConfig.java | 2 + .../report/docengine/DocxReportEngine.java | 107 ++++++++++++++---- .../generate/GenerateReportUseCase.java | 61 +++++++++- .../reports/CreateTemplateModal.tsx | 19 +++- .../components/reports/EditTemplateModal.tsx | 19 +++- .../reports/GenerateReportModal.tsx | 25 ++++ frontend/src/pages/ReportsPage.tsx | 1 + frontend/src/services/reportService.ts | 2 +- 8 files changed, 209 insertions(+), 27 deletions(-) diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java index 335e465..1ab19cc 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java @@ -85,6 +85,7 @@ public class ReportUseCaseConfig { ProjectRepository projectRepository, UserRepository userRepository, StorageRepository storageRepository, + net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository, AuthorizeOrganizationMemberUseCase authorizer ) { return new GenerateReportUseCase( @@ -95,6 +96,7 @@ public class ReportUseCaseConfig { projectRepository, userRepository, storageRepository, + userSignatureRepository, authorizer ); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/docengine/DocxReportEngine.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/docengine/DocxReportEngine.java index 42d4e5b..889139e 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/docengine/DocxReportEngine.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/docengine/DocxReportEngine.java @@ -1,14 +1,17 @@ package net.beetechgroup.beetask.usecase.report.docengine; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; -import org.apache.poi.xwpf.usermodel.IBodyElement; +import org.apache.poi.util.Units; +import org.apache.poi.xwpf.usermodel.Document; import org.apache.poi.xwpf.usermodel.XWPFDocument; -import org.apache.poi.xwpf.usermodel.XWPFHeader; import org.apache.poi.xwpf.usermodel.XWPFFooter; +import org.apache.poi.xwpf.usermodel.XWPFHeader; import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFRun; import org.apache.poi.xwpf.usermodel.XWPFTable; @@ -43,30 +46,39 @@ public class DocxReportEngine { InputStream templateStream, Map variables, Map> listSections + ) throws Exception { + return generate(templateStream, variables, listSections, Collections.emptyMap()); + } + + public static byte[] generate( + InputStream templateStream, + Map variables, + Map> listSections, + Map images ) throws Exception { try (XWPFDocument doc = new XWPFDocument(templateStream)) { - // 1. Processar Tabelas Dinâmicas primeiro (linhas com tags de lista) - processTables(doc, variables, listSections); + // 1. Processar Tabelas Dinâmicas primeiro (linhas com tags de lista ou campos) + processTables(doc, variables, listSections, images); // 2. Processar Parágrafos do corpo do documento - processParagraphs(doc, variables, listSections); + processParagraphs(doc, variables, listSections, images); // 3. Processar Cabeçalhos e Rodapés for (XWPFHeader header : doc.getHeaderList()) { for (XWPFParagraph p : header.getParagraphs()) { - replaceTextInParagraph(p, variables, null); + replaceTextInParagraph(p, variables, null, images); } for (XWPFTable table : header.getTables()) { - replaceTextInTableCells(table, variables); + replaceTextInTableCells(table, variables, images); } } for (XWPFFooter footer : doc.getFooterList()) { for (XWPFParagraph p : footer.getParagraphs()) { - replaceTextInParagraph(p, variables, null); + replaceTextInParagraph(p, variables, null, images); } for (XWPFTable table : footer.getTables()) { - replaceTextInTableCells(table, variables); + replaceTextInTableCells(table, variables, images); } } @@ -79,18 +91,20 @@ public class DocxReportEngine { private static void processParagraphs( XWPFDocument doc, Map variables, - Map> listSections + Map> listSections, + Map images ) { List paragraphs = new ArrayList<>(doc.getParagraphs()); for (XWPFParagraph p : paragraphs) { - replaceTextInParagraph(p, variables, listSections); + replaceTextInParagraph(p, variables, listSections, images); } } private static void processTables( XWPFDocument doc, Map variables, - Map> listSections + Map> listSections, + Map images ) { for (XWPFTable table : doc.getTables()) { List rows = new ArrayList<>(table.getRows()); @@ -132,10 +146,10 @@ public class DocxReportEngine { table.removeRow(r); r += items.size() - 1; // Ajustar índice } else { - // Linha normal: substituir variáveis simples + // Linha normal: substituir variáveis simples e imagens/assinaturas for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph p : cell.getParagraphs()) { - replaceTextInParagraph(p, variables, null); + replaceTextInParagraph(p, variables, null, images); } } } @@ -143,11 +157,11 @@ public class DocxReportEngine { } } - private static void replaceTextInTableCells(XWPFTable table, Map variables) { + private static void replaceTextInTableCells(XWPFTable table, Map variables, Map images) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph p : cell.getParagraphs()) { - replaceTextInParagraph(p, variables, null); + replaceTextInParagraph(p, variables, null, images); } } } @@ -156,7 +170,8 @@ public class DocxReportEngine { private static void replaceTextInParagraph( XWPFParagraph p, Map variables, - Map> listSections + Map> listSections, + Map images ) { String text = p.getText(); if (text == null || !text.contains("{{")) { @@ -196,7 +211,27 @@ public class DocxReportEngine { } } - // 2. Substituir variáveis simples ou campos de texto livre + // 2. Processar imagens / assinaturas (ex: {{assinatura}}, {{assinatura_responsavel}}) + if (images != null && !images.isEmpty()) { + for (Map.Entry imgEntry : images.entrySet()) { + String tag = "{{" + imgEntry.getKey() + "}}"; + if (text.contains(tag)) { + byte[] imgBytes = imgEntry.getValue(); + if (imgBytes != null && imgBytes.length > 0) { + insertImageIntoParagraph(p, tag, imgBytes); + text = p.getText(); + if (text == null || !text.contains("{{")) { + return; + } + } else { + // "se nao tem, nao assina": remove a tag para não ficar visível no documento + text = text.replace(tag, ""); + } + } + } + } + + // 3. Substituir variáveis simples ou campos de texto livre String replaced = text; if (variables != null) { for (Map.Entry var : variables.entrySet()) { @@ -208,7 +243,7 @@ public class DocxReportEngine { } // Se o texto mudou, reaplicamos no parágrafo preservando o estilo original do run 0 - if (!replaced.equals(text)) { + if (!replaced.equals(p.getText())) { String font = null; int fontSize = -1; String color = null; @@ -244,6 +279,40 @@ public class DocxReportEngine { } } + private static void insertImageIntoParagraph(XWPFParagraph p, String tag, byte[] imgBytes) { + String fullText = p.getText(); + if (fullText == null || !fullText.contains(tag)) return; + + int tagIndex = fullText.indexOf(tag); + String before = fullText.substring(0, tagIndex); + String after = fullText.substring(tagIndex + tag.length()); + + clearRuns(p); + + if (!before.isEmpty()) { + XWPFRun rBefore = p.createRun(); + rBefore.setText(before); + } + + XWPFRun imgRun = p.createRun(); + try (ByteArrayInputStream bais = new ByteArrayInputStream(imgBytes)) { + int format = Document.PICTURE_TYPE_PNG; + if (imgBytes.length > 2 && (imgBytes[0] & 0xFF) == 0xFF && (imgBytes[1] & 0xFF) == 0xD8) { + format = Document.PICTURE_TYPE_JPEG; + } + int width = Units.toEMU(160); + int height = Units.toEMU(60); + imgRun.addPicture(bais, format, "signature.png", width, height); + } catch (Exception e) { + LOGGER.errorf("Erro ao inserir imagem no docx: %s", e.getMessage()); + } + + if (!after.isEmpty()) { + XWPFRun rAfter = p.createRun(); + rAfter.setText(after); + } + } + private static String replaceItemTags(String cellText, TaskItem item) { if (cellText == null) return ""; return cellText diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java index 04f01db..d4e615d 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java @@ -17,6 +17,7 @@ import java.util.Objects; import java.util.UUID; import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.entities.UserSignature; import net.beetechgroup.beetask.entities.organization.Organization; import net.beetechgroup.beetask.entities.report.GeneratedReport; import net.beetechgroup.beetask.entities.report.ReportSection; @@ -33,6 +34,7 @@ import net.beetechgroup.beetask.usecase.repository.ReportTemplateRepository; import net.beetechgroup.beetask.usecase.repository.StorageRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; +import net.beetechgroup.beetask.usecase.repository.UserSignatureRepository; import org.jboss.logging.Logger; public class GenerateReportUseCase { @@ -47,6 +49,7 @@ public class GenerateReportUseCase { private final ProjectRepository projectRepository; private final UserRepository userRepository; private final StorageRepository storageRepository; + private final UserSignatureRepository userSignatureRepository; private final AuthorizeOrganizationMemberUseCase authorizer; public GenerateReportUseCase( @@ -57,6 +60,7 @@ public class GenerateReportUseCase { ProjectRepository projectRepository, UserRepository userRepository, StorageRepository storageRepository, + UserSignatureRepository userSignatureRepository, AuthorizeOrganizationMemberUseCase authorizer ) { this.reportTemplateRepository = reportTemplateRepository; @@ -66,6 +70,7 @@ public class GenerateReportUseCase { this.projectRepository = projectRepository; this.userRepository = userRepository; this.storageRepository = storageRepository; + this.userSignatureRepository = userSignatureRepository; this.authorizer = authorizer; } @@ -251,16 +256,66 @@ public class GenerateReportUseCase { } } - // 5. Baixar o template do MinIO e gerar o documento preenchido + // 5. Carregar assinatura padrão do usuário (se houver) + byte[] defaultSignatureBytes = null; + if (userSignatureRepository != null) { + List userSignatures = userSignatureRepository.findByUserId(user.getId()); + UserSignature defaultSig = userSignatures.stream() + .filter(UserSignature::isDefault) + .findFirst() + .or(() -> userSignatures.stream().findFirst()) + .orElse(null); + + if (defaultSig != null && defaultSig.getStorageKey() != null) { + try (InputStream sigStream = storageRepository.download(defaultSig.getStorageKey())) { + if (sigStream != null) { + defaultSignatureBytes = sigStream.readAllBytes(); + LOGGER.infof("Assinatura padrão do usuário %s carregada com sucesso (%d bytes)", user.getEmail(), defaultSignatureBytes.length); + } + } catch (Exception e) { + LOGGER.warnf("Não foi possível carregar imagem da assinatura padrão: %s", e.getMessage()); + } + } + } + + // Mapear imagens e assinaturas para inserção no Word + Map images = new HashMap<>(); + + if (defaultSignatureBytes != null) { + images.put("assinatura", defaultSignatureBytes); + images.put("assinatura_usuario", defaultSignatureBytes); + images.put("assinatura_colaborador", defaultSignatureBytes); + } else { + // "se nao tem, nao assina": remove as tags do documento + variables.put("assinatura", ""); + variables.put("assinatura_usuario", ""); + variables.put("assinatura_colaborador", ""); + } + + // Processar seções configuradas no modelo do tipo SIGNATURE ou IMAGE + if (template.getSections() != null) { + for (ReportSection section : template.getSections()) { + if ("SIGNATURE".equalsIgnoreCase(section.getSectionType()) || "IMAGE".equalsIgnoreCase(section.getSectionType())) { + if (defaultSignatureBytes != null) { + images.put(section.getSectionKey(), defaultSignatureBytes); + } else { + // "se nao tem, nao assina": substitui por vazio + variables.put(section.getSectionKey(), ""); + } + } + } + } + + // 6. Baixar o template do MinIO e gerar o documento preenchido byte[] docxBytes; try (InputStream templateStream = storageRepository.download(template.getStorageKey())) { - docxBytes = DocxReportEngine.generate(templateStream, variables, listSections); + docxBytes = DocxReportEngine.generate(templateStream, variables, listSections, images); } catch (Exception e) { LOGGER.errorf("Erro ao processar template .docx %d: %s", template.getId(), e.getMessage()); throw new RuntimeException("Falha ao gerar o documento Word: " + e.getMessage(), e); } - // 6. Fazer upload do relatório gerado para o MinIO + // 7. Fazer upload do relatório gerado para o MinIO String cleanTitle = (template.getName() + "_" + strStart.replace("/", "-") + "_" + strEnd.replace("/", "-")) .replaceAll("[^a-zA-Z0-9._-]", "_"); String generatedFileName = cleanTitle + ".docx"; diff --git a/frontend/src/components/reports/CreateTemplateModal.tsx b/frontend/src/components/reports/CreateTemplateModal.tsx index 61fde51..cb2b4e5 100644 --- a/frontend/src/components/reports/CreateTemplateModal.tsx +++ b/frontend/src/components/reports/CreateTemplateModal.tsx @@ -11,6 +11,7 @@ import { Calendar, Sparkles, Info, + PenTool, } from 'lucide-react' import { CustomSelect } from '../ui/Select' import { reportService, type ReportTemplateOutput } from '../../services/reportService' @@ -28,7 +29,7 @@ interface CreateTemplateModalProps { interface SectionFormItem { sectionKey: string title: string - sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' + sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' defaultValue?: string dateFormat?: string filterCategoryId?: number @@ -376,19 +377,33 @@ export const CreateTemplateModal: React.FC = ({ value={section.sectionType} onChange={(val) => handleUpdateSection(idx, { - sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST', + sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE', }) } options={[ { value: 'FREE_TEXT', label: 'Texto Livre (Preenchido na emissão)' }, { value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' }, { value: 'DATE', label: 'Data (Com formato personalizado)' }, + { value: 'SIGNATURE', label: '✍️ Assinatura Digital (Assinatura do usuário)' }, { value: 'TASK_LIST', label: 'Lista de Tarefas do Período' }, ]} /> + {/* Configuração de Campo de Tipo ASSINATURA */} + {section.sectionType === 'SIGNATURE' && ( +
+ +
+

Preenchimento Automático com Assinatura Digital

+

+ O BeeTask buscará automaticamente a assinatura padrão cadastrada no perfil do usuário que gerar o relatório e inserirá a imagem no Word na tag {`{{${section.sectionKey || 'assinatura'}}}`}. Se o usuário não tiver assinatura cadastrada, o campo ficará em branco (não assina). +

+
+
+ )} + {/* Configuração de Campo de Tipo DATA */} {section.sectionType === 'DATE' && (
diff --git a/frontend/src/components/reports/EditTemplateModal.tsx b/frontend/src/components/reports/EditTemplateModal.tsx index f7a8f8f..e723da8 100644 --- a/frontend/src/components/reports/EditTemplateModal.tsx +++ b/frontend/src/components/reports/EditTemplateModal.tsx @@ -12,6 +12,7 @@ import { Sparkles, Info, Check, + PenTool, } from 'lucide-react' import { CustomSelect } from '../ui/Select' import { reportService, type ReportTemplateOutput } from '../../services/reportService' @@ -30,7 +31,7 @@ interface EditTemplateModalProps { interface SectionFormItem { sectionKey: string title: string - sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' + sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' defaultValue?: string dateFormat?: string filterCategoryId?: number @@ -398,19 +399,33 @@ export const EditTemplateModal: React.FC = ({ value={section.sectionType} onChange={(val) => handleUpdateSection(idx, { - sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST', + sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE', }) } options={[ { value: 'FREE_TEXT', label: 'Texto Livre (Preenchido na emissão)' }, { value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' }, { value: 'DATE', label: 'Data (Com formato personalizado)' }, + { value: 'SIGNATURE', label: '✍️ Assinatura Digital (Assinatura do usuário)' }, { value: 'TASK_LIST', label: 'Lista de Tarefas do Período' }, ]} />
+ {/* Configuração de Campo de Tipo ASSINATURA */} + {section.sectionType === 'SIGNATURE' && ( +
+ +
+

Preenchimento Automático com Assinatura Digital

+

+ O BeeTask buscará automaticamente a assinatura padrão cadastrada no perfil do usuário que gerar o relatório e inserirá a imagem no Word na tag {`{{${section.sectionKey || 'assinatura'}}}`}. Se o usuário não tiver assinatura cadastrada, o campo ficará em branco (não assina). +

+
+
+ )} + {/* Configuração de Campo de Tipo DATA */} {section.sectionType === 'DATE' && (
diff --git a/frontend/src/components/reports/GenerateReportModal.tsx b/frontend/src/components/reports/GenerateReportModal.tsx index 1528be1..5150f87 100644 --- a/frontend/src/components/reports/GenerateReportModal.tsx +++ b/frontend/src/components/reports/GenerateReportModal.tsx @@ -10,6 +10,7 @@ import { Loader2, Lock, Tag, + PenTool, } from 'lucide-react' import { CustomSelect } from '../ui/Select' import { reportService, type ReportTemplateOutput } from '../../services/reportService' @@ -107,6 +108,12 @@ export const GenerateReportModal: React.FC = ({ return selectedTemplate.sections.filter((s) => s.sectionType === 'TASK_LIST') }, [selectedTemplate]) + // Lista de seções de assinatura do template selecionado + const signatureSections = useMemo(() => { + if (!selectedTemplate) return [] + return selectedTemplate.sections.filter((s) => s.sectionType === 'SIGNATURE') + }, [selectedTemplate]) + // Preset Handlers const handlePresetChange = (preset: 'current_month' | 'last_month' | 'custom') => { setPeriodPreset(preset) @@ -515,6 +522,24 @@ export const GenerateReportModal: React.FC = ({
)} + + {/* Seções de Assinatura Digital do Modelo */} + {signatureSections.length > 0 && ( +
+
+ + + Assinatura Digital no Relatório + + + {signatureSections.map((s) => `{{${s.sectionKey}}}`).join(', ')} + +
+

+ Este modelo contém campo de assinatura ({signatureSections.map((s) => s.title).join(', ')}). O BeeTask incluirá automaticamente a assinatura padrão cadastrada no seu perfil. Caso não possua assinatura cadastrada, o campo ficará em branco. +

+
+ )} {/* Footer */} diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index eee7303..a9131da 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -98,6 +98,7 @@ export const ReportsPage: React.FC = () => { const standardTags = [ { tag: '{{nome}}', desc: 'Nome do colaborador (pega automaticamente do usuário logado)' }, + { tag: '{{assinatura}}', desc: 'Assinatura digital padrão do usuário logado (se não tiver cadastrada, fica em branco)' }, { tag: '{{data_inicio}}', desc: 'Data inicial do período escolhido (ex: 01/10/2026)' }, { tag: '{{data_fim}}', desc: 'Data final do período escolhido (ex: 31/10/2026)' }, { tag: '{{mes_ano}}', desc: 'Mês e ano por extenso (ex: Outubro/2026)' }, diff --git a/frontend/src/services/reportService.ts b/frontend/src/services/reportService.ts index 117c647..49a429f 100644 --- a/frontend/src/services/reportService.ts +++ b/frontend/src/services/reportService.ts @@ -4,7 +4,7 @@ export interface ReportSectionOutput { id: number sectionKey: string title: string - sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' + sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' defaultValue?: string dateFormat?: string filterCategoryId?: number