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
.env
*.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>
<version>8.5.17</version>
</dependency>
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<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.Project;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.entities.organization.Organization;
public class Task {
private Long id;
private String title;
private String description;
private TaskStatus status;
private Organization organization;
private Project project;
private Category category;
private User user;
private User createdBy;
private LocalDateTime finishedAt;
private List<TaskHistoryItem> history = new ArrayList<>();
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() {
return externalUrl;
}
@ -60,11 +79,18 @@ public class Task {
public void setStatus(TaskStatus status) {
if (this.status == status) return;
if (status == TaskStatus.COMPLETED || status == TaskStatus.CANCELED) {
if (isRunning()) {
this.stop();
}
if (status == TaskStatus.COMPLETED) {
this.finishedAt = LocalDateTime.now();
} else {
this.finishedAt = null;
}
} else {
this.finishedAt = null;
}
this.status = status;
}
@ -121,6 +147,10 @@ public class Task {
}
public void start() {
start(this.user);
}
public void start(User worker) {
TaskHistoryItem current = getCurrentRunningItem();
if (Objects.nonNull(current)) {
@ -130,6 +160,7 @@ public class Task {
this.status = TaskStatus.IN_PROGRESS;
TaskHistoryItem item = new TaskHistoryItem();
item.setStartAt(LocalDateTime.now());
item.setUser(worker != null ? worker : this.user);
this.history.add(item);
}

View file

@ -8,6 +8,15 @@ public class TaskHistoryItem {
private Long id;
private LocalDateTime startAt;
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() {
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.update.UpdateCategoryUseCase;
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.OrganizationRepository;
@ -26,7 +27,9 @@ public class CategoryUseCaseConfig {
}
@Produces
public ListCategoriesUseCase listCategoriesUseCase(CategoryRepository categoryRepository) {
return new ListCategoriesUseCase(categoryRepository);
public ListCategoriesUseCase listCategoriesUseCase(CategoryRepository 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.HandleJoinRequestUseCase;
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.repository.OrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
@ -23,6 +24,12 @@ public class OrganizationUseCaseConfig {
return new AuthorizeOrganizationAdminUseCase(userRepository, userOrganizationRepository);
}
@Produces
public AuthorizeOrganizationMemberUseCase authorizeOrganizationMemberUseCase(UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
return new AuthorizeOrganizationMemberUseCase(userRepository, userOrganizationRepository);
}
@Produces
public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository,
UserRepository userRepository,
@ -65,4 +72,24 @@ public class OrganizationUseCaseConfig {
UserOrganizationRepository 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.inject.Produces;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.project.create.CreateProjectUseCase;
import net.beetechgroup.beetask.usecase.project.list.ListProjectsUseCase;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
@ -12,12 +13,29 @@ public class ProjectUseCaseConfig {
@Produces
public CreateProjectUseCase createProjectUseCase(ProjectRepository projectRepository,
OrganizationRepository organizationRepository) {
return new CreateProjectUseCase(projectRepository, organizationRepository);
OrganizationRepository organizationRepository,
AuthorizeOrganizationMemberUseCase authorizer) {
return new CreateProjectUseCase(projectRepository, organizationRepository, authorizer);
}
@Produces
public ListProjectsUseCase listProjectsUseCase(ProjectRepository projectRepository) {
return new ListProjectsUseCase(projectRepository);
public ListProjectsUseCase listProjectsUseCase(ProjectRepository 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.inject.Produces;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
@ -22,18 +23,19 @@ public class TaskUseCaseConfig {
@Produces
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository);
UserOrganizationRepository userOrganizationRepository,
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository);
}
@Produces
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository) {
return new StartTaskUseCase(taskRepository, userRepository);
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) {
return new StartTaskUseCase(taskRepository, userRepository, authorizer);
}
@Produces
public StopTaskUseCase stopTaskUseCase(TaskRepository taskRepository) {
return new StopTaskUseCase(taskRepository);
public StopTaskUseCase stopTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
return new StopTaskUseCase(taskRepository, authorizer);
}
@Produces
@ -47,19 +49,29 @@ public class TaskUseCaseConfig {
}
@Produces
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository) {
return new UpdateTaskStatusUseCase(taskRepository);
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
return new UpdateTaskStatusUseCase(taskRepository, authorizer);
}
@Produces
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository);
UserOrganizationRepository userOrganizationRepository,
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository);
}
@Produces
public GetTaskUseCase getTaskUseCase(TaskRepository taskRepository) {
return new GetTaskUseCase(taskRepository);
public GetTaskUseCase getTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
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) {
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.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Column;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
@Entity
@Table(name = "categories")
@SQLDelete(sql = "UPDATE categories SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class CategoryEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -17,6 +22,8 @@ public class CategoryEntity {
private String name;
private String color;
private String icon;
@Column(name = "deleted_at")
private java.time.LocalDateTime deletedAt;
@ManyToOne
@JoinColumn(name = "organization_id")
@ -61,4 +68,12 @@ public class CategoryEntity {
public void setOrganization(OrganizationEntity 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.OneToMany;
import jakarta.persistence.Table;
import jakarta.persistence.Column;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
@Entity
@Table(name = "organizations")
@SQLDelete(sql = "UPDATE organizations SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class OrganizationEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Column(name = "deleted_at")
private java.time.LocalDateTime deletedAt;
@OneToMany(mappedBy = "organization")
private List<UserOrganizationEntity> userOrganizations;
@ -43,4 +50,11 @@ public class OrganizationEntity {
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.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Column;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
@Entity
@Table(name = "projects")
@SQLDelete(sql = "UPDATE projects SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class ProjectEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -21,6 +26,8 @@ public class ProjectEntity {
private String name;
private String color;
private String icon;
@Column(name = "deleted_at")
private java.time.LocalDateTime deletedAt;
@ManyToOne
@JoinColumn(name = "organization_id")
private OrganizationEntity organization;
@ -76,4 +83,12 @@ public class ProjectEntity {
public void setUsers(List<UserEntity> 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.OneToMany;
import jakarta.persistence.Table;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import net.beetechgroup.beetask.entities.task.TaskStatus;
@Entity
@Table(name = "tasks")
@SQLDelete(sql = "UPDATE tasks SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class TaskEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -30,7 +34,12 @@ public class TaskEntity {
private java.time.LocalDateTime finishedAt;
@Column(name = "external_url", length = 1000)
private String externalUrl;
@Column(name = "deleted_at")
private java.time.LocalDateTime deletedAt;
@ManyToOne
@JoinColumn(name = "organization_id")
private OrganizationEntity organization;
@ManyToOne
@JoinColumn(name = "project_id")
private ProjectEntity project;
@ -40,9 +49,28 @@ public class TaskEntity {
@ManyToOne
@JoinColumn(name = "user_id")
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;
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) {
this.history = history;
}
@ -122,4 +150,12 @@ public class TaskEntity {
public void setExternalUrl(String 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")
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() {
return id;
}

View file

@ -5,9 +5,14 @@ import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Column;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
@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 {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -16,6 +21,8 @@ public class UserEntity {
private String email;
private String password;
private String photo;
@Column(name = "deleted_at")
private java.time.LocalDateTime deletedAt;
@jakarta.persistence.Column(columnDefinition = "jsonb")
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
private String preferences;
@ -67,4 +74,12 @@ public class UserEntity {
public void setPreferences(String 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;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
@ -11,9 +12,15 @@ import net.beetechgroup.beetask.entities.organization.UserOrganizationRole;
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import java.time.LocalDateTime;
@Entity
@Table(name = "user_organizations")
@SQLDelete(sql = "UPDATE user_organizations SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class UserOrganizationEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -28,6 +35,16 @@ public class UserOrganizationEntity {
private UserOrganizationRole role;
@Enumerated(EnumType.STRING)
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() {
return user;
@ -60,4 +77,12 @@ public class UserOrganizationEntity {
public void setStatus(UserOrganizationStatus 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 static OrganizationEntity toEntity(Organization organization) {
if (Objects.isNull(organization)) return null;
OrganizationEntity entity = new OrganizationEntity();
entity.setId(organization.getId());
entity.setName(organization.getName());

View file

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

View file

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

View file

@ -41,7 +41,7 @@ public class OrganizationRepositoryImpl implements OrganizationRepository, Panac
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)
.toList();
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);
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,
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds) {
StringBuilder query = new StringBuilder(
"select distinct t from TaskEntity t join t.project p " +
"where p.organization.id = ?1"
"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)"
);
List<Object> params = new ArrayList<>();
params.add(organizationId);
@ -100,7 +100,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
@Override
public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
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<>();
params.add(email);
@ -134,9 +134,29 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
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
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 ->
Objects.nonNull(h.getStartAt()) &&
!h.getStartAt().isAfter(end) &&
@ -161,15 +181,18 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
@Override
public List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) {
return find("select distinct t from TaskEntity t join t.history h " +
"where t.project.organization.id = ?1 " +
return find("select distinct t from TaskEntity t " +
"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)",
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
}
@Override
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.finishedAt >= ?2 and t.finishedAt <= ?3",
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
@ -186,6 +209,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
.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
public List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) {
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();
}
@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
public List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) {
if (emails.isEmpty()) {
@ -208,6 +255,24 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
.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
public List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) {
if (emails.isEmpty()) {
@ -219,6 +284,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
.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
public long countByStatus(TaskStatus status) {
return count("status", status);
@ -226,10 +305,32 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
@Override
public long sumAllCompletedTrackedMinutes() {
return findAllTasks().stream()
.flatMap(task -> task.getHistory().stream())
.filter(h -> Objects.nonNull(h.getEndAt()))
.mapToLong(h -> Duration.between(h.getStartAt(), h.getEndAt()).toMinutes())
.sum();
Object result = getEntityManager().createNativeQuery(
"SELECT COALESCE(SUM(EXTRACT(EPOCH FROM (h.end_at - h.start_at)) / 60), 0) " +
"FROM task_history h JOIN tasks t ON t.id = h.task_id " +
"WHERE h.end_at IS NOT NULL AND t.deleted_at IS NULL"
).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",
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() {
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")
@APIResponse(responseCode = "200", description = "Categories listed successfully")
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)
.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;
}

View file

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

View file

@ -52,6 +52,12 @@ public class OrganizationController {
@Inject
ListOrganizationMembersUseCase listOrganizationMembersUseCase;
@Inject
net.beetechgroup.beetask.usecase.organization.members.RemoveOrganizationMemberUseCase removeOrganizationMemberUseCase;
@Inject
net.beetechgroup.beetask.usecase.organization.members.LeaveOrganizationUseCase leaveOrganizationUseCase;
@Inject
SecurityIdentity securityIdentity;
@ -142,4 +148,26 @@ public class OrganizationController {
LOGGER.infof("User %s listed %d organization join requests", userEmail, requests.size());
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;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
@ -19,6 +20,7 @@ import java.util.List;
@Path("/organizations/{orgId}/projects")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Authenticated
public class ProjectController {
private static final Logger LOGGER = Logger.getLogger(ProjectController.class);
@ -28,25 +30,55 @@ public class ProjectController {
@Inject
ListProjectsUseCase listProjectsUseCase;
@Inject
net.beetechgroup.beetask.usecase.project.update.UpdateProjectUseCase updateProjectUseCase;
@Inject
net.beetechgroup.beetask.usecase.project.delete.DeleteProjectUseCase deleteProjectUseCase;
@Inject
SecurityIdentity securityIdentity;
@GET
@Authenticated
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()))
.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;
}
@POST
@Authenticated
public ProjectResponse createProject(@PathParam("orgId") Long orgId, CreateProjectRequest request) {
LOGGER.infof("Project creation requested for organization %d with name '%s'", orgId, request.name());
Project project = createProjectUseCase.execute(orgId, request.name(), request.color(), request.icon());
LOGGER.infof("Project %d created successfully for organization %d", project.getId(), orgId);
String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Project creation requested by %s for organization %d with name '%s'", email, orgId, request.name());
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());
}
@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
public record CreateProjectRequest(String name, String color, String icon) {}

View file

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

View file

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

View file

@ -68,6 +68,13 @@ public class TaskControllerMapper {
output.user().email(),
output.user().photo())
: null,
Objects.nonNull(output.createdBy())
? new CreateTaskResponse.UserResponse(
output.createdBy().id(),
output.createdBy().name(),
output.createdBy().email(),
output.createdBy().photo())
: null,
output.finishedAt(),
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(
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();
// Simple password check for now (should use Bcrypt)
if (!user.getPassword().equals(input.password())) {
boolean passwordMatches = false;
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());
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.CategoryOutput;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
import org.jboss.logging.Logger;
public class ListCategoriesUseCase {
private static final Logger LOGGER = Logger.getLogger(ListCategoriesUseCase.class);
private final CategoryRepository categoryRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public ListCategoriesUseCase(CategoryRepository categoryRepository) {
public ListCategoriesUseCase(CategoryRepository categoryRepository, AuthorizeOrganizationMemberUseCase authorizer) {
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()
.map(CategoryMapper::toCategoryOutput)
.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;
}
}

View file

@ -5,5 +5,10 @@ import java.time.LocalDateTime;
public record DashboardInput(
String userEmail,
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",
input.userEmail(), input.startDate(), input.endDate());
List<Task> workedTasks = taskRepository.findTasksWorkedByUserInPeriod(
input.userEmail(), input.startDate(), input.endDate()
input.userEmail(), input.startDate(), input.endDate(), input.organizationId()
);
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserInPeriod(
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 endOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MAX);
List<Task> yesterdayTasks = taskRepository.findTasksWorkedByUserInPeriod(
input.userEmail(), startOfYesterday, endOfYesterday
input.userEmail(), startOfYesterday, endOfYesterday, input.organizationId()
);
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);
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.setOrganization(organization);
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();
List<Task> workedTasks = taskRepository.findTasksWorkedByUserEmailsInPeriod(
memberEmails, input.startDate(), input.endDate()
input.organizationId(), memberEmails, input.startDate(), input.endDate()
);
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserEmailsInPeriod(
memberEmails, input.startDate(), input.endDate()
input.organizationId(), memberEmails, input.startDate(), input.endDate()
);
Map<Long, OrgProjectStats> projectStatsMap = new HashMap<>();

View file

@ -54,8 +54,8 @@ public class MemberDetailUseCase {
long daySpan = ChronoUnit.DAYS.between(start.toLocalDate(), end.toLocalDate());
boolean groupByDay = daySpan <= 30;
List<Task> workedTasks = taskRepository.findTasksWorkedByUserIdInPeriod(input.memberId(), start, end);
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.memberId(), start, end);
List<Task> workedTasks = taskRepository.findTasksWorkedByUserIdInPeriod(input.organizationId(), 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.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.organization.Organization;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import org.jboss.logging.Logger;
@ -10,22 +11,36 @@ public class CreateProjectUseCase {
private static final Logger LOGGER = Logger.getLogger(CreateProjectUseCase.class);
private final ProjectRepository projectRepository;
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.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)
.orElseThrow(() -> {
LOGGER.warnf("Project creation failed because organization %d was not found", organizationId);
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.setName(projectName);
project.setName(projectName.trim());
project.setColor(color);
project.setIcon(icon);
project.setOrganization(organization);
@ -35,3 +50,4 @@ public class CreateProjectUseCase {
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;
import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import java.util.List;
@ -9,14 +10,18 @@ import org.jboss.logging.Logger;
public class ListProjectsUseCase {
private static final Logger LOGGER = Logger.getLogger(ListProjectsUseCase.class);
private final ProjectRepository projectRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public ListProjectsUseCase(ProjectRepository projectRepository) {
public ListProjectsUseCase(ProjectRepository projectRepository, AuthorizeOrganizationMemberUseCase authorizer) {
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);
LOGGER.infof("Loaded %d projects for organization %d", projects.size(), organizationId);
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);
Optional<Project> findProjectById(Long id);
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> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
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, Long organizationId);
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
List<Task> findTasksByUser(String email);
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> findTasksFinishedByOrgInPeriod(Long orgId, 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 orgId, Long userId, 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(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end);
long countByStatus(TaskStatus status);
long sumAllCompletedTrackedMinutes();
void deleteTask(Long id);
}

View file

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

View file

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

View file

@ -28,6 +28,13 @@ public class CreateTaskMapper {
task.getUser().getEmail(),
getPhotoUrl(task.getUser().getName(), task.getUser().getPhoto()))
: 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.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList(),
task.getExternalUrl());

View file

@ -12,6 +12,7 @@ public record CreateTaskOutput(
ProjectOutput project,
CategoryOutput category,
UserOutput user,
UserOutput createdBy,
LocalDateTime finishedAt,
List<TaskHistoryItemOutput> history,
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.task.Task;
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.CategoryRepository;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
@ -22,15 +23,18 @@ public class CreateTaskUseCase {
private final CategoryRepository categoryRepository;
private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository;
private final OrganizationRepository organizationRepository;
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
UserOrganizationRepository userOrganizationRepository,
OrganizationRepository organizationRepository) {
this.taskRepository = taskRepository;
this.projectRepository = projectRepository;
this.categoryRepository = categoryRepository;
this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository;
this.organizationRepository = organizationRepository;
}
public CreateTaskOutput execute(CreateTaskInput input) {
@ -60,7 +64,17 @@ public class CreateTaskUseCase {
}
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()));
User creator = userRepository.findByEmail(input.userEmail()).orElse(null);
task.setCreatedBy(creator);
if (Objects.nonNull(input.history())) {
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;
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.task.create.CreateTaskMapper;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
@ -8,14 +10,32 @@ import org.jboss.logging.Logger;
public class GetTaskUseCase {
private static final Logger LOGGER = Logger.getLogger(GetTaskUseCase.class);
private final TaskRepository taskRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public GetTaskUseCase(TaskRepository taskRepository) {
public GetTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
this.taskRepository = taskRepository;
this.authorizer = authorizer;
}
public CreateTaskOutput execute(Long id) {
//TODO deve validar se o usuario logado é o dono da task
LOGGER.infof("Fetching task %d", id);
return CreateTaskMapper.toCreateTaskOutput(taskRepository.findTaskById(id));
public CreateTaskOutput execute(Long id, String userEmail) {
LOGGER.infof("Fetching task %d requested by %s", id, userEmail);
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 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 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.UserRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
@ -13,25 +14,53 @@ public class StartTaskUseCase {
private final TaskRepository taskRepository;
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.userRepository = userRepository;
this.authorizer = authorizer;
}
public CreateTaskOutput execute(StartTaskInput input) {
LOGGER.infof("Starting task %d for user %s", input.id(), input.userEmail());
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())) {
LOGGER.warnf("User %s attempted to start task %d owned by %s",
input.userEmail(), input.id(), task.getUser().getEmail());
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));
LOGGER.infof("Task %d started successfully", input.id());
return output;
}
}

View file

@ -1,6 +1,7 @@
package net.beetechgroup.beetask.usecase.task.stop;
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.task.create.CreateTaskMapper;
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 final TaskRepository taskRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public StopTaskUseCase(TaskRepository taskRepository) {
public StopTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
this.taskRepository = taskRepository;
this.authorizer = authorizer;
}
public CreateTaskOutput execute(StopTaskInput input) {
LOGGER.infof("Stopping task %d for user %s", input.id(), input.userEmail());
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())) {
LOGGER.warnf("User %s attempted to stop task %d owned by %s",
input.userEmail(), input.id(), task.getUser().getEmail());
@ -31,3 +45,4 @@ public class StopTaskUseCase {
return output;
}
}

View file

@ -1,9 +1,8 @@
package net.beetechgroup.beetask.usecase.task.update;
import net.beetechgroup.beetask.entities.task.Task;
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.task.create.CreateTaskMapper;
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 final TaskRepository taskRepository;
private final AuthorizeOrganizationMemberUseCase authorizer;
public UpdateTaskStatusUseCase(TaskRepository taskRepository) {
public UpdateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
this.taskRepository = taskRepository;
this.authorizer = authorizer;
}
public CreateTaskOutput execute(Long id, TaskStatus newStatus) {
LOGGER.infof("Updating status for task %d to %s", id, newStatus);
public CreateTaskOutput execute(Long id, TaskStatus newStatus, String userEmail) {
LOGGER.infof("Updating status for task %d to %s by user %s", id, newStatus, userEmail);
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);
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
@ -29,3 +44,4 @@ public class UpdateTaskStatusUseCase {
return output;
}
}

View file

@ -24,21 +24,36 @@ public class UpdateTaskUseCase {
private final CategoryRepository categoryRepository;
private final UserRepository userRepository;
private final UserOrganizationRepository userOrganizationRepository;
private final net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository;
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
CategoryRepository categoryRepository, UserRepository userRepository,
UserOrganizationRepository userOrganizationRepository) {
UserOrganizationRepository userOrganizationRepository,
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
this.taskRepository = taskRepository;
this.projectRepository = projectRepository;
this.categoryRepository = categoryRepository;
this.userRepository = userRepository;
this.userOrganizationRepository = userOrganizationRepository;
this.organizationRepository = organizationRepository;
}
public CreateTaskOutput execute(UpdateTaskInput input) {
LOGGER.infof("Updating task %d requested by user %s", input.id(), input.userEmail());
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());
task.setTitle(input.title());
@ -46,6 +61,10 @@ public class UpdateTaskUseCase {
task.setStatus(input.status());
task.setExternalUrl(input.externalUrl());
if (Objects.nonNull(input.organizationId())) {
organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization);
}
if (Objects.nonNull(input.projectId())) {
Project project = projectRepository.findProjectById(input.projectId())
.orElseThrow(() -> {
@ -71,6 +90,18 @@ public class UpdateTaskUseCase {
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
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 -> {
if (Objects.nonNull(hInput.id())) {
return task.getHistory().stream()
@ -79,18 +110,23 @@ public class UpdateTaskUseCase {
.map(existing -> {
existing.setStartAt(hInput.startAt());
existing.setEndAt(hInput.endAt());
if (existing.getUser() == null) {
existing.setUser(defaultWorker);
}
return existing;
})
.orElseGet(() -> {
TaskHistoryItem newItem = new TaskHistoryItem();
newItem.setStartAt(hInput.startAt());
newItem.setEndAt(hInput.endAt());
newItem.setUser(defaultWorker);
return newItem;
});
} else {
TaskHistoryItem newItem = new TaskHistoryItem();
newItem.setStartAt(hInput.startAt());
newItem.setEndAt(hInput.endAt());
newItem.setUser(defaultWorker);
return newItem;
}
}).toList();

View file

@ -21,6 +21,9 @@ public class CreateUserUseCase {
LOGGER.infof("Creating user with email %s", input.email());
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);
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,
type WorkGoal,
} from '../../lib/userGoals'
import { useAuth } from '../../contexts/AuthContext'
interface BiUniformCardProps {
card: DashboardV2Card
@ -42,6 +43,7 @@ interface BiUniformCardProps {
activeTask: Task | null
activeSeconds: number
workGoal: WorkGoal
globalScope?: 'mine' | 'team'
isBeingDragged?: boolean
isDragOver?: boolean
onToggleTimer: (task: Task) => void
@ -119,6 +121,7 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
activeTask,
activeSeconds,
workGoal,
globalScope = 'team',
isBeingDragged = false,
isDragOver = false,
onToggleTimer,
@ -131,11 +134,30 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
onDrop,
onDragEnd,
}) => {
const { user } = useAuth()
const theme = THEME_STYLES[card.colorTheme || 'brand']
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) => {
// 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.categoryId && t.category?.id !== card.categoryId) return false
if (card.statusFilter && card.statusFilter !== 'ALL') {
@ -351,24 +373,35 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
}
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 (
<div className="space-y-1 my-auto">
<div className="flex items-center justify-between">
<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>
{targetTask && (
<button
type="button"
onClick={() => onToggleTimer(targetTask)}
className={`p-1.5 rounded-lg transition-all shadow-xs ${
activeTask
className={`p-1.5 rounded-lg transition-all shadow-xs cursor-pointer ${
currentActive
? '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'
}`}
>
{activeTask ? (
{currentActive ? (
<Square className="w-3 h-3 fill-rose-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>
<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>
</div>
)
@ -524,6 +557,23 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
{/* 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">
{/* 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}`}>
{periodLabels[card.period]}
</span>

View file

@ -11,7 +11,7 @@ import {
ListOrdered,
Layers,
} 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'
interface CreateBiCardModalProps {
@ -103,6 +103,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
const [categoryId, setCategoryId] = useState<number | undefined>(undefined)
const [statusFilter, setStatusFilter] = useState<'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'>('ALL')
const [colorTheme, setColorTheme] = useState<DashboardV2Card['colorTheme']>('brand')
const [scope, setScope] = useState<BiCardScope>('INHERIT')
useEffect(() => {
if (editingCard) {
@ -113,6 +114,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
setCategoryId(editingCard.categoryId)
setStatusFilter(editingCard.statusFilter || 'ALL')
setColorTheme(editingCard.colorTheme || 'brand')
setScope(editingCard.scope || 'INHERIT')
} else {
setTitle('')
setMetricType('HOURS_TRACKED')
@ -121,6 +123,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
setCategoryId(undefined)
setStatusFilter('ALL')
setColorTheme('brand')
setScope('INHERIT')
}
}, [editingCard, isOpen])
@ -138,6 +141,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
categoryId,
statusFilter,
colorTheme,
scope,
}
onSave(card)
onClose()
@ -313,6 +317,23 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
))}
</select>
</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>

View file

@ -7,7 +7,7 @@ export const AppShell = () => {
const [isMobileOpen, setIsMobileOpen] = useState(false)
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 */}
<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" />
@ -18,14 +18,14 @@ export const AppShell = () => {
onCloseMobile={() => setIsMobileOpen(false)}
/>
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0 z-10">
{/* Main Content Area - only this scrolls */}
<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 */}
<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
type="button"
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"
>
<MenuIcon className="w-5 h-5" />

View file

@ -64,6 +64,13 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
.toUpperCase()
: '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 (
<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 */}
@ -175,7 +182,7 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
{/* Right Area: Active Tracker Pill + Action Button + User Profile */}
<div className="flex items-center gap-3">
{/* 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="w-2 h-2 rounded-full bg-teal-400 animate-ping" />
<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')
}
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
const navItems = [
{
@ -232,7 +239,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
</div>
{/* Active Running Task Activity Status Banner */}
{activeTask && (
{isMyActiveTask && activeTask && (
<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="flex items-center justify-between gap-2 mb-1.5">
@ -416,7 +423,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
return (
<>
{/* 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}
</aside>

View file

@ -23,6 +23,7 @@ import { categoryService } from '../../services/categoryService'
import { organizationService } from '../../services/organizationService'
import { CustomSelect } from '../ui/Select'
import { useAuth } from '../../contexts/AuthContext'
import { canDeleteTask } from '../../lib/taskPermissions'
import {
calculateTaskTotalSeconds,
formatSecondsToTimer,
@ -46,6 +47,7 @@ interface CreateTaskModalProps {
history?: TaskHistoryItemInput[]
externalUrl?: string
}) => Promise<void>
onDelete?: (task: Task) => void
organizationId: number
initialStatus?: TaskStatus
initialTask?: Task | null
@ -56,12 +58,13 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
isOpen,
onClose,
onSubmit,
onDelete,
organizationId,
initialStatus = 'NOT_STARTED',
initialTask = null,
initialTab = 'details',
}) => {
const { user } = useAuth()
const { user, activeOrg } = useAuth()
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details')
// Task details
@ -343,13 +346,24 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
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({
title: title.trim(),
description: description.trim(),
status,
projectId,
categoryId,
assigneeUserId,
assigneeUserId: effectiveAssigneeUserId,
history: initialTask ? mappedHistory : undefined,
externalUrl: externalUrl.trim() || undefined,
})
@ -585,15 +599,19 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
Responsável
</label>
<CustomSelect
value={assigneeUserId ?? 0}
onChange={(val) => setAssigneeUserId(Number(val) === 0 ? undefined : Number(val))}
placeholder="Atribuir a mim mesmo / Sem responsável"
value={
assigneeUserId !== undefined
? 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" />}
options={[
{ value: 0, label: 'Sem responsável (atribuir a mim)' },
{ value: -1, label: 'Sem responsável (Livre)' },
...members.map((m) => ({
value: m.userId,
label: m.userName,
label: `${m.userName}${m.userEmail?.toLowerCase() === user?.email?.toLowerCase() ? ' (Você)' : ''}`,
description: m.userEmail,
})),
]}
@ -922,9 +940,25 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
{/* Footer Controls */}
<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 && (
<span>
<span className="text-[11px] text-muted">
Tempo total: <strong className="text-foreground font-mono">{formatSecondsToHuman(totalCalculatedSeconds)}</strong>
</span>
)}

View file

@ -1,13 +1,16 @@
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 { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
import { useAuth } from '../../contexts/AuthContext'
import { canDeleteTask } from '../../lib/taskPermissions'
interface TaskCardProps {
task: Task
onToggleTimer: (task: Task) => void
onEdit?: (task: Task) => void
onOpenHistory?: (task: Task) => void
onDelete?: (task: Task) => void
isTrackingThis?: boolean
currentSeconds?: number
}
@ -17,9 +20,11 @@ export const TaskCard: React.FC<TaskCardProps> = ({
onToggleTimer,
onEdit,
onOpenHistory,
onDelete,
isTrackingThis,
currentSeconds,
}) => {
const { user, activeOrg } = useAuth()
const running = isTrackingThis ?? isTaskRunning(task)
const totalSeconds = currentSeconds !== undefined ? currentSeconds : calculateTaskTotalSeconds(task)
const [menuOpen, setMenuOpen] = React.useState(false)
@ -90,7 +95,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
</div>
{/* More Actions Menu */}
{(onEdit || onOpenHistory) && (
{(onEdit || onOpenHistory || (onDelete && canDeleteTask(task, user, activeOrg))) && (
<div className="relative">
<button
type="button"
@ -142,6 +147,23 @@ export const TaskCard: React.FC<TaskCardProps> = ({
<Clock className="w-3.5 h-3.5 text-muted" />
</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>
</>
)}

View file

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

View file

@ -1,8 +1,10 @@
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 { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
import { calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
import { CustomSelect } from '../ui/Select'
import { useAuth } from '../../contexts/AuthContext'
import { canDeleteTask } from '../../lib/taskPermissions'
interface TaskListViewProps {
tasks: Task[]
@ -10,6 +12,7 @@ interface TaskListViewProps {
onStatusChange: (task: Task, newStatus: TaskStatus) => void
onEditTask: (task: Task) => void
onOpenHistory?: (task: Task) => void
onDeleteTask?: (task: Task) => void
activeTaskId?: number | null
activeTaskSeconds?: number
}
@ -21,9 +24,11 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
onStatusChange,
onEditTask,
onOpenHistory,
onDeleteTask,
activeTaskId,
activeTaskSeconds,
}) => {
const { user, activeOrg } = useAuth()
const [activeMenuId, setActiveMenuId] = React.useState<number | null>(null)
return (
@ -43,10 +48,9 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
</thead>
<tbody className="divide-y divide-border/60">
{tasks.map((task) => {
const isRunning =
activeTaskId === task.id || (activeTaskId === undefined && isTaskRunning(task))
const isRunning = Boolean(activeTaskId && activeTaskId === task.id)
const totalSec =
activeTaskId === task.id && activeTaskSeconds !== undefined
isRunning && activeTaskSeconds !== undefined
? activeTaskSeconds
: calculateTaskTotalSeconds(task)
@ -247,6 +251,19 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
<span>Concluir</span>
<CheckCircle2 className="w-3.5 h-3.5" />
</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>
</>
)}

View file

@ -23,6 +23,7 @@ interface AuthContextType {
register: (name: string, email: string, password: string) => Promise<CreateAccountResponse>
logout: () => void
refreshUser: () => Promise<void>
updateUser: (updated: Partial<LoginResponse>) => 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) => {
setActiveOrgId(id)
localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, id.toString())
@ -169,6 +179,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
register,
logout,
refreshUser,
updateUser,
setActiveOrg,
}}
>

View file

@ -15,29 +15,65 @@ interface TimerContextType {
const TimerContext = createContext<TimerContextType | undefined>(undefined)
export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { activeOrg } = useAuth()
const { user, activeOrg } = useAuth()
const [activeTask, setActiveTask] = useState<Task | null>(null)
const [activeSeconds, setActiveSeconds] = useState<number>(0)
// Track the baseline seconds and client-side timestamp when task data was fetched/started
const baseSecondsRef = useRef<number>(0)
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
const syncWithTasks = useCallback((tasks: Task[]) => {
const running = tasks.find((t) => isTaskRunning(t))
const syncWithTasks = useCallback(
(tasks: Task[]) => {
// ONLY consider tasks assigned to the current logged-in user
const running = tasks.find((t) => isTaskRunning(t) && isMyTask(t))
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)
baseSecondsRef.current = base
receivedAtRef.current = Date.now()
setActiveSeconds(base)
} 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)
baseSecondsRef.current = 0
setActiveSeconds(0)
}
}, [])
},
[isMyTask]
)
// Auto-fetch active running task on mount or workspace change
useEffect(() => {
@ -48,7 +84,7 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre
.listMyTasks({ organizationId: activeOrg.id })
.then((tasks) => {
if (!isMounted) return
const running = tasks.find((t) => isTaskRunning(t))
const running = tasks.find((t) => isTaskRunning(t) && isMyTask(t))
if (running) {
syncWithTasks(tasks)
}
@ -102,15 +138,16 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const startTimer = useCallback(
async (task: Task): Promise<Task> => {
// 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 {
await taskService.stopTask(activeTask.id)
await taskService.stopTask(activeTaskRef.current.id)
} catch (e) {
console.warn('Could not stop previously running task', e)
}
}
const updated = await taskService.startTask(task.id)
activeTaskRef.current = updated
setActiveTask(updated)
const base = calculateBaseSeconds(updated)
baseSecondsRef.current = base
@ -118,21 +155,22 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre
setActiveSeconds(base)
return updated
},
[activeTask]
[]
)
const stopTimer = useCallback(
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
const updated = await taskService.stopTask(targetId)
activeTaskRef.current = null
setActiveTask(null)
baseSecondsRef.current = 0
setActiveSeconds(0)
return updated
},
[activeTask]
[]
)
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 BiCardScope = 'INHERIT' | 'MINE' | 'TEAM'
export interface DashboardV2Card {
id: string
title: string
@ -36,6 +38,7 @@ export interface DashboardV2Card {
categoryId?: number
statusFilter?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'
colorTheme?: 'brand' | 'accent' | 'emerald' | 'indigo' | 'rose' | 'cyan'
scope?: BiCardScope
}
export const DEFAULT_V2_CARDS: DashboardV2Card[] = [

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
Clock,
CheckCircle2,
@ -10,6 +11,7 @@ import {
Sparkles,
Activity,
AlertCircle,
User,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { taskService } from '../services/taskService'
@ -20,7 +22,7 @@ import type { Task, Project, Category } from '../types/task'
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
export const AnalyticsPage: React.FC = () => {
const { activeOrg } = useAuth()
const { user, activeOrg } = useAuth()
const [tasks, setTasks] = useState<Task[]>([])
const [projects, setProjects] = useState<Project[]>([])
@ -31,6 +33,24 @@ export const AnalyticsPage: React.FC = () => {
const [refreshing, setRefreshing] = useState(false)
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 loadData = useCallback(async () => {
@ -61,15 +81,25 @@ export const AnalyticsPage: React.FC = () => {
loadData()
}, [loadData])
// Aggregate high-level KPIs
const kpis = useMemo(() => {
const totalTasks = tasks.length
const completedTasks = tasks.filter((t) => t.status === 'COMPLETED').length
const inProgressTasks = tasks.filter((t) => t.status === 'IN_PROGRESS').length
const notStartedTasks = tasks.filter((t) => t.status === 'NOT_STARTED').length
const canceledTasks = tasks.filter((t) => t.status === 'CANCELED').length
// Filter tasks based on selected member scope
const scopedTasks = useMemo(() => {
if (selectedMember === 'ALL') return tasks
const targetEmail = selectedMember === 'MINE' ? user?.email : selectedMember
if (!targetEmail) return tasks
return tasks.filter(
(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),
0
)
@ -80,7 +110,7 @@ export const AnalyticsPage: React.FC = () => {
const avgSecondsPerCompletedTask =
completedTasks > 0
? Math.round(
tasks
scopedTasks
.filter((t) => t.status === 'COMPLETED')
.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0) / completedTasks
)
@ -96,7 +126,7 @@ export const AnalyticsPage: React.FC = () => {
completionRate,
avgSecondsPerCompletedTask,
}
}, [tasks])
}, [scopedTasks])
// Project breakdown
const projectBreakdown = useMemo(() => {
@ -109,7 +139,7 @@ export const AnalyticsPage: React.FC = () => {
let unassignedSeconds = 0
let unassignedTasks = 0
tasks.forEach((t) => {
scopedTasks.forEach((t) => {
const sec = calculateTaskTotalSeconds(t)
if (t.project?.id && map.has(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)
}, [projects, tasks])
}, [projects, scopedTasks])
// Category breakdown
const categoryBreakdown = useMemo(() => {
@ -144,7 +174,7 @@ export const AnalyticsPage: React.FC = () => {
let unassignedSeconds = 0
let unassignedTasks = 0
tasks.forEach((t) => {
scopedTasks.forEach((t) => {
const sec = calculateTaskTotalSeconds(t)
if (t.category?.id && map.has(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)
}, [categories, tasks])
}, [categories, scopedTasks])
// Member breakdown
const memberBreakdown = useMemo(() => {
@ -252,6 +282,68 @@ export const AnalyticsPage: React.FC = () => {
</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 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 py-8">
{[1, 2, 3, 4].map((i) => (
@ -274,9 +366,14 @@ export const AnalyticsPage: React.FC = () => {
</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">
{formatSecondsToTimer(kpis.totalSecondsTracked)}
</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">
Equivalente a {formatSecondsToHuman(kpis.totalSecondsTracked)}
</p>
@ -292,9 +389,14 @@ export const AnalyticsPage: React.FC = () => {
</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">
{kpis.completionRate}%
</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">
{kpis.completedTasks} de {kpis.totalTasks} demandas finalizadas
</p>
@ -310,9 +412,14 @@ export const AnalyticsPage: React.FC = () => {
</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">
{kpis.inProgressTasks} tarefas
</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">
{kpis.notStartedTasks} no backlog aguardando início
</p>
@ -328,9 +435,14 @@ export const AnalyticsPage: React.FC = () => {
</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">
{formatSecondsToHuman(kpis.avgSecondsPerCompletedTask)}
</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">
Tempo médio gasto em cada entrega concluída
</p>

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import {
Clock,
CheckCircle2,
@ -21,6 +21,8 @@ import {
Settings2,
BarChart3,
ExternalLink,
User,
Users,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useTimer } from '../contexts/TimerContext'
@ -40,6 +42,15 @@ export const DashboardPage: React.FC = () => {
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
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 [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
@ -71,6 +82,29 @@ export const DashboardPage: React.FC = () => {
// Modal
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 loadData = useCallback(async () => {
@ -102,21 +136,39 @@ export const DashboardPage: React.FC = () => {
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 inProgress = tasks.filter((t) => t.status === 'IN_PROGRESS').length
const completed = tasks.filter((t) => t.status === 'COMPLETED').length
const notStarted = tasks.filter((t) => t.status === 'NOT_STARTED').length
const totalSeconds = tasks.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0)
const inProgress = scopeTasks.filter((t) => t.status === 'IN_PROGRESS').length
const completed = scopeTasks.filter((t) => t.status === 'COMPLETED').length
const notStarted = scopeTasks.filter((t) => t.status === 'NOT_STARTED').length
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 {
inProgress,
completed,
notStarted,
totalSeconds,
projectsCount: projects.length,
projectsCount,
}
}, [tasks, projects])
}, [scopeTasks, projects, viewScope])
// Work Goal Progress in Current Period
const goalProgress = useMemo(() => {
@ -124,7 +176,7 @@ export const DashboardPage: React.FC = () => {
const workedSeconds = calculateUserPeriodWorkedSeconds(
tasks,
workGoal?.period || 'DAILY',
activeTask?.id,
currentActiveTask?.id,
activeSeconds
)
const percent = Math.min(100, Math.round((workedSeconds / targetSeconds) * 100))
@ -152,17 +204,17 @@ export const DashboardPage: React.FC = () => {
targetHours: workGoal?.targetHours || 8,
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(() => {
return [...tasks].slice(0, 5)
}, [tasks])
return [...scopeTasks].slice(0, 5)
}, [scopeTasks])
// Toggle timer on a task
const handleToggleTimer = async (task: Task) => {
try {
if (activeTask?.id === task.id) {
if (currentActiveTask?.id === task.id) {
const stopped = await stopTimer(task)
if (stopped) {
setTasks((prev) => prev.map((t) => (t.id === stopped.id ? stopped : t)))
@ -201,10 +253,19 @@ export const DashboardPage: React.FC = () => {
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(() => {
return tasks.find((t) => t.status === 'IN_PROGRESS') || tasks.find((t) => t.status === 'NOT_STARTED') || tasks[0]
}, [tasks])
const myTasks = tasks.filter((t) => {
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'
@ -288,6 +349,64 @@ export const DashboardPage: React.FC = () => {
</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 */}
{loading ? (
<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 */}
<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">
<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">
<Clock className="w-4 h-4" />
</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">
{stats.inProgress}
</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">
{stats.notStarted} tarefas aguardando no backlog
{viewScope === 'mine'
? `${stats.notStarted} na sua fila aguardando`
: `${stats.notStarted} tarefas aguardando no backlog`}
</p>
</div>
</div>
@ -321,17 +451,26 @@ export const DashboardPage: React.FC = () => {
{/* 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="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">
<CheckCircle2 className="w-4 h-4" />
</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">
{stats.completed}
</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">
de {tasks.length} demandas cadastradas
{viewScope === 'mine'
? `de ${myTasks.length} demandas suas`
: `de ${tasks.length} demandas cadastradas`}
</p>
</div>
</div>
@ -339,17 +478,26 @@ export const DashboardPage: React.FC = () => {
{/* 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="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">
<TrendingUp className="w-4 h-4" />
</div>
</div>
<div>
<div className="flex items-baseline justify-between">
<p className="text-3xl font-extrabold text-amber-400 tracking-tight font-mono">
{formatSecondsToTimer(stats.totalSeconds)}
</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">
{formatSecondsToHuman(stats.totalSeconds)} auditadas no time
{viewScope === 'mine'
? `${formatSecondsToHuman(stats.totalSeconds)} auditadas por você`
: `${formatSecondsToHuman(stats.totalSeconds)} auditadas no time`}
</p>
</div>
</div>
@ -357,17 +505,26 @@ export const DashboardPage: React.FC = () => {
{/* 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="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">
<FolderKanban className="w-4 h-4 text-brand" />
</div>
</div>
<div>
<div className="flex items-baseline justify-between">
<p className="text-3xl font-extrabold text-foreground tracking-tight font-mono">
{stats.projectsCount}
</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">
Iniciativas ativas no workspace
{viewScope === 'mine'
? 'Iniciativas com tarefas suas'
: 'Iniciativas ativas no workspace'}
</p>
</div>
</div>
@ -394,43 +551,43 @@ export const DashboardPage: React.FC = () => {
<div className="flex items-center gap-1.5">
<span
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
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>
</div>
</div>
{/* 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="flex items-center gap-2 flex-wrap">
{activeTask.project && (
{currentActiveTask.project && (
<span
className="px-2 py-0.5 rounded-md text-[10px] font-semibold border"
style={{
backgroundColor: activeTask.project.color ? `${activeTask.project.color}15` : 'rgba(15,118,110,0.1)',
color: activeTask.project.color || '#0f766e',
borderColor: activeTask.project.color ? `${activeTask.project.color}35` : 'rgba(15,118,110,0.25)',
backgroundColor: currentActiveTask.project.color ? `${currentActiveTask.project.color}15` : 'rgba(15,118,110,0.1)',
color: currentActiveTask.project.color || '#0f766e',
borderColor: currentActiveTask.project.color ? `${currentActiveTask.project.color}35` : 'rgba(15,118,110,0.25)',
}}
>
{activeTask.project.name}
{currentActiveTask.project.name}
</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">
{activeTask.category.name}
{currentActiveTask.category.name}
</span>
)}
</div>
<h3 className="text-sm font-bold text-foreground line-clamp-2 leading-snug">
{activeTask.title}
{currentActiveTask.title}
</h3>
</div>
) : suggestedTask ? (
@ -458,19 +615,19 @@ export const DashboardPage: React.FC = () => {
{/* Big Bold Timer Display */}
<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">
{activeTask ? 'Tempo Decorrido na Tarefa' : 'Pronto para Iniciar'}
{currentActiveTask ? 'Tempo Decorrido na Tarefa' : 'Pronto para Iniciar'}
</span>
<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>
</div>
{/* Timer Actions */}
{activeTask ? (
{currentActiveTask ? (
<button
type="button"
onClick={() => stopTimer(activeTask)}
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"
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 cursor-pointer"
>
<Square className="w-4 h-4 fill-rose-300" />
<span>Pausar Cronômetro</span>
@ -607,7 +764,7 @@ export const DashboardPage: React.FC = () => {
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
<Zap className="w-4 h-4 text-accent" />
Tarefas Recentes da Equipe
{viewScope === 'mine' ? 'Minhas Demandas Recentes' : 'Demandas Recentes da Equipe'}
</h2>
<Link
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">
<Activity className="w-8 h-8 text-muted mx-auto mb-2" />
<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>
<button
type="button"
@ -645,7 +804,7 @@ export const DashboardPage: React.FC = () => {
) : (
<div className="space-y-3">
{recentTasks.map((task) => {
const isTracking = activeTask?.id === task.id
const isTracking = currentActiveTask?.id === task.id
const taskSec = isTracking ? activeSeconds : calculateTaskTotalSeconds(task)
return (
@ -659,6 +818,34 @@ export const DashboardPage: React.FC = () => {
>
<div className="space-y-1.5 flex-1 min-w-0">
<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 && (
<span
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 { Link } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import {
Plus,
RefreshCw,
@ -9,6 +9,8 @@ import {
LayoutDashboard,
AlertCircle,
Check,
User,
Users,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useTimer } from '../contexts/TimerContext'
@ -51,6 +53,29 @@ export const DashboardV2Page: React.FC = () => {
const [editingCard, setEditingCard] = useState<DashboardV2Card | null>(null)
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
// Load backend data
@ -341,6 +366,50 @@ export const DashboardV2Page: React.FC = () => {
</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 */}
{loading ? (
<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}
activeSeconds={activeSeconds}
workGoal={workGoal}
globalScope={viewScope}
isBeingDragged={draggedCardId === card.id}
isDragOver={dragOverCardId === card.id}
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 {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Input,
Chip,
ChipLabel,
Alert,
AlertTitle,
AlertDescription,
} from '@heroui/react'
import {
Building2,
Plus,
ArrowRight,
Users,
ShieldCheck,
Search,
Clock,
CheckCircle2,
XCircle,
Send,
Check,
Loader2,
Inbox,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { organizationService } from '../services/organizationService'
import {
organizationService,
type SearchOrgOutput,
type JoinRequestOutput,
} from '../services/organizationService'
export const OrganizationsPage = () => {
const { user, activeOrg, setActiveOrg, refreshUser } = useAuth()
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 [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 [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) => {
e.preventDefault()
if (!newOrgName.trim()) return
@ -63,180 +96,574 @@ export const OrganizationsPage = () => {
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 (
<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 */}
<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" />
<span>Gestão de Workspaces Corporativos</span>
<span>Gestão de Workspaces & Organizações</span>
</div>
<h1 className="text-3xl font-extrabold text-text-main tracking-tight">
Seus Workspaces & Organizações
<h1 className="text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight">
Workspaces Corporativos
</h1>
<p className="text-sm text-text-muted max-w-2xl">
Selecione uma organização ativa para acessar projetos, tarefas e métricas da equipe,
ou crie um novo workspace dedicado para sua empresa ou squad.
<p className="text-xs sm:text-sm text-muted max-w-2xl">
Navegue entre seus workspaces ativos, solicite entrada em uma organização existente ou
crie um novo ambiente dedicado para seu squad ou empresa.
</p>
</div>
{/* Notifications */}
{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">
<AlertTitle className="font-bold">Não foi possível criar</AlertTitle>
<AlertDescription className="text-xs mt-0.5">{errorMessage}</AlertDescription>
<p className="font-bold">Ocorreu um erro</p>
<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>
</Alert>
)}
{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">
<AlertTitle className="font-bold">Sucesso</AlertTitle>
<AlertDescription className="text-xs mt-0.5">{successMessage}</AlertDescription>
<p className="font-bold">Sucesso</p>
<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>
</Alert>
)}
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
{/* Existing Organizations (7 cols) */}
<div className="md:col-span-7 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-bold text-text-main flex items-center gap-2">
<Users className="w-4 h-4 text-brand" />
{/* Tabs Navigation */}
<div className="flex items-center gap-2 border-b border-border-soft pb-2 overflow-x-auto">
<button
type="button"
onClick={() => setActiveTab('workspaces')}
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})
</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>
{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) => {
const isActive = org.id === activeOrg?.id
return (
<Card
<div
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)}
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={`w-11 h-11 rounded-xl flex items-center justify-center font-bold text-sm shrink-0 shadow-xs ${
isActive
? '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()}
</div>
<div className="truncate">
<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}
</h3>
{isActive && (
<Chip className="bg-brand-soft text-brand text-[10px] font-bold px-2 py-0.2">
<ChipLabel>ATIVO</ChipLabel>
</Chip>
<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">
Ativo
</span>
)}
</div>
<p className="text-xs text-text-muted mt-0.5 font-medium">
Papel: <span className="text-brand font-semibold">{org.roles.join(', ')}</span>
<p className="text-xs text-muted mt-0.5 font-medium">
Papel:{' '}
<span className="text-brand font-semibold">
{org.roles.join(', ')}
</span>
</p>
</div>
</div>
<Button
<button
type="button"
onClick={(e) => {
e.stopPropagation()
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
? 'bg-brand text-white shadow-sm'
: 'bg-surface-muted text-text-muted hover:text-text-main hover:bg-border-soft'
? 'bg-brand text-white shadow-xs'
: 'bg-surface-raised border border-border-soft text-muted hover:text-foreground hover:bg-surface-sunken'
}`}
>
{isActive ? 'Acessar' : 'Selecionar'}
</Button>
</Card>
</button>
</div>
)
})}
</div>
) : (
<Card className="bg-surface border border-border-soft rounded-2xl 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="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-500/15 text-amber-500 mx-auto flex items-center justify-center">
<Building2 className="w-6 h-6" />
</div>
<h3 className="text-base font-bold text-text-main">
Nenhum workspace encontrado
<h3 className="text-base font-bold text-foreground">
Você ainda não está vinculado a nenhum workspace
</h3>
<p className="text-xs text-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!
<p className="text-xs text-muted max-w-sm mx-auto">
Você pode solicitar entrada em uma organização existente ou criar um novo workspace para sua equipe.
</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>
{/* Create New Workspace Form (5 cols) */}
<div className="md:col-span-5 space-y-4">
<Card className="bg-surface border border-border-soft rounded-3xl p-6 shadow-panel space-y-5">
<CardHeader className="p-0 space-y-1">
<div className="w-10 h-10 rounded-xl bg-accent-soft text-accent flex items-center justify-center mb-1">
{/* My Join Requests Status Section */}
<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 className="flex items-center justify-between">
<div>
<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" />
</div>
<CardTitle className="text-base font-bold text-text-main">
<h2 className="text-lg font-bold text-foreground">
Criar Novo Workspace
</CardTitle>
<CardDescription className="text-xs text-text-muted">
Você será automaticamente configurado como <strong>OWNER</strong>.
</CardDescription>
</CardHeader>
</h2>
<p className="text-xs text-muted">
Você será automaticamente configurado como <strong>PROPRIETÁRIO (OWNER)</strong> deste workspace.
</p>
</div>
<CardContent className="p-0">
<form onSubmit={handleCreateOrg} className="space-y-4">
<form onSubmit={handleCreateOrg} className="space-y-4 max-w-lg">
<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
</label>
<Input
<input
type="text"
required
value={newOrgName}
onChange={(e) => setNewOrgName(e.target.value)}
placeholder="Ex: Minha Empresa Corp"
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"
placeholder="Ex: Minha Empresa Corp, Tech Labs..."
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>
<Button
<button
type="submit"
isDisabled={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"
disabled={isCreating || !newOrgName.trim()}
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 ? (
<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" />
</>
)}
</Button>
</button>
</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" />
<span>Multi-tenancy com isolamento de dados</span>
</div>
</Card>
<span>Multi-tenancy com isolamento seguro de projetos, tarefas e métricas da equipe</span>
</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 {
Mail,
@ -9,9 +9,15 @@ import {
CheckCircle2,
Save,
Sparkles,
Camera,
Edit2,
AlertCircle,
X,
Check,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { taskService } from '../services/taskService'
import { authService } from '../services/authService'
import type { Task } from '../types/task'
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
import { CustomSelect } from '../components/ui/Select'
@ -23,17 +29,101 @@ import {
} from '../lib/userGoals'
export const ProfilePage: React.FC = () => {
const { user, activeOrg, setActiveOrg, logout } = useAuth()
const { user, activeOrg, setActiveOrg, logout, updateUser, refreshUser } = useAuth()
const navigate = useNavigate()
const [myTasks, setMyTasks] = useState<Task[]>([])
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)
const [goalPeriod, setGoalPeriod] = useState<WorkGoalPeriod>('DAILY')
const [goalHours, setGoalHours] = useState<number>(8)
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(() => {
if (user?.email) {
const g = getUserWorkGoal(user.email)
@ -97,10 +187,46 @@ export const ProfilePage: React.FC = () => {
</div>
{/* 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="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-border-soft/60">
<div className="flex items-center gap-4">
<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="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-6 sm:p-8 shadow-panel space-y-6">
{/* Status Alerts */}
{photoSuccess && (
<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 ? (
<img
src={user.photo}
@ -112,20 +238,114 @@ export const ProfilePage: React.FC = () => {
) : (
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>
<h2 className="text-lg font-bold text-foreground">{user.name || 'Usuário'}</h2>
<p className="text-xs text-muted flex items-center gap-1.5 mt-0.5">
{/* Uploading Overlay */}
{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" />
{user.email}
</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>
<button
type="button"
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" />
<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 {
LayoutGrid,
List as ListIcon,
@ -38,6 +38,11 @@ export const TasksPage: React.FC = () => {
const { activeOrg, user } = useAuth()
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
const syncWithTasksRef = useRef(syncWithTasks)
useEffect(() => {
syncWithTasksRef.current = syncWithTasks
}, [syncWithTasks])
const [tasks, setTasks] = useState<Task[]>([])
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
@ -135,7 +140,7 @@ export const TasksPage: React.FC = () => {
setTasks(tasksRes || [])
setProjects(projsRes || [])
syncWithTasks(tasksRes || [])
syncWithTasksRef.current(tasksRes || [])
} catch (err) {
console.error('Failed to load tasks', err)
setActionError('Falha ao carregar tarefas. Tente atualizar a página.')
@ -143,7 +148,7 @@ export const TasksPage: React.FC = () => {
setLoading(false)
setRefreshing(false)
}
}, [activeOrgId, onlyMyTasks, syncWithTasks])
}, [activeOrgId, onlyMyTasks])
useEffect(() => {
loadData()
@ -277,6 +282,29 @@ export const TasksPage: React.FC = () => {
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
const notStartedCount = tasks.filter((t) => t.status === 'NOT_STARTED').length
const inProgressCount = tasks.filter((t) => t.status === 'IN_PROGRESS').length
@ -682,6 +710,7 @@ export const TasksPage: React.FC = () => {
onStatusChange={handleStatusChange}
onEditTask={handleOpenEditModal}
onOpenHistory={handleOpenHistoryModal}
onDeleteTask={handleDeleteTask}
onAddTaskToStatus={handleOpenCreateModal}
activeTaskId={activeTask?.id}
activeTaskSeconds={activeSeconds}
@ -697,6 +726,7 @@ export const TasksPage: React.FC = () => {
onStatusChange={handleStatusChange}
onEditTask={handleOpenEditModal}
onOpenHistory={handleOpenHistoryModal}
onDeleteTask={handleDeleteTask}
activeTaskId={activeTask?.id}
activeTaskSeconds={activeSeconds}
/>
@ -711,6 +741,7 @@ export const TasksPage: React.FC = () => {
setEditingTask(null)
}}
onSubmit={handleModalSubmit}
onDelete={handleDeleteTask}
organizationId={activeOrgId}
initialStatus={modalInitialStatus}
initialTask={editingTask}

View file

@ -63,4 +63,20 @@ export const authService = {
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',
})
},
async deleteTask(id: number): Promise<void> {
return apiFetch<void>(`/tasks/${id}`, {
method: 'DELETE',
})
},
}

View file

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