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
RUN apt-get update && apt-get install -y --no-install-recommends libreoffice-writer-nogui && rm -rf /var/lib/apt/lists/*
WORKDIR /app
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.inject.Produces;
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.report.ai.GenerateAiSummaryUseCase;
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase;
@ -89,7 +90,8 @@ public class ReportUseCaseConfig {
StorageRepository storageRepository,
net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository,
AuthorizeOrganizationMemberUseCase authorizer,
OllamaService ollamaService
OllamaService ollamaService,
PdfConverterService pdfConverterService
) {
return new GenerateReportUseCase(
reportTemplateRepository,
@ -101,7 +103,8 @@ public class ReportUseCaseConfig {
storageRepository,
userSignatureRepository,
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 endDate;
public Map<String, String> customFields;
public String format;
}

View file

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

View file

@ -10,5 +10,6 @@ public record GenerateReportInput(
Long projectId,
LocalDate startDate,
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 AuthorizeOrganizationMemberUseCase authorizer;
private final net.beetechgroup.beetask.frameworks.ai.OllamaService ollamaService;
private final net.beetechgroup.beetask.frameworks.report.PdfConverterService pdfConverterService;
public GenerateReportUseCase(
ReportTemplateRepository reportTemplateRepository,
@ -63,7 +64,8 @@ public class GenerateReportUseCase {
StorageRepository storageRepository,
UserSignatureRepository userSignatureRepository,
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.generatedReportRepository = generatedReportRepository;
@ -75,6 +77,7 @@ public class GenerateReportUseCase {
this.userSignatureRepository = userSignatureRepository;
this.authorizer = authorizer;
this.ollamaService = ollamaService;
this.pdfConverterService = pdfConverterService;
}
public GenerateReportOutput execute(GenerateReportInput input) {
@ -332,21 +335,35 @@ public class GenerateReportUseCase {
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("/", "-"))
.replaceAll("[^a-zA-Z0-9._-]", "_");
String generatedFileName = cleanTitle + ".docx";
String generatedFileName = cleanTitle + extension;
String generatedStorageKey = "reports/generated/" + org.getId() + "/" + UUID.randomUUID() + "_" + generatedFileName;
String contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
String fileUrl = storageRepository.upload(
generatedStorageKey,
new ByteArrayInputStream(docxBytes),
new ByteArrayInputStream(finalBytes),
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();
generated.setReportTemplateId(template.getId());
generated.setOrganization(org);
@ -364,7 +381,7 @@ public class GenerateReportUseCase {
LOGGER.infof("Relatório gerado com sucesso! ID: %d, Arquivo: %s", savedReport.getId(), generatedFileName);
return new GenerateReportOutput(
docxBytes,
finalBytes,
generatedFileName,
contentType,
fileUrl,

View file

@ -55,6 +55,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
const [customFields, setCustomFields] = useState<Record<string, string>>({})
const [aiLoading, setAiLoading] = useState<Record<string, boolean>>({})
const [aiError, setAiError] = useState<Record<string, string | null>>({})
const [formatTarget, setFormatTarget] = useState<'docx' | 'pdf'>('pdf')
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -170,8 +171,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
setCustomFields((prev) => ({ ...prev, [key]: value }))
}
const handleGenerate = async (e: React.FormEvent) => {
e.preventDefault()
const handleGenerate = async (format: 'docx' | 'pdf' = 'pdf') => {
if (!selectedTemplateId) {
setError('Selecione um modelo de relatório')
return
@ -186,6 +186,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
}
try {
setFormatTarget(format)
setGenerating(true)
setError(null)
setSuccess(false)
@ -196,6 +197,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
startDate,
endDate,
customFields,
format,
})
// Download do arquivo gerado
@ -254,7 +256,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
</div>
{/* 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 && (
<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" />
@ -652,7 +654,7 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
</form>
{/* 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
type="button"
onClick={onClose}
@ -661,24 +663,46 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
>
Cancelar
</button>
<button
type="button"
onClick={handleGenerate}
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 ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Preenchendo e Gerando .DOCX...</span>
</>
) : (
<>
<FileDown className="w-4 h-4" />
<span>Gerar e Baixar (.DOCX)</span>
</>
)}
</button>
<div className="flex items-center gap-2.5 flex-wrap">
<button
type="button"
onClick={() => handleGenerate('docx')}
disabled={generating || templates.length === 0}
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"
>
{generating && formatTarget === 'docx' ? (
<>
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
<span>Gerando Word...</span>
</>
) : (
<>
<FileText className="w-4 h-4 text-blue-500" />
<span>Baixar (.DOCX)</span>
</>
)}
</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>

View file

@ -323,19 +323,18 @@ export const DashboardPage: React.FC = () => {
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-brand' : ''}`} />
</button>
<Link to="/tasks">
<button
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"
<Link to="/tasks" className="inline-flex shrink-0">
<span
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"
>
Quadro Kanban
</button>
</span>
</Link>
<button
type="button"
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" />
<span>Gerar Relatório</span>
@ -344,7 +343,7 @@ export const DashboardPage: React.FC = () => {
<button
type="button"
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" />
<span>Nova Tarefa</span>

View file

@ -151,7 +151,7 @@ export const ReportsPage: React.FC = () => {
<button
type="button"
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" />
<span>Novo Modelo .DOCX</span>
@ -161,7 +161,7 @@ export const ReportsPage: React.FC = () => {
type="button"
onClick={() => handleOpenGenerate()}
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" />
<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"
>
<Download className="w-3.5 h-3.5" />
<span>Baixar (.DOCX)</span>
<span>Baixar ({rep.fileName?.toLowerCase().endsWith('.pdf') ? '.PDF' : '.DOCX'})</span>
</a>
</td>
</tr>

View file

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