feat(reports): add docx template management and dynamic report generation module
This commit is contained in:
parent
e141ad0721
commit
55fd823b2d
42 changed files with 4102 additions and 8 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,5 +4,6 @@
|
||||||
.env
|
.env
|
||||||
*.pem
|
*.pem
|
||||||
*.sql
|
*.sql
|
||||||
|
!backend/src/main/resources/db/migration/*.sql
|
||||||
*.sql.gz
|
*.sql.gz
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,11 @@
|
||||||
<artifactId>jbcrypt</artifactId>
|
<artifactId>jbcrypt</artifactId>
|
||||||
<version>0.4</version>
|
<version>0.4</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi-ooxml</artifactId>
|
||||||
|
<version>5.4.0</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-junit</artifactId>
|
<artifactId>quarkus-junit</artifactId>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
package net.beetechgroup.beetask.entities.report;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import net.beetechgroup.beetask.entities.Project;
|
||||||
|
import net.beetechgroup.beetask.entities.User;
|
||||||
|
import net.beetechgroup.beetask.entities.organization.Organization;
|
||||||
|
|
||||||
|
public class GeneratedReport {
|
||||||
|
private Long id;
|
||||||
|
private Long reportTemplateId;
|
||||||
|
private Organization organization;
|
||||||
|
private Project project;
|
||||||
|
private User user;
|
||||||
|
private String title;
|
||||||
|
private LocalDate startDate;
|
||||||
|
private LocalDate endDate;
|
||||||
|
private String fileUrl;
|
||||||
|
private String fileName;
|
||||||
|
private String storageKey;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getReportTemplateId() {
|
||||||
|
return reportTemplateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReportTemplateId(Long reportTemplateId) {
|
||||||
|
this.reportTemplateId = reportTemplateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Organization getOrganization() {
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrganization(Organization organization) {
|
||||||
|
this.organization = organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Project getProject() {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProject(Project project) {
|
||||||
|
this.project = project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(User user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getStartDate() {
|
||||||
|
return startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartDate(LocalDate startDate) {
|
||||||
|
this.startDate = startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getEndDate() {
|
||||||
|
return endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEndDate(LocalDate endDate) {
|
||||||
|
this.endDate = endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFileUrl() {
|
||||||
|
return fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileUrl(String fileUrl) {
|
||||||
|
this.fileUrl = fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFileName() {
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileName(String fileName) {
|
||||||
|
this.fileName = fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStorageKey() {
|
||||||
|
return storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStorageKey(String storageKey) {
|
||||||
|
this.storageKey = storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
package net.beetechgroup.beetask.entities.report;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import net.beetechgroup.beetask.entities.Category;
|
||||||
|
|
||||||
|
public class ReportSection {
|
||||||
|
private Long id;
|
||||||
|
private Long reportTemplateId;
|
||||||
|
private String sectionKey;
|
||||||
|
private String title;
|
||||||
|
private String sectionType; // 'FREE_TEXT' ou 'TASK_LIST'
|
||||||
|
private Category filterCategory;
|
||||||
|
private String filterStatus; // 'ALL', 'COMPLETED', 'IN_PROGRESS'
|
||||||
|
private Boolean includeTime;
|
||||||
|
private Integer orderIndex;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getReportTemplateId() {
|
||||||
|
return reportTemplateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReportTemplateId(Long reportTemplateId) {
|
||||||
|
this.reportTemplateId = reportTemplateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSectionKey() {
|
||||||
|
return sectionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSectionKey(String sectionKey) {
|
||||||
|
this.sectionKey = sectionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSectionType() {
|
||||||
|
return sectionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSectionType(String sectionType) {
|
||||||
|
this.sectionType = sectionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Category getFilterCategory() {
|
||||||
|
return filterCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFilterCategory(Category filterCategory) {
|
||||||
|
this.filterCategory = filterCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFilterStatus() {
|
||||||
|
return filterStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFilterStatus(String filterStatus) {
|
||||||
|
this.filterStatus = filterStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIncludeTime() {
|
||||||
|
return includeTime != null ? includeTime : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIncludeTime(Boolean includeTime) {
|
||||||
|
this.includeTime = includeTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getOrderIndex() {
|
||||||
|
return orderIndex != null ? orderIndex : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrderIndex(Integer orderIndex) {
|
||||||
|
this.orderIndex = orderIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
package net.beetechgroup.beetask.entities.report;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import net.beetechgroup.beetask.entities.Project;
|
||||||
|
import net.beetechgroup.beetask.entities.User;
|
||||||
|
import net.beetechgroup.beetask.entities.organization.Organization;
|
||||||
|
|
||||||
|
public class ReportTemplate {
|
||||||
|
private Long id;
|
||||||
|
private Organization organization;
|
||||||
|
private Project project;
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private String scope; // 'ORGANIZATION' ou 'PROJECT'
|
||||||
|
private String fileUrl;
|
||||||
|
private String fileName;
|
||||||
|
private String storageKey;
|
||||||
|
private User createdBy;
|
||||||
|
private List<ReportSection> sections = new ArrayList<>();
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Organization getOrganization() {
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrganization(Organization organization) {
|
||||||
|
this.organization = organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Project getProject() {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProject(Project project) {
|
||||||
|
this.project = project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getScope() {
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setScope(String scope) {
|
||||||
|
this.scope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFileUrl() {
|
||||||
|
return fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileUrl(String fileUrl) {
|
||||||
|
this.fileUrl = fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFileName() {
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileName(String fileName) {
|
||||||
|
this.fileName = fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStorageKey() {
|
||||||
|
return storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStorageKey(String storageKey) {
|
||||||
|
this.storageKey = storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getCreatedBy() {
|
||||||
|
return createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedBy(User createdBy) {
|
||||||
|
this.createdBy = createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ReportSection> getSections() {
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSections(List<ReportSection> sections) {
|
||||||
|
this.sections = sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package net.beetechgroup.beetask.frameworks.config;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.enterprise.inject.Produces;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.delete.DeleteReportTemplateUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.generate.GenerateReportUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.list.ListGeneratedReportsUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.list.ListReportTemplatesUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.GeneratedReportRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.ReportTemplateRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.StorageRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ReportUseCaseConfig {
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public CreateReportTemplateUseCase createReportTemplateUseCase(
|
||||||
|
ReportTemplateRepository reportTemplateRepository,
|
||||||
|
OrganizationRepository organizationRepository,
|
||||||
|
ProjectRepository projectRepository,
|
||||||
|
CategoryRepository categoryRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
StorageRepository storageRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer
|
||||||
|
) {
|
||||||
|
return new CreateReportTemplateUseCase(
|
||||||
|
reportTemplateRepository,
|
||||||
|
organizationRepository,
|
||||||
|
projectRepository,
|
||||||
|
categoryRepository,
|
||||||
|
userRepository,
|
||||||
|
storageRepository,
|
||||||
|
authorizer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public ListReportTemplatesUseCase listReportTemplatesUseCase(
|
||||||
|
ReportTemplateRepository reportTemplateRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer
|
||||||
|
) {
|
||||||
|
return new ListReportTemplatesUseCase(reportTemplateRepository, authorizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public DeleteReportTemplateUseCase deleteReportTemplateUseCase(
|
||||||
|
ReportTemplateRepository reportTemplateRepository,
|
||||||
|
StorageRepository storageRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer
|
||||||
|
) {
|
||||||
|
return new DeleteReportTemplateUseCase(reportTemplateRepository, storageRepository, authorizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public GenerateReportUseCase generateReportUseCase(
|
||||||
|
ReportTemplateRepository reportTemplateRepository,
|
||||||
|
GeneratedReportRepository generatedReportRepository,
|
||||||
|
TaskRepository taskRepository,
|
||||||
|
OrganizationRepository organizationRepository,
|
||||||
|
ProjectRepository projectRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
StorageRepository storageRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer
|
||||||
|
) {
|
||||||
|
return new GenerateReportUseCase(
|
||||||
|
reportTemplateRepository,
|
||||||
|
generatedReportRepository,
|
||||||
|
taskRepository,
|
||||||
|
organizationRepository,
|
||||||
|
projectRepository,
|
||||||
|
userRepository,
|
||||||
|
storageRepository,
|
||||||
|
authorizer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public ListGeneratedReportsUseCase listGeneratedReportsUseCase(
|
||||||
|
GeneratedReportRepository generatedReportRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer
|
||||||
|
) {
|
||||||
|
return new ListGeneratedReportsUseCase(generatedReportRepository, authorizer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
package net.beetechgroup.beetask.frameworks.persistence.entities;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "generated_reports")
|
||||||
|
public class GeneratedReportEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "report_template_id")
|
||||||
|
private ReportTemplateEntity reportTemplate;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "organization_id", nullable = false)
|
||||||
|
private OrganizationEntity organization;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "project_id")
|
||||||
|
private ProjectEntity project;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "user_id")
|
||||||
|
private UserEntity user;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(name = "start_date", nullable = false)
|
||||||
|
private LocalDate startDate;
|
||||||
|
|
||||||
|
@Column(name = "end_date", nullable = false)
|
||||||
|
private LocalDate endDate;
|
||||||
|
|
||||||
|
@Column(name = "file_url", nullable = false, length = 1024)
|
||||||
|
private String fileUrl;
|
||||||
|
|
||||||
|
@Column(name = "file_name", nullable = false)
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
@Column(name = "storage_key", nullable = false, length = 512)
|
||||||
|
private String storageKey;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReportTemplateEntity getReportTemplate() {
|
||||||
|
return reportTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReportTemplate(ReportTemplateEntity reportTemplate) {
|
||||||
|
this.reportTemplate = reportTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationEntity getOrganization() {
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrganization(OrganizationEntity organization) {
|
||||||
|
this.organization = organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProjectEntity getProject() {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProject(ProjectEntity project) {
|
||||||
|
this.project = project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserEntity getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(UserEntity user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getStartDate() {
|
||||||
|
return startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartDate(LocalDate startDate) {
|
||||||
|
this.startDate = startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getEndDate() {
|
||||||
|
return endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEndDate(LocalDate endDate) {
|
||||||
|
this.endDate = endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFileUrl() {
|
||||||
|
return fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileUrl(String fileUrl) {
|
||||||
|
this.fileUrl = fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFileName() {
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileName(String fileName) {
|
||||||
|
this.fileName = fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStorageKey() {
|
||||||
|
return storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStorageKey(String storageKey) {
|
||||||
|
this.storageKey = storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
package net.beetechgroup.beetask.frameworks.persistence.entities;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "report_sections")
|
||||||
|
public class ReportSectionEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "report_template_id", nullable = false)
|
||||||
|
private ReportTemplateEntity reportTemplate;
|
||||||
|
|
||||||
|
@Column(name = "section_key", nullable = false, length = 100)
|
||||||
|
private String sectionKey;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(name = "section_type", nullable = false, length = 50)
|
||||||
|
private String sectionType; // 'FREE_TEXT' ou 'TASK_LIST'
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "filter_category_id")
|
||||||
|
private CategoryEntity filterCategory;
|
||||||
|
|
||||||
|
@Column(name = "filter_status", length = 50)
|
||||||
|
private String filterStatus = "ALL";
|
||||||
|
|
||||||
|
@Column(name = "include_time")
|
||||||
|
private Boolean includeTime = true;
|
||||||
|
|
||||||
|
@Column(name = "order_index")
|
||||||
|
private Integer orderIndex = 0;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReportTemplateEntity getReportTemplate() {
|
||||||
|
return reportTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReportTemplate(ReportTemplateEntity reportTemplate) {
|
||||||
|
this.reportTemplate = reportTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSectionKey() {
|
||||||
|
return sectionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSectionKey(String sectionKey) {
|
||||||
|
this.sectionKey = sectionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSectionType() {
|
||||||
|
return sectionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSectionType(String sectionType) {
|
||||||
|
this.sectionType = sectionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CategoryEntity getFilterCategory() {
|
||||||
|
return filterCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFilterCategory(CategoryEntity filterCategory) {
|
||||||
|
this.filterCategory = filterCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFilterStatus() {
|
||||||
|
return filterStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFilterStatus(String filterStatus) {
|
||||||
|
this.filterStatus = filterStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIncludeTime() {
|
||||||
|
return includeTime != null ? includeTime : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIncludeTime(Boolean includeTime) {
|
||||||
|
this.includeTime = includeTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getOrderIndex() {
|
||||||
|
return orderIndex != null ? orderIndex : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrderIndex(Integer orderIndex) {
|
||||||
|
this.orderIndex = orderIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
package net.beetechgroup.beetask.frameworks.persistence.entities;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.OrderBy;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "report_templates")
|
||||||
|
public class ReportTemplateEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "organization_id", nullable = false)
|
||||||
|
private OrganizationEntity organization;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "project_id")
|
||||||
|
private ProjectEntity project;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 32)
|
||||||
|
private String scope;
|
||||||
|
|
||||||
|
@Column(name = "file_url", nullable = false, length = 1024)
|
||||||
|
private String fileUrl;
|
||||||
|
|
||||||
|
@Column(name = "file_name", nullable = false)
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
@Column(name = "storage_key", nullable = false, length = 512)
|
||||||
|
private String storageKey;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "created_by")
|
||||||
|
private UserEntity createdBy;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "reportTemplate", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("orderIndex ASC")
|
||||||
|
private List<ReportSectionEntity> sections = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt = LocalDateTime.now();
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrganizationEntity getOrganization() {
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrganization(OrganizationEntity organization) {
|
||||||
|
this.organization = organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProjectEntity getProject() {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProject(ProjectEntity project) {
|
||||||
|
this.project = project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescription(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getScope() {
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setScope(String scope) {
|
||||||
|
this.scope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFileUrl() {
|
||||||
|
return fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileUrl(String fileUrl) {
|
||||||
|
this.fileUrl = fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFileName() {
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileName(String fileName) {
|
||||||
|
this.fileName = fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStorageKey() {
|
||||||
|
return storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStorageKey(String storageKey) {
|
||||||
|
this.storageKey = storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserEntity getCreatedBy() {
|
||||||
|
return createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedBy(UserEntity createdBy) {
|
||||||
|
this.createdBy = createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ReportSectionEntity> getSections() {
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSections(List<ReportSectionEntity> sections) {
|
||||||
|
this.sections = sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
package net.beetechgroup.beetask.frameworks.persistence.mapper;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import net.beetechgroup.beetask.entities.report.GeneratedReport;
|
||||||
|
import net.beetechgroup.beetask.entities.report.ReportSection;
|
||||||
|
import net.beetechgroup.beetask.entities.report.ReportTemplate;
|
||||||
|
import net.beetechgroup.beetask.frameworks.persistence.entities.GeneratedReportEntity;
|
||||||
|
import net.beetechgroup.beetask.frameworks.persistence.entities.ReportSectionEntity;
|
||||||
|
import net.beetechgroup.beetask.frameworks.persistence.entities.ReportTemplateEntity;
|
||||||
|
|
||||||
|
public class ReportEntityMapper {
|
||||||
|
|
||||||
|
public static ReportTemplateEntity toEntity(ReportTemplate domain) {
|
||||||
|
if (Objects.isNull(domain)) return null;
|
||||||
|
ReportTemplateEntity entity = new ReportTemplateEntity();
|
||||||
|
entity.setId(domain.getId());
|
||||||
|
entity.setName(domain.getName());
|
||||||
|
entity.setDescription(domain.getDescription());
|
||||||
|
entity.setScope(domain.getScope());
|
||||||
|
entity.setFileUrl(domain.getFileUrl());
|
||||||
|
entity.setFileName(domain.getFileName());
|
||||||
|
entity.setStorageKey(domain.getStorageKey());
|
||||||
|
entity.setCreatedAt(domain.getCreatedAt());
|
||||||
|
entity.setUpdatedAt(domain.getUpdatedAt());
|
||||||
|
|
||||||
|
if (domain.getOrganization() != null) {
|
||||||
|
entity.setOrganization(OrganizationEntityMapper.toEntity(domain.getOrganization()));
|
||||||
|
}
|
||||||
|
if (domain.getProject() != null) {
|
||||||
|
entity.setProject(ProjectEntityMapper.toEntity(domain.getProject()));
|
||||||
|
}
|
||||||
|
if (domain.getCreatedBy() != null) {
|
||||||
|
entity.setCreatedBy(UserEntityMapper.toEntity(domain.getCreatedBy()));
|
||||||
|
}
|
||||||
|
if (domain.getSections() != null) {
|
||||||
|
entity.setSections(domain.getSections().stream()
|
||||||
|
.map(s -> toEntity(s, entity))
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReportSectionEntity toEntity(ReportSection domain, ReportTemplateEntity templateEntity) {
|
||||||
|
if (Objects.isNull(domain)) return null;
|
||||||
|
ReportSectionEntity entity = new ReportSectionEntity();
|
||||||
|
entity.setId(domain.getId());
|
||||||
|
entity.setReportTemplate(templateEntity);
|
||||||
|
entity.setSectionKey(domain.getSectionKey());
|
||||||
|
entity.setTitle(domain.getTitle());
|
||||||
|
entity.setSectionType(domain.getSectionType());
|
||||||
|
entity.setFilterStatus(domain.getFilterStatus());
|
||||||
|
entity.setIncludeTime(domain.getIncludeTime());
|
||||||
|
entity.setOrderIndex(domain.getOrderIndex());
|
||||||
|
entity.setCreatedAt(domain.getCreatedAt());
|
||||||
|
|
||||||
|
if (domain.getFilterCategory() != null) {
|
||||||
|
entity.setFilterCategory(CategoryEntityMapper.toEntity(domain.getFilterCategory()));
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReportTemplate toDomain(ReportTemplateEntity entity) {
|
||||||
|
if (Objects.isNull(entity)) return null;
|
||||||
|
ReportTemplate domain = new ReportTemplate();
|
||||||
|
domain.setId(entity.getId());
|
||||||
|
domain.setName(entity.getName());
|
||||||
|
domain.setDescription(entity.getDescription());
|
||||||
|
domain.setScope(entity.getScope());
|
||||||
|
domain.setFileUrl(entity.getFileUrl());
|
||||||
|
domain.setFileName(entity.getFileName());
|
||||||
|
domain.setStorageKey(entity.getStorageKey());
|
||||||
|
domain.setCreatedAt(entity.getCreatedAt());
|
||||||
|
domain.setUpdatedAt(entity.getUpdatedAt());
|
||||||
|
|
||||||
|
if (entity.getOrganization() != null) {
|
||||||
|
domain.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization()));
|
||||||
|
}
|
||||||
|
if (entity.getProject() != null) {
|
||||||
|
domain.setProject(ProjectEntityMapper.toDomain(entity.getProject()));
|
||||||
|
}
|
||||||
|
if (entity.getCreatedBy() != null) {
|
||||||
|
domain.setCreatedBy(UserEntityMapper.toDomain(entity.getCreatedBy()));
|
||||||
|
}
|
||||||
|
if (entity.getSections() != null) {
|
||||||
|
domain.setSections(entity.getSections().stream()
|
||||||
|
.map(ReportEntityMapper::toDomain)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReportSection toDomain(ReportSectionEntity entity) {
|
||||||
|
if (Objects.isNull(entity)) return null;
|
||||||
|
ReportSection domain = new ReportSection();
|
||||||
|
domain.setId(entity.getId());
|
||||||
|
domain.setReportTemplateId(entity.getReportTemplate() != null ? entity.getReportTemplate().getId() : null);
|
||||||
|
domain.setSectionKey(entity.getSectionKey());
|
||||||
|
domain.setTitle(entity.getTitle());
|
||||||
|
domain.setSectionType(entity.getSectionType());
|
||||||
|
domain.setFilterStatus(entity.getFilterStatus());
|
||||||
|
domain.setIncludeTime(entity.getIncludeTime());
|
||||||
|
domain.setOrderIndex(entity.getOrderIndex());
|
||||||
|
domain.setCreatedAt(entity.getCreatedAt());
|
||||||
|
|
||||||
|
if (entity.getFilterCategory() != null) {
|
||||||
|
domain.setFilterCategory(CategoryEntityMapper.toDomain(entity.getFilterCategory()));
|
||||||
|
}
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GeneratedReport toDomain(GeneratedReportEntity entity) {
|
||||||
|
if (Objects.isNull(entity)) return null;
|
||||||
|
GeneratedReport domain = new GeneratedReport();
|
||||||
|
domain.setId(entity.getId());
|
||||||
|
domain.setReportTemplateId(entity.getReportTemplate() != null ? entity.getReportTemplate().getId() : null);
|
||||||
|
domain.setTitle(entity.getTitle());
|
||||||
|
domain.setStartDate(entity.getStartDate());
|
||||||
|
domain.setEndDate(entity.getEndDate());
|
||||||
|
domain.setFileUrl(entity.getFileUrl());
|
||||||
|
domain.setFileName(entity.getFileName());
|
||||||
|
domain.setStorageKey(entity.getStorageKey());
|
||||||
|
domain.setCreatedAt(entity.getCreatedAt());
|
||||||
|
|
||||||
|
if (entity.getOrganization() != null) {
|
||||||
|
domain.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization()));
|
||||||
|
}
|
||||||
|
if (entity.getProject() != null) {
|
||||||
|
domain.setProject(ProjectEntityMapper.toDomain(entity.getProject()));
|
||||||
|
}
|
||||||
|
if (entity.getUser() != null) {
|
||||||
|
domain.setUser(UserEntityMapper.toDomain(entity.getUser()));
|
||||||
|
}
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
package net.beetechgroup.beetask.frameworks.persistence.repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import net.beetechgroup.beetask.entities.report.GeneratedReport;
|
||||||
|
import net.beetechgroup.beetask.frameworks.persistence.entities.GeneratedReportEntity;
|
||||||
|
import net.beetechgroup.beetask.frameworks.persistence.entities.ReportTemplateEntity;
|
||||||
|
import net.beetechgroup.beetask.frameworks.persistence.mapper.ReportEntityMapper;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.GeneratedReportRepository;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class GeneratedReportRepositoryImpl implements GeneratedReportRepository, PanacheRepository<GeneratedReportEntity> {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(GeneratedReportRepositoryImpl.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public GeneratedReport saveGeneratedReport(GeneratedReport report) {
|
||||||
|
GeneratedReportEntity entity = new GeneratedReportEntity();
|
||||||
|
entity.setId(report.getId());
|
||||||
|
entity.setTitle(report.getTitle());
|
||||||
|
entity.setStartDate(report.getStartDate());
|
||||||
|
entity.setEndDate(report.getEndDate());
|
||||||
|
entity.setFileUrl(report.getFileUrl());
|
||||||
|
entity.setFileName(report.getFileName());
|
||||||
|
entity.setStorageKey(report.getStorageKey());
|
||||||
|
entity.setCreatedAt(report.getCreatedAt());
|
||||||
|
|
||||||
|
if (report.getReportTemplateId() != null) {
|
||||||
|
ReportTemplateEntity template = new ReportTemplateEntity();
|
||||||
|
template.setId(report.getReportTemplateId());
|
||||||
|
entity.setReportTemplate(template);
|
||||||
|
}
|
||||||
|
if (report.getOrganization() != null) {
|
||||||
|
entity.setOrganization(net.beetechgroup.beetask.frameworks.persistence.mapper.OrganizationEntityMapper.toEntity(report.getOrganization()));
|
||||||
|
}
|
||||||
|
if (report.getProject() != null) {
|
||||||
|
entity.setProject(net.beetechgroup.beetask.frameworks.persistence.mapper.ProjectEntityMapper.toEntity(report.getProject()));
|
||||||
|
}
|
||||||
|
if (report.getUser() != null) {
|
||||||
|
entity.setUser(net.beetechgroup.beetask.frameworks.persistence.mapper.UserEntityMapper.toEntity(report.getUser()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Objects.isNull(entity.getId())) {
|
||||||
|
persist(entity);
|
||||||
|
} else {
|
||||||
|
entity = getEntityManager().merge(entity);
|
||||||
|
}
|
||||||
|
GeneratedReport saved = ReportEntityMapper.toDomain(entity);
|
||||||
|
LOGGER.infof("Persisted generated report %d for organization %d", saved.getId(), saved.getOrganization().getId());
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<GeneratedReport> findGeneratedReportById(Long id) {
|
||||||
|
return findByIdOptional(id).map(ReportEntityMapper::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<GeneratedReport> findByOrganizationId(Long organizationId) {
|
||||||
|
return find("organization.id = ?1 order by createdAt desc", organizationId).stream()
|
||||||
|
.map(ReportEntityMapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<GeneratedReport> findByUserId(Long userId) {
|
||||||
|
return find("user.id = ?1 order by createdAt desc", userId).stream()
|
||||||
|
.map(ReportEntityMapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteGeneratedReportById(Long id) {
|
||||||
|
delete("id", id);
|
||||||
|
LOGGER.infof("Deleted generated report %d", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
package net.beetechgroup.beetask.frameworks.persistence.repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import net.beetechgroup.beetask.entities.report.ReportTemplate;
|
||||||
|
import net.beetechgroup.beetask.frameworks.persistence.entities.ReportTemplateEntity;
|
||||||
|
import net.beetechgroup.beetask.frameworks.persistence.mapper.ReportEntityMapper;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.ReportTemplateRepository;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ReportTemplateRepositoryImpl implements ReportTemplateRepository, PanacheRepository<ReportTemplateEntity> {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(ReportTemplateRepositoryImpl.class);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ReportTemplate saveReportTemplate(ReportTemplate template) {
|
||||||
|
ReportTemplateEntity entity = ReportEntityMapper.toEntity(template);
|
||||||
|
if (Objects.isNull(entity.getId())) {
|
||||||
|
persist(entity);
|
||||||
|
} else {
|
||||||
|
entity = getEntityManager().merge(entity);
|
||||||
|
}
|
||||||
|
ReportTemplate saved = ReportEntityMapper.toDomain(entity);
|
||||||
|
LOGGER.infof("Persisted report template %d for organization %d", saved.getId(), saved.getOrganization().getId());
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ReportTemplate> findReportTemplateById(Long id) {
|
||||||
|
return findByIdOptional(id).map(ReportEntityMapper::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ReportTemplate> findByOrganizationId(Long organizationId) {
|
||||||
|
return find("organization.id = ?1 order by createdAt desc", organizationId).stream()
|
||||||
|
.map(ReportEntityMapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ReportTemplate> findByProjectId(Long projectId) {
|
||||||
|
return find("project.id = ?1 order by createdAt desc", projectId).stream()
|
||||||
|
.map(ReportEntityMapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteReportTemplateById(Long id) {
|
||||||
|
delete("id", id);
|
||||||
|
LOGGER.infof("Deleted report template %d", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package net.beetechgroup.beetask.frameworks.storage;
|
package net.beetechgroup.beetask.frameworks.storage;
|
||||||
|
|
||||||
import io.minio.BucketExistsArgs;
|
import io.minio.BucketExistsArgs;
|
||||||
|
import io.minio.GetObjectArgs;
|
||||||
import io.minio.MakeBucketArgs;
|
import io.minio.MakeBucketArgs;
|
||||||
import io.minio.MinioClient;
|
import io.minio.MinioClient;
|
||||||
import io.minio.PutObjectArgs;
|
import io.minio.PutObjectArgs;
|
||||||
|
import io.minio.RemoveObjectArgs;
|
||||||
import io.minio.SetBucketPolicyArgs;
|
import io.minio.SetBucketPolicyArgs;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
@ -73,4 +75,30 @@ public class MinioStorageService implements StorageRepository {
|
||||||
throw new RuntimeException("Falha ao fazer upload da imagem", e);
|
throw new RuntimeException("Falha ao fazer upload da imagem", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public InputStream download(String key) {
|
||||||
|
try {
|
||||||
|
return minioClient.getObject(GetObjectArgs.builder()
|
||||||
|
.bucket(bucket)
|
||||||
|
.object(key)
|
||||||
|
.build());
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.errorf("Failed to download object '%s' from bucket '%s': %s", key, bucket, e.getMessage());
|
||||||
|
throw new RuntimeException("Falha ao recuperar arquivo do storage", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String key) {
|
||||||
|
try {
|
||||||
|
minioClient.removeObject(RemoveObjectArgs.builder()
|
||||||
|
.bucket(bucket)
|
||||||
|
.object(key)
|
||||||
|
.build());
|
||||||
|
LOGGER.infof("Deleted object '%s' from bucket '%s'", key, bucket);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warnf("Failed to delete object '%s' from bucket '%s': %s", key, bucket, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package net.beetechgroup.beetask.interfaceadapters.controllers.report;
|
||||||
|
|
||||||
|
import org.jboss.resteasy.reactive.RestForm;
|
||||||
|
import org.jboss.resteasy.reactive.multipart.FileUpload;
|
||||||
|
|
||||||
|
public class CreateReportTemplateForm {
|
||||||
|
|
||||||
|
@RestForm("file")
|
||||||
|
public FileUpload file;
|
||||||
|
|
||||||
|
@RestForm("name")
|
||||||
|
public String name;
|
||||||
|
|
||||||
|
@RestForm("description")
|
||||||
|
public String description;
|
||||||
|
|
||||||
|
@RestForm("scope")
|
||||||
|
public String scope; // 'ORGANIZATION' ou 'PROJECT'
|
||||||
|
|
||||||
|
@RestForm("projectId")
|
||||||
|
public Long projectId;
|
||||||
|
|
||||||
|
@RestForm("sectionsJson")
|
||||||
|
public String sectionsJson;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package net.beetechgroup.beetask.interfaceadapters.controllers.report;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class GenerateReportRequest {
|
||||||
|
public Long templateId;
|
||||||
|
public Long projectId;
|
||||||
|
public LocalDate startDate;
|
||||||
|
public LocalDate endDate;
|
||||||
|
public Map<String, String> customFields;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
package net.beetechgroup.beetask.interfaceadapters.controllers.report;
|
||||||
|
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import io.quarkus.security.Authenticated;
|
||||||
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DELETE;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import net.beetechgroup.beetask.entities.report.GeneratedReport;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.ReportTemplateOutput;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateInput;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.delete.DeleteReportTemplateUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.generate.GenerateReportInput;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.generate.GenerateReportOutput;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.generate.GenerateReportUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.list.ListGeneratedReportsUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.list.ListReportTemplatesUseCase;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
import org.jboss.resteasy.reactive.MultipartForm;
|
||||||
|
|
||||||
|
@Path("/organizations/{orgId}/reports")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Authenticated
|
||||||
|
@Tag(name = "Reports", description = "Operations for generating reports and managing report templates")
|
||||||
|
public class ReportController {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(ReportController.class);
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
CreateReportTemplateUseCase createReportTemplateUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ListReportTemplatesUseCase listReportTemplatesUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
DeleteReportTemplateUseCase deleteReportTemplateUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
GenerateReportUseCase generateReportUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ListGeneratedReportsUseCase listGeneratedReportsUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/templates")
|
||||||
|
@Operation(summary = "List report templates", description = "Lists all templates for the given organization and optional project")
|
||||||
|
public List<ReportTemplateOutput> listTemplates(
|
||||||
|
@PathParam("orgId") Long orgId,
|
||||||
|
@QueryParam("projectId") Long projectId
|
||||||
|
) {
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
return listReportTemplatesUseCase.execute(email, orgId, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/templates")
|
||||||
|
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
||||||
|
@Operation(summary = "Create report template", description = "Uploads a .docx template and configures sections")
|
||||||
|
public ReportTemplateOutput createTemplate(
|
||||||
|
@PathParam("orgId") Long orgId,
|
||||||
|
@MultipartForm CreateReportTemplateForm form
|
||||||
|
) {
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
|
||||||
|
if (form.file == null || form.file.filePath() == null) {
|
||||||
|
throw new IllegalArgumentException("Arquivo .docx de modelo é obrigatório");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CreateReportTemplateInput.SectionInput> sections = new ArrayList<>();
|
||||||
|
if (form.sectionsJson != null && !form.sectionsJson.isBlank()) {
|
||||||
|
try {
|
||||||
|
sections = objectMapper.readValue(form.sectionsJson, new TypeReference<List<CreateReportTemplateInput.SectionInput>>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warnf("Falha ao parsear sectionsJson: %s", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (InputStream is = new FileInputStream(form.file.filePath().toFile())) {
|
||||||
|
CreateReportTemplateInput input = new CreateReportTemplateInput(
|
||||||
|
email,
|
||||||
|
orgId,
|
||||||
|
form.projectId,
|
||||||
|
form.name,
|
||||||
|
form.description,
|
||||||
|
form.scope,
|
||||||
|
form.file.fileName(),
|
||||||
|
is,
|
||||||
|
form.file.size(),
|
||||||
|
sections
|
||||||
|
);
|
||||||
|
return createReportTemplateUseCase.execute(input);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.errorf("Erro ao processar template: %s", e.getMessage());
|
||||||
|
throw new RuntimeException("Erro ao processar arquivo de template: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("/templates/{templateId}")
|
||||||
|
@Operation(summary = "Delete report template", description = "Deletes a report template by ID")
|
||||||
|
public Response deleteTemplate(
|
||||||
|
@PathParam("orgId") Long orgId,
|
||||||
|
@PathParam("templateId") Long templateId
|
||||||
|
) {
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
deleteReportTemplateUseCase.execute(email, templateId);
|
||||||
|
return Response.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/generate")
|
||||||
|
@Operation(summary = "Generate filled report", description = "Generates a .docx report for the period and user")
|
||||||
|
public Response generateReport(
|
||||||
|
@PathParam("orgId") Long orgId,
|
||||||
|
GenerateReportRequest req
|
||||||
|
) {
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
|
||||||
|
GenerateReportInput input = new GenerateReportInput(
|
||||||
|
email,
|
||||||
|
orgId,
|
||||||
|
req.templateId,
|
||||||
|
req.projectId,
|
||||||
|
req.startDate,
|
||||||
|
req.endDate,
|
||||||
|
req.customFields
|
||||||
|
);
|
||||||
|
|
||||||
|
GenerateReportOutput output = generateReportUseCase.execute(input);
|
||||||
|
|
||||||
|
String encodedFileName = URLEncoder.encode(output.fileName(), StandardCharsets.UTF_8).replace("+", "%20");
|
||||||
|
|
||||||
|
return Response.ok(output.docxBytes())
|
||||||
|
.type(output.contentType())
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"" + output.fileName() + "\"; filename*=UTF-8''" + encodedFileName)
|
||||||
|
.header("X-Report-Id", String.valueOf(output.generatedReportId()))
|
||||||
|
.header("X-Report-Url", output.fileUrl())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/history")
|
||||||
|
@Operation(summary = "List generated reports history", description = "Lists reports generated in this organization")
|
||||||
|
public List<GeneratedReport> listHistory(@PathParam("orgId") Long orgId) {
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
return listGeneratedReportsUseCase.execute(email, orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import net.beetechgroup.beetask.entities.report.ReportSection;
|
||||||
|
import net.beetechgroup.beetask.entities.report.ReportTemplate;
|
||||||
|
|
||||||
|
public class ReportMapper {
|
||||||
|
|
||||||
|
public static ReportTemplateOutput toOutput(ReportTemplate template) {
|
||||||
|
if (Objects.isNull(template)) return null;
|
||||||
|
|
||||||
|
List<ReportSectionOutput> sections = template.getSections() != null
|
||||||
|
? template.getSections().stream().map(ReportMapper::toSectionOutput).collect(Collectors.toList())
|
||||||
|
: Collections.emptyList();
|
||||||
|
|
||||||
|
return new ReportTemplateOutput(
|
||||||
|
template.getId(),
|
||||||
|
template.getOrganization() != null ? template.getOrganization().getId() : null,
|
||||||
|
template.getOrganization() != null ? template.getOrganization().getName() : null,
|
||||||
|
template.getProject() != null ? template.getProject().getId() : null,
|
||||||
|
template.getProject() != null ? template.getProject().getName() : null,
|
||||||
|
template.getName(),
|
||||||
|
template.getDescription(),
|
||||||
|
template.getScope(),
|
||||||
|
template.getFileUrl(),
|
||||||
|
template.getFileName(),
|
||||||
|
template.getStorageKey(),
|
||||||
|
template.getCreatedBy() != null ? template.getCreatedBy().getId() : null,
|
||||||
|
template.getCreatedBy() != null ? template.getCreatedBy().getName() : null,
|
||||||
|
sections,
|
||||||
|
template.getCreatedAt(),
|
||||||
|
template.getUpdatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReportSectionOutput toSectionOutput(ReportSection section) {
|
||||||
|
if (Objects.isNull(section)) return null;
|
||||||
|
return new ReportSectionOutput(
|
||||||
|
section.getId(),
|
||||||
|
section.getSectionKey(),
|
||||||
|
section.getTitle(),
|
||||||
|
section.getSectionType(),
|
||||||
|
section.getFilterCategory() != null ? section.getFilterCategory().getId() : null,
|
||||||
|
section.getFilterCategory() != null ? section.getFilterCategory().getName() : null,
|
||||||
|
section.getFilterStatus(),
|
||||||
|
section.getIncludeTime(),
|
||||||
|
section.getOrderIndex(),
|
||||||
|
section.getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record ReportSectionOutput(
|
||||||
|
Long id,
|
||||||
|
String sectionKey,
|
||||||
|
String title,
|
||||||
|
String sectionType,
|
||||||
|
Long filterCategoryId,
|
||||||
|
String filterCategoryName,
|
||||||
|
String filterStatus,
|
||||||
|
Boolean includeTime,
|
||||||
|
Integer orderIndex,
|
||||||
|
LocalDateTime createdAt
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record ReportTemplateOutput(
|
||||||
|
Long id,
|
||||||
|
Long organizationId,
|
||||||
|
String organizationName,
|
||||||
|
Long projectId,
|
||||||
|
String projectName,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
String scope,
|
||||||
|
String fileUrl,
|
||||||
|
String fileName,
|
||||||
|
String storageKey,
|
||||||
|
Long createdById,
|
||||||
|
String createdByName,
|
||||||
|
List<ReportSectionOutput> sections,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
LocalDateTime updatedAt
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report.create;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record CreateReportTemplateInput(
|
||||||
|
String userEmail,
|
||||||
|
Long organizationId,
|
||||||
|
Long projectId,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
String scope,
|
||||||
|
String fileName,
|
||||||
|
InputStream fileInputStream,
|
||||||
|
long fileSize,
|
||||||
|
List<SectionInput> sections
|
||||||
|
) {
|
||||||
|
public record SectionInput(
|
||||||
|
String sectionKey,
|
||||||
|
String title,
|
||||||
|
String sectionType,
|
||||||
|
Long filterCategoryId,
|
||||||
|
String filterStatus,
|
||||||
|
Boolean includeTime,
|
||||||
|
Integer orderIndex
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report.create;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.beetechgroup.beetask.entities.Category;
|
||||||
|
import net.beetechgroup.beetask.entities.Project;
|
||||||
|
import net.beetechgroup.beetask.entities.User;
|
||||||
|
import net.beetechgroup.beetask.entities.organization.Organization;
|
||||||
|
import net.beetechgroup.beetask.entities.report.ReportSection;
|
||||||
|
import net.beetechgroup.beetask.entities.report.ReportTemplate;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.ReportMapper;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.ReportTemplateOutput;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.ReportTemplateRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.StorageRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
public class CreateReportTemplateUseCase {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(CreateReportTemplateUseCase.class);
|
||||||
|
|
||||||
|
private final ReportTemplateRepository reportTemplateRepository;
|
||||||
|
private final OrganizationRepository organizationRepository;
|
||||||
|
private final ProjectRepository projectRepository;
|
||||||
|
private final CategoryRepository categoryRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final StorageRepository storageRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
|
public CreateReportTemplateUseCase(
|
||||||
|
ReportTemplateRepository reportTemplateRepository,
|
||||||
|
OrganizationRepository organizationRepository,
|
||||||
|
ProjectRepository projectRepository,
|
||||||
|
CategoryRepository categoryRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
StorageRepository storageRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer
|
||||||
|
) {
|
||||||
|
this.reportTemplateRepository = reportTemplateRepository;
|
||||||
|
this.organizationRepository = organizationRepository;
|
||||||
|
this.projectRepository = projectRepository;
|
||||||
|
this.categoryRepository = categoryRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.storageRepository = storageRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReportTemplateOutput execute(CreateReportTemplateInput input) {
|
||||||
|
authorizer.execute(input.userEmail(), input.organizationId());
|
||||||
|
|
||||||
|
if (input.name() == null || input.name().trim().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Nome do modelo de relatório é obrigatório");
|
||||||
|
}
|
||||||
|
if (input.fileInputStream() == null || input.fileName() == null) {
|
||||||
|
throw new IllegalArgumentException("Arquivo de modelo .docx é obrigatório");
|
||||||
|
}
|
||||||
|
if (!input.fileName().toLowerCase().endsWith(".docx")) {
|
||||||
|
throw new IllegalArgumentException("O arquivo de modelo deve ser no formato .docx");
|
||||||
|
}
|
||||||
|
|
||||||
|
Organization org = organizationRepository.findOrganizationById(input.organizationId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Organização não encontrada"));
|
||||||
|
|
||||||
|
User user = userRepository.findByEmail(input.userEmail())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Usuário não encontrado"));
|
||||||
|
|
||||||
|
Project project = null;
|
||||||
|
if (input.projectId() != null && input.projectId() > 0) {
|
||||||
|
project = projectRepository.findProjectById(input.projectId()).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload do arquivo para MinIO
|
||||||
|
String cleanFileName = input.fileName().replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||||
|
String storageKey = "reports/templates/" + org.getId() + "/" + UUID.randomUUID() + "_" + cleanFileName;
|
||||||
|
String contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
String fileUrl = storageRepository.upload(storageKey, input.fileInputStream(), contentType, input.fileSize());
|
||||||
|
|
||||||
|
ReportTemplate template = new ReportTemplate();
|
||||||
|
template.setOrganization(org);
|
||||||
|
template.setProject(project);
|
||||||
|
template.setName(input.name().trim());
|
||||||
|
template.setDescription(input.description());
|
||||||
|
template.setScope(input.scope() != null && input.scope().equalsIgnoreCase("PROJECT") ? "PROJECT" : "ORGANIZATION");
|
||||||
|
template.setFileName(input.fileName());
|
||||||
|
template.setStorageKey(storageKey);
|
||||||
|
template.setFileUrl(fileUrl);
|
||||||
|
template.setCreatedBy(user);
|
||||||
|
template.setCreatedAt(LocalDateTime.now());
|
||||||
|
template.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
List<ReportSection> sections = new ArrayList<>();
|
||||||
|
if (input.sections() != null) {
|
||||||
|
int order = 0;
|
||||||
|
for (CreateReportTemplateInput.SectionInput sInput : input.sections()) {
|
||||||
|
if (sInput.sectionKey() == null || sInput.sectionKey().isBlank()) continue;
|
||||||
|
|
||||||
|
ReportSection section = new ReportSection();
|
||||||
|
section.setSectionKey(sInput.sectionKey().trim().toLowerCase().replaceAll("[^a-z0-9_]", "_"));
|
||||||
|
section.setTitle(sInput.title() != null ? sInput.title().trim() : section.getSectionKey());
|
||||||
|
section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT");
|
||||||
|
section.setFilterStatus(sInput.filterStatus() != null ? sInput.filterStatus() : "ALL");
|
||||||
|
section.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true);
|
||||||
|
section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++);
|
||||||
|
section.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
if (sInput.filterCategoryId() != null && sInput.filterCategoryId() > 0) {
|
||||||
|
Category cat = categoryRepository.findCategoryById(sInput.filterCategoryId()).orElse(null);
|
||||||
|
section.setFilterCategory(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.add(section);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
template.setSections(sections);
|
||||||
|
|
||||||
|
ReportTemplate saved = reportTemplateRepository.saveReportTemplate(template);
|
||||||
|
LOGGER.infof("Template de relatório '%s' salvo com ID %d", saved.getName(), saved.getId());
|
||||||
|
return ReportMapper.toOutput(saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report.delete;
|
||||||
|
|
||||||
|
import net.beetechgroup.beetask.entities.report.ReportTemplate;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.ReportTemplateRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.StorageRepository;
|
||||||
|
|
||||||
|
public class DeleteReportTemplateUseCase {
|
||||||
|
|
||||||
|
private final ReportTemplateRepository reportTemplateRepository;
|
||||||
|
private final StorageRepository storageRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
|
public DeleteReportTemplateUseCase(
|
||||||
|
ReportTemplateRepository reportTemplateRepository,
|
||||||
|
StorageRepository storageRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer
|
||||||
|
) {
|
||||||
|
this.reportTemplateRepository = reportTemplateRepository;
|
||||||
|
this.storageRepository = storageRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void execute(String userEmail, Long templateId) {
|
||||||
|
ReportTemplate template = reportTemplateRepository.findReportTemplateById(templateId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Modelo de relatório não encontrado"));
|
||||||
|
|
||||||
|
authorizer.execute(userEmail, template.getOrganization().getId());
|
||||||
|
|
||||||
|
if (template.getStorageKey() != null) {
|
||||||
|
storageRepository.delete(template.getStorageKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
reportTemplateRepository.deleteReportTemplateById(templateId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report.docengine;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.apache.poi.xwpf.usermodel.IBodyElement;
|
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFDocument;
|
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFHeader;
|
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFFooter;
|
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
|
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFRun;
|
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFTable;
|
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
|
||||||
|
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
public class DocxReportEngine {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(DocxReportEngine.class);
|
||||||
|
|
||||||
|
public static class TaskItem {
|
||||||
|
public String title;
|
||||||
|
public String date;
|
||||||
|
public String duration;
|
||||||
|
public String status;
|
||||||
|
public String category;
|
||||||
|
public String project;
|
||||||
|
public String description;
|
||||||
|
|
||||||
|
public TaskItem(String title, String date, String duration, String status, String category, String project, String description) {
|
||||||
|
this.title = title != null ? title : "";
|
||||||
|
this.date = date != null ? date : "";
|
||||||
|
this.duration = duration != null ? duration : "";
|
||||||
|
this.status = status != null ? status : "";
|
||||||
|
this.category = category != null ? category : "";
|
||||||
|
this.project = project != null ? project : "";
|
||||||
|
this.description = description != null ? description : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] generate(
|
||||||
|
InputStream templateStream,
|
||||||
|
Map<String, String> variables,
|
||||||
|
Map<String, List<TaskItem>> listSections
|
||||||
|
) throws Exception {
|
||||||
|
try (XWPFDocument doc = new XWPFDocument(templateStream)) {
|
||||||
|
// 1. Processar Tabelas Dinâmicas primeiro (linhas com tags de lista)
|
||||||
|
processTables(doc, variables, listSections);
|
||||||
|
|
||||||
|
// 2. Processar Parágrafos do corpo do documento
|
||||||
|
processParagraphs(doc, variables, listSections);
|
||||||
|
|
||||||
|
// 3. Processar Cabeçalhos e Rodapés
|
||||||
|
for (XWPFHeader header : doc.getHeaderList()) {
|
||||||
|
for (XWPFParagraph p : header.getParagraphs()) {
|
||||||
|
replaceTextInParagraph(p, variables, null);
|
||||||
|
}
|
||||||
|
for (XWPFTable table : header.getTables()) {
|
||||||
|
replaceTextInTableCells(table, variables);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (XWPFFooter footer : doc.getFooterList()) {
|
||||||
|
for (XWPFParagraph p : footer.getParagraphs()) {
|
||||||
|
replaceTextInParagraph(p, variables, null);
|
||||||
|
}
|
||||||
|
for (XWPFTable table : footer.getTables()) {
|
||||||
|
replaceTextInTableCells(table, variables);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
doc.write(out);
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void processParagraphs(
|
||||||
|
XWPFDocument doc,
|
||||||
|
Map<String, String> variables,
|
||||||
|
Map<String, List<TaskItem>> listSections
|
||||||
|
) {
|
||||||
|
List<XWPFParagraph> paragraphs = new ArrayList<>(doc.getParagraphs());
|
||||||
|
for (XWPFParagraph p : paragraphs) {
|
||||||
|
replaceTextInParagraph(p, variables, listSections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void processTables(
|
||||||
|
XWPFDocument doc,
|
||||||
|
Map<String, String> variables,
|
||||||
|
Map<String, List<TaskItem>> listSections
|
||||||
|
) {
|
||||||
|
for (XWPFTable table : doc.getTables()) {
|
||||||
|
List<XWPFTableRow> rows = new ArrayList<>(table.getRows());
|
||||||
|
for (int r = 0; r < rows.size(); r++) {
|
||||||
|
XWPFTableRow row = rows.get(r);
|
||||||
|
String rowText = getRowText(row);
|
||||||
|
|
||||||
|
// Verificar se a linha contém tags de repetição de itens:
|
||||||
|
// ex: {{item.titulo}} ou {{tarefa.titulo}}
|
||||||
|
boolean isItemTemplateRow = rowText.contains("{{item.") || rowText.contains("{{tarefa.");
|
||||||
|
String matchedListKey = null;
|
||||||
|
|
||||||
|
if (isItemTemplateRow && listSections != null) {
|
||||||
|
for (String listKey : listSections.keySet()) {
|
||||||
|
if (rowText.contains("{{" + listKey + ".") || rowText.contains("{{item.") || rowText.contains("{{tarefa.")) {
|
||||||
|
matchedListKey = listKey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isItemTemplateRow && matchedListKey != null && listSections.containsKey(matchedListKey)) {
|
||||||
|
List<TaskItem> items = listSections.get(matchedListKey);
|
||||||
|
int insertIndex = r + 1;
|
||||||
|
|
||||||
|
for (TaskItem item : items) {
|
||||||
|
XWPFTableRow newRow = table.insertNewTableRow(insertIndex++);
|
||||||
|
for (int c = 0; c < row.getTableCells().size(); c++) {
|
||||||
|
XWPFTableCell oldCell = row.getCell(c);
|
||||||
|
XWPFTableCell newCell = newRow.addNewTableCell();
|
||||||
|
// Copiar estilo e substituir variáveis do item
|
||||||
|
String cellText = oldCell.getText();
|
||||||
|
String replacedText = replaceItemTags(cellText, item);
|
||||||
|
newCell.setText(replacedText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remover a linha modelo de template
|
||||||
|
table.removeRow(r);
|
||||||
|
r += items.size() - 1; // Ajustar índice
|
||||||
|
} else {
|
||||||
|
// Linha normal: substituir variáveis simples
|
||||||
|
for (XWPFTableCell cell : row.getTableCells()) {
|
||||||
|
for (XWPFParagraph p : cell.getParagraphs()) {
|
||||||
|
replaceTextInParagraph(p, variables, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void replaceTextInTableCells(XWPFTable table, Map<String, String> variables) {
|
||||||
|
for (XWPFTableRow row : table.getRows()) {
|
||||||
|
for (XWPFTableCell cell : row.getTableCells()) {
|
||||||
|
for (XWPFParagraph p : cell.getParagraphs()) {
|
||||||
|
replaceTextInParagraph(p, variables, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void replaceTextInParagraph(
|
||||||
|
XWPFParagraph p,
|
||||||
|
Map<String, String> variables,
|
||||||
|
Map<String, List<TaskItem>> listSections
|
||||||
|
) {
|
||||||
|
String text = p.getText();
|
||||||
|
if (text == null || !text.contains("{{")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Verificar se o parágrafo contém uma tag de lista (ex: {{atividades}}, {{reunioes}})
|
||||||
|
if (listSections != null) {
|
||||||
|
for (Map.Entry<String, List<TaskItem>> entry : listSections.entrySet()) {
|
||||||
|
String tag = "{{" + entry.getKey() + "}}";
|
||||||
|
if (text.contains(tag)) {
|
||||||
|
List<TaskItem> items = entry.getValue();
|
||||||
|
clearRuns(p);
|
||||||
|
if (items == null || items.isEmpty()) {
|
||||||
|
XWPFRun r = p.createRun();
|
||||||
|
r.setText("Nenhuma atividade registrada no período.");
|
||||||
|
r.setItalic(true);
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < items.size(); i++) {
|
||||||
|
TaskItem it = items.get(i);
|
||||||
|
XWPFRun r = p.createRun();
|
||||||
|
String line = "• " + it.title;
|
||||||
|
if (!it.duration.isEmpty()) {
|
||||||
|
line += " (" + it.duration + ")";
|
||||||
|
}
|
||||||
|
if (!it.status.isEmpty()) {
|
||||||
|
line += " - " + it.status;
|
||||||
|
}
|
||||||
|
r.setText(line);
|
||||||
|
if (i < items.size() - 1) {
|
||||||
|
r.addBreak();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Substituir variáveis simples ou campos de texto livre
|
||||||
|
String replaced = text;
|
||||||
|
if (variables != null) {
|
||||||
|
for (Map.Entry<String, String> var : variables.entrySet()) {
|
||||||
|
String tag = "{{" + var.getKey() + "}}";
|
||||||
|
if (replaced.contains(tag)) {
|
||||||
|
replaced = replaced.replace(tag, var.getValue() != null ? var.getValue() : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se o texto mudou, reaplicamos no parágrafo preservando o estilo original do run 0
|
||||||
|
if (!replaced.equals(text)) {
|
||||||
|
String font = null;
|
||||||
|
int fontSize = -1;
|
||||||
|
String color = null;
|
||||||
|
boolean bold = false;
|
||||||
|
boolean italic = false;
|
||||||
|
|
||||||
|
if (!p.getRuns().isEmpty()) {
|
||||||
|
XWPFRun firstRun = p.getRuns().get(0);
|
||||||
|
font = firstRun.getFontFamily();
|
||||||
|
fontSize = firstRun.getFontSize();
|
||||||
|
color = firstRun.getColor();
|
||||||
|
bold = firstRun.isBold();
|
||||||
|
italic = firstRun.isItalic();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearRuns(p);
|
||||||
|
|
||||||
|
// Suportar quebras de linha em campos de texto livre
|
||||||
|
String[] lines = replaced.split("\n", -1);
|
||||||
|
for (int i = 0; i < lines.length; i++) {
|
||||||
|
XWPFRun run = p.createRun();
|
||||||
|
run.setText(lines[i]);
|
||||||
|
if (font != null) run.setFontFamily(font);
|
||||||
|
if (fontSize > 0) run.setFontSize(fontSize);
|
||||||
|
if (color != null) run.setColor(color);
|
||||||
|
run.setBold(bold);
|
||||||
|
run.setItalic(italic);
|
||||||
|
|
||||||
|
if (i < lines.length - 1) {
|
||||||
|
run.addBreak();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String replaceItemTags(String cellText, TaskItem item) {
|
||||||
|
if (cellText == null) return "";
|
||||||
|
return cellText
|
||||||
|
.replace("{{item.titulo}}", item.title)
|
||||||
|
.replace("{{item.data}}", item.date)
|
||||||
|
.replace("{{item.tempo}}", item.duration)
|
||||||
|
.replace("{{item.status}}", item.status)
|
||||||
|
.replace("{{item.categoria}}", item.category)
|
||||||
|
.replace("{{item.projeto}}", item.project)
|
||||||
|
.replace("{{item.descricao}}", item.description)
|
||||||
|
.replace("{{tarefa.titulo}}", item.title)
|
||||||
|
.replace("{{tarefa.data}}", item.date)
|
||||||
|
.replace("{{tarefa.tempo}}", item.duration)
|
||||||
|
.replace("{{tarefa.status}}", item.status)
|
||||||
|
.replace("{{tarefa.categoria}}", item.category)
|
||||||
|
.replace("{{tarefa.projeto}}", item.project)
|
||||||
|
.replace("{{tarefa.descricao}}", item.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void clearRuns(XWPFParagraph p) {
|
||||||
|
int runsCount = p.getRuns().size();
|
||||||
|
for (int i = runsCount - 1; i >= 0; i--) {
|
||||||
|
p.removeRun(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getRowText(XWPFTableRow row) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (XWPFTableCell cell : row.getTableCells()) {
|
||||||
|
sb.append(cell.getText()).append(" ");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report.generate;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public record GenerateReportInput(
|
||||||
|
String userEmail,
|
||||||
|
Long organizationId,
|
||||||
|
Long templateId,
|
||||||
|
Long projectId,
|
||||||
|
LocalDate startDate,
|
||||||
|
LocalDate endDate,
|
||||||
|
Map<String, String> customFields
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report.generate;
|
||||||
|
|
||||||
|
public record GenerateReportOutput(
|
||||||
|
byte[] docxBytes,
|
||||||
|
String fileName,
|
||||||
|
String contentType,
|
||||||
|
String fileUrl,
|
||||||
|
Long generatedReportId
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,355 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report.generate;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.format.TextStyle;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import net.beetechgroup.beetask.entities.Project;
|
||||||
|
import net.beetechgroup.beetask.entities.User;
|
||||||
|
import net.beetechgroup.beetask.entities.organization.Organization;
|
||||||
|
import net.beetechgroup.beetask.entities.report.GeneratedReport;
|
||||||
|
import net.beetechgroup.beetask.entities.report.ReportSection;
|
||||||
|
import net.beetechgroup.beetask.entities.report.ReportTemplate;
|
||||||
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
|
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
||||||
|
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.docengine.DocxReportEngine;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.GeneratedReportRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.ReportTemplateRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.StorageRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
public class GenerateReportUseCase {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(GenerateReportUseCase.class);
|
||||||
|
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy");
|
||||||
|
private static final Locale PT_BR = new Locale("pt", "BR");
|
||||||
|
|
||||||
|
private final ReportTemplateRepository reportTemplateRepository;
|
||||||
|
private final GeneratedReportRepository generatedReportRepository;
|
||||||
|
private final TaskRepository taskRepository;
|
||||||
|
private final OrganizationRepository organizationRepository;
|
||||||
|
private final ProjectRepository projectRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final StorageRepository storageRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
|
public GenerateReportUseCase(
|
||||||
|
ReportTemplateRepository reportTemplateRepository,
|
||||||
|
GeneratedReportRepository generatedReportRepository,
|
||||||
|
TaskRepository taskRepository,
|
||||||
|
OrganizationRepository organizationRepository,
|
||||||
|
ProjectRepository projectRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
StorageRepository storageRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer
|
||||||
|
) {
|
||||||
|
this.reportTemplateRepository = reportTemplateRepository;
|
||||||
|
this.generatedReportRepository = generatedReportRepository;
|
||||||
|
this.taskRepository = taskRepository;
|
||||||
|
this.organizationRepository = organizationRepository;
|
||||||
|
this.projectRepository = projectRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.storageRepository = storageRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GenerateReportOutput execute(GenerateReportInput input) {
|
||||||
|
authorizer.execute(input.userEmail(), input.organizationId());
|
||||||
|
|
||||||
|
if (input.startDate() == null || input.endDate() == null) {
|
||||||
|
throw new IllegalArgumentException("Data de início e data de fim são obrigatórias");
|
||||||
|
}
|
||||||
|
if (input.startDate().isAfter(input.endDate())) {
|
||||||
|
throw new IllegalArgumentException("A data de início não pode ser posterior à data de fim");
|
||||||
|
}
|
||||||
|
|
||||||
|
ReportTemplate template = reportTemplateRepository.findReportTemplateById(input.templateId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Modelo de relatório não encontrado"));
|
||||||
|
|
||||||
|
Organization org = organizationRepository.findOrganizationById(input.organizationId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Organização não encontrada"));
|
||||||
|
|
||||||
|
User user = userRepository.findByEmail(input.userEmail())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Usuário não encontrado"));
|
||||||
|
|
||||||
|
Long selectedProjectId = input.projectId() != null && input.projectId() > 0
|
||||||
|
? input.projectId()
|
||||||
|
: (template.getProject() != null ? template.getProject().getId() : null);
|
||||||
|
|
||||||
|
Project project = selectedProjectId != null
|
||||||
|
? projectRepository.findProjectById(selectedProjectId).orElse(null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
LocalDateTime startDt = input.startDate().atStartOfDay();
|
||||||
|
LocalDateTime endDt = input.endDate().atTime(23, 59, 59);
|
||||||
|
|
||||||
|
// 1. Carregar tarefas do usuário no período
|
||||||
|
List<Task> workedTasks = taskRepository.findTasksWorkedByUserInPeriod(input.userEmail(), startDt, endDt, org.getId());
|
||||||
|
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserInPeriod(input.userEmail(), startDt, endDt);
|
||||||
|
|
||||||
|
// Mesclar tarefas únicas
|
||||||
|
Map<Long, Task> uniqueTasks = new LinkedHashMap<>();
|
||||||
|
for (Task t : workedTasks) {
|
||||||
|
if (matchesOrgAndProject(t, org.getId(), selectedProjectId)) {
|
||||||
|
uniqueTasks.put(t.getId(), t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Task t : finishedTasks) {
|
||||||
|
if (matchesOrgAndProject(t, org.getId(), selectedProjectId)) {
|
||||||
|
uniqueTasks.put(t.getId(), t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Também incluir tarefas criadas/atribuídas ao usuário no período se aplicável
|
||||||
|
List<Task> allUserTasks = taskRepository.findTasksByUser(input.userEmail());
|
||||||
|
for (Task t : allUserTasks) {
|
||||||
|
if (matchesOrgAndProject(t, org.getId(), selectedProjectId)) {
|
||||||
|
// Se a tarefa teve apontamentos de tempo ou foi finalizada no período
|
||||||
|
if (hasActivityInPeriod(t, startDt, endDt)) {
|
||||||
|
uniqueTasks.put(t.getId(), t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Task> taskList = new ArrayList<>(uniqueTasks.values());
|
||||||
|
|
||||||
|
// 2. Calcular tempo total trabalhado no período
|
||||||
|
long totalSecondsInPeriod = 0;
|
||||||
|
for (Task t : taskList) {
|
||||||
|
totalSecondsInPeriod += calculateTaskSecondsInPeriod(t, user.getEmail(), startDt, endDt);
|
||||||
|
}
|
||||||
|
|
||||||
|
String formattedTotalTime = formatDuration(totalSecondsInPeriod);
|
||||||
|
|
||||||
|
// 3. Montar variáveis globais
|
||||||
|
Map<String, String> variables = new HashMap<>();
|
||||||
|
String strStart = input.startDate().format(DATE_FORMATTER);
|
||||||
|
String strEnd = input.endDate().format(DATE_FORMATTER);
|
||||||
|
String strPeriodo = strStart + " a " + strEnd;
|
||||||
|
|
||||||
|
String monthName = input.startDate().getMonth().getDisplayName(TextStyle.FULL, PT_BR);
|
||||||
|
String mesAno = capitalize(monthName) + "/" + input.startDate().getYear();
|
||||||
|
|
||||||
|
variables.put("data_inicio", strStart);
|
||||||
|
variables.put("data_fim", strEnd);
|
||||||
|
variables.put("periodo_vigencia", strPeriodo);
|
||||||
|
variables.put("mes_ano", mesAno);
|
||||||
|
variables.put("nome_usuario", user.getName() != null ? user.getName() : user.getEmail());
|
||||||
|
variables.put("email_usuario", user.getEmail());
|
||||||
|
variables.put("organizacao", org.getName());
|
||||||
|
variables.put("projeto", project != null ? project.getName() : "Geral / Todos os Projetos");
|
||||||
|
variables.put("total_horas", formattedTotalTime);
|
||||||
|
variables.put("data_emissao", LocalDate.now().format(DATE_FORMATTER));
|
||||||
|
|
||||||
|
// Incorporar campos de texto customizados enviados pelo usuário
|
||||||
|
if (input.customFields() != null) {
|
||||||
|
for (Map.Entry<String, String> entry : input.customFields().entrySet()) {
|
||||||
|
variables.put(entry.getKey(), entry.getValue() != null ? entry.getValue() : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Montar seções de listagem de tarefas / reuniões
|
||||||
|
Map<String, List<DocxReportEngine.TaskItem>> listSections = new HashMap<>();
|
||||||
|
|
||||||
|
if (template.getSections() != null) {
|
||||||
|
for (ReportSection section : template.getSections()) {
|
||||||
|
if ("TASK_LIST".equalsIgnoreCase(section.getSectionType())) {
|
||||||
|
List<DocxReportEngine.TaskItem> items = new ArrayList<>();
|
||||||
|
for (Task t : taskList) {
|
||||||
|
if (matchesSectionFilters(t, section)) {
|
||||||
|
long sec = calculateTaskSecondsInPeriod(t, user.getEmail(), startDt, endDt);
|
||||||
|
String dur = section.getIncludeTime() && sec > 0 ? formatDuration(sec) : "";
|
||||||
|
String dateStr = t.getFinishedAt() != null
|
||||||
|
? t.getFinishedAt().format(DATE_FORMATTER)
|
||||||
|
: strEnd;
|
||||||
|
String statusLabel = formatStatus(t.getStatus());
|
||||||
|
String catName = t.getCategory() != null ? t.getCategory().getName() : "";
|
||||||
|
String projName = t.getProject() != null ? t.getProject().getName() : "";
|
||||||
|
|
||||||
|
items.add(new DocxReportEngine.TaskItem(
|
||||||
|
t.getTitle(),
|
||||||
|
dateStr,
|
||||||
|
dur,
|
||||||
|
statusLabel,
|
||||||
|
catName,
|
||||||
|
projName,
|
||||||
|
t.getDescription()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listSections.put(section.getSectionKey(), items);
|
||||||
|
} else if ("FREE_TEXT".equalsIgnoreCase(section.getSectionType())) {
|
||||||
|
// Garantir que a chave existe nas variáveis mesmo se veio vazia
|
||||||
|
if (!variables.containsKey(section.getSectionKey())) {
|
||||||
|
variables.put(section.getSectionKey(), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks para tags comuns se não foram explicitamente seções
|
||||||
|
if (!listSections.containsKey("atividades")) {
|
||||||
|
List<DocxReportEngine.TaskItem> allItems = new ArrayList<>();
|
||||||
|
for (Task t : taskList) {
|
||||||
|
long sec = calculateTaskSecondsInPeriod(t, user.getEmail(), startDt, endDt);
|
||||||
|
allItems.add(new DocxReportEngine.TaskItem(
|
||||||
|
t.getTitle(),
|
||||||
|
t.getFinishedAt() != null ? t.getFinishedAt().format(DATE_FORMATTER) : strEnd,
|
||||||
|
sec > 0 ? formatDuration(sec) : "",
|
||||||
|
formatStatus(t.getStatus()),
|
||||||
|
t.getCategory() != null ? t.getCategory().getName() : "",
|
||||||
|
t.getProject() != null ? t.getProject().getName() : "",
|
||||||
|
t.getDescription()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
listSections.put("atividades", allItems);
|
||||||
|
listSections.put("tarefas", allItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Baixar o template do MinIO e gerar o documento preenchido
|
||||||
|
byte[] docxBytes;
|
||||||
|
try (InputStream templateStream = storageRepository.download(template.getStorageKey())) {
|
||||||
|
docxBytes = DocxReportEngine.generate(templateStream, variables, listSections);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.errorf("Erro ao processar template .docx %d: %s", template.getId(), e.getMessage());
|
||||||
|
throw new RuntimeException("Falha ao gerar o documento Word: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Fazer upload do relatório gerado para o MinIO
|
||||||
|
String cleanTitle = (template.getName() + "_" + strStart.replace("/", "-") + "_" + strEnd.replace("/", "-"))
|
||||||
|
.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||||
|
String generatedFileName = cleanTitle + ".docx";
|
||||||
|
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),
|
||||||
|
contentType,
|
||||||
|
docxBytes.length
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Persistir histórico do relatório gerado
|
||||||
|
GeneratedReport generated = new GeneratedReport();
|
||||||
|
generated.setReportTemplateId(template.getId());
|
||||||
|
generated.setOrganization(org);
|
||||||
|
generated.setProject(project);
|
||||||
|
generated.setUser(user);
|
||||||
|
generated.setTitle(template.getName() + " (" + strPeriodo + ")");
|
||||||
|
generated.setStartDate(input.startDate());
|
||||||
|
generated.setEndDate(input.endDate());
|
||||||
|
generated.setFileUrl(fileUrl);
|
||||||
|
generated.setFileName(generatedFileName);
|
||||||
|
generated.setStorageKey(generatedStorageKey);
|
||||||
|
generated.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
GeneratedReport savedReport = generatedReportRepository.saveGeneratedReport(generated);
|
||||||
|
LOGGER.infof("Relatório gerado com sucesso! ID: %d, Arquivo: %s", savedReport.getId(), generatedFileName);
|
||||||
|
|
||||||
|
return new GenerateReportOutput(
|
||||||
|
docxBytes,
|
||||||
|
generatedFileName,
|
||||||
|
contentType,
|
||||||
|
fileUrl,
|
||||||
|
savedReport.getId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesOrgAndProject(Task t, Long orgId, Long projectId) {
|
||||||
|
if (t.getOrganization() != null && !Objects.equals(t.getOrganization().getId(), orgId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (projectId != null && (t.getProject() == null || !Objects.equals(t.getProject().getId(), projectId))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasActivityInPeriod(Task t, LocalDateTime startDt, LocalDateTime endDt) {
|
||||||
|
if (t.getFinishedAt() != null && !t.getFinishedAt().isBefore(startDt) && !t.getFinishedAt().isAfter(endDt)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (t.getHistory() != null) {
|
||||||
|
for (TaskHistoryItem h : t.getHistory()) {
|
||||||
|
if (h.getStartAt() != null && !h.getStartAt().isAfter(endDt)) {
|
||||||
|
LocalDateTime itemEnd = h.getEndAt() != null ? h.getEndAt() : LocalDateTime.now();
|
||||||
|
if (!itemEnd.isBefore(startDt)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matchesSectionFilters(Task t, ReportSection section) {
|
||||||
|
if (section.getFilterCategory() != null) {
|
||||||
|
if (t.getCategory() == null || !Objects.equals(t.getCategory().getId(), section.getFilterCategory().getId())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (section.getFilterStatus() != null && !section.getFilterStatus().equalsIgnoreCase("ALL")) {
|
||||||
|
if (t.getStatus() == null || !t.getStatus().name().equalsIgnoreCase(section.getFilterStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long calculateTaskSecondsInPeriod(Task t, String userEmail, LocalDateTime start, LocalDateTime end) {
|
||||||
|
if (t.getHistory() == null || t.getHistory().isEmpty()) return 0;
|
||||||
|
long totalSeconds = 0;
|
||||||
|
for (TaskHistoryItem item : t.getHistory()) {
|
||||||
|
if (item.getUser() != null && !userEmail.equalsIgnoreCase(item.getUser().getEmail())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.getStartAt() == null) continue;
|
||||||
|
LocalDateTime itemStart = item.getStartAt().isBefore(start) ? start : item.getStartAt();
|
||||||
|
LocalDateTime itemEnd = item.getEndAt() != null ? item.getEndAt() : LocalDateTime.now();
|
||||||
|
if (itemEnd.isAfter(end)) itemEnd = end;
|
||||||
|
|
||||||
|
if (itemEnd.isAfter(itemStart)) {
|
||||||
|
totalSeconds += Duration.between(itemStart, itemEnd).toSeconds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return totalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatDuration(long seconds) {
|
||||||
|
long hours = seconds / 3600;
|
||||||
|
long minutes = (seconds % 3600) / 60;
|
||||||
|
if (hours == 0 && minutes == 0 && seconds > 0) return "1m";
|
||||||
|
return hours + "h " + minutes + "m";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatStatus(TaskStatus status) {
|
||||||
|
if (status == null) return "";
|
||||||
|
return switch (status) {
|
||||||
|
case COMPLETED -> "Concluído";
|
||||||
|
case IN_PROGRESS -> "Em Andamento";
|
||||||
|
case NOT_STARTED -> "A Fazer";
|
||||||
|
case CANCELED -> "Cancelado";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String capitalize(String text) {
|
||||||
|
if (text == null || text.isEmpty()) return "";
|
||||||
|
return text.substring(0, 1).toUpperCase(PT_BR) + text.substring(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report.list;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import net.beetechgroup.beetask.entities.report.GeneratedReport;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.GeneratedReportRepository;
|
||||||
|
|
||||||
|
public class ListGeneratedReportsUseCase {
|
||||||
|
|
||||||
|
private final GeneratedReportRepository generatedReportRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
|
public ListGeneratedReportsUseCase(
|
||||||
|
GeneratedReportRepository generatedReportRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer
|
||||||
|
) {
|
||||||
|
this.generatedReportRepository = generatedReportRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<GeneratedReport> execute(String userEmail, Long organizationId) {
|
||||||
|
authorizer.execute(userEmail, organizationId);
|
||||||
|
return generatedReportRepository.findByOrganizationId(organizationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.report.list;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.ReportMapper;
|
||||||
|
import net.beetechgroup.beetask.usecase.report.ReportTemplateOutput;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.ReportTemplateRepository;
|
||||||
|
|
||||||
|
public class ListReportTemplatesUseCase {
|
||||||
|
|
||||||
|
private final ReportTemplateRepository reportTemplateRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
|
public ListReportTemplatesUseCase(
|
||||||
|
ReportTemplateRepository reportTemplateRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer
|
||||||
|
) {
|
||||||
|
this.reportTemplateRepository = reportTemplateRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ReportTemplateOutput> execute(String userEmail, Long organizationId, Long projectId) {
|
||||||
|
authorizer.execute(userEmail, organizationId);
|
||||||
|
|
||||||
|
if (projectId != null && projectId > 0) {
|
||||||
|
return reportTemplateRepository.findByProjectId(projectId).stream()
|
||||||
|
.map(ReportMapper::toOutput)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return reportTemplateRepository.findByOrganizationId(organizationId).stream()
|
||||||
|
.map(ReportMapper::toOutput)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import net.beetechgroup.beetask.entities.report.GeneratedReport;
|
||||||
|
|
||||||
|
public interface GeneratedReportRepository {
|
||||||
|
GeneratedReport saveGeneratedReport(GeneratedReport report);
|
||||||
|
Optional<GeneratedReport> findGeneratedReportById(Long id);
|
||||||
|
List<GeneratedReport> findByOrganizationId(Long organizationId);
|
||||||
|
List<GeneratedReport> findByUserId(Long userId);
|
||||||
|
void deleteGeneratedReportById(Long id);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import net.beetechgroup.beetask.entities.report.ReportTemplate;
|
||||||
|
|
||||||
|
public interface ReportTemplateRepository {
|
||||||
|
ReportTemplate saveReportTemplate(ReportTemplate template);
|
||||||
|
Optional<ReportTemplate> findReportTemplateById(Long id);
|
||||||
|
List<ReportTemplate> findByOrganizationId(Long organizationId);
|
||||||
|
List<ReportTemplate> findByProjectId(Long projectId);
|
||||||
|
void deleteReportTemplateById(Long id);
|
||||||
|
}
|
||||||
|
|
@ -4,4 +4,6 @@ import java.io.InputStream;
|
||||||
|
|
||||||
public interface StorageRepository {
|
public interface StorageRepository {
|
||||||
String upload(String key, InputStream data, String contentType, long size);
|
String upload(String key, InputStream data, String contentType, long size);
|
||||||
|
InputStream download(String key);
|
||||||
|
void delete(String key);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
CREATE TABLE report_templates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
scope VARCHAR(32) NOT NULL DEFAULT 'ORGANIZATION',
|
||||||
|
file_url VARCHAR(1024) NOT NULL,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
storage_key VARCHAR(512) NOT NULL,
|
||||||
|
created_by BIGINT REFERENCES users_entity(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_report_templates_org ON report_templates(organization_id);
|
||||||
|
CREATE INDEX idx_report_templates_proj ON report_templates(project_id);
|
||||||
|
|
||||||
|
CREATE TABLE report_sections (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
report_template_id BIGINT NOT NULL REFERENCES report_templates(id) ON DELETE CASCADE,
|
||||||
|
section_key VARCHAR(100) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
section_type VARCHAR(50) NOT NULL, -- 'FREE_TEXT' ou 'TASK_LIST'
|
||||||
|
filter_category_id BIGINT REFERENCES categories(id) ON DELETE SET NULL,
|
||||||
|
filter_status VARCHAR(50) DEFAULT 'ALL',
|
||||||
|
include_time BOOLEAN DEFAULT TRUE,
|
||||||
|
order_index INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_report_sections_template ON report_sections(report_template_id);
|
||||||
|
|
||||||
|
CREATE TABLE generated_reports (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
report_template_id BIGINT REFERENCES report_templates(id) ON DELETE SET NULL,
|
||||||
|
organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
|
user_id BIGINT REFERENCES users_entity(id) ON DELETE SET NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
file_url VARCHAR(1024) NOT NULL,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
storage_key VARCHAR(512) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_generated_reports_org ON generated_reports(organization_id);
|
||||||
|
CREATE INDEX idx_generated_reports_user ON generated_reports(user_id);
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
ALTER TABLE tasks ADD COLUMN created_by_id BIGINT;
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT fk_tasks_created_by FOREIGN KEY (created_by_id) REFERENCES users_entity (id);
|
||||||
|
UPDATE tasks SET created_by_id = user_id WHERE created_by_id IS NULL AND user_id IS NOT NULL;
|
||||||
|
UPDATE tasks SET created_by_id = (SELECT id FROM users_entity ORDER BY id ASC LIMIT 1) WHERE created_by_id IS NULL;
|
||||||
|
CREATE INDEX idx_tasks_created_by_id ON tasks (created_by_id);
|
||||||
|
|
||||||
|
ALTER TABLE task_history DROP CONSTRAINT IF EXISTS fk_task_history_task;
|
||||||
|
ALTER TABLE task_history ADD CONSTRAINT fk_task_history_task FOREIGN KEY (task_id) REFERENCES tasks (id) ON DELETE CASCADE;
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
ALTER TABLE tasks ADD COLUMN organization_id BIGINT REFERENCES organizations(id);
|
||||||
|
|
||||||
|
-- Populate organization_id from projects if available
|
||||||
|
UPDATE tasks t
|
||||||
|
SET organization_id = p.organization_id
|
||||||
|
FROM projects p
|
||||||
|
WHERE t.project_id = p.id;
|
||||||
|
|
||||||
|
-- Populate organization_id from categories if project was null
|
||||||
|
UPDATE tasks t
|
||||||
|
SET organization_id = c.organization_id
|
||||||
|
FROM categories c
|
||||||
|
WHERE t.category_id = c.id AND t.organization_id IS NULL;
|
||||||
|
|
||||||
|
-- Fallback for any orphaned tasks to organization 1 (or first organization)
|
||||||
|
UPDATE tasks
|
||||||
|
SET organization_id = (SELECT id FROM organizations ORDER BY id ASC LIMIT 1)
|
||||||
|
WHERE organization_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_tasks_organization_id ON tasks(organization_id);
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
-- 1. Soft Delete columns on all core entities
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
|
||||||
|
ALTER TABLE categories ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
|
||||||
|
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
|
||||||
|
ALTER TABLE users_entity ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
|
||||||
|
ALTER TABLE user_organizations ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
|
||||||
|
|
||||||
|
-- 2. Author tracking in task_history
|
||||||
|
ALTER TABLE task_history ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users_entity(id);
|
||||||
|
|
||||||
|
-- Backfill user_id on existing history records with the task assignee (or creator fallback)
|
||||||
|
UPDATE task_history th
|
||||||
|
SET user_id = COALESCE(t.user_id, t.created_by_id)
|
||||||
|
FROM tasks t
|
||||||
|
WHERE th.task_id = t.id AND th.user_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_task_history_user_id ON task_history(user_id);
|
||||||
|
|
||||||
|
-- 3. Partial Unique Indexes compatible with Soft Delete
|
||||||
|
ALTER TABLE user_organizations DROP CONSTRAINT IF EXISTS uk_user_organizations_user_org;
|
||||||
|
DROP INDEX IF EXISTS uk_user_organizations_user_org;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_user_organizations_active
|
||||||
|
ON user_organizations (user_id, organization_id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_projects_org_name
|
||||||
|
ON projects (organization_id, lower(name))
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE categories DROP CONSTRAINT IF EXISTS uk_categories_org_name;
|
||||||
|
DROP INDEX IF EXISTS uk_categories_org_name;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_categories_org_name
|
||||||
|
ON categories (organization_id, lower(name))
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
@ -16,6 +16,7 @@ import { MembersPage } from './pages/MembersPage'
|
||||||
import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage'
|
import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage'
|
||||||
import { AnalyticsPage } from './pages/AnalyticsPage'
|
import { AnalyticsPage } from './pages/AnalyticsPage'
|
||||||
import { ProfilePage } from './pages/ProfilePage'
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
|
import { ReportsPage } from './pages/ReportsPage'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -47,6 +48,7 @@ export function App() {
|
||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
<Route path="/members" element={<MembersPage />} />
|
<Route path="/members" element={<MembersPage />} />
|
||||||
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
|
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
|
||||||
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
User,
|
User,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Chip, ChipLabel } from '@heroui/react'
|
import { Chip, ChipLabel } from '@heroui/react'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
|
@ -99,6 +100,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
||||||
group: 'Gestão',
|
group: 'Gestão',
|
||||||
links: [
|
links: [
|
||||||
{ to: '/analytics', label: 'Analytics', icon: <BarChart3 className="w-4 h-4" /> },
|
{ to: '/analytics', label: 'Analytics', icon: <BarChart3 className="w-4 h-4" /> },
|
||||||
|
{ to: '/reports', label: 'Relatórios', icon: <FileText className="w-4 h-4" /> },
|
||||||
{ to: '/members', label: 'Membros', icon: <Users className="w-4 h-4" /> },
|
{ to: '/members', label: 'Membros', icon: <Users className="w-4 h-4" /> },
|
||||||
{ to: '/organization/settings', label: 'Configurações', icon: <Settings className="w-4 h-4" /> },
|
{ to: '/organization/settings', label: 'Configurações', icon: <Settings className="w-4 h-4" /> },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
446
frontend/src/components/reports/CreateTemplateModal.tsx
Normal file
446
frontend/src/components/reports/CreateTemplateModal.tsx
Normal file
|
|
@ -0,0 +1,446 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
UploadCloud,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
Tag,
|
||||||
|
AlertCircle,
|
||||||
|
Folder,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { CustomSelect } from '../ui/Select'
|
||||||
|
import { reportService, type ReportTemplateOutput } from '../../services/reportService'
|
||||||
|
import type { Project, Category } from '../../types/task'
|
||||||
|
|
||||||
|
interface CreateTemplateModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: (newTemplate: ReportTemplateOutput) => void
|
||||||
|
orgId: number
|
||||||
|
projects: Project[]
|
||||||
|
categories: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionFormItem {
|
||||||
|
sectionKey: string
|
||||||
|
title: string
|
||||||
|
sectionType: 'FREE_TEXT' | 'TASK_LIST'
|
||||||
|
filterCategoryId?: number
|
||||||
|
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
|
||||||
|
includeTime: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
orgId,
|
||||||
|
projects,
|
||||||
|
categories,
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [scope, setScope] = useState<'ORGANIZATION' | 'PROJECT'>('ORGANIZATION')
|
||||||
|
const [projectId, setProjectId] = useState<number | undefined>(undefined)
|
||||||
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [sections, setSections] = useState<SectionFormItem[]>([
|
||||||
|
{
|
||||||
|
sectionKey: 'resumo_andamento',
|
||||||
|
title: 'Resumo do Andamento do Projeto',
|
||||||
|
sectionType: 'FREE_TEXT',
|
||||||
|
includeTime: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sectionKey: 'atividades',
|
||||||
|
title: 'Atividades e Entregas Desenvolvidas',
|
||||||
|
sectionType: 'TASK_LIST',
|
||||||
|
filterStatus: 'ALL',
|
||||||
|
includeTime: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sectionKey: 'reunioes',
|
||||||
|
title: 'Reuniões e Alinhamentos Técnicos',
|
||||||
|
sectionType: 'TASK_LIST',
|
||||||
|
filterStatus: 'ALL',
|
||||||
|
includeTime: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const handleAddSection = () => {
|
||||||
|
setSections((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
sectionKey: `secao_${prev.length + 1}`,
|
||||||
|
title: `Nova Seção ${prev.length + 1}`,
|
||||||
|
sectionType: 'FREE_TEXT',
|
||||||
|
includeTime: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveSection = (index: number) => {
|
||||||
|
setSections((prev) => prev.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateSection = (index: number, patch: Partial<SectionFormItem>) => {
|
||||||
|
setSections((prev) =>
|
||||||
|
prev.map((s, i) => (i === index ? { ...s, ...patch } : s))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
const selected = e.target.files[0]
|
||||||
|
if (!selected.name.toLowerCase().endsWith('.docx')) {
|
||||||
|
setError('O modelo deve ser um arquivo com extensão .docx (Word)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFile(selected)
|
||||||
|
setError(null)
|
||||||
|
if (!name) {
|
||||||
|
setName(selected.name.replace(/\.[^/.]+$/, '').replace(/_/g, ' '))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Informe o nome do modelo de relatório')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
setError('Faça o upload de um arquivo .docx para o modelo')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('name', name.trim())
|
||||||
|
formData.append('description', description.trim())
|
||||||
|
formData.append('scope', scope)
|
||||||
|
if (scope === 'PROJECT' && projectId) {
|
||||||
|
formData.append('projectId', String(projectId))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpar chaves das seções para serem seguras para {{tag}}
|
||||||
|
const sanitizedSections = sections.map((s, idx) => ({
|
||||||
|
sectionKey: s.sectionKey.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_'),
|
||||||
|
title: s.title.trim() || s.sectionKey,
|
||||||
|
sectionType: s.sectionType,
|
||||||
|
filterCategoryId: s.filterCategoryId,
|
||||||
|
filterStatus: s.filterStatus || 'ALL',
|
||||||
|
includeTime: s.includeTime ?? true,
|
||||||
|
orderIndex: idx,
|
||||||
|
}))
|
||||||
|
|
||||||
|
formData.append('sectionsJson', JSON.stringify(sanitizedSections))
|
||||||
|
|
||||||
|
const created = await reportService.createTemplate(orgId, formData)
|
||||||
|
onSuccess(created)
|
||||||
|
onClose()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Falha ao salvar modelo de relatório')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-2xl bg-surface border border-border-soft rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border-soft">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-brand/10 text-brand border border-brand/20 flex items-center justify-center shadow-xs">
|
||||||
|
<FileText className="w-5 h-5 text-brand" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-extrabold text-foreground">
|
||||||
|
Cadastrar Modelo de Relatório (.DOCX)
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Envie seu arquivo Word e configure as tags que serão preenchidas automaticamente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Body */}
|
||||||
|
<form onSubmit={handleSubmit} 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" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nome e Escopo */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div className="sm:col-span-2 space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-foreground">
|
||||||
|
Nome do Modelo <span className="text-rose-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Ex.: Relatório Mensal de Atividades P&D"
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand focus:ring-1 focus:ring-brand shadow-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-foreground">Nível / Escopo</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={scope}
|
||||||
|
onChange={(val) => setScope(val as 'ORGANIZATION' | 'PROJECT')}
|
||||||
|
options={[
|
||||||
|
{ value: 'ORGANIZATION', label: 'Organização' },
|
||||||
|
{ value: 'PROJECT', label: 'Projeto Específico' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Se for escopo de Projeto, selecionar qual */}
|
||||||
|
{scope === 'PROJECT' && (
|
||||||
|
<div className="space-y-1.5 animate-in fade-in duration-150">
|
||||||
|
<label className="text-xs font-bold text-foreground">Projeto Vinculado</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={projectId ?? 0}
|
||||||
|
onChange={(val) => setProjectId(Number(val) === 0 ? undefined : Number(val))}
|
||||||
|
placeholder="Selecione o Projeto..."
|
||||||
|
leftIcon={<Folder className="w-3.5 h-3.5 text-brand" />}
|
||||||
|
options={[
|
||||||
|
{ value: 0, label: 'Selecione o Projeto...' },
|
||||||
|
...projects.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.name,
|
||||||
|
color: p.color || '#0f766e',
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Descrição */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-foreground">
|
||||||
|
Descrição ou Orientações <span className="text-muted font-normal">(Opcional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Ex.: Template padrão exigido para prestação de contas mensal."
|
||||||
|
className="w-full px-3.5 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload de Arquivo .DOCX */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-bold text-foreground flex items-center justify-between">
|
||||||
|
<span>Arquivo Template .DOCX <span className="text-rose-500">*</span></span>
|
||||||
|
<span className="text-[11px] text-muted font-normal">Formatos suportados: .docx</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="relative border-2 border-dashed border-border-soft hover:border-brand/40 bg-surface-raised/60 hover:bg-surface-raised rounded-2xl p-5 flex flex-col items-center justify-center cursor-pointer transition-all duration-200">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<UploadCloud className="w-8 h-8 text-brand/70 mb-2" />
|
||||||
|
{file ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs font-bold text-brand">{file.name}</p>
|
||||||
|
<p className="text-[10px] text-muted">{(file.size / 1024).toFixed(1)} KB — Clique para substituir</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs font-semibold text-foreground">Clique para selecionar seu arquivo Word</p>
|
||||||
|
<p className="text-[10px] text-muted">ou arraste e solte o arquivo .docx aqui</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Construtor de Seções Dinâmicas */}
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-bold text-foreground">Seções e Tags Dinâmicas do Modelo</h4>
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
Insira essas tags entre <code className="text-brand font-bold">{'{{nome_da_tag}}'}</code> no seu arquivo Word.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddSection}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-brand/10 hover:bg-brand/20 text-brand text-xs font-bold transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Adicionar Seção</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sections.map((section, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-3.5 rounded-2xl border border-border-soft bg-surface-raised/40 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[11px] font-bold text-brand flex items-center gap-1.5">
|
||||||
|
<Tag className="w-3 h-3" />
|
||||||
|
Tag no Word: <code className="bg-surface px-1.5 py-0.5 rounded-md border border-border-soft text-foreground font-mono font-bold">
|
||||||
|
{`{{${section.sectionKey || 'tag'}}}`}
|
||||||
|
</code>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveSection(idx)}
|
||||||
|
className="p-1 rounded-lg text-muted hover:text-rose-500 hover:bg-rose-500/10 transition-colors cursor-pointer"
|
||||||
|
title="Remover seção"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-semibold text-muted block mb-1">Identificador da Tag</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={section.sectionKey}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateSection(idx, {
|
||||||
|
sectionKey: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="ex: resumo_projeto"
|
||||||
|
className="w-full px-2.5 py-1.5 rounded-lg border border-border-soft bg-surface text-xs font-mono text-foreground focus:outline-hidden focus:border-brand"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-semibold text-muted block mb-1">Título Amigável</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={section.title}
|
||||||
|
onChange={(e) => handleUpdateSection(idx, { title: e.target.value })}
|
||||||
|
placeholder="ex: Resumo do Andamento"
|
||||||
|
className="w-full px-2.5 py-1.5 rounded-lg border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-semibold text-muted block mb-1">Tipo de Seção</label>
|
||||||
|
<CustomSelect
|
||||||
|
size="sm"
|
||||||
|
value={section.sectionType}
|
||||||
|
onChange={(val) =>
|
||||||
|
handleUpdateSection(idx, { sectionType: val as 'FREE_TEXT' | 'TASK_LIST' })
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: 'FREE_TEXT', label: 'Texto Livre (Digitado)' },
|
||||||
|
{ value: 'TASK_LIST', label: 'Lista de Tarefas do Período' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{section.sectionType === 'TASK_LIST' && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 pt-1 border-t border-border-soft/60 animate-in fade-in duration-150">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-semibold text-muted block mb-1">
|
||||||
|
Filtrar por Categoria (Opcional)
|
||||||
|
</label>
|
||||||
|
<CustomSelect
|
||||||
|
size="sm"
|
||||||
|
value={section.filterCategoryId ?? 0}
|
||||||
|
onChange={(val) =>
|
||||||
|
handleUpdateSection(idx, {
|
||||||
|
filterCategoryId: Number(val) === 0 ? undefined : Number(val),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: 0, label: 'Todas as Categorias' },
|
||||||
|
...categories.map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.name,
|
||||||
|
color: c.color,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-xs text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={section.includeTime}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateSection(idx, { includeTime: e.target.checked })
|
||||||
|
}
|
||||||
|
className="rounded border-border text-brand focus:ring-brand"
|
||||||
|
/>
|
||||||
|
<span>Incluir duração/horas da tarefa</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end gap-3 p-5 border-t border-border-soft bg-surface-raised/40">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{loading ? 'Salvando Modelo...' : 'Salvar Modelo de Relatório'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
433
frontend/src/components/reports/GenerateReportModal.tsx
Normal file
433
frontend/src/components/reports/GenerateReportModal.tsx
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
FileDown,
|
||||||
|
Calendar,
|
||||||
|
AlertCircle,
|
||||||
|
Folder,
|
||||||
|
CheckCircle2,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { CustomSelect } from '../ui/Select'
|
||||||
|
import { reportService, type ReportTemplateOutput } from '../../services/reportService'
|
||||||
|
import type { Project } from '../../types/task'
|
||||||
|
|
||||||
|
interface GenerateReportModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
orgId: number
|
||||||
|
templates: ReportTemplateOutput[]
|
||||||
|
projects: Project[]
|
||||||
|
initialTemplateId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
orgId,
|
||||||
|
templates,
|
||||||
|
projects,
|
||||||
|
initialTemplateId,
|
||||||
|
}) => {
|
||||||
|
// Inicializar datas com o mês atual (do 1º ao último dia do mês)
|
||||||
|
const defaultDates = useMemo(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||||
|
return {
|
||||||
|
start: firstDay.toISOString().split('T')[0],
|
||||||
|
end: lastDay.toISOString().split('T')[0],
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<number | undefined>(
|
||||||
|
initialTemplateId || (templates[0]?.id)
|
||||||
|
)
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>(undefined)
|
||||||
|
const [startDate, setStartDate] = useState(defaultDates.start)
|
||||||
|
const [endDate, setEndDate] = useState(defaultDates.end)
|
||||||
|
const [periodPreset, setPeriodPreset] = useState<'current_month' | 'last_month' | 'custom'>('current_month')
|
||||||
|
const [customFields, setCustomFields] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
// Atualizar template selecionado se initialTemplateId mudar ou templates carregarem
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialTemplateId) {
|
||||||
|
setSelectedTemplateId(initialTemplateId)
|
||||||
|
} else if (!selectedTemplateId && templates.length > 0) {
|
||||||
|
setSelectedTemplateId(templates[0].id)
|
||||||
|
}
|
||||||
|
}, [initialTemplateId, templates, selectedTemplateId])
|
||||||
|
|
||||||
|
const selectedTemplate = useMemo(() => {
|
||||||
|
return templates.find((t) => t.id === selectedTemplateId)
|
||||||
|
}, [templates, selectedTemplateId])
|
||||||
|
|
||||||
|
// Ajustar projeto selecionado de acordo com o template
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTemplate?.projectId) {
|
||||||
|
setSelectedProjectId(selectedTemplate.projectId)
|
||||||
|
}
|
||||||
|
}, [selectedTemplate])
|
||||||
|
|
||||||
|
// Seções de texto livre do template selecionado
|
||||||
|
const freeTextSections = useMemo(() => {
|
||||||
|
if (!selectedTemplate) return []
|
||||||
|
return selectedTemplate.sections.filter((s) => s.sectionType === 'FREE_TEXT')
|
||||||
|
}, [selectedTemplate])
|
||||||
|
|
||||||
|
// Lista de seções de tarefas do template selecionado
|
||||||
|
const taskListSections = useMemo(() => {
|
||||||
|
if (!selectedTemplate) return []
|
||||||
|
return selectedTemplate.sections.filter((s) => s.sectionType === 'TASK_LIST')
|
||||||
|
}, [selectedTemplate])
|
||||||
|
|
||||||
|
// Preset Handlers
|
||||||
|
const handlePresetChange = (preset: 'current_month' | 'last_month' | 'custom') => {
|
||||||
|
setPeriodPreset(preset)
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (preset === 'current_month') {
|
||||||
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||||
|
setStartDate(firstDay.toISOString().split('T')[0])
|
||||||
|
setEndDate(lastDay.toISOString().split('T')[0])
|
||||||
|
} else if (preset === 'last_month') {
|
||||||
|
const firstDay = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||||
|
const lastDay = new Date(now.getFullYear(), now.getMonth(), 0)
|
||||||
|
setStartDate(firstDay.toISOString().split('T')[0])
|
||||||
|
setEndDate(lastDay.toISOString().split('T')[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCustomFieldChange = (key: string, value: string) => {
|
||||||
|
setCustomFields((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!selectedTemplateId) {
|
||||||
|
setError('Selecione um modelo de relatório')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
setError('Defina o período (data de início e término)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (new Date(startDate) > new Date(endDate)) {
|
||||||
|
setError('A data de início não pode ser posterior à data de término')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGenerating(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(false)
|
||||||
|
|
||||||
|
const { blob, fileName } = await reportService.generateReport(orgId, {
|
||||||
|
templateId: selectedTemplateId,
|
||||||
|
projectId: selectedProjectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
customFields,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Download do arquivo gerado
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = downloadUrl
|
||||||
|
a.download = fileName
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(downloadUrl)
|
||||||
|
document.body.removeChild(a)
|
||||||
|
|
||||||
|
setSuccess(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose()
|
||||||
|
}, 1500)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Falha ao gerar relatório')
|
||||||
|
} finally {
|
||||||
|
setGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
|
||||||
|
<div
|
||||||
|
className="w-full max-w-2xl bg-surface border border-border-soft rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[92vh] animate-in zoom-in-95 duration-200"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border-soft">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-brand/10 text-brand border border-brand/20 flex items-center justify-center shadow-xs">
|
||||||
|
<FileDown className="w-5 h-5 text-brand" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-extrabold text-foreground">
|
||||||
|
Gerar Relatório de Atividades (.DOCX)
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Selecione o modelo, o período de vigência e preencha as informações do relatório.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Body */}
|
||||||
|
<form onSubmit={handleGenerate} 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" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="p-3.5 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
|
<span>Relatório gerado e baixado com sucesso!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Seleção do Template */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-foreground">
|
||||||
|
Modelo de Relatório (.docx) <span className="text-rose-500">*</span>
|
||||||
|
</label>
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<div className="p-4 rounded-2xl border border-dashed border-border-soft text-center text-xs text-muted">
|
||||||
|
Nenhum modelo cadastrado ainda. Cadastre um modelo antes de gerar o relatório.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedTemplateId}
|
||||||
|
onChange={(val) => setSelectedTemplateId(Number(val))}
|
||||||
|
placeholder="Selecione um modelo..."
|
||||||
|
leftIcon={<FileText className="w-3.5 h-3.5 text-brand" />}
|
||||||
|
options={templates.map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: t.name,
|
||||||
|
description: t.scope === 'PROJECT' && t.projectName ? `Projeto: ${t.projectName}` : 'Nível Organização',
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seleção de Projeto (se o template não for restrito) */}
|
||||||
|
{selectedTemplate?.scope !== 'PROJECT' && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-foreground">
|
||||||
|
Iniciativa / Projeto <span className="text-muted font-normal">(Opcional)</span>
|
||||||
|
</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedProjectId ?? 0}
|
||||||
|
onChange={(val) => setSelectedProjectId(Number(val) === 0 ? undefined : Number(val))}
|
||||||
|
placeholder="Todos os Projetos (Consolidado)"
|
||||||
|
leftIcon={<Folder className="w-3.5 h-3.5 text-brand" />}
|
||||||
|
options={[
|
||||||
|
{ value: 0, label: 'Todos os Projetos (Consolidado)' },
|
||||||
|
...projects.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.name,
|
||||||
|
color: p.color || '#0f766e',
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Período de Vigência com Atalhos Rápidos */}
|
||||||
|
<div className="space-y-2.5 p-4 rounded-2xl border border-border-soft bg-surface-raised/40">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Período de Vigência do Relatório
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Botões de Atalho */}
|
||||||
|
<div className="inline-flex rounded-xl p-0.5 bg-surface-sunken/60 border border-border-soft text-[11px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePresetChange('current_month')}
|
||||||
|
className={`px-2.5 py-1 rounded-lg font-medium transition-all cursor-pointer ${
|
||||||
|
periodPreset === 'current_month'
|
||||||
|
? 'bg-brand text-white shadow-xs'
|
||||||
|
: 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mês Atual
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePresetChange('last_month')}
|
||||||
|
className={`px-2.5 py-1 rounded-lg font-medium transition-all cursor-pointer ${
|
||||||
|
periodPreset === 'last_month'
|
||||||
|
? 'bg-brand text-white shadow-xs'
|
||||||
|
: 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Mês Anterior
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPeriodPreset('custom')}
|
||||||
|
className={`px-2.5 py-1 rounded-lg font-medium transition-all cursor-pointer ${
|
||||||
|
periodPreset === 'custom'
|
||||||
|
? 'bg-brand text-white shadow-xs'
|
||||||
|
: 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Personalizado
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-semibold text-muted block mb-1">
|
||||||
|
Data de Início
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStartDate(e.target.value)
|
||||||
|
setPeriodPreset('custom')
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand focus:ring-1 focus:ring-brand shadow-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[11px] font-semibold text-muted block mb-1">
|
||||||
|
Data de Término
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEndDate(e.target.value)
|
||||||
|
setPeriodPreset('custom')
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand focus:ring-1 focus:ring-brand shadow-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[11px] text-muted flex items-center gap-1.5 pt-1">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse" />
|
||||||
|
<span>
|
||||||
|
Refletirá nas tags: <code className="text-brand font-semibold">{'{{data_inicio}}'}</code>,{' '}
|
||||||
|
<code className="text-brand font-semibold">{'{{data_fim}}'}</code> e{' '}
|
||||||
|
<code className="text-brand font-semibold">{'{{periodo_vigencia}}'}</code>.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seções de Texto Livre Dinâmicas */}
|
||||||
|
{freeTextSections.length > 0 && (
|
||||||
|
<div className="space-y-3.5">
|
||||||
|
<div className="border-b border-border-soft pb-1">
|
||||||
|
<h4 className="text-xs font-bold text-foreground">
|
||||||
|
Informações Descritivas do Relatório
|
||||||
|
</h4>
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
Preencha os textos que serão substituídos no documento Word.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{freeTextSections.map((sec) => (
|
||||||
|
<div key={sec.sectionKey} className="space-y-1.5">
|
||||||
|
<label className="text-xs font-bold text-foreground flex items-center justify-between">
|
||||||
|
<span>{sec.title}</span>
|
||||||
|
<span className="text-[10px] text-muted font-mono">{`{{${sec.sectionKey}}}`}</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={customFields[sec.sectionKey] || ''}
|
||||||
|
onChange={(e) => handleCustomFieldChange(sec.sectionKey, e.target.value)}
|
||||||
|
placeholder={`Escreva aqui o conteúdo para "${sec.title}"...`}
|
||||||
|
className="w-full p-3 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand focus:ring-1 focus:ring-brand shadow-xs resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lista de Seções que serão preenchidas automaticamente */}
|
||||||
|
{taskListSections.length > 0 && (
|
||||||
|
<div className="p-3.5 rounded-2xl bg-surface-raised/40 border border-border-soft">
|
||||||
|
<span className="text-xs font-bold text-foreground block mb-1.5">
|
||||||
|
Listagens Automáticas incluídas no Relatório:
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{taskListSections.map((sec) => (
|
||||||
|
<span
|
||||||
|
key={sec.sectionKey}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold bg-brand/10 text-brand border border-brand/20"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
<span>{sec.title}</span>
|
||||||
|
{sec.filterCategoryName && (
|
||||||
|
<span className="text-[10px] text-muted">({sec.filterCategoryName})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end gap-3 p-5 border-t border-border-soft bg-surface-raised/40">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={generating}
|
||||||
|
className="px-4 py-2 rounded-xl text-xs font-semibold text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -23,14 +23,17 @@ import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
|
FileDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useTimer } from '../contexts/TimerContext'
|
import { useTimer } from '../contexts/TimerContext'
|
||||||
import { taskService } from '../services/taskService'
|
import { taskService } from '../services/taskService'
|
||||||
import { projectService } from '../services/projectService'
|
import { projectService } from '../services/projectService'
|
||||||
|
import { reportService, type ReportTemplateOutput } from '../services/reportService'
|
||||||
import type { Task, Project } from '../types/task'
|
import type { Task, Project } from '../types/task'
|
||||||
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
||||||
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
||||||
|
import { GenerateReportModal } from '../components/reports/GenerateReportModal'
|
||||||
import {
|
import {
|
||||||
getUserWorkGoal,
|
getUserWorkGoal,
|
||||||
calculateUserPeriodWorkedSeconds,
|
calculateUserPeriodWorkedSeconds,
|
||||||
|
|
@ -53,6 +56,8 @@ export const DashboardPage: React.FC = () => {
|
||||||
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
|
const [reportTemplates, setReportTemplates] = useState<ReportTemplateOutput[]>([])
|
||||||
|
const [isReportModalOpen, setIsReportModalOpen] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
@ -111,9 +116,10 @@ export const DashboardPage: React.FC = () => {
|
||||||
if (!activeOrgId) return
|
if (!activeOrgId) return
|
||||||
try {
|
try {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
const [tasksRes, projsRes] = await Promise.allSettled([
|
const [tasksRes, projsRes, tplsRes] = await Promise.allSettled([
|
||||||
taskService.listTasks({ organizationId: activeOrgId }),
|
taskService.listTasks({ organizationId: activeOrgId }),
|
||||||
projectService.listProjects(activeOrgId),
|
projectService.listProjects(activeOrgId),
|
||||||
|
reportService.listTemplates(activeOrgId),
|
||||||
])
|
])
|
||||||
|
|
||||||
const loadedTasks = tasksRes.status === 'fulfilled' ? tasksRes.value || [] : []
|
const loadedTasks = tasksRes.status === 'fulfilled' ? tasksRes.value || [] : []
|
||||||
|
|
@ -123,6 +129,10 @@ export const DashboardPage: React.FC = () => {
|
||||||
if (projsRes.status === 'fulfilled') {
|
if (projsRes.status === 'fulfilled') {
|
||||||
setProjects(projsRes.value || [])
|
setProjects(projsRes.value || [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tplsRes.status === 'fulfilled') {
|
||||||
|
setReportTemplates(tplsRes.value || [])
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setError('Falha ao atualizar dados do dashboard.')
|
setError('Falha ao atualizar dados do dashboard.')
|
||||||
|
|
@ -322,6 +332,15 @@ export const DashboardPage: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</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"
|
||||||
|
>
|
||||||
|
<FileDown className="w-4 h-4" />
|
||||||
|
<span>Gerar Relatório</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
|
@ -980,6 +999,7 @@ export const DashboardPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Task Creation Modal */}
|
{/* Task Creation Modal */}
|
||||||
{activeOrgId && (
|
{activeOrgId && (
|
||||||
|
<>
|
||||||
<CreateTaskModal
|
<CreateTaskModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
|
@ -987,6 +1007,15 @@ export const DashboardPage: React.FC = () => {
|
||||||
organizationId={activeOrgId}
|
organizationId={activeOrgId}
|
||||||
initialStatus="NOT_STARTED"
|
initialStatus="NOT_STARTED"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GenerateReportModal
|
||||||
|
isOpen={isReportModalOpen}
|
||||||
|
onClose={() => setIsReportModalOpen(false)}
|
||||||
|
orgId={activeOrgId}
|
||||||
|
templates={reportTemplates}
|
||||||
|
projects={projects}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
505
frontend/src/pages/ReportsPage.tsx
Normal file
505
frontend/src/pages/ReportsPage.tsx
Normal file
|
|
@ -0,0 +1,505 @@
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
FileDown,
|
||||||
|
Sparkles,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
Tag,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import {
|
||||||
|
reportService,
|
||||||
|
type ReportTemplateOutput,
|
||||||
|
type GeneratedReportOutput,
|
||||||
|
} from '../services/reportService'
|
||||||
|
import { projectService } from '../services/projectService'
|
||||||
|
import { categoryService } from '../services/categoryService'
|
||||||
|
import type { Project, Category } from '../types/task'
|
||||||
|
import { CreateTemplateModal } from '../components/reports/CreateTemplateModal'
|
||||||
|
import { GenerateReportModal } from '../components/reports/GenerateReportModal'
|
||||||
|
|
||||||
|
export const ReportsPage: React.FC = () => {
|
||||||
|
const { activeOrg } = useAuth()
|
||||||
|
const orgId = activeOrg?.id
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'templates' | 'history' | 'guide'>('templates')
|
||||||
|
const [templates, setTemplates] = useState<ReportTemplateOutput[]>([])
|
||||||
|
const [history, setHistory] = useState<GeneratedReportOutput[]>([])
|
||||||
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [copiedTag, setCopiedTag] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Modais
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
|
const [isGenerateOpen, setIsGenerateOpen] = useState(false)
|
||||||
|
const [generateInitialTemplateId, setGenerateInitialTemplateId] = useState<number | undefined>(undefined)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!orgId) return
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const [tpls, hist, projs, cats] = await Promise.all([
|
||||||
|
reportService.listTemplates(orgId),
|
||||||
|
reportService.listHistory(orgId).catch(() => []),
|
||||||
|
projectService.listProjects(orgId).catch(() => []),
|
||||||
|
categoryService.listCategories(orgId).catch(() => []),
|
||||||
|
])
|
||||||
|
setTemplates(tpls)
|
||||||
|
setHistory(hist)
|
||||||
|
setProjects(projs)
|
||||||
|
setCategories(cats)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Falha ao carregar modelos e relatórios')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [orgId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const handleDeleteTemplate = async (templateId: number) => {
|
||||||
|
if (!orgId) return
|
||||||
|
if (!window.confirm('Tem certeza de que deseja excluir este modelo de relatório?')) return
|
||||||
|
try {
|
||||||
|
await reportService.deleteTemplate(orgId, templateId)
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== templateId))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err)
|
||||||
|
alert(err instanceof Error ? err.message : 'Erro ao excluir modelo')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenGenerate = (templateId?: number) => {
|
||||||
|
setGenerateInitialTemplateId(templateId)
|
||||||
|
setIsGenerateOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyTag = (tag: string) => {
|
||||||
|
navigator.clipboard.writeText(tag)
|
||||||
|
setCopiedTag(tag)
|
||||||
|
setTimeout(() => setCopiedTag(null), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const standardTags = [
|
||||||
|
{ tag: '{{data_inicio}}', desc: 'Data inicial do período escolhido (ex: 01/10/2026)' },
|
||||||
|
{ tag: '{{data_fim}}', desc: 'Data final do período escolhido (ex: 31/10/2026)' },
|
||||||
|
{ tag: '{{periodo_vigencia}}', desc: 'Intervalo formatado (ex: 01/10/2026 a 31/10/2026)' },
|
||||||
|
{ tag: '{{mes_ano}}', desc: 'Mês e ano por extenso (ex: Outubro/2026)' },
|
||||||
|
{ tag: '{{nome_usuario}}', desc: 'Nome completo do usuário logado' },
|
||||||
|
{ tag: '{{email_usuario}}', desc: 'E-mail do usuário' },
|
||||||
|
{ tag: '{{organizacao}}', desc: 'Nome da organização / workspace ativo' },
|
||||||
|
{ tag: '{{projeto}}', desc: 'Nome do projeto selecionado ou Geral' },
|
||||||
|
{ tag: '{{total_horas}}', desc: 'Total de horas trabalhadas no período (ex: 142h 30m)' },
|
||||||
|
{ tag: '{{data_emissao}}', desc: 'Data de geração do relatório (ex: 31/10/2026)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tableItemTags = [
|
||||||
|
{ tag: '{{item.titulo}}', desc: 'Título da tarefa (em linhas repetidas de tabelas)' },
|
||||||
|
{ tag: '{{item.data}}', desc: 'Data da tarefa / conclusão' },
|
||||||
|
{ tag: '{{item.tempo}}', desc: 'Duração / horas trabalhadas' },
|
||||||
|
{ tag: '{{item.status}}', desc: 'Status da tarefa (Concluído, Em Andamento)' },
|
||||||
|
{ tag: '{{item.categoria}}', desc: 'Nome da categoria (ex: Reunião, Feature)' },
|
||||||
|
{ tag: '{{item.projeto}}', desc: 'Nome do projeto da tarefa' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
|
{/* Top Banner */}
|
||||||
|
{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" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-brand/10 border border-brand/20 text-xs font-semibold text-brand">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-brand animate-pulse" />
|
||||||
|
<span>Workspace: {activeOrg?.name || 'BeeTask'}</span>
|
||||||
|
</div>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full bg-accent/15 text-amber-700 dark:text-amber-300 border border-accent/25 text-[11px] font-bold">
|
||||||
|
Templates .DOCX
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
|
||||||
|
Central de Relatórios
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs sm:text-sm text-muted">
|
||||||
|
Cadastre seus modelos corporativos em Word e gere relatórios de atividades e reuniões com preenchimento 100% automático.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 text-brand" />
|
||||||
|
<span>Novo Modelo .DOCX</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<FileDown className="w-4 h-4" />
|
||||||
|
<span>Gerar Relatório</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-border-soft">
|
||||||
|
<nav className="flex space-x-6" aria-label="Tabs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('templates')}
|
||||||
|
className={`py-3 px-1 border-b-2 font-bold text-xs sm:text-sm cursor-pointer transition-all flex items-center gap-2 ${
|
||||||
|
activeTab === 'templates'
|
||||||
|
? 'border-brand text-brand'
|
||||||
|
: 'border-transparent text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>Modelos de Relatório ({templates.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('history')}
|
||||||
|
className={`py-3 px-1 border-b-2 font-bold text-xs sm:text-sm cursor-pointer transition-all flex items-center gap-2 ${
|
||||||
|
activeTab === 'history'
|
||||||
|
? 'border-brand text-brand'
|
||||||
|
: 'border-transparent text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span>Histórico de Emissões ({history.length})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('guide')}
|
||||||
|
className={`py-3 px-1 border-b-2 font-bold text-xs sm:text-sm cursor-pointer transition-all flex items-center gap-2 ${
|
||||||
|
activeTab === 'guide'
|
||||||
|
? 'border-brand text-brand'
|
||||||
|
: 'border-transparent text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Tag className="w-4 h-4" />
|
||||||
|
<span>Guia de Tags do Word</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 1: Modelos de Relatório */}
|
||||||
|
{activeTab === 'templates' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-44 rounded-3xl border border-border-soft bg-surface-sunken/40 animate-pulse p-6"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<div className="p-12 text-center rounded-3xl border border-dashed border-border-soft bg-surface/60 backdrop-blur-md space-y-4">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-brand/10 text-brand flex items-center justify-center mx-auto">
|
||||||
|
<FileText className="w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md mx-auto space-y-1">
|
||||||
|
<h3 className="text-base font-bold text-foreground">
|
||||||
|
Nenhum modelo de relatório cadastrado
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Faça o upload do seu primeiro template em formato Word (.docx) com a identidade visual da sua organização.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCreateOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand hover:bg-brand-hover text-white text-xs font-bold shadow-md shadow-brand/20 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Cadastrar Primeiro Modelo</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{templates.map((tpl) => (
|
||||||
|
<div
|
||||||
|
key={tpl.id}
|
||||||
|
className="p-5 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200 flex flex-col justify-between space-y-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-brand/10 border border-brand/20 text-brand flex items-center justify-center shrink-0">
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-surface-raised border border-border-soft text-muted">
|
||||||
|
{tpl.scope === 'PROJECT' && tpl.projectName
|
||||||
|
? `Projeto: ${tpl.projectName}`
|
||||||
|
: 'Organização'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-foreground line-clamp-1">{tpl.name}</h3>
|
||||||
|
{tpl.description && (
|
||||||
|
<p className="text-xs text-muted line-clamp-2 mt-1">{tpl.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags configuradas */}
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
<span className="text-[10px] font-bold text-muted uppercase tracking-wider block">
|
||||||
|
Seções Dinâmicas ({tpl.sections.length})
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5 max-h-20 overflow-y-auto">
|
||||||
|
{tpl.sections.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s.sectionKey}
|
||||||
|
title={`${s.title} (${s.sectionType})`}
|
||||||
|
className="px-2 py-0.5 rounded-md text-[10px] font-mono font-semibold bg-surface-raised border border-border-soft text-foreground/80"
|
||||||
|
>
|
||||||
|
{`{{${s.sectionKey}}}`}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-between pt-3 border-t border-border-soft/60">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteTemplate(tpl.id)}
|
||||||
|
className="p-1.5 rounded-xl text-muted hover:text-rose-500 hover:bg-rose-500/10 transition-colors cursor-pointer"
|
||||||
|
title="Excluir Modelo"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={tpl.fileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="p-1.5 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
|
||||||
|
title="Baixar Template Original (.docx)"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenGenerate(tpl.id)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-brand/10 hover:bg-brand text-brand hover:text-white text-xs font-bold transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<FileDown className="w-3.5 h-3.5" />
|
||||||
|
<span>Gerar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 2: Histórico de Emissões */}
|
||||||
|
{activeTab === 'history' && (
|
||||||
|
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel overflow-hidden">
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-xs text-muted">
|
||||||
|
Nenhum relatório foi emitido neste workspace ainda.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-soft bg-surface-raised/60 text-muted font-bold">
|
||||||
|
<th className="py-3 px-4">Relatório / Período</th>
|
||||||
|
<th className="py-3 px-4">Período de Vigência</th>
|
||||||
|
<th className="py-3 px-4">Emitido por</th>
|
||||||
|
<th className="py-3 px-4">Data de Emissão</th>
|
||||||
|
<th className="py-3 px-4 text-right">Download</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border-soft/60">
|
||||||
|
{history.map((rep) => (
|
||||||
|
<tr key={rep.id} className="hover:bg-surface-raised/40 transition-colors">
|
||||||
|
<td className="py-3.5 px-4 font-bold text-foreground flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-brand shrink-0" />
|
||||||
|
<span>{rep.title}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-muted font-mono">
|
||||||
|
{rep.startDate} até {rep.endDate}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-foreground/90">
|
||||||
|
{rep.userName || 'Você'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-muted">
|
||||||
|
{new Date(rep.createdAt).toLocaleDateString('pt-BR')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right">
|
||||||
|
<a
|
||||||
|
href={rep.fileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
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>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 3: Guia de Tags do Word */}
|
||||||
|
{activeTab === 'guide' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-6 shadow-panel space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/25 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-extrabold text-foreground">
|
||||||
|
Como preparar seu Template no Microsoft Word (.docx)
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Basta abrir seu arquivo Word habitual e digitar as tags com chaves duplas nos locais onde deseja que as informações apareçam.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-2">
|
||||||
|
{/* Tags Globais de Período e Usuário */}
|
||||||
|
<div className="p-4 rounded-2xl border border-border-soft bg-surface-raised/40 space-y-3">
|
||||||
|
<span className="text-xs font-bold text-foreground block">
|
||||||
|
Tags Globais de Período e Cabeçalho
|
||||||
|
</span>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{standardTags.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.tag}
|
||||||
|
className="flex items-center justify-between gap-2 p-2 rounded-xl bg-surface border border-border-soft text-xs"
|
||||||
|
>
|
||||||
|
<div className="truncate">
|
||||||
|
<code className="text-brand font-mono font-bold">{t.tag}</code>
|
||||||
|
<p className="text-[11px] text-muted truncate">{t.desc}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopyTag(t.tag)}
|
||||||
|
className="p-1.5 rounded-lg text-muted hover:text-brand hover:bg-brand/10 transition-colors cursor-pointer shrink-0"
|
||||||
|
title="Copiar Tag"
|
||||||
|
>
|
||||||
|
{copiedTag === t.tag ? (
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags em Tabelas Dinâmicas */}
|
||||||
|
<div className="p-4 rounded-2xl border border-border-soft bg-surface-raised/40 space-y-3">
|
||||||
|
<span className="text-xs font-bold text-foreground block">
|
||||||
|
Tags para Tabelas com Repetição de Linhas
|
||||||
|
</span>
|
||||||
|
<p className="text-[11px] text-muted leading-relaxed">
|
||||||
|
Crie uma tabela no Word com um cabeçalho e 1 linha contendo as tags abaixo. O BeeTask multiplicará as linhas para cada tarefa encontrada no período!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tableItemTags.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.tag}
|
||||||
|
className="flex items-center justify-between gap-2 p-2 rounded-xl bg-surface border border-border-soft text-xs"
|
||||||
|
>
|
||||||
|
<div className="truncate">
|
||||||
|
<code className="text-accent font-mono font-bold">{t.tag}</code>
|
||||||
|
<p className="text-[11px] text-muted truncate">{t.desc}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopyTag(t.tag)}
|
||||||
|
className="p-1.5 rounded-lg text-muted hover:text-brand hover:bg-brand/10 transition-colors cursor-pointer shrink-0"
|
||||||
|
title="Copiar Tag"
|
||||||
|
>
|
||||||
|
{copiedTag === t.tag ? (
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modais */}
|
||||||
|
{orgId && (
|
||||||
|
<>
|
||||||
|
<CreateTemplateModal
|
||||||
|
isOpen={isCreateOpen}
|
||||||
|
onClose={() => setIsCreateOpen(false)}
|
||||||
|
onSuccess={(newTemplate) => {
|
||||||
|
setTemplates((prev) => [newTemplate, ...prev])
|
||||||
|
}}
|
||||||
|
orgId={orgId}
|
||||||
|
projects={projects}
|
||||||
|
categories={categories}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GenerateReportModal
|
||||||
|
isOpen={isGenerateOpen}
|
||||||
|
onClose={() => setIsGenerateOpen(false)}
|
||||||
|
orgId={orgId}
|
||||||
|
templates={templates}
|
||||||
|
projects={projects}
|
||||||
|
initialTemplateId={generateInitialTemplateId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
frontend/src/services/reportService.ts
Normal file
127
frontend/src/services/reportService.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { apiFetch } from '../lib/api'
|
||||||
|
|
||||||
|
export interface ReportSectionOutput {
|
||||||
|
id: number
|
||||||
|
sectionKey: string
|
||||||
|
title: string
|
||||||
|
sectionType: 'FREE_TEXT' | 'TASK_LIST'
|
||||||
|
filterCategoryId?: number
|
||||||
|
filterCategoryName?: string
|
||||||
|
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
|
||||||
|
includeTime: boolean
|
||||||
|
orderIndex: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportTemplateOutput {
|
||||||
|
id: number
|
||||||
|
organizationId: number
|
||||||
|
organizationName?: string
|
||||||
|
projectId?: number
|
||||||
|
projectName?: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
scope: 'ORGANIZATION' | 'PROJECT'
|
||||||
|
fileUrl: string
|
||||||
|
fileName: string
|
||||||
|
storageKey: string
|
||||||
|
createdById?: number
|
||||||
|
createdByName?: string
|
||||||
|
sections: ReportSectionOutput[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedReportOutput {
|
||||||
|
id: number
|
||||||
|
reportTemplateId?: number
|
||||||
|
organizationId: number
|
||||||
|
projectId?: number
|
||||||
|
projectName?: string
|
||||||
|
userId?: number
|
||||||
|
userName?: string
|
||||||
|
title: string
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
fileUrl: string
|
||||||
|
fileName: string
|
||||||
|
storageKey: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateReportPayload {
|
||||||
|
templateId: number
|
||||||
|
projectId?: number
|
||||||
|
startDate: string // YYYY-MM-DD
|
||||||
|
endDate: string // YYYY-MM-DD
|
||||||
|
customFields?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reportService = {
|
||||||
|
async listTemplates(orgId: number, projectId?: number): Promise<ReportTemplateOutput[]> {
|
||||||
|
const query = projectId ? `?projectId=${projectId}` : ''
|
||||||
|
return apiFetch<ReportTemplateOutput[]>(`/organizations/${orgId}/reports/templates${query}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTemplate(orgId: number, formData: FormData): Promise<ReportTemplateOutput> {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || 'https://api-task.beetech.dev'
|
||||||
|
|
||||||
|
const res = await fetch(`${apiUrl}/organizations/${orgId}/reports/templates`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: 'Erro ao criar modelo de relatório' }))
|
||||||
|
throw new Error(err.message || 'Erro ao criar modelo de relatório')
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTemplate(orgId: number, templateId: number): Promise<void> {
|
||||||
|
return apiFetch<void>(`/organizations/${orgId}/reports/templates/${templateId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateReport(orgId: number, payload: GenerateReportPayload): Promise<{ blob: Blob; fileName: string }> {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || 'https://api-task.beetech.dev'
|
||||||
|
|
||||||
|
const res = await fetch(`${apiUrl}/organizations/${orgId}/reports/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ message: 'Erro ao gerar relatório' }))
|
||||||
|
throw new Error(err.message || 'Erro ao gerar relatório')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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`
|
||||||
|
if (disposition && disposition.includes('filename=')) {
|
||||||
|
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||||
|
if (match && match[1]) {
|
||||||
|
fileName = match[1].replace(/['"]/g, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob()
|
||||||
|
return { blob, fileName }
|
||||||
|
},
|
||||||
|
|
||||||
|
async listHistory(orgId: number): Promise<GeneratedReportOutput[]> {
|
||||||
|
return apiFetch<GeneratedReportOutput[]>(`/organizations/${orgId}/reports/history`)
|
||||||
|
},
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue