diff --git a/.gitignore b/.gitignore index 451f097..5ca255b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ .env *.pem *.sql +!backend/src/main/resources/db/migration/*.sql *.sql.gz diff --git a/backend/pom.xml b/backend/pom.xml index 18a03d0..aa2beb1 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -89,6 +89,11 @@ jbcrypt 0.4 + + org.apache.poi + poi-ooxml + 5.4.0 + io.quarkus quarkus-junit diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/report/GeneratedReport.java b/backend/src/main/java/net/beetechgroup/beetask/entities/report/GeneratedReport.java new file mode 100644 index 0000000..9738938 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/report/GeneratedReport.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/report/ReportSection.java b/backend/src/main/java/net/beetechgroup/beetask/entities/report/ReportSection.java new file mode 100644 index 0000000..bcedcc9 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/report/ReportSection.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/report/ReportTemplate.java b/backend/src/main/java/net/beetechgroup/beetask/entities/report/ReportTemplate.java new file mode 100644 index 0000000..5d73bc9 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/report/ReportTemplate.java @@ -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 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 getSections() { + return sections; + } + + public void setSections(List 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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java new file mode 100644 index 0000000..d33c81a --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java @@ -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); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/GeneratedReportEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/GeneratedReportEntity.java new file mode 100644 index 0000000..44b5223 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/GeneratedReportEntity.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ReportSectionEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ReportSectionEntity.java new file mode 100644 index 0000000..d8be910 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ReportSectionEntity.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ReportTemplateEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ReportTemplateEntity.java new file mode 100644 index 0000000..b968bea --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ReportTemplateEntity.java @@ -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 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 getSections() { + return sections; + } + + public void setSections(List 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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/ReportEntityMapper.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/ReportEntityMapper.java new file mode 100644 index 0000000..e351ea3 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/ReportEntityMapper.java @@ -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; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/GeneratedReportRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/GeneratedReportRepositoryImpl.java new file mode 100644 index 0000000..4accfc0 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/GeneratedReportRepositoryImpl.java @@ -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 { + 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 findGeneratedReportById(Long id) { + return findByIdOptional(id).map(ReportEntityMapper::toDomain); + } + + @Override + public List findByOrganizationId(Long organizationId) { + return find("organization.id = ?1 order by createdAt desc", organizationId).stream() + .map(ReportEntityMapper::toDomain) + .toList(); + } + + @Override + public List 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); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/ReportTemplateRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/ReportTemplateRepositoryImpl.java new file mode 100644 index 0000000..ed150b9 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/ReportTemplateRepositoryImpl.java @@ -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 { + 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 findReportTemplateById(Long id) { + return findByIdOptional(id).map(ReportEntityMapper::toDomain); + } + + @Override + public List findByOrganizationId(Long organizationId) { + return find("organization.id = ?1 order by createdAt desc", organizationId).stream() + .map(ReportEntityMapper::toDomain) + .toList(); + } + + @Override + public List 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); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/storage/MinioStorageService.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/storage/MinioStorageService.java index 9d83a72..a7e1b27 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/storage/MinioStorageService.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/storage/MinioStorageService.java @@ -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()); + } + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/CreateReportTemplateForm.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/CreateReportTemplateForm.java new file mode 100644 index 0000000..408582f --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/CreateReportTemplateForm.java @@ -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; +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateReportRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateReportRequest.java new file mode 100644 index 0000000..136bb46 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateReportRequest.java @@ -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 customFields; +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java new file mode 100644 index 0000000..7d09ab1 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java @@ -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 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 sections = new ArrayList<>(); + if (form.sectionsJson != null && !form.sectionsJson.isBlank()) { + try { + sections = objectMapper.readValue(form.sectionsJson, new TypeReference>() {}); + } 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 listHistory(@PathParam("orgId") Long orgId) { + String email = securityIdentity.getPrincipal().getName(); + return listGeneratedReportsUseCase.execute(email, orgId); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportMapper.java new file mode 100644 index 0000000..67d6af2 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportMapper.java @@ -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 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() + ); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportSectionOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportSectionOutput.java new file mode 100644 index 0000000..af58086 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportSectionOutput.java @@ -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 +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportTemplateOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportTemplateOutput.java new file mode 100644 index 0000000..5adef4d --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportTemplateOutput.java @@ -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 sections, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateInput.java new file mode 100644 index 0000000..6c2eb10 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateInput.java @@ -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 sections +) { + public record SectionInput( + String sectionKey, + String title, + String sectionType, + Long filterCategoryId, + String filterStatus, + Boolean includeTime, + Integer orderIndex + ) {} +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateUseCase.java new file mode 100644 index 0000000..43eb9b6 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateUseCase.java @@ -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 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); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/delete/DeleteReportTemplateUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/delete/DeleteReportTemplateUseCase.java new file mode 100644 index 0000000..578a629 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/delete/DeleteReportTemplateUseCase.java @@ -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); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/docengine/DocxReportEngine.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/docengine/DocxReportEngine.java new file mode 100644 index 0000000..42d4e5b --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/docengine/DocxReportEngine.java @@ -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 variables, + Map> 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 variables, + Map> listSections + ) { + List paragraphs = new ArrayList<>(doc.getParagraphs()); + for (XWPFParagraph p : paragraphs) { + replaceTextInParagraph(p, variables, listSections); + } + } + + private static void processTables( + XWPFDocument doc, + Map variables, + Map> listSections + ) { + for (XWPFTable table : doc.getTables()) { + List 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 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 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 variables, + Map> 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> entry : listSections.entrySet()) { + String tag = "{{" + entry.getKey() + "}}"; + if (text.contains(tag)) { + List 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 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(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportInput.java new file mode 100644 index 0000000..90a9a96 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportInput.java @@ -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 customFields +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportOutput.java new file mode 100644 index 0000000..c32aea3 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportOutput.java @@ -0,0 +1,9 @@ +package net.beetechgroup.beetask.usecase.report.generate; + +public record GenerateReportOutput( + byte[] docxBytes, + String fileName, + String contentType, + String fileUrl, + Long generatedReportId +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java new file mode 100644 index 0000000..1572e69 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java @@ -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 workedTasks = taskRepository.findTasksWorkedByUserInPeriod(input.userEmail(), startDt, endDt, org.getId()); + List finishedTasks = taskRepository.findTasksFinishedByUserInPeriod(input.userEmail(), startDt, endDt); + + // Mesclar tarefas únicas + Map 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 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 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 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 entry : input.customFields().entrySet()) { + variables.put(entry.getKey(), entry.getValue() != null ? entry.getValue() : ""); + } + } + + // 4. Montar seções de listagem de tarefas / reuniões + Map> listSections = new HashMap<>(); + + if (template.getSections() != null) { + for (ReportSection section : template.getSections()) { + if ("TASK_LIST".equalsIgnoreCase(section.getSectionType())) { + List 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 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); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/list/ListGeneratedReportsUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/list/ListGeneratedReportsUseCase.java new file mode 100644 index 0000000..9eaa39b --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/list/ListGeneratedReportsUseCase.java @@ -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 execute(String userEmail, Long organizationId) { + authorizer.execute(userEmail, organizationId); + return generatedReportRepository.findByOrganizationId(organizationId); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/list/ListReportTemplatesUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/list/ListReportTemplatesUseCase.java new file mode 100644 index 0000000..3ff343d --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/list/ListReportTemplatesUseCase.java @@ -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 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(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/GeneratedReportRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/GeneratedReportRepository.java new file mode 100644 index 0000000..85323e7 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/GeneratedReportRepository.java @@ -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 findGeneratedReportById(Long id); + List findByOrganizationId(Long organizationId); + List findByUserId(Long userId); + void deleteGeneratedReportById(Long id); +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/ReportTemplateRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/ReportTemplateRepository.java new file mode 100644 index 0000000..5ce71d8 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/ReportTemplateRepository.java @@ -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 findReportTemplateById(Long id); + List findByOrganizationId(Long organizationId); + List findByProjectId(Long projectId); + void deleteReportTemplateById(Long id); +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/StorageRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/StorageRepository.java index 3b5a044..9c6143c 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/StorageRepository.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/StorageRepository.java @@ -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); } diff --git a/backend/src/main/resources/db/migration/V10__add_report_templates.sql b/backend/src/main/resources/db/migration/V10__add_report_templates.sql new file mode 100644 index 0000000..ed3d919 --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__add_report_templates.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V7__add_task_created_by.sql b/backend/src/main/resources/db/migration/V7__add_task_created_by.sql new file mode 100644 index 0000000..76c115d --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__add_task_created_by.sql @@ -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; diff --git a/backend/src/main/resources/db/migration/V8__add_task_organization.sql b/backend/src/main/resources/db/migration/V8__add_task_organization.sql new file mode 100644 index 0000000..b3e0e23 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__add_task_organization.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V9__add_soft_delete_and_user_history.sql b/backend/src/main/resources/db/migration/V9__add_soft_delete_and_user_history.sql new file mode 100644 index 0000000..099aac5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__add_soft_delete_and_user_history.sql @@ -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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 011b17f..b3f7a0b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9429915..17cc34d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -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: }, + { to: '/reports', label: 'Relatórios', icon: }, { to: '/members', label: 'Membros', icon: }, { to: '/organization/settings', label: 'Configurações', icon: }, ], diff --git a/frontend/src/components/reports/CreateTemplateModal.tsx b/frontend/src/components/reports/CreateTemplateModal.tsx new file mode 100644 index 0000000..890272f --- /dev/null +++ b/frontend/src/components/reports/CreateTemplateModal.tsx @@ -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 = ({ + 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(undefined) + const [file, setFile] = useState(null) + const [sections, setSections] = useState([ + { + 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(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) => { + setSections((prev) => + prev.map((s, i) => (i === index ? { ...s, ...patch } : s)) + ) + } + + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Cadastrar Modelo de Relatório (.DOCX) +

+

+ Envie seu arquivo Word e configure as tags que serão preenchidas automaticamente. +

+
+
+ + +
+ + {/* Form Body */} +
+ {error && ( +
+ + {error} +
+ )} + + {/* Nome e Escopo */} +
+
+ + 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 + /> +
+ +
+ + setScope(val as 'ORGANIZATION' | 'PROJECT')} + options={[ + { value: 'ORGANIZATION', label: 'Organização' }, + { value: 'PROJECT', label: 'Projeto Específico' }, + ]} + /> +
+
+ + {/* Se for escopo de Projeto, selecionar qual */} + {scope === 'PROJECT' && ( +
+ + setProjectId(Number(val) === 0 ? undefined : Number(val))} + placeholder="Selecione o Projeto..." + leftIcon={} + options={[ + { value: 0, label: 'Selecione o Projeto...' }, + ...projects.map((p) => ({ + value: p.id, + label: p.name, + color: p.color || '#0f766e', + })), + ]} + /> +
+ )} + + {/* Descrição */} +
+ + 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" + /> +
+ + {/* Upload de Arquivo .DOCX */} +
+ + + +
+ + {/* Construtor de Seções Dinâmicas */} +
+
+
+

Seções e Tags Dinâmicas do Modelo

+

+ Insira essas tags entre {'{{nome_da_tag}}'} no seu arquivo Word. +

+
+ +
+ +
+ {sections.map((section, idx) => ( +
+
+ + + Tag no Word: + {`{{${section.sectionKey || 'tag'}}}`} + + + +
+ +
+
+ + + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + + 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' }, + ]} + /> +
+
+ + {section.sectionType === 'TASK_LIST' && ( +
+
+ + + 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, + })), + ]} + /> +
+ +
+ +
+
+ )} +
+ ))} +
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/reports/GenerateReportModal.tsx b/frontend/src/components/reports/GenerateReportModal.tsx new file mode 100644 index 0000000..328e776 --- /dev/null +++ b/frontend/src/components/reports/GenerateReportModal.tsx @@ -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 = ({ + 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( + initialTemplateId || (templates[0]?.id) + ) + const [selectedProjectId, setSelectedProjectId] = useState(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>({}) + + const [generating, setGenerating] = useState(false) + const [error, setError] = useState(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 ( +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Gerar Relatório de Atividades (.DOCX) +

+

+ Selecione o modelo, o período de vigência e preencha as informações do relatório. +

+
+
+ + +
+ + {/* Form Body */} +
+ {error && ( +
+ + {error} +
+ )} + + {success && ( +
+ + Relatório gerado e baixado com sucesso! +
+ )} + + {/* Seleção do Template */} +
+ + {templates.length === 0 ? ( +
+ Nenhum modelo cadastrado ainda. Cadastre um modelo antes de gerar o relatório. +
+ ) : ( + setSelectedTemplateId(Number(val))} + placeholder="Selecione um modelo..." + leftIcon={} + options={templates.map((t) => ({ + value: t.id, + label: t.name, + description: t.scope === 'PROJECT' && t.projectName ? `Projeto: ${t.projectName}` : 'Nível Organização', + }))} + /> + )} +
+ + {/* Seleção de Projeto (se o template não for restrito) */} + {selectedTemplate?.scope !== 'PROJECT' && ( +
+ + setSelectedProjectId(Number(val) === 0 ? undefined : Number(val))} + placeholder="Todos os Projetos (Consolidado)" + leftIcon={} + options={[ + { value: 0, label: 'Todos os Projetos (Consolidado)' }, + ...projects.map((p) => ({ + value: p.id, + label: p.name, + color: p.color || '#0f766e', + })), + ]} + /> +
+ )} + + {/* Período de Vigência com Atalhos Rápidos */} +
+
+ + + {/* Botões de Atalho */} +
+ + + +
+
+ +
+
+ + { + 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 + /> +
+ +
+ + { + 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 + /> +
+
+ +
+ + + Refletirá nas tags: {'{{data_inicio}}'},{' '} + {'{{data_fim}}'} e{' '} + {'{{periodo_vigencia}}'}. + +
+
+ + {/* Seções de Texto Livre Dinâmicas */} + {freeTextSections.length > 0 && ( +
+
+

+ Informações Descritivas do Relatório +

+

+ Preencha os textos que serão substituídos no documento Word. +

+
+ + {freeTextSections.map((sec) => ( +
+ +