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
|
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/
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 startDate;
|
||||||
public LocalDate endDate;
|
public LocalDate endDate;
|
||||||
public Map<String, String> customFields;
|
public Map<String, String> customFields;
|
||||||
|
public String format;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue