feat(reports): add PDF report download via headless LibreOffice converter

This commit is contained in:
Gabriel Menezes 2026-11-01 12:50:42 -04:00
parent b8c23d5faa
commit 977c7ffd7a
11 changed files with 176 additions and 45 deletions

View file

@ -12,6 +12,8 @@ RUN mvn -B clean package -DskipTests
FROM eclipse-temurin:21-jre FROM eclipse-temurin:21-jre
RUN apt-get update && apt-get install -y --no-install-recommends libreoffice-writer-nogui && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=build /app/target/quarkus-app/lib/ ./lib/ COPY --from=build /app/target/quarkus-app/lib/ ./lib/

View file

@ -3,6 +3,7 @@ package net.beetechgroup.beetask.frameworks.config;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces; import jakarta.enterprise.inject.Produces;
import net.beetechgroup.beetask.frameworks.ai.OllamaService; import net.beetechgroup.beetask.frameworks.ai.OllamaService;
import net.beetechgroup.beetask.frameworks.report.PdfConverterService;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.report.ai.GenerateAiSummaryUseCase; import net.beetechgroup.beetask.usecase.report.ai.GenerateAiSummaryUseCase;
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase;
@ -89,7 +90,8 @@ public class ReportUseCaseConfig {
StorageRepository storageRepository, StorageRepository storageRepository,
net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository, net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository,
AuthorizeOrganizationMemberUseCase authorizer, AuthorizeOrganizationMemberUseCase authorizer,
OllamaService ollamaService OllamaService ollamaService,
PdfConverterService pdfConverterService
) { ) {
return new GenerateReportUseCase( return new GenerateReportUseCase(
reportTemplateRepository, reportTemplateRepository,
@ -101,7 +103,8 @@ public class ReportUseCaseConfig {
storageRepository, storageRepository,
userSignatureRepository, userSignatureRepository,
authorizer, authorizer,
ollamaService ollamaService,
pdfConverterService
); );
} }

View file

@ -0,0 +1,81 @@
package net.beetechgroup.beetask.frameworks.report;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@ApplicationScoped
public class PdfConverterService {
private static final Logger LOGGER = Logger.getLogger(PdfConverterService.class);
public byte[] convertDocxToPdf(byte[] docxBytes) {
if (docxBytes == null || docxBytes.length == 0) {
throw new IllegalArgumentException("Arquivo DOCX vazio");
}
String tempId = UUID.randomUUID().toString();
Path tempDir = null;
try {
tempDir = Files.createTempDirectory("docx_pdf_" + tempId);
Path docxPath = tempDir.resolve("document.docx");
Files.write(docxPath, docxBytes);
ProcessBuilder pb = new ProcessBuilder(
"soffice",
"--headless",
"--convert-to",
"pdf",
"--outdir",
tempDir.toAbsolutePath().toString(),
docxPath.toAbsolutePath().toString()
);
pb.redirectErrorStream(true);
Process process = pb.start();
boolean finished = process.waitFor(45, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
throw new IllegalStateException("Timeout ao converter DOCX para PDF com LibreOffice");
}
int exitCode = process.exitValue();
if (exitCode != 0) {
String errorOutput = new String(process.getInputStream().readAllBytes());
throw new IllegalStateException("LibreOffice falhou com código " + exitCode + ": " + errorOutput);
}
Path pdfPath = tempDir.resolve("document.pdf");
if (!Files.exists(pdfPath)) {
throw new IllegalStateException("Arquivo PDF gerado não foi encontrado");
}
LOGGER.infof("Conversão para PDF realizada com sucesso (%d bytes)", Files.size(pdfPath));
return Files.readAllBytes(pdfPath);
} catch (Exception e) {
LOGGER.errorf("Erro ao converter DOCX para PDF: %s", e.getMessage());
throw new RuntimeException("Falha ao gerar PDF: " + e.getMessage(), e);
} finally {
if (tempDir != null) {
try {
deleteDirectory(tempDir.toFile());
} catch (Exception ignored) {}
}
}
}
private void deleteDirectory(File dir) {
File[] files = dir.listFiles();
if (files != null) {
for (File f : files) {
if (f.isDirectory()) deleteDirectory(f);
else f.delete();
}
}
dir.delete();
}
}

View file

@ -9,4 +9,5 @@ public class GenerateReportRequest {
public LocalDate startDate; public LocalDate startDate;
public LocalDate endDate; public LocalDate endDate;
public Map<String, String> customFields; public Map<String, String> customFields;
public String format;
} }

View file

@ -216,7 +216,8 @@ public class ReportController {
req.projectId, req.projectId,
req.startDate, req.startDate,
req.endDate, req.endDate,
req.customFields req.customFields,
req.format
); );
GenerateReportOutput output = generateReportUseCase.execute(input); GenerateReportOutput output = generateReportUseCase.execute(input);

View file

@ -10,5 +10,6 @@ public record GenerateReportInput(
Long projectId, Long projectId,
LocalDate startDate, LocalDate startDate,
LocalDate endDate, LocalDate endDate,
Map<String, String> customFields Map<String, String> customFields,
String format
) {} ) {}

View file

@ -52,6 +52,7 @@ public class GenerateReportUseCase {
private final UserSignatureRepository userSignatureRepository; private final UserSignatureRepository userSignatureRepository;
private final AuthorizeOrganizationMemberUseCase authorizer; private final AuthorizeOrganizationMemberUseCase authorizer;
private final net.beetechgroup.beetask.frameworks.ai.OllamaService ollamaService; private final net.beetechgroup.beetask.frameworks.ai.OllamaService ollamaService;
private final net.beetechgroup.beetask.frameworks.report.PdfConverterService pdfConverterService;
public GenerateReportUseCase( public GenerateReportUseCase(
ReportTemplateRepository reportTemplateRepository, ReportTemplateRepository reportTemplateRepository,
@ -63,7 +64,8 @@ public class GenerateReportUseCase {
StorageRepository storageRepository, StorageRepository storageRepository,
UserSignatureRepository userSignatureRepository, UserSignatureRepository userSignatureRepository,
AuthorizeOrganizationMemberUseCase authorizer, AuthorizeOrganizationMemberUseCase authorizer,
net.beetechgroup.beetask.frameworks.ai.OllamaService ollamaService net.beetechgroup.beetask.frameworks.ai.OllamaService ollamaService,
net.beetechgroup.beetask.frameworks.report.PdfConverterService pdfConverterService
) { ) {
this.reportTemplateRepository = reportTemplateRepository; this.reportTemplateRepository = reportTemplateRepository;
this.generatedReportRepository = generatedReportRepository; this.generatedReportRepository = generatedReportRepository;
@ -75,6 +77,7 @@ public class GenerateReportUseCase {
this.userSignatureRepository = userSignatureRepository; this.userSignatureRepository = userSignatureRepository;
this.authorizer = authorizer; this.authorizer = authorizer;
this.ollamaService = ollamaService; this.ollamaService = ollamaService;
this.pdfConverterService = pdfConverterService;
} }
public GenerateReportOutput execute(GenerateReportInput input) { public GenerateReportOutput execute(GenerateReportInput input) {
@ -332,21 +335,35 @@ public class GenerateReportUseCase {
throw new RuntimeException("Falha ao gerar o documento Word: " + e.getMessage(), e); throw new RuntimeException("Falha ao gerar o documento Word: " + e.getMessage(), e);
} }
// 7. Fazer upload do relatório gerado para o MinIO // 7. Fazer conversão para PDF se solicitado e upload do relatório gerado para o MinIO
byte[] finalBytes = docxBytes;
String extension = ".docx";
String contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
if ("pdf".equalsIgnoreCase(input.format())) {
if (pdfConverterService != null) {
LOGGER.infof("Convertendo relatório para PDF...");
finalBytes = pdfConverterService.convertDocxToPdf(docxBytes);
extension = ".pdf";
contentType = "application/pdf";
} else {
LOGGER.warn("PdfConverterService não configurado. Retornando documento como .docx.");
}
}
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 + extension;
String generatedStorageKey = "reports/generated/" + org.getId() + "/" + UUID.randomUUID() + "_" + generatedFileName; String generatedStorageKey = "reports/generated/" + org.getId() + "/" + UUID.randomUUID() + "_" + generatedFileName;
String contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
String fileUrl = storageRepository.upload( String fileUrl = storageRepository.upload(
generatedStorageKey, generatedStorageKey,
new ByteArrayInputStream(docxBytes), new ByteArrayInputStream(finalBytes),
contentType, contentType,
docxBytes.length finalBytes.length
); );
// 7. Persistir histórico do relatório gerado // 8. Persistir histórico do relatório gerado
GeneratedReport generated = new GeneratedReport(); GeneratedReport generated = new GeneratedReport();
generated.setReportTemplateId(template.getId()); generated.setReportTemplateId(template.getId());
generated.setOrganization(org); generated.setOrganization(org);
@ -364,7 +381,7 @@ public class GenerateReportUseCase {
LOGGER.infof("Relatório gerado com sucesso! ID: %d, Arquivo: %s", savedReport.getId(), generatedFileName); LOGGER.infof("Relatório gerado com sucesso! ID: %d, Arquivo: %s", savedReport.getId(), generatedFileName);
return new GenerateReportOutput( return new GenerateReportOutput(
docxBytes, finalBytes,
generatedFileName, generatedFileName,
contentType, contentType,
fileUrl, fileUrl,

View file

@ -55,6 +55,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
const [customFields, setCustomFields] = useState<Record<string, string>>({}) const [customFields, setCustomFields] = useState<Record<string, string>>({})
const [aiLoading, setAiLoading] = useState<Record<string, boolean>>({}) const [aiLoading, setAiLoading] = useState<Record<string, boolean>>({})
const [aiError, setAiError] = useState<Record<string, string | null>>({}) const [aiError, setAiError] = useState<Record<string, string | null>>({})
const [formatTarget, setFormatTarget] = useState<'docx' | 'pdf'>('pdf')
const [generating, setGenerating] = useState(false) const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -170,8 +171,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
setCustomFields((prev) => ({ ...prev, [key]: value })) setCustomFields((prev) => ({ ...prev, [key]: value }))
} }
const handleGenerate = async (e: React.FormEvent) => { const handleGenerate = async (format: 'docx' | 'pdf' = 'pdf') => {
e.preventDefault()
if (!selectedTemplateId) { if (!selectedTemplateId) {
setError('Selecione um modelo de relatório') setError('Selecione um modelo de relatório')
return return
@ -186,6 +186,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
} }
try { try {
setFormatTarget(format)
setGenerating(true) setGenerating(true)
setError(null) setError(null)
setSuccess(false) setSuccess(false)
@ -196,6 +197,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
startDate, startDate,
endDate, endDate,
customFields, customFields,
format,
}) })
// Download do arquivo gerado // Download do arquivo gerado
@ -254,7 +256,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
</div> </div>
{/* Form Body */} {/* Form Body */}
<form onSubmit={handleGenerate} className="p-6 overflow-y-auto space-y-5 flex-1"> <form onSubmit={(e) => { e.preventDefault(); handleGenerate(formatTarget); }} className="p-6 overflow-y-auto space-y-5 flex-1">
{error && ( {error && (
<div className="p-3.5 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-700 dark:text-red-400 text-xs flex items-center gap-2"> <div className="p-3.5 rounded-2xl bg-red-500/10 border border-red-500/20 text-red-700 dark:text-red-400 text-xs flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" /> <AlertCircle className="w-4 h-4 shrink-0" />
@ -652,7 +654,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
</form> </form>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-end gap-3 p-5 border-t border-border-soft bg-surface-raised/40"> <div className="flex items-center justify-between gap-3 p-5 border-t border-border-soft bg-surface-raised/40 flex-wrap">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@ -661,24 +663,46 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
> >
Cancelar Cancelar
</button> </button>
<button
type="button" <div className="flex items-center gap-2.5 flex-wrap">
onClick={handleGenerate} <button
disabled={generating || templates.length === 0} type="button"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-brand to-teal-700 hover:from-brand-hover hover:to-teal-800 text-white text-xs font-bold shadow-md shadow-brand/20 transition-all cursor-pointer disabled:opacity-50" onClick={() => handleGenerate('docx')}
> disabled={generating || templates.length === 0}
{generating ? ( className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-border-soft hover:border-blue-500/40 bg-surface hover:bg-surface-raised text-foreground text-xs font-bold transition-all shadow-xs cursor-pointer disabled:opacity-50"
<> >
<Loader2 className="w-4 h-4 animate-spin" /> {generating && formatTarget === 'docx' ? (
<span>Preenchendo e Gerando .DOCX...</span> <>
</> <Loader2 className="w-4 h-4 animate-spin text-blue-500" />
) : ( <span>Gerando Word...</span>
<> </>
<FileDown className="w-4 h-4" /> ) : (
<span>Gerar e Baixar (.DOCX)</span> <>
</> <FileText className="w-4 h-4 text-blue-500" />
)} <span>Baixar (.DOCX)</span>
</button> </>
)}
</button>
<button
type="button"
onClick={() => handleGenerate('pdf')}
disabled={generating || templates.length === 0}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-brand to-teal-700 hover:from-brand-hover hover:to-teal-800 text-white text-xs font-bold shadow-md shadow-brand/20 transition-all cursor-pointer disabled:opacity-50"
>
{generating && formatTarget === 'pdf' ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Convertendo e Gerando PDF...</span>
</>
) : (
<>
<FileDown className="w-4 h-4" />
<span>Baixar (.PDF)</span>
</>
)}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -323,19 +323,18 @@ export const DashboardPage: React.FC = () => {
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-brand' : ''}`} /> <RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-brand' : ''}`} />
</button> </button>
<Link to="/tasks"> <Link to="/tasks" className="inline-flex shrink-0">
<button <span
type="button" className="px-4 py-2.5 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md text-xs font-bold text-foreground hover:bg-surface-raised hover:border-brand/40 transition-all shadow-xs inline-flex items-center cursor-pointer"
className="px-4 py-2.5 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md text-xs font-bold text-foreground hover:bg-surface-raised hover:border-brand/40 transition-all shadow-xs"
> >
Quadro Kanban Quadro Kanban
</button> </span>
</Link> </Link>
<button <button
type="button" type="button"
onClick={() => setIsReportModalOpen(true)} onClick={() => setIsReportModalOpen(true)}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-brand/30 bg-brand/10 hover:bg-brand/20 text-brand text-xs font-bold transition-all shadow-xs cursor-pointer" className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-brand/30 bg-brand/10 hover:bg-brand/20 text-brand text-xs font-bold transition-all shadow-xs cursor-pointer shrink-0"
> >
<FileDown className="w-4 h-4" /> <FileDown className="w-4 h-4" />
<span>Gerar Relatório</span> <span>Gerar Relatório</span>
@ -344,7 +343,7 @@ export const DashboardPage: React.FC = () => {
<button <button
type="button" type="button"
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand hover:bg-brand-strong text-white font-bold text-xs shadow-lg shadow-brand/25 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98]" className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand hover:bg-brand-strong text-white font-bold text-xs shadow-lg shadow-brand/25 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shrink-0"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
<span>Nova Tarefa</span> <span>Nova Tarefa</span>

View file

@ -151,7 +151,7 @@ export const ReportsPage: React.FC = () => {
<button <button
type="button" type="button"
onClick={() => setIsCreateOpen(true)} onClick={() => setIsCreateOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-border-soft hover:border-brand/40 bg-surface/80 hover:bg-surface-raised text-foreground text-xs font-bold transition-all shadow-xs cursor-pointer" className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-border-soft hover:border-brand/40 bg-surface/80 hover:bg-surface-raised text-foreground text-xs font-bold transition-all shadow-xs cursor-pointer shrink-0"
> >
<Plus className="w-4 h-4 text-brand" /> <Plus className="w-4 h-4 text-brand" />
<span>Novo Modelo .DOCX</span> <span>Novo Modelo .DOCX</span>
@ -161,7 +161,7 @@ export const ReportsPage: React.FC = () => {
type="button" type="button"
onClick={() => handleOpenGenerate()} onClick={() => handleOpenGenerate()}
disabled={templates.length === 0} disabled={templates.length === 0}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-brand to-teal-700 hover:from-brand-hover hover:to-teal-800 text-white text-xs font-bold shadow-md shadow-brand/20 transition-all cursor-pointer disabled:opacity-50" className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-brand to-teal-700 hover:from-brand-hover hover:to-teal-800 text-white text-xs font-bold shadow-md shadow-brand/20 transition-all cursor-pointer disabled:opacity-50 shrink-0"
> >
<FileDown className="w-4 h-4" /> <FileDown className="w-4 h-4" />
<span>Gerar Relatório</span> <span>Gerar Relatório</span>
@ -414,7 +414,7 @@ export const ReportsPage: React.FC = () => {
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-brand/10 hover:bg-brand text-brand hover:text-white text-xs font-bold transition-all" className="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-brand/10 hover:bg-brand text-brand hover:text-white text-xs font-bold transition-all"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
<span>Baixar (.DOCX)</span> <span>Baixar ({rep.fileName?.toLowerCase().endsWith('.pdf') ? '.PDF' : '.DOCX'})</span>
</a> </a>
</td> </td>
</tr> </tr>

View file

@ -61,6 +61,7 @@ export interface GenerateReportPayload {
startDate: string // YYYY-MM-DD startDate: string // YYYY-MM-DD
endDate: string // YYYY-MM-DD endDate: string // YYYY-MM-DD
customFields?: Record<string, string> customFields?: Record<string, string>
format?: 'docx' | 'pdf'
} }
export interface GenerateAiSummaryPayload { export interface GenerateAiSummaryPayload {
@ -124,7 +125,8 @@ export const reportService = {
// Obter nome do arquivo do header Content-Disposition ou gerar fallback // Obter nome do arquivo do header Content-Disposition ou gerar fallback
const disposition = res.headers.get('content-disposition') const disposition = res.headers.get('content-disposition')
let fileName = `Relatorio_${payload.startDate}_a_${payload.endDate}.docx` const ext = payload.format === 'pdf' ? 'pdf' : 'docx'
let fileName = `Relatorio_${payload.startDate}_a_${payload.endDate}.${ext}`
if (disposition && disposition.includes('filename=')) { if (disposition && disposition.includes('filename=')) {
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match && match[1]) { if (match && match[1]) {