feat(reports): add SIGNATURE section type inserting user default signature into docx

This commit is contained in:
Gabriel Menezes 2026-11-01 11:14:10 -04:00
parent cc317e274b
commit c00a5ff4b7
8 changed files with 209 additions and 27 deletions

View file

@ -85,6 +85,7 @@ public class ReportUseCaseConfig {
ProjectRepository projectRepository, ProjectRepository projectRepository,
UserRepository userRepository, UserRepository userRepository,
StorageRepository storageRepository, StorageRepository storageRepository,
net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository,
AuthorizeOrganizationMemberUseCase authorizer AuthorizeOrganizationMemberUseCase authorizer
) { ) {
return new GenerateReportUseCase( return new GenerateReportUseCase(
@ -95,6 +96,7 @@ public class ReportUseCaseConfig {
projectRepository, projectRepository,
userRepository, userRepository,
storageRepository, storageRepository,
userSignatureRepository,
authorizer authorizer
); );
} }

View file

@ -1,14 +1,17 @@
package net.beetechgroup.beetask.usecase.report.docengine; package net.beetechgroup.beetask.usecase.report.docengine;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; 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.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFHeader;
import org.apache.poi.xwpf.usermodel.XWPFFooter; 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.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun; import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTable; import org.apache.poi.xwpf.usermodel.XWPFTable;
@ -43,30 +46,39 @@ public class DocxReportEngine {
InputStream templateStream, InputStream templateStream,
Map<String, String> variables, Map<String, String> variables,
Map<String, List<TaskItem>> listSections Map<String, List<TaskItem>> listSections
) throws Exception {
return generate(templateStream, variables, listSections, Collections.emptyMap());
}
public static byte[] generate(
InputStream templateStream,
Map<String, String> variables,
Map<String, List<TaskItem>> listSections,
Map<String, byte[]> images
) throws Exception { ) throws Exception {
try (XWPFDocument doc = new XWPFDocument(templateStream)) { try (XWPFDocument doc = new XWPFDocument(templateStream)) {
// 1. Processar Tabelas Dinâmicas primeiro (linhas com tags de lista) // 1. Processar Tabelas Dinâmicas primeiro (linhas com tags de lista ou campos)
processTables(doc, variables, listSections); processTables(doc, variables, listSections, images);
// 2. Processar Parágrafos do corpo do documento // 2. Processar Parágrafos do corpo do documento
processParagraphs(doc, variables, listSections); processParagraphs(doc, variables, listSections, images);
// 3. Processar Cabeçalhos e Rodapés // 3. Processar Cabeçalhos e Rodapés
for (XWPFHeader header : doc.getHeaderList()) { for (XWPFHeader header : doc.getHeaderList()) {
for (XWPFParagraph p : header.getParagraphs()) { for (XWPFParagraph p : header.getParagraphs()) {
replaceTextInParagraph(p, variables, null); replaceTextInParagraph(p, variables, null, images);
} }
for (XWPFTable table : header.getTables()) { for (XWPFTable table : header.getTables()) {
replaceTextInTableCells(table, variables); replaceTextInTableCells(table, variables, images);
} }
} }
for (XWPFFooter footer : doc.getFooterList()) { for (XWPFFooter footer : doc.getFooterList()) {
for (XWPFParagraph p : footer.getParagraphs()) { for (XWPFParagraph p : footer.getParagraphs()) {
replaceTextInParagraph(p, variables, null); replaceTextInParagraph(p, variables, null, images);
} }
for (XWPFTable table : footer.getTables()) { for (XWPFTable table : footer.getTables()) {
replaceTextInTableCells(table, variables); replaceTextInTableCells(table, variables, images);
} }
} }
@ -79,18 +91,20 @@ public class DocxReportEngine {
private static void processParagraphs( private static void processParagraphs(
XWPFDocument doc, XWPFDocument doc,
Map<String, String> variables, Map<String, String> variables,
Map<String, List<TaskItem>> listSections Map<String, List<TaskItem>> listSections,
Map<String, byte[]> images
) { ) {
List<XWPFParagraph> paragraphs = new ArrayList<>(doc.getParagraphs()); List<XWPFParagraph> paragraphs = new ArrayList<>(doc.getParagraphs());
for (XWPFParagraph p : paragraphs) { for (XWPFParagraph p : paragraphs) {
replaceTextInParagraph(p, variables, listSections); replaceTextInParagraph(p, variables, listSections, images);
} }
} }
private static void processTables( private static void processTables(
XWPFDocument doc, XWPFDocument doc,
Map<String, String> variables, Map<String, String> variables,
Map<String, List<TaskItem>> listSections Map<String, List<TaskItem>> listSections,
Map<String, byte[]> images
) { ) {
for (XWPFTable table : doc.getTables()) { for (XWPFTable table : doc.getTables()) {
List<XWPFTableRow> rows = new ArrayList<>(table.getRows()); List<XWPFTableRow> rows = new ArrayList<>(table.getRows());
@ -132,10 +146,10 @@ public class DocxReportEngine {
table.removeRow(r); table.removeRow(r);
r += items.size() - 1; // Ajustar índice r += items.size() - 1; // Ajustar índice
} else { } else {
// Linha normal: substituir variáveis simples // Linha normal: substituir variáveis simples e imagens/assinaturas
for (XWPFTableCell cell : row.getTableCells()) { for (XWPFTableCell cell : row.getTableCells()) {
for (XWPFParagraph p : cell.getParagraphs()) { 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<String, String> variables) { private static void replaceTextInTableCells(XWPFTable table, Map<String, String> variables, Map<String, byte[]> images) {
for (XWPFTableRow row : table.getRows()) { for (XWPFTableRow row : table.getRows()) {
for (XWPFTableCell cell : row.getTableCells()) { for (XWPFTableCell cell : row.getTableCells()) {
for (XWPFParagraph p : cell.getParagraphs()) { 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( private static void replaceTextInParagraph(
XWPFParagraph p, XWPFParagraph p,
Map<String, String> variables, Map<String, String> variables,
Map<String, List<TaskItem>> listSections Map<String, List<TaskItem>> listSections,
Map<String, byte[]> images
) { ) {
String text = p.getText(); String text = p.getText();
if (text == null || !text.contains("{{")) { 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<String, byte[]> 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; String replaced = text;
if (variables != null) { if (variables != null) {
for (Map.Entry<String, String> var : variables.entrySet()) { for (Map.Entry<String, String> 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 // 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; String font = null;
int fontSize = -1; int fontSize = -1;
String color = null; 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) { private static String replaceItemTags(String cellText, TaskItem item) {
if (cellText == null) return ""; if (cellText == null) return "";
return cellText return cellText

View file

@ -17,6 +17,7 @@ import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.entities.User; import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.UserSignature;
import net.beetechgroup.beetask.entities.organization.Organization; import net.beetechgroup.beetask.entities.organization.Organization;
import net.beetechgroup.beetask.entities.report.GeneratedReport; import net.beetechgroup.beetask.entities.report.GeneratedReport;
import net.beetechgroup.beetask.entities.report.ReportSection; 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.StorageRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
import net.beetechgroup.beetask.usecase.repository.UserSignatureRepository;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
public class GenerateReportUseCase { public class GenerateReportUseCase {
@ -47,6 +49,7 @@ public class GenerateReportUseCase {
private final ProjectRepository projectRepository; private final ProjectRepository projectRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final StorageRepository storageRepository; private final StorageRepository storageRepository;
private final UserSignatureRepository userSignatureRepository;
private final AuthorizeOrganizationMemberUseCase authorizer; private final AuthorizeOrganizationMemberUseCase authorizer;
public GenerateReportUseCase( public GenerateReportUseCase(
@ -57,6 +60,7 @@ public class GenerateReportUseCase {
ProjectRepository projectRepository, ProjectRepository projectRepository,
UserRepository userRepository, UserRepository userRepository,
StorageRepository storageRepository, StorageRepository storageRepository,
UserSignatureRepository userSignatureRepository,
AuthorizeOrganizationMemberUseCase authorizer AuthorizeOrganizationMemberUseCase authorizer
) { ) {
this.reportTemplateRepository = reportTemplateRepository; this.reportTemplateRepository = reportTemplateRepository;
@ -66,6 +70,7 @@ public class GenerateReportUseCase {
this.projectRepository = projectRepository; this.projectRepository = projectRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.storageRepository = storageRepository; this.storageRepository = storageRepository;
this.userSignatureRepository = userSignatureRepository;
this.authorizer = authorizer; 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<UserSignature> 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<String, byte[]> 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; byte[] docxBytes;
try (InputStream templateStream = storageRepository.download(template.getStorageKey())) { try (InputStream templateStream = storageRepository.download(template.getStorageKey())) {
docxBytes = DocxReportEngine.generate(templateStream, variables, listSections); docxBytes = DocxReportEngine.generate(templateStream, variables, listSections, images);
} catch (Exception e) { } catch (Exception e) {
LOGGER.errorf("Erro ao processar template .docx %d: %s", template.getId(), e.getMessage()); 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); 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("/", "-")) String cleanTitle = (template.getName() + "_" + strStart.replace("/", "-") + "_" + strEnd.replace("/", "-"))
.replaceAll("[^a-zA-Z0-9._-]", "_"); .replaceAll("[^a-zA-Z0-9._-]", "_");
String generatedFileName = cleanTitle + ".docx"; String generatedFileName = cleanTitle + ".docx";

View file

@ -11,6 +11,7 @@ import {
Calendar, Calendar,
Sparkles, Sparkles,
Info, Info,
PenTool,
} from 'lucide-react' } from 'lucide-react'
import { CustomSelect } from '../ui/Select' import { CustomSelect } from '../ui/Select'
import { reportService, type ReportTemplateOutput } from '../../services/reportService' import { reportService, type ReportTemplateOutput } from '../../services/reportService'
@ -28,7 +29,7 @@ interface CreateTemplateModalProps {
interface SectionFormItem { interface SectionFormItem {
sectionKey: string sectionKey: string
title: string title: string
sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE'
defaultValue?: string defaultValue?: string
dateFormat?: string dateFormat?: string
filterCategoryId?: number filterCategoryId?: number
@ -376,19 +377,33 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
value={section.sectionType} value={section.sectionType}
onChange={(val) => onChange={(val) =>
handleUpdateSection(idx, { handleUpdateSection(idx, {
sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST', sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE',
}) })
} }
options={[ options={[
{ value: 'FREE_TEXT', label: 'Texto Livre (Preenchido na emissão)' }, { value: 'FREE_TEXT', label: 'Texto Livre (Preenchido na emissão)' },
{ value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' }, { value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' },
{ value: 'DATE', label: 'Data (Com formato personalizado)' }, { 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' }, { value: 'TASK_LIST', label: 'Lista de Tarefas do Período' },
]} ]}
/> />
</div> </div>
</div> </div>
{/* Configuração de Campo de Tipo ASSINATURA */}
{section.sectionType === 'SIGNATURE' && (
<div className="p-3 rounded-xl bg-brand/5 border border-brand/15 text-[11px] text-foreground/80 flex items-start gap-2.5 animate-in fade-in duration-150">
<PenTool className="w-4 h-4 text-brand shrink-0 mt-0.5" />
<div>
<p className="font-bold text-brand">Preenchimento Automático com Assinatura Digital</p>
<p className="text-muted mt-0.5">
O BeeTask buscará automaticamente a <strong>assinatura padrão</strong> cadastrada no perfil do usuário que gerar o relatório e inserirá a imagem no Word na tag <code className="text-brand font-mono font-bold">{`{{${section.sectionKey || 'assinatura'}}}`}</code>. Se o usuário não tiver assinatura cadastrada, o campo ficará em branco (não assina).
</p>
</div>
</div>
)}
{/* Configuração de Campo de Tipo DATA */} {/* Configuração de Campo de Tipo DATA */}
{section.sectionType === 'DATE' && ( {section.sectionType === 'DATE' && (
<div className="pt-2 border-t border-border-soft/60 space-y-2 animate-in fade-in duration-150"> <div className="pt-2 border-t border-border-soft/60 space-y-2 animate-in fade-in duration-150">

View file

@ -12,6 +12,7 @@ import {
Sparkles, Sparkles,
Info, Info,
Check, Check,
PenTool,
} from 'lucide-react' } from 'lucide-react'
import { CustomSelect } from '../ui/Select' import { CustomSelect } from '../ui/Select'
import { reportService, type ReportTemplateOutput } from '../../services/reportService' import { reportService, type ReportTemplateOutput } from '../../services/reportService'
@ -30,7 +31,7 @@ interface EditTemplateModalProps {
interface SectionFormItem { interface SectionFormItem {
sectionKey: string sectionKey: string
title: string title: string
sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE'
defaultValue?: string defaultValue?: string
dateFormat?: string dateFormat?: string
filterCategoryId?: number filterCategoryId?: number
@ -398,19 +399,33 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
value={section.sectionType} value={section.sectionType}
onChange={(val) => onChange={(val) =>
handleUpdateSection(idx, { handleUpdateSection(idx, {
sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST', sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE',
}) })
} }
options={[ options={[
{ value: 'FREE_TEXT', label: 'Texto Livre (Preenchido na emissão)' }, { value: 'FREE_TEXT', label: 'Texto Livre (Preenchido na emissão)' },
{ value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' }, { value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' },
{ value: 'DATE', label: 'Data (Com formato personalizado)' }, { 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' }, { value: 'TASK_LIST', label: 'Lista de Tarefas do Período' },
]} ]}
/> />
</div> </div>
</div> </div>
{/* Configuração de Campo de Tipo ASSINATURA */}
{section.sectionType === 'SIGNATURE' && (
<div className="p-3 rounded-xl bg-brand/5 border border-brand/15 text-[11px] text-foreground/80 flex items-start gap-2.5 animate-in fade-in duration-150">
<PenTool className="w-4 h-4 text-brand shrink-0 mt-0.5" />
<div>
<p className="font-bold text-brand">Preenchimento Automático com Assinatura Digital</p>
<p className="text-muted mt-0.5">
O BeeTask buscará automaticamente a <strong>assinatura padrão</strong> cadastrada no perfil do usuário que gerar o relatório e inserirá a imagem no Word na tag <code className="text-brand font-mono font-bold">{`{{${section.sectionKey || 'assinatura'}}}`}</code>. Se o usuário não tiver assinatura cadastrada, o campo ficará em branco (não assina).
</p>
</div>
</div>
)}
{/* Configuração de Campo de Tipo DATA */} {/* Configuração de Campo de Tipo DATA */}
{section.sectionType === 'DATE' && ( {section.sectionType === 'DATE' && (
<div className="pt-2 border-t border-border-soft/60 space-y-2 animate-in fade-in duration-150"> <div className="pt-2 border-t border-border-soft/60 space-y-2 animate-in fade-in duration-150">

View file

@ -10,6 +10,7 @@ import {
Loader2, Loader2,
Lock, Lock,
Tag, Tag,
PenTool,
} from 'lucide-react' } from 'lucide-react'
import { CustomSelect } from '../ui/Select' import { CustomSelect } from '../ui/Select'
import { reportService, type ReportTemplateOutput } from '../../services/reportService' import { reportService, type ReportTemplateOutput } from '../../services/reportService'
@ -107,6 +108,12 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
return selectedTemplate.sections.filter((s) => s.sectionType === 'TASK_LIST') return selectedTemplate.sections.filter((s) => s.sectionType === 'TASK_LIST')
}, [selectedTemplate]) }, [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 // Preset Handlers
const handlePresetChange = (preset: 'current_month' | 'last_month' | 'custom') => { const handlePresetChange = (preset: 'current_month' | 'last_month' | 'custom') => {
setPeriodPreset(preset) setPeriodPreset(preset)
@ -515,6 +522,24 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
</div> </div>
</div> </div>
)} )}
{/* Seções de Assinatura Digital do Modelo */}
{signatureSections.length > 0 && (
<div className="p-4 rounded-2xl bg-surface-raised/40 border border-border-soft space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<PenTool className="w-4 h-4 text-brand" />
Assinatura Digital no Relatório
</span>
<span className="text-[10px] text-muted font-mono font-semibold">
{signatureSections.map((s) => `{{${s.sectionKey}}}`).join(', ')}
</span>
</div>
<p className="text-[11px] text-muted leading-relaxed">
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.
</p>
</div>
)}
</form> </form>
{/* Footer */} {/* Footer */}

View file

@ -98,6 +98,7 @@ export const ReportsPage: React.FC = () => {
const standardTags = [ const standardTags = [
{ tag: '{{nome}}', desc: 'Nome do colaborador (pega automaticamente do usuário logado)' }, { 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_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: '{{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)' }, { tag: '{{mes_ano}}', desc: 'Mês e ano por extenso (ex: Outubro/2026)' },

View file

@ -4,7 +4,7 @@ export interface ReportSectionOutput {
id: number id: number
sectionKey: string sectionKey: string
title: string title: string
sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE'
defaultValue?: string defaultValue?: string
dateFormat?: string dateFormat?: string
filterCategoryId?: number filterCategoryId?: number