feat(reports): add docx template management and dynamic report generation module

This commit is contained in:
Gabriel Menezes 2026-10-31 11:07:13 -04:00
parent e141ad0721
commit 55fd823b2d
42 changed files with 4102 additions and 8 deletions

1
.gitignore vendored
View file

@ -4,5 +4,6 @@
.env
*.pem
*.sql
!backend/src/main/resources/db/migration/*.sql
*.sql.gz

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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());
}
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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()
);
}
}

View file

@ -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
) {}

View file

@ -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
) {}

View file

@ -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
) {}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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
) {}

View file

@ -0,0 +1,9 @@
package net.beetechgroup.beetask.usecase.report.generate;
public record GenerateReportOutput(
byte[] docxBytes,
String fileName,
String contentType,
String fileUrl,
Long generatedReportId
) {}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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>

View file

@ -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" /> },
],

View 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>
)
}

View 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>
)
}

View file

@ -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>
)

View 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>
)
}

View 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`)
},
}