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
|
||||
*.pem
|
||||
*.sql
|
||||
!backend/src/main/resources/db/migration/*.sql
|
||||
*.sql.gz
|
||||
|
||||
|
|
|
|||
|
|
@ -89,6 +89,11 @@
|
|||
<artifactId>jbcrypt</artifactId>
|
||||
<version>0.4</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.4.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<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;
|
||||
|
||||
import io.minio.BucketExistsArgs;
|
||||
import io.minio.GetObjectArgs;
|
||||
import io.minio.MakeBucketArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import io.minio.SetBucketPolicyArgs;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
|
@ -73,4 +75,30 @@ public class MinioStorageService implements StorageRepository {
|
|||
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 {
|
||||
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 { AnalyticsPage } from './pages/AnalyticsPage'
|
||||
import { ProfilePage } from './pages/ProfilePage'
|
||||
import { ReportsPage } from './pages/ReportsPage'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
|
|
@ -47,6 +48,7 @@ export function App() {
|
|||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/members" element={<MembersPage />} />
|
||||
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
PlusCircle,
|
||||
User,
|
||||
LogOut,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { Chip, ChipLabel } from '@heroui/react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
|
|
@ -99,6 +100,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
|||
group: 'Gestão',
|
||||
links: [
|
||||
{ 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: '/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,
|
||||
User,
|
||||
Users,
|
||||
FileDown,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useTimer } from '../contexts/TimerContext'
|
||||
import { taskService } from '../services/taskService'
|
||||
import { projectService } from '../services/projectService'
|
||||
import { reportService, type ReportTemplateOutput } from '../services/reportService'
|
||||
import type { Task, Project } from '../types/task'
|
||||
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
||||
import { CreateTaskModal } from '../components/tasks/CreateTaskModal'
|
||||
import { GenerateReportModal } from '../components/reports/GenerateReportModal'
|
||||
import {
|
||||
getUserWorkGoal,
|
||||
calculateUserPeriodWorkedSeconds,
|
||||
|
|
@ -53,6 +56,8 @@ export const DashboardPage: React.FC = () => {
|
|||
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [reportTemplates, setReportTemplates] = useState<ReportTemplateOutput[]>([])
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -111,9 +116,10 @@ export const DashboardPage: React.FC = () => {
|
|||
if (!activeOrgId) return
|
||||
try {
|
||||
setRefreshing(true)
|
||||
const [tasksRes, projsRes] = await Promise.allSettled([
|
||||
const [tasksRes, projsRes, tplsRes] = await Promise.allSettled([
|
||||
taskService.listTasks({ organizationId: activeOrgId }),
|
||||
projectService.listProjects(activeOrgId),
|
||||
reportService.listTemplates(activeOrgId),
|
||||
])
|
||||
|
||||
const loadedTasks = tasksRes.status === 'fulfilled' ? tasksRes.value || [] : []
|
||||
|
|
@ -123,6 +129,10 @@ export const DashboardPage: React.FC = () => {
|
|||
if (projsRes.status === 'fulfilled') {
|
||||
setProjects(projsRes.value || [])
|
||||
}
|
||||
|
||||
if (tplsRes.status === 'fulfilled') {
|
||||
setReportTemplates(tplsRes.value || [])
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
setError('Falha ao atualizar dados do dashboard.')
|
||||
|
|
@ -322,6 +332,15 @@ export const DashboardPage: React.FC = () => {
|
|||
</button>
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
|
|
@ -980,6 +999,7 @@ export const DashboardPage: React.FC = () => {
|
|||
|
||||
{/* Task Creation Modal */}
|
||||
{activeOrgId && (
|
||||
<>
|
||||
<CreateTaskModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
|
|
@ -987,6 +1007,15 @@ export const DashboardPage: React.FC = () => {
|
|||
organizationId={activeOrgId}
|
||||
initialStatus="NOT_STARTED"
|
||||
/>
|
||||
|
||||
<GenerateReportModal
|
||||
isOpen={isReportModalOpen}
|
||||
onClose={() => setIsReportModalOpen(false)}
|
||||
orgId={activeOrgId}
|
||||
templates={reportTemplates}
|
||||
projects={projects}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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