feat(reports): add PDF report download via headless LibreOffice converter
This commit is contained in:
parent
b8c23d5faa
commit
977c7ffd7a
11 changed files with 176 additions and 45 deletions
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -9,4 +9,5 @@ public class GenerateReportRequest {
|
|||
public LocalDate startDate;
|
||||
public LocalDate endDate;
|
||||
public Map<String, String> customFields;
|
||||
public String format;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,7 +216,8 @@ public class ReportController {
|
|||
req.projectId,
|
||||
req.startDate,
|
||||
req.endDate,
|
||||
req.customFields
|
||||
req.customFields,
|
||||
req.format
|
||||
);
|
||||
|
||||
GenerateReportOutput output = generateReportUseCase.execute(input);
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ public record GenerateReportInput(
|
|||
Long projectId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
Map<String, String> customFields
|
||||
Map<String, String> customFields,
|
||||
String format
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue