diff --git a/backend/Dockerfile b/backend/Dockerfile index 220219d..38f3b83 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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/ 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 f4c92b9..6486e24 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 @@ -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 ); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/report/PdfConverterService.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/report/PdfConverterService.java new file mode 100644 index 0000000..d34dea5 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/report/PdfConverterService.java @@ -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(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateReportRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateReportRequest.java index 136bb46..79b48b9 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateReportRequest.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateReportRequest.java @@ -9,4 +9,5 @@ public class GenerateReportRequest { public LocalDate startDate; public LocalDate endDate; public Map customFields; + public String format; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java index 0d61c47..0553dc9 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java @@ -216,7 +216,8 @@ public class ReportController { req.projectId, req.startDate, req.endDate, - req.customFields + req.customFields, + req.format ); GenerateReportOutput output = generateReportUseCase.execute(input); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportInput.java index 90a9a96..40a92f6 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportInput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportInput.java @@ -10,5 +10,6 @@ public record GenerateReportInput( Long projectId, LocalDate startDate, LocalDate endDate, - Map customFields + Map customFields, + String format ) {} 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 2a2821f..2fc7ed9 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 @@ -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, diff --git a/frontend/src/components/reports/GenerateReportModal.tsx b/frontend/src/components/reports/GenerateReportModal.tsx index 24655cd..a66dc0b 100644 --- a/frontend/src/components/reports/GenerateReportModal.tsx +++ b/frontend/src/components/reports/GenerateReportModal.tsx @@ -55,6 +55,7 @@ export const GenerateReportModal: React.FC = ({ const [customFields, setCustomFields] = useState>({}) const [aiLoading, setAiLoading] = useState>({}) const [aiError, setAiError] = useState>({}) + const [formatTarget, setFormatTarget] = useState<'docx' | 'pdf'>('pdf') const [generating, setGenerating] = useState(false) const [error, setError] = useState(null) @@ -170,8 +171,7 @@ export const GenerateReportModal: React.FC = ({ 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 = ({ } try { + setFormatTarget(format) setGenerating(true) setError(null) setSuccess(false) @@ -196,6 +197,7 @@ export const GenerateReportModal: React.FC = ({ startDate, endDate, customFields, + format, }) // Download do arquivo gerado @@ -254,7 +256,7 @@ export const GenerateReportModal: React.FC = ({ {/* Form Body */} -
+ { e.preventDefault(); handleGenerate(formatTarget); }} className="p-6 overflow-y-auto space-y-5 flex-1"> {error && (
@@ -652,7 +654,7 @@ export const GenerateReportModal: React.FC = ({ {/* Footer */} -
+
- + +
+ + + +
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index dbd2ef8..25901e9 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -323,19 +323,18 @@ export const DashboardPage: React.FC = () => { - - +