feat: add scope toggles for individual vs team metrics, add GEMINI.md guidelines

This commit is contained in:
Gabriel Menezes 2026-10-22 09:41:58 -04:00
parent d1bfe4d1cb
commit d3174653b1
91 changed files with 2973 additions and 363 deletions

2
.gitignore vendored
View file

@ -3,4 +3,6 @@
.vscode .vscode
.env .env
*.pem *.pem
*.sql
*.sql.gz

47
GEMINI.md Normal file
View file

@ -0,0 +1,47 @@
# Diretrizes do Projeto BeeTask (GEMINI.md)
Este documento contém as instruções e regras mandatórias para desenvolvimento e manutenção no repositório **bee-task-v3**.
---
## ⚠️ Regra Mandatória: Commit e Push ao Finalizar Edições
> [!IMPORTANT]
> **Sempre após a finalização de quaisquer edições ou correções no código, é OBRIGATÓRIO realizar o `git commit` com uma mensagem clara e padronizada e em seguida executar o `git push` para o repositório remoto.**
### Fluxo Obrigatório pós-edição:
1. Validar as alterações (testes/build, ex: `tsc -b` ou `./mvnw test` se aplicável).
2. Adicionar os arquivos alterados:
```bash
git add <arquivos>
```
3. Fazer o commit semântico:
```bash
git commit -m "feat/fix/refactor: descrição clara da alteração"
```
4. Fazer o push imediatamente:
```bash
git push origin <branch-atual>
```
---
## 🐳 Atualização de Ambiente Docker
Caso as alterações envolvam o frontend ou backend que rodam via Docker Compose em produção/staging:
- **Frontend**:
```bash
docker compose build --no-cache frontend && docker compose up -d --no-deps frontend
```
- **Backend**:
```bash
docker compose build --no-cache backend && docker compose up -d --no-deps backend
```
---
## 🛠️ Padrões do Projeto
- **Frontend**: Vite + React 19 + TypeScript + Tailwind v4 + HeroUI.
- Verificar tipagem estrita com `npm --prefix frontend exec -- tsc -b`.
- **Backend**: Quarkus 3.34 + Java 17 + Clean Architecture (Domain Entities -> Use Cases -> Interface Adapters -> Frameworks).

View file

@ -84,6 +84,11 @@
<artifactId>minio</artifactId> <artifactId>minio</artifactId>
<version>8.5.17</version> <version>8.5.17</version>
</dependency> </dependency>
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
<dependency> <dependency>
<groupId>io.quarkus</groupId> <groupId>io.quarkus</groupId>
<artifactId>quarkus-junit</artifactId> <artifactId>quarkus-junit</artifactId>

View file

@ -8,19 +8,38 @@ import java.util.Objects;
import net.beetechgroup.beetask.entities.Category; import net.beetechgroup.beetask.entities.Category;
import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.entities.User; import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.organization.Organization;
public class Task { public class Task {
private Long id; private Long id;
private String title; private String title;
private String description; private String description;
private TaskStatus status; private TaskStatus status;
private Organization organization;
private Project project; private Project project;
private Category category; private Category category;
private User user; private User user;
private User createdBy;
private LocalDateTime finishedAt; private LocalDateTime finishedAt;
private List<TaskHistoryItem> history = new ArrayList<>(); private List<TaskHistoryItem> history = new ArrayList<>();
private String externalUrl; private String externalUrl;
public Organization getOrganization() {
return organization;
}
public void setOrganization(Organization organization) {
this.organization = organization;
}
public User getCreatedBy() {
return createdBy;
}
public void setCreatedBy(User createdBy) {
this.createdBy = createdBy;
}
public String getExternalUrl() { public String getExternalUrl() {
return externalUrl; return externalUrl;
} }
@ -60,11 +79,18 @@ public class Task {
public void setStatus(TaskStatus status) { public void setStatus(TaskStatus status) {
if (this.status == status) return; if (this.status == status) return;
if (status == TaskStatus.COMPLETED || status == TaskStatus.CANCELED) {
if (isRunning()) {
this.stop();
}
if (status == TaskStatus.COMPLETED) { if (status == TaskStatus.COMPLETED) {
this.finishedAt = LocalDateTime.now(); this.finishedAt = LocalDateTime.now();
} else { } else {
this.finishedAt = null; this.finishedAt = null;
} }
} else {
this.finishedAt = null;
}
this.status = status; this.status = status;
} }
@ -121,6 +147,10 @@ public class Task {
} }
public void start() { public void start() {
start(this.user);
}
public void start(User worker) {
TaskHistoryItem current = getCurrentRunningItem(); TaskHistoryItem current = getCurrentRunningItem();
if (Objects.nonNull(current)) { if (Objects.nonNull(current)) {
@ -130,6 +160,7 @@ public class Task {
this.status = TaskStatus.IN_PROGRESS; this.status = TaskStatus.IN_PROGRESS;
TaskHistoryItem item = new TaskHistoryItem(); TaskHistoryItem item = new TaskHistoryItem();
item.setStartAt(LocalDateTime.now()); item.setStartAt(LocalDateTime.now());
item.setUser(worker != null ? worker : this.user);
this.history.add(item); this.history.add(item);
} }

View file

@ -8,6 +8,15 @@ public class TaskHistoryItem {
private Long id; private Long id;
private LocalDateTime startAt; private LocalDateTime startAt;
private LocalDateTime endAt; private LocalDateTime endAt;
private net.beetechgroup.beetask.entities.User user;
public net.beetechgroup.beetask.entities.User getUser() {
return user;
}
public void setUser(net.beetechgroup.beetask.entities.User user) {
this.user = user;
}
public Long getId() { public Long getId() {
return id; return id;

View file

@ -6,6 +6,7 @@ import net.beetechgroup.beetask.usecase.category.create.CreateCategoryUseCase;
import net.beetechgroup.beetask.usecase.category.list.ListCategoriesUseCase; import net.beetechgroup.beetask.usecase.category.list.ListCategoriesUseCase;
import net.beetechgroup.beetask.usecase.category.update.UpdateCategoryUseCase; import net.beetechgroup.beetask.usecase.category.update.UpdateCategoryUseCase;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
@ -26,7 +27,9 @@ public class CategoryUseCaseConfig {
} }
@Produces @Produces
public ListCategoriesUseCase listCategoriesUseCase(CategoryRepository categoryRepository) { public ListCategoriesUseCase listCategoriesUseCase(CategoryRepository categoryRepository,
return new ListCategoriesUseCase(categoryRepository); AuthorizeOrganizationMemberUseCase authorizer) {
return new ListCategoriesUseCase(categoryRepository, authorizer);
} }
} }

View file

@ -9,6 +9,7 @@ import net.beetechgroup.beetask.usecase.organization.requests.ListPendingRequest
import net.beetechgroup.beetask.usecase.organization.requests.ListUserJoinRequestsUseCase; import net.beetechgroup.beetask.usecase.organization.requests.ListUserJoinRequestsUseCase;
import net.beetechgroup.beetask.usecase.organization.requests.HandleJoinRequestUseCase; import net.beetechgroup.beetask.usecase.organization.requests.HandleJoinRequestUseCase;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase; import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
@ -23,6 +24,12 @@ public class OrganizationUseCaseConfig {
return new AuthorizeOrganizationAdminUseCase(userRepository, userOrganizationRepository); return new AuthorizeOrganizationAdminUseCase(userRepository, userOrganizationRepository);
} }
@Produces
public AuthorizeOrganizationMemberUseCase authorizeOrganizationMemberUseCase(UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
return new AuthorizeOrganizationMemberUseCase(userRepository, userOrganizationRepository);
}
@Produces @Produces
public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository, public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository,
UserRepository userRepository, UserRepository userRepository,
@ -65,4 +72,24 @@ public class OrganizationUseCaseConfig {
UserOrganizationRepository userOrganizationRepository) { UserOrganizationRepository userOrganizationRepository) {
return new ListOrganizationMembersUseCase(userRepository, userOrganizationRepository); return new ListOrganizationMembersUseCase(userRepository, userOrganizationRepository);
} }
@Produces
public net.beetechgroup.beetask.usecase.organization.members.RemoveOrganizationMemberUseCase removeOrganizationMemberUseCase(
UserOrganizationRepository userOrganizationRepository,
UserRepository userRepository,
AuthorizeOrganizationAdminUseCase adminAuthorizer,
jakarta.persistence.EntityManager entityManager) {
return new net.beetechgroup.beetask.usecase.organization.members.RemoveOrganizationMemberUseCase(
userOrganizationRepository, userRepository, adminAuthorizer, entityManager);
}
@Produces
public net.beetechgroup.beetask.usecase.organization.members.LeaveOrganizationUseCase leaveOrganizationUseCase(
UserOrganizationRepository userOrganizationRepository,
UserRepository userRepository,
AuthorizeOrganizationMemberUseCase memberAuthorizer,
jakarta.persistence.EntityManager entityManager) {
return new net.beetechgroup.beetask.usecase.organization.members.LeaveOrganizationUseCase(
userOrganizationRepository, userRepository, memberAuthorizer, entityManager);
}
} }

View file

@ -2,6 +2,7 @@ package net.beetechgroup.beetask.frameworks.config;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces; import jakarta.enterprise.inject.Produces;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.project.create.CreateProjectUseCase; import net.beetechgroup.beetask.usecase.project.create.CreateProjectUseCase;
import net.beetechgroup.beetask.usecase.project.list.ListProjectsUseCase; import net.beetechgroup.beetask.usecase.project.list.ListProjectsUseCase;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
@ -12,12 +13,29 @@ public class ProjectUseCaseConfig {
@Produces @Produces
public CreateProjectUseCase createProjectUseCase(ProjectRepository projectRepository, public CreateProjectUseCase createProjectUseCase(ProjectRepository projectRepository,
OrganizationRepository organizationRepository) { OrganizationRepository organizationRepository,
return new CreateProjectUseCase(projectRepository, organizationRepository); AuthorizeOrganizationMemberUseCase authorizer) {
return new CreateProjectUseCase(projectRepository, organizationRepository, authorizer);
} }
@Produces @Produces
public ListProjectsUseCase listProjectsUseCase(ProjectRepository projectRepository) { public ListProjectsUseCase listProjectsUseCase(ProjectRepository projectRepository,
return new ListProjectsUseCase(projectRepository); AuthorizeOrganizationMemberUseCase authorizer) {
return new ListProjectsUseCase(projectRepository, authorizer);
}
@Produces
public net.beetechgroup.beetask.usecase.project.update.UpdateProjectUseCase updateProjectUseCase(
ProjectRepository projectRepository,
AuthorizeOrganizationMemberUseCase authorizer) {
return new net.beetechgroup.beetask.usecase.project.update.UpdateProjectUseCase(projectRepository, authorizer);
}
@Produces
public net.beetechgroup.beetask.usecase.project.delete.DeleteProjectUseCase deleteProjectUseCase(
ProjectRepository projectRepository,
net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase authorizer) {
return new net.beetechgroup.beetask.usecase.project.delete.DeleteProjectUseCase(projectRepository, authorizer);
} }
} }

View file

@ -2,6 +2,7 @@ package net.beetechgroup.beetask.frameworks.config;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces; import jakarta.enterprise.inject.Produces;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
@ -22,18 +23,19 @@ public class TaskUseCaseConfig {
@Produces @Produces
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository, CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) { UserOrganizationRepository userOrganizationRepository,
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository); net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository);
} }
@Produces @Produces
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository) { public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) {
return new StartTaskUseCase(taskRepository, userRepository); return new StartTaskUseCase(taskRepository, userRepository, authorizer);
} }
@Produces @Produces
public StopTaskUseCase stopTaskUseCase(TaskRepository taskRepository) { public StopTaskUseCase stopTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
return new StopTaskUseCase(taskRepository); return new StopTaskUseCase(taskRepository, authorizer);
} }
@Produces @Produces
@ -47,19 +49,29 @@ public class TaskUseCaseConfig {
} }
@Produces @Produces
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository) { public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
return new UpdateTaskStatusUseCase(taskRepository); return new UpdateTaskStatusUseCase(taskRepository, authorizer);
} }
@Produces @Produces
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository, CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) { UserOrganizationRepository userOrganizationRepository,
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository); net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository);
} }
@Produces @Produces
public GetTaskUseCase getTaskUseCase(TaskRepository taskRepository) { public GetTaskUseCase getTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
return new GetTaskUseCase(taskRepository); return new GetTaskUseCase(taskRepository, authorizer);
}
@Produces
public net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase deleteTaskUseCase(
TaskRepository taskRepository,
UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
return new net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase(taskRepository, userRepository, userOrganizationRepository);
} }
} }

View file

@ -63,4 +63,9 @@ public class UserUseCaseConfig {
public net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase updateUserPreferencesUseCase(UserRepository userRepository) { public net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase updateUserPreferencesUseCase(UserRepository userRepository) {
return new net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase(userRepository); return new net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase(userRepository);
} }
@Produces
public net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase listAllUsersUseCase(UserRepository userRepository) {
return new net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase(userRepository);
}
} }

View file

@ -7,9 +7,14 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Column;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
@Entity @Entity
@Table(name = "categories") @Table(name = "categories")
@SQLDelete(sql = "UPDATE categories SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class CategoryEntity { public class CategoryEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -17,6 +22,8 @@ public class CategoryEntity {
private String name; private String name;
private String color; private String color;
private String icon; private String icon;
@Column(name = "deleted_at")
private java.time.LocalDateTime deletedAt;
@ManyToOne @ManyToOne
@JoinColumn(name = "organization_id") @JoinColumn(name = "organization_id")
@ -61,4 +68,12 @@ public class CategoryEntity {
public void setOrganization(OrganizationEntity organization) { public void setOrganization(OrganizationEntity organization) {
this.organization = organization; this.organization = organization;
} }
public java.time.LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(java.time.LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
} }

View file

@ -8,14 +8,21 @@ import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Column;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
@Entity @Entity
@Table(name = "organizations") @Table(name = "organizations")
@SQLDelete(sql = "UPDATE organizations SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class OrganizationEntity { public class OrganizationEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
private String name; private String name;
@Column(name = "deleted_at")
private java.time.LocalDateTime deletedAt;
@OneToMany(mappedBy = "organization") @OneToMany(mappedBy = "organization")
private List<UserOrganizationEntity> userOrganizations; private List<UserOrganizationEntity> userOrganizations;
@ -43,4 +50,11 @@ public class OrganizationEntity {
this.name = name; this.name = name;
} }
public java.time.LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(java.time.LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
} }

View file

@ -11,9 +11,14 @@ import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Column;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
@Entity @Entity
@Table(name = "projects") @Table(name = "projects")
@SQLDelete(sql = "UPDATE projects SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class ProjectEntity { public class ProjectEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -21,6 +26,8 @@ public class ProjectEntity {
private String name; private String name;
private String color; private String color;
private String icon; private String icon;
@Column(name = "deleted_at")
private java.time.LocalDateTime deletedAt;
@ManyToOne @ManyToOne
@JoinColumn(name = "organization_id") @JoinColumn(name = "organization_id")
private OrganizationEntity organization; private OrganizationEntity organization;
@ -76,4 +83,12 @@ public class ProjectEntity {
public void setUsers(List<UserEntity> users) { public void setUsers(List<UserEntity> users) {
this.users = users; this.users = users;
} }
public java.time.LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(java.time.LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
} }

View file

@ -14,10 +14,14 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.entities.task.TaskStatus;
@Entity @Entity
@Table(name = "tasks") @Table(name = "tasks")
@SQLDelete(sql = "UPDATE tasks SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class TaskEntity { public class TaskEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -30,7 +34,12 @@ public class TaskEntity {
private java.time.LocalDateTime finishedAt; private java.time.LocalDateTime finishedAt;
@Column(name = "external_url", length = 1000) @Column(name = "external_url", length = 1000)
private String externalUrl; private String externalUrl;
@Column(name = "deleted_at")
private java.time.LocalDateTime deletedAt;
@ManyToOne
@JoinColumn(name = "organization_id")
private OrganizationEntity organization;
@ManyToOne @ManyToOne
@JoinColumn(name = "project_id") @JoinColumn(name = "project_id")
private ProjectEntity project; private ProjectEntity project;
@ -40,9 +49,28 @@ public class TaskEntity {
@ManyToOne @ManyToOne
@JoinColumn(name = "user_id") @JoinColumn(name = "user_id")
private UserEntity user; private UserEntity user;
@OneToMany(mappedBy = "task", cascade = CascadeType.ALL, orphanRemoval = true) @ManyToOne
@JoinColumn(name = "created_by_id")
private UserEntity createdBy;
@OneToMany(mappedBy = "task", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<TaskHistoryItemEntity> history; private List<TaskHistoryItemEntity> history;
public OrganizationEntity getOrganization() {
return organization;
}
public void setOrganization(OrganizationEntity organization) {
this.organization = organization;
}
public UserEntity getCreatedBy() {
return createdBy;
}
public void setCreatedBy(UserEntity createdBy) {
this.createdBy = createdBy;
}
public void setHistory(List<TaskHistoryItemEntity> history) { public void setHistory(List<TaskHistoryItemEntity> history) {
this.history = history; this.history = history;
} }
@ -122,4 +150,12 @@ public class TaskEntity {
public void setExternalUrl(String externalUrl) { public void setExternalUrl(String externalUrl) {
this.externalUrl = externalUrl; this.externalUrl = externalUrl;
} }
public java.time.LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(java.time.LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
} }

View file

@ -27,6 +27,18 @@ public class TaskHistoryItemEntity {
@JoinColumn(name = "task_id") @JoinColumn(name = "task_id")
private TaskEntity task; private TaskEntity task;
@ManyToOne
@JoinColumn(name = "user_id")
private UserEntity user;
public UserEntity getUser() {
return user;
}
public void setUser(UserEntity user) {
this.user = user;
}
public Long getId() { public Long getId() {
return id; return id;
} }

View file

@ -5,9 +5,14 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.Column;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
@Entity @Entity
@Table(name = "users_entity") @Table(name = "users_entity")
@SQLDelete(sql = "UPDATE users_entity SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class UserEntity { public class UserEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -16,6 +21,8 @@ public class UserEntity {
private String email; private String email;
private String password; private String password;
private String photo; private String photo;
@Column(name = "deleted_at")
private java.time.LocalDateTime deletedAt;
@jakarta.persistence.Column(columnDefinition = "jsonb") @jakarta.persistence.Column(columnDefinition = "jsonb")
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON) @org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
private String preferences; private String preferences;
@ -67,4 +74,12 @@ public class UserEntity {
public void setPreferences(String preferences) { public void setPreferences(String preferences) {
this.preferences = preferences; this.preferences = preferences;
} }
public java.time.LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(java.time.LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
} }

View file

@ -1,5 +1,6 @@
package net.beetechgroup.beetask.frameworks.persistence.entities; package net.beetechgroup.beetask.frameworks.persistence.entities;
import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
@ -11,9 +12,15 @@ import net.beetechgroup.beetask.entities.organization.UserOrganizationRole;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import jakarta.persistence.EnumType; import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated; import jakarta.persistence.Enumerated;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "user_organizations") @Table(name = "user_organizations")
@SQLDelete(sql = "UPDATE user_organizations SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class UserOrganizationEntity { public class UserOrganizationEntity {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -28,6 +35,16 @@ public class UserOrganizationEntity {
private UserOrganizationRole role; private UserOrganizationRole role;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private UserOrganizationStatus status; private UserOrganizationStatus status;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public UserEntity getUser() { public UserEntity getUser() {
return user; return user;
@ -60,4 +77,12 @@ public class UserOrganizationEntity {
public void setStatus(UserOrganizationStatus status) { public void setStatus(UserOrganizationStatus status) {
this.status = status; this.status = status;
} }
public LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
} }

View file

@ -6,6 +6,7 @@ import net.beetechgroup.beetask.frameworks.persistence.entities.OrganizationEnti
public class OrganizationEntityMapper { public class OrganizationEntityMapper {
public static OrganizationEntity toEntity(Organization organization) { public static OrganizationEntity toEntity(Organization organization) {
if (Objects.isNull(organization)) return null;
OrganizationEntity entity = new OrganizationEntity(); OrganizationEntity entity = new OrganizationEntity();
entity.setId(organization.getId()); entity.setId(organization.getId());
entity.setName(organization.getName()); entity.setName(organization.getName());

View file

@ -17,9 +17,11 @@ public class TaskEntityMapper {
entity.setStatus(task.getStatus()); entity.setStatus(task.getStatus());
entity.setFinishedAt(task.getFinishedAt()); entity.setFinishedAt(task.getFinishedAt());
entity.setExternalUrl(task.getExternalUrl()); entity.setExternalUrl(task.getExternalUrl());
entity.setOrganization(OrganizationEntityMapper.toEntity(task.getOrganization()));
entity.setProject(ProjectEntityMapper.toEntity(task.getProject())); entity.setProject(ProjectEntityMapper.toEntity(task.getProject()));
entity.setCategory(CategoryEntityMapper.toEntity(task.getCategory())); entity.setCategory(CategoryEntityMapper.toEntity(task.getCategory()));
entity.setUser(UserEntityMapper.toEntity(task.getUser())); entity.setUser(UserEntityMapper.toEntity(task.getUser()));
entity.setCreatedBy(UserEntityMapper.toEntity(task.getCreatedBy()));
entity.setHistory(new ArrayList<>(task.getHistory().stream().map(h -> { entity.setHistory(new ArrayList<>(task.getHistory().stream().map(h -> {
TaskHistoryItemEntity he = TaskHistoryItemMapper.toEntity(h); TaskHistoryItemEntity he = TaskHistoryItemMapper.toEntity(h);
he.setTask(entity); he.setTask(entity);
@ -38,9 +40,11 @@ public class TaskEntityMapper {
task.setStatus(entity.getStatus()); task.setStatus(entity.getStatus());
task.setFinishedAt(entity.getFinishedAt()); task.setFinishedAt(entity.getFinishedAt());
task.setExternalUrl(entity.getExternalUrl()); task.setExternalUrl(entity.getExternalUrl());
task.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization()));
task.setProject(ProjectEntityMapper.toDomain(entity.getProject())); task.setProject(ProjectEntityMapper.toDomain(entity.getProject()));
task.setCategory(CategoryEntityMapper.toDomain(entity.getCategory())); task.setCategory(CategoryEntityMapper.toDomain(entity.getCategory()));
task.setUser(UserEntityMapper.toDomain(entity.getUser())); task.setUser(UserEntityMapper.toDomain(entity.getUser()));
task.setCreatedBy(UserEntityMapper.toDomain(entity.getCreatedBy()));
task.setHistory(new ArrayList<>(entity.getHistory().stream().map(TaskHistoryItemMapper::toDomain).toList())); task.setHistory(new ArrayList<>(entity.getHistory().stream().map(TaskHistoryItemMapper::toDomain).toList()));
return task; return task;

View file

@ -10,6 +10,9 @@ public class TaskHistoryItemMapper {
entity.setId(item.getId()); entity.setId(item.getId());
entity.setStartAt(item.getStartAt()); entity.setStartAt(item.getStartAt());
entity.setEndAt(item.getEndAt()); entity.setEndAt(item.getEndAt());
if (Objects.nonNull(item.getUser())) {
entity.setUser(UserEntityMapper.toEntity(item.getUser()));
}
return entity; return entity;
} }
@ -20,6 +23,9 @@ public class TaskHistoryItemMapper {
item.setId(entity.getId()); item.setId(entity.getId());
item.setStartAt(entity.getStartAt()); item.setStartAt(entity.getStartAt());
item.setEndAt(entity.getEndAt()); item.setEndAt(entity.getEndAt());
if (Objects.nonNull(entity.getUser())) {
item.setUser(UserEntityMapper.toDomain(entity.getUser()));
}
return item; return item;
} }
} }

View file

@ -41,7 +41,7 @@ public class OrganizationRepositoryImpl implements OrganizationRepository, Panac
return organizations; return organizations;
} }
List<Organization> organizations = find("name like ?1", "%" + query + "%").stream() List<Organization> organizations = find("lower(name) like ?1", "%" + query.toLowerCase() + "%").stream()
.map(OrganizationEntityMapper::toDomain) .map(OrganizationEntityMapper::toDomain)
.toList(); .toList();
LOGGER.infof("Loaded %d organizations for search query '%s'", organizations.size(), query); LOGGER.infof("Loaded %d organizations for search query '%s'", organizations.size(), query);

View file

@ -49,4 +49,24 @@ public class ProjectRepositoryImpl implements ProjectRepository, PanacheReposito
LOGGER.infof("Loaded %d projects for organization %d from database", projects.size(), organizationId); LOGGER.infof("Loaded %d projects for organization %d from database", projects.size(), organizationId);
return projects; return projects;
} }
@Override
public Optional<Project> findByNameAndOrganizationId(String name, Long organizationId) {
return find("lower(name) = lower(?1) and organization.id = ?2", name.trim(), organizationId)
.firstResultOptional()
.map(ProjectEntityMapper::toDomain);
}
@Override
@Transactional
public void deleteProject(Long id) {
ProjectEntity entity = findByIdOptional(id).orElse(null);
if (entity != null) {
getEntityManager().createQuery("UPDATE TaskEntity t SET t.project = NULL WHERE t.project.id = :pid")
.setParameter("pid", id)
.executeUpdate();
delete(entity);
LOGGER.infof("Project %d soft deleted and unlinked from tasks", id);
}
}
} }

View file

@ -57,8 +57,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
public List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds, public List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds) { List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds) {
StringBuilder query = new StringBuilder( StringBuilder query = new StringBuilder(
"select distinct t from TaskEntity t join t.project p " + "select distinct t from TaskEntity t left join t.project p left join t.category c " +
"where p.organization.id = ?1" "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1)"
); );
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
params.add(organizationId); params.add(organizationId);
@ -100,7 +100,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
@Override @Override
public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds, public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds) { List<TaskStatus> statuses, List<Long> categoryIds) {
StringBuilder query = new StringBuilder("user.email = ?1"); StringBuilder query = new StringBuilder("(user.email = ?1 or (user is null and createdBy.email = ?1))");
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
params.add(email); params.add(email);
@ -134,9 +134,29 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
return tasks; return tasks;
} }
@Override
public List<Task> findRunningTasksByUserEmail(String email) {
return find("select distinct t from TaskEntity t left join t.user tu join t.history h left join h.user hu " +
"where (tu.email = ?1 or hu.email = ?1) and h.endAt is null", email)
.list().stream().map(TaskEntityMapper::toDomain).toList();
}
@Override @Override
public List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) { public List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) {
return find("user.email = ?1", email).list().stream() return findTasksWorkedByUserInPeriod(email, start, end, null);
}
@Override
public List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId) {
StringBuilder query = new StringBuilder("select distinct t from TaskEntity t " +
"left join t.project p left join t.category c where (t.user.email = ?1 or (t.user is null and t.createdBy.email = ?1))");
List<Object> params = new ArrayList<>();
params.add(email);
if (organizationId != null) {
query.append(" and (t.organization.id = ?2 or p.organization.id = ?2 or c.organization.id = ?2)");
params.add(organizationId);
}
return find(query.toString(), params.toArray()).list().stream()
.filter(entity -> entity.getHistory().stream().anyMatch(h -> .filter(entity -> entity.getHistory().stream().anyMatch(h ->
Objects.nonNull(h.getStartAt()) && Objects.nonNull(h.getStartAt()) &&
!h.getStartAt().isAfter(end) && !h.getStartAt().isAfter(end) &&
@ -161,15 +181,18 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
@Override @Override
public List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) { public List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) {
return find("select distinct t from TaskEntity t join t.history h " + return find("select distinct t from TaskEntity t " +
"where t.project.organization.id = ?1 " + "left join t.project p left join t.category c join t.history h " +
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
"and h.startAt <= ?3 and (h.endAt is null or h.endAt >= ?2)", "and h.startAt <= ?3 and (h.endAt is null or h.endAt >= ?2)",
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList(); orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
} }
@Override @Override
public List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) { public List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) {
return find("select distinct t from TaskEntity t where t.project.organization.id = ?1 " + return find("select distinct t from TaskEntity t " +
"left join t.project p left join t.category c " +
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
"and t.status = net.beetechgroup.beetask.entities.task.TaskStatus.COMPLETED " + "and t.status = net.beetechgroup.beetask.entities.task.TaskStatus.COMPLETED " +
"and t.finishedAt >= ?2 and t.finishedAt <= ?3", "and t.finishedAt >= ?2 and t.finishedAt <= ?3",
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList(); orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
@ -186,6 +209,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
.toList(); .toList();
} }
@Override
public List<Task> findTasksWorkedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end) {
return find("select distinct t from TaskEntity t " +
"left join t.project p left join t.category c " +
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) and t.user.id = ?2",
orgId, userId).list().stream()
.filter(entity -> entity.getHistory().stream().anyMatch(h ->
Objects.nonNull(h.getStartAt()) &&
!h.getStartAt().isAfter(end) &&
(Objects.isNull(h.getEndAt()) || !h.getEndAt().isBefore(start))))
.map(TaskEntityMapper::toDomain)
.toList();
}
@Override @Override
public List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { public List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) {
return find("user.id = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4", return find("user.id = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4",
@ -193,6 +230,16 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
.list().stream().map(TaskEntityMapper::toDomain).toList(); .list().stream().map(TaskEntityMapper::toDomain).toList();
} }
@Override
public List<Task> findTasksFinishedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end) {
return find("select distinct t from TaskEntity t " +
"left join t.project p left join t.category c " +
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
"and t.user.id = ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5",
orgId, userId, TaskStatus.COMPLETED, start, end)
.list().stream().map(TaskEntityMapper::toDomain).toList();
}
@Override @Override
public List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) { public List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) {
if (emails.isEmpty()) { if (emails.isEmpty()) {
@ -208,6 +255,24 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
.toList(); .toList();
} }
@Override
public List<Task> findTasksWorkedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end) {
if (emails.isEmpty()) {
LOGGER.warn("Task search by user emails skipped because the email list is empty");
return List.of();
}
return find("select distinct t from TaskEntity t " +
"left join t.project p left join t.category c " +
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) and t.user.email in ?2",
orgId, emails).list().stream()
.filter(entity -> entity.getHistory().stream().anyMatch(h ->
Objects.nonNull(h.getStartAt()) &&
!h.getStartAt().isAfter(end) &&
(Objects.isNull(h.getEndAt()) || !h.getEndAt().isBefore(start))))
.map(TaskEntityMapper::toDomain)
.toList();
}
@Override @Override
public List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) { public List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) {
if (emails.isEmpty()) { if (emails.isEmpty()) {
@ -219,6 +284,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
.list().stream().map(TaskEntityMapper::toDomain).toList(); .list().stream().map(TaskEntityMapper::toDomain).toList();
} }
@Override
public List<Task> findTasksFinishedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end) {
if (emails.isEmpty()) {
LOGGER.warn("Finished task search by user emails skipped because the email list is empty");
return List.of();
}
return find("select distinct t from TaskEntity t " +
"left join t.project p left join t.category c " +
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
"and t.user.email in ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5",
orgId, emails, TaskStatus.COMPLETED, start, end)
.list().stream().map(TaskEntityMapper::toDomain).toList();
}
@Override @Override
public long countByStatus(TaskStatus status) { public long countByStatus(TaskStatus status) {
return count("status", status); return count("status", status);
@ -226,10 +305,32 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
@Override @Override
public long sumAllCompletedTrackedMinutes() { public long sumAllCompletedTrackedMinutes() {
return findAllTasks().stream() Object result = getEntityManager().createNativeQuery(
.flatMap(task -> task.getHistory().stream()) "SELECT COALESCE(SUM(EXTRACT(EPOCH FROM (h.end_at - h.start_at)) / 60), 0) " +
.filter(h -> Objects.nonNull(h.getEndAt())) "FROM task_history h JOIN tasks t ON t.id = h.task_id " +
.mapToLong(h -> Duration.between(h.getStartAt(), h.getEndAt()).toMinutes()) "WHERE h.end_at IS NOT NULL AND t.deleted_at IS NULL"
.sum(); ).getSingleResult();
return ((Number) result).longValue();
}
@Override
@Transactional
public void deleteTask(Long id) {
TaskEntity entity = find("id = ?1", id).firstResultOptional()
.orElseThrow(() -> {
LOGGER.warnf("Task %d was not found for deletion", id);
return new TaskNotFoundException(id);
});
if (entity.getHistory() != null) {
for (var h : entity.getHistory()) {
if (h.getEndAt() == null) {
h.setEndAt(LocalDateTime.now());
}
}
}
entity.setDeletedAt(LocalDateTime.now());
getEntityManager().merge(entity);
getEntityManager().flush();
LOGGER.infof("Task %d soft deleted from database", id);
} }
} }

View file

@ -77,4 +77,14 @@ public class UserOrganizationRepositoryImpl implements UserOrganizationRepositor
LOGGER.infof("Created membership for user %d in organization %d with role %s and status %s", LOGGER.infof("Created membership for user %d in organization %d with role %s and status %s",
entity.getUser().getId(), entity.getOrganization().getId(), entity.getRole(), entity.getStatus()); entity.getUser().getId(), entity.getOrganization().getId(), entity.getRole(), entity.getStatus());
} }
@Override
@Transactional
public void deleteByUserAndOrganization(Long userId, Long organizationId) {
Optional<UserOrganizationEntity> existing = find("user.id = ?1 and organization.id = ?2", userId, organizationId).firstResultOptional();
existing.ifPresent(entity -> {
delete(entity);
LOGGER.infof("Soft deleted membership for user %d in organization %d", userId, organizationId);
});
}
} }

View file

@ -69,4 +69,13 @@ public class UserRepositoryImpl implements UserRepository, PanacheRepository<Use
public long countAll() { public long countAll() {
return count(); return count();
} }
@Override
public List<User> findAllUsers() {
List<User> users = list("ORDER BY id ASC").stream()
.map(UserEntityMapper::toDomain)
.toList();
LOGGER.infof("Loaded %d users", users.size());
return users;
}
} }

View file

@ -0,0 +1,43 @@
package net.beetechgroup.beetask.frameworks.security;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import net.beetechgroup.beetask.frameworks.persistence.entities.UserEntity;
import org.jboss.logging.Logger;
import org.mindrot.jbcrypt.BCrypt;
import java.util.List;
@ApplicationScoped
public class PasswordMigrationStartup {
private static final Logger LOGGER = Logger.getLogger(PasswordMigrationStartup.class);
@Inject
EntityManager em;
@Transactional
public void onStart(@Observes StartupEvent ev) {
LOGGER.info("Checking for unhashed user passwords for active members to migrate to BCrypt...");
List<UserEntity> users = em.createQuery(
"SELECT DISTINCT u FROM UserEntity u WHERE u.id IN (SELECT uo.user.id FROM UserOrganizationEntity uo)",
UserEntity.class).getResultList();
int migrated = 0;
for (UserEntity user : users) {
String pwd = user.getPassword();
if (pwd != null && !pwd.startsWith("$2a$") && !pwd.startsWith("$2b$") && !pwd.startsWith("$2y$")) {
user.setPassword(BCrypt.hashpw(pwd, BCrypt.gensalt(10)));
em.merge(user);
migrated++;
}
}
if (migrated > 0) {
LOGGER.infof("Migrated %d user passwords to BCrypt successfully", migrated);
} else {
LOGGER.info("All user passwords are already hashed with BCrypt");
}
}
}

View file

@ -0,0 +1,22 @@
package net.beetechgroup.beetask.interfaceadapters.controllers;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import java.util.Map;
@Provider
public class IllegalArgumentExceptionMapper implements ExceptionMapper<IllegalArgumentException> {
@Override
public Response toResponse(IllegalArgumentException exception) {
return Response.status(Response.Status.BAD_REQUEST)
.type(MediaType.APPLICATION_JSON)
.entity(Map.of(
"error", "Bad Request",
"message", exception.getMessage() != null ? exception.getMessage() : "Requisição inválida"
))
.build();
}
}

View file

@ -0,0 +1,22 @@
package net.beetechgroup.beetask.interfaceadapters.controllers;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import java.util.Map;
@Provider
public class IllegalStateExceptionMapper implements ExceptionMapper<IllegalStateException> {
@Override
public Response toResponse(IllegalStateException exception) {
return Response.status(Response.Status.CONFLICT)
.type(MediaType.APPLICATION_JSON)
.entity(Map.of(
"error", "Conflict",
"message", exception.getMessage() != null ? exception.getMessage() : "Conflito de estado na operação"
))
.build();
}
}

View file

@ -0,0 +1,22 @@
package net.beetechgroup.beetask.interfaceadapters.controllers;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import java.util.Map;
@Provider
public class SecurityExceptionMapper implements ExceptionMapper<SecurityException> {
@Override
public Response toResponse(SecurityException exception) {
return Response.status(Response.Status.FORBIDDEN)
.type(MediaType.APPLICATION_JSON)
.entity(Map.of(
"error", "Forbidden",
"message", exception.getMessage() != null ? exception.getMessage() : "Acesso negado"
))
.build();
}
}

View file

@ -0,0 +1,23 @@
package net.beetechgroup.beetask.interfaceadapters.controllers;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import net.beetechgroup.beetask.usecase.exceptions.TaskNotFoundException;
import java.util.Map;
@Provider
public class TaskNotFoundExceptionMapper implements ExceptionMapper<TaskNotFoundException> {
@Override
public Response toResponse(TaskNotFoundException exception) {
return Response.status(Response.Status.NOT_FOUND)
.type(MediaType.APPLICATION_JSON)
.entity(Map.of(
"error", "Not Found",
"message", exception.getMessage() != null ? exception.getMessage() : "Tarefa não encontrada"
))
.build();
}
}

View file

@ -45,10 +45,11 @@ public class CategoryController {
@Operation(summary = "List categories", description = "Lists all categories for the given organization") @Operation(summary = "List categories", description = "Lists all categories for the given organization")
@APIResponse(responseCode = "200", description = "Categories listed successfully") @APIResponse(responseCode = "200", description = "Categories listed successfully")
public List<CategoryResponse> listCategories(@PathParam("orgId") Long orgId) { public List<CategoryResponse> listCategories(@PathParam("orgId") Long orgId) {
List<CategoryResponse> categories = listCategoriesUseCase.execute(orgId).stream() String email = securityIdentity.getPrincipal().getName();
List<CategoryResponse> categories = listCategoriesUseCase.execute(email, orgId).stream()
.map(CategoryControllerMapper::toResponse) .map(CategoryControllerMapper::toResponse)
.toList(); .toList();
LOGGER.infof("Listed %d categories for organization %d", categories.size(), orgId); LOGGER.infof("Listed %d categories for organization %d by user %s", categories.size(), orgId, email);
return categories; return categories;
} }

View file

@ -31,7 +31,8 @@ public class DashboardController {
@GET @GET
public DashboardOutput getDashboard( public DashboardOutput getDashboard(
@QueryParam("startDate") String startDateStr, @QueryParam("startDate") String startDateStr,
@QueryParam("endDate") String endDateStr) { @QueryParam("endDate") String endDateStr,
@QueryParam("organizationId") Long organizationId) {
String email = securityIdentity.getPrincipal().getName(); String email = securityIdentity.getPrincipal().getName();
@ -47,8 +48,8 @@ public class DashboardController {
end = end.with(java.time.LocalTime.MAX); end = end.with(java.time.LocalTime.MAX);
} }
DashboardInput input = new DashboardInput(email, start, end); DashboardInput input = new DashboardInput(email, start, end, organizationId);
LOGGER.infof("Dashboard requested by user %s for period %s to %s", email, start, end); LOGGER.infof("Dashboard requested by user %s for period %s to %s with orgId %s", email, start, end, organizationId);
return dashboardUseCase.execute(input); return dashboardUseCase.execute(input);
} }
} }

View file

@ -52,6 +52,12 @@ public class OrganizationController {
@Inject @Inject
ListOrganizationMembersUseCase listOrganizationMembersUseCase; ListOrganizationMembersUseCase listOrganizationMembersUseCase;
@Inject
net.beetechgroup.beetask.usecase.organization.members.RemoveOrganizationMemberUseCase removeOrganizationMemberUseCase;
@Inject
net.beetechgroup.beetask.usecase.organization.members.LeaveOrganizationUseCase leaveOrganizationUseCase;
@Inject @Inject
SecurityIdentity securityIdentity; SecurityIdentity securityIdentity;
@ -142,4 +148,26 @@ public class OrganizationController {
LOGGER.infof("User %s listed %d organization join requests", userEmail, requests.size()); LOGGER.infof("User %s listed %d organization join requests", userEmail, requests.size());
return requests; return requests;
} }
@jakarta.ws.rs.DELETE
@Path("/{id}/members/{userId}")
@Authenticated
@Operation(summary = "Remove organization member", description = "Removes a member from the organization (Admin/Owner only)")
public jakarta.ws.rs.core.Response removeMember(@PathParam("id") Long organizationId, @PathParam("userId") Long userId) {
String requesterEmail = securityIdentity.getPrincipal().getName();
LOGGER.infof("Admin %s requested removal of member %d from organization %d", requesterEmail, userId, organizationId);
removeOrganizationMemberUseCase.execute(requesterEmail, organizationId, userId);
return jakarta.ws.rs.core.Response.noContent().build();
}
@POST
@Path("/{id}/leave")
@Authenticated
@Operation(summary = "Leave organization", description = "Allows the current user to leave an organization")
public jakarta.ws.rs.core.Response leaveOrganization(@PathParam("id") Long organizationId) {
String userEmail = securityIdentity.getPrincipal().getName();
LOGGER.infof("User %s requested to leave organization %d", userEmail, organizationId);
leaveOrganizationUseCase.execute(userEmail, organizationId);
return jakarta.ws.rs.core.Response.noContent().build();
}
} }

View file

@ -1,6 +1,7 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.project; package net.beetechgroup.beetask.interfaceadapters.controllers.project;
import io.quarkus.security.Authenticated; import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
@ -19,6 +20,7 @@ import java.util.List;
@Path("/organizations/{orgId}/projects") @Path("/organizations/{orgId}/projects")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Authenticated
public class ProjectController { public class ProjectController {
private static final Logger LOGGER = Logger.getLogger(ProjectController.class); private static final Logger LOGGER = Logger.getLogger(ProjectController.class);
@ -28,25 +30,55 @@ public class ProjectController {
@Inject @Inject
ListProjectsUseCase listProjectsUseCase; ListProjectsUseCase listProjectsUseCase;
@Inject
net.beetechgroup.beetask.usecase.project.update.UpdateProjectUseCase updateProjectUseCase;
@Inject
net.beetechgroup.beetask.usecase.project.delete.DeleteProjectUseCase deleteProjectUseCase;
@Inject
SecurityIdentity securityIdentity;
@GET @GET
@Authenticated
public List<ProjectResponse> listProjects(@PathParam("orgId") Long orgId) { public List<ProjectResponse> listProjects(@PathParam("orgId") Long orgId) {
List<ProjectResponse> projects = listProjectsUseCase.execute(orgId).stream() String email = securityIdentity.getPrincipal().getName();
List<ProjectResponse> projects = listProjectsUseCase.execute(email, orgId).stream()
.map(p -> new ProjectResponse(p.getId(), p.getName(), p.getColor(), p.getIcon())) .map(p -> new ProjectResponse(p.getId(), p.getName(), p.getColor(), p.getIcon()))
.toList(); .toList();
LOGGER.infof("Listed %d projects for organization %d", projects.size(), orgId); LOGGER.infof("Listed %d projects for organization %d by user %s", projects.size(), orgId, email);
return projects; return projects;
} }
@POST @POST
@Authenticated
public ProjectResponse createProject(@PathParam("orgId") Long orgId, CreateProjectRequest request) { public ProjectResponse createProject(@PathParam("orgId") Long orgId, CreateProjectRequest request) {
LOGGER.infof("Project creation requested for organization %d with name '%s'", orgId, request.name()); String email = securityIdentity.getPrincipal().getName();
Project project = createProjectUseCase.execute(orgId, request.name(), request.color(), request.icon()); LOGGER.infof("Project creation requested by %s for organization %d with name '%s'", email, orgId, request.name());
LOGGER.infof("Project %d created successfully for organization %d", project.getId(), orgId); Project project = createProjectUseCase.execute(email, orgId, request.name(), request.color(), request.icon());
LOGGER.infof("Project %d created successfully for organization %d by user %s", project.getId(), orgId, email);
return new ProjectResponse(project.getId(), project.getName(), project.getColor(), project.getIcon()); return new ProjectResponse(project.getId(), project.getName(), project.getColor(), project.getIcon());
} }
@jakarta.ws.rs.PUT
@Path("/{id}")
public ProjectResponse updateProject(@PathParam("orgId") Long orgId,
@PathParam("id") Long id,
CreateProjectRequest request) {
String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Project update requested by %s for project %d in org %d", email, id, orgId);
Project project = updateProjectUseCase.execute(email, orgId, id, request.name(), request.color(), request.icon());
return new ProjectResponse(project.getId(), project.getName(), project.getColor(), project.getIcon());
}
@jakarta.ws.rs.DELETE
@Path("/{id}")
public jakarta.ws.rs.core.Response deleteProject(@PathParam("orgId") Long orgId,
@PathParam("id") Long id) {
String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Project deletion requested by %s for project %d in org %d", email, id, orgId);
deleteProjectUseCase.execute(email, orgId, id);
return jakarta.ws.rs.core.Response.noContent().build();
}
// TODO must be a independent file // TODO must be a independent file
public record CreateProjectRequest(String name, String color, String icon) {} public record CreateProjectRequest(String name, String color, String icon) {}

View file

@ -13,6 +13,7 @@ public record CreateTaskResponse(
ProjectResponse project, ProjectResponse project,
CategoryResponse category, CategoryResponse category,
UserResponse user, UserResponse user,
UserResponse createdBy,
LocalDateTime finishedAt, LocalDateTime finishedAt,
List<TaskHistoryItemResponse> history, List<TaskHistoryItemResponse> history,
String externalUrl) { String externalUrl) {

View file

@ -3,6 +3,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.BeanParam; import jakarta.ws.rs.BeanParam;
import jakarta.ws.rs.Consumes; import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.PATCH; import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.POST; import jakarta.ws.rs.POST;
@ -11,6 +12,8 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam; import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces; import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.SecurityIdentity;
import net.beetechgroup.beetask.usecase.task.start.StartTaskInput; import net.beetechgroup.beetask.usecase.task.start.StartTaskInput;
@ -25,6 +28,7 @@ import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase; import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
import net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase;
import net.beetechgroup.beetask.usecase.task.listall.ListAllTasksUseCase; import net.beetechgroup.beetask.usecase.task.listall.ListAllTasksUseCase;
import net.beetechgroup.beetask.usecase.task.listall.ListMyTasksUseCase; import net.beetechgroup.beetask.usecase.task.listall.ListMyTasksUseCase;
import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase; import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase;
@ -36,6 +40,7 @@ import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase;
@Path("/tasks") @Path("/tasks")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@Authenticated
@Tag(name = "Tasks", description = "Task management operations") @Tag(name = "Tasks", description = "Task management operations")
public class TaskController { public class TaskController {
private static final Logger LOGGER = Logger.getLogger(TaskController.class); private static final Logger LOGGER = Logger.getLogger(TaskController.class);
@ -64,6 +69,9 @@ public class TaskController {
@Inject @Inject
GetTaskUseCase getTaskUseCase; GetTaskUseCase getTaskUseCase;
@Inject
DeleteTaskUseCase deleteTaskUseCase;
@Inject @Inject
SecurityIdentity securityIdentity; SecurityIdentity securityIdentity;
@ -96,8 +104,9 @@ public class TaskController {
@Operation(summary = "Get a task", description = "Gets a specific task by ID") @Operation(summary = "Get a task", description = "Gets a specific task by ID")
@APIResponse(responseCode = "200", description = "Task retrieved successfully") @APIResponse(responseCode = "200", description = "Task retrieved successfully")
public CreateTaskResponse getTask(@PathParam("id") Long id) { public CreateTaskResponse getTask(@PathParam("id") Long id) {
LOGGER.infof("Task details requested for task %d", id); String email = securityIdentity.getPrincipal().getName();
return TaskControllerMapper.toCreateTaskResponse(getTaskUseCase.execute(id)); LOGGER.infof("Task details requested for task %d by %s", id, email);
return TaskControllerMapper.toCreateTaskResponse(getTaskUseCase.execute(id, email));
} }
@PUT @PUT
@ -129,12 +138,24 @@ public class TaskController {
@Operation(summary = "Update task status", description = "Updates the status of a task") @Operation(summary = "Update task status", description = "Updates the status of a task")
@APIResponse(responseCode = "200", description = "Task status updated successfully") @APIResponse(responseCode = "200", description = "Task status updated successfully")
public CreateTaskResponse updateTaskStatus(@PathParam("id") Long id, TaskStatus status) { public CreateTaskResponse updateTaskStatus(@PathParam("id") Long id, TaskStatus status) {
LOGGER.infof("Status update requested for task %d to %s", id, status); String email = securityIdentity.getPrincipal().getName();
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, status); LOGGER.infof("Status update requested for task %d to %s by %s", id, status, email);
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, status, email);
LOGGER.infof("Task %d status updated successfully to %s", id, status); LOGGER.infof("Task %d status updated successfully to %s", id, status);
return TaskControllerMapper.toCreateTaskResponse(output); return TaskControllerMapper.toCreateTaskResponse(output);
} }
@DELETE
@Path("/{id}")
@Operation(summary = "Delete a task", description = "Deletes a task if requester is admin of the organization or the owner/creator")
@APIResponse(responseCode = "204", description = "Task deleted successfully")
public Response deleteTask(@PathParam("id") Long id) {
String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("User %s requested deletion of task %d", email, id);
deleteTaskUseCase.execute(id, email);
return Response.noContent().build();
}
@GET @GET
@Operation(summary = "List all tasks", description = "Lists all tasks in the system") @Operation(summary = "List all tasks", description = "Lists all tasks in the system")
@APIResponse(responseCode = "200", description = "Tasks listed successfully") @APIResponse(responseCode = "200", description = "Tasks listed successfully")

View file

@ -68,6 +68,13 @@ public class TaskControllerMapper {
output.user().email(), output.user().email(),
output.user().photo()) output.user().photo())
: null, : null,
Objects.nonNull(output.createdBy())
? new CreateTaskResponse.UserResponse(
output.createdBy().id(),
output.createdBy().name(),
output.createdBy().email(),
output.createdBy().photo())
: null,
output.finishedAt(), output.finishedAt(),
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse( output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(
taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt(), taskHistoryItemOutput.elapsedSeconds())).toList(), taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt(), taskHistoryItemOutput.elapsedSeconds())).toList(),

View file

@ -0,0 +1,28 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.user;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import java.util.List;
@Path("/public/users")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Users")
public class PublicUserController {
@Inject
UserController userController;
@GET
@Operation(summary = "List all users publicly")
public List<UserController.UserResponse> listUsers() {
return userController.listUsers();
}
}

View file

@ -0,0 +1,36 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.user;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import java.util.List;
@Path("/users")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Users", description = "User management operations")
public class UserController {
private static final Logger LOGGER = Logger.getLogger(UserController.class);
@Inject
ListAllUsersUseCase listAllUsersUseCase;
@GET
@Operation(summary = "List all users", description = "Lists all users with name and email, without pagination or authentication required")
public List<UserResponse> listUsers() {
LOGGER.info("Public list of all users requested");
return listAllUsersUseCase.execute().stream()
.map(u -> new UserResponse(u.getName(), u.getEmail()))
.toList();
}
public record UserResponse(String name, String email) {}
}

View file

@ -36,8 +36,25 @@ public class LoginUseCase {
User user = userOptional.get(); User user = userOptional.get();
// Simple password check for now (should use Bcrypt) boolean passwordMatches = false;
if (!user.getPassword().equals(input.password())) { String storedPassword = user.getPassword();
if (storedPassword != null) {
if (storedPassword.startsWith("$2a$") || storedPassword.startsWith("$2b$") || storedPassword.startsWith("$2y$")) {
try {
passwordMatches = org.mindrot.jbcrypt.BCrypt.checkpw(input.password(), storedPassword);
} catch (Exception e) {
passwordMatches = false;
}
} else {
passwordMatches = storedPassword.equals(input.password());
if (passwordMatches) {
user.setPassword(org.mindrot.jbcrypt.BCrypt.hashpw(input.password(), org.mindrot.jbcrypt.BCrypt.gensalt(10)));
userRepository.save(user);
}
}
}
if (!passwordMatches) {
LOGGER.warnf("Login failed because password did not match for email %s", input.email()); LOGGER.warnf("Login failed because password did not match for email %s", input.email());
throw new RuntimeException("Invalid credentials"); throw new RuntimeException("Invalid credentials");
} }

View file

@ -4,22 +4,27 @@ import java.util.List;
import net.beetechgroup.beetask.usecase.category.CategoryMapper; import net.beetechgroup.beetask.usecase.category.CategoryMapper;
import net.beetechgroup.beetask.usecase.category.CategoryOutput; import net.beetechgroup.beetask.usecase.category.CategoryOutput;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
public class ListCategoriesUseCase { public class ListCategoriesUseCase {
private static final Logger LOGGER = Logger.getLogger(ListCategoriesUseCase.class); private static final Logger LOGGER = Logger.getLogger(ListCategoriesUseCase.class);
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public ListCategoriesUseCase(CategoryRepository categoryRepository) { public ListCategoriesUseCase(CategoryRepository categoryRepository, AuthorizeOrganizationMemberUseCase authorizer) {
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.authorizer = authorizer;
} }
public List<CategoryOutput> execute(Long organizationId) { public List<CategoryOutput> execute(String userEmail, Long organizationId) {
authorizer.execute(userEmail, organizationId);
List<CategoryOutput> categories = categoryRepository.findByOrganizationId(organizationId).stream() List<CategoryOutput> categories = categoryRepository.findByOrganizationId(organizationId).stream()
.map(CategoryMapper::toCategoryOutput) .map(CategoryMapper::toCategoryOutput)
.toList(); .toList();
LOGGER.infof("Loaded %d categories for organization %d", categories.size(), organizationId); LOGGER.infof("Loaded %d categories for organization %d by user %s", categories.size(), organizationId, userEmail);
return categories; return categories;
} }
} }

View file

@ -5,5 +5,10 @@ import java.time.LocalDateTime;
public record DashboardInput( public record DashboardInput(
String userEmail, String userEmail,
LocalDateTime startDate, LocalDateTime startDate,
LocalDateTime endDate LocalDateTime endDate,
) {} Long organizationId
) {
public DashboardInput(String userEmail, LocalDateTime startDate, LocalDateTime endDate) {
this(userEmail, startDate, endDate, null);
}
}

View file

@ -33,17 +33,24 @@ public class DashboardUseCase {
LOGGER.infof("Calculating dashboard for user %s in period %s to %s", LOGGER.infof("Calculating dashboard for user %s in period %s to %s",
input.userEmail(), input.startDate(), input.endDate()); input.userEmail(), input.startDate(), input.endDate());
List<Task> workedTasks = taskRepository.findTasksWorkedByUserInPeriod( List<Task> workedTasks = taskRepository.findTasksWorkedByUserInPeriod(
input.userEmail(), input.startDate(), input.endDate() input.userEmail(), input.startDate(), input.endDate(), input.organizationId()
); );
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserInPeriod( List<Task> finishedTasks = taskRepository.findTasksFinishedByUserInPeriod(
input.userEmail(), input.startDate(), input.endDate() input.userEmail(), input.startDate(), input.endDate()
); );
if (input.organizationId() != null) {
finishedTasks = finishedTasks.stream().filter(t ->
(t.getOrganization() != null && input.organizationId().equals(t.getOrganization().getId())) ||
(t.getProject() != null && t.getProject().getOrganization() != null && input.organizationId().equals(t.getProject().getOrganization().getId())) ||
(t.getCategory() != null && t.getCategory().getOrganization() != null && input.organizationId().equals(t.getCategory().getOrganization().getId()))
).toList();
}
LocalDateTime startOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MIN); LocalDateTime startOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MIN);
LocalDateTime endOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MAX); LocalDateTime endOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MAX);
List<Task> yesterdayTasks = taskRepository.findTasksWorkedByUserInPeriod( List<Task> yesterdayTasks = taskRepository.findTasksWorkedByUserInPeriod(
input.userEmail(), startOfYesterday, endOfYesterday input.userEmail(), startOfYesterday, endOfYesterday, input.organizationId()
); );
long totalMinutes = 0; long totalMinutes = 0;

View file

@ -0,0 +1,41 @@
package net.beetechgroup.beetask.usecase.organization.auth;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.organization.UserOrganization;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import org.jboss.logging.Logger;
import java.util.Optional;
public class AuthorizeOrganizationMemberUseCase {
private static final Logger LOGGER = Logger.getLogger(AuthorizeOrganizationMemberUseCase.class);
private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository;
public AuthorizeOrganizationMemberUseCase(UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository;
}
public User execute(String userEmail, Long organizationId) {
if (userEmail == null || userEmail.isBlank()) {
throw new SecurityException("Usuário não autenticado.");
}
User user = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new SecurityException("Usuário não encontrado: " + userEmail));
if (organizationId == null) {
return user;
}
Optional<UserOrganization> association = userOrganizationRepository.findByUserAndOrganization(user.getId(), organizationId);
if (association.isEmpty() || association.get().getStatus() != UserOrganizationStatus.ACTIVE) {
LOGGER.warnf("User %s is not an active member of organization %d", userEmail, organizationId);
throw new SecurityException("Você não tem permissão para acessar recursos desta organização.");
}
return user;
}
}

View file

@ -47,10 +47,9 @@ public class RequestJoinOrganizationUseCase {
LOGGER.warnf("Join request ignored because user %s already has a pending request for organization %d", userEmail, organizationId); LOGGER.warnf("Join request ignored because user %s already has a pending request for organization %d", userEmail, organizationId);
throw new RuntimeException("A join request is already pending for this organization"); throw new RuntimeException("A join request is already pending for this organization");
} }
// If REJECTED, we allow a new request (or we could just update the existing one to PENDING)
} }
UserOrganization request = new UserOrganization(); UserOrganization request = existing.orElseGet(UserOrganization::new);
request.setUser(user); request.setUser(user);
request.setOrganization(organization); request.setOrganization(organization);
request.setRole(UserOrganizationRole.MEMBER); request.setRole(UserOrganizationRole.MEMBER);

View file

@ -0,0 +1,72 @@
package net.beetechgroup.beetask.usecase.organization.members;
import jakarta.persistence.EntityManager;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.organization.UserOrganization;
import net.beetechgroup.beetask.entities.organization.UserOrganizationRole;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.util.Optional;
public class LeaveOrganizationUseCase {
private static final Logger LOGGER = Logger.getLogger(LeaveOrganizationUseCase.class);
private final UserOrganizationRepository userOrganizationRepository;
private final UserRepository userRepository;
private final AuthorizeOrganizationMemberUseCase memberAuthorizer;
private final EntityManager entityManager;
public LeaveOrganizationUseCase(UserOrganizationRepository userOrganizationRepository,
UserRepository userRepository,
AuthorizeOrganizationMemberUseCase memberAuthorizer,
EntityManager entityManager) {
this.userOrganizationRepository = userOrganizationRepository;
this.userRepository = userRepository;
this.memberAuthorizer = memberAuthorizer;
this.entityManager = entityManager;
}
@jakarta.transaction.Transactional
public void execute(String userEmail, Long organizationId) {
LOGGER.infof("User %s requested to leave organization %d", userEmail, organizationId);
memberAuthorizer.execute(userEmail, organizationId);
User user = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new IllegalArgumentException("Usuário não encontrado: " + userEmail));
Optional<UserOrganization> membership = userOrganizationRepository.findByUserAndOrganization(user.getId(), organizationId);
if (membership.isEmpty()) {
throw new IllegalArgumentException("Membro não encontrado nesta organização.");
}
if (membership.get().getRole() == UserOrganizationRole.OWNER) {
throw new IllegalArgumentException("O proprietário não pode sair da organização. Transfira a posse antes de sair.");
}
userOrganizationRepository.deleteByUserAndOrganization(user.getId(), organizationId);
// Unassign user from tasks in this organization
entityManager.createQuery(
"UPDATE TaskEntity t SET t.user = NULL " +
"WHERE t.user.id = :userId AND (t.organization.id = :orgId OR t.project.organization.id = :orgId OR t.category.organization.id = :orgId)")
.setParameter("userId", user.getId())
.setParameter("orgId", organizationId)
.executeUpdate();
// Close any running timer for this user in this organization
entityManager.createQuery(
"UPDATE TaskHistoryItemEntity h SET h.endAt = :now " +
"WHERE h.endAt IS NULL AND (h.user.id = :userId OR (h.user IS NULL AND h.task.user.id = :userId)) AND " +
"h.task.id IN (SELECT t.id FROM TaskEntity t WHERE t.organization.id = :orgId OR t.project.organization.id = :orgId OR t.category.organization.id = :orgId)")
.setParameter("now", LocalDateTime.now())
.setParameter("userId", user.getId())
.setParameter("orgId", organizationId)
.executeUpdate();
LOGGER.infof("User %s successfully left organization %d", userEmail, organizationId);
}
}

View file

@ -0,0 +1,69 @@
package net.beetechgroup.beetask.usecase.organization.members;
import jakarta.persistence.EntityManager;
import net.beetechgroup.beetask.entities.organization.UserOrganization;
import net.beetechgroup.beetask.entities.organization.UserOrganizationRole;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.util.Optional;
public class RemoveOrganizationMemberUseCase {
private static final Logger LOGGER = Logger.getLogger(RemoveOrganizationMemberUseCase.class);
private final UserOrganizationRepository userOrganizationRepository;
private final UserRepository userRepository;
private final AuthorizeOrganizationAdminUseCase adminAuthorizer;
private final EntityManager entityManager;
public RemoveOrganizationMemberUseCase(UserOrganizationRepository userOrganizationRepository,
UserRepository userRepository,
AuthorizeOrganizationAdminUseCase adminAuthorizer,
EntityManager entityManager) {
this.userOrganizationRepository = userOrganizationRepository;
this.userRepository = userRepository;
this.adminAuthorizer = adminAuthorizer;
this.entityManager = entityManager;
}
@jakarta.transaction.Transactional
public void execute(String requesterEmail, Long organizationId, Long targetUserId) {
LOGGER.infof("Admin %s requested removal of member %d from organization %d", requesterEmail, targetUserId, organizationId);
adminAuthorizer.execute(requesterEmail, organizationId);
Optional<UserOrganization> targetMembership = userOrganizationRepository.findByUserAndOrganization(targetUserId, organizationId);
if (targetMembership.isEmpty()) {
throw new IllegalArgumentException("Membro não encontrado nesta organização.");
}
if (targetMembership.get().getRole() == UserOrganizationRole.OWNER) {
throw new IllegalArgumentException("O proprietário da organização não pode ser removido.");
}
// Soft delete the membership
userOrganizationRepository.deleteByUserAndOrganization(targetUserId, organizationId);
// Unassign member from tasks in this organization
entityManager.createQuery(
"UPDATE TaskEntity t SET t.user = NULL " +
"WHERE t.user.id = :userId AND (t.organization.id = :orgId OR t.project.organization.id = :orgId OR t.category.organization.id = :orgId)")
.setParameter("userId", targetUserId)
.setParameter("orgId", organizationId)
.executeUpdate();
// Close any running timer for this member in this organization
entityManager.createQuery(
"UPDATE TaskHistoryItemEntity h SET h.endAt = :now " +
"WHERE h.endAt IS NULL AND (h.user.id = :userId OR (h.user IS NULL AND h.task.user.id = :userId)) AND " +
"h.task.id IN (SELECT t.id FROM TaskEntity t WHERE t.organization.id = :orgId OR t.project.organization.id = :orgId OR t.category.organization.id = :orgId)")
.setParameter("now", LocalDateTime.now())
.setParameter("userId", targetUserId)
.setParameter("orgId", organizationId)
.executeUpdate();
LOGGER.infof("Member %d removed successfully from organization %d", targetUserId, organizationId);
}
}

View file

@ -48,10 +48,10 @@ public class OrgDashboardUseCase {
.toList(); .toList();
List<Task> workedTasks = taskRepository.findTasksWorkedByUserEmailsInPeriod( List<Task> workedTasks = taskRepository.findTasksWorkedByUserEmailsInPeriod(
memberEmails, input.startDate(), input.endDate() input.organizationId(), memberEmails, input.startDate(), input.endDate()
); );
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserEmailsInPeriod( List<Task> finishedTasks = taskRepository.findTasksFinishedByUserEmailsInPeriod(
memberEmails, input.startDate(), input.endDate() input.organizationId(), memberEmails, input.startDate(), input.endDate()
); );
Map<Long, OrgProjectStats> projectStatsMap = new HashMap<>(); Map<Long, OrgProjectStats> projectStatsMap = new HashMap<>();

View file

@ -54,8 +54,8 @@ public class MemberDetailUseCase {
long daySpan = ChronoUnit.DAYS.between(start.toLocalDate(), end.toLocalDate()); long daySpan = ChronoUnit.DAYS.between(start.toLocalDate(), end.toLocalDate());
boolean groupByDay = daySpan <= 30; boolean groupByDay = daySpan <= 30;
List<Task> workedTasks = taskRepository.findTasksWorkedByUserIdInPeriod(input.memberId(), start, end); List<Task> workedTasks = taskRepository.findTasksWorkedByUserIdInPeriod(input.organizationId(), input.memberId(), start, end);
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.memberId(), start, end); List<Task> finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.organizationId(), input.memberId(), start, end);
Map<Long, MemberDetailOutput.ProjectStats> projectStatsMap = new HashMap<>(); Map<Long, MemberDetailOutput.ProjectStats> projectStatsMap = new HashMap<>();
Map<Long, MemberDetailOutput.CategoryStats> categoryStatsMap = new HashMap<>(); Map<Long, MemberDetailOutput.CategoryStats> categoryStatsMap = new HashMap<>();

View file

@ -2,6 +2,7 @@ package net.beetechgroup.beetask.usecase.project.create;
import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.entities.organization.Organization; import net.beetechgroup.beetask.entities.organization.Organization;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
@ -10,22 +11,36 @@ public class CreateProjectUseCase {
private static final Logger LOGGER = Logger.getLogger(CreateProjectUseCase.class); private static final Logger LOGGER = Logger.getLogger(CreateProjectUseCase.class);
private final ProjectRepository projectRepository; private final ProjectRepository projectRepository;
private final OrganizationRepository organizationRepository; private final OrganizationRepository organizationRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public CreateProjectUseCase(ProjectRepository projectRepository, OrganizationRepository organizationRepository) { public CreateProjectUseCase(ProjectRepository projectRepository,
OrganizationRepository organizationRepository,
AuthorizeOrganizationMemberUseCase authorizer) {
this.projectRepository = projectRepository; this.projectRepository = projectRepository;
this.organizationRepository = organizationRepository; this.organizationRepository = organizationRepository;
this.authorizer = authorizer;
} }
public Project execute(Long organizationId, String projectName, String color, String icon) { public Project execute(String userEmail, Long organizationId, String projectName, String color, String icon) {
authorizer.execute(userEmail, organizationId);
Organization organization = organizationRepository.findOrganizationById(organizationId) Organization organization = organizationRepository.findOrganizationById(organizationId)
.orElseThrow(() -> { .orElseThrow(() -> {
LOGGER.warnf("Project creation failed because organization %d was not found", organizationId); LOGGER.warnf("Project creation failed because organization %d was not found", organizationId);
return new RuntimeException("Organization not found"); return new RuntimeException("Organization not found");
}); });
LOGGER.infof("Creating project '%s' in organization %d", projectName, organizationId); if (projectName == null || projectName.isBlank()) {
throw new IllegalArgumentException("Nome do projeto não pode ser vazio.");
}
if (projectRepository.findByNameAndOrganizationId(projectName, organizationId).isPresent()) {
throw new IllegalArgumentException("Já existe um projeto com este nome nesta organização.");
}
LOGGER.infof("Creating project '%s' in organization %d by user %s", projectName, organizationId, userEmail);
Project project = new Project(); Project project = new Project();
project.setName(projectName); project.setName(projectName.trim());
project.setColor(color); project.setColor(color);
project.setIcon(icon); project.setIcon(icon);
project.setOrganization(organization); project.setOrganization(organization);
@ -35,3 +50,4 @@ public class CreateProjectUseCase {
return savedProject; return savedProject;
} }
} }

View file

@ -0,0 +1,32 @@
package net.beetechgroup.beetask.usecase.project.delete;
import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import org.jboss.logging.Logger;
public class DeleteProjectUseCase {
private static final Logger LOGGER = Logger.getLogger(DeleteProjectUseCase.class);
private final ProjectRepository projectRepository;
private final AuthorizeOrganizationAdminUseCase authorizer;
public DeleteProjectUseCase(ProjectRepository projectRepository, AuthorizeOrganizationAdminUseCase authorizer) {
this.projectRepository = projectRepository;
this.authorizer = authorizer;
}
public void execute(String userEmail, Long organizationId, Long projectId) {
authorizer.execute(userEmail, organizationId);
Project project = projectRepository.findProjectById(projectId)
.orElseThrow(() -> new IllegalArgumentException("Projeto não encontrado com ID: " + projectId));
if (project.getOrganization() == null || !project.getOrganization().getId().equals(organizationId)) {
throw new IllegalArgumentException("Projeto não pertence à organização informada.");
}
projectRepository.deleteProject(projectId);
LOGGER.infof("Project %d soft deleted from org %d by %s", projectId, organizationId, userEmail);
}
}

View file

@ -1,6 +1,7 @@
package net.beetechgroup.beetask.usecase.project.list; package net.beetechgroup.beetask.usecase.project.list;
import net.beetechgroup.beetask.entities.Project; import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import java.util.List; import java.util.List;
@ -9,14 +10,18 @@ import org.jboss.logging.Logger;
public class ListProjectsUseCase { public class ListProjectsUseCase {
private static final Logger LOGGER = Logger.getLogger(ListProjectsUseCase.class); private static final Logger LOGGER = Logger.getLogger(ListProjectsUseCase.class);
private final ProjectRepository projectRepository; private final ProjectRepository projectRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public ListProjectsUseCase(ProjectRepository projectRepository) { public ListProjectsUseCase(ProjectRepository projectRepository, AuthorizeOrganizationMemberUseCase authorizer) {
this.projectRepository = projectRepository; this.projectRepository = projectRepository;
this.authorizer = authorizer;
} }
public List<Project> execute(Long organizationId) { public List<Project> execute(String userEmail, Long organizationId) {
authorizer.execute(userEmail, organizationId);
List<Project> projects = projectRepository.findByOrganizationId(organizationId); List<Project> projects = projectRepository.findByOrganizationId(organizationId);
LOGGER.infof("Loaded %d projects for organization %d", projects.size(), organizationId); LOGGER.infof("Loaded %d projects for organization %d", projects.size(), organizationId);
return projects; return projects;
} }
} }

View file

@ -0,0 +1,48 @@
package net.beetechgroup.beetask.usecase.project.update;
import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import org.jboss.logging.Logger;
import java.util.Optional;
public class UpdateProjectUseCase {
private static final Logger LOGGER = Logger.getLogger(UpdateProjectUseCase.class);
private final ProjectRepository projectRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public UpdateProjectUseCase(ProjectRepository projectRepository, AuthorizeOrganizationMemberUseCase authorizer) {
this.projectRepository = projectRepository;
this.authorizer = authorizer;
}
public Project execute(String userEmail, Long organizationId, Long projectId, String name, String color, String icon) {
authorizer.execute(userEmail, organizationId);
Project project = projectRepository.findProjectById(projectId)
.orElseThrow(() -> new IllegalArgumentException("Projeto não encontrado com ID: " + projectId));
if (project.getOrganization() == null || !project.getOrganization().getId().equals(organizationId)) {
throw new IllegalArgumentException("Projeto não pertence à organização informada.");
}
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Nome do projeto não pode ser vazio.");
}
Optional<Project> duplicate = projectRepository.findByNameAndOrganizationId(name, organizationId);
if (duplicate.isPresent() && !duplicate.get().getId().equals(projectId)) {
throw new IllegalArgumentException("Já existe um projeto com este nome nesta organização.");
}
project.setName(name.trim());
if (color != null) project.setColor(color);
if (icon != null) project.setIcon(icon);
Project updated = projectRepository.saveProject(project);
LOGGER.infof("Project %d updated in org %d by %s", projectId, organizationId, userEmail);
return updated;
}
}

View file

@ -8,4 +8,6 @@ public interface ProjectRepository {
Project saveProject(Project project); Project saveProject(Project project);
Optional<Project> findProjectById(Long id); Optional<Project> findProjectById(Long id);
List<Project> findByOrganizationId(Long organizationId); List<Project> findByOrganizationId(Long organizationId);
Optional<Project> findByNameAndOrganizationId(String name, Long organizationId);
void deleteProject(Long id);
} }

View file

@ -12,7 +12,9 @@ public interface TaskRepository {
List<Task> findAllTasks(); List<Task> findAllTasks();
List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds, List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds); List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds);
List<Task> findRunningTasksByUserEmail(String email);
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId);
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksByUser(String email); List<Task> findTasksByUser(String email);
List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds, List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
@ -20,9 +22,14 @@ public interface TaskRepository {
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end); List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
List<Task> findTasksWorkedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end); List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
List<Task> findTasksFinishedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end);
long countByStatus(TaskStatus status); long countByStatus(TaskStatus status);
long sumAllCompletedTrackedMinutes(); long sumAllCompletedTrackedMinutes();
void deleteTask(Long id);
} }

View file

@ -11,4 +11,5 @@ public interface UserOrganizationRepository {
List<UserOrganization> findByOrganizationIdAndStatus(Long organizationId, UserOrganizationStatus status); List<UserOrganization> findByOrganizationIdAndStatus(Long organizationId, UserOrganizationStatus status);
Optional<UserOrganization> findByUserAndOrganization(Long userId, Long organizationId); Optional<UserOrganization> findByUserAndOrganization(Long userId, Long organizationId);
void save(UserOrganization userOrganization); void save(UserOrganization userOrganization);
void deleteByUserAndOrganization(Long userId, Long organizationId);
} }

View file

@ -11,4 +11,5 @@ public interface UserRepository {
Optional<User> findUserById(Long id); Optional<User> findUserById(Long id);
List<UserOrganization> findUserOrganizations(Long userId); List<UserOrganization> findUserOrganizations(Long userId);
long countAll(); long countAll();
List<User> findAllUsers();
} }

View file

@ -28,6 +28,13 @@ public class CreateTaskMapper {
task.getUser().getEmail(), task.getUser().getEmail(),
getPhotoUrl(task.getUser().getName(), task.getUser().getPhoto())) getPhotoUrl(task.getUser().getName(), task.getUser().getPhoto()))
: null, : null,
Objects.nonNull(task.getCreatedBy())
? new CreateTaskOutput.UserOutput(
task.getCreatedBy().getId(),
task.getCreatedBy().getName(),
task.getCreatedBy().getEmail(),
getPhotoUrl(task.getCreatedBy().getName(), task.getCreatedBy().getPhoto()))
: null,
task.getFinishedAt(), task.getFinishedAt(),
task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList(), task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList(),
task.getExternalUrl()); task.getExternalUrl());

View file

@ -12,6 +12,7 @@ public record CreateTaskOutput(
ProjectOutput project, ProjectOutput project,
CategoryOutput category, CategoryOutput category,
UserOutput user, UserOutput user,
UserOutput createdBy,
LocalDateTime finishedAt, LocalDateTime finishedAt,
List<TaskHistoryItemOutput> history, List<TaskHistoryItemOutput> history,
String externalUrl) { String externalUrl) {

View file

@ -8,6 +8,7 @@ import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskHistoryItem; import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
@ -22,15 +23,18 @@ public class CreateTaskUseCase {
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository; private final UserOrganizationRepository userOrganizationRepository;
private final OrganizationRepository organizationRepository;
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository, CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) { UserOrganizationRepository userOrganizationRepository,
OrganizationRepository organizationRepository) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.projectRepository = projectRepository; this.projectRepository = projectRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository; this.userOrganizationRepository = userOrganizationRepository;
this.organizationRepository = organizationRepository;
} }
public CreateTaskOutput execute(CreateTaskInput input) { public CreateTaskOutput execute(CreateTaskInput input) {
@ -60,7 +64,17 @@ public class CreateTaskUseCase {
} }
validateRequesterMembership(input.organizationId(), input.userEmail()); validateRequesterMembership(input.organizationId(), input.userEmail());
if (Objects.nonNull(input.organizationId())) {
organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization);
} else if (task.getProject() != null && task.getProject().getOrganization() != null) {
task.setOrganization(task.getProject().getOrganization());
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
task.setOrganization(task.getCategory().getOrganization());
}
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId())); task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
User creator = userRepository.findByEmail(input.userEmail()).orElse(null);
task.setCreatedBy(creator);
if (Objects.nonNull(input.history())) { if (Objects.nonNull(input.history())) {
List<TaskHistoryItem> historyItems = input.history().stream().map(h -> { List<TaskHistoryItem> historyItems = input.history().stream().map(h -> {

View file

@ -0,0 +1,76 @@
package net.beetechgroup.beetask.usecase.task.delete;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.organization.UserOrganization;
import net.beetechgroup.beetask.entities.organization.UserOrganizationRole;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import org.jboss.logging.Logger;
import java.util.Optional;
public class DeleteTaskUseCase {
private static final Logger LOGGER = Logger.getLogger(DeleteTaskUseCase.class);
private final TaskRepository taskRepository;
private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository;
public DeleteTaskUseCase(TaskRepository taskRepository,
UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
this.taskRepository = taskRepository;
this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository;
}
public void execute(Long taskId, String userEmail) {
LOGGER.infof("Delete task %d requested by %s", taskId, userEmail);
Task task = taskRepository.findTaskById(taskId);
User requester = userRepository.findByEmail(userEmail)
.orElseThrow(() -> new SecurityException("Usuário não encontrado: " + userEmail));
// 1. Check if requester is the task creator/owner
boolean isCreatorOrOwner = false;
if (task.getCreatedBy() != null && task.getCreatedBy().getId() != null && task.getCreatedBy().getId().equals(requester.getId())) {
isCreatorOrOwner = true;
} else if (task.getUser() != null && task.getUser().getId() != null && task.getUser().getId().equals(requester.getId())) {
isCreatorOrOwner = true;
}
// 2. Check if requester is admin/owner of the task's organization
Long taskOrgId = null;
if (task.getOrganization() != null) {
taskOrgId = task.getOrganization().getId();
} else if (task.getProject() != null && task.getProject().getOrganization() != null) {
taskOrgId = task.getProject().getOrganization().getId();
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
taskOrgId = task.getCategory().getOrganization().getId();
}
boolean isOrgAdmin = false;
if (taskOrgId != null) {
Optional<UserOrganization> uo = userOrganizationRepository.findByUserAndOrganization(requester.getId(), taskOrgId);
if (uo.isPresent() && uo.get().getStatus() == UserOrganizationStatus.ACTIVE) {
UserOrganizationRole role = uo.get().getRole();
if (role == UserOrganizationRole.OWNER || role == UserOrganizationRole.ADMIN) {
isOrgAdmin = true;
}
}
}
if (!isCreatorOrOwner && !isOrgAdmin) {
LOGGER.warnf("User %s denied deletion of task %d. isCreatorOrOwner=%b, isOrgAdmin=%b, taskOrgId=%d",
userEmail, taskId, isCreatorOrOwner, isOrgAdmin, taskOrgId);
throw new SecurityException("Você não tem permissão para excluir esta tarefa. Apenas o criador ou administradores da organização podem excluí-la.");
}
taskRepository.deleteTask(taskId);
LOGGER.infof("Task %d deleted successfully by user %s", taskId, userEmail);
}
}

View file

@ -1,5 +1,7 @@
package net.beetechgroup.beetask.usecase.task.get; package net.beetechgroup.beetask.usecase.task.get;
import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
@ -8,14 +10,32 @@ import org.jboss.logging.Logger;
public class GetTaskUseCase { public class GetTaskUseCase {
private static final Logger LOGGER = Logger.getLogger(GetTaskUseCase.class); private static final Logger LOGGER = Logger.getLogger(GetTaskUseCase.class);
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public GetTaskUseCase(TaskRepository taskRepository) { public GetTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.authorizer = authorizer;
} }
public CreateTaskOutput execute(Long id) { public CreateTaskOutput execute(Long id, String userEmail) {
//TODO deve validar se o usuario logado é o dono da task LOGGER.infof("Fetching task %d requested by %s", id, userEmail);
LOGGER.infof("Fetching task %d", id); Task task = taskRepository.findTaskById(id);
return CreateTaskMapper.toCreateTaskOutput(taskRepository.findTaskById(id));
Long orgId = null;
if (task.getProject() != null && task.getProject().getOrganization() != null) {
orgId = task.getProject().getOrganization().getId();
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
orgId = task.getCategory().getOrganization().getId();
}
if (orgId != null) {
authorizer.execute(userEmail, orgId);
} else if (task.getUser() != null && !task.getUser().getEmail().equals(userEmail)) {
LOGGER.warnf("User %s denied access to task %d owned by %s", userEmail, id, task.getUser().getEmail());
throw new SecurityException("Você não tem permissão para acessar esta tarefa.");
}
return CreateTaskMapper.toCreateTaskOutput(task);
} }
} }

View file

@ -2,6 +2,7 @@ package net.beetechgroup.beetask.usecase.task.start;
import java.util.Objects; import java.util.Objects;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
@ -13,25 +14,53 @@ public class StartTaskUseCase {
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository) { public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.authorizer = authorizer;
} }
public CreateTaskOutput execute(StartTaskInput input) { public CreateTaskOutput execute(StartTaskInput input) {
LOGGER.infof("Starting task %d for user %s", input.id(), input.userEmail()); LOGGER.infof("Starting task %d for user %s", input.id(), input.userEmail());
Task task = taskRepository.findTaskById(input.id()); Task task = taskRepository.findTaskById(input.id());
Long orgId = null;
if (task.getOrganization() != null) {
orgId = task.getOrganization().getId();
} else if (task.getProject() != null && task.getProject().getOrganization() != null) {
orgId = task.getProject().getOrganization().getId();
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
orgId = task.getCategory().getOrganization().getId();
}
if (orgId != null) {
authorizer.execute(input.userEmail(), orgId);
}
if (Objects.nonNull(task.getUser()) && !task.getUser().getEmail().equals(input.userEmail())) { if (Objects.nonNull(task.getUser()) && !task.getUser().getEmail().equals(input.userEmail())) {
LOGGER.warnf("User %s attempted to start task %d owned by %s", LOGGER.warnf("User %s attempted to start task %d owned by %s",
input.userEmail(), input.id(), task.getUser().getEmail()); input.userEmail(), input.id(), task.getUser().getEmail());
throw new SecurityException("Você não tem permissão para iniciar esta tarefa."); throw new SecurityException("Você não tem permissão para iniciar esta tarefa.");
} }
task.start(); net.beetechgroup.beetask.entities.User currentUser = userRepository.findByEmail(input.userEmail()).orElse(null);
// Auto-stop any existing running timers for this user across all tasks (Toggl style)
java.util.List<Task> runningTasks = taskRepository.findRunningTasksByUserEmail(input.userEmail());
for (Task running : runningTasks) {
if (!running.getId().equals(task.getId()) && running.isRunning()) {
LOGGER.infof("Auto-stopping previously running task %d for user %s", running.getId(), input.userEmail());
running.stop();
taskRepository.saveTask(running);
}
}
task.start(currentUser);
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
LOGGER.infof("Task %d started successfully", input.id()); LOGGER.infof("Task %d started successfully", input.id());
return output; return output;
} }
} }

View file

@ -1,6 +1,7 @@
package net.beetechgroup.beetask.usecase.task.stop; package net.beetechgroup.beetask.usecase.task.stop;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
@ -10,15 +11,28 @@ public class StopTaskUseCase {
private static final Logger LOGGER = Logger.getLogger(StopTaskUseCase.class); private static final Logger LOGGER = Logger.getLogger(StopTaskUseCase.class);
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public StopTaskUseCase(TaskRepository taskRepository) { public StopTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.authorizer = authorizer;
} }
public CreateTaskOutput execute(StopTaskInput input) { public CreateTaskOutput execute(StopTaskInput input) {
LOGGER.infof("Stopping task %d for user %s", input.id(), input.userEmail()); LOGGER.infof("Stopping task %d for user %s", input.id(), input.userEmail());
Task task = taskRepository.findTaskById(input.id()); Task task = taskRepository.findTaskById(input.id());
Long orgId = null;
if (task.getProject() != null && task.getProject().getOrganization() != null) {
orgId = task.getProject().getOrganization().getId();
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
orgId = task.getCategory().getOrganization().getId();
}
if (orgId != null) {
authorizer.execute(input.userEmail(), orgId);
}
if (task.getUser() != null && !task.getUser().getEmail().equals(input.userEmail())) { if (task.getUser() != null && !task.getUser().getEmail().equals(input.userEmail())) {
LOGGER.warnf("User %s attempted to stop task %d owned by %s", LOGGER.warnf("User %s attempted to stop task %d owned by %s",
input.userEmail(), input.id(), task.getUser().getEmail()); input.userEmail(), input.id(), task.getUser().getEmail());
@ -31,3 +45,4 @@ public class StopTaskUseCase {
return output; return output;
} }
} }

View file

@ -1,9 +1,8 @@
package net.beetechgroup.beetask.usecase.task.update; package net.beetechgroup.beetask.usecase.task.update;
import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.entities.task.TaskStatus;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
@ -13,15 +12,31 @@ public class UpdateTaskStatusUseCase {
private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusUseCase.class); private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusUseCase.class);
private final TaskRepository taskRepository; private final TaskRepository taskRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public UpdateTaskStatusUseCase(TaskRepository taskRepository) { public UpdateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.authorizer = authorizer;
} }
public CreateTaskOutput execute(Long id, TaskStatus newStatus) { public CreateTaskOutput execute(Long id, TaskStatus newStatus, String userEmail) {
LOGGER.infof("Updating status for task %d to %s", id, newStatus); LOGGER.infof("Updating status for task %d to %s by user %s", id, newStatus, userEmail);
Task task = taskRepository.findTaskById(id); Task task = taskRepository.findTaskById(id);
Long orgId = null;
if (task.getProject() != null && task.getProject().getOrganization() != null) {
orgId = task.getProject().getOrganization().getId();
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
orgId = task.getCategory().getOrganization().getId();
}
if (orgId != null) {
authorizer.execute(userEmail, orgId);
} else if (task.getUser() != null && !task.getUser().getEmail().equals(userEmail)) {
LOGGER.warnf("User %s denied status update on task %d owned by %s", userEmail, id, task.getUser().getEmail());
throw new SecurityException("Você não tem permissão para atualizar esta tarefa.");
}
task.setStatus(newStatus); task.setStatus(newStatus);
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
@ -29,3 +44,4 @@ public class UpdateTaskStatusUseCase {
return output; return output;
} }
} }

View file

@ -24,21 +24,36 @@ public class UpdateTaskUseCase {
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository; private final UserOrganizationRepository userOrganizationRepository;
private final net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository;
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository, CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) { UserOrganizationRepository userOrganizationRepository,
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
this.taskRepository = taskRepository; this.taskRepository = taskRepository;
this.projectRepository = projectRepository; this.projectRepository = projectRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository; this.userOrganizationRepository = userOrganizationRepository;
this.organizationRepository = organizationRepository;
} }
public CreateTaskOutput execute(UpdateTaskInput input) { public CreateTaskOutput execute(UpdateTaskInput input) {
LOGGER.infof("Updating task %d requested by user %s", input.id(), input.userEmail()); LOGGER.infof("Updating task %d requested by user %s", input.id(), input.userEmail());
Task task = taskRepository.findTaskById(input.id()); Task task = taskRepository.findTaskById(input.id());
Long currentOrgId = null;
if (task.getOrganization() != null) {
currentOrgId = task.getOrganization().getId();
} else if (task.getProject() != null && task.getProject().getOrganization() != null) {
currentOrgId = task.getProject().getOrganization().getId();
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
currentOrgId = task.getCategory().getOrganization().getId();
}
if (currentOrgId != null && !currentOrgId.equals(input.organizationId())) {
validateRequesterMembership(currentOrgId, input.userEmail());
}
validateRequesterMembership(input.organizationId(), input.userEmail()); validateRequesterMembership(input.organizationId(), input.userEmail());
task.setTitle(input.title()); task.setTitle(input.title());
@ -46,6 +61,10 @@ public class UpdateTaskUseCase {
task.setStatus(input.status()); task.setStatus(input.status());
task.setExternalUrl(input.externalUrl()); task.setExternalUrl(input.externalUrl());
if (Objects.nonNull(input.organizationId())) {
organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization);
}
if (Objects.nonNull(input.projectId())) { if (Objects.nonNull(input.projectId())) {
Project project = projectRepository.findProjectById(input.projectId()) Project project = projectRepository.findProjectById(input.projectId())
.orElseThrow(() -> { .orElseThrow(() -> {
@ -71,6 +90,18 @@ public class UpdateTaskUseCase {
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId())); task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
if (Objects.nonNull(input.history())) { if (Objects.nonNull(input.history())) {
long openIntervalCount = input.history().stream().filter(h -> h.endAt() == null).count();
if (openIntervalCount > 1) {
throw new IllegalArgumentException("Não é permitido mais de um intervalo em andamento ao mesmo tempo.");
}
for (var hInput : input.history()) {
if (hInput.startAt() != null && hInput.endAt() != null && hInput.endAt().isBefore(hInput.startAt())) {
throw new IllegalArgumentException("Data final do intervalo não pode ser anterior à data inicial.");
}
}
User defaultWorker = task.getUser();
List<TaskHistoryItem> updatedHistory = input.history().stream().map(hInput -> { List<TaskHistoryItem> updatedHistory = input.history().stream().map(hInput -> {
if (Objects.nonNull(hInput.id())) { if (Objects.nonNull(hInput.id())) {
return task.getHistory().stream() return task.getHistory().stream()
@ -79,18 +110,23 @@ public class UpdateTaskUseCase {
.map(existing -> { .map(existing -> {
existing.setStartAt(hInput.startAt()); existing.setStartAt(hInput.startAt());
existing.setEndAt(hInput.endAt()); existing.setEndAt(hInput.endAt());
if (existing.getUser() == null) {
existing.setUser(defaultWorker);
}
return existing; return existing;
}) })
.orElseGet(() -> { .orElseGet(() -> {
TaskHistoryItem newItem = new TaskHistoryItem(); TaskHistoryItem newItem = new TaskHistoryItem();
newItem.setStartAt(hInput.startAt()); newItem.setStartAt(hInput.startAt());
newItem.setEndAt(hInput.endAt()); newItem.setEndAt(hInput.endAt());
newItem.setUser(defaultWorker);
return newItem; return newItem;
}); });
} else { } else {
TaskHistoryItem newItem = new TaskHistoryItem(); TaskHistoryItem newItem = new TaskHistoryItem();
newItem.setStartAt(hInput.startAt()); newItem.setStartAt(hInput.startAt());
newItem.setEndAt(hInput.endAt()); newItem.setEndAt(hInput.endAt());
newItem.setUser(defaultWorker);
return newItem; return newItem;
} }
}).toList(); }).toList();

View file

@ -21,6 +21,9 @@ public class CreateUserUseCase {
LOGGER.infof("Creating user with email %s", input.email()); LOGGER.infof("Creating user with email %s", input.email());
User user = CreateUserMapper.toUser(input); User user = CreateUserMapper.toUser(input);
if (user.getPassword() != null && !user.getPassword().isBlank()) {
user.setPassword(org.mindrot.jbcrypt.BCrypt.hashpw(user.getPassword(), org.mindrot.jbcrypt.BCrypt.gensalt(10)));
}
User savedUser = userRepository.save(user); User savedUser = userRepository.save(user);
LOGGER.infof("User %d created successfully with email %s", savedUser.getId(), savedUser.getEmail()); LOGGER.infof("User %d created successfully with email %s", savedUser.getId(), savedUser.getEmail());

View file

@ -0,0 +1,21 @@
package net.beetechgroup.beetask.usecase.user.list;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import org.jboss.logging.Logger;
import java.util.List;
public class ListAllUsersUseCase {
private static final Logger LOGGER = Logger.getLogger(ListAllUsersUseCase.class);
private final UserRepository userRepository;
public ListAllUsersUseCase(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<User> execute() {
LOGGER.info("Executing ListAllUsersUseCase to retrieve all users");
return userRepository.findAllUsers();
}
}

3
frontend/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
.git

View file

@ -31,6 +31,7 @@ import {
isSameMonth, isSameMonth,
type WorkGoal, type WorkGoal,
} from '../../lib/userGoals' } from '../../lib/userGoals'
import { useAuth } from '../../contexts/AuthContext'
interface BiUniformCardProps { interface BiUniformCardProps {
card: DashboardV2Card card: DashboardV2Card
@ -42,6 +43,7 @@ interface BiUniformCardProps {
activeTask: Task | null activeTask: Task | null
activeSeconds: number activeSeconds: number
workGoal: WorkGoal workGoal: WorkGoal
globalScope?: 'mine' | 'team'
isBeingDragged?: boolean isBeingDragged?: boolean
isDragOver?: boolean isDragOver?: boolean
onToggleTimer: (task: Task) => void onToggleTimer: (task: Task) => void
@ -119,6 +121,7 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
activeTask, activeTask,
activeSeconds, activeSeconds,
workGoal, workGoal,
globalScope = 'team',
isBeingDragged = false, isBeingDragged = false,
isDragOver = false, isDragOver = false,
onToggleTimer, onToggleTimer,
@ -131,11 +134,30 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
onDrop, onDrop,
onDragEnd, onDragEnd,
}) => { }) => {
const { user } = useAuth()
const theme = THEME_STYLES[card.colorTheme || 'brand'] const theme = THEME_STYLES[card.colorTheme || 'brand']
const now = new Date() const now = new Date()
// 1. Filter Tasks based on card's combined dimensions // Resolve scope: Card's specific scope takes precedence, otherwise fallback to page's globalScope
const effectiveScope: 'mine' | 'team' =
card.scope && card.scope !== 'INHERIT'
? card.scope === 'MINE'
? 'mine'
: 'team'
: globalScope
// 1. Filter Tasks based on scope and card's combined dimensions
const filteredTasks = tasks.filter((t) => { const filteredTasks = tasks.filter((t) => {
// Scope filter (if 'mine', only user's tasks)
if (effectiveScope === 'mine') {
const isMine = Boolean(
t.user?.email &&
user?.email &&
t.user.email.toLowerCase() === user.email.toLowerCase()
)
if (!isMine) return false
}
if (card.projectId && t.project?.id !== card.projectId) return false if (card.projectId && t.project?.id !== card.projectId) return false
if (card.categoryId && t.category?.id !== card.categoryId) return false if (card.categoryId && t.category?.id !== card.categoryId) return false
if (card.statusFilter && card.statusFilter !== 'ALL') { if (card.statusFilter && card.statusFilter !== 'ALL') {
@ -351,24 +373,35 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
} }
case 'ACTIVE_TIMER': { case 'ACTIVE_TIMER': {
const targetTask = activeTask || tasks[0] const isMyActiveTask = Boolean(
activeTask &&
user?.email &&
activeTask.user?.email &&
activeTask.user.email.toLowerCase() === user.email.toLowerCase()
)
const currentActive = isMyActiveTask ? activeTask : null
const myTasks = tasks.filter((t) => {
if (!user?.email || !t.user?.email) return false
return t.user.email.toLowerCase() === user.email.toLowerCase()
})
const targetTask = currentActive || myTasks.find((t) => t.status === 'IN_PROGRESS') || myTasks.find((t) => t.status === 'NOT_STARTED') || myTasks[0]
return ( return (
<div className="space-y-1 my-auto"> <div className="space-y-1 my-auto">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xl sm:text-2xl font-extrabold font-mono text-foreground"> <span className="text-xl sm:text-2xl font-extrabold font-mono text-foreground">
{activeTask ? formatSecondsToTimer(activeSeconds) : '00:00:00'} {currentActive ? formatSecondsToTimer(activeSeconds) : '00:00:00'}
</span> </span>
{targetTask && ( {targetTask && (
<button <button
type="button" type="button"
onClick={() => onToggleTimer(targetTask)} onClick={() => onToggleTimer(targetTask)}
className={`p-1.5 rounded-lg transition-all shadow-xs ${ className={`p-1.5 rounded-lg transition-all shadow-xs cursor-pointer ${
activeTask currentActive
? 'bg-rose-500/20 text-rose-300 ring-1 ring-rose-500/40' ? 'bg-rose-500/20 text-rose-300 ring-1 ring-rose-500/40'
: 'bg-teal-500/10 text-teal-700 hover:bg-teal-500/20' : 'bg-teal-500/10 text-teal-700 hover:bg-teal-500/20'
}`} }`}
> >
{activeTask ? ( {currentActive ? (
<Square className="w-3 h-3 fill-rose-300" /> <Square className="w-3 h-3 fill-rose-300" />
) : ( ) : (
<Play className="w-3 h-3 fill-teal-700 dark:fill-teal-300" /> <Play className="w-3 h-3 fill-teal-700 dark:fill-teal-300" />
@ -377,7 +410,7 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
)} )}
</div> </div>
<p className="text-[10px] text-muted truncate"> <p className="text-[10px] text-muted truncate">
{activeTask ? activeTask.title : targetTask ? targetTask.title : 'Nenhuma tarefa pronta'} {currentActive ? currentActive.title : targetTask ? targetTask.title : 'Nenhuma tarefa pronta'}
</p> </p>
</div> </div>
) )
@ -524,6 +557,23 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
{/* Footer: Compact Dimension Tags */} {/* Footer: Compact Dimension Tags */}
<div className="pt-1.5 border-t border-border-soft/60 flex items-center gap-1.5 flex-wrap text-[9px] truncate"> <div className="pt-1.5 border-t border-border-soft/60 flex items-center gap-1.5 flex-wrap text-[9px] truncate">
{/* Scope Tag */}
<span
className={`px-1.5 py-0.2 rounded font-semibold border ${
effectiveScope === 'mine'
? 'bg-brand/10 text-brand border-brand/25'
: 'bg-zinc-500/10 text-muted border-zinc-500/20'
}`}
title={
card.scope && card.scope !== 'INHERIT'
? 'Escopo fixado nas configurações deste card'
: 'Escopo herdado do filtro global da página'
}
>
{effectiveScope === 'mine' ? '👤 Minhas' : '👥 Equipe'}
{card.scope && card.scope !== 'INHERIT' && ' • fixo'}
</span>
<span className={`px-1.5 py-0.2 rounded font-semibold border ${theme.badge}`}> <span className={`px-1.5 py-0.2 rounded font-semibold border ${theme.badge}`}>
{periodLabels[card.period]} {periodLabels[card.period]}
</span> </span>

View file

@ -11,7 +11,7 @@ import {
ListOrdered, ListOrdered,
Layers, Layers,
} from 'lucide-react' } from 'lucide-react'
import type { DashboardV2Card, BiMetricType, BiPeriodFilter } from '../../lib/userPreferences' import type { DashboardV2Card, BiMetricType, BiPeriodFilter, BiCardScope } from '../../lib/userPreferences'
import type { Project, Category } from '../../types/task' import type { Project, Category } from '../../types/task'
interface CreateBiCardModalProps { interface CreateBiCardModalProps {
@ -103,6 +103,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
const [categoryId, setCategoryId] = useState<number | undefined>(undefined) const [categoryId, setCategoryId] = useState<number | undefined>(undefined)
const [statusFilter, setStatusFilter] = useState<'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'>('ALL') const [statusFilter, setStatusFilter] = useState<'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'>('ALL')
const [colorTheme, setColorTheme] = useState<DashboardV2Card['colorTheme']>('brand') const [colorTheme, setColorTheme] = useState<DashboardV2Card['colorTheme']>('brand')
const [scope, setScope] = useState<BiCardScope>('INHERIT')
useEffect(() => { useEffect(() => {
if (editingCard) { if (editingCard) {
@ -113,6 +114,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
setCategoryId(editingCard.categoryId) setCategoryId(editingCard.categoryId)
setStatusFilter(editingCard.statusFilter || 'ALL') setStatusFilter(editingCard.statusFilter || 'ALL')
setColorTheme(editingCard.colorTheme || 'brand') setColorTheme(editingCard.colorTheme || 'brand')
setScope(editingCard.scope || 'INHERIT')
} else { } else {
setTitle('') setTitle('')
setMetricType('HOURS_TRACKED') setMetricType('HOURS_TRACKED')
@ -121,6 +123,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
setCategoryId(undefined) setCategoryId(undefined)
setStatusFilter('ALL') setStatusFilter('ALL')
setColorTheme('brand') setColorTheme('brand')
setScope('INHERIT')
} }
}, [editingCard, isOpen]) }, [editingCard, isOpen])
@ -138,6 +141,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
categoryId, categoryId,
statusFilter, statusFilter,
colorTheme, colorTheme,
scope,
} }
onSave(card) onSave(card)
onClose() onClose()
@ -313,6 +317,23 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
))} ))}
</select> </select>
</div> </div>
{/* Data Scope Filter */}
<div className="space-y-1.5 sm:col-span-2">
<label className="text-xs font-bold text-foreground flex items-center justify-between">
<span>Escopo dos Dados (Atribuição)</span>
<span className="text-[11px] text-muted font-normal">Individual vs Equipe</span>
</label>
<select
value={scope}
onChange={(e) => setScope(e.target.value as BiCardScope)}
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 shadow-xs"
>
<option value="INHERIT">🔗 Seguir filtro global da página (Padrão)</option>
<option value="MINE">👤 Sempre Apenas Minhas Tarefas (Individual)</option>
<option value="TEAM">👥 Sempre Toda a Equipe (Workspace Geral)</option>
</select>
</div>
</div> </div>
</div> </div>

View file

@ -7,7 +7,7 @@ export const AppShell = () => {
const [isMobileOpen, setIsMobileOpen] = useState(false) const [isMobileOpen, setIsMobileOpen] = useState(false)
return ( return (
<div className="min-h-screen bg-app-bg text-text-main flex font-sans selection:bg-brand-soft selection:text-brand relative overflow-x-hidden"> <div className="h-screen w-full bg-app-bg text-text-main flex font-sans selection:bg-brand-soft selection:text-brand relative overflow-hidden">
{/* Decorative ambient background glows matching Landing Page aesthetic */} {/* Decorative ambient background glows matching Landing Page aesthetic */}
<div className="fixed top-0 left-1/4 w-[650px] h-[350px] bg-gradient-to-tr from-accent/10 via-brand/12 to-transparent blur-3xl pointer-events-none rounded-full -z-0" /> <div className="fixed top-0 left-1/4 w-[650px] h-[350px] bg-gradient-to-tr from-accent/10 via-brand/12 to-transparent blur-3xl pointer-events-none rounded-full -z-0" />
<div className="fixed bottom-10 right-10 w-[550px] h-[350px] bg-gradient-to-br from-brand/10 via-accent/10 to-transparent blur-3xl pointer-events-none rounded-full -z-0" /> <div className="fixed bottom-10 right-10 w-[550px] h-[350px] bg-gradient-to-br from-brand/10 via-accent/10 to-transparent blur-3xl pointer-events-none rounded-full -z-0" />
@ -18,14 +18,14 @@ export const AppShell = () => {
onCloseMobile={() => setIsMobileOpen(false)} onCloseMobile={() => setIsMobileOpen(false)}
/> />
{/* Main Content Area */} {/* Main Content Area - only this scrolls */}
<div className="flex-1 flex flex-col min-w-0 z-10"> <div className="flex-1 flex flex-col min-w-0 h-full overflow-y-auto z-10">
{/* Mobile-only compact top bar to open drawer on smartphones */} {/* Mobile-only compact top bar to open drawer on smartphones */}
<div className="lg:hidden h-14 border-b border-border-soft bg-surface/85 backdrop-blur-md px-4 flex items-center justify-between sticky top-0 z-20"> <div className="lg:hidden h-14 border-b border-border-soft bg-surface/85 backdrop-blur-md px-4 flex items-center justify-between sticky top-0 z-20 shrink-0">
<button <button
type="button" type="button"
onClick={() => setIsMobileOpen(true)} onClick={() => setIsMobileOpen(true)}
className="p-2 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors" className="p-2 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
aria-label="Abrir menu lateral" aria-label="Abrir menu lateral"
> >
<MenuIcon className="w-5 h-5" /> <MenuIcon className="w-5 h-5" />

View file

@ -64,6 +64,13 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
.toUpperCase() .toUpperCase()
: 'U' : 'U'
const isMyActiveTask = Boolean(
activeTask &&
user?.email &&
((activeTask.user?.email && activeTask.user.email.toLowerCase() === user.email.toLowerCase()) ||
(activeTask.createdBy?.email && activeTask.createdBy.email.toLowerCase() === user.email.toLowerCase()))
)
return ( return (
<header className="sticky top-0 z-40 h-18 border-b border-border-soft bg-surface/85 backdrop-blur-md px-4 sm:px-6 lg:px-8 flex items-center justify-between transition-all"> <header className="sticky top-0 z-40 h-18 border-b border-border-soft bg-surface/85 backdrop-blur-md px-4 sm:px-6 lg:px-8 flex items-center justify-between transition-all">
{/* Left Area: Mobile Menu Trigger + Workspace Switcher */} {/* Left Area: Mobile Menu Trigger + Workspace Switcher */}
@ -175,7 +182,7 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
{/* Right Area: Active Tracker Pill + Action Button + User Profile */} {/* Right Area: Active Tracker Pill + Action Button + User Profile */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Active Timer Pill Banner */} {/* Active Timer Pill Banner */}
{activeTask ? ( {isMyActiveTask && activeTask ? (
<div className="hidden md:flex items-center gap-2.5 px-3 py-1.5 rounded-xl bg-teal-500/10 border border-teal-500/30 text-xs text-teal-300"> <div className="hidden md:flex items-center gap-2.5 px-3 py-1.5 rounded-xl bg-teal-500/10 border border-teal-500/30 text-xs text-teal-300">
<div className="w-2 h-2 rounded-full bg-teal-400 animate-ping" /> <div className="w-2 h-2 rounded-full bg-teal-400 animate-ping" />
<Clock className="w-3.5 h-3.5 text-teal-400 animate-pulse" /> <Clock className="w-3.5 h-3.5 text-teal-400 animate-pulse" />

View file

@ -71,6 +71,13 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
navigate('/login') navigate('/login')
} }
const isMyActiveTask = Boolean(
activeTask &&
user?.email &&
((activeTask.user?.email && activeTask.user.email.toLowerCase() === user.email.toLowerCase()) ||
(activeTask.createdBy?.email && activeTask.createdBy.email.toLowerCase() === user.email.toLowerCase()))
)
// All navigation items strictly formatted with a SINGLE WORD // All navigation items strictly formatted with a SINGLE WORD
const navItems = [ const navItems = [
{ {
@ -232,7 +239,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
</div> </div>
{/* Active Running Task Activity Status Banner */} {/* Active Running Task Activity Status Banner */}
{activeTask && ( {isMyActiveTask && activeTask && (
<div className="px-4 py-2 shrink-0"> <div className="px-4 py-2 shrink-0">
<div className="p-3 rounded-2xl bg-teal-500/10 border border-teal-600/25 dark:border-teal-500/30 shadow-xs relative overflow-hidden"> <div className="p-3 rounded-2xl bg-teal-500/10 border border-teal-600/25 dark:border-teal-500/30 shadow-xs relative overflow-hidden">
<div className="flex items-center justify-between gap-2 mb-1.5"> <div className="flex items-center justify-between gap-2 mb-1.5">
@ -416,7 +423,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
return ( return (
<> <>
{/* Desktop Sidebar (Permanent) */} {/* Desktop Sidebar (Permanent) */}
<aside className="hidden lg:block w-64 h-screen sticky top-0 shrink-0 z-30"> <aside className="hidden lg:block w-64 h-full shrink-0 z-30">
{navContent} {navContent}
</aside> </aside>

View file

@ -23,6 +23,7 @@ import { categoryService } from '../../services/categoryService'
import { organizationService } from '../../services/organizationService' import { organizationService } from '../../services/organizationService'
import { CustomSelect } from '../ui/Select' import { CustomSelect } from '../ui/Select'
import { useAuth } from '../../contexts/AuthContext' import { useAuth } from '../../contexts/AuthContext'
import { canDeleteTask } from '../../lib/taskPermissions'
import { import {
calculateTaskTotalSeconds, calculateTaskTotalSeconds,
formatSecondsToTimer, formatSecondsToTimer,
@ -46,6 +47,7 @@ interface CreateTaskModalProps {
history?: TaskHistoryItemInput[] history?: TaskHistoryItemInput[]
externalUrl?: string externalUrl?: string
}) => Promise<void> }) => Promise<void>
onDelete?: (task: Task) => void
organizationId: number organizationId: number
initialStatus?: TaskStatus initialStatus?: TaskStatus
initialTask?: Task | null initialTask?: Task | null
@ -56,12 +58,13 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
isOpen, isOpen,
onClose, onClose,
onSubmit, onSubmit,
onDelete,
organizationId, organizationId,
initialStatus = 'NOT_STARTED', initialStatus = 'NOT_STARTED',
initialTask = null, initialTask = null,
initialTab = 'details', initialTab = 'details',
}) => { }) => {
const { user } = useAuth() const { user, activeOrg } = useAuth()
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details') const [activeTab, setActiveTab] = useState<'details' | 'history'>('details')
// Task details // Task details
@ -343,13 +346,24 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
endAt: item.endAt, endAt: item.endAt,
})) }))
let effectiveAssigneeUserId = assigneeUserId
if (!initialTask && effectiveAssigneeUserId === undefined) {
const myMember = members.find((m) => m.userEmail?.toLowerCase() === user?.email?.toLowerCase())
if (myMember) {
effectiveAssigneeUserId = myMember.userId
}
}
if (effectiveAssigneeUserId === -1) {
effectiveAssigneeUserId = undefined
}
await onSubmit({ await onSubmit({
title: title.trim(), title: title.trim(),
description: description.trim(), description: description.trim(),
status, status,
projectId, projectId,
categoryId, categoryId,
assigneeUserId, assigneeUserId: effectiveAssigneeUserId,
history: initialTask ? mappedHistory : undefined, history: initialTask ? mappedHistory : undefined,
externalUrl: externalUrl.trim() || undefined, externalUrl: externalUrl.trim() || undefined,
}) })
@ -585,15 +599,19 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
Responsável Responsável
</label> </label>
<CustomSelect <CustomSelect
value={assigneeUserId ?? 0} value={
onChange={(val) => setAssigneeUserId(Number(val) === 0 ? undefined : Number(val))} assigneeUserId !== undefined
placeholder="Atribuir a mim mesmo / Sem responsável" ? assigneeUserId
: (members.find((m) => m.userEmail?.toLowerCase() === user?.email?.toLowerCase())?.userId ?? -1)
}
onChange={(val) => setAssigneeUserId(Number(val) === -1 ? -1 : Number(val))}
placeholder="Selecione o responsável"
leftIcon={<User className="w-3.5 h-3.5 text-muted" />} leftIcon={<User className="w-3.5 h-3.5 text-muted" />}
options={[ options={[
{ value: 0, label: 'Sem responsável (atribuir a mim)' }, { value: -1, label: 'Sem responsável (Livre)' },
...members.map((m) => ({ ...members.map((m) => ({
value: m.userId, value: m.userId,
label: m.userName, label: `${m.userName}${m.userEmail?.toLowerCase() === user?.email?.toLowerCase() ? ' (Você)' : ''}`,
description: m.userEmail, description: m.userEmail,
})), })),
]} ]}
@ -922,9 +940,25 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
{/* Footer Controls */} {/* Footer Controls */}
<div className="pt-4 border-t border-border flex items-center justify-between gap-2.5"> <div className="pt-4 border-t border-border flex items-center justify-between gap-2.5">
<div className="text-[11px] text-muted"> <div className="flex items-center gap-3">
{initialTask && onDelete && canDeleteTask(initialTask, user, activeOrg) && (
<button
type="button"
onClick={() => {
if (window.confirm(`Tem certeza que deseja excluir a tarefa "${initialTask.title}"?`)) {
onDelete(initialTask)
onClose()
}
}}
disabled={submitting}
className="px-3 py-2 rounded-xl border border-rose-500/30 bg-rose-500/10 text-xs font-semibold text-rose-500 hover:bg-rose-500/20 transition-colors inline-flex items-center gap-1.5"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Excluir</span>
</button>
)}
{initialTask && ( {initialTask && (
<span> <span className="text-[11px] text-muted">
Tempo total: <strong className="text-foreground font-mono">{formatSecondsToHuman(totalCalculatedSeconds)}</strong> Tempo total: <strong className="text-foreground font-mono">{formatSecondsToHuman(totalCalculatedSeconds)}</strong>
</span> </span>
)} )}

View file

@ -1,13 +1,16 @@
import React from 'react' import React from 'react'
import { Play, Square, Clock, Folder, Tag, MoreVertical, CircleDot, ExternalLink } from 'lucide-react' import { Play, Square, Clock, Folder, Tag, MoreVertical, CircleDot, ExternalLink, Trash2 } from 'lucide-react'
import type { Task } from '../../types/task' import type { Task } from '../../types/task'
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils' import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
import { useAuth } from '../../contexts/AuthContext'
import { canDeleteTask } from '../../lib/taskPermissions'
interface TaskCardProps { interface TaskCardProps {
task: Task task: Task
onToggleTimer: (task: Task) => void onToggleTimer: (task: Task) => void
onEdit?: (task: Task) => void onEdit?: (task: Task) => void
onOpenHistory?: (task: Task) => void onOpenHistory?: (task: Task) => void
onDelete?: (task: Task) => void
isTrackingThis?: boolean isTrackingThis?: boolean
currentSeconds?: number currentSeconds?: number
} }
@ -17,9 +20,11 @@ export const TaskCard: React.FC<TaskCardProps> = ({
onToggleTimer, onToggleTimer,
onEdit, onEdit,
onOpenHistory, onOpenHistory,
onDelete,
isTrackingThis, isTrackingThis,
currentSeconds, currentSeconds,
}) => { }) => {
const { user, activeOrg } = useAuth()
const running = isTrackingThis ?? isTaskRunning(task) const running = isTrackingThis ?? isTaskRunning(task)
const totalSeconds = currentSeconds !== undefined ? currentSeconds : calculateTaskTotalSeconds(task) const totalSeconds = currentSeconds !== undefined ? currentSeconds : calculateTaskTotalSeconds(task)
const [menuOpen, setMenuOpen] = React.useState(false) const [menuOpen, setMenuOpen] = React.useState(false)
@ -90,7 +95,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
</div> </div>
{/* More Actions Menu */} {/* More Actions Menu */}
{(onEdit || onOpenHistory) && ( {(onEdit || onOpenHistory || (onDelete && canDeleteTask(task, user, activeOrg))) && (
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
@ -142,6 +147,23 @@ export const TaskCard: React.FC<TaskCardProps> = ({
<Clock className="w-3.5 h-3.5 text-muted" /> <Clock className="w-3.5 h-3.5 text-muted" />
</button> </button>
)} )}
{onDelete && canDeleteTask(task, user, activeOrg) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setMenuOpen(false)
if (window.confirm(`Tem certeza que deseja excluir a tarefa "${task.title}"?`)) {
onDelete(task)
}
}}
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left text-rose-500 hover:bg-rose-500/10 transition-colors"
>
<span>Excluir Tarefa</span>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div> </div>
</> </>
)} )}

View file

@ -10,6 +10,7 @@ interface TaskKanbanViewProps {
onStatusChange: (task: Task, newStatus: TaskStatus) => void onStatusChange: (task: Task, newStatus: TaskStatus) => void
onEditTask: (task: Task) => void onEditTask: (task: Task) => void
onOpenHistory?: (task: Task) => void onOpenHistory?: (task: Task) => void
onDeleteTask?: (task: Task) => void
onAddTaskToStatus: (status: TaskStatus) => void onAddTaskToStatus: (status: TaskStatus) => void
activeTaskId?: number | null activeTaskId?: number | null
activeTaskSeconds?: number activeTaskSeconds?: number
@ -69,6 +70,7 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
onStatusChange, onStatusChange,
onEditTask, onEditTask,
onOpenHistory, onOpenHistory,
onDeleteTask,
onAddTaskToStatus, onAddTaskToStatus,
activeTaskId, activeTaskId,
activeTaskSeconds, activeTaskSeconds,
@ -267,6 +269,7 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
onToggleTimer={onToggleTimer} onToggleTimer={onToggleTimer}
onEdit={onEditTask} onEdit={onEditTask}
onOpenHistory={onOpenHistory} onOpenHistory={onOpenHistory}
onDelete={onDeleteTask}
isTrackingThis={activeTaskId === task.id} isTrackingThis={activeTaskId === task.id}
currentSeconds={activeTaskId === task.id ? activeTaskSeconds : undefined} currentSeconds={activeTaskId === task.id ? activeTaskSeconds : undefined}
/> />

View file

@ -1,8 +1,10 @@
import React from 'react' import React from 'react'
import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2, ExternalLink } from 'lucide-react' import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2, ExternalLink, Trash2 } from 'lucide-react'
import type { Task, TaskStatus } from '../../types/task' import type { Task, TaskStatus } from '../../types/task'
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils' import { calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
import { CustomSelect } from '../ui/Select' import { CustomSelect } from '../ui/Select'
import { useAuth } from '../../contexts/AuthContext'
import { canDeleteTask } from '../../lib/taskPermissions'
interface TaskListViewProps { interface TaskListViewProps {
tasks: Task[] tasks: Task[]
@ -10,6 +12,7 @@ interface TaskListViewProps {
onStatusChange: (task: Task, newStatus: TaskStatus) => void onStatusChange: (task: Task, newStatus: TaskStatus) => void
onEditTask: (task: Task) => void onEditTask: (task: Task) => void
onOpenHistory?: (task: Task) => void onOpenHistory?: (task: Task) => void
onDeleteTask?: (task: Task) => void
activeTaskId?: number | null activeTaskId?: number | null
activeTaskSeconds?: number activeTaskSeconds?: number
} }
@ -21,9 +24,11 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
onStatusChange, onStatusChange,
onEditTask, onEditTask,
onOpenHistory, onOpenHistory,
onDeleteTask,
activeTaskId, activeTaskId,
activeTaskSeconds, activeTaskSeconds,
}) => { }) => {
const { user, activeOrg } = useAuth()
const [activeMenuId, setActiveMenuId] = React.useState<number | null>(null) const [activeMenuId, setActiveMenuId] = React.useState<number | null>(null)
return ( return (
@ -43,10 +48,9 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
</thead> </thead>
<tbody className="divide-y divide-border/60"> <tbody className="divide-y divide-border/60">
{tasks.map((task) => { {tasks.map((task) => {
const isRunning = const isRunning = Boolean(activeTaskId && activeTaskId === task.id)
activeTaskId === task.id || (activeTaskId === undefined && isTaskRunning(task))
const totalSec = const totalSec =
activeTaskId === task.id && activeTaskSeconds !== undefined isRunning && activeTaskSeconds !== undefined
? activeTaskSeconds ? activeTaskSeconds
: calculateTaskTotalSeconds(task) : calculateTaskTotalSeconds(task)
@ -247,6 +251,19 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
<span>Concluir</span> <span>Concluir</span>
<CheckCircle2 className="w-3.5 h-3.5" /> <CheckCircle2 className="w-3.5 h-3.5" />
</button> </button>
{onDeleteTask && canDeleteTask(task, user, activeOrg) && (
<button
type="button"
onClick={() => {
setActiveMenuId(null)
onDeleteTask(task)
}}
className="w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
>
<span>Excluir Tarefa</span>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div> </div>
</> </>
)} )}

View file

@ -23,6 +23,7 @@ interface AuthContextType {
register: (name: string, email: string, password: string) => Promise<CreateAccountResponse> register: (name: string, email: string, password: string) => Promise<CreateAccountResponse>
logout: () => void logout: () => void
refreshUser: () => Promise<void> refreshUser: () => Promise<void>
updateUser: (updated: Partial<LoginResponse>) => void
setActiveOrg: (id: number) => void setActiveOrg: (id: number) => void
} }
@ -148,6 +149,15 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
} }
} }
const updateUser = useCallback((updated: Partial<LoginResponse>) => {
setUser((prev) => {
if (!prev) return null
const next = { ...prev, ...updated }
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(next))
return next
})
}, [])
const setActiveOrg = (id: number) => { const setActiveOrg = (id: number) => {
setActiveOrgId(id) setActiveOrgId(id)
localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, id.toString()) localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, id.toString())
@ -169,6 +179,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
register, register,
logout, logout,
refreshUser, refreshUser,
updateUser,
setActiveOrg, setActiveOrg,
}} }}
> >

View file

@ -15,29 +15,65 @@ interface TimerContextType {
const TimerContext = createContext<TimerContextType | undefined>(undefined) const TimerContext = createContext<TimerContextType | undefined>(undefined)
export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { activeOrg } = useAuth() const { user, activeOrg } = useAuth()
const [activeTask, setActiveTask] = useState<Task | null>(null) const [activeTask, setActiveTask] = useState<Task | null>(null)
const [activeSeconds, setActiveSeconds] = useState<number>(0) const [activeSeconds, setActiveSeconds] = useState<number>(0)
// Track the baseline seconds and client-side timestamp when task data was fetched/started // Track the baseline seconds and client-side timestamp when task data was fetched/started
const baseSecondsRef = useRef<number>(0) const baseSecondsRef = useRef<number>(0)
const receivedAtRef = useRef<number>(Date.now()) const receivedAtRef = useRef<number>(Date.now())
const activeTaskRef = useRef<Task | null>(null)
useEffect(() => {
activeTaskRef.current = activeTask
}, [activeTask])
// Check if task belongs to the logged-in user (as assignee or creator)
const isMyTask = useCallback(
(t: Task) => {
if (!user?.email) return false
const userEmail = user.email.toLowerCase()
const matchesAssignee = t.user?.email && t.user.email.toLowerCase() === userEmail
const matchesCreator = t.createdBy?.email && t.createdBy.email.toLowerCase() === userEmail
return Boolean(matchesAssignee || matchesCreator)
},
[user?.email]
)
// Sync state whenever tasks list updates // Sync state whenever tasks list updates
const syncWithTasks = useCallback((tasks: Task[]) => { const syncWithTasks = useCallback(
const running = tasks.find((t) => isTaskRunning(t)) (tasks: Task[]) => {
// ONLY consider tasks assigned to the current logged-in user
const running = tasks.find((t) => isTaskRunning(t) && isMyTask(t))
if (running) { if (running) {
setActiveTask(running) activeTaskRef.current = running
setActiveTask((prev) => {
if (prev?.id === running.id && prev.status === running.status) {
const prevRunningItem = prev.history?.find((h) => !h.endAt)
const currRunningItem = running.history?.find((h) => !h.endAt)
if (prevRunningItem?.id === currRunningItem?.id && prevRunningItem?.startAt === currRunningItem?.startAt) {
return prev
}
}
return running
})
const base = calculateBaseSeconds(running) const base = calculateBaseSeconds(running)
baseSecondsRef.current = base baseSecondsRef.current = base
receivedAtRef.current = Date.now() receivedAtRef.current = Date.now()
setActiveSeconds(base) setActiveSeconds(base)
} else { } else {
// If syncing a single task and it's not the active task, don't accidentally clear
if (tasks.length === 1 && activeTaskRef.current && tasks[0].id !== activeTaskRef.current.id) {
return
}
activeTaskRef.current = null
setActiveTask(null) setActiveTask(null)
baseSecondsRef.current = 0 baseSecondsRef.current = 0
setActiveSeconds(0) setActiveSeconds(0)
} }
}, []) },
[isMyTask]
)
// Auto-fetch active running task on mount or workspace change // Auto-fetch active running task on mount or workspace change
useEffect(() => { useEffect(() => {
@ -48,7 +84,7 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre
.listMyTasks({ organizationId: activeOrg.id }) .listMyTasks({ organizationId: activeOrg.id })
.then((tasks) => { .then((tasks) => {
if (!isMounted) return if (!isMounted) return
const running = tasks.find((t) => isTaskRunning(t)) const running = tasks.find((t) => isTaskRunning(t) && isMyTask(t))
if (running) { if (running) {
syncWithTasks(tasks) syncWithTasks(tasks)
} }
@ -102,15 +138,16 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const startTimer = useCallback( const startTimer = useCallback(
async (task: Task): Promise<Task> => { async (task: Task): Promise<Task> => {
// If there was an active task running different from this one, stop it first // If there was an active task running different from this one, stop it first
if (activeTask && activeTask.id !== task.id) { if (activeTaskRef.current && activeTaskRef.current.id !== task.id) {
try { try {
await taskService.stopTask(activeTask.id) await taskService.stopTask(activeTaskRef.current.id)
} catch (e) { } catch (e) {
console.warn('Could not stop previously running task', e) console.warn('Could not stop previously running task', e)
} }
} }
const updated = await taskService.startTask(task.id) const updated = await taskService.startTask(task.id)
activeTaskRef.current = updated
setActiveTask(updated) setActiveTask(updated)
const base = calculateBaseSeconds(updated) const base = calculateBaseSeconds(updated)
baseSecondsRef.current = base baseSecondsRef.current = base
@ -118,21 +155,22 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre
setActiveSeconds(base) setActiveSeconds(base)
return updated return updated
}, },
[activeTask] []
) )
const stopTimer = useCallback( const stopTimer = useCallback(
async (task?: Task): Promise<Task | null> => { async (task?: Task): Promise<Task | null> => {
const targetId = task ? task.id : activeTask?.id const targetId = task ? task.id : activeTaskRef.current?.id
if (!targetId) return null if (!targetId) return null
const updated = await taskService.stopTask(targetId) const updated = await taskService.stopTask(targetId)
activeTaskRef.current = null
setActiveTask(null) setActiveTask(null)
baseSecondsRef.current = 0 baseSecondsRef.current = 0
setActiveSeconds(0) setActiveSeconds(0)
return updated return updated
}, },
[activeTask] []
) )
return ( return (

View file

@ -0,0 +1,30 @@
import type { Task } from '../types/task'
import type { LoginResponse, OrganizationMembership } from '../services/authService'
/**
* Checks whether the current user is allowed to delete a task:
* - Must be an ADMIN or OWNER of the organization to which the task belongs, OR
* - Must be the owner/creator of the task.
*/
export function canDeleteTask(
task: Task,
user: LoginResponse | null,
activeOrg: OrganizationMembership | null
): boolean {
if (!user) return false
// 1. Is the user the creator or owner of the task?
const isCreator = Boolean(
(task.createdBy?.email && task.createdBy.email.toLowerCase() === user.email.toLowerCase()) ||
(task.user?.email && task.user.email.toLowerCase() === user.email.toLowerCase())
)
if (isCreator) return true
// 2. Is the user an admin or owner of the active organization?
const isOrgAdmin = Boolean(
activeOrg?.roles?.some((role) => role === 'ADMIN' || role === 'OWNER')
)
return isOrgAdmin
}

View file

@ -27,6 +27,8 @@ export type BiMetricType =
export type BiPeriodFilter = 'ALL' | 'TODAY' | 'WEEK' | 'MONTH' export type BiPeriodFilter = 'ALL' | 'TODAY' | 'WEEK' | 'MONTH'
export type BiCardScope = 'INHERIT' | 'MINE' | 'TEAM'
export interface DashboardV2Card { export interface DashboardV2Card {
id: string id: string
title: string title: string
@ -36,6 +38,7 @@ export interface DashboardV2Card {
categoryId?: number categoryId?: number
statusFilter?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED' statusFilter?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'
colorTheme?: 'brand' | 'accent' | 'emerald' | 'indigo' | 'rose' | 'cyan' colorTheme?: 'brand' | 'accent' | 'emerald' | 'indigo' | 'rose' | 'cyan'
scope?: BiCardScope
} }
export const DEFAULT_V2_CARDS: DashboardV2Card[] = [ export const DEFAULT_V2_CARDS: DashboardV2Card[] = [

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react' import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import { import {
Clock, Clock,
CheckCircle2, CheckCircle2,
@ -10,6 +11,7 @@ import {
Sparkles, Sparkles,
Activity, Activity,
AlertCircle, AlertCircle,
User,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { taskService } from '../services/taskService' import { taskService } from '../services/taskService'
@ -20,7 +22,7 @@ import type { Task, Project, Category } from '../types/task'
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils' import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
export const AnalyticsPage: React.FC = () => { export const AnalyticsPage: React.FC = () => {
const { activeOrg } = useAuth() const { user, activeOrg } = useAuth()
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
@ -31,6 +33,24 @@ export const AnalyticsPage: React.FC = () => {
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Scope Filter: 'ALL' | 'MINE' | email do membro
const [searchParams, setSearchParams] = useSearchParams()
const initialScope = searchParams.get('member') || 'ALL'
const [selectedMember, setSelectedMember] = useState<string>(initialScope)
const handleMemberChange = (value: string) => {
setSelectedMember(value)
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
if (value === 'ALL') next.delete('member')
else next.set('member', value)
return next
},
{ replace: true }
)
}
const activeOrgId = activeOrg?.id const activeOrgId = activeOrg?.id
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
@ -61,15 +81,25 @@ export const AnalyticsPage: React.FC = () => {
loadData() loadData()
}, [loadData]) }, [loadData])
// Aggregate high-level KPIs // Filter tasks based on selected member scope
const kpis = useMemo(() => { const scopedTasks = useMemo(() => {
const totalTasks = tasks.length if (selectedMember === 'ALL') return tasks
const completedTasks = tasks.filter((t) => t.status === 'COMPLETED').length const targetEmail = selectedMember === 'MINE' ? user?.email : selectedMember
const inProgressTasks = tasks.filter((t) => t.status === 'IN_PROGRESS').length if (!targetEmail) return tasks
const notStartedTasks = tasks.filter((t) => t.status === 'NOT_STARTED').length return tasks.filter(
const canceledTasks = tasks.filter((t) => t.status === 'CANCELED').length (t) => t.user?.email && t.user.email.toLowerCase() === targetEmail.toLowerCase()
)
}, [tasks, selectedMember, user?.email])
const totalSecondsTracked = tasks.reduce( // Aggregate high-level KPIs based on scopedTasks
const kpis = useMemo(() => {
const totalTasks = scopedTasks.length
const completedTasks = scopedTasks.filter((t) => t.status === 'COMPLETED').length
const inProgressTasks = scopedTasks.filter((t) => t.status === 'IN_PROGRESS').length
const notStartedTasks = scopedTasks.filter((t) => t.status === 'NOT_STARTED').length
const canceledTasks = scopedTasks.filter((t) => t.status === 'CANCELED').length
const totalSecondsTracked = scopedTasks.reduce(
(acc, t) => acc + calculateTaskTotalSeconds(t), (acc, t) => acc + calculateTaskTotalSeconds(t),
0 0
) )
@ -80,7 +110,7 @@ export const AnalyticsPage: React.FC = () => {
const avgSecondsPerCompletedTask = const avgSecondsPerCompletedTask =
completedTasks > 0 completedTasks > 0
? Math.round( ? Math.round(
tasks scopedTasks
.filter((t) => t.status === 'COMPLETED') .filter((t) => t.status === 'COMPLETED')
.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0) / completedTasks .reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0) / completedTasks
) )
@ -96,7 +126,7 @@ export const AnalyticsPage: React.FC = () => {
completionRate, completionRate,
avgSecondsPerCompletedTask, avgSecondsPerCompletedTask,
} }
}, [tasks]) }, [scopedTasks])
// Project breakdown // Project breakdown
const projectBreakdown = useMemo(() => { const projectBreakdown = useMemo(() => {
@ -109,7 +139,7 @@ export const AnalyticsPage: React.FC = () => {
let unassignedSeconds = 0 let unassignedSeconds = 0
let unassignedTasks = 0 let unassignedTasks = 0
tasks.forEach((t) => { scopedTasks.forEach((t) => {
const sec = calculateTaskTotalSeconds(t) const sec = calculateTaskTotalSeconds(t)
if (t.project?.id && map.has(t.project.id)) { if (t.project?.id && map.has(t.project.id)) {
const item = map.get(t.project.id)! const item = map.get(t.project.id)!
@ -131,7 +161,7 @@ export const AnalyticsPage: React.FC = () => {
} }
return list.sort((a, b) => b.seconds - a.seconds) return list.sort((a, b) => b.seconds - a.seconds)
}, [projects, tasks]) }, [projects, scopedTasks])
// Category breakdown // Category breakdown
const categoryBreakdown = useMemo(() => { const categoryBreakdown = useMemo(() => {
@ -144,7 +174,7 @@ export const AnalyticsPage: React.FC = () => {
let unassignedSeconds = 0 let unassignedSeconds = 0
let unassignedTasks = 0 let unassignedTasks = 0
tasks.forEach((t) => { scopedTasks.forEach((t) => {
const sec = calculateTaskTotalSeconds(t) const sec = calculateTaskTotalSeconds(t)
if (t.category?.id && map.has(t.category.id)) { if (t.category?.id && map.has(t.category.id)) {
const item = map.get(t.category.id)! const item = map.get(t.category.id)!
@ -166,7 +196,7 @@ export const AnalyticsPage: React.FC = () => {
} }
return list.sort((a, b) => b.seconds - a.seconds) return list.sort((a, b) => b.seconds - a.seconds)
}, [categories, tasks]) }, [categories, scopedTasks])
// Member breakdown // Member breakdown
const memberBreakdown = useMemo(() => { const memberBreakdown = useMemo(() => {
@ -252,6 +282,68 @@ export const AnalyticsPage: React.FC = () => {
</div> </div>
)} )}
{/* Scope Selector: Equipe Completa vs Apenas Minhas vs Membro Específico */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-3.5 sm:p-4 rounded-3xl bg-surface/90 border border-border-soft backdrop-blur-md shadow-panel">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-xs font-bold text-muted uppercase tracking-wider hidden sm:inline">
Filtro de Membro:
</span>
<div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft shadow-inner">
<button
type="button"
onClick={() => handleMemberChange('ALL')}
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all ${
selectedMember === 'ALL'
? 'bg-brand text-white shadow-md shadow-brand/25'
: 'text-muted hover:text-foreground'
}`}
>
<Users className="w-3.5 h-3.5" />
<span>Toda a Equipe</span>
</button>
<button
type="button"
onClick={() => handleMemberChange('MINE')}
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all ${
selectedMember === 'MINE'
? 'bg-brand text-white shadow-md shadow-brand/25'
: 'text-muted hover:text-foreground'
}`}
>
<User className="w-3.5 h-3.5" />
<span>Apenas Minhas</span>
</button>
</div>
{/* Specific Member Dropdown */}
<div className="flex items-center gap-1.5">
<select
value={selectedMember === 'ALL' || selectedMember === 'MINE' ? '' : selectedMember}
onChange={(e) => handleMemberChange(e.target.value || 'ALL')}
className="px-3 py-1.5 rounded-xl border border-border-soft bg-surface text-xs font-medium text-foreground focus:outline-hidden focus:border-brand shadow-xs"
>
<option value="">Outro Membro...</option>
{members.map((m) => (
<option key={m.userId} value={m.userEmail}>
{m.userName} ({m.userEmail})
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted font-medium">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
<span className="text-[11px] truncate">
{selectedMember === 'ALL'
? 'Calculando métricas globais consolidadas do workspace'
: selectedMember === 'MINE'
? 'Calculando métricas estritamente do seu perfil'
: `Filtrando métricas para o membro selecionado`}
</span>
</div>
</div>
{loading ? ( {loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 py-8"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 py-8">
{[1, 2, 3, 4].map((i) => ( {[1, 2, 3, 4].map((i) => (
@ -274,9 +366,14 @@ export const AnalyticsPage: React.FC = () => {
</div> </div>
</div> </div>
<div> <div>
<div className="flex items-baseline justify-between">
<p className="text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight font-mono"> <p className="text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight font-mono">
{formatSecondsToTimer(kpis.totalSecondsTracked)} {formatSecondsToTimer(kpis.totalSecondsTracked)}
</p> </p>
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
{selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'}
</span>
</div>
<p className="text-[11px] text-muted mt-1"> <p className="text-[11px] text-muted mt-1">
Equivalente a {formatSecondsToHuman(kpis.totalSecondsTracked)} Equivalente a {formatSecondsToHuman(kpis.totalSecondsTracked)}
</p> </p>
@ -292,9 +389,14 @@ export const AnalyticsPage: React.FC = () => {
</div> </div>
</div> </div>
<div> <div>
<div className="flex items-baseline justify-between">
<p className="text-2xl sm:text-3xl font-extrabold text-emerald-700 dark:text-emerald-400 tracking-tight"> <p className="text-2xl sm:text-3xl font-extrabold text-emerald-700 dark:text-emerald-400 tracking-tight">
{kpis.completionRate}% {kpis.completionRate}%
</p> </p>
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
{selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'}
</span>
</div>
<p className="text-[11px] text-muted mt-1"> <p className="text-[11px] text-muted mt-1">
{kpis.completedTasks} de {kpis.totalTasks} demandas finalizadas {kpis.completedTasks} de {kpis.totalTasks} demandas finalizadas
</p> </p>
@ -310,9 +412,14 @@ export const AnalyticsPage: React.FC = () => {
</div> </div>
</div> </div>
<div> <div>
<div className="flex items-baseline justify-between">
<p className="text-2xl sm:text-3xl font-extrabold text-teal-700 dark:text-teal-300 tracking-tight"> <p className="text-2xl sm:text-3xl font-extrabold text-teal-700 dark:text-teal-300 tracking-tight">
{kpis.inProgressTasks} tarefas {kpis.inProgressTasks} tarefas
</p> </p>
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
{selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'}
</span>
</div>
<p className="text-[11px] text-muted mt-1"> <p className="text-[11px] text-muted mt-1">
{kpis.notStartedTasks} no backlog aguardando início {kpis.notStartedTasks} no backlog aguardando início
</p> </p>
@ -328,9 +435,14 @@ export const AnalyticsPage: React.FC = () => {
</div> </div>
</div> </div>
<div> <div>
<div className="flex items-baseline justify-between">
<p className="text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight font-mono"> <p className="text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight font-mono">
{formatSecondsToHuman(kpis.avgSecondsPerCompletedTask)} {formatSecondsToHuman(kpis.avgSecondsPerCompletedTask)}
</p> </p>
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
{selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'}
</span>
</div>
<p className="text-[11px] text-muted mt-1"> <p className="text-[11px] text-muted mt-1">
Tempo médio gasto em cada entrega concluída Tempo médio gasto em cada entrega concluída
</p> </p>

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react' import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { import {
Clock, Clock,
CheckCircle2, CheckCircle2,
@ -21,6 +21,8 @@ import {
Settings2, Settings2,
BarChart3, BarChart3,
ExternalLink, ExternalLink,
User,
Users,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useTimer } from '../contexts/TimerContext' import { useTimer } from '../contexts/TimerContext'
@ -40,6 +42,15 @@ export const DashboardPage: React.FC = () => {
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer() const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
const navigate = useNavigate() const navigate = useNavigate()
// Check if active task belongs to current user (as assignee or creator)
const isMyActiveTask = Boolean(
activeTask &&
user?.email &&
((activeTask.user?.email && activeTask.user.email.toLowerCase() === user.email.toLowerCase()) ||
(activeTask.createdBy?.email && activeTask.createdBy.email.toLowerCase() === user.email.toLowerCase()))
)
const currentActiveTask = isMyActiveTask ? activeTask : null
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -71,6 +82,29 @@ export const DashboardPage: React.FC = () => {
// Modal // Modal
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
// Scope Toggle State: 'mine' (Individual) vs 'team' (Equipe)
const [searchParams, setSearchParams] = useSearchParams()
const initialScope =
(searchParams.get('scope') as 'mine' | 'team') ||
(localStorage.getItem('beetask_dashboard_scope') as 'mine' | 'team') ||
'mine'
const [viewScope, setViewScope] = useState<'mine' | 'team'>(initialScope)
const handleScopeChange = (newScope: 'mine' | 'team') => {
setViewScope(newScope)
try {
localStorage.setItem('beetask_dashboard_scope', newScope)
} catch (_) {}
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
next.set('scope', newScope)
return next
},
{ replace: true }
)
}
const activeOrgId = activeOrg?.id const activeOrgId = activeOrg?.id
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
@ -102,21 +136,39 @@ export const DashboardPage: React.FC = () => {
loadData() loadData()
}, [loadData]) }, [loadData])
// KPIs // Filter user's own tasks
const myTasks = useMemo(() => {
return tasks.filter((t) => {
if (!user?.email) return false
return Boolean(
t.user?.email &&
user?.email &&
t.user.email.toLowerCase() === user.email.toLowerCase()
)
})
}, [tasks, user])
// Scope tasks based on viewScope
const scopeTasks = viewScope === 'mine' ? myTasks : tasks
// KPIs dynamically calculated based on active scope
const stats = useMemo(() => { const stats = useMemo(() => {
const inProgress = tasks.filter((t) => t.status === 'IN_PROGRESS').length const inProgress = scopeTasks.filter((t) => t.status === 'IN_PROGRESS').length
const completed = tasks.filter((t) => t.status === 'COMPLETED').length const completed = scopeTasks.filter((t) => t.status === 'COMPLETED').length
const notStarted = tasks.filter((t) => t.status === 'NOT_STARTED').length const notStarted = scopeTasks.filter((t) => t.status === 'NOT_STARTED').length
const totalSeconds = tasks.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0) const totalSeconds = scopeTasks.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0)
const uniqueProjectIds = new Set(scopeTasks.map((t) => t.project?.id).filter(Boolean))
const projectsCount = viewScope === 'mine' ? uniqueProjectIds.size : projects.length
return { return {
inProgress, inProgress,
completed, completed,
notStarted, notStarted,
totalSeconds, totalSeconds,
projectsCount: projects.length, projectsCount,
} }
}, [tasks, projects]) }, [scopeTasks, projects, viewScope])
// Work Goal Progress in Current Period // Work Goal Progress in Current Period
const goalProgress = useMemo(() => { const goalProgress = useMemo(() => {
@ -124,7 +176,7 @@ export const DashboardPage: React.FC = () => {
const workedSeconds = calculateUserPeriodWorkedSeconds( const workedSeconds = calculateUserPeriodWorkedSeconds(
tasks, tasks,
workGoal?.period || 'DAILY', workGoal?.period || 'DAILY',
activeTask?.id, currentActiveTask?.id,
activeSeconds activeSeconds
) )
const percent = Math.min(100, Math.round((workedSeconds / targetSeconds) * 100)) const percent = Math.min(100, Math.round((workedSeconds / targetSeconds) * 100))
@ -152,17 +204,17 @@ export const DashboardPage: React.FC = () => {
targetHours: workGoal?.targetHours || 8, targetHours: workGoal?.targetHours || 8,
isCompleted: workedSeconds >= targetSeconds, isCompleted: workedSeconds >= targetSeconds,
} }
}, [tasks, workGoal, activeTask?.id, activeSeconds]) }, [tasks, workGoal, currentActiveTask?.id, activeSeconds])
// Recent 5 tasks // Recent 5 tasks based on active scope
const recentTasks = useMemo(() => { const recentTasks = useMemo(() => {
return [...tasks].slice(0, 5) return [...scopeTasks].slice(0, 5)
}, [tasks]) }, [scopeTasks])
// Toggle timer on a task // Toggle timer on a task
const handleToggleTimer = async (task: Task) => { const handleToggleTimer = async (task: Task) => {
try { try {
if (activeTask?.id === task.id) { if (currentActiveTask?.id === task.id) {
const stopped = await stopTimer(task) const stopped = await stopTimer(task)
if (stopped) { if (stopped) {
setTasks((prev) => prev.map((t) => (t.id === stopped.id ? stopped : t))) setTasks((prev) => prev.map((t) => (t.id === stopped.id ? stopped : t)))
@ -201,10 +253,19 @@ export const DashboardPage: React.FC = () => {
setTasks((prev) => [created, ...prev]) setTasks((prev) => [created, ...prev])
} }
// Priority task to focus on if none running // Priority task to focus on if none running (only user's own tasks)
const suggestedTask = useMemo(() => { const suggestedTask = useMemo(() => {
return tasks.find((t) => t.status === 'IN_PROGRESS') || tasks.find((t) => t.status === 'NOT_STARTED') || tasks[0] const myTasks = tasks.filter((t) => {
}, [tasks]) if (!user?.email || !t.user?.email) return false
return t.user.email.toLowerCase() === user.email.toLowerCase()
})
return (
myTasks.find((t) => t.status === 'IN_PROGRESS') ||
myTasks.find((t) => t.status === 'NOT_STARTED') ||
myTasks[0] ||
null
)
}, [tasks, user])
const firstName = user?.name ? user.name.split(' ')[0] : 'Desenvolvedor' const firstName = user?.name ? user.name.split(' ')[0] : 'Desenvolvedor'
@ -288,6 +349,64 @@ export const DashboardPage: React.FC = () => {
</div> </div>
)} )}
{/* Scope Selector: Individual (Minhas Tarefas) vs Equipe (Workspace) */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-3 sm:p-4 rounded-3xl bg-surface/90 border border-border-soft backdrop-blur-md shadow-panel">
<div className="flex items-center gap-3">
<span className="text-xs font-bold text-muted uppercase tracking-wider hidden sm:inline">
Modo de Visualização:
</span>
<div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft shadow-inner">
<button
type="button"
onClick={() => handleScopeChange('mine')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
viewScope === 'mine'
? 'bg-brand text-white shadow-md shadow-brand/25'
: 'text-muted hover:text-foreground'
}`}
>
<User className="w-3.5 h-3.5" />
<span>Individual (Minhas Tarefas)</span>
<span
className={`px-1.5 py-0.2 rounded-full text-[10px] font-bold ${
viewScope === 'mine' ? 'bg-white/20 text-white' : 'bg-surface text-muted'
}`}
>
{myTasks.length}
</span>
</button>
<button
type="button"
onClick={() => handleScopeChange('team')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
viewScope === 'team'
? 'bg-brand text-white shadow-md shadow-brand/25'
: 'text-muted hover:text-foreground'
}`}
>
<Users className="w-3.5 h-3.5" />
<span>Equipe (Workspace Completo)</span>
<span
className={`px-1.5 py-0.2 rounded-full text-[10px] font-bold ${
viewScope === 'team' ? 'bg-white/20 text-white' : 'bg-surface text-muted'
}`}
>
{tasks.length}
</span>
</button>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted font-medium">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
<span className="text-[11px] truncate">
{viewScope === 'mine'
? 'KPIs e métricas filtrados estritamente para demandas atribuídas a você'
: 'KPIs e métricas consolidados de todos os integrantes do workspace'}
</span>
</div>
</div>
{/* 4 Premium KPI Cards with shadow-panel & backdrop-blur */} {/* 4 Premium KPI Cards with shadow-panel & backdrop-blur */}
{loading ? ( {loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
@ -303,17 +422,28 @@ export const DashboardPage: React.FC = () => {
{/* In Progress */} {/* In Progress */}
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between"> <div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-muted">Tarefas em Andamento</span> <div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-muted">
{viewScope === 'mine' ? 'Suas Tarefas em Andamento' : 'Tarefas da Equipe em Andamento'}
</span>
</div>
<div className="w-9 h-9 rounded-xl bg-teal-500/15 border border-teal-500/30 flex items-center justify-center text-teal-700 dark:text-teal-400 shadow-xs"> <div className="w-9 h-9 rounded-xl bg-teal-500/15 border border-teal-500/30 flex items-center justify-center text-teal-700 dark:text-teal-400 shadow-xs">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
</div> </div>
</div> </div>
<div> <div>
<div className="flex items-baseline justify-between">
<p className="text-3xl font-extrabold text-teal-700 dark:text-teal-400 tracking-tight font-mono"> <p className="text-3xl font-extrabold text-teal-700 dark:text-teal-400 tracking-tight font-mono">
{stats.inProgress} {stats.inProgress}
</p> </p>
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
{viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'}
</span>
</div>
<p className="text-[11px] text-muted font-medium mt-1"> <p className="text-[11px] text-muted font-medium mt-1">
{stats.notStarted} tarefas aguardando no backlog {viewScope === 'mine'
? `${stats.notStarted} na sua fila aguardando`
: `${stats.notStarted} tarefas aguardando no backlog`}
</p> </p>
</div> </div>
</div> </div>
@ -321,17 +451,26 @@ export const DashboardPage: React.FC = () => {
{/* Completed */} {/* Completed */}
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between"> <div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-muted">Entregas Concluídas</span> <span className="text-xs font-bold text-muted">
{viewScope === 'mine' ? 'Suas Entregas Concluídas' : 'Entregas Concluídas da Equipe'}
</span>
<div className="w-9 h-9 rounded-xl bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-700 dark:text-emerald-400 shadow-xs"> <div className="w-9 h-9 rounded-xl bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-700 dark:text-emerald-400 shadow-xs">
<CheckCircle2 className="w-4 h-4" /> <CheckCircle2 className="w-4 h-4" />
</div> </div>
</div> </div>
<div> <div>
<div className="flex items-baseline justify-between">
<p className="text-3xl font-extrabold text-emerald-700 dark:text-emerald-400 tracking-tight font-mono"> <p className="text-3xl font-extrabold text-emerald-700 dark:text-emerald-400 tracking-tight font-mono">
{stats.completed} {stats.completed}
</p> </p>
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
{viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'}
</span>
</div>
<p className="text-[11px] text-muted font-medium mt-1"> <p className="text-[11px] text-muted font-medium mt-1">
de {tasks.length} demandas cadastradas {viewScope === 'mine'
? `de ${myTasks.length} demandas suas`
: `de ${tasks.length} demandas cadastradas`}
</p> </p>
</div> </div>
</div> </div>
@ -339,17 +478,26 @@ export const DashboardPage: React.FC = () => {
{/* Time Tracked */} {/* Time Tracked */}
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between"> <div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-muted">Tempo Total Dedicado</span> <span className="text-xs font-bold text-muted">
{viewScope === 'mine' ? 'Seu Tempo Dedicado' : 'Tempo Total da Equipe'}
</span>
<div className="w-9 h-9 rounded-xl bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-400 shadow-xs"> <div className="w-9 h-9 rounded-xl bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-400 shadow-xs">
<TrendingUp className="w-4 h-4" /> <TrendingUp className="w-4 h-4" />
</div> </div>
</div> </div>
<div> <div>
<div className="flex items-baseline justify-between">
<p className="text-3xl font-extrabold text-amber-400 tracking-tight font-mono"> <p className="text-3xl font-extrabold text-amber-400 tracking-tight font-mono">
{formatSecondsToTimer(stats.totalSeconds)} {formatSecondsToTimer(stats.totalSeconds)}
</p> </p>
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
{viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'}
</span>
</div>
<p className="text-[11px] text-muted font-medium mt-1"> <p className="text-[11px] text-muted font-medium mt-1">
{formatSecondsToHuman(stats.totalSeconds)} auditadas no time {viewScope === 'mine'
? `${formatSecondsToHuman(stats.totalSeconds)} auditadas por você`
: `${formatSecondsToHuman(stats.totalSeconds)} auditadas no time`}
</p> </p>
</div> </div>
</div> </div>
@ -357,17 +505,26 @@ export const DashboardPage: React.FC = () => {
{/* Projects */} {/* Projects */}
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between"> <div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold text-muted">Projetos Ativos</span> <span className="text-xs font-bold text-muted">
{viewScope === 'mine' ? 'Seus Projetos Ativos' : 'Projetos da Organização'}
</span>
<div className="w-9 h-9 rounded-xl bg-brand-soft text-brand border border-brand/25 flex items-center justify-center shadow-xs"> <div className="w-9 h-9 rounded-xl bg-brand-soft text-brand border border-brand/25 flex items-center justify-center shadow-xs">
<FolderKanban className="w-4 h-4 text-brand" /> <FolderKanban className="w-4 h-4 text-brand" />
</div> </div>
</div> </div>
<div> <div>
<div className="flex items-baseline justify-between">
<p className="text-3xl font-extrabold text-foreground tracking-tight font-mono"> <p className="text-3xl font-extrabold text-foreground tracking-tight font-mono">
{stats.projectsCount} {stats.projectsCount}
</p> </p>
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
{viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'}
</span>
</div>
<p className="text-[11px] text-muted font-medium mt-1"> <p className="text-[11px] text-muted font-medium mt-1">
Iniciativas ativas no workspace {viewScope === 'mine'
? 'Iniciativas com tarefas suas'
: 'Iniciativas ativas no workspace'}
</p> </p>
</div> </div>
</div> </div>
@ -394,43 +551,43 @@ export const DashboardPage: React.FC = () => {
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span <span
className={`inline-block w-2 h-2 rounded-full ${ className={`inline-block w-2 h-2 rounded-full ${
activeTask ? 'bg-emerald-500 animate-ping' : 'bg-zinc-400' currentActiveTask ? 'bg-emerald-500 animate-ping' : 'bg-zinc-400'
}`} }`}
/> />
<span <span
className={`text-xs font-bold ${ className={`text-xs font-bold ${
activeTask ? 'text-teal-700 dark:text-teal-400' : 'text-muted' currentActiveTask ? 'text-teal-700 dark:text-teal-400' : 'text-muted'
}`} }`}
> >
{activeTask ? 'CRONOMETRANDO' : 'EM ESPERA'} {currentActiveTask ? 'CRONOMETRANDO' : 'EM ESPERA'}
</span> </span>
</div> </div>
</div> </div>
{/* Active or Suggested Task Info Box */} {/* Active or Suggested Task Info Box */}
{activeTask ? ( {currentActiveTask ? (
<div className="p-4 rounded-2xl bg-surface-raised border border-border-soft space-y-2 shadow-xs"> <div className="p-4 rounded-2xl bg-surface-raised border border-border-soft space-y-2 shadow-xs">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{activeTask.project && ( {currentActiveTask.project && (
<span <span
className="px-2 py-0.5 rounded-md text-[10px] font-semibold border" className="px-2 py-0.5 rounded-md text-[10px] font-semibold border"
style={{ style={{
backgroundColor: activeTask.project.color ? `${activeTask.project.color}15` : 'rgba(15,118,110,0.1)', backgroundColor: currentActiveTask.project.color ? `${currentActiveTask.project.color}15` : 'rgba(15,118,110,0.1)',
color: activeTask.project.color || '#0f766e', color: currentActiveTask.project.color || '#0f766e',
borderColor: activeTask.project.color ? `${activeTask.project.color}35` : 'rgba(15,118,110,0.25)', borderColor: currentActiveTask.project.color ? `${currentActiveTask.project.color}35` : 'rgba(15,118,110,0.25)',
}} }}
> >
{activeTask.project.name} {currentActiveTask.project.name}
</span> </span>
)} )}
{activeTask.category && ( {currentActiveTask.category && (
<span className="px-2 py-0.5 rounded-md text-[10px] font-semibold bg-accent-soft text-accent border border-accent/25"> <span className="px-2 py-0.5 rounded-md text-[10px] font-semibold bg-accent-soft text-accent border border-accent/25">
{activeTask.category.name} {currentActiveTask.category.name}
</span> </span>
)} )}
</div> </div>
<h3 className="text-sm font-bold text-foreground line-clamp-2 leading-snug"> <h3 className="text-sm font-bold text-foreground line-clamp-2 leading-snug">
{activeTask.title} {currentActiveTask.title}
</h3> </h3>
</div> </div>
) : suggestedTask ? ( ) : suggestedTask ? (
@ -458,19 +615,19 @@ export const DashboardPage: React.FC = () => {
{/* Big Bold Timer Display */} {/* Big Bold Timer Display */}
<div className="text-center py-5 rounded-2xl bg-surface border border-border-soft shadow-xs"> <div className="text-center py-5 rounded-2xl bg-surface border border-border-soft shadow-xs">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted"> <span className="text-[11px] font-bold uppercase tracking-wider text-muted">
{activeTask ? 'Tempo Decorrido na Tarefa' : 'Pronto para Iniciar'} {currentActiveTask ? 'Tempo Decorrido na Tarefa' : 'Pronto para Iniciar'}
</span> </span>
<p className="text-4xl sm:text-5xl font-extrabold font-mono text-foreground mt-1.5 tracking-tight"> <p className="text-4xl sm:text-5xl font-extrabold font-mono text-foreground mt-1.5 tracking-tight">
{activeTask ? formatSecondsToTimer(activeSeconds) : '00:00:00'} {currentActiveTask ? formatSecondsToTimer(activeSeconds) : '00:00:00'}
</p> </p>
</div> </div>
{/* Timer Actions */} {/* Timer Actions */}
{activeTask ? ( {currentActiveTask ? (
<button <button
type="button" type="button"
onClick={() => stopTimer(activeTask)} onClick={() => stopTimer(currentActiveTask)}
className="w-full py-3.5 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 hover:bg-rose-500/25 text-xs font-bold transition-all flex items-center justify-center gap-2 shadow-xs" className="w-full py-3.5 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 hover:bg-rose-500/25 text-xs font-bold transition-all flex items-center justify-center gap-2 shadow-xs cursor-pointer"
> >
<Square className="w-4 h-4 fill-rose-300" /> <Square className="w-4 h-4 fill-rose-300" />
<span>Pausar Cronômetro</span> <span>Pausar Cronômetro</span>
@ -607,7 +764,7 @@ export const DashboardPage: React.FC = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground flex items-center gap-2"> <h2 className="text-lg font-bold text-foreground flex items-center gap-2">
<Zap className="w-4 h-4 text-accent" /> <Zap className="w-4 h-4 text-accent" />
Tarefas Recentes da Equipe {viewScope === 'mine' ? 'Minhas Demandas Recentes' : 'Demandas Recentes da Equipe'}
</h2> </h2>
<Link <Link
to="/tasks" to="/tasks"
@ -631,7 +788,9 @@ export const DashboardPage: React.FC = () => {
<div className="text-center py-20 rounded-3xl border border-dashed border-border-soft bg-surface/60 backdrop-blur-md"> <div className="text-center py-20 rounded-3xl border border-dashed border-border-soft bg-surface/60 backdrop-blur-md">
<Activity className="w-8 h-8 text-muted mx-auto mb-2" /> <Activity className="w-8 h-8 text-muted mx-auto mb-2" />
<p className="text-xs text-muted mb-4 font-medium"> <p className="text-xs text-muted mb-4 font-medium">
Nenhuma tarefa encontrada neste workspace. {viewScope === 'mine'
? 'Você não possui nenhuma demanda atribuída no momento.'
: 'Nenhuma tarefa encontrada neste workspace.'}
</p> </p>
<button <button
type="button" type="button"
@ -645,7 +804,7 @@ export const DashboardPage: React.FC = () => {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{recentTasks.map((task) => { {recentTasks.map((task) => {
const isTracking = activeTask?.id === task.id const isTracking = currentActiveTask?.id === task.id
const taskSec = isTracking ? activeSeconds : calculateTaskTotalSeconds(task) const taskSec = isTracking ? activeSeconds : calculateTaskTotalSeconds(task)
return ( return (
@ -659,6 +818,34 @@ export const DashboardPage: React.FC = () => {
> >
<div className="space-y-1.5 flex-1 min-w-0"> <div className="space-y-1.5 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
{/* Assignee Indicator */}
{task.user?.name ? (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-semibold border ${
user?.email &&
task.user.email &&
task.user.email.toLowerCase() === user.email.toLowerCase()
? 'bg-brand/10 text-brand border-brand/20'
: 'bg-surface-sunken text-muted border-border-soft'
}`}
title={`Responsável: ${task.user.name} (${task.user.email || ''})`}
>
<User className="w-3 h-3" />
<span className="truncate max-w-[110px]">
{user?.email &&
task.user.email &&
task.user.email.toLowerCase() === user.email.toLowerCase()
? 'Você'
: task.user.name.split(' ')[0]}
</span>
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium bg-surface-sunken text-muted/60 border border-border-soft">
<User className="w-3 h-3" />
<span>Sem responsável</span>
</span>
)}
{task.project && ( {task.project && (
<span <span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-semibold border" className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-semibold border"

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { import {
Plus, Plus,
RefreshCw, RefreshCw,
@ -9,6 +9,8 @@ import {
LayoutDashboard, LayoutDashboard,
AlertCircle, AlertCircle,
Check, Check,
User,
Users,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { useTimer } from '../contexts/TimerContext' import { useTimer } from '../contexts/TimerContext'
@ -51,6 +53,29 @@ export const DashboardV2Page: React.FC = () => {
const [editingCard, setEditingCard] = useState<DashboardV2Card | null>(null) const [editingCard, setEditingCard] = useState<DashboardV2Card | null>(null)
const [saveToast, setSaveToast] = useState(false) const [saveToast, setSaveToast] = useState(false)
// Scope Toggle State (Individual vs Equipe)
const [searchParams, setSearchParams] = useSearchParams()
const initialScope =
(searchParams.get('scope') as 'mine' | 'team') ||
(localStorage.getItem('beetask_bi_scope') as 'mine' | 'team') ||
'team'
const [viewScope, setViewScope] = useState<'mine' | 'team'>(initialScope)
const handleScopeChange = (newScope: 'mine' | 'team') => {
setViewScope(newScope)
try {
localStorage.setItem('beetask_bi_scope', newScope)
} catch (_) {}
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
next.set('scope', newScope)
return next
},
{ replace: true }
)
}
const activeOrgId = activeOrg?.id const activeOrgId = activeOrg?.id
// Load backend data // Load backend data
@ -341,6 +366,50 @@ export const DashboardV2Page: React.FC = () => {
</div> </div>
)} )}
{/* Scope Selector: Individual vs Equipe */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-3 sm:p-4 rounded-2xl bg-surface/90 border border-border-soft backdrop-blur-md shadow-panel">
<div className="flex items-center gap-3">
<span className="text-xs font-bold text-muted uppercase tracking-wider hidden sm:inline">
Escopo do Painel:
</span>
<div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft shadow-inner">
<button
type="button"
onClick={() => handleScopeChange('mine')}
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all ${
viewScope === 'mine'
? 'bg-brand text-white shadow-md shadow-brand/25'
: 'text-muted hover:text-foreground'
}`}
>
<User className="w-3.5 h-3.5" />
<span>Minha Visão (Individual)</span>
</button>
<button
type="button"
onClick={() => handleScopeChange('team')}
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all ${
viewScope === 'team'
? 'bg-brand text-white shadow-md shadow-brand/25'
: 'text-muted hover:text-foreground'
}`}
>
<Users className="w-3.5 h-3.5" />
<span>Visão da Equipe (Workspace)</span>
</button>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted font-medium">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
<span className="text-[11px] truncate">
{viewScope === 'mine'
? 'Exibindo cards com dados das suas tarefas atribuídas'
: 'Exibindo cards com métricas consolidadas de todo o workspace'}
</span>
</div>
</div>
{/* Uniform Modular Grid */} {/* Uniform Modular Grid */}
{loading ? ( {loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@ -393,6 +462,7 @@ export const DashboardV2Page: React.FC = () => {
activeTask={activeTask} activeTask={activeTask}
activeSeconds={activeSeconds} activeSeconds={activeSeconds}
workGoal={workGoal} workGoal={workGoal}
globalScope={viewScope}
isBeingDragged={draggedCardId === card.id} isBeingDragged={draggedCardId === card.id}
isDragOver={dragOverCardId === card.id} isDragOver={dragOverCardId === card.id}
onToggleTimer={handleToggleTimer} onToggleTimer={handleToggleTimer}

View file

@ -1,38 +1,71 @@
import { useState, type FormEvent } from 'react' import { useState, useEffect, useCallback, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
Chip,
ChipLabel,
Alert,
AlertTitle,
AlertDescription,
} from '@heroui/react'
import { import {
Building2, Building2,
Plus, Plus,
ArrowRight, ArrowRight,
Users, Users,
ShieldCheck, ShieldCheck,
Search,
Clock,
CheckCircle2,
XCircle,
Send,
Check,
Loader2,
Inbox,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { organizationService } from '../services/organizationService' import {
organizationService,
type SearchOrgOutput,
type JoinRequestOutput,
} from '../services/organizationService'
export const OrganizationsPage = () => { export const OrganizationsPage = () => {
const { user, activeOrg, setActiveOrg, refreshUser } = useAuth() const { user, activeOrg, setActiveOrg, refreshUser } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<'workspaces' | 'join' | 'create'>(
user?.organizations && user.organizations.length > 0 ? 'workspaces' : 'join'
)
// Create Org states
const [newOrgName, setNewOrgName] = useState('') const [newOrgName, setNewOrgName] = useState('')
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
// Search & Join states
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<SearchOrgOutput[]>([])
const [isSearching, setIsSearching] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
const [joiningOrgId, setJoiningOrgId] = useState<number | null>(null)
// My join requests
const [myRequests, setMyRequests] = useState<JoinRequestOutput[]>([])
const [loadingRequests, setLoadingRequests] = useState(false)
// Feedback notifications
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null) const [successMessage, setSuccessMessage] = useState<string | null>(null)
// Load user's join requests
const loadMyRequests = useCallback(async () => {
setLoadingRequests(true)
try {
const reqs = await organizationService.listMyRequests()
setMyRequests(reqs || [])
} catch (err) {
console.error('Error fetching join requests', err)
} finally {
setLoadingRequests(false)
}
}, [])
useEffect(() => {
loadMyRequests()
}, [loadMyRequests])
const handleCreateOrg = async (e: FormEvent) => { const handleCreateOrg = async (e: FormEvent) => {
e.preventDefault() e.preventDefault()
if (!newOrgName.trim()) return if (!newOrgName.trim()) return
@ -63,180 +96,574 @@ export const OrganizationsPage = () => {
navigate('/dashboard') navigate('/dashboard')
} }
const handleSearch = async (e?: FormEvent) => {
if (e) e.preventDefault()
setIsSearching(true)
setHasSearched(true)
setErrorMessage(null)
try {
const results = await organizationService.search(searchQuery.trim())
setSearchResults(results || [])
} catch (err: unknown) {
console.error(err)
setErrorMessage('Falha ao buscar organizações.')
} finally {
setIsSearching(false)
}
}
const handleRequestJoin = async (orgId: number, orgName: string) => {
setJoiningOrgId(orgId)
setErrorMessage(null)
setSuccessMessage(null)
try {
await organizationService.requestJoin(orgId)
setSuccessMessage(`Solicitação enviada para "${orgName}" com sucesso! Aguarde a aprovação do proprietário.`)
await loadMyRequests()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Falha ao solicitar entrada.'
setErrorMessage(msg)
} finally {
setJoiningOrgId(null)
}
}
const handleEnterApprovedOrg = async (orgId: number) => {
try {
await refreshUser()
setActiveOrg(orgId)
navigate('/dashboard')
} catch (err) {
console.error(err)
}
}
const pendingRequestsCount = myRequests.filter((r) => r.status === 'PENDING').length
return ( return (
<div className="max-w-4xl mx-auto py-8 space-y-8 animate-in fade-in duration-200"> <div className="max-w-4xl mx-auto py-4 sm:py-6 space-y-6 animate-in fade-in duration-200">
{/* Page Header */} {/* Page Header */}
<div className="text-center sm:text-left space-y-2"> <div className="text-center sm:text-left space-y-2">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-soft text-brand text-xs font-bold"> <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand/10 border border-brand/20 text-brand text-xs font-bold">
<Building2 className="w-3.5 h-3.5" /> <Building2 className="w-3.5 h-3.5" />
<span>Gestão de Workspaces Corporativos</span> <span>Gestão de Workspaces & Organizações</span>
</div> </div>
<h1 className="text-3xl font-extrabold text-text-main tracking-tight"> <h1 className="text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight">
Seus Workspaces & Organizações Workspaces Corporativos
</h1> </h1>
<p className="text-sm text-text-muted max-w-2xl"> <p className="text-xs sm:text-sm text-muted max-w-2xl">
Selecione uma organização ativa para acessar projetos, tarefas e métricas da equipe, Navegue entre seus workspaces ativos, solicite entrada em uma organização existente ou
ou crie um novo workspace dedicado para sua empresa ou squad. crie um novo ambiente dedicado para seu squad ou empresa.
</p> </p>
</div> </div>
{/* Notifications */} {/* Notifications */}
{errorMessage && ( {errorMessage && (
<Alert className="bg-danger-soft border border-danger/30 text-danger rounded-2xl p-4 text-xs flex items-start gap-2"> <div className="p-4 rounded-2xl bg-rose-500/10 border border-rose-500/25 text-rose-400 text-xs flex items-start gap-3 animate-in fade-in">
<XCircle className="w-4 h-4 shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
<AlertTitle className="font-bold">Não foi possível criar</AlertTitle> <p className="font-bold">Ocorreu um erro</p>
<AlertDescription className="text-xs mt-0.5">{errorMessage}</AlertDescription> <p className="mt-0.5 text-rose-300/90">{errorMessage}</p>
</div>
<button
type="button"
onClick={() => setErrorMessage(null)}
className="text-muted hover:text-foreground text-xs"
>
✕
</button>
</div> </div>
</Alert>
)} )}
{successMessage && ( {successMessage && (
<Alert className="bg-success-soft border border-success/30 text-success rounded-2xl p-4 text-xs flex items-start gap-2"> <div className="p-4 rounded-2xl bg-emerald-500/10 border border-emerald-500/25 text-emerald-400 text-xs flex items-start gap-3 animate-in fade-in">
<CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
<AlertTitle className="font-bold">Sucesso</AlertTitle> <p className="font-bold">Sucesso</p>
<AlertDescription className="text-xs mt-0.5">{successMessage}</AlertDescription> <p className="mt-0.5 text-emerald-300/90">{successMessage}</p>
</div>
<button
type="button"
onClick={() => setSuccessMessage(null)}
className="text-muted hover:text-foreground text-xs"
>
✕
</button>
</div> </div>
</Alert>
)} )}
<div className="grid grid-cols-1 md:grid-cols-12 gap-8"> {/* Tabs Navigation */}
{/* Existing Organizations (7 cols) */} <div className="flex items-center gap-2 border-b border-border-soft pb-2 overflow-x-auto">
<div className="md:col-span-7 space-y-4"> <button
<div className="flex items-center justify-between"> type="button"
<h2 className="text-base font-bold text-text-main flex items-center gap-2"> onClick={() => setActiveTab('workspaces')}
<Users className="w-4 h-4 text-brand" /> className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap cursor-pointer ${
activeTab === 'workspaces'
? 'bg-brand text-white shadow-md shadow-brand/20'
: 'text-muted hover:text-foreground hover:bg-surface-raised'
}`}
>
<Users className="w-3.5 h-3.5" />
<span>Meus Workspaces</span>
<span
className={`px-1.5 py-0.2 rounded-full text-[10px] ${
activeTab === 'workspaces'
? 'bg-white/20 text-white font-bold'
: 'bg-surface-sunken text-muted'
}`}
>
{user?.organizations?.length || 0}
</span>
</button>
<button
type="button"
onClick={() => {
setActiveTab('join')
if (!hasSearched) handleSearch()
}}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap cursor-pointer ${
activeTab === 'join'
? 'bg-brand text-white shadow-md shadow-brand/20'
: 'text-muted hover:text-foreground hover:bg-surface-raised'
}`}
>
<Search className="w-3.5 h-3.5" />
<span>Solicitar Entrada</span>
{pendingRequestsCount > 0 && (
<span className="px-1.5 py-0.2 rounded-full bg-amber-500/20 text-amber-300 text-[10px] font-bold">
{pendingRequestsCount} pendente{pendingRequestsCount > 1 ? 's' : ''}
</span>
)}
</button>
<button
type="button"
onClick={() => setActiveTab('create')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap cursor-pointer ${
activeTab === 'create'
? 'bg-brand text-white shadow-md shadow-brand/20'
: 'text-muted hover:text-foreground hover:bg-surface-raised'
}`}
>
<Plus className="w-3.5 h-3.5" />
<span>Criar Workspace</span>
</button>
</div>
{/* TAB 1: WORKSPACES VINCULADOS */}
{activeTab === 'workspaces' && (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
<Building2 className="w-4 h-4 text-brand" />
Workspaces Vinculados ({user?.organizations?.length || 0}) Workspaces Vinculados ({user?.organizations?.length || 0})
</h2> </h2>
<p className="text-xs text-muted mt-0.5">
Organizações das quais você já é membro ou proprietário.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
setActiveTab('join')
if (!hasSearched) handleSearch()
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-border-soft bg-surface-raised text-xs font-semibold text-foreground hover:border-brand/40 hover:text-brand transition-colors cursor-pointer"
>
<Search className="w-3.5 h-3.5" />
<span>Entrar em Outra</span>
</button>
<button
type="button"
onClick={() => setActiveTab('create')}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-accent text-white text-xs font-bold hover:bg-accent-hover shadow-xs transition-colors cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>Novo Workspace</span>
</button>
</div>
</div> </div>
{user?.organizations && user.organizations.length > 0 ? ( {user?.organizations && user.organizations.length > 0 ? (
<div className="space-y-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3.5">
{user.organizations.map((org) => { {user.organizations.map((org) => {
const isActive = org.id === activeOrg?.id const isActive = org.id === activeOrg?.id
return ( return (
<Card <div
key={org.id} key={org.id}
className={`bg-surface border rounded-2xl p-4.5 transition-all flex items-center justify-between gap-4 cursor-pointer hover:shadow-panel ${
isActive
? 'border-brand ring-2 ring-brand/10'
: 'border-border-soft hover:border-brand/40'
}`}
onClick={() => handleSelectOrg(org.id)} onClick={() => handleSelectOrg(org.id)}
className={`rounded-2xl border p-4 transition-all duration-200 cursor-pointer flex items-center justify-between gap-3 shadow-panel ${
isActive
? 'border-brand/60 bg-brand/5 ring-1 ring-brand/30 shadow-md'
: 'border-border-soft bg-surface/90 backdrop-blur-md hover:border-brand/40 hover:shadow-lg'
}`}
> >
<div className="flex items-center gap-3.5 min-w-0"> <div className="flex items-center gap-3.5 min-w-0">
<div <div
className={`w-11 h-11 rounded-xl flex items-center justify-center font-bold text-sm shrink-0 shadow-xs ${ className={`w-11 h-11 rounded-xl flex items-center justify-center font-bold text-sm shrink-0 shadow-xs ${
isActive isActive
? 'bg-brand text-white shadow-brand/20' ? 'bg-brand text-white shadow-brand/20'
: 'bg-surface-muted text-text-muted border border-border-soft' : 'bg-surface-sunken text-muted border border-border-soft'
}`} }`}
> >
{org.name.slice(0, 2).toUpperCase()} {org.name.slice(0, 2).toUpperCase()}
</div> </div>
<div className="truncate"> <div className="truncate">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-sm font-bold text-text-main truncate"> <h3 className="text-sm font-bold text-foreground truncate">
{org.name} {org.name}
</h3> </h3>
{isActive && ( {isActive && (
<Chip className="bg-brand-soft text-brand text-[10px] font-bold px-2 py-0.2"> <span className="px-2 py-0.5 rounded-full bg-brand/15 text-brand text-[10px] font-extrabold tracking-wide uppercase border border-brand/30">
<ChipLabel>ATIVO</ChipLabel> Ativo
</Chip> </span>
)} )}
</div> </div>
<p className="text-xs text-text-muted mt-0.5 font-medium"> <p className="text-xs text-muted mt-0.5 font-medium">
Papel: <span className="text-brand font-semibold">{org.roles.join(', ')}</span> Papel:{' '}
<span className="text-brand font-semibold">
{org.roles.join(', ')}
</span>
</p> </p>
</div> </div>
</div> </div>
<Button <button
type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleSelectOrg(org.id) handleSelectOrg(org.id)
}} }}
className={`text-xs font-bold px-3 py-1.5 rounded-xl shrink-0 ${ className={`text-xs font-bold px-3 py-1.5 rounded-xl shrink-0 transition-colors cursor-pointer ${
isActive isActive
? 'bg-brand text-white shadow-sm' ? 'bg-brand text-white shadow-xs'
: 'bg-surface-muted text-text-muted hover:text-text-main hover:bg-border-soft' : 'bg-surface-raised border border-border-soft text-muted hover:text-foreground hover:bg-surface-sunken'
}`} }`}
> >
{isActive ? 'Acessar' : 'Selecionar'} {isActive ? 'Acessar' : 'Selecionar'}
</Button> </button>
</Card> </div>
) )
})} })}
</div> </div>
) : ( ) : (
<Card className="bg-surface border border-border-soft rounded-2xl p-8 text-center space-y-3"> <div className="rounded-3xl border border-dashed border-border-soft bg-surface/60 backdrop-blur-md p-8 text-center space-y-3">
<div className="w-12 h-12 rounded-2xl bg-amber-50 text-amber-600 mx-auto flex items-center justify-center"> <div className="w-12 h-12 rounded-2xl bg-amber-500/15 text-amber-500 mx-auto flex items-center justify-center">
<Building2 className="w-6 h-6" /> <Building2 className="w-6 h-6" />
</div> </div>
<h3 className="text-base font-bold text-text-main"> <h3 className="text-base font-bold text-foreground">
Nenhum workspace encontrado Você ainda não está vinculado a nenhum workspace
</h3> </h3>
<p className="text-xs text-text-muted max-w-sm mx-auto"> <p className="text-xs text-muted max-w-sm mx-auto">
Você ainda não faz parte de nenhuma organização. Crie o seu primeiro workspace ao lado para começar! Você pode solicitar entrada em uma organização existente ou criar um novo workspace para sua equipe.
</p> </p>
</Card> <div className="pt-2 flex items-center justify-center gap-3">
<button
type="button"
onClick={() => {
setActiveTab('join')
handleSearch()
}}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-brand text-white text-xs font-bold hover:bg-brand-hover shadow-sm transition-all cursor-pointer"
>
<Search className="w-3.5 h-3.5" />
<span>Buscar e Entrar</span>
</button>
<button
type="button"
onClick={() => setActiveTab('create')}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border-soft bg-surface-raised text-xs font-bold text-foreground hover:border-brand/40 transition-all cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>Criar Novo</span>
</button>
</div>
</div>
)}
</div>
)}
{/* TAB 2: SOLICITAR ENTRADA EM UMA ORGANIZAÇÃO */}
{activeTab === 'join' && (
<div className="space-y-6">
{/* Search Box */}
<div className="p-5 sm:p-6 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel space-y-4">
<div>
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
<Search className="w-4 h-4 text-brand" />
Buscar Organização
</h2>
<p className="text-xs text-muted mt-0.5">
Digite o nome da empresa ou organização para solicitar participação como membro.
</p>
</div>
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Ex: BeeTech, Empresa Alpha, Squad..."
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand transition-all pl-9"
/>
<Search className="w-4 h-4 text-muted absolute left-3 top-1/2 -translate-y-1/2" />
</div>
<button
type="submit"
disabled={isSearching}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-brand text-white text-xs font-bold hover:bg-brand-hover transition-all shadow-xs cursor-pointer disabled:opacity-50"
>
{isSearching ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Search className="w-4 h-4" />
<span>Buscar</span>
</>
)}
</button>
</form>
{/* Search Results */}
{hasSearched && (
<div className="pt-2 space-y-2 border-t border-border-soft/60">
<div className="flex items-center justify-between text-xs text-muted font-medium">
<span>Resultados da busca:</span>
<span>{searchResults.length} encontrada{searchResults.length === 1 ? '' : 's'}</span>
</div>
{searchResults.length === 0 ? (
<div className="py-6 text-center text-xs text-muted border border-dashed border-border-soft rounded-2xl">
Nenhuma organização encontrada com o termo "{searchQuery}".
</div>
) : (
<div className="space-y-2 max-h-72 overflow-y-auto">
{searchResults.map((org) => {
const isAlreadyMember = user?.organizations?.some((o) => o.id === org.id)
const pendingReq = myRequests.find(
(r) => r.organizationId === org.id && r.status === 'PENDING'
)
const isJoining = joiningOrgId === org.id
return (
<div
key={org.id}
className="p-3.5 rounded-2xl border border-border-soft bg-surface-raised/70 flex items-center justify-between gap-3 hover:border-brand/30 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-xl bg-brand/15 text-brand flex items-center justify-center font-bold text-xs shrink-0 border border-brand/25">
{org.name.slice(0, 2).toUpperCase()}
</div>
<div className="truncate">
<h4 className="text-xs font-bold text-foreground truncate">
{org.name}
</h4>
<p className="text-[10px] text-muted">ID: #{org.id}</p>
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
{isAlreadyMember ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 text-[11px] font-bold">
<Check className="w-3 h-3" />
<span>Já é Membro</span>
</span>
) : pendingReq ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[11px] font-bold">
<Clock className="w-3 h-3 animate-pulse" />
<span>Pendente</span>
</span>
) : (
<button
type="button"
disabled={isJoining}
onClick={() => handleRequestJoin(org.id, org.name)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-brand/15 text-brand border border-brand/30 text-xs font-bold hover:bg-brand hover:text-white transition-all cursor-pointer disabled:opacity-50"
>
{isJoining ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<>
<Send className="w-3.5 h-3.5" />
<span>Solicitar Entrada</span>
</>
)}
</button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)} )}
</div> </div>
{/* Create New Workspace Form (5 cols) */} {/* My Join Requests Status Section */}
<div className="md:col-span-5 space-y-4"> <div className="p-5 sm:p-6 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel space-y-4">
<Card className="bg-surface border border-border-soft rounded-3xl p-6 shadow-panel space-y-5"> <div className="flex items-center justify-between">
<CardHeader className="p-0 space-y-1"> <div>
<div className="w-10 h-10 rounded-xl bg-accent-soft text-accent flex items-center justify-center mb-1"> <h2 className="text-base font-bold text-foreground flex items-center gap-2">
<Inbox className="w-4 h-4 text-accent" />
Minhas Solicitações de Entrada
</h2>
<p className="text-xs text-muted mt-0.5">
Acompanhe o status dos seus pedidos para ingressar em organizações.
</p>
</div>
<button
type="button"
onClick={loadMyRequests}
disabled={loadingRequests}
className="text-xs text-brand hover:underline font-semibold cursor-pointer disabled:opacity-50"
>
{loadingRequests ? 'Atualizando...' : 'Atualizar'}
</button>
</div>
{myRequests.length === 0 ? (
<div className="py-8 text-center text-xs text-muted border border-dashed border-border-soft rounded-2xl">
Você ainda não enviou nenhuma solicitação de entrada.
</div>
) : (
<div className="space-y-2.5">
{myRequests.map((req, idx) => {
const isPending = req.status === 'PENDING'
const isApproved = req.status === 'APPROVED'
const isRejected = req.status === 'REJECTED'
return (
<div
key={idx}
className="p-3.5 rounded-2xl border border-border-soft bg-surface-raised/50 flex flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<div className="flex items-center gap-3 min-w-0">
<div
className={`w-9 h-9 rounded-xl flex items-center justify-center font-bold text-xs shrink-0 ${
isApproved
? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30'
: isPending
? 'bg-amber-500/15 text-amber-300 border border-amber-500/30'
: 'bg-rose-500/15 text-rose-400 border border-rose-500/30'
}`}
>
<Building2 className="w-4 h-4" />
</div>
<div className="truncate">
<h4 className="text-xs font-bold text-foreground truncate">
{req.organizationName}
</h4>
<p className="text-[10px] text-muted">
Solicitado em:{' '}
{req.createdAt
? new Date(req.createdAt).toLocaleDateString('pt-BR')
: 'Recentemente'}
</p>
</div>
</div>
<div className="flex items-center gap-2.5 self-end sm:self-center">
{isPending && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[11px] font-bold">
<Clock className="w-3 h-3" />
<span>Pendente de Aprovação</span>
</span>
)}
{isApproved && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 text-[11px] font-bold">
<CheckCircle2 className="w-3 h-3" />
<span>Aprovada</span>
</span>
<button
type="button"
onClick={() => handleEnterApprovedOrg(req.organizationId)}
className="px-2.5 py-1 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-[11px] font-bold transition-colors cursor-pointer"
>
Entrar no Workspace
</button>
</div>
)}
{isRejected && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-rose-500/15 text-rose-400 border border-rose-500/30 text-[11px] font-bold">
<XCircle className="w-3 h-3" />
<span>Recusada pelo Dono</span>
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)}
{/* TAB 3: CRIAR NOVO WORKSPACE */}
{activeTab === 'create' && (
<div className="p-6 sm:p-8 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel space-y-6">
<div className="space-y-1">
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent flex items-center justify-center mb-2 border border-accent/25">
<Plus className="w-5 h-5 text-accent" /> <Plus className="w-5 h-5 text-accent" />
</div> </div>
<CardTitle className="text-base font-bold text-text-main"> <h2 className="text-lg font-bold text-foreground">
Criar Novo Workspace Criar Novo Workspace
</CardTitle> </h2>
<CardDescription className="text-xs text-text-muted"> <p className="text-xs text-muted">
Você será automaticamente configurado como <strong>OWNER</strong>. Você será automaticamente configurado como <strong>PROPRIETÁRIO (OWNER)</strong> deste workspace.
</CardDescription> </p>
</CardHeader> </div>
<CardContent className="p-0"> <form onSubmit={handleCreateOrg} className="space-y-4 max-w-lg">
<form onSubmit={handleCreateOrg} className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold text-text-main"> <label className="text-xs font-bold text-foreground">
Nome da Organização / Empresa Nome da Organização / Empresa
</label> </label>
<Input <input
type="text" type="text"
required required
value={newOrgName} value={newOrgName}
onChange={(e) => setNewOrgName(e.target.value)} onChange={(e) => setNewOrgName(e.target.value)}
placeholder="Ex: Minha Empresa Corp" placeholder="Ex: Minha Empresa Corp, Tech Labs..."
className="w-full px-3.5 py-2.5 bg-surface-muted/60 border border-border-soft rounded-xl text-xs text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent transition-all" className="w-full px-3.5 py-2.5 bg-surface border border-border-soft rounded-xl text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
/> />
</div> </div>
<Button <button
type="submit" type="submit"
isDisabled={isCreating || !newOrgName.trim()} disabled={isCreating || !newOrgName.trim()}
className="w-full bg-accent hover:bg-[#b45309] text-white font-bold text-xs py-3 rounded-xl shadow-md shadow-accent/20 transition-all flex items-center justify-center gap-2" className="w-full sm:w-auto px-6 py-2.5 bg-accent hover:bg-accent-hover text-white font-bold text-xs rounded-xl shadow-md shadow-accent/20 transition-all flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
> >
{isCreating ? ( {isCreating ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
) : ( ) : (
<> <>
Criar Organização <span>Criar Organização</span>
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</> </>
)} )}
</Button> </button>
</form> </form>
</CardContent>
<div className="pt-3 border-t border-border-soft flex items-center gap-2 text-[11px] text-text-muted font-medium"> <div className="pt-4 border-t border-border-soft flex items-center gap-2 text-[11px] text-muted font-medium">
<ShieldCheck className="w-3.5 h-3.5 text-accent" /> <ShieldCheck className="w-3.5 h-3.5 text-accent" />
<span>Multi-tenancy com isolamento de dados</span> <span>Multi-tenancy com isolamento seguro de projetos, tarefas e métricas da equipe</span>
</div>
</Card>
</div> </div>
</div> </div>
)}
</div> </div>
) )
} }

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
Mail, Mail,
@ -9,9 +9,15 @@ import {
CheckCircle2, CheckCircle2,
Save, Save,
Sparkles, Sparkles,
Camera,
Edit2,
AlertCircle,
X,
Check,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { taskService } from '../services/taskService' import { taskService } from '../services/taskService'
import { authService } from '../services/authService'
import type { Task } from '../types/task' import type { Task } from '../types/task'
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils' import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
import { CustomSelect } from '../components/ui/Select' import { CustomSelect } from '../components/ui/Select'
@ -23,17 +29,101 @@ import {
} from '../lib/userGoals' } from '../lib/userGoals'
export const ProfilePage: React.FC = () => { export const ProfilePage: React.FC = () => {
const { user, activeOrg, setActiveOrg, logout } = useAuth() const { user, activeOrg, setActiveOrg, logout, updateUser, refreshUser } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [myTasks, setMyTasks] = useState<Task[]>([]) const [myTasks, setMyTasks] = useState<Task[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Photo upload state
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadingPhoto, setUploadingPhoto] = useState(false)
const [photoSuccess, setPhotoSuccess] = useState(false)
const [photoError, setPhotoError] = useState<string | null>(null)
// Name edit state
const [editingName, setEditingName] = useState(false)
const [nameInput, setNameInput] = useState(user?.name || '')
const [savingName, setSavingName] = useState(false)
const [nameSuccess, setNameSuccess] = useState(false)
const [nameError, setNameError] = useState<string | null>(null)
useEffect(() => {
if (user?.name) {
setNameInput(user.name)
}
}, [user?.name])
// Workload & Goal State (Global) // Workload & Goal State (Global)
const [goalPeriod, setGoalPeriod] = useState<WorkGoalPeriod>('DAILY') const [goalPeriod, setGoalPeriod] = useState<WorkGoalPeriod>('DAILY')
const [goalHours, setGoalHours] = useState<number>(8) const [goalHours, setGoalHours] = useState<number>(8)
const [savedSuccess, setSavedSuccess] = useState(false) const [savedSuccess, setSavedSuccess] = useState(false)
const handlePhotoSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate size (5 MB)
if (file.size > 5 * 1024 * 1024) {
setPhotoError('A imagem deve ter no máximo 5 MB.')
setTimeout(() => setPhotoError(null), 5000)
if (fileInputRef.current) fileInputRef.current.value = ''
return
}
// Validate image mime type
if (!file.type.startsWith('image/')) {
setPhotoError('O arquivo selecionado deve ser uma imagem (JPG, PNG, WebP).')
setTimeout(() => setPhotoError(null), 5000)
if (fileInputRef.current) fileInputRef.current.value = ''
return
}
setUploadingPhoto(true)
setPhotoError(null)
try {
const res = await authService.uploadPhoto(file)
// Append timestamp query parameter to bust browser cache
const freshPhotoUrl = res.photoUrl.includes('?')
? `${res.photoUrl}&t=${Date.now()}`
: `${res.photoUrl}?t=${Date.now()}`
updateUser({ photo: freshPhotoUrl })
setPhotoSuccess(true)
setTimeout(() => setPhotoSuccess(false), 4000)
await refreshUser()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Falha ao atualizar a foto de perfil.'
setPhotoError(msg)
setTimeout(() => setPhotoError(null), 5000)
} finally {
setUploadingPhoto(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const handleSaveName = async (e: React.FormEvent) => {
e.preventDefault()
if (!user?.email || !nameInput.trim()) return
setSavingName(true)
setNameError(null)
try {
const res = await authService.updateProfile(nameInput.trim(), user.email)
updateUser({ name: res.name })
setNameSuccess(true)
setEditingName(false)
setTimeout(() => setNameSuccess(false), 4000)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Erro ao salvar o nome.'
setNameError(msg)
setTimeout(() => setNameError(null), 5000)
} finally {
setSavingName(false)
}
}
useEffect(() => { useEffect(() => {
if (user?.email) { if (user?.email) {
const g = getUserWorkGoal(user.email) const g = getUserWorkGoal(user.email)
@ -97,10 +187,46 @@ export const ProfilePage: React.FC = () => {
</div> </div>
{/* Main Profile Info Card */} {/* Main Profile Info Card */}
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-8 shadow-panel space-y-6"> <div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-6 sm:p-8 shadow-panel space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-border-soft/60"> {/* Status Alerts */}
<div className="flex items-center gap-4"> {photoSuccess && (
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-brand/40 bg-gradient-to-br from-brand to-teal-700 flex items-center justify-center text-white font-extrabold text-2xl shadow-lg shadow-brand/20 shrink-0"> <div className="flex items-center gap-2 p-3.5 rounded-2xl bg-emerald-500/10 border border-emerald-600/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold animate-in fade-in duration-200">
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>Foto de perfil atualizada com sucesso!</span>
</div>
)}
{photoError && (
<div className="flex items-center gap-2 p-3.5 rounded-2xl bg-rose-500/10 border border-rose-600/20 text-rose-300 text-xs font-semibold animate-in fade-in duration-200">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{photoError}</span>
</div>
)}
{nameSuccess && (
<div className="flex items-center gap-2 p-3.5 rounded-2xl bg-emerald-500/10 border border-emerald-600/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold animate-in fade-in duration-200">
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>Nome atualizado com sucesso!</span>
</div>
)}
{nameError && (
<div className="flex items-center gap-2 p-3.5 rounded-2xl bg-rose-500/10 border border-rose-600/20 text-rose-300 text-xs font-semibold animate-in fade-in duration-200">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{nameError}</span>
</div>
)}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 pb-6 border-b border-border-soft/60">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-5">
{/* Avatar with Camera Trigger */}
<div className="relative group shrink-0">
<div
onClick={() => !uploadingPhoto && fileInputRef.current?.click()}
className={`w-20 h-20 sm:w-24 sm:h-24 rounded-full overflow-hidden border-2 border-brand/50 bg-gradient-to-br from-brand to-teal-700 flex items-center justify-center text-white font-extrabold text-2xl sm:text-3xl shadow-lg shadow-brand/20 cursor-pointer relative transition-transform duration-200 group-hover:scale-[1.02] ${
uploadingPhoto ? 'opacity-80 pointer-events-none' : ''
}`}
>
{user.photo ? ( {user.photo ? (
<img <img
src={user.photo} src={user.photo}
@ -112,20 +238,114 @@ export const ProfilePage: React.FC = () => {
) : ( ) : (
user.email.charAt(0).toUpperCase() user.email.charAt(0).toUpperCase()
)} )}
{/* Hover Overlay */}
<div className="absolute inset-0 bg-black/55 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center text-white text-[11px] font-bold gap-1">
<Camera className="w-5 h-5 text-white drop-shadow" />
<span>Alterar</span>
</div> </div>
<div>
<h2 className="text-lg font-bold text-foreground">{user.name || 'Usuário'}</h2> {/* Uploading Overlay */}
<p className="text-xs text-muted flex items-center gap-1.5 mt-0.5"> {uploadingPhoto && (
<div className="absolute inset-0 bg-black/65 flex flex-col items-center justify-center text-white gap-1 z-10">
<Loader2 className="w-6 h-6 animate-spin text-brand" />
<span className="text-[10px] font-bold">Enviando...</span>
</div>
)}
</div>
{/* Camera Badge Button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingPhoto}
title="Alterar foto de perfil"
className="absolute -bottom-1 -right-1 p-2 rounded-full bg-brand hover:bg-brand-strong text-white shadow-md border-2 border-surface transition-transform duration-200 hover:scale-110 active:scale-95 cursor-pointer disabled:opacity-60"
>
<Camera className="w-3.5 h-3.5" />
</button>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onChange={handlePhotoSelected}
className="hidden"
/>
</div>
{/* User Details & Edit Form */}
<div className="space-y-1.5 flex-1 min-w-0">
{editingName ? (
<form onSubmit={handleSaveName} className="flex items-center gap-2 flex-wrap">
<input
type="text"
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
required
placeholder="Seu nome"
className="px-3 py-1.5 rounded-xl border border-brand bg-surface text-sm font-bold text-foreground focus:outline-none focus:ring-2 focus:ring-brand/40"
autoFocus
/>
<button
type="submit"
disabled={savingName || !nameInput.trim()}
className="flex items-center gap-1 px-3 py-1.5 rounded-xl bg-brand text-white text-xs font-bold hover:bg-brand-strong shadow-xs disabled:opacity-60 cursor-pointer"
>
{savingName ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
<span>Salvar</span>
</button>
<button
type="button"
onClick={() => {
setNameInput(user.name || '')
setEditingName(false)
}}
className="p-1.5 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</form>
) : (
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-lg sm:text-xl font-bold text-foreground">{user.name || 'Usuário'}</h2>
<button
type="button"
onClick={() => setEditingName(true)}
title="Editar nome"
className="p-1 rounded-lg text-muted hover:text-brand hover:bg-brand/10 transition-colors cursor-pointer"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</div>
)}
<p className="text-xs text-muted flex items-center gap-1.5">
<Mail className="w-3.5 h-3.5" /> <Mail className="w-3.5 h-3.5" />
{user.email} {user.email}
</p> </p>
<div className="flex items-center gap-2 pt-1 flex-wrap">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingPhoto}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-surface-raised hover:bg-surface-sunken border border-border-soft text-xs font-semibold text-foreground transition-all cursor-pointer shadow-xs"
>
<Camera className="w-3.5 h-3.5 text-brand" />
<span>Trocar foto</span>
</button>
<span className="text-[10px] text-muted">
PNG, JPG, WebP ou GIF (máx. 5MB)
</span>
</div>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
className="self-start sm:self-auto flex items-center gap-2 px-4 py-2.5 rounded-xl border border-rose-500/30 text-rose-300 hover:bg-rose-500/10 text-xs font-semibold transition-all hover:shadow-xs" className="self-start sm:self-auto flex items-center gap-2 px-4 py-2.5 rounded-xl border border-rose-500/30 text-rose-300 hover:bg-rose-500/10 text-xs font-semibold transition-all hover:shadow-xs cursor-pointer"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
<span>Encerrar Sessão</span> <span>Encerrar Sessão</span>

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react' import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { import {
LayoutGrid, LayoutGrid,
List as ListIcon, List as ListIcon,
@ -38,6 +38,11 @@ export const TasksPage: React.FC = () => {
const { activeOrg, user } = useAuth() const { activeOrg, user } = useAuth()
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer() const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
const syncWithTasksRef = useRef(syncWithTasks)
useEffect(() => {
syncWithTasksRef.current = syncWithTasks
}, [syncWithTasks])
const [tasks, setTasks] = useState<Task[]>([]) const [tasks, setTasks] = useState<Task[]>([])
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -135,7 +140,7 @@ export const TasksPage: React.FC = () => {
setTasks(tasksRes || []) setTasks(tasksRes || [])
setProjects(projsRes || []) setProjects(projsRes || [])
syncWithTasks(tasksRes || []) syncWithTasksRef.current(tasksRes || [])
} catch (err) { } catch (err) {
console.error('Failed to load tasks', err) console.error('Failed to load tasks', err)
setActionError('Falha ao carregar tarefas. Tente atualizar a página.') setActionError('Falha ao carregar tarefas. Tente atualizar a página.')
@ -143,7 +148,7 @@ export const TasksPage: React.FC = () => {
setLoading(false) setLoading(false)
setRefreshing(false) setRefreshing(false)
} }
}, [activeOrgId, onlyMyTasks, syncWithTasks]) }, [activeOrgId, onlyMyTasks])
useEffect(() => { useEffect(() => {
loadData() loadData()
@ -277,6 +282,29 @@ export const TasksPage: React.FC = () => {
setIsModalOpen(true) setIsModalOpen(true)
}, []) }, [])
const handleDeleteTask = useCallback(
async (task: Task) => {
setActionError(null)
try {
if (activeTask?.id === task.id) {
await stopTimer(task)
}
await taskService.deleteTask(task.id)
setTasks((prev) => prev.filter((t) => t.id !== task.id))
if (editingTask?.id === task.id) {
setEditingTask(null)
setIsModalOpen(false)
}
} catch (err: unknown) {
console.error('Failed to delete task', err)
setActionError(
err instanceof Error ? err.message : 'Falha ao excluir a tarefa.'
)
}
},
[activeTask, stopTimer, editingTask]
)
// Summary counts // Summary counts
const notStartedCount = tasks.filter((t) => t.status === 'NOT_STARTED').length const notStartedCount = tasks.filter((t) => t.status === 'NOT_STARTED').length
const inProgressCount = tasks.filter((t) => t.status === 'IN_PROGRESS').length const inProgressCount = tasks.filter((t) => t.status === 'IN_PROGRESS').length
@ -682,6 +710,7 @@ export const TasksPage: React.FC = () => {
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
onEditTask={handleOpenEditModal} onEditTask={handleOpenEditModal}
onOpenHistory={handleOpenHistoryModal} onOpenHistory={handleOpenHistoryModal}
onDeleteTask={handleDeleteTask}
onAddTaskToStatus={handleOpenCreateModal} onAddTaskToStatus={handleOpenCreateModal}
activeTaskId={activeTask?.id} activeTaskId={activeTask?.id}
activeTaskSeconds={activeSeconds} activeTaskSeconds={activeSeconds}
@ -697,6 +726,7 @@ export const TasksPage: React.FC = () => {
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
onEditTask={handleOpenEditModal} onEditTask={handleOpenEditModal}
onOpenHistory={handleOpenHistoryModal} onOpenHistory={handleOpenHistoryModal}
onDeleteTask={handleDeleteTask}
activeTaskId={activeTask?.id} activeTaskId={activeTask?.id}
activeTaskSeconds={activeSeconds} activeTaskSeconds={activeSeconds}
/> />
@ -711,6 +741,7 @@ export const TasksPage: React.FC = () => {
setEditingTask(null) setEditingTask(null)
}} }}
onSubmit={handleModalSubmit} onSubmit={handleModalSubmit}
onDelete={handleDeleteTask}
organizationId={activeOrgId} organizationId={activeOrgId}
initialStatus={modalInitialStatus} initialStatus={modalInitialStatus}
initialTask={editingTask} initialTask={editingTask}

View file

@ -63,4 +63,20 @@ export const authService = {
body: { preferences }, body: { preferences },
}) })
}, },
async uploadPhoto(file: File): Promise<{ photoUrl: string }> {
const formData = new FormData()
formData.append('photo', file)
return apiFetch<{ photoUrl: string }>('/auth/profile/photo', {
method: 'POST',
body: formData,
})
},
async updateProfile(name: string, email: string): Promise<{ name: string; email: string; photo: string }> {
return apiFetch<{ name: string; email: string; photo: string }>('/auth/profile', {
method: 'PUT',
body: { name, email },
})
},
} }

View file

@ -75,4 +75,10 @@ export const taskService = {
method: 'PUT', method: 'PUT',
}) })
}, },
async deleteTask(id: number): Promise<void> {
return apiFetch<void>(`/tasks/${id}`, {
method: 'DELETE',
})
},
} }

View file

@ -36,6 +36,7 @@ export interface Task {
project?: Project; project?: Project;
category?: Category; category?: Category;
user?: TaskUser; user?: TaskUser;
createdBy?: TaskUser;
finishedAt?: string; finishedAt?: string;
history?: TaskHistoryItem[]; history?: TaskHistoryItem[];
externalUrl?: string; externalUrl?: string;