feat: add scope toggles for individual vs team metrics, add GEMINI.md guidelines
This commit is contained in:
parent
d1bfe4d1cb
commit
d3174653b1
91 changed files with 2973 additions and 363 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,4 +3,6 @@
|
||||||
.vscode
|
.vscode
|
||||||
.env
|
.env
|
||||||
*.pem
|
*.pem
|
||||||
|
*.sql
|
||||||
|
*.sql.gz
|
||||||
|
|
||||||
|
|
|
||||||
47
GEMINI.md
Normal file
47
GEMINI.md
Normal 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).
|
||||||
|
|
@ -84,6 +84,11 @@
|
||||||
<artifactId>minio</artifactId>
|
<artifactId>minio</artifactId>
|
||||||
<version>8.5.17</version>
|
<version>8.5.17</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mindrot</groupId>
|
||||||
|
<artifactId>jbcrypt</artifactId>
|
||||||
|
<version>0.4</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-junit</artifactId>
|
<artifactId>quarkus-junit</artifactId>
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,38 @@ import java.util.Objects;
|
||||||
import net.beetechgroup.beetask.entities.Category;
|
import net.beetechgroup.beetask.entities.Category;
|
||||||
import net.beetechgroup.beetask.entities.Project;
|
import net.beetechgroup.beetask.entities.Project;
|
||||||
import net.beetechgroup.beetask.entities.User;
|
import net.beetechgroup.beetask.entities.User;
|
||||||
|
import net.beetechgroup.beetask.entities.organization.Organization;
|
||||||
|
|
||||||
public class Task {
|
public class Task {
|
||||||
private Long id;
|
private Long id;
|
||||||
private String title;
|
private String title;
|
||||||
private String description;
|
private String description;
|
||||||
private TaskStatus status;
|
private TaskStatus status;
|
||||||
|
private Organization organization;
|
||||||
private Project project;
|
private Project project;
|
||||||
private Category category;
|
private Category category;
|
||||||
private User user;
|
private User user;
|
||||||
|
private User createdBy;
|
||||||
private LocalDateTime finishedAt;
|
private LocalDateTime finishedAt;
|
||||||
private List<TaskHistoryItem> history = new ArrayList<>();
|
private List<TaskHistoryItem> history = new ArrayList<>();
|
||||||
private String externalUrl;
|
private String externalUrl;
|
||||||
|
|
||||||
|
public Organization getOrganization() {
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrganization(Organization organization) {
|
||||||
|
this.organization = organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getCreatedBy() {
|
||||||
|
return createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedBy(User createdBy) {
|
||||||
|
this.createdBy = createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
public String getExternalUrl() {
|
public String getExternalUrl() {
|
||||||
return externalUrl;
|
return externalUrl;
|
||||||
}
|
}
|
||||||
|
|
@ -60,11 +79,18 @@ public class Task {
|
||||||
public void setStatus(TaskStatus status) {
|
public void setStatus(TaskStatus status) {
|
||||||
if (this.status == status) return;
|
if (this.status == status) return;
|
||||||
|
|
||||||
|
if (status == TaskStatus.COMPLETED || status == TaskStatus.CANCELED) {
|
||||||
|
if (isRunning()) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
if (status == TaskStatus.COMPLETED) {
|
if (status == TaskStatus.COMPLETED) {
|
||||||
this.finishedAt = LocalDateTime.now();
|
this.finishedAt = LocalDateTime.now();
|
||||||
} else {
|
} else {
|
||||||
this.finishedAt = null;
|
this.finishedAt = null;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.finishedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +147,10 @@ public class Task {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void start() {
|
public void start() {
|
||||||
|
start(this.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void start(User worker) {
|
||||||
TaskHistoryItem current = getCurrentRunningItem();
|
TaskHistoryItem current = getCurrentRunningItem();
|
||||||
|
|
||||||
if (Objects.nonNull(current)) {
|
if (Objects.nonNull(current)) {
|
||||||
|
|
@ -130,6 +160,7 @@ public class Task {
|
||||||
this.status = TaskStatus.IN_PROGRESS;
|
this.status = TaskStatus.IN_PROGRESS;
|
||||||
TaskHistoryItem item = new TaskHistoryItem();
|
TaskHistoryItem item = new TaskHistoryItem();
|
||||||
item.setStartAt(LocalDateTime.now());
|
item.setStartAt(LocalDateTime.now());
|
||||||
|
item.setUser(worker != null ? worker : this.user);
|
||||||
this.history.add(item);
|
this.history.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,15 @@ public class TaskHistoryItem {
|
||||||
private Long id;
|
private Long id;
|
||||||
private LocalDateTime startAt;
|
private LocalDateTime startAt;
|
||||||
private LocalDateTime endAt;
|
private LocalDateTime endAt;
|
||||||
|
private net.beetechgroup.beetask.entities.User user;
|
||||||
|
|
||||||
|
public net.beetechgroup.beetask.entities.User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(net.beetechgroup.beetask.entities.User user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import net.beetechgroup.beetask.usecase.category.create.CreateCategoryUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.category.list.ListCategoriesUseCase;
|
import net.beetechgroup.beetask.usecase.category.list.ListCategoriesUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.category.update.UpdateCategoryUseCase;
|
import net.beetechgroup.beetask.usecase.category.update.UpdateCategoryUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||||
|
|
||||||
|
|
@ -26,7 +27,9 @@ public class CategoryUseCaseConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public ListCategoriesUseCase listCategoriesUseCase(CategoryRepository categoryRepository) {
|
public ListCategoriesUseCase listCategoriesUseCase(CategoryRepository categoryRepository,
|
||||||
return new ListCategoriesUseCase(categoryRepository);
|
AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
|
return new ListCategoriesUseCase(categoryRepository, authorizer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import net.beetechgroup.beetask.usecase.organization.requests.ListPendingRequest
|
||||||
import net.beetechgroup.beetask.usecase.organization.requests.ListUserJoinRequestsUseCase;
|
import net.beetechgroup.beetask.usecase.organization.requests.ListUserJoinRequestsUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.organization.requests.HandleJoinRequestUseCase;
|
import net.beetechgroup.beetask.usecase.organization.requests.HandleJoinRequestUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase;
|
import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||||
|
|
@ -23,6 +24,12 @@ public class OrganizationUseCaseConfig {
|
||||||
return new AuthorizeOrganizationAdminUseCase(userRepository, userOrganizationRepository);
|
return new AuthorizeOrganizationAdminUseCase(userRepository, userOrganizationRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public AuthorizeOrganizationMemberUseCase authorizeOrganizationMemberUseCase(UserRepository userRepository,
|
||||||
|
UserOrganizationRepository userOrganizationRepository) {
|
||||||
|
return new AuthorizeOrganizationMemberUseCase(userRepository, userOrganizationRepository);
|
||||||
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository,
|
public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
|
|
@ -65,4 +72,24 @@ public class OrganizationUseCaseConfig {
|
||||||
UserOrganizationRepository userOrganizationRepository) {
|
UserOrganizationRepository userOrganizationRepository) {
|
||||||
return new ListOrganizationMembersUseCase(userRepository, userOrganizationRepository);
|
return new ListOrganizationMembersUseCase(userRepository, userOrganizationRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public net.beetechgroup.beetask.usecase.organization.members.RemoveOrganizationMemberUseCase removeOrganizationMemberUseCase(
|
||||||
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
AuthorizeOrganizationAdminUseCase adminAuthorizer,
|
||||||
|
jakarta.persistence.EntityManager entityManager) {
|
||||||
|
return new net.beetechgroup.beetask.usecase.organization.members.RemoveOrganizationMemberUseCase(
|
||||||
|
userOrganizationRepository, userRepository, adminAuthorizer, entityManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public net.beetechgroup.beetask.usecase.organization.members.LeaveOrganizationUseCase leaveOrganizationUseCase(
|
||||||
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase memberAuthorizer,
|
||||||
|
jakarta.persistence.EntityManager entityManager) {
|
||||||
|
return new net.beetechgroup.beetask.usecase.organization.members.LeaveOrganizationUseCase(
|
||||||
|
userOrganizationRepository, userRepository, memberAuthorizer, entityManager);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package net.beetechgroup.beetask.frameworks.config;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.enterprise.inject.Produces;
|
import jakarta.enterprise.inject.Produces;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.project.create.CreateProjectUseCase;
|
import net.beetechgroup.beetask.usecase.project.create.CreateProjectUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.project.list.ListProjectsUseCase;
|
import net.beetechgroup.beetask.usecase.project.list.ListProjectsUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||||
|
|
@ -12,12 +13,29 @@ public class ProjectUseCaseConfig {
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public CreateProjectUseCase createProjectUseCase(ProjectRepository projectRepository,
|
public CreateProjectUseCase createProjectUseCase(ProjectRepository projectRepository,
|
||||||
OrganizationRepository organizationRepository) {
|
OrganizationRepository organizationRepository,
|
||||||
return new CreateProjectUseCase(projectRepository, organizationRepository);
|
AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
|
return new CreateProjectUseCase(projectRepository, organizationRepository, authorizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public ListProjectsUseCase listProjectsUseCase(ProjectRepository projectRepository) {
|
public ListProjectsUseCase listProjectsUseCase(ProjectRepository projectRepository,
|
||||||
return new ListProjectsUseCase(projectRepository);
|
AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
|
return new ListProjectsUseCase(projectRepository, authorizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public net.beetechgroup.beetask.usecase.project.update.UpdateProjectUseCase updateProjectUseCase(
|
||||||
|
ProjectRepository projectRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
|
return new net.beetechgroup.beetask.usecase.project.update.UpdateProjectUseCase(projectRepository, authorizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public net.beetechgroup.beetask.usecase.project.delete.DeleteProjectUseCase deleteProjectUseCase(
|
||||||
|
ProjectRepository projectRepository,
|
||||||
|
net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationAdminUseCase authorizer) {
|
||||||
|
return new net.beetechgroup.beetask.usecase.project.delete.DeleteProjectUseCase(projectRepository, authorizer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package net.beetechgroup.beetask.frameworks.config;
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.enterprise.inject.Produces;
|
import jakarta.enterprise.inject.Produces;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
|
|
@ -22,18 +23,19 @@ public class TaskUseCaseConfig {
|
||||||
@Produces
|
@Produces
|
||||||
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||||
UserOrganizationRepository userOrganizationRepository) {
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository);
|
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
|
||||||
|
return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository) {
|
public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
return new StartTaskUseCase(taskRepository, userRepository);
|
return new StartTaskUseCase(taskRepository, userRepository, authorizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public StopTaskUseCase stopTaskUseCase(TaskRepository taskRepository) {
|
public StopTaskUseCase stopTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
return new StopTaskUseCase(taskRepository);
|
return new StopTaskUseCase(taskRepository, authorizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
|
|
@ -47,19 +49,29 @@ public class TaskUseCaseConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository) {
|
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
return new UpdateTaskStatusUseCase(taskRepository);
|
return new UpdateTaskStatusUseCase(taskRepository, authorizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||||
UserOrganizationRepository userOrganizationRepository) {
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository);
|
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
|
||||||
|
return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public GetTaskUseCase getTaskUseCase(TaskRepository taskRepository) {
|
public GetTaskUseCase getTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
return new GetTaskUseCase(taskRepository);
|
return new GetTaskUseCase(taskRepository, authorizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase deleteTaskUseCase(
|
||||||
|
TaskRepository taskRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
UserOrganizationRepository userOrganizationRepository) {
|
||||||
|
return new net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase(taskRepository, userRepository, userOrganizationRepository);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,4 +63,9 @@ public class UserUseCaseConfig {
|
||||||
public net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase updateUserPreferencesUseCase(UserRepository userRepository) {
|
public net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase updateUserPreferencesUseCase(UserRepository userRepository) {
|
||||||
return new net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase(userRepository);
|
return new net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase(userRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
public net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase listAllUsersUseCase(UserRepository userRepository) {
|
||||||
|
return new net.beetechgroup.beetask.usecase.user.list.ListAllUsersUseCase(userRepository);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,14 @@ import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import org.hibernate.annotations.SQLDelete;
|
||||||
|
import org.hibernate.annotations.SQLRestriction;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "categories")
|
@Table(name = "categories")
|
||||||
|
@SQLDelete(sql = "UPDATE categories SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
|
@SQLRestriction("deleted_at IS NULL")
|
||||||
public class CategoryEntity {
|
public class CategoryEntity {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
|
@ -17,6 +22,8 @@ public class CategoryEntity {
|
||||||
private String name;
|
private String name;
|
||||||
private String color;
|
private String color;
|
||||||
private String icon;
|
private String icon;
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private java.time.LocalDateTime deletedAt;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "organization_id")
|
@JoinColumn(name = "organization_id")
|
||||||
|
|
@ -61,4 +68,12 @@ public class CategoryEntity {
|
||||||
public void setOrganization(OrganizationEntity organization) {
|
public void setOrganization(OrganizationEntity organization) {
|
||||||
this.organization = organization;
|
this.organization = organization;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public java.time.LocalDateTime getDeletedAt() {
|
||||||
|
return deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeletedAt(java.time.LocalDateTime deletedAt) {
|
||||||
|
this.deletedAt = deletedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,21 @@ import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.OneToMany;
|
import jakarta.persistence.OneToMany;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import org.hibernate.annotations.SQLDelete;
|
||||||
|
import org.hibernate.annotations.SQLRestriction;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "organizations")
|
@Table(name = "organizations")
|
||||||
|
@SQLDelete(sql = "UPDATE organizations SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
|
@SQLRestriction("deleted_at IS NULL")
|
||||||
public class OrganizationEntity {
|
public class OrganizationEntity {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
private String name;
|
private String name;
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private java.time.LocalDateTime deletedAt;
|
||||||
@OneToMany(mappedBy = "organization")
|
@OneToMany(mappedBy = "organization")
|
||||||
private List<UserOrganizationEntity> userOrganizations;
|
private List<UserOrganizationEntity> userOrganizations;
|
||||||
|
|
||||||
|
|
@ -43,4 +50,11 @@ public class OrganizationEntity {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public java.time.LocalDateTime getDeletedAt() {
|
||||||
|
return deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeletedAt(java.time.LocalDateTime deletedAt) {
|
||||||
|
this.deletedAt = deletedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,14 @@ import jakarta.persistence.JoinTable;
|
||||||
import jakarta.persistence.ManyToMany;
|
import jakarta.persistence.ManyToMany;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import org.hibernate.annotations.SQLDelete;
|
||||||
|
import org.hibernate.annotations.SQLRestriction;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "projects")
|
@Table(name = "projects")
|
||||||
|
@SQLDelete(sql = "UPDATE projects SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
|
@SQLRestriction("deleted_at IS NULL")
|
||||||
public class ProjectEntity {
|
public class ProjectEntity {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
|
@ -21,6 +26,8 @@ public class ProjectEntity {
|
||||||
private String name;
|
private String name;
|
||||||
private String color;
|
private String color;
|
||||||
private String icon;
|
private String icon;
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private java.time.LocalDateTime deletedAt;
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "organization_id")
|
@JoinColumn(name = "organization_id")
|
||||||
private OrganizationEntity organization;
|
private OrganizationEntity organization;
|
||||||
|
|
@ -76,4 +83,12 @@ public class ProjectEntity {
|
||||||
public void setUsers(List<UserEntity> users) {
|
public void setUsers(List<UserEntity> users) {
|
||||||
this.users = users;
|
this.users = users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public java.time.LocalDateTime getDeletedAt() {
|
||||||
|
return deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeletedAt(java.time.LocalDateTime deletedAt) {
|
||||||
|
this.deletedAt = deletedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,14 @@ import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.OneToMany;
|
import jakarta.persistence.OneToMany;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.annotations.SQLDelete;
|
||||||
|
import org.hibernate.annotations.SQLRestriction;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "tasks")
|
@Table(name = "tasks")
|
||||||
|
@SQLDelete(sql = "UPDATE tasks SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
|
@SQLRestriction("deleted_at IS NULL")
|
||||||
public class TaskEntity {
|
public class TaskEntity {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
|
@ -30,7 +34,12 @@ public class TaskEntity {
|
||||||
private java.time.LocalDateTime finishedAt;
|
private java.time.LocalDateTime finishedAt;
|
||||||
@Column(name = "external_url", length = 1000)
|
@Column(name = "external_url", length = 1000)
|
||||||
private String externalUrl;
|
private String externalUrl;
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private java.time.LocalDateTime deletedAt;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "organization_id")
|
||||||
|
private OrganizationEntity organization;
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "project_id")
|
@JoinColumn(name = "project_id")
|
||||||
private ProjectEntity project;
|
private ProjectEntity project;
|
||||||
|
|
@ -40,9 +49,28 @@ public class TaskEntity {
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "user_id")
|
@JoinColumn(name = "user_id")
|
||||||
private UserEntity user;
|
private UserEntity user;
|
||||||
@OneToMany(mappedBy = "task", cascade = CascadeType.ALL, orphanRemoval = true)
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "created_by_id")
|
||||||
|
private UserEntity createdBy;
|
||||||
|
@OneToMany(mappedBy = "task", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
|
||||||
private List<TaskHistoryItemEntity> history;
|
private List<TaskHistoryItemEntity> history;
|
||||||
|
|
||||||
|
public OrganizationEntity getOrganization() {
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrganization(OrganizationEntity organization) {
|
||||||
|
this.organization = organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserEntity getCreatedBy() {
|
||||||
|
return createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedBy(UserEntity createdBy) {
|
||||||
|
this.createdBy = createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
public void setHistory(List<TaskHistoryItemEntity> history) {
|
public void setHistory(List<TaskHistoryItemEntity> history) {
|
||||||
this.history = history;
|
this.history = history;
|
||||||
}
|
}
|
||||||
|
|
@ -122,4 +150,12 @@ public class TaskEntity {
|
||||||
public void setExternalUrl(String externalUrl) {
|
public void setExternalUrl(String externalUrl) {
|
||||||
this.externalUrl = externalUrl;
|
this.externalUrl = externalUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public java.time.LocalDateTime getDeletedAt() {
|
||||||
|
return deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeletedAt(java.time.LocalDateTime deletedAt) {
|
||||||
|
this.deletedAt = deletedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,18 @@ public class TaskHistoryItemEntity {
|
||||||
@JoinColumn(name = "task_id")
|
@JoinColumn(name = "task_id")
|
||||||
private TaskEntity task;
|
private TaskEntity task;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "user_id")
|
||||||
|
private UserEntity user;
|
||||||
|
|
||||||
|
public UserEntity getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(UserEntity user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,14 @@ import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import org.hibernate.annotations.SQLDelete;
|
||||||
|
import org.hibernate.annotations.SQLRestriction;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "users_entity")
|
@Table(name = "users_entity")
|
||||||
|
@SQLDelete(sql = "UPDATE users_entity SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
|
@SQLRestriction("deleted_at IS NULL")
|
||||||
public class UserEntity {
|
public class UserEntity {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
|
@ -16,6 +21,8 @@ public class UserEntity {
|
||||||
private String email;
|
private String email;
|
||||||
private String password;
|
private String password;
|
||||||
private String photo;
|
private String photo;
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private java.time.LocalDateTime deletedAt;
|
||||||
@jakarta.persistence.Column(columnDefinition = "jsonb")
|
@jakarta.persistence.Column(columnDefinition = "jsonb")
|
||||||
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
|
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
|
||||||
private String preferences;
|
private String preferences;
|
||||||
|
|
@ -67,4 +74,12 @@ public class UserEntity {
|
||||||
public void setPreferences(String preferences) {
|
public void setPreferences(String preferences) {
|
||||||
this.preferences = preferences;
|
this.preferences = preferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public java.time.LocalDateTime getDeletedAt() {
|
||||||
|
return deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeletedAt(java.time.LocalDateTime deletedAt) {
|
||||||
|
this.deletedAt = deletedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package net.beetechgroup.beetask.frameworks.persistence.entities;
|
package net.beetechgroup.beetask.frameworks.persistence.entities;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
|
|
@ -11,9 +12,15 @@ import net.beetechgroup.beetask.entities.organization.UserOrganizationRole;
|
||||||
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
||||||
import jakarta.persistence.EnumType;
|
import jakarta.persistence.EnumType;
|
||||||
import jakarta.persistence.Enumerated;
|
import jakarta.persistence.Enumerated;
|
||||||
|
import org.hibernate.annotations.SQLDelete;
|
||||||
|
import org.hibernate.annotations.SQLRestriction;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "user_organizations")
|
@Table(name = "user_organizations")
|
||||||
|
@SQLDelete(sql = "UPDATE user_organizations SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
|
@SQLRestriction("deleted_at IS NULL")
|
||||||
public class UserOrganizationEntity {
|
public class UserOrganizationEntity {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
|
@ -28,6 +35,16 @@ public class UserOrganizationEntity {
|
||||||
private UserOrganizationRole role;
|
private UserOrganizationRole role;
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
private UserOrganizationStatus status;
|
private UserOrganizationStatus status;
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
public UserEntity getUser() {
|
public UserEntity getUser() {
|
||||||
return user;
|
return user;
|
||||||
|
|
@ -60,4 +77,12 @@ public class UserOrganizationEntity {
|
||||||
public void setStatus(UserOrganizationStatus status) {
|
public void setStatus(UserOrganizationStatus status) {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getDeletedAt() {
|
||||||
|
return deletedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeletedAt(LocalDateTime deletedAt) {
|
||||||
|
this.deletedAt = deletedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import net.beetechgroup.beetask.frameworks.persistence.entities.OrganizationEnti
|
||||||
|
|
||||||
public class OrganizationEntityMapper {
|
public class OrganizationEntityMapper {
|
||||||
public static OrganizationEntity toEntity(Organization organization) {
|
public static OrganizationEntity toEntity(Organization organization) {
|
||||||
|
if (Objects.isNull(organization)) return null;
|
||||||
OrganizationEntity entity = new OrganizationEntity();
|
OrganizationEntity entity = new OrganizationEntity();
|
||||||
entity.setId(organization.getId());
|
entity.setId(organization.getId());
|
||||||
entity.setName(organization.getName());
|
entity.setName(organization.getName());
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,11 @@ public class TaskEntityMapper {
|
||||||
entity.setStatus(task.getStatus());
|
entity.setStatus(task.getStatus());
|
||||||
entity.setFinishedAt(task.getFinishedAt());
|
entity.setFinishedAt(task.getFinishedAt());
|
||||||
entity.setExternalUrl(task.getExternalUrl());
|
entity.setExternalUrl(task.getExternalUrl());
|
||||||
|
entity.setOrganization(OrganizationEntityMapper.toEntity(task.getOrganization()));
|
||||||
entity.setProject(ProjectEntityMapper.toEntity(task.getProject()));
|
entity.setProject(ProjectEntityMapper.toEntity(task.getProject()));
|
||||||
entity.setCategory(CategoryEntityMapper.toEntity(task.getCategory()));
|
entity.setCategory(CategoryEntityMapper.toEntity(task.getCategory()));
|
||||||
entity.setUser(UserEntityMapper.toEntity(task.getUser()));
|
entity.setUser(UserEntityMapper.toEntity(task.getUser()));
|
||||||
|
entity.setCreatedBy(UserEntityMapper.toEntity(task.getCreatedBy()));
|
||||||
entity.setHistory(new ArrayList<>(task.getHistory().stream().map(h -> {
|
entity.setHistory(new ArrayList<>(task.getHistory().stream().map(h -> {
|
||||||
TaskHistoryItemEntity he = TaskHistoryItemMapper.toEntity(h);
|
TaskHistoryItemEntity he = TaskHistoryItemMapper.toEntity(h);
|
||||||
he.setTask(entity);
|
he.setTask(entity);
|
||||||
|
|
@ -38,9 +40,11 @@ public class TaskEntityMapper {
|
||||||
task.setStatus(entity.getStatus());
|
task.setStatus(entity.getStatus());
|
||||||
task.setFinishedAt(entity.getFinishedAt());
|
task.setFinishedAt(entity.getFinishedAt());
|
||||||
task.setExternalUrl(entity.getExternalUrl());
|
task.setExternalUrl(entity.getExternalUrl());
|
||||||
|
task.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization()));
|
||||||
task.setProject(ProjectEntityMapper.toDomain(entity.getProject()));
|
task.setProject(ProjectEntityMapper.toDomain(entity.getProject()));
|
||||||
task.setCategory(CategoryEntityMapper.toDomain(entity.getCategory()));
|
task.setCategory(CategoryEntityMapper.toDomain(entity.getCategory()));
|
||||||
task.setUser(UserEntityMapper.toDomain(entity.getUser()));
|
task.setUser(UserEntityMapper.toDomain(entity.getUser()));
|
||||||
|
task.setCreatedBy(UserEntityMapper.toDomain(entity.getCreatedBy()));
|
||||||
task.setHistory(new ArrayList<>(entity.getHistory().stream().map(TaskHistoryItemMapper::toDomain).toList()));
|
task.setHistory(new ArrayList<>(entity.getHistory().stream().map(TaskHistoryItemMapper::toDomain).toList()));
|
||||||
|
|
||||||
return task;
|
return task;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ public class TaskHistoryItemMapper {
|
||||||
entity.setId(item.getId());
|
entity.setId(item.getId());
|
||||||
entity.setStartAt(item.getStartAt());
|
entity.setStartAt(item.getStartAt());
|
||||||
entity.setEndAt(item.getEndAt());
|
entity.setEndAt(item.getEndAt());
|
||||||
|
if (Objects.nonNull(item.getUser())) {
|
||||||
|
entity.setUser(UserEntityMapper.toEntity(item.getUser()));
|
||||||
|
}
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,6 +23,9 @@ public class TaskHistoryItemMapper {
|
||||||
item.setId(entity.getId());
|
item.setId(entity.getId());
|
||||||
item.setStartAt(entity.getStartAt());
|
item.setStartAt(entity.getStartAt());
|
||||||
item.setEndAt(entity.getEndAt());
|
item.setEndAt(entity.getEndAt());
|
||||||
|
if (Objects.nonNull(entity.getUser())) {
|
||||||
|
item.setUser(UserEntityMapper.toDomain(entity.getUser()));
|
||||||
|
}
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ public class OrganizationRepositoryImpl implements OrganizationRepository, Panac
|
||||||
return organizations;
|
return organizations;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Organization> organizations = find("name like ?1", "%" + query + "%").stream()
|
List<Organization> organizations = find("lower(name) like ?1", "%" + query.toLowerCase() + "%").stream()
|
||||||
.map(OrganizationEntityMapper::toDomain)
|
.map(OrganizationEntityMapper::toDomain)
|
||||||
.toList();
|
.toList();
|
||||||
LOGGER.infof("Loaded %d organizations for search query '%s'", organizations.size(), query);
|
LOGGER.infof("Loaded %d organizations for search query '%s'", organizations.size(), query);
|
||||||
|
|
|
||||||
|
|
@ -49,4 +49,24 @@ public class ProjectRepositoryImpl implements ProjectRepository, PanacheReposito
|
||||||
LOGGER.infof("Loaded %d projects for organization %d from database", projects.size(), organizationId);
|
LOGGER.infof("Loaded %d projects for organization %d from database", projects.size(), organizationId);
|
||||||
return projects;
|
return projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Project> findByNameAndOrganizationId(String name, Long organizationId) {
|
||||||
|
return find("lower(name) = lower(?1) and organization.id = ?2", name.trim(), organizationId)
|
||||||
|
.firstResultOptional()
|
||||||
|
.map(ProjectEntityMapper::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteProject(Long id) {
|
||||||
|
ProjectEntity entity = findByIdOptional(id).orElse(null);
|
||||||
|
if (entity != null) {
|
||||||
|
getEntityManager().createQuery("UPDATE TaskEntity t SET t.project = NULL WHERE t.project.id = :pid")
|
||||||
|
.setParameter("pid", id)
|
||||||
|
.executeUpdate();
|
||||||
|
delete(entity);
|
||||||
|
LOGGER.infof("Project %d soft deleted and unlinked from tasks", id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
public List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
|
public List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
|
||||||
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds) {
|
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds) {
|
||||||
StringBuilder query = new StringBuilder(
|
StringBuilder query = new StringBuilder(
|
||||||
"select distinct t from TaskEntity t join t.project p " +
|
"select distinct t from TaskEntity t left join t.project p left join t.category c " +
|
||||||
"where p.organization.id = ?1"
|
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1)"
|
||||||
);
|
);
|
||||||
List<Object> params = new ArrayList<>();
|
List<Object> params = new ArrayList<>();
|
||||||
params.add(organizationId);
|
params.add(organizationId);
|
||||||
|
|
@ -100,7 +100,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
@Override
|
@Override
|
||||||
public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
|
public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
|
||||||
List<TaskStatus> statuses, List<Long> categoryIds) {
|
List<TaskStatus> statuses, List<Long> categoryIds) {
|
||||||
StringBuilder query = new StringBuilder("user.email = ?1");
|
StringBuilder query = new StringBuilder("(user.email = ?1 or (user is null and createdBy.email = ?1))");
|
||||||
List<Object> params = new ArrayList<>();
|
List<Object> params = new ArrayList<>();
|
||||||
params.add(email);
|
params.add(email);
|
||||||
|
|
||||||
|
|
@ -134,9 +134,29 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Task> findRunningTasksByUserEmail(String email) {
|
||||||
|
return find("select distinct t from TaskEntity t left join t.user tu join t.history h left join h.user hu " +
|
||||||
|
"where (tu.email = ?1 or hu.email = ?1) and h.endAt is null", email)
|
||||||
|
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) {
|
public List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) {
|
||||||
return find("user.email = ?1", email).list().stream()
|
return findTasksWorkedByUserInPeriod(email, start, end, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId) {
|
||||||
|
StringBuilder query = new StringBuilder("select distinct t from TaskEntity t " +
|
||||||
|
"left join t.project p left join t.category c where (t.user.email = ?1 or (t.user is null and t.createdBy.email = ?1))");
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
params.add(email);
|
||||||
|
if (organizationId != null) {
|
||||||
|
query.append(" and (t.organization.id = ?2 or p.organization.id = ?2 or c.organization.id = ?2)");
|
||||||
|
params.add(organizationId);
|
||||||
|
}
|
||||||
|
return find(query.toString(), params.toArray()).list().stream()
|
||||||
.filter(entity -> entity.getHistory().stream().anyMatch(h ->
|
.filter(entity -> entity.getHistory().stream().anyMatch(h ->
|
||||||
Objects.nonNull(h.getStartAt()) &&
|
Objects.nonNull(h.getStartAt()) &&
|
||||||
!h.getStartAt().isAfter(end) &&
|
!h.getStartAt().isAfter(end) &&
|
||||||
|
|
@ -161,15 +181,18 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) {
|
public List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) {
|
||||||
return find("select distinct t from TaskEntity t join t.history h " +
|
return find("select distinct t from TaskEntity t " +
|
||||||
"where t.project.organization.id = ?1 " +
|
"left join t.project p left join t.category c join t.history h " +
|
||||||
|
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
|
||||||
"and h.startAt <= ?3 and (h.endAt is null or h.endAt >= ?2)",
|
"and h.startAt <= ?3 and (h.endAt is null or h.endAt >= ?2)",
|
||||||
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
|
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) {
|
public List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end) {
|
||||||
return find("select distinct t from TaskEntity t where t.project.organization.id = ?1 " +
|
return find("select distinct t from TaskEntity t " +
|
||||||
|
"left join t.project p left join t.category c " +
|
||||||
|
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
|
||||||
"and t.status = net.beetechgroup.beetask.entities.task.TaskStatus.COMPLETED " +
|
"and t.status = net.beetechgroup.beetask.entities.task.TaskStatus.COMPLETED " +
|
||||||
"and t.finishedAt >= ?2 and t.finishedAt <= ?3",
|
"and t.finishedAt >= ?2 and t.finishedAt <= ?3",
|
||||||
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
|
orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
|
|
@ -186,6 +209,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Task> findTasksWorkedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end) {
|
||||||
|
return find("select distinct t from TaskEntity t " +
|
||||||
|
"left join t.project p left join t.category c " +
|
||||||
|
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) and t.user.id = ?2",
|
||||||
|
orgId, userId).list().stream()
|
||||||
|
.filter(entity -> entity.getHistory().stream().anyMatch(h ->
|
||||||
|
Objects.nonNull(h.getStartAt()) &&
|
||||||
|
!h.getStartAt().isAfter(end) &&
|
||||||
|
(Objects.isNull(h.getEndAt()) || !h.getEndAt().isBefore(start))))
|
||||||
|
.map(TaskEntityMapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) {
|
public List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) {
|
||||||
return find("user.id = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4",
|
return find("user.id = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4",
|
||||||
|
|
@ -193,6 +230,16 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Task> findTasksFinishedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end) {
|
||||||
|
return find("select distinct t from TaskEntity t " +
|
||||||
|
"left join t.project p left join t.category c " +
|
||||||
|
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
|
||||||
|
"and t.user.id = ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5",
|
||||||
|
orgId, userId, TaskStatus.COMPLETED, start, end)
|
||||||
|
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) {
|
public List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) {
|
||||||
if (emails.isEmpty()) {
|
if (emails.isEmpty()) {
|
||||||
|
|
@ -208,6 +255,24 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Task> findTasksWorkedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end) {
|
||||||
|
if (emails.isEmpty()) {
|
||||||
|
LOGGER.warn("Task search by user emails skipped because the email list is empty");
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return find("select distinct t from TaskEntity t " +
|
||||||
|
"left join t.project p left join t.category c " +
|
||||||
|
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) and t.user.email in ?2",
|
||||||
|
orgId, emails).list().stream()
|
||||||
|
.filter(entity -> entity.getHistory().stream().anyMatch(h ->
|
||||||
|
Objects.nonNull(h.getStartAt()) &&
|
||||||
|
!h.getStartAt().isAfter(end) &&
|
||||||
|
(Objects.isNull(h.getEndAt()) || !h.getEndAt().isBefore(start))))
|
||||||
|
.map(TaskEntityMapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) {
|
public List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end) {
|
||||||
if (emails.isEmpty()) {
|
if (emails.isEmpty()) {
|
||||||
|
|
@ -219,6 +284,20 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Task> findTasksFinishedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end) {
|
||||||
|
if (emails.isEmpty()) {
|
||||||
|
LOGGER.warn("Finished task search by user emails skipped because the email list is empty");
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return find("select distinct t from TaskEntity t " +
|
||||||
|
"left join t.project p left join t.category c " +
|
||||||
|
"where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1) " +
|
||||||
|
"and t.user.email in ?2 and t.status = ?3 and t.finishedAt >= ?4 and t.finishedAt <= ?5",
|
||||||
|
orgId, emails, TaskStatus.COMPLETED, start, end)
|
||||||
|
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long countByStatus(TaskStatus status) {
|
public long countByStatus(TaskStatus status) {
|
||||||
return count("status", status);
|
return count("status", status);
|
||||||
|
|
@ -226,10 +305,32 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public long sumAllCompletedTrackedMinutes() {
|
public long sumAllCompletedTrackedMinutes() {
|
||||||
return findAllTasks().stream()
|
Object result = getEntityManager().createNativeQuery(
|
||||||
.flatMap(task -> task.getHistory().stream())
|
"SELECT COALESCE(SUM(EXTRACT(EPOCH FROM (h.end_at - h.start_at)) / 60), 0) " +
|
||||||
.filter(h -> Objects.nonNull(h.getEndAt()))
|
"FROM task_history h JOIN tasks t ON t.id = h.task_id " +
|
||||||
.mapToLong(h -> Duration.between(h.getStartAt(), h.getEndAt()).toMinutes())
|
"WHERE h.end_at IS NOT NULL AND t.deleted_at IS NULL"
|
||||||
.sum();
|
).getSingleResult();
|
||||||
|
return ((Number) result).longValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteTask(Long id) {
|
||||||
|
TaskEntity entity = find("id = ?1", id).firstResultOptional()
|
||||||
|
.orElseThrow(() -> {
|
||||||
|
LOGGER.warnf("Task %d was not found for deletion", id);
|
||||||
|
return new TaskNotFoundException(id);
|
||||||
|
});
|
||||||
|
if (entity.getHistory() != null) {
|
||||||
|
for (var h : entity.getHistory()) {
|
||||||
|
if (h.getEndAt() == null) {
|
||||||
|
h.setEndAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.setDeletedAt(LocalDateTime.now());
|
||||||
|
getEntityManager().merge(entity);
|
||||||
|
getEntityManager().flush();
|
||||||
|
LOGGER.infof("Task %d soft deleted from database", id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,4 +77,14 @@ public class UserOrganizationRepositoryImpl implements UserOrganizationRepositor
|
||||||
LOGGER.infof("Created membership for user %d in organization %d with role %s and status %s",
|
LOGGER.infof("Created membership for user %d in organization %d with role %s and status %s",
|
||||||
entity.getUser().getId(), entity.getOrganization().getId(), entity.getRole(), entity.getStatus());
|
entity.getUser().getId(), entity.getOrganization().getId(), entity.getRole(), entity.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteByUserAndOrganization(Long userId, Long organizationId) {
|
||||||
|
Optional<UserOrganizationEntity> existing = find("user.id = ?1 and organization.id = ?2", userId, organizationId).firstResultOptional();
|
||||||
|
existing.ifPresent(entity -> {
|
||||||
|
delete(entity);
|
||||||
|
LOGGER.infof("Soft deleted membership for user %d in organization %d", userId, organizationId);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,4 +69,13 @@ public class UserRepositoryImpl implements UserRepository, PanacheRepository<Use
|
||||||
public long countAll() {
|
public long countAll() {
|
||||||
return count();
|
return count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<User> findAllUsers() {
|
||||||
|
List<User> users = list("ORDER BY id ASC").stream()
|
||||||
|
.map(UserEntityMapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
LOGGER.infof("Loaded %d users", users.size());
|
||||||
|
return users;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,10 +45,11 @@ public class CategoryController {
|
||||||
@Operation(summary = "List categories", description = "Lists all categories for the given organization")
|
@Operation(summary = "List categories", description = "Lists all categories for the given organization")
|
||||||
@APIResponse(responseCode = "200", description = "Categories listed successfully")
|
@APIResponse(responseCode = "200", description = "Categories listed successfully")
|
||||||
public List<CategoryResponse> listCategories(@PathParam("orgId") Long orgId) {
|
public List<CategoryResponse> listCategories(@PathParam("orgId") Long orgId) {
|
||||||
List<CategoryResponse> categories = listCategoriesUseCase.execute(orgId).stream()
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
List<CategoryResponse> categories = listCategoriesUseCase.execute(email, orgId).stream()
|
||||||
.map(CategoryControllerMapper::toResponse)
|
.map(CategoryControllerMapper::toResponse)
|
||||||
.toList();
|
.toList();
|
||||||
LOGGER.infof("Listed %d categories for organization %d", categories.size(), orgId);
|
LOGGER.infof("Listed %d categories for organization %d by user %s", categories.size(), orgId, email);
|
||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ public class DashboardController {
|
||||||
@GET
|
@GET
|
||||||
public DashboardOutput getDashboard(
|
public DashboardOutput getDashboard(
|
||||||
@QueryParam("startDate") String startDateStr,
|
@QueryParam("startDate") String startDateStr,
|
||||||
@QueryParam("endDate") String endDateStr) {
|
@QueryParam("endDate") String endDateStr,
|
||||||
|
@QueryParam("organizationId") Long organizationId) {
|
||||||
|
|
||||||
String email = securityIdentity.getPrincipal().getName();
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
|
||||||
|
|
@ -47,8 +48,8 @@ public class DashboardController {
|
||||||
end = end.with(java.time.LocalTime.MAX);
|
end = end.with(java.time.LocalTime.MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
DashboardInput input = new DashboardInput(email, start, end);
|
DashboardInput input = new DashboardInput(email, start, end, organizationId);
|
||||||
LOGGER.infof("Dashboard requested by user %s for period %s to %s", email, start, end);
|
LOGGER.infof("Dashboard requested by user %s for period %s to %s with orgId %s", email, start, end, organizationId);
|
||||||
return dashboardUseCase.execute(input);
|
return dashboardUseCase.execute(input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ public class OrganizationController {
|
||||||
@Inject
|
@Inject
|
||||||
ListOrganizationMembersUseCase listOrganizationMembersUseCase;
|
ListOrganizationMembersUseCase listOrganizationMembersUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
net.beetechgroup.beetask.usecase.organization.members.RemoveOrganizationMemberUseCase removeOrganizationMemberUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
net.beetechgroup.beetask.usecase.organization.members.LeaveOrganizationUseCase leaveOrganizationUseCase;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
SecurityIdentity securityIdentity;
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
|
|
@ -142,4 +148,26 @@ public class OrganizationController {
|
||||||
LOGGER.infof("User %s listed %d organization join requests", userEmail, requests.size());
|
LOGGER.infof("User %s listed %d organization join requests", userEmail, requests.size());
|
||||||
return requests;
|
return requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@jakarta.ws.rs.DELETE
|
||||||
|
@Path("/{id}/members/{userId}")
|
||||||
|
@Authenticated
|
||||||
|
@Operation(summary = "Remove organization member", description = "Removes a member from the organization (Admin/Owner only)")
|
||||||
|
public jakarta.ws.rs.core.Response removeMember(@PathParam("id") Long organizationId, @PathParam("userId") Long userId) {
|
||||||
|
String requesterEmail = securityIdentity.getPrincipal().getName();
|
||||||
|
LOGGER.infof("Admin %s requested removal of member %d from organization %d", requesterEmail, userId, organizationId);
|
||||||
|
removeOrganizationMemberUseCase.execute(requesterEmail, organizationId, userId);
|
||||||
|
return jakarta.ws.rs.core.Response.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/leave")
|
||||||
|
@Authenticated
|
||||||
|
@Operation(summary = "Leave organization", description = "Allows the current user to leave an organization")
|
||||||
|
public jakarta.ws.rs.core.Response leaveOrganization(@PathParam("id") Long organizationId) {
|
||||||
|
String userEmail = securityIdentity.getPrincipal().getName();
|
||||||
|
LOGGER.infof("User %s requested to leave organization %d", userEmail, organizationId);
|
||||||
|
leaveOrganizationUseCase.execute(userEmail, organizationId);
|
||||||
|
return jakarta.ws.rs.core.Response.noContent().build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package net.beetechgroup.beetask.interfaceadapters.controllers.project;
|
package net.beetechgroup.beetask.interfaceadapters.controllers.project;
|
||||||
|
|
||||||
import io.quarkus.security.Authenticated;
|
import io.quarkus.security.Authenticated;
|
||||||
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
|
@ -19,6 +20,7 @@ import java.util.List;
|
||||||
@Path("/organizations/{orgId}/projects")
|
@Path("/organizations/{orgId}/projects")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Authenticated
|
||||||
public class ProjectController {
|
public class ProjectController {
|
||||||
private static final Logger LOGGER = Logger.getLogger(ProjectController.class);
|
private static final Logger LOGGER = Logger.getLogger(ProjectController.class);
|
||||||
|
|
||||||
|
|
@ -28,25 +30,55 @@ public class ProjectController {
|
||||||
@Inject
|
@Inject
|
||||||
ListProjectsUseCase listProjectsUseCase;
|
ListProjectsUseCase listProjectsUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
net.beetechgroup.beetask.usecase.project.update.UpdateProjectUseCase updateProjectUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
net.beetechgroup.beetask.usecase.project.delete.DeleteProjectUseCase deleteProjectUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Authenticated
|
|
||||||
public List<ProjectResponse> listProjects(@PathParam("orgId") Long orgId) {
|
public List<ProjectResponse> listProjects(@PathParam("orgId") Long orgId) {
|
||||||
List<ProjectResponse> projects = listProjectsUseCase.execute(orgId).stream()
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
List<ProjectResponse> projects = listProjectsUseCase.execute(email, orgId).stream()
|
||||||
.map(p -> new ProjectResponse(p.getId(), p.getName(), p.getColor(), p.getIcon()))
|
.map(p -> new ProjectResponse(p.getId(), p.getName(), p.getColor(), p.getIcon()))
|
||||||
.toList();
|
.toList();
|
||||||
LOGGER.infof("Listed %d projects for organization %d", projects.size(), orgId);
|
LOGGER.infof("Listed %d projects for organization %d by user %s", projects.size(), orgId, email);
|
||||||
return projects;
|
return projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Authenticated
|
|
||||||
public ProjectResponse createProject(@PathParam("orgId") Long orgId, CreateProjectRequest request) {
|
public ProjectResponse createProject(@PathParam("orgId") Long orgId, CreateProjectRequest request) {
|
||||||
LOGGER.infof("Project creation requested for organization %d with name '%s'", orgId, request.name());
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
Project project = createProjectUseCase.execute(orgId, request.name(), request.color(), request.icon());
|
LOGGER.infof("Project creation requested by %s for organization %d with name '%s'", email, orgId, request.name());
|
||||||
LOGGER.infof("Project %d created successfully for organization %d", project.getId(), orgId);
|
Project project = createProjectUseCase.execute(email, orgId, request.name(), request.color(), request.icon());
|
||||||
|
LOGGER.infof("Project %d created successfully for organization %d by user %s", project.getId(), orgId, email);
|
||||||
return new ProjectResponse(project.getId(), project.getName(), project.getColor(), project.getIcon());
|
return new ProjectResponse(project.getId(), project.getName(), project.getColor(), project.getIcon());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@jakarta.ws.rs.PUT
|
||||||
|
@Path("/{id}")
|
||||||
|
public ProjectResponse updateProject(@PathParam("orgId") Long orgId,
|
||||||
|
@PathParam("id") Long id,
|
||||||
|
CreateProjectRequest request) {
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
LOGGER.infof("Project update requested by %s for project %d in org %d", email, id, orgId);
|
||||||
|
Project project = updateProjectUseCase.execute(email, orgId, id, request.name(), request.color(), request.icon());
|
||||||
|
return new ProjectResponse(project.getId(), project.getName(), project.getColor(), project.getIcon());
|
||||||
|
}
|
||||||
|
|
||||||
|
@jakarta.ws.rs.DELETE
|
||||||
|
@Path("/{id}")
|
||||||
|
public jakarta.ws.rs.core.Response deleteProject(@PathParam("orgId") Long orgId,
|
||||||
|
@PathParam("id") Long id) {
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
LOGGER.infof("Project deletion requested by %s for project %d in org %d", email, id, orgId);
|
||||||
|
deleteProjectUseCase.execute(email, orgId, id);
|
||||||
|
return jakarta.ws.rs.core.Response.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
// TODO must be a independent file
|
// TODO must be a independent file
|
||||||
public record CreateProjectRequest(String name, String color, String icon) {}
|
public record CreateProjectRequest(String name, String color, String icon) {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ public record CreateTaskResponse(
|
||||||
ProjectResponse project,
|
ProjectResponse project,
|
||||||
CategoryResponse category,
|
CategoryResponse category,
|
||||||
UserResponse user,
|
UserResponse user,
|
||||||
|
UserResponse createdBy,
|
||||||
LocalDateTime finishedAt,
|
LocalDateTime finishedAt,
|
||||||
List<TaskHistoryItemResponse> history,
|
List<TaskHistoryItemResponse> history,
|
||||||
String externalUrl) {
|
String externalUrl) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.BeanParam;
|
import jakarta.ws.rs.BeanParam;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DELETE;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.PATCH;
|
import jakarta.ws.rs.PATCH;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
|
|
@ -11,6 +12,8 @@ import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.PathParam;
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import io.quarkus.security.Authenticated;
|
||||||
import io.quarkus.security.identity.SecurityIdentity;
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
import net.beetechgroup.beetask.usecase.task.start.StartTaskInput;
|
import net.beetechgroup.beetask.usecase.task.start.StartTaskInput;
|
||||||
|
|
||||||
|
|
@ -25,6 +28,7 @@ import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
||||||
|
import net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.task.listall.ListAllTasksUseCase;
|
import net.beetechgroup.beetask.usecase.task.listall.ListAllTasksUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.task.listall.ListMyTasksUseCase;
|
import net.beetechgroup.beetask.usecase.task.listall.ListMyTasksUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase;
|
import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase;
|
||||||
|
|
@ -36,6 +40,7 @@ import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase;
|
||||||
@Path("/tasks")
|
@Path("/tasks")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Authenticated
|
||||||
@Tag(name = "Tasks", description = "Task management operations")
|
@Tag(name = "Tasks", description = "Task management operations")
|
||||||
public class TaskController {
|
public class TaskController {
|
||||||
private static final Logger LOGGER = Logger.getLogger(TaskController.class);
|
private static final Logger LOGGER = Logger.getLogger(TaskController.class);
|
||||||
|
|
@ -64,6 +69,9 @@ public class TaskController {
|
||||||
@Inject
|
@Inject
|
||||||
GetTaskUseCase getTaskUseCase;
|
GetTaskUseCase getTaskUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
DeleteTaskUseCase deleteTaskUseCase;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
SecurityIdentity securityIdentity;
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
|
|
@ -96,8 +104,9 @@ public class TaskController {
|
||||||
@Operation(summary = "Get a task", description = "Gets a specific task by ID")
|
@Operation(summary = "Get a task", description = "Gets a specific task by ID")
|
||||||
@APIResponse(responseCode = "200", description = "Task retrieved successfully")
|
@APIResponse(responseCode = "200", description = "Task retrieved successfully")
|
||||||
public CreateTaskResponse getTask(@PathParam("id") Long id) {
|
public CreateTaskResponse getTask(@PathParam("id") Long id) {
|
||||||
LOGGER.infof("Task details requested for task %d", id);
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
return TaskControllerMapper.toCreateTaskResponse(getTaskUseCase.execute(id));
|
LOGGER.infof("Task details requested for task %d by %s", id, email);
|
||||||
|
return TaskControllerMapper.toCreateTaskResponse(getTaskUseCase.execute(id, email));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PUT
|
@PUT
|
||||||
|
|
@ -129,12 +138,24 @@ public class TaskController {
|
||||||
@Operation(summary = "Update task status", description = "Updates the status of a task")
|
@Operation(summary = "Update task status", description = "Updates the status of a task")
|
||||||
@APIResponse(responseCode = "200", description = "Task status updated successfully")
|
@APIResponse(responseCode = "200", description = "Task status updated successfully")
|
||||||
public CreateTaskResponse updateTaskStatus(@PathParam("id") Long id, TaskStatus status) {
|
public CreateTaskResponse updateTaskStatus(@PathParam("id") Long id, TaskStatus status) {
|
||||||
LOGGER.infof("Status update requested for task %d to %s", id, status);
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, status);
|
LOGGER.infof("Status update requested for task %d to %s by %s", id, status, email);
|
||||||
|
CreateTaskOutput output = updateTaskStatusUseCase.execute(id, status, email);
|
||||||
LOGGER.infof("Task %d status updated successfully to %s", id, status);
|
LOGGER.infof("Task %d status updated successfully to %s", id, status);
|
||||||
return TaskControllerMapper.toCreateTaskResponse(output);
|
return TaskControllerMapper.toCreateTaskResponse(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Path("/{id}")
|
||||||
|
@Operation(summary = "Delete a task", description = "Deletes a task if requester is admin of the organization or the owner/creator")
|
||||||
|
@APIResponse(responseCode = "204", description = "Task deleted successfully")
|
||||||
|
public Response deleteTask(@PathParam("id") Long id) {
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
LOGGER.infof("User %s requested deletion of task %d", email, id);
|
||||||
|
deleteTaskUseCase.execute(id, email);
|
||||||
|
return Response.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Operation(summary = "List all tasks", description = "Lists all tasks in the system")
|
@Operation(summary = "List all tasks", description = "Lists all tasks in the system")
|
||||||
@APIResponse(responseCode = "200", description = "Tasks listed successfully")
|
@APIResponse(responseCode = "200", description = "Tasks listed successfully")
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,13 @@ public class TaskControllerMapper {
|
||||||
output.user().email(),
|
output.user().email(),
|
||||||
output.user().photo())
|
output.user().photo())
|
||||||
: null,
|
: null,
|
||||||
|
Objects.nonNull(output.createdBy())
|
||||||
|
? new CreateTaskResponse.UserResponse(
|
||||||
|
output.createdBy().id(),
|
||||||
|
output.createdBy().name(),
|
||||||
|
output.createdBy().email(),
|
||||||
|
output.createdBy().photo())
|
||||||
|
: null,
|
||||||
output.finishedAt(),
|
output.finishedAt(),
|
||||||
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(
|
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(
|
||||||
taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt(), taskHistoryItemOutput.elapsedSeconds())).toList(),
|
taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt(), taskHistoryItemOutput.elapsedSeconds())).toList(),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
|
@ -36,8 +36,25 @@ public class LoginUseCase {
|
||||||
|
|
||||||
User user = userOptional.get();
|
User user = userOptional.get();
|
||||||
|
|
||||||
// Simple password check for now (should use Bcrypt)
|
boolean passwordMatches = false;
|
||||||
if (!user.getPassword().equals(input.password())) {
|
String storedPassword = user.getPassword();
|
||||||
|
if (storedPassword != null) {
|
||||||
|
if (storedPassword.startsWith("$2a$") || storedPassword.startsWith("$2b$") || storedPassword.startsWith("$2y$")) {
|
||||||
|
try {
|
||||||
|
passwordMatches = org.mindrot.jbcrypt.BCrypt.checkpw(input.password(), storedPassword);
|
||||||
|
} catch (Exception e) {
|
||||||
|
passwordMatches = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
passwordMatches = storedPassword.equals(input.password());
|
||||||
|
if (passwordMatches) {
|
||||||
|
user.setPassword(org.mindrot.jbcrypt.BCrypt.hashpw(input.password(), org.mindrot.jbcrypt.BCrypt.gensalt(10)));
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordMatches) {
|
||||||
LOGGER.warnf("Login failed because password did not match for email %s", input.email());
|
LOGGER.warnf("Login failed because password did not match for email %s", input.email());
|
||||||
throw new RuntimeException("Invalid credentials");
|
throw new RuntimeException("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,27 @@ import java.util.List;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.usecase.category.CategoryMapper;
|
import net.beetechgroup.beetask.usecase.category.CategoryMapper;
|
||||||
import net.beetechgroup.beetask.usecase.category.CategoryOutput;
|
import net.beetechgroup.beetask.usecase.category.CategoryOutput;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
public class ListCategoriesUseCase {
|
public class ListCategoriesUseCase {
|
||||||
private static final Logger LOGGER = Logger.getLogger(ListCategoriesUseCase.class);
|
private static final Logger LOGGER = Logger.getLogger(ListCategoriesUseCase.class);
|
||||||
private final CategoryRepository categoryRepository;
|
private final CategoryRepository categoryRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
public ListCategoriesUseCase(CategoryRepository categoryRepository) {
|
public ListCategoriesUseCase(CategoryRepository categoryRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
this.categoryRepository = categoryRepository;
|
this.categoryRepository = categoryRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<CategoryOutput> execute(Long organizationId) {
|
public List<CategoryOutput> execute(String userEmail, Long organizationId) {
|
||||||
|
authorizer.execute(userEmail, organizationId);
|
||||||
List<CategoryOutput> categories = categoryRepository.findByOrganizationId(organizationId).stream()
|
List<CategoryOutput> categories = categoryRepository.findByOrganizationId(organizationId).stream()
|
||||||
.map(CategoryMapper::toCategoryOutput)
|
.map(CategoryMapper::toCategoryOutput)
|
||||||
.toList();
|
.toList();
|
||||||
LOGGER.infof("Loaded %d categories for organization %d", categories.size(), organizationId);
|
LOGGER.infof("Loaded %d categories for organization %d by user %s", categories.size(), organizationId, userEmail);
|
||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,10 @@ import java.time.LocalDateTime;
|
||||||
public record DashboardInput(
|
public record DashboardInput(
|
||||||
String userEmail,
|
String userEmail,
|
||||||
LocalDateTime startDate,
|
LocalDateTime startDate,
|
||||||
LocalDateTime endDate
|
LocalDateTime endDate,
|
||||||
) {}
|
Long organizationId
|
||||||
|
) {
|
||||||
|
public DashboardInput(String userEmail, LocalDateTime startDate, LocalDateTime endDate) {
|
||||||
|
this(userEmail, startDate, endDate, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,24 @@ public class DashboardUseCase {
|
||||||
LOGGER.infof("Calculating dashboard for user %s in period %s to %s",
|
LOGGER.infof("Calculating dashboard for user %s in period %s to %s",
|
||||||
input.userEmail(), input.startDate(), input.endDate());
|
input.userEmail(), input.startDate(), input.endDate());
|
||||||
List<Task> workedTasks = taskRepository.findTasksWorkedByUserInPeriod(
|
List<Task> workedTasks = taskRepository.findTasksWorkedByUserInPeriod(
|
||||||
input.userEmail(), input.startDate(), input.endDate()
|
input.userEmail(), input.startDate(), input.endDate(), input.organizationId()
|
||||||
);
|
);
|
||||||
|
|
||||||
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserInPeriod(
|
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserInPeriod(
|
||||||
input.userEmail(), input.startDate(), input.endDate()
|
input.userEmail(), input.startDate(), input.endDate()
|
||||||
);
|
);
|
||||||
|
if (input.organizationId() != null) {
|
||||||
|
finishedTasks = finishedTasks.stream().filter(t ->
|
||||||
|
(t.getOrganization() != null && input.organizationId().equals(t.getOrganization().getId())) ||
|
||||||
|
(t.getProject() != null && t.getProject().getOrganization() != null && input.organizationId().equals(t.getProject().getOrganization().getId())) ||
|
||||||
|
(t.getCategory() != null && t.getCategory().getOrganization() != null && input.organizationId().equals(t.getCategory().getOrganization().getId()))
|
||||||
|
).toList();
|
||||||
|
}
|
||||||
|
|
||||||
LocalDateTime startOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MIN);
|
LocalDateTime startOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MIN);
|
||||||
LocalDateTime endOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MAX);
|
LocalDateTime endOfYesterday = LocalDateTime.now().minusDays(1).with(LocalTime.MAX);
|
||||||
List<Task> yesterdayTasks = taskRepository.findTasksWorkedByUserInPeriod(
|
List<Task> yesterdayTasks = taskRepository.findTasksWorkedByUserInPeriod(
|
||||||
input.userEmail(), startOfYesterday, endOfYesterday
|
input.userEmail(), startOfYesterday, endOfYesterday, input.organizationId()
|
||||||
);
|
);
|
||||||
|
|
||||||
long totalMinutes = 0;
|
long totalMinutes = 0;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,10 +47,9 @@ public class RequestJoinOrganizationUseCase {
|
||||||
LOGGER.warnf("Join request ignored because user %s already has a pending request for organization %d", userEmail, organizationId);
|
LOGGER.warnf("Join request ignored because user %s already has a pending request for organization %d", userEmail, organizationId);
|
||||||
throw new RuntimeException("A join request is already pending for this organization");
|
throw new RuntimeException("A join request is already pending for this organization");
|
||||||
}
|
}
|
||||||
// If REJECTED, we allow a new request (or we could just update the existing one to PENDING)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
UserOrganization request = new UserOrganization();
|
UserOrganization request = existing.orElseGet(UserOrganization::new);
|
||||||
request.setUser(user);
|
request.setUser(user);
|
||||||
request.setOrganization(organization);
|
request.setOrganization(organization);
|
||||||
request.setRole(UserOrganizationRole.MEMBER);
|
request.setRole(UserOrganizationRole.MEMBER);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,10 +48,10 @@ public class OrgDashboardUseCase {
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
List<Task> workedTasks = taskRepository.findTasksWorkedByUserEmailsInPeriod(
|
List<Task> workedTasks = taskRepository.findTasksWorkedByUserEmailsInPeriod(
|
||||||
memberEmails, input.startDate(), input.endDate()
|
input.organizationId(), memberEmails, input.startDate(), input.endDate()
|
||||||
);
|
);
|
||||||
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserEmailsInPeriod(
|
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserEmailsInPeriod(
|
||||||
memberEmails, input.startDate(), input.endDate()
|
input.organizationId(), memberEmails, input.startDate(), input.endDate()
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<Long, OrgProjectStats> projectStatsMap = new HashMap<>();
|
Map<Long, OrgProjectStats> projectStatsMap = new HashMap<>();
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,8 @@ public class MemberDetailUseCase {
|
||||||
long daySpan = ChronoUnit.DAYS.between(start.toLocalDate(), end.toLocalDate());
|
long daySpan = ChronoUnit.DAYS.between(start.toLocalDate(), end.toLocalDate());
|
||||||
boolean groupByDay = daySpan <= 30;
|
boolean groupByDay = daySpan <= 30;
|
||||||
|
|
||||||
List<Task> workedTasks = taskRepository.findTasksWorkedByUserIdInPeriod(input.memberId(), start, end);
|
List<Task> workedTasks = taskRepository.findTasksWorkedByUserIdInPeriod(input.organizationId(), input.memberId(), start, end);
|
||||||
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.memberId(), start, end);
|
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserIdInPeriod(input.organizationId(), input.memberId(), start, end);
|
||||||
|
|
||||||
Map<Long, MemberDetailOutput.ProjectStats> projectStatsMap = new HashMap<>();
|
Map<Long, MemberDetailOutput.ProjectStats> projectStatsMap = new HashMap<>();
|
||||||
Map<Long, MemberDetailOutput.CategoryStats> categoryStatsMap = new HashMap<>();
|
Map<Long, MemberDetailOutput.CategoryStats> categoryStatsMap = new HashMap<>();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package net.beetechgroup.beetask.usecase.project.create;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.Project;
|
import net.beetechgroup.beetask.entities.Project;
|
||||||
import net.beetechgroup.beetask.entities.organization.Organization;
|
import net.beetechgroup.beetask.entities.organization.Organization;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
@ -10,22 +11,36 @@ public class CreateProjectUseCase {
|
||||||
private static final Logger LOGGER = Logger.getLogger(CreateProjectUseCase.class);
|
private static final Logger LOGGER = Logger.getLogger(CreateProjectUseCase.class);
|
||||||
private final ProjectRepository projectRepository;
|
private final ProjectRepository projectRepository;
|
||||||
private final OrganizationRepository organizationRepository;
|
private final OrganizationRepository organizationRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
public CreateProjectUseCase(ProjectRepository projectRepository, OrganizationRepository organizationRepository) {
|
public CreateProjectUseCase(ProjectRepository projectRepository,
|
||||||
|
OrganizationRepository organizationRepository,
|
||||||
|
AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
this.projectRepository = projectRepository;
|
this.projectRepository = projectRepository;
|
||||||
this.organizationRepository = organizationRepository;
|
this.organizationRepository = organizationRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Project execute(Long organizationId, String projectName, String color, String icon) {
|
public Project execute(String userEmail, Long organizationId, String projectName, String color, String icon) {
|
||||||
|
authorizer.execute(userEmail, organizationId);
|
||||||
|
|
||||||
Organization organization = organizationRepository.findOrganizationById(organizationId)
|
Organization organization = organizationRepository.findOrganizationById(organizationId)
|
||||||
.orElseThrow(() -> {
|
.orElseThrow(() -> {
|
||||||
LOGGER.warnf("Project creation failed because organization %d was not found", organizationId);
|
LOGGER.warnf("Project creation failed because organization %d was not found", organizationId);
|
||||||
return new RuntimeException("Organization not found");
|
return new RuntimeException("Organization not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
LOGGER.infof("Creating project '%s' in organization %d", projectName, organizationId);
|
if (projectName == null || projectName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Nome do projeto não pode ser vazio.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectRepository.findByNameAndOrganizationId(projectName, organizationId).isPresent()) {
|
||||||
|
throw new IllegalArgumentException("Já existe um projeto com este nome nesta organização.");
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGER.infof("Creating project '%s' in organization %d by user %s", projectName, organizationId, userEmail);
|
||||||
Project project = new Project();
|
Project project = new Project();
|
||||||
project.setName(projectName);
|
project.setName(projectName.trim());
|
||||||
project.setColor(color);
|
project.setColor(color);
|
||||||
project.setIcon(icon);
|
project.setIcon(icon);
|
||||||
project.setOrganization(organization);
|
project.setOrganization(organization);
|
||||||
|
|
@ -35,3 +50,4 @@ public class CreateProjectUseCase {
|
||||||
return savedProject;
|
return savedProject;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package net.beetechgroup.beetask.usecase.project.list;
|
package net.beetechgroup.beetask.usecase.project.list;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.Project;
|
import net.beetechgroup.beetask.entities.Project;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -9,14 +10,18 @@ import org.jboss.logging.Logger;
|
||||||
public class ListProjectsUseCase {
|
public class ListProjectsUseCase {
|
||||||
private static final Logger LOGGER = Logger.getLogger(ListProjectsUseCase.class);
|
private static final Logger LOGGER = Logger.getLogger(ListProjectsUseCase.class);
|
||||||
private final ProjectRepository projectRepository;
|
private final ProjectRepository projectRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
public ListProjectsUseCase(ProjectRepository projectRepository) {
|
public ListProjectsUseCase(ProjectRepository projectRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
this.projectRepository = projectRepository;
|
this.projectRepository = projectRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Project> execute(Long organizationId) {
|
public List<Project> execute(String userEmail, Long organizationId) {
|
||||||
|
authorizer.execute(userEmail, organizationId);
|
||||||
List<Project> projects = projectRepository.findByOrganizationId(organizationId);
|
List<Project> projects = projectRepository.findByOrganizationId(organizationId);
|
||||||
LOGGER.infof("Loaded %d projects for organization %d", projects.size(), organizationId);
|
LOGGER.infof("Loaded %d projects for organization %d", projects.size(), organizationId);
|
||||||
return projects;
|
return projects;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,4 +8,6 @@ public interface ProjectRepository {
|
||||||
Project saveProject(Project project);
|
Project saveProject(Project project);
|
||||||
Optional<Project> findProjectById(Long id);
|
Optional<Project> findProjectById(Long id);
|
||||||
List<Project> findByOrganizationId(Long organizationId);
|
List<Project> findByOrganizationId(Long organizationId);
|
||||||
|
Optional<Project> findByNameAndOrganizationId(String name, Long organizationId);
|
||||||
|
void deleteProject(Long id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ public interface TaskRepository {
|
||||||
List<Task> findAllTasks();
|
List<Task> findAllTasks();
|
||||||
List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
|
List<Task> findAccessibleTasksFiltered(Long organizationId, String text, List<Long> projectIds,
|
||||||
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds);
|
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds);
|
||||||
|
List<Task> findRunningTasksByUserEmail(String email);
|
||||||
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||||
|
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId);
|
||||||
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksByUser(String email);
|
List<Task> findTasksByUser(String email);
|
||||||
List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
|
List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
|
||||||
|
|
@ -20,9 +22,14 @@ public interface TaskRepository {
|
||||||
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
|
||||||
|
List<Task> findTasksWorkedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
|
||||||
|
List<Task> findTasksFinishedByUserIdInPeriod(Long orgId, Long userId, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
|
||||||
|
List<Task> findTasksWorkedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
|
||||||
|
List<Task> findTasksFinishedByUserEmailsInPeriod(Long orgId, List<String> emails, LocalDateTime start, LocalDateTime end);
|
||||||
long countByStatus(TaskStatus status);
|
long countByStatus(TaskStatus status);
|
||||||
long sumAllCompletedTrackedMinutes();
|
long sumAllCompletedTrackedMinutes();
|
||||||
|
void deleteTask(Long id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,5 @@ public interface UserOrganizationRepository {
|
||||||
List<UserOrganization> findByOrganizationIdAndStatus(Long organizationId, UserOrganizationStatus status);
|
List<UserOrganization> findByOrganizationIdAndStatus(Long organizationId, UserOrganizationStatus status);
|
||||||
Optional<UserOrganization> findByUserAndOrganization(Long userId, Long organizationId);
|
Optional<UserOrganization> findByUserAndOrganization(Long userId, Long organizationId);
|
||||||
void save(UserOrganization userOrganization);
|
void save(UserOrganization userOrganization);
|
||||||
|
void deleteByUserAndOrganization(Long userId, Long organizationId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,5 @@ public interface UserRepository {
|
||||||
Optional<User> findUserById(Long id);
|
Optional<User> findUserById(Long id);
|
||||||
List<UserOrganization> findUserOrganizations(Long userId);
|
List<UserOrganization> findUserOrganizations(Long userId);
|
||||||
long countAll();
|
long countAll();
|
||||||
|
List<User> findAllUsers();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ public class CreateTaskMapper {
|
||||||
task.getUser().getEmail(),
|
task.getUser().getEmail(),
|
||||||
getPhotoUrl(task.getUser().getName(), task.getUser().getPhoto()))
|
getPhotoUrl(task.getUser().getName(), task.getUser().getPhoto()))
|
||||||
: null,
|
: null,
|
||||||
|
Objects.nonNull(task.getCreatedBy())
|
||||||
|
? new CreateTaskOutput.UserOutput(
|
||||||
|
task.getCreatedBy().getId(),
|
||||||
|
task.getCreatedBy().getName(),
|
||||||
|
task.getCreatedBy().getEmail(),
|
||||||
|
getPhotoUrl(task.getCreatedBy().getName(), task.getCreatedBy().getPhoto()))
|
||||||
|
: null,
|
||||||
task.getFinishedAt(),
|
task.getFinishedAt(),
|
||||||
task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList(),
|
task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList(),
|
||||||
task.getExternalUrl());
|
task.getExternalUrl());
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ public record CreateTaskOutput(
|
||||||
ProjectOutput project,
|
ProjectOutput project,
|
||||||
CategoryOutput category,
|
CategoryOutput category,
|
||||||
UserOutput user,
|
UserOutput user,
|
||||||
|
UserOutput createdBy,
|
||||||
LocalDateTime finishedAt,
|
LocalDateTime finishedAt,
|
||||||
List<TaskHistoryItemOutput> history,
|
List<TaskHistoryItemOutput> history,
|
||||||
String externalUrl) {
|
String externalUrl) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import net.beetechgroup.beetask.entities.User;
|
||||||
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus;
|
||||||
import net.beetechgroup.beetask.entities.task.Task;
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
|
||||||
|
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
import net.beetechgroup.beetask.usecase.repository.CategoryRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
|
||||||
|
|
@ -22,15 +23,18 @@ public class CreateTaskUseCase {
|
||||||
private final CategoryRepository categoryRepository;
|
private final CategoryRepository categoryRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserOrganizationRepository userOrganizationRepository;
|
private final UserOrganizationRepository userOrganizationRepository;
|
||||||
|
private final OrganizationRepository organizationRepository;
|
||||||
|
|
||||||
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||||
UserOrganizationRepository userOrganizationRepository) {
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
|
OrganizationRepository organizationRepository) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
this.projectRepository = projectRepository;
|
this.projectRepository = projectRepository;
|
||||||
this.categoryRepository = categoryRepository;
|
this.categoryRepository = categoryRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.userOrganizationRepository = userOrganizationRepository;
|
this.userOrganizationRepository = userOrganizationRepository;
|
||||||
|
this.organizationRepository = organizationRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(CreateTaskInput input) {
|
public CreateTaskOutput execute(CreateTaskInput input) {
|
||||||
|
|
@ -60,7 +64,17 @@ public class CreateTaskUseCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
validateRequesterMembership(input.organizationId(), input.userEmail());
|
validateRequesterMembership(input.organizationId(), input.userEmail());
|
||||||
|
if (Objects.nonNull(input.organizationId())) {
|
||||||
|
organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization);
|
||||||
|
} else if (task.getProject() != null && task.getProject().getOrganization() != null) {
|
||||||
|
task.setOrganization(task.getProject().getOrganization());
|
||||||
|
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
|
||||||
|
task.setOrganization(task.getCategory().getOrganization());
|
||||||
|
}
|
||||||
|
|
||||||
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
|
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
|
||||||
|
User creator = userRepository.findByEmail(input.userEmail()).orElse(null);
|
||||||
|
task.setCreatedBy(creator);
|
||||||
|
|
||||||
if (Objects.nonNull(input.history())) {
|
if (Objects.nonNull(input.history())) {
|
||||||
List<TaskHistoryItem> historyItems = input.history().stream().map(h -> {
|
List<TaskHistoryItem> historyItems = input.history().stream().map(h -> {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package net.beetechgroup.beetask.usecase.task.get;
|
package net.beetechgroup.beetask.usecase.task.get;
|
||||||
|
|
||||||
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||||
|
|
@ -8,14 +10,32 @@ import org.jboss.logging.Logger;
|
||||||
public class GetTaskUseCase {
|
public class GetTaskUseCase {
|
||||||
private static final Logger LOGGER = Logger.getLogger(GetTaskUseCase.class);
|
private static final Logger LOGGER = Logger.getLogger(GetTaskUseCase.class);
|
||||||
private final TaskRepository taskRepository;
|
private final TaskRepository taskRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
public GetTaskUseCase(TaskRepository taskRepository) {
|
public GetTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(Long id) {
|
public CreateTaskOutput execute(Long id, String userEmail) {
|
||||||
//TODO deve validar se o usuario logado é o dono da task
|
LOGGER.infof("Fetching task %d requested by %s", id, userEmail);
|
||||||
LOGGER.infof("Fetching task %d", id);
|
Task task = taskRepository.findTaskById(id);
|
||||||
return CreateTaskMapper.toCreateTaskOutput(taskRepository.findTaskById(id));
|
|
||||||
|
Long orgId = null;
|
||||||
|
if (task.getProject() != null && task.getProject().getOrganization() != null) {
|
||||||
|
orgId = task.getProject().getOrganization().getId();
|
||||||
|
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
|
||||||
|
orgId = task.getCategory().getOrganization().getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orgId != null) {
|
||||||
|
authorizer.execute(userEmail, orgId);
|
||||||
|
} else if (task.getUser() != null && !task.getUser().getEmail().equals(userEmail)) {
|
||||||
|
LOGGER.warnf("User %s denied access to task %d owned by %s", userEmail, id, task.getUser().getEmail());
|
||||||
|
throw new SecurityException("Você não tem permissão para acessar esta tarefa.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateTaskMapper.toCreateTaskOutput(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package net.beetechgroup.beetask.usecase.task.start;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import net.beetechgroup.beetask.entities.task.Task;
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
||||||
|
|
@ -13,25 +14,53 @@ public class StartTaskUseCase {
|
||||||
|
|
||||||
private final TaskRepository taskRepository;
|
private final TaskRepository taskRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository) {
|
public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(StartTaskInput input) {
|
public CreateTaskOutput execute(StartTaskInput input) {
|
||||||
LOGGER.infof("Starting task %d for user %s", input.id(), input.userEmail());
|
LOGGER.infof("Starting task %d for user %s", input.id(), input.userEmail());
|
||||||
Task task = taskRepository.findTaskById(input.id());
|
Task task = taskRepository.findTaskById(input.id());
|
||||||
|
|
||||||
|
Long orgId = null;
|
||||||
|
if (task.getOrganization() != null) {
|
||||||
|
orgId = task.getOrganization().getId();
|
||||||
|
} else if (task.getProject() != null && task.getProject().getOrganization() != null) {
|
||||||
|
orgId = task.getProject().getOrganization().getId();
|
||||||
|
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
|
||||||
|
orgId = task.getCategory().getOrganization().getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orgId != null) {
|
||||||
|
authorizer.execute(input.userEmail(), orgId);
|
||||||
|
}
|
||||||
|
|
||||||
if (Objects.nonNull(task.getUser()) && !task.getUser().getEmail().equals(input.userEmail())) {
|
if (Objects.nonNull(task.getUser()) && !task.getUser().getEmail().equals(input.userEmail())) {
|
||||||
LOGGER.warnf("User %s attempted to start task %d owned by %s",
|
LOGGER.warnf("User %s attempted to start task %d owned by %s",
|
||||||
input.userEmail(), input.id(), task.getUser().getEmail());
|
input.userEmail(), input.id(), task.getUser().getEmail());
|
||||||
throw new SecurityException("Você não tem permissão para iniciar esta tarefa.");
|
throw new SecurityException("Você não tem permissão para iniciar esta tarefa.");
|
||||||
}
|
}
|
||||||
|
|
||||||
task.start();
|
net.beetechgroup.beetask.entities.User currentUser = userRepository.findByEmail(input.userEmail()).orElse(null);
|
||||||
|
|
||||||
|
// Auto-stop any existing running timers for this user across all tasks (Toggl style)
|
||||||
|
java.util.List<Task> runningTasks = taskRepository.findRunningTasksByUserEmail(input.userEmail());
|
||||||
|
for (Task running : runningTasks) {
|
||||||
|
if (!running.getId().equals(task.getId()) && running.isRunning()) {
|
||||||
|
LOGGER.infof("Auto-stopping previously running task %d for user %s", running.getId(), input.userEmail());
|
||||||
|
running.stop();
|
||||||
|
taskRepository.saveTask(running);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task.start(currentUser);
|
||||||
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
||||||
LOGGER.infof("Task %d started successfully", input.id());
|
LOGGER.infof("Task %d started successfully", input.id());
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package net.beetechgroup.beetask.usecase.task.stop;
|
package net.beetechgroup.beetask.usecase.task.stop;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.task.Task;
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||||
|
|
@ -10,15 +11,28 @@ public class StopTaskUseCase {
|
||||||
private static final Logger LOGGER = Logger.getLogger(StopTaskUseCase.class);
|
private static final Logger LOGGER = Logger.getLogger(StopTaskUseCase.class);
|
||||||
|
|
||||||
private final TaskRepository taskRepository;
|
private final TaskRepository taskRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
public StopTaskUseCase(TaskRepository taskRepository) {
|
public StopTaskUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(StopTaskInput input) {
|
public CreateTaskOutput execute(StopTaskInput input) {
|
||||||
LOGGER.infof("Stopping task %d for user %s", input.id(), input.userEmail());
|
LOGGER.infof("Stopping task %d for user %s", input.id(), input.userEmail());
|
||||||
Task task = taskRepository.findTaskById(input.id());
|
Task task = taskRepository.findTaskById(input.id());
|
||||||
|
|
||||||
|
Long orgId = null;
|
||||||
|
if (task.getProject() != null && task.getProject().getOrganization() != null) {
|
||||||
|
orgId = task.getProject().getOrganization().getId();
|
||||||
|
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
|
||||||
|
orgId = task.getCategory().getOrganization().getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orgId != null) {
|
||||||
|
authorizer.execute(input.userEmail(), orgId);
|
||||||
|
}
|
||||||
|
|
||||||
if (task.getUser() != null && !task.getUser().getEmail().equals(input.userEmail())) {
|
if (task.getUser() != null && !task.getUser().getEmail().equals(input.userEmail())) {
|
||||||
LOGGER.warnf("User %s attempted to stop task %d owned by %s",
|
LOGGER.warnf("User %s attempted to stop task %d owned by %s",
|
||||||
input.userEmail(), input.id(), task.getUser().getEmail());
|
input.userEmail(), input.id(), task.getUser().getEmail());
|
||||||
|
|
@ -31,3 +45,4 @@ public class StopTaskUseCase {
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
package net.beetechgroup.beetask.usecase.task.update;
|
package net.beetechgroup.beetask.usecase.task.update;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.task.Task;
|
import net.beetechgroup.beetask.entities.task.Task;
|
||||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||||
|
import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
|
||||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
|
||||||
|
|
@ -13,15 +12,31 @@ public class UpdateTaskStatusUseCase {
|
||||||
private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusUseCase.class);
|
private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusUseCase.class);
|
||||||
|
|
||||||
private final TaskRepository taskRepository;
|
private final TaskRepository taskRepository;
|
||||||
|
private final AuthorizeOrganizationMemberUseCase authorizer;
|
||||||
|
|
||||||
public UpdateTaskStatusUseCase(TaskRepository taskRepository) {
|
public UpdateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
|
this.authorizer = authorizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(Long id, TaskStatus newStatus) {
|
public CreateTaskOutput execute(Long id, TaskStatus newStatus, String userEmail) {
|
||||||
LOGGER.infof("Updating status for task %d to %s", id, newStatus);
|
LOGGER.infof("Updating status for task %d to %s by user %s", id, newStatus, userEmail);
|
||||||
Task task = taskRepository.findTaskById(id);
|
Task task = taskRepository.findTaskById(id);
|
||||||
|
|
||||||
|
Long orgId = null;
|
||||||
|
if (task.getProject() != null && task.getProject().getOrganization() != null) {
|
||||||
|
orgId = task.getProject().getOrganization().getId();
|
||||||
|
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
|
||||||
|
orgId = task.getCategory().getOrganization().getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orgId != null) {
|
||||||
|
authorizer.execute(userEmail, orgId);
|
||||||
|
} else if (task.getUser() != null && !task.getUser().getEmail().equals(userEmail)) {
|
||||||
|
LOGGER.warnf("User %s denied status update on task %d owned by %s", userEmail, id, task.getUser().getEmail());
|
||||||
|
throw new SecurityException("Você não tem permissão para atualizar esta tarefa.");
|
||||||
|
}
|
||||||
|
|
||||||
task.setStatus(newStatus);
|
task.setStatus(newStatus);
|
||||||
|
|
||||||
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
||||||
|
|
@ -29,3 +44,4 @@ public class UpdateTaskStatusUseCase {
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,21 +24,36 @@ public class UpdateTaskUseCase {
|
||||||
private final CategoryRepository categoryRepository;
|
private final CategoryRepository categoryRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserOrganizationRepository userOrganizationRepository;
|
private final UserOrganizationRepository userOrganizationRepository;
|
||||||
|
private final net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository;
|
||||||
|
|
||||||
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository,
|
||||||
CategoryRepository categoryRepository, UserRepository userRepository,
|
CategoryRepository categoryRepository, UserRepository userRepository,
|
||||||
UserOrganizationRepository userOrganizationRepository) {
|
UserOrganizationRepository userOrganizationRepository,
|
||||||
|
net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
this.projectRepository = projectRepository;
|
this.projectRepository = projectRepository;
|
||||||
this.categoryRepository = categoryRepository;
|
this.categoryRepository = categoryRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.userOrganizationRepository = userOrganizationRepository;
|
this.userOrganizationRepository = userOrganizationRepository;
|
||||||
|
this.organizationRepository = organizationRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(UpdateTaskInput input) {
|
public CreateTaskOutput execute(UpdateTaskInput input) {
|
||||||
LOGGER.infof("Updating task %d requested by user %s", input.id(), input.userEmail());
|
LOGGER.infof("Updating task %d requested by user %s", input.id(), input.userEmail());
|
||||||
Task task = taskRepository.findTaskById(input.id());
|
Task task = taskRepository.findTaskById(input.id());
|
||||||
|
|
||||||
|
Long currentOrgId = null;
|
||||||
|
if (task.getOrganization() != null) {
|
||||||
|
currentOrgId = task.getOrganization().getId();
|
||||||
|
} else if (task.getProject() != null && task.getProject().getOrganization() != null) {
|
||||||
|
currentOrgId = task.getProject().getOrganization().getId();
|
||||||
|
} else if (task.getCategory() != null && task.getCategory().getOrganization() != null) {
|
||||||
|
currentOrgId = task.getCategory().getOrganization().getId();
|
||||||
|
}
|
||||||
|
if (currentOrgId != null && !currentOrgId.equals(input.organizationId())) {
|
||||||
|
validateRequesterMembership(currentOrgId, input.userEmail());
|
||||||
|
}
|
||||||
|
|
||||||
validateRequesterMembership(input.organizationId(), input.userEmail());
|
validateRequesterMembership(input.organizationId(), input.userEmail());
|
||||||
|
|
||||||
task.setTitle(input.title());
|
task.setTitle(input.title());
|
||||||
|
|
@ -46,6 +61,10 @@ public class UpdateTaskUseCase {
|
||||||
task.setStatus(input.status());
|
task.setStatus(input.status());
|
||||||
task.setExternalUrl(input.externalUrl());
|
task.setExternalUrl(input.externalUrl());
|
||||||
|
|
||||||
|
if (Objects.nonNull(input.organizationId())) {
|
||||||
|
organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization);
|
||||||
|
}
|
||||||
|
|
||||||
if (Objects.nonNull(input.projectId())) {
|
if (Objects.nonNull(input.projectId())) {
|
||||||
Project project = projectRepository.findProjectById(input.projectId())
|
Project project = projectRepository.findProjectById(input.projectId())
|
||||||
.orElseThrow(() -> {
|
.orElseThrow(() -> {
|
||||||
|
|
@ -71,6 +90,18 @@ public class UpdateTaskUseCase {
|
||||||
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
|
task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId()));
|
||||||
|
|
||||||
if (Objects.nonNull(input.history())) {
|
if (Objects.nonNull(input.history())) {
|
||||||
|
long openIntervalCount = input.history().stream().filter(h -> h.endAt() == null).count();
|
||||||
|
if (openIntervalCount > 1) {
|
||||||
|
throw new IllegalArgumentException("Não é permitido mais de um intervalo em andamento ao mesmo tempo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var hInput : input.history()) {
|
||||||
|
if (hInput.startAt() != null && hInput.endAt() != null && hInput.endAt().isBefore(hInput.startAt())) {
|
||||||
|
throw new IllegalArgumentException("Data final do intervalo não pode ser anterior à data inicial.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
User defaultWorker = task.getUser();
|
||||||
List<TaskHistoryItem> updatedHistory = input.history().stream().map(hInput -> {
|
List<TaskHistoryItem> updatedHistory = input.history().stream().map(hInput -> {
|
||||||
if (Objects.nonNull(hInput.id())) {
|
if (Objects.nonNull(hInput.id())) {
|
||||||
return task.getHistory().stream()
|
return task.getHistory().stream()
|
||||||
|
|
@ -79,18 +110,23 @@ public class UpdateTaskUseCase {
|
||||||
.map(existing -> {
|
.map(existing -> {
|
||||||
existing.setStartAt(hInput.startAt());
|
existing.setStartAt(hInput.startAt());
|
||||||
existing.setEndAt(hInput.endAt());
|
existing.setEndAt(hInput.endAt());
|
||||||
|
if (existing.getUser() == null) {
|
||||||
|
existing.setUser(defaultWorker);
|
||||||
|
}
|
||||||
return existing;
|
return existing;
|
||||||
})
|
})
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
TaskHistoryItem newItem = new TaskHistoryItem();
|
TaskHistoryItem newItem = new TaskHistoryItem();
|
||||||
newItem.setStartAt(hInput.startAt());
|
newItem.setStartAt(hInput.startAt());
|
||||||
newItem.setEndAt(hInput.endAt());
|
newItem.setEndAt(hInput.endAt());
|
||||||
|
newItem.setUser(defaultWorker);
|
||||||
return newItem;
|
return newItem;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
TaskHistoryItem newItem = new TaskHistoryItem();
|
TaskHistoryItem newItem = new TaskHistoryItem();
|
||||||
newItem.setStartAt(hInput.startAt());
|
newItem.setStartAt(hInput.startAt());
|
||||||
newItem.setEndAt(hInput.endAt());
|
newItem.setEndAt(hInput.endAt());
|
||||||
|
newItem.setUser(defaultWorker);
|
||||||
return newItem;
|
return newItem;
|
||||||
}
|
}
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ public class CreateUserUseCase {
|
||||||
|
|
||||||
LOGGER.infof("Creating user with email %s", input.email());
|
LOGGER.infof("Creating user with email %s", input.email());
|
||||||
User user = CreateUserMapper.toUser(input);
|
User user = CreateUserMapper.toUser(input);
|
||||||
|
if (user.getPassword() != null && !user.getPassword().isBlank()) {
|
||||||
|
user.setPassword(org.mindrot.jbcrypt.BCrypt.hashpw(user.getPassword(), org.mindrot.jbcrypt.BCrypt.gensalt(10)));
|
||||||
|
}
|
||||||
User savedUser = userRepository.save(user);
|
User savedUser = userRepository.save(user);
|
||||||
LOGGER.infof("User %d created successfully with email %s", savedUser.getId(), savedUser.getEmail());
|
LOGGER.infof("User %d created successfully with email %s", savedUser.getId(), savedUser.getEmail());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
3
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
isSameMonth,
|
isSameMonth,
|
||||||
type WorkGoal,
|
type WorkGoal,
|
||||||
} from '../../lib/userGoals'
|
} from '../../lib/userGoals'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
|
||||||
interface BiUniformCardProps {
|
interface BiUniformCardProps {
|
||||||
card: DashboardV2Card
|
card: DashboardV2Card
|
||||||
|
|
@ -42,6 +43,7 @@ interface BiUniformCardProps {
|
||||||
activeTask: Task | null
|
activeTask: Task | null
|
||||||
activeSeconds: number
|
activeSeconds: number
|
||||||
workGoal: WorkGoal
|
workGoal: WorkGoal
|
||||||
|
globalScope?: 'mine' | 'team'
|
||||||
isBeingDragged?: boolean
|
isBeingDragged?: boolean
|
||||||
isDragOver?: boolean
|
isDragOver?: boolean
|
||||||
onToggleTimer: (task: Task) => void
|
onToggleTimer: (task: Task) => void
|
||||||
|
|
@ -119,6 +121,7 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
activeTask,
|
activeTask,
|
||||||
activeSeconds,
|
activeSeconds,
|
||||||
workGoal,
|
workGoal,
|
||||||
|
globalScope = 'team',
|
||||||
isBeingDragged = false,
|
isBeingDragged = false,
|
||||||
isDragOver = false,
|
isDragOver = false,
|
||||||
onToggleTimer,
|
onToggleTimer,
|
||||||
|
|
@ -131,11 +134,30 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
onDrop,
|
onDrop,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { user } = useAuth()
|
||||||
const theme = THEME_STYLES[card.colorTheme || 'brand']
|
const theme = THEME_STYLES[card.colorTheme || 'brand']
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
// 1. Filter Tasks based on card's combined dimensions
|
// Resolve scope: Card's specific scope takes precedence, otherwise fallback to page's globalScope
|
||||||
|
const effectiveScope: 'mine' | 'team' =
|
||||||
|
card.scope && card.scope !== 'INHERIT'
|
||||||
|
? card.scope === 'MINE'
|
||||||
|
? 'mine'
|
||||||
|
: 'team'
|
||||||
|
: globalScope
|
||||||
|
|
||||||
|
// 1. Filter Tasks based on scope and card's combined dimensions
|
||||||
const filteredTasks = tasks.filter((t) => {
|
const filteredTasks = tasks.filter((t) => {
|
||||||
|
// Scope filter (if 'mine', only user's tasks)
|
||||||
|
if (effectiveScope === 'mine') {
|
||||||
|
const isMine = Boolean(
|
||||||
|
t.user?.email &&
|
||||||
|
user?.email &&
|
||||||
|
t.user.email.toLowerCase() === user.email.toLowerCase()
|
||||||
|
)
|
||||||
|
if (!isMine) return false
|
||||||
|
}
|
||||||
|
|
||||||
if (card.projectId && t.project?.id !== card.projectId) return false
|
if (card.projectId && t.project?.id !== card.projectId) return false
|
||||||
if (card.categoryId && t.category?.id !== card.categoryId) return false
|
if (card.categoryId && t.category?.id !== card.categoryId) return false
|
||||||
if (card.statusFilter && card.statusFilter !== 'ALL') {
|
if (card.statusFilter && card.statusFilter !== 'ALL') {
|
||||||
|
|
@ -351,24 +373,35 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'ACTIVE_TIMER': {
|
case 'ACTIVE_TIMER': {
|
||||||
const targetTask = activeTask || tasks[0]
|
const isMyActiveTask = Boolean(
|
||||||
|
activeTask &&
|
||||||
|
user?.email &&
|
||||||
|
activeTask.user?.email &&
|
||||||
|
activeTask.user.email.toLowerCase() === user.email.toLowerCase()
|
||||||
|
)
|
||||||
|
const currentActive = isMyActiveTask ? activeTask : null
|
||||||
|
const myTasks = tasks.filter((t) => {
|
||||||
|
if (!user?.email || !t.user?.email) return false
|
||||||
|
return t.user.email.toLowerCase() === user.email.toLowerCase()
|
||||||
|
})
|
||||||
|
const targetTask = currentActive || myTasks.find((t) => t.status === 'IN_PROGRESS') || myTasks.find((t) => t.status === 'NOT_STARTED') || myTasks[0]
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1 my-auto">
|
<div className="space-y-1 my-auto">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xl sm:text-2xl font-extrabold font-mono text-foreground">
|
<span className="text-xl sm:text-2xl font-extrabold font-mono text-foreground">
|
||||||
{activeTask ? formatSecondsToTimer(activeSeconds) : '00:00:00'}
|
{currentActive ? formatSecondsToTimer(activeSeconds) : '00:00:00'}
|
||||||
</span>
|
</span>
|
||||||
{targetTask && (
|
{targetTask && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleTimer(targetTask)}
|
onClick={() => onToggleTimer(targetTask)}
|
||||||
className={`p-1.5 rounded-lg transition-all shadow-xs ${
|
className={`p-1.5 rounded-lg transition-all shadow-xs cursor-pointer ${
|
||||||
activeTask
|
currentActive
|
||||||
? 'bg-rose-500/20 text-rose-300 ring-1 ring-rose-500/40'
|
? 'bg-rose-500/20 text-rose-300 ring-1 ring-rose-500/40'
|
||||||
: 'bg-teal-500/10 text-teal-700 hover:bg-teal-500/20'
|
: 'bg-teal-500/10 text-teal-700 hover:bg-teal-500/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{activeTask ? (
|
{currentActive ? (
|
||||||
<Square className="w-3 h-3 fill-rose-300" />
|
<Square className="w-3 h-3 fill-rose-300" />
|
||||||
) : (
|
) : (
|
||||||
<Play className="w-3 h-3 fill-teal-700 dark:fill-teal-300" />
|
<Play className="w-3 h-3 fill-teal-700 dark:fill-teal-300" />
|
||||||
|
|
@ -377,7 +410,7 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted truncate">
|
<p className="text-[10px] text-muted truncate">
|
||||||
{activeTask ? activeTask.title : targetTask ? targetTask.title : 'Nenhuma tarefa pronta'}
|
{currentActive ? currentActive.title : targetTask ? targetTask.title : 'Nenhuma tarefa pronta'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -524,6 +557,23 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
|
||||||
|
|
||||||
{/* Footer: Compact Dimension Tags */}
|
{/* Footer: Compact Dimension Tags */}
|
||||||
<div className="pt-1.5 border-t border-border-soft/60 flex items-center gap-1.5 flex-wrap text-[9px] truncate">
|
<div className="pt-1.5 border-t border-border-soft/60 flex items-center gap-1.5 flex-wrap text-[9px] truncate">
|
||||||
|
{/* Scope Tag */}
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.2 rounded font-semibold border ${
|
||||||
|
effectiveScope === 'mine'
|
||||||
|
? 'bg-brand/10 text-brand border-brand/25'
|
||||||
|
: 'bg-zinc-500/10 text-muted border-zinc-500/20'
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
card.scope && card.scope !== 'INHERIT'
|
||||||
|
? 'Escopo fixado nas configurações deste card'
|
||||||
|
: 'Escopo herdado do filtro global da página'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{effectiveScope === 'mine' ? '👤 Minhas' : '👥 Equipe'}
|
||||||
|
{card.scope && card.scope !== 'INHERIT' && ' • fixo'}
|
||||||
|
</span>
|
||||||
|
|
||||||
<span className={`px-1.5 py-0.2 rounded font-semibold border ${theme.badge}`}>
|
<span className={`px-1.5 py-0.2 rounded font-semibold border ${theme.badge}`}>
|
||||||
{periodLabels[card.period]}
|
{periodLabels[card.period]}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Layers,
|
Layers,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { DashboardV2Card, BiMetricType, BiPeriodFilter } from '../../lib/userPreferences'
|
import type { DashboardV2Card, BiMetricType, BiPeriodFilter, BiCardScope } from '../../lib/userPreferences'
|
||||||
import type { Project, Category } from '../../types/task'
|
import type { Project, Category } from '../../types/task'
|
||||||
|
|
||||||
interface CreateBiCardModalProps {
|
interface CreateBiCardModalProps {
|
||||||
|
|
@ -103,6 +103,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
|
||||||
const [categoryId, setCategoryId] = useState<number | undefined>(undefined)
|
const [categoryId, setCategoryId] = useState<number | undefined>(undefined)
|
||||||
const [statusFilter, setStatusFilter] = useState<'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'>('ALL')
|
const [statusFilter, setStatusFilter] = useState<'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'>('ALL')
|
||||||
const [colorTheme, setColorTheme] = useState<DashboardV2Card['colorTheme']>('brand')
|
const [colorTheme, setColorTheme] = useState<DashboardV2Card['colorTheme']>('brand')
|
||||||
|
const [scope, setScope] = useState<BiCardScope>('INHERIT')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingCard) {
|
if (editingCard) {
|
||||||
|
|
@ -113,6 +114,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
|
||||||
setCategoryId(editingCard.categoryId)
|
setCategoryId(editingCard.categoryId)
|
||||||
setStatusFilter(editingCard.statusFilter || 'ALL')
|
setStatusFilter(editingCard.statusFilter || 'ALL')
|
||||||
setColorTheme(editingCard.colorTheme || 'brand')
|
setColorTheme(editingCard.colorTheme || 'brand')
|
||||||
|
setScope(editingCard.scope || 'INHERIT')
|
||||||
} else {
|
} else {
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setMetricType('HOURS_TRACKED')
|
setMetricType('HOURS_TRACKED')
|
||||||
|
|
@ -121,6 +123,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
|
||||||
setCategoryId(undefined)
|
setCategoryId(undefined)
|
||||||
setStatusFilter('ALL')
|
setStatusFilter('ALL')
|
||||||
setColorTheme('brand')
|
setColorTheme('brand')
|
||||||
|
setScope('INHERIT')
|
||||||
}
|
}
|
||||||
}, [editingCard, isOpen])
|
}, [editingCard, isOpen])
|
||||||
|
|
||||||
|
|
@ -138,6 +141,7 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
|
||||||
categoryId,
|
categoryId,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
colorTheme,
|
colorTheme,
|
||||||
|
scope,
|
||||||
}
|
}
|
||||||
onSave(card)
|
onSave(card)
|
||||||
onClose()
|
onClose()
|
||||||
|
|
@ -313,6 +317,23 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Data Scope Filter */}
|
||||||
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
|
<label className="text-xs font-bold text-foreground flex items-center justify-between">
|
||||||
|
<span>Escopo dos Dados (Atribuição)</span>
|
||||||
|
<span className="text-[11px] text-muted font-normal">Individual vs Equipe</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={scope}
|
||||||
|
onChange={(e) => setScope(e.target.value as BiCardScope)}
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
|
||||||
|
>
|
||||||
|
<option value="INHERIT">🔗 Seguir filtro global da página (Padrão)</option>
|
||||||
|
<option value="MINE">👤 Sempre Apenas Minhas Tarefas (Individual)</option>
|
||||||
|
<option value="TEAM">👥 Sempre Toda a Equipe (Workspace Geral)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export const AppShell = () => {
|
||||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-app-bg text-text-main flex font-sans selection:bg-brand-soft selection:text-brand relative overflow-x-hidden">
|
<div className="h-screen w-full bg-app-bg text-text-main flex font-sans selection:bg-brand-soft selection:text-brand relative overflow-hidden">
|
||||||
{/* Decorative ambient background glows matching Landing Page aesthetic */}
|
{/* Decorative ambient background glows matching Landing Page aesthetic */}
|
||||||
<div className="fixed top-0 left-1/4 w-[650px] h-[350px] bg-gradient-to-tr from-accent/10 via-brand/12 to-transparent blur-3xl pointer-events-none rounded-full -z-0" />
|
<div className="fixed top-0 left-1/4 w-[650px] h-[350px] bg-gradient-to-tr from-accent/10 via-brand/12 to-transparent blur-3xl pointer-events-none rounded-full -z-0" />
|
||||||
<div className="fixed bottom-10 right-10 w-[550px] h-[350px] bg-gradient-to-br from-brand/10 via-accent/10 to-transparent blur-3xl pointer-events-none rounded-full -z-0" />
|
<div className="fixed bottom-10 right-10 w-[550px] h-[350px] bg-gradient-to-br from-brand/10 via-accent/10 to-transparent blur-3xl pointer-events-none rounded-full -z-0" />
|
||||||
|
|
@ -18,14 +18,14 @@ export const AppShell = () => {
|
||||||
onCloseMobile={() => setIsMobileOpen(false)}
|
onCloseMobile={() => setIsMobileOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area - only this scrolls */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 z-10">
|
<div className="flex-1 flex flex-col min-w-0 h-full overflow-y-auto z-10">
|
||||||
{/* Mobile-only compact top bar to open drawer on smartphones */}
|
{/* Mobile-only compact top bar to open drawer on smartphones */}
|
||||||
<div className="lg:hidden h-14 border-b border-border-soft bg-surface/85 backdrop-blur-md px-4 flex items-center justify-between sticky top-0 z-20">
|
<div className="lg:hidden h-14 border-b border-border-soft bg-surface/85 backdrop-blur-md px-4 flex items-center justify-between sticky top-0 z-20 shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsMobileOpen(true)}
|
onClick={() => setIsMobileOpen(true)}
|
||||||
className="p-2 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
|
className="p-2 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
aria-label="Abrir menu lateral"
|
aria-label="Abrir menu lateral"
|
||||||
>
|
>
|
||||||
<MenuIcon className="w-5 h-5" />
|
<MenuIcon className="w-5 h-5" />
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,13 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
: 'U'
|
: 'U'
|
||||||
|
|
||||||
|
const isMyActiveTask = Boolean(
|
||||||
|
activeTask &&
|
||||||
|
user?.email &&
|
||||||
|
((activeTask.user?.email && activeTask.user.email.toLowerCase() === user.email.toLowerCase()) ||
|
||||||
|
(activeTask.createdBy?.email && activeTask.createdBy.email.toLowerCase() === user.email.toLowerCase()))
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 h-18 border-b border-border-soft bg-surface/85 backdrop-blur-md px-4 sm:px-6 lg:px-8 flex items-center justify-between transition-all">
|
<header className="sticky top-0 z-40 h-18 border-b border-border-soft bg-surface/85 backdrop-blur-md px-4 sm:px-6 lg:px-8 flex items-center justify-between transition-all">
|
||||||
{/* Left Area: Mobile Menu Trigger + Workspace Switcher */}
|
{/* Left Area: Mobile Menu Trigger + Workspace Switcher */}
|
||||||
|
|
@ -175,7 +182,7 @@ export const Header = ({ onToggleMobileSidebar }: HeaderProps) => {
|
||||||
{/* Right Area: Active Tracker Pill + Action Button + User Profile */}
|
{/* Right Area: Active Tracker Pill + Action Button + User Profile */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Active Timer Pill Banner */}
|
{/* Active Timer Pill Banner */}
|
||||||
{activeTask ? (
|
{isMyActiveTask && activeTask ? (
|
||||||
<div className="hidden md:flex items-center gap-2.5 px-3 py-1.5 rounded-xl bg-teal-500/10 border border-teal-500/30 text-xs text-teal-300">
|
<div className="hidden md:flex items-center gap-2.5 px-3 py-1.5 rounded-xl bg-teal-500/10 border border-teal-500/30 text-xs text-teal-300">
|
||||||
<div className="w-2 h-2 rounded-full bg-teal-400 animate-ping" />
|
<div className="w-2 h-2 rounded-full bg-teal-400 animate-ping" />
|
||||||
<Clock className="w-3.5 h-3.5 text-teal-400 animate-pulse" />
|
<Clock className="w-3.5 h-3.5 text-teal-400 animate-pulse" />
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,13 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMyActiveTask = Boolean(
|
||||||
|
activeTask &&
|
||||||
|
user?.email &&
|
||||||
|
((activeTask.user?.email && activeTask.user.email.toLowerCase() === user.email.toLowerCase()) ||
|
||||||
|
(activeTask.createdBy?.email && activeTask.createdBy.email.toLowerCase() === user.email.toLowerCase()))
|
||||||
|
)
|
||||||
|
|
||||||
// All navigation items strictly formatted with a SINGLE WORD
|
// All navigation items strictly formatted with a SINGLE WORD
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
|
|
@ -232,7 +239,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Running Task Activity Status Banner */}
|
{/* Active Running Task Activity Status Banner */}
|
||||||
{activeTask && (
|
{isMyActiveTask && activeTask && (
|
||||||
<div className="px-4 py-2 shrink-0">
|
<div className="px-4 py-2 shrink-0">
|
||||||
<div className="p-3 rounded-2xl bg-teal-500/10 border border-teal-600/25 dark:border-teal-500/30 shadow-xs relative overflow-hidden">
|
<div className="p-3 rounded-2xl bg-teal-500/10 border border-teal-600/25 dark:border-teal-500/30 shadow-xs relative overflow-hidden">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||||
|
|
@ -416,7 +423,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop Sidebar (Permanent) */}
|
{/* Desktop Sidebar (Permanent) */}
|
||||||
<aside className="hidden lg:block w-64 h-screen sticky top-0 shrink-0 z-30">
|
<aside className="hidden lg:block w-64 h-full shrink-0 z-30">
|
||||||
{navContent}
|
{navContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { categoryService } from '../../services/categoryService'
|
||||||
import { organizationService } from '../../services/organizationService'
|
import { organizationService } from '../../services/organizationService'
|
||||||
import { CustomSelect } from '../ui/Select'
|
import { CustomSelect } from '../ui/Select'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import { canDeleteTask } from '../../lib/taskPermissions'
|
||||||
import {
|
import {
|
||||||
calculateTaskTotalSeconds,
|
calculateTaskTotalSeconds,
|
||||||
formatSecondsToTimer,
|
formatSecondsToTimer,
|
||||||
|
|
@ -46,6 +47,7 @@ interface CreateTaskModalProps {
|
||||||
history?: TaskHistoryItemInput[]
|
history?: TaskHistoryItemInput[]
|
||||||
externalUrl?: string
|
externalUrl?: string
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
|
onDelete?: (task: Task) => void
|
||||||
organizationId: number
|
organizationId: number
|
||||||
initialStatus?: TaskStatus
|
initialStatus?: TaskStatus
|
||||||
initialTask?: Task | null
|
initialTask?: Task | null
|
||||||
|
|
@ -56,12 +58,13 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
organizationId,
|
organizationId,
|
||||||
initialStatus = 'NOT_STARTED',
|
initialStatus = 'NOT_STARTED',
|
||||||
initialTask = null,
|
initialTask = null,
|
||||||
initialTab = 'details',
|
initialTab = 'details',
|
||||||
}) => {
|
}) => {
|
||||||
const { user } = useAuth()
|
const { user, activeOrg } = useAuth()
|
||||||
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details')
|
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details')
|
||||||
|
|
||||||
// Task details
|
// Task details
|
||||||
|
|
@ -343,13 +346,24 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||||
endAt: item.endAt,
|
endAt: item.endAt,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
let effectiveAssigneeUserId = assigneeUserId
|
||||||
|
if (!initialTask && effectiveAssigneeUserId === undefined) {
|
||||||
|
const myMember = members.find((m) => m.userEmail?.toLowerCase() === user?.email?.toLowerCase())
|
||||||
|
if (myMember) {
|
||||||
|
effectiveAssigneeUserId = myMember.userId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (effectiveAssigneeUserId === -1) {
|
||||||
|
effectiveAssigneeUserId = undefined
|
||||||
|
}
|
||||||
|
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
status,
|
status,
|
||||||
projectId,
|
projectId,
|
||||||
categoryId,
|
categoryId,
|
||||||
assigneeUserId,
|
assigneeUserId: effectiveAssigneeUserId,
|
||||||
history: initialTask ? mappedHistory : undefined,
|
history: initialTask ? mappedHistory : undefined,
|
||||||
externalUrl: externalUrl.trim() || undefined,
|
externalUrl: externalUrl.trim() || undefined,
|
||||||
})
|
})
|
||||||
|
|
@ -585,15 +599,19 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||||
Responsável
|
Responsável
|
||||||
</label>
|
</label>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={assigneeUserId ?? 0}
|
value={
|
||||||
onChange={(val) => setAssigneeUserId(Number(val) === 0 ? undefined : Number(val))}
|
assigneeUserId !== undefined
|
||||||
placeholder="Atribuir a mim mesmo / Sem responsável"
|
? assigneeUserId
|
||||||
|
: (members.find((m) => m.userEmail?.toLowerCase() === user?.email?.toLowerCase())?.userId ?? -1)
|
||||||
|
}
|
||||||
|
onChange={(val) => setAssigneeUserId(Number(val) === -1 ? -1 : Number(val))}
|
||||||
|
placeholder="Selecione o responsável"
|
||||||
leftIcon={<User className="w-3.5 h-3.5 text-muted" />}
|
leftIcon={<User className="w-3.5 h-3.5 text-muted" />}
|
||||||
options={[
|
options={[
|
||||||
{ value: 0, label: 'Sem responsável (atribuir a mim)' },
|
{ value: -1, label: 'Sem responsável (Livre)' },
|
||||||
...members.map((m) => ({
|
...members.map((m) => ({
|
||||||
value: m.userId,
|
value: m.userId,
|
||||||
label: m.userName,
|
label: `${m.userName}${m.userEmail?.toLowerCase() === user?.email?.toLowerCase() ? ' (Você)' : ''}`,
|
||||||
description: m.userEmail,
|
description: m.userEmail,
|
||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
|
|
@ -922,9 +940,25 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
||||||
|
|
||||||
{/* Footer Controls */}
|
{/* Footer Controls */}
|
||||||
<div className="pt-4 border-t border-border flex items-center justify-between gap-2.5">
|
<div className="pt-4 border-t border-border flex items-center justify-between gap-2.5">
|
||||||
<div className="text-[11px] text-muted">
|
<div className="flex items-center gap-3">
|
||||||
|
{initialTask && onDelete && canDeleteTask(initialTask, user, activeOrg) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Tem certeza que deseja excluir a tarefa "${initialTask.title}"?`)) {
|
||||||
|
onDelete(initialTask)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-3 py-2 rounded-xl border border-rose-500/30 bg-rose-500/10 text-xs font-semibold text-rose-500 hover:bg-rose-500/20 transition-colors inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Excluir</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{initialTask && (
|
{initialTask && (
|
||||||
<span>
|
<span className="text-[11px] text-muted">
|
||||||
Tempo total: <strong className="text-foreground font-mono">{formatSecondsToHuman(totalCalculatedSeconds)}</strong>
|
Tempo total: <strong className="text-foreground font-mono">{formatSecondsToHuman(totalCalculatedSeconds)}</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Play, Square, Clock, Folder, Tag, MoreVertical, CircleDot, ExternalLink } from 'lucide-react'
|
import { Play, Square, Clock, Folder, Tag, MoreVertical, CircleDot, ExternalLink, Trash2 } from 'lucide-react'
|
||||||
import type { Task } from '../../types/task'
|
import type { Task } from '../../types/task'
|
||||||
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
|
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import { canDeleteTask } from '../../lib/taskPermissions'
|
||||||
|
|
||||||
interface TaskCardProps {
|
interface TaskCardProps {
|
||||||
task: Task
|
task: Task
|
||||||
onToggleTimer: (task: Task) => void
|
onToggleTimer: (task: Task) => void
|
||||||
onEdit?: (task: Task) => void
|
onEdit?: (task: Task) => void
|
||||||
onOpenHistory?: (task: Task) => void
|
onOpenHistory?: (task: Task) => void
|
||||||
|
onDelete?: (task: Task) => void
|
||||||
isTrackingThis?: boolean
|
isTrackingThis?: boolean
|
||||||
currentSeconds?: number
|
currentSeconds?: number
|
||||||
}
|
}
|
||||||
|
|
@ -17,9 +20,11 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
onToggleTimer,
|
onToggleTimer,
|
||||||
onEdit,
|
onEdit,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
|
onDelete,
|
||||||
isTrackingThis,
|
isTrackingThis,
|
||||||
currentSeconds,
|
currentSeconds,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { user, activeOrg } = useAuth()
|
||||||
const running = isTrackingThis ?? isTaskRunning(task)
|
const running = isTrackingThis ?? isTaskRunning(task)
|
||||||
const totalSeconds = currentSeconds !== undefined ? currentSeconds : calculateTaskTotalSeconds(task)
|
const totalSeconds = currentSeconds !== undefined ? currentSeconds : calculateTaskTotalSeconds(task)
|
||||||
const [menuOpen, setMenuOpen] = React.useState(false)
|
const [menuOpen, setMenuOpen] = React.useState(false)
|
||||||
|
|
@ -90,7 +95,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* More Actions Menu */}
|
{/* More Actions Menu */}
|
||||||
{(onEdit || onOpenHistory) && (
|
{(onEdit || onOpenHistory || (onDelete && canDeleteTask(task, user, activeOrg))) && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -142,6 +147,23 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
<Clock className="w-3.5 h-3.5 text-muted" />
|
<Clock className="w-3.5 h-3.5 text-muted" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onDelete && canDeleteTask(task, user, activeOrg) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setMenuOpen(false)
|
||||||
|
if (window.confirm(`Tem certeza que deseja excluir a tarefa "${task.title}"?`)) {
|
||||||
|
onDelete(task)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<span>Excluir Tarefa</span>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface TaskKanbanViewProps {
|
||||||
onStatusChange: (task: Task, newStatus: TaskStatus) => void
|
onStatusChange: (task: Task, newStatus: TaskStatus) => void
|
||||||
onEditTask: (task: Task) => void
|
onEditTask: (task: Task) => void
|
||||||
onOpenHistory?: (task: Task) => void
|
onOpenHistory?: (task: Task) => void
|
||||||
|
onDeleteTask?: (task: Task) => void
|
||||||
onAddTaskToStatus: (status: TaskStatus) => void
|
onAddTaskToStatus: (status: TaskStatus) => void
|
||||||
activeTaskId?: number | null
|
activeTaskId?: number | null
|
||||||
activeTaskSeconds?: number
|
activeTaskSeconds?: number
|
||||||
|
|
@ -69,6 +70,7 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onEditTask,
|
onEditTask,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
|
onDeleteTask,
|
||||||
onAddTaskToStatus,
|
onAddTaskToStatus,
|
||||||
activeTaskId,
|
activeTaskId,
|
||||||
activeTaskSeconds,
|
activeTaskSeconds,
|
||||||
|
|
@ -267,6 +269,7 @@ export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
|
||||||
onToggleTimer={onToggleTimer}
|
onToggleTimer={onToggleTimer}
|
||||||
onEdit={onEditTask}
|
onEdit={onEditTask}
|
||||||
onOpenHistory={onOpenHistory}
|
onOpenHistory={onOpenHistory}
|
||||||
|
onDelete={onDeleteTask}
|
||||||
isTrackingThis={activeTaskId === task.id}
|
isTrackingThis={activeTaskId === task.id}
|
||||||
currentSeconds={activeTaskId === task.id ? activeTaskSeconds : undefined}
|
currentSeconds={activeTaskId === task.id ? activeTaskSeconds : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2, ExternalLink } from 'lucide-react'
|
import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2, ExternalLink, Trash2 } from 'lucide-react'
|
||||||
import type { Task, TaskStatus } from '../../types/task'
|
import type { Task, TaskStatus } from '../../types/task'
|
||||||
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
|
import { calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
|
||||||
import { CustomSelect } from '../ui/Select'
|
import { CustomSelect } from '../ui/Select'
|
||||||
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
|
import { canDeleteTask } from '../../lib/taskPermissions'
|
||||||
|
|
||||||
interface TaskListViewProps {
|
interface TaskListViewProps {
|
||||||
tasks: Task[]
|
tasks: Task[]
|
||||||
|
|
@ -10,6 +12,7 @@ interface TaskListViewProps {
|
||||||
onStatusChange: (task: Task, newStatus: TaskStatus) => void
|
onStatusChange: (task: Task, newStatus: TaskStatus) => void
|
||||||
onEditTask: (task: Task) => void
|
onEditTask: (task: Task) => void
|
||||||
onOpenHistory?: (task: Task) => void
|
onOpenHistory?: (task: Task) => void
|
||||||
|
onDeleteTask?: (task: Task) => void
|
||||||
activeTaskId?: number | null
|
activeTaskId?: number | null
|
||||||
activeTaskSeconds?: number
|
activeTaskSeconds?: number
|
||||||
}
|
}
|
||||||
|
|
@ -21,9 +24,11 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onEditTask,
|
onEditTask,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
|
onDeleteTask,
|
||||||
activeTaskId,
|
activeTaskId,
|
||||||
activeTaskSeconds,
|
activeTaskSeconds,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { user, activeOrg } = useAuth()
|
||||||
const [activeMenuId, setActiveMenuId] = React.useState<number | null>(null)
|
const [activeMenuId, setActiveMenuId] = React.useState<number | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -43,10 +48,9 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border/60">
|
<tbody className="divide-y divide-border/60">
|
||||||
{tasks.map((task) => {
|
{tasks.map((task) => {
|
||||||
const isRunning =
|
const isRunning = Boolean(activeTaskId && activeTaskId === task.id)
|
||||||
activeTaskId === task.id || (activeTaskId === undefined && isTaskRunning(task))
|
|
||||||
const totalSec =
|
const totalSec =
|
||||||
activeTaskId === task.id && activeTaskSeconds !== undefined
|
isRunning && activeTaskSeconds !== undefined
|
||||||
? activeTaskSeconds
|
? activeTaskSeconds
|
||||||
: calculateTaskTotalSeconds(task)
|
: calculateTaskTotalSeconds(task)
|
||||||
|
|
||||||
|
|
@ -247,6 +251,19 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
||||||
<span>Concluir</span>
|
<span>Concluir</span>
|
||||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
{onDeleteTask && canDeleteTask(task, user, activeOrg) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveMenuId(null)
|
||||||
|
onDeleteTask(task)
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<span>Excluir Tarefa</span>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ interface AuthContextType {
|
||||||
register: (name: string, email: string, password: string) => Promise<CreateAccountResponse>
|
register: (name: string, email: string, password: string) => Promise<CreateAccountResponse>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
refreshUser: () => Promise<void>
|
refreshUser: () => Promise<void>
|
||||||
|
updateUser: (updated: Partial<LoginResponse>) => void
|
||||||
setActiveOrg: (id: number) => void
|
setActiveOrg: (id: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,6 +149,15 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateUser = useCallback((updated: Partial<LoginResponse>) => {
|
||||||
|
setUser((prev) => {
|
||||||
|
if (!prev) return null
|
||||||
|
const next = { ...prev, ...updated }
|
||||||
|
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(next))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const setActiveOrg = (id: number) => {
|
const setActiveOrg = (id: number) => {
|
||||||
setActiveOrgId(id)
|
setActiveOrgId(id)
|
||||||
localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, id.toString())
|
localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, id.toString())
|
||||||
|
|
@ -169,6 +179,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
refreshUser,
|
refreshUser,
|
||||||
|
updateUser,
|
||||||
setActiveOrg,
|
setActiveOrg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -15,29 +15,65 @@ interface TimerContextType {
|
||||||
const TimerContext = createContext<TimerContextType | undefined>(undefined)
|
const TimerContext = createContext<TimerContextType | undefined>(undefined)
|
||||||
|
|
||||||
export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const { activeOrg } = useAuth()
|
const { user, activeOrg } = useAuth()
|
||||||
const [activeTask, setActiveTask] = useState<Task | null>(null)
|
const [activeTask, setActiveTask] = useState<Task | null>(null)
|
||||||
const [activeSeconds, setActiveSeconds] = useState<number>(0)
|
const [activeSeconds, setActiveSeconds] = useState<number>(0)
|
||||||
|
|
||||||
// Track the baseline seconds and client-side timestamp when task data was fetched/started
|
// Track the baseline seconds and client-side timestamp when task data was fetched/started
|
||||||
const baseSecondsRef = useRef<number>(0)
|
const baseSecondsRef = useRef<number>(0)
|
||||||
const receivedAtRef = useRef<number>(Date.now())
|
const receivedAtRef = useRef<number>(Date.now())
|
||||||
|
const activeTaskRef = useRef<Task | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeTaskRef.current = activeTask
|
||||||
|
}, [activeTask])
|
||||||
|
|
||||||
|
// Check if task belongs to the logged-in user (as assignee or creator)
|
||||||
|
const isMyTask = useCallback(
|
||||||
|
(t: Task) => {
|
||||||
|
if (!user?.email) return false
|
||||||
|
const userEmail = user.email.toLowerCase()
|
||||||
|
const matchesAssignee = t.user?.email && t.user.email.toLowerCase() === userEmail
|
||||||
|
const matchesCreator = t.createdBy?.email && t.createdBy.email.toLowerCase() === userEmail
|
||||||
|
return Boolean(matchesAssignee || matchesCreator)
|
||||||
|
},
|
||||||
|
[user?.email]
|
||||||
|
)
|
||||||
|
|
||||||
// Sync state whenever tasks list updates
|
// Sync state whenever tasks list updates
|
||||||
const syncWithTasks = useCallback((tasks: Task[]) => {
|
const syncWithTasks = useCallback(
|
||||||
const running = tasks.find((t) => isTaskRunning(t))
|
(tasks: Task[]) => {
|
||||||
|
// ONLY consider tasks assigned to the current logged-in user
|
||||||
|
const running = tasks.find((t) => isTaskRunning(t) && isMyTask(t))
|
||||||
if (running) {
|
if (running) {
|
||||||
setActiveTask(running)
|
activeTaskRef.current = running
|
||||||
|
setActiveTask((prev) => {
|
||||||
|
if (prev?.id === running.id && prev.status === running.status) {
|
||||||
|
const prevRunningItem = prev.history?.find((h) => !h.endAt)
|
||||||
|
const currRunningItem = running.history?.find((h) => !h.endAt)
|
||||||
|
if (prevRunningItem?.id === currRunningItem?.id && prevRunningItem?.startAt === currRunningItem?.startAt) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return running
|
||||||
|
})
|
||||||
const base = calculateBaseSeconds(running)
|
const base = calculateBaseSeconds(running)
|
||||||
baseSecondsRef.current = base
|
baseSecondsRef.current = base
|
||||||
receivedAtRef.current = Date.now()
|
receivedAtRef.current = Date.now()
|
||||||
setActiveSeconds(base)
|
setActiveSeconds(base)
|
||||||
} else {
|
} else {
|
||||||
|
// If syncing a single task and it's not the active task, don't accidentally clear
|
||||||
|
if (tasks.length === 1 && activeTaskRef.current && tasks[0].id !== activeTaskRef.current.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeTaskRef.current = null
|
||||||
setActiveTask(null)
|
setActiveTask(null)
|
||||||
baseSecondsRef.current = 0
|
baseSecondsRef.current = 0
|
||||||
setActiveSeconds(0)
|
setActiveSeconds(0)
|
||||||
}
|
}
|
||||||
}, [])
|
},
|
||||||
|
[isMyTask]
|
||||||
|
)
|
||||||
|
|
||||||
// Auto-fetch active running task on mount or workspace change
|
// Auto-fetch active running task on mount or workspace change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -48,7 +84,7 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||||
.listMyTasks({ organizationId: activeOrg.id })
|
.listMyTasks({ organizationId: activeOrg.id })
|
||||||
.then((tasks) => {
|
.then((tasks) => {
|
||||||
if (!isMounted) return
|
if (!isMounted) return
|
||||||
const running = tasks.find((t) => isTaskRunning(t))
|
const running = tasks.find((t) => isTaskRunning(t) && isMyTask(t))
|
||||||
if (running) {
|
if (running) {
|
||||||
syncWithTasks(tasks)
|
syncWithTasks(tasks)
|
||||||
}
|
}
|
||||||
|
|
@ -102,15 +138,16 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||||
const startTimer = useCallback(
|
const startTimer = useCallback(
|
||||||
async (task: Task): Promise<Task> => {
|
async (task: Task): Promise<Task> => {
|
||||||
// If there was an active task running different from this one, stop it first
|
// If there was an active task running different from this one, stop it first
|
||||||
if (activeTask && activeTask.id !== task.id) {
|
if (activeTaskRef.current && activeTaskRef.current.id !== task.id) {
|
||||||
try {
|
try {
|
||||||
await taskService.stopTask(activeTask.id)
|
await taskService.stopTask(activeTaskRef.current.id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Could not stop previously running task', e)
|
console.warn('Could not stop previously running task', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await taskService.startTask(task.id)
|
const updated = await taskService.startTask(task.id)
|
||||||
|
activeTaskRef.current = updated
|
||||||
setActiveTask(updated)
|
setActiveTask(updated)
|
||||||
const base = calculateBaseSeconds(updated)
|
const base = calculateBaseSeconds(updated)
|
||||||
baseSecondsRef.current = base
|
baseSecondsRef.current = base
|
||||||
|
|
@ -118,21 +155,22 @@ export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||||
setActiveSeconds(base)
|
setActiveSeconds(base)
|
||||||
return updated
|
return updated
|
||||||
},
|
},
|
||||||
[activeTask]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const stopTimer = useCallback(
|
const stopTimer = useCallback(
|
||||||
async (task?: Task): Promise<Task | null> => {
|
async (task?: Task): Promise<Task | null> => {
|
||||||
const targetId = task ? task.id : activeTask?.id
|
const targetId = task ? task.id : activeTaskRef.current?.id
|
||||||
if (!targetId) return null
|
if (!targetId) return null
|
||||||
|
|
||||||
const updated = await taskService.stopTask(targetId)
|
const updated = await taskService.stopTask(targetId)
|
||||||
|
activeTaskRef.current = null
|
||||||
setActiveTask(null)
|
setActiveTask(null)
|
||||||
baseSecondsRef.current = 0
|
baseSecondsRef.current = 0
|
||||||
setActiveSeconds(0)
|
setActiveSeconds(0)
|
||||||
return updated
|
return updated
|
||||||
},
|
},
|
||||||
[activeTask]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
30
frontend/src/lib/taskPermissions.ts
Normal file
30
frontend/src/lib/taskPermissions.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,8 @@ export type BiMetricType =
|
||||||
|
|
||||||
export type BiPeriodFilter = 'ALL' | 'TODAY' | 'WEEK' | 'MONTH'
|
export type BiPeriodFilter = 'ALL' | 'TODAY' | 'WEEK' | 'MONTH'
|
||||||
|
|
||||||
|
export type BiCardScope = 'INHERIT' | 'MINE' | 'TEAM'
|
||||||
|
|
||||||
export interface DashboardV2Card {
|
export interface DashboardV2Card {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -36,6 +38,7 @@ export interface DashboardV2Card {
|
||||||
categoryId?: number
|
categoryId?: number
|
||||||
statusFilter?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'
|
statusFilter?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'
|
||||||
colorTheme?: 'brand' | 'accent' | 'emerald' | 'indigo' | 'rose' | 'cyan'
|
colorTheme?: 'brand' | 'accent' | 'emerald' | 'indigo' | 'rose' | 'cyan'
|
||||||
|
scope?: BiCardScope
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_V2_CARDS: DashboardV2Card[] = [
|
export const DEFAULT_V2_CARDS: DashboardV2Card[] = [
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
|
@ -10,6 +11,7 @@ import {
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Activity,
|
Activity,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
User,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { taskService } from '../services/taskService'
|
import { taskService } from '../services/taskService'
|
||||||
|
|
@ -20,7 +22,7 @@ import type { Task, Project, Category } from '../types/task'
|
||||||
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
||||||
|
|
||||||
export const AnalyticsPage: React.FC = () => {
|
export const AnalyticsPage: React.FC = () => {
|
||||||
const { activeOrg } = useAuth()
|
const { user, activeOrg } = useAuth()
|
||||||
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
|
|
@ -31,6 +33,24 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Scope Filter: 'ALL' | 'MINE' | email do membro
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const initialScope = searchParams.get('member') || 'ALL'
|
||||||
|
const [selectedMember, setSelectedMember] = useState<string>(initialScope)
|
||||||
|
|
||||||
|
const handleMemberChange = (value: string) => {
|
||||||
|
setSelectedMember(value)
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev)
|
||||||
|
if (value === 'ALL') next.delete('member')
|
||||||
|
else next.set('member', value)
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
{ replace: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const activeOrgId = activeOrg?.id
|
const activeOrgId = activeOrg?.id
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
|
|
@ -61,15 +81,25 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [loadData])
|
}, [loadData])
|
||||||
|
|
||||||
// Aggregate high-level KPIs
|
// Filter tasks based on selected member scope
|
||||||
const kpis = useMemo(() => {
|
const scopedTasks = useMemo(() => {
|
||||||
const totalTasks = tasks.length
|
if (selectedMember === 'ALL') return tasks
|
||||||
const completedTasks = tasks.filter((t) => t.status === 'COMPLETED').length
|
const targetEmail = selectedMember === 'MINE' ? user?.email : selectedMember
|
||||||
const inProgressTasks = tasks.filter((t) => t.status === 'IN_PROGRESS').length
|
if (!targetEmail) return tasks
|
||||||
const notStartedTasks = tasks.filter((t) => t.status === 'NOT_STARTED').length
|
return tasks.filter(
|
||||||
const canceledTasks = tasks.filter((t) => t.status === 'CANCELED').length
|
(t) => t.user?.email && t.user.email.toLowerCase() === targetEmail.toLowerCase()
|
||||||
|
)
|
||||||
|
}, [tasks, selectedMember, user?.email])
|
||||||
|
|
||||||
const totalSecondsTracked = tasks.reduce(
|
// Aggregate high-level KPIs based on scopedTasks
|
||||||
|
const kpis = useMemo(() => {
|
||||||
|
const totalTasks = scopedTasks.length
|
||||||
|
const completedTasks = scopedTasks.filter((t) => t.status === 'COMPLETED').length
|
||||||
|
const inProgressTasks = scopedTasks.filter((t) => t.status === 'IN_PROGRESS').length
|
||||||
|
const notStartedTasks = scopedTasks.filter((t) => t.status === 'NOT_STARTED').length
|
||||||
|
const canceledTasks = scopedTasks.filter((t) => t.status === 'CANCELED').length
|
||||||
|
|
||||||
|
const totalSecondsTracked = scopedTasks.reduce(
|
||||||
(acc, t) => acc + calculateTaskTotalSeconds(t),
|
(acc, t) => acc + calculateTaskTotalSeconds(t),
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
|
|
@ -80,7 +110,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
const avgSecondsPerCompletedTask =
|
const avgSecondsPerCompletedTask =
|
||||||
completedTasks > 0
|
completedTasks > 0
|
||||||
? Math.round(
|
? Math.round(
|
||||||
tasks
|
scopedTasks
|
||||||
.filter((t) => t.status === 'COMPLETED')
|
.filter((t) => t.status === 'COMPLETED')
|
||||||
.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0) / completedTasks
|
.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0) / completedTasks
|
||||||
)
|
)
|
||||||
|
|
@ -96,7 +126,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
completionRate,
|
completionRate,
|
||||||
avgSecondsPerCompletedTask,
|
avgSecondsPerCompletedTask,
|
||||||
}
|
}
|
||||||
}, [tasks])
|
}, [scopedTasks])
|
||||||
|
|
||||||
// Project breakdown
|
// Project breakdown
|
||||||
const projectBreakdown = useMemo(() => {
|
const projectBreakdown = useMemo(() => {
|
||||||
|
|
@ -109,7 +139,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
let unassignedSeconds = 0
|
let unassignedSeconds = 0
|
||||||
let unassignedTasks = 0
|
let unassignedTasks = 0
|
||||||
|
|
||||||
tasks.forEach((t) => {
|
scopedTasks.forEach((t) => {
|
||||||
const sec = calculateTaskTotalSeconds(t)
|
const sec = calculateTaskTotalSeconds(t)
|
||||||
if (t.project?.id && map.has(t.project.id)) {
|
if (t.project?.id && map.has(t.project.id)) {
|
||||||
const item = map.get(t.project.id)!
|
const item = map.get(t.project.id)!
|
||||||
|
|
@ -131,7 +161,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return list.sort((a, b) => b.seconds - a.seconds)
|
return list.sort((a, b) => b.seconds - a.seconds)
|
||||||
}, [projects, tasks])
|
}, [projects, scopedTasks])
|
||||||
|
|
||||||
// Category breakdown
|
// Category breakdown
|
||||||
const categoryBreakdown = useMemo(() => {
|
const categoryBreakdown = useMemo(() => {
|
||||||
|
|
@ -144,7 +174,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
let unassignedSeconds = 0
|
let unassignedSeconds = 0
|
||||||
let unassignedTasks = 0
|
let unassignedTasks = 0
|
||||||
|
|
||||||
tasks.forEach((t) => {
|
scopedTasks.forEach((t) => {
|
||||||
const sec = calculateTaskTotalSeconds(t)
|
const sec = calculateTaskTotalSeconds(t)
|
||||||
if (t.category?.id && map.has(t.category.id)) {
|
if (t.category?.id && map.has(t.category.id)) {
|
||||||
const item = map.get(t.category.id)!
|
const item = map.get(t.category.id)!
|
||||||
|
|
@ -166,7 +196,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return list.sort((a, b) => b.seconds - a.seconds)
|
return list.sort((a, b) => b.seconds - a.seconds)
|
||||||
}, [categories, tasks])
|
}, [categories, scopedTasks])
|
||||||
|
|
||||||
// Member breakdown
|
// Member breakdown
|
||||||
const memberBreakdown = useMemo(() => {
|
const memberBreakdown = useMemo(() => {
|
||||||
|
|
@ -252,6 +282,68 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Scope Selector: Equipe Completa vs Apenas Minhas vs Membro Específico */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-3.5 sm:p-4 rounded-3xl bg-surface/90 border border-border-soft backdrop-blur-md shadow-panel">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-xs font-bold text-muted uppercase tracking-wider hidden sm:inline">
|
||||||
|
Filtro de Membro:
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft shadow-inner">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMemberChange('ALL')}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
selectedMember === 'ALL'
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/25'
|
||||||
|
: 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
<span>Toda a Equipe</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMemberChange('MINE')}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
selectedMember === 'MINE'
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/25'
|
||||||
|
: 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User className="w-3.5 h-3.5" />
|
||||||
|
<span>Apenas Minhas</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Specific Member Dropdown */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<select
|
||||||
|
value={selectedMember === 'ALL' || selectedMember === 'MINE' ? '' : selectedMember}
|
||||||
|
onChange={(e) => handleMemberChange(e.target.value || 'ALL')}
|
||||||
|
className="px-3 py-1.5 rounded-xl border border-border-soft bg-surface text-xs font-medium text-foreground focus:outline-hidden focus:border-brand shadow-xs"
|
||||||
|
>
|
||||||
|
<option value="">Outro Membro...</option>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.userId} value={m.userEmail}>
|
||||||
|
{m.userName} ({m.userEmail})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted font-medium">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
|
||||||
|
<span className="text-[11px] truncate">
|
||||||
|
{selectedMember === 'ALL'
|
||||||
|
? 'Calculando métricas globais consolidadas do workspace'
|
||||||
|
: selectedMember === 'MINE'
|
||||||
|
? 'Calculando métricas estritamente do seu perfil'
|
||||||
|
: `Filtrando métricas para o membro selecionado`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 py-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 py-8">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
|
@ -274,9 +366,14 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
<p className="text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight font-mono">
|
<p className="text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight font-mono">
|
||||||
{formatSecondsToTimer(kpis.totalSecondsTracked)}
|
{formatSecondsToTimer(kpis.totalSecondsTracked)}
|
||||||
</p>
|
</p>
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
|
||||||
|
{selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p className="text-[11px] text-muted mt-1">
|
<p className="text-[11px] text-muted mt-1">
|
||||||
Equivalente a {formatSecondsToHuman(kpis.totalSecondsTracked)}
|
Equivalente a {formatSecondsToHuman(kpis.totalSecondsTracked)}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -292,9 +389,14 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
<p className="text-2xl sm:text-3xl font-extrabold text-emerald-700 dark:text-emerald-400 tracking-tight">
|
<p className="text-2xl sm:text-3xl font-extrabold text-emerald-700 dark:text-emerald-400 tracking-tight">
|
||||||
{kpis.completionRate}%
|
{kpis.completionRate}%
|
||||||
</p>
|
</p>
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
|
||||||
|
{selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p className="text-[11px] text-muted mt-1">
|
<p className="text-[11px] text-muted mt-1">
|
||||||
{kpis.completedTasks} de {kpis.totalTasks} demandas finalizadas
|
{kpis.completedTasks} de {kpis.totalTasks} demandas finalizadas
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -310,9 +412,14 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
<p className="text-2xl sm:text-3xl font-extrabold text-teal-700 dark:text-teal-300 tracking-tight">
|
<p className="text-2xl sm:text-3xl font-extrabold text-teal-700 dark:text-teal-300 tracking-tight">
|
||||||
{kpis.inProgressTasks} tarefas
|
{kpis.inProgressTasks} tarefas
|
||||||
</p>
|
</p>
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
|
||||||
|
{selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p className="text-[11px] text-muted mt-1">
|
<p className="text-[11px] text-muted mt-1">
|
||||||
{kpis.notStartedTasks} no backlog aguardando início
|
{kpis.notStartedTasks} no backlog aguardando início
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -328,9 +435,14 @@ export const AnalyticsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
<p className="text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight font-mono">
|
<p className="text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight font-mono">
|
||||||
{formatSecondsToHuman(kpis.avgSecondsPerCompletedTask)}
|
{formatSecondsToHuman(kpis.avgSecondsPerCompletedTask)}
|
||||||
</p>
|
</p>
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
|
||||||
|
{selectedMember === 'ALL' ? '👥 Equipe' : '👤 Individual'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p className="text-[11px] text-muted mt-1">
|
<p className="text-[11px] text-muted mt-1">
|
||||||
Tempo médio gasto em cada entrega concluída
|
Tempo médio gasto em cada entrega concluída
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
Settings2,
|
Settings2,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useTimer } from '../contexts/TimerContext'
|
import { useTimer } from '../contexts/TimerContext'
|
||||||
|
|
@ -40,6 +42,15 @@ export const DashboardPage: React.FC = () => {
|
||||||
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
|
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// Check if active task belongs to current user (as assignee or creator)
|
||||||
|
const isMyActiveTask = Boolean(
|
||||||
|
activeTask &&
|
||||||
|
user?.email &&
|
||||||
|
((activeTask.user?.email && activeTask.user.email.toLowerCase() === user.email.toLowerCase()) ||
|
||||||
|
(activeTask.createdBy?.email && activeTask.createdBy.email.toLowerCase() === user.email.toLowerCase()))
|
||||||
|
)
|
||||||
|
const currentActiveTask = isMyActiveTask ? activeTask : null
|
||||||
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -71,6 +82,29 @@ export const DashboardPage: React.FC = () => {
|
||||||
// Modal
|
// Modal
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
|
||||||
|
// Scope Toggle State: 'mine' (Individual) vs 'team' (Equipe)
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const initialScope =
|
||||||
|
(searchParams.get('scope') as 'mine' | 'team') ||
|
||||||
|
(localStorage.getItem('beetask_dashboard_scope') as 'mine' | 'team') ||
|
||||||
|
'mine'
|
||||||
|
const [viewScope, setViewScope] = useState<'mine' | 'team'>(initialScope)
|
||||||
|
|
||||||
|
const handleScopeChange = (newScope: 'mine' | 'team') => {
|
||||||
|
setViewScope(newScope)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('beetask_dashboard_scope', newScope)
|
||||||
|
} catch (_) {}
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev)
|
||||||
|
next.set('scope', newScope)
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
{ replace: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const activeOrgId = activeOrg?.id
|
const activeOrgId = activeOrg?.id
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
|
|
@ -102,21 +136,39 @@ export const DashboardPage: React.FC = () => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [loadData])
|
}, [loadData])
|
||||||
|
|
||||||
// KPIs
|
// Filter user's own tasks
|
||||||
|
const myTasks = useMemo(() => {
|
||||||
|
return tasks.filter((t) => {
|
||||||
|
if (!user?.email) return false
|
||||||
|
return Boolean(
|
||||||
|
t.user?.email &&
|
||||||
|
user?.email &&
|
||||||
|
t.user.email.toLowerCase() === user.email.toLowerCase()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [tasks, user])
|
||||||
|
|
||||||
|
// Scope tasks based on viewScope
|
||||||
|
const scopeTasks = viewScope === 'mine' ? myTasks : tasks
|
||||||
|
|
||||||
|
// KPIs dynamically calculated based on active scope
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const inProgress = tasks.filter((t) => t.status === 'IN_PROGRESS').length
|
const inProgress = scopeTasks.filter((t) => t.status === 'IN_PROGRESS').length
|
||||||
const completed = tasks.filter((t) => t.status === 'COMPLETED').length
|
const completed = scopeTasks.filter((t) => t.status === 'COMPLETED').length
|
||||||
const notStarted = tasks.filter((t) => t.status === 'NOT_STARTED').length
|
const notStarted = scopeTasks.filter((t) => t.status === 'NOT_STARTED').length
|
||||||
const totalSeconds = tasks.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0)
|
const totalSeconds = scopeTasks.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0)
|
||||||
|
|
||||||
|
const uniqueProjectIds = new Set(scopeTasks.map((t) => t.project?.id).filter(Boolean))
|
||||||
|
const projectsCount = viewScope === 'mine' ? uniqueProjectIds.size : projects.length
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inProgress,
|
inProgress,
|
||||||
completed,
|
completed,
|
||||||
notStarted,
|
notStarted,
|
||||||
totalSeconds,
|
totalSeconds,
|
||||||
projectsCount: projects.length,
|
projectsCount,
|
||||||
}
|
}
|
||||||
}, [tasks, projects])
|
}, [scopeTasks, projects, viewScope])
|
||||||
|
|
||||||
// Work Goal Progress in Current Period
|
// Work Goal Progress in Current Period
|
||||||
const goalProgress = useMemo(() => {
|
const goalProgress = useMemo(() => {
|
||||||
|
|
@ -124,7 +176,7 @@ export const DashboardPage: React.FC = () => {
|
||||||
const workedSeconds = calculateUserPeriodWorkedSeconds(
|
const workedSeconds = calculateUserPeriodWorkedSeconds(
|
||||||
tasks,
|
tasks,
|
||||||
workGoal?.period || 'DAILY',
|
workGoal?.period || 'DAILY',
|
||||||
activeTask?.id,
|
currentActiveTask?.id,
|
||||||
activeSeconds
|
activeSeconds
|
||||||
)
|
)
|
||||||
const percent = Math.min(100, Math.round((workedSeconds / targetSeconds) * 100))
|
const percent = Math.min(100, Math.round((workedSeconds / targetSeconds) * 100))
|
||||||
|
|
@ -152,17 +204,17 @@ export const DashboardPage: React.FC = () => {
|
||||||
targetHours: workGoal?.targetHours || 8,
|
targetHours: workGoal?.targetHours || 8,
|
||||||
isCompleted: workedSeconds >= targetSeconds,
|
isCompleted: workedSeconds >= targetSeconds,
|
||||||
}
|
}
|
||||||
}, [tasks, workGoal, activeTask?.id, activeSeconds])
|
}, [tasks, workGoal, currentActiveTask?.id, activeSeconds])
|
||||||
|
|
||||||
// Recent 5 tasks
|
// Recent 5 tasks based on active scope
|
||||||
const recentTasks = useMemo(() => {
|
const recentTasks = useMemo(() => {
|
||||||
return [...tasks].slice(0, 5)
|
return [...scopeTasks].slice(0, 5)
|
||||||
}, [tasks])
|
}, [scopeTasks])
|
||||||
|
|
||||||
// Toggle timer on a task
|
// Toggle timer on a task
|
||||||
const handleToggleTimer = async (task: Task) => {
|
const handleToggleTimer = async (task: Task) => {
|
||||||
try {
|
try {
|
||||||
if (activeTask?.id === task.id) {
|
if (currentActiveTask?.id === task.id) {
|
||||||
const stopped = await stopTimer(task)
|
const stopped = await stopTimer(task)
|
||||||
if (stopped) {
|
if (stopped) {
|
||||||
setTasks((prev) => prev.map((t) => (t.id === stopped.id ? stopped : t)))
|
setTasks((prev) => prev.map((t) => (t.id === stopped.id ? stopped : t)))
|
||||||
|
|
@ -201,10 +253,19 @@ export const DashboardPage: React.FC = () => {
|
||||||
setTasks((prev) => [created, ...prev])
|
setTasks((prev) => [created, ...prev])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority task to focus on if none running
|
// Priority task to focus on if none running (only user's own tasks)
|
||||||
const suggestedTask = useMemo(() => {
|
const suggestedTask = useMemo(() => {
|
||||||
return tasks.find((t) => t.status === 'IN_PROGRESS') || tasks.find((t) => t.status === 'NOT_STARTED') || tasks[0]
|
const myTasks = tasks.filter((t) => {
|
||||||
}, [tasks])
|
if (!user?.email || !t.user?.email) return false
|
||||||
|
return t.user.email.toLowerCase() === user.email.toLowerCase()
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
myTasks.find((t) => t.status === 'IN_PROGRESS') ||
|
||||||
|
myTasks.find((t) => t.status === 'NOT_STARTED') ||
|
||||||
|
myTasks[0] ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}, [tasks, user])
|
||||||
|
|
||||||
const firstName = user?.name ? user.name.split(' ')[0] : 'Desenvolvedor'
|
const firstName = user?.name ? user.name.split(' ')[0] : 'Desenvolvedor'
|
||||||
|
|
||||||
|
|
@ -288,6 +349,64 @@ export const DashboardPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Scope Selector: Individual (Minhas Tarefas) vs Equipe (Workspace) */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-3 sm:p-4 rounded-3xl bg-surface/90 border border-border-soft backdrop-blur-md shadow-panel">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-bold text-muted uppercase tracking-wider hidden sm:inline">
|
||||||
|
Modo de Visualização:
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft shadow-inner">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleScopeChange('mine')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
viewScope === 'mine'
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/25'
|
||||||
|
: 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User className="w-3.5 h-3.5" />
|
||||||
|
<span>Individual (Minhas Tarefas)</span>
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.2 rounded-full text-[10px] font-bold ${
|
||||||
|
viewScope === 'mine' ? 'bg-white/20 text-white' : 'bg-surface text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{myTasks.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleScopeChange('team')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
viewScope === 'team'
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/25'
|
||||||
|
: 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
<span>Equipe (Workspace Completo)</span>
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.2 rounded-full text-[10px] font-bold ${
|
||||||
|
viewScope === 'team' ? 'bg-white/20 text-white' : 'bg-surface text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tasks.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted font-medium">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
|
||||||
|
<span className="text-[11px] truncate">
|
||||||
|
{viewScope === 'mine'
|
||||||
|
? 'KPIs e métricas filtrados estritamente para demandas atribuídas a você'
|
||||||
|
: 'KPIs e métricas consolidados de todos os integrantes do workspace'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 4 Premium KPI Cards with shadow-panel & backdrop-blur */}
|
{/* 4 Premium KPI Cards with shadow-panel & backdrop-blur */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
|
|
@ -303,17 +422,28 @@ export const DashboardPage: React.FC = () => {
|
||||||
{/* In Progress */}
|
{/* In Progress */}
|
||||||
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
|
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-bold text-muted">Tarefas em Andamento</span>
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs font-bold text-muted">
|
||||||
|
{viewScope === 'mine' ? 'Suas Tarefas em Andamento' : 'Tarefas da Equipe em Andamento'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="w-9 h-9 rounded-xl bg-teal-500/15 border border-teal-500/30 flex items-center justify-center text-teal-700 dark:text-teal-400 shadow-xs">
|
<div className="w-9 h-9 rounded-xl bg-teal-500/15 border border-teal-500/30 flex items-center justify-center text-teal-700 dark:text-teal-400 shadow-xs">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
<p className="text-3xl font-extrabold text-teal-700 dark:text-teal-400 tracking-tight font-mono">
|
<p className="text-3xl font-extrabold text-teal-700 dark:text-teal-400 tracking-tight font-mono">
|
||||||
{stats.inProgress}
|
{stats.inProgress}
|
||||||
</p>
|
</p>
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
|
||||||
|
{viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p className="text-[11px] text-muted font-medium mt-1">
|
<p className="text-[11px] text-muted font-medium mt-1">
|
||||||
{stats.notStarted} tarefas aguardando no backlog
|
{viewScope === 'mine'
|
||||||
|
? `${stats.notStarted} na sua fila aguardando`
|
||||||
|
: `${stats.notStarted} tarefas aguardando no backlog`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -321,17 +451,26 @@ export const DashboardPage: React.FC = () => {
|
||||||
{/* Completed */}
|
{/* Completed */}
|
||||||
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
|
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-bold text-muted">Entregas Concluídas</span>
|
<span className="text-xs font-bold text-muted">
|
||||||
|
{viewScope === 'mine' ? 'Suas Entregas Concluídas' : 'Entregas Concluídas da Equipe'}
|
||||||
|
</span>
|
||||||
<div className="w-9 h-9 rounded-xl bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-700 dark:text-emerald-400 shadow-xs">
|
<div className="w-9 h-9 rounded-xl bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-700 dark:text-emerald-400 shadow-xs">
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
<p className="text-3xl font-extrabold text-emerald-700 dark:text-emerald-400 tracking-tight font-mono">
|
<p className="text-3xl font-extrabold text-emerald-700 dark:text-emerald-400 tracking-tight font-mono">
|
||||||
{stats.completed}
|
{stats.completed}
|
||||||
</p>
|
</p>
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
|
||||||
|
{viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p className="text-[11px] text-muted font-medium mt-1">
|
<p className="text-[11px] text-muted font-medium mt-1">
|
||||||
de {tasks.length} demandas cadastradas
|
{viewScope === 'mine'
|
||||||
|
? `de ${myTasks.length} demandas suas`
|
||||||
|
: `de ${tasks.length} demandas cadastradas`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -339,17 +478,26 @@ export const DashboardPage: React.FC = () => {
|
||||||
{/* Time Tracked */}
|
{/* Time Tracked */}
|
||||||
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
|
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-bold text-muted">Tempo Total Dedicado</span>
|
<span className="text-xs font-bold text-muted">
|
||||||
|
{viewScope === 'mine' ? 'Seu Tempo Dedicado' : 'Tempo Total da Equipe'}
|
||||||
|
</span>
|
||||||
<div className="w-9 h-9 rounded-xl bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-400 shadow-xs">
|
<div className="w-9 h-9 rounded-xl bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-400 shadow-xs">
|
||||||
<TrendingUp className="w-4 h-4" />
|
<TrendingUp className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
<p className="text-3xl font-extrabold text-amber-400 tracking-tight font-mono">
|
<p className="text-3xl font-extrabold text-amber-400 tracking-tight font-mono">
|
||||||
{formatSecondsToTimer(stats.totalSeconds)}
|
{formatSecondsToTimer(stats.totalSeconds)}
|
||||||
</p>
|
</p>
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
|
||||||
|
{viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p className="text-[11px] text-muted font-medium mt-1">
|
<p className="text-[11px] text-muted font-medium mt-1">
|
||||||
{formatSecondsToHuman(stats.totalSeconds)} auditadas no time
|
{viewScope === 'mine'
|
||||||
|
? `${formatSecondsToHuman(stats.totalSeconds)} auditadas por você`
|
||||||
|
: `${formatSecondsToHuman(stats.totalSeconds)} auditadas no time`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -357,17 +505,26 @@ export const DashboardPage: React.FC = () => {
|
||||||
{/* Projects */}
|
{/* Projects */}
|
||||||
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
|
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-5 shadow-panel hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 flex flex-col justify-between">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-bold text-muted">Projetos Ativos</span>
|
<span className="text-xs font-bold text-muted">
|
||||||
|
{viewScope === 'mine' ? 'Seus Projetos Ativos' : 'Projetos da Organização'}
|
||||||
|
</span>
|
||||||
<div className="w-9 h-9 rounded-xl bg-brand-soft text-brand border border-brand/25 flex items-center justify-center shadow-xs">
|
<div className="w-9 h-9 rounded-xl bg-brand-soft text-brand border border-brand/25 flex items-center justify-center shadow-xs">
|
||||||
<FolderKanban className="w-4 h-4 text-brand" />
|
<FolderKanban className="w-4 h-4 text-brand" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
<p className="text-3xl font-extrabold text-foreground tracking-tight font-mono">
|
<p className="text-3xl font-extrabold text-foreground tracking-tight font-mono">
|
||||||
{stats.projectsCount}
|
{stats.projectsCount}
|
||||||
</p>
|
</p>
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-surface-sunken text-muted border border-border-soft">
|
||||||
|
{viewScope === 'mine' ? '👤 Minhas' : '👥 Equipe'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p className="text-[11px] text-muted font-medium mt-1">
|
<p className="text-[11px] text-muted font-medium mt-1">
|
||||||
Iniciativas ativas no workspace
|
{viewScope === 'mine'
|
||||||
|
? 'Iniciativas com tarefas suas'
|
||||||
|
: 'Iniciativas ativas no workspace'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -394,43 +551,43 @@ export const DashboardPage: React.FC = () => {
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className={`inline-block w-2 h-2 rounded-full ${
|
className={`inline-block w-2 h-2 rounded-full ${
|
||||||
activeTask ? 'bg-emerald-500 animate-ping' : 'bg-zinc-400'
|
currentActiveTask ? 'bg-emerald-500 animate-ping' : 'bg-zinc-400'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-bold ${
|
className={`text-xs font-bold ${
|
||||||
activeTask ? 'text-teal-700 dark:text-teal-400' : 'text-muted'
|
currentActiveTask ? 'text-teal-700 dark:text-teal-400' : 'text-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{activeTask ? 'CRONOMETRANDO' : 'EM ESPERA'}
|
{currentActiveTask ? 'CRONOMETRANDO' : 'EM ESPERA'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active or Suggested Task Info Box */}
|
{/* Active or Suggested Task Info Box */}
|
||||||
{activeTask ? (
|
{currentActiveTask ? (
|
||||||
<div className="p-4 rounded-2xl bg-surface-raised border border-border-soft space-y-2 shadow-xs">
|
<div className="p-4 rounded-2xl bg-surface-raised border border-border-soft space-y-2 shadow-xs">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{activeTask.project && (
|
{currentActiveTask.project && (
|
||||||
<span
|
<span
|
||||||
className="px-2 py-0.5 rounded-md text-[10px] font-semibold border"
|
className="px-2 py-0.5 rounded-md text-[10px] font-semibold border"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: activeTask.project.color ? `${activeTask.project.color}15` : 'rgba(15,118,110,0.1)',
|
backgroundColor: currentActiveTask.project.color ? `${currentActiveTask.project.color}15` : 'rgba(15,118,110,0.1)',
|
||||||
color: activeTask.project.color || '#0f766e',
|
color: currentActiveTask.project.color || '#0f766e',
|
||||||
borderColor: activeTask.project.color ? `${activeTask.project.color}35` : 'rgba(15,118,110,0.25)',
|
borderColor: currentActiveTask.project.color ? `${currentActiveTask.project.color}35` : 'rgba(15,118,110,0.25)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeTask.project.name}
|
{currentActiveTask.project.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{activeTask.category && (
|
{currentActiveTask.category && (
|
||||||
<span className="px-2 py-0.5 rounded-md text-[10px] font-semibold bg-accent-soft text-accent border border-accent/25">
|
<span className="px-2 py-0.5 rounded-md text-[10px] font-semibold bg-accent-soft text-accent border border-accent/25">
|
||||||
{activeTask.category.name}
|
{currentActiveTask.category.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-bold text-foreground line-clamp-2 leading-snug">
|
<h3 className="text-sm font-bold text-foreground line-clamp-2 leading-snug">
|
||||||
{activeTask.title}
|
{currentActiveTask.title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
) : suggestedTask ? (
|
) : suggestedTask ? (
|
||||||
|
|
@ -458,19 +615,19 @@ export const DashboardPage: React.FC = () => {
|
||||||
{/* Big Bold Timer Display */}
|
{/* Big Bold Timer Display */}
|
||||||
<div className="text-center py-5 rounded-2xl bg-surface border border-border-soft shadow-xs">
|
<div className="text-center py-5 rounded-2xl bg-surface border border-border-soft shadow-xs">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">
|
<span className="text-[11px] font-bold uppercase tracking-wider text-muted">
|
||||||
{activeTask ? 'Tempo Decorrido na Tarefa' : 'Pronto para Iniciar'}
|
{currentActiveTask ? 'Tempo Decorrido na Tarefa' : 'Pronto para Iniciar'}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-4xl sm:text-5xl font-extrabold font-mono text-foreground mt-1.5 tracking-tight">
|
<p className="text-4xl sm:text-5xl font-extrabold font-mono text-foreground mt-1.5 tracking-tight">
|
||||||
{activeTask ? formatSecondsToTimer(activeSeconds) : '00:00:00'}
|
{currentActiveTask ? formatSecondsToTimer(activeSeconds) : '00:00:00'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timer Actions */}
|
{/* Timer Actions */}
|
||||||
{activeTask ? (
|
{currentActiveTask ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => stopTimer(activeTask)}
|
onClick={() => stopTimer(currentActiveTask)}
|
||||||
className="w-full py-3.5 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 hover:bg-rose-500/25 text-xs font-bold transition-all flex items-center justify-center gap-2 shadow-xs"
|
className="w-full py-3.5 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 hover:bg-rose-500/25 text-xs font-bold transition-all flex items-center justify-center gap-2 shadow-xs cursor-pointer"
|
||||||
>
|
>
|
||||||
<Square className="w-4 h-4 fill-rose-300" />
|
<Square className="w-4 h-4 fill-rose-300" />
|
||||||
<span>Pausar Cronômetro</span>
|
<span>Pausar Cronômetro</span>
|
||||||
|
|
@ -607,7 +764,7 @@ export const DashboardPage: React.FC = () => {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||||
<Zap className="w-4 h-4 text-accent" />
|
<Zap className="w-4 h-4 text-accent" />
|
||||||
Tarefas Recentes da Equipe
|
{viewScope === 'mine' ? 'Minhas Demandas Recentes' : 'Demandas Recentes da Equipe'}
|
||||||
</h2>
|
</h2>
|
||||||
<Link
|
<Link
|
||||||
to="/tasks"
|
to="/tasks"
|
||||||
|
|
@ -631,7 +788,9 @@ export const DashboardPage: React.FC = () => {
|
||||||
<div className="text-center py-20 rounded-3xl border border-dashed border-border-soft bg-surface/60 backdrop-blur-md">
|
<div className="text-center py-20 rounded-3xl border border-dashed border-border-soft bg-surface/60 backdrop-blur-md">
|
||||||
<Activity className="w-8 h-8 text-muted mx-auto mb-2" />
|
<Activity className="w-8 h-8 text-muted mx-auto mb-2" />
|
||||||
<p className="text-xs text-muted mb-4 font-medium">
|
<p className="text-xs text-muted mb-4 font-medium">
|
||||||
Nenhuma tarefa encontrada neste workspace.
|
{viewScope === 'mine'
|
||||||
|
? 'Você não possui nenhuma demanda atribuída no momento.'
|
||||||
|
: 'Nenhuma tarefa encontrada neste workspace.'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -645,7 +804,7 @@ export const DashboardPage: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{recentTasks.map((task) => {
|
{recentTasks.map((task) => {
|
||||||
const isTracking = activeTask?.id === task.id
|
const isTracking = currentActiveTask?.id === task.id
|
||||||
const taskSec = isTracking ? activeSeconds : calculateTaskTotalSeconds(task)
|
const taskSec = isTracking ? activeSeconds : calculateTaskTotalSeconds(task)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -659,6 +818,34 @@ export const DashboardPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
<div className="space-y-1.5 flex-1 min-w-0">
|
<div className="space-y-1.5 flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{/* Assignee Indicator */}
|
||||||
|
{task.user?.name ? (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-semibold border ${
|
||||||
|
user?.email &&
|
||||||
|
task.user.email &&
|
||||||
|
task.user.email.toLowerCase() === user.email.toLowerCase()
|
||||||
|
? 'bg-brand/10 text-brand border-brand/20'
|
||||||
|
: 'bg-surface-sunken text-muted border-border-soft'
|
||||||
|
}`}
|
||||||
|
title={`Responsável: ${task.user.name} (${task.user.email || ''})`}
|
||||||
|
>
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
<span className="truncate max-w-[110px]">
|
||||||
|
{user?.email &&
|
||||||
|
task.user.email &&
|
||||||
|
task.user.email.toLowerCase() === user.email.toLowerCase()
|
||||||
|
? 'Você'
|
||||||
|
: task.user.name.split(' ')[0]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium bg-surface-sunken text-muted/60 border border-border-soft">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
<span>Sem responsável</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{task.project && (
|
{task.project && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-semibold border"
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-semibold border"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Check,
|
Check,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useTimer } from '../contexts/TimerContext'
|
import { useTimer } from '../contexts/TimerContext'
|
||||||
|
|
@ -51,6 +53,29 @@ export const DashboardV2Page: React.FC = () => {
|
||||||
const [editingCard, setEditingCard] = useState<DashboardV2Card | null>(null)
|
const [editingCard, setEditingCard] = useState<DashboardV2Card | null>(null)
|
||||||
const [saveToast, setSaveToast] = useState(false)
|
const [saveToast, setSaveToast] = useState(false)
|
||||||
|
|
||||||
|
// Scope Toggle State (Individual vs Equipe)
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const initialScope =
|
||||||
|
(searchParams.get('scope') as 'mine' | 'team') ||
|
||||||
|
(localStorage.getItem('beetask_bi_scope') as 'mine' | 'team') ||
|
||||||
|
'team'
|
||||||
|
const [viewScope, setViewScope] = useState<'mine' | 'team'>(initialScope)
|
||||||
|
|
||||||
|
const handleScopeChange = (newScope: 'mine' | 'team') => {
|
||||||
|
setViewScope(newScope)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('beetask_bi_scope', newScope)
|
||||||
|
} catch (_) {}
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev)
|
||||||
|
next.set('scope', newScope)
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
{ replace: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const activeOrgId = activeOrg?.id
|
const activeOrgId = activeOrg?.id
|
||||||
|
|
||||||
// Load backend data
|
// Load backend data
|
||||||
|
|
@ -341,6 +366,50 @@ export const DashboardV2Page: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Scope Selector: Individual vs Equipe */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-3 sm:p-4 rounded-2xl bg-surface/90 border border-border-soft backdrop-blur-md shadow-panel">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs font-bold text-muted uppercase tracking-wider hidden sm:inline">
|
||||||
|
Escopo do Painel:
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft shadow-inner">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleScopeChange('mine')}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
viewScope === 'mine'
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/25'
|
||||||
|
: 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User className="w-3.5 h-3.5" />
|
||||||
|
<span>Minha Visão (Individual)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleScopeChange('team')}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
viewScope === 'team'
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/25'
|
||||||
|
: 'text-muted hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
<span>Visão da Equipe (Workspace)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted font-medium">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shrink-0" />
|
||||||
|
<span className="text-[11px] truncate">
|
||||||
|
{viewScope === 'mine'
|
||||||
|
? 'Exibindo cards com dados das suas tarefas atribuídas'
|
||||||
|
: 'Exibindo cards com métricas consolidadas de todo o workspace'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Uniform Modular Grid */}
|
{/* Uniform Modular Grid */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
|
@ -393,6 +462,7 @@ export const DashboardV2Page: React.FC = () => {
|
||||||
activeTask={activeTask}
|
activeTask={activeTask}
|
||||||
activeSeconds={activeSeconds}
|
activeSeconds={activeSeconds}
|
||||||
workGoal={workGoal}
|
workGoal={workGoal}
|
||||||
|
globalScope={viewScope}
|
||||||
isBeingDragged={draggedCardId === card.id}
|
isBeingDragged={draggedCardId === card.id}
|
||||||
isDragOver={dragOverCardId === card.id}
|
isDragOver={dragOverCardId === card.id}
|
||||||
onToggleTimer={handleToggleTimer}
|
onToggleTimer={handleToggleTimer}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,71 @@
|
||||||
import { useState, type FormEvent } from 'react'
|
import { useState, useEffect, useCallback, type FormEvent } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Chip,
|
|
||||||
ChipLabel,
|
|
||||||
Alert,
|
|
||||||
AlertTitle,
|
|
||||||
AlertDescription,
|
|
||||||
} from '@heroui/react'
|
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Plus,
|
Plus,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Users,
|
Users,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Search,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Send,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Inbox,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { organizationService } from '../services/organizationService'
|
import {
|
||||||
|
organizationService,
|
||||||
|
type SearchOrgOutput,
|
||||||
|
type JoinRequestOutput,
|
||||||
|
} from '../services/organizationService'
|
||||||
|
|
||||||
export const OrganizationsPage = () => {
|
export const OrganizationsPage = () => {
|
||||||
const { user, activeOrg, setActiveOrg, refreshUser } = useAuth()
|
const { user, activeOrg, setActiveOrg, refreshUser } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'workspaces' | 'join' | 'create'>(
|
||||||
|
user?.organizations && user.organizations.length > 0 ? 'workspaces' : 'join'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create Org states
|
||||||
const [newOrgName, setNewOrgName] = useState('')
|
const [newOrgName, setNewOrgName] = useState('')
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
|
||||||
|
// Search & Join states
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchOrgOutput[]>([])
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
const [hasSearched, setHasSearched] = useState(false)
|
||||||
|
const [joiningOrgId, setJoiningOrgId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// My join requests
|
||||||
|
const [myRequests, setMyRequests] = useState<JoinRequestOutput[]>([])
|
||||||
|
const [loadingRequests, setLoadingRequests] = useState(false)
|
||||||
|
|
||||||
|
// Feedback notifications
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load user's join requests
|
||||||
|
const loadMyRequests = useCallback(async () => {
|
||||||
|
setLoadingRequests(true)
|
||||||
|
try {
|
||||||
|
const reqs = await organizationService.listMyRequests()
|
||||||
|
setMyRequests(reqs || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching join requests', err)
|
||||||
|
} finally {
|
||||||
|
setLoadingRequests(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMyRequests()
|
||||||
|
}, [loadMyRequests])
|
||||||
|
|
||||||
const handleCreateOrg = async (e: FormEvent) => {
|
const handleCreateOrg = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!newOrgName.trim()) return
|
if (!newOrgName.trim()) return
|
||||||
|
|
@ -63,180 +96,574 @@ export const OrganizationsPage = () => {
|
||||||
navigate('/dashboard')
|
navigate('/dashboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSearch = async (e?: FormEvent) => {
|
||||||
|
if (e) e.preventDefault()
|
||||||
|
setIsSearching(true)
|
||||||
|
setHasSearched(true)
|
||||||
|
setErrorMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await organizationService.search(searchQuery.trim())
|
||||||
|
setSearchResults(results || [])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error(err)
|
||||||
|
setErrorMessage('Falha ao buscar organizações.')
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequestJoin = async (orgId: number, orgName: string) => {
|
||||||
|
setJoiningOrgId(orgId)
|
||||||
|
setErrorMessage(null)
|
||||||
|
setSuccessMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await organizationService.requestJoin(orgId)
|
||||||
|
setSuccessMessage(`Solicitação enviada para "${orgName}" com sucesso! Aguarde a aprovação do proprietário.`)
|
||||||
|
await loadMyRequests()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Falha ao solicitar entrada.'
|
||||||
|
setErrorMessage(msg)
|
||||||
|
} finally {
|
||||||
|
setJoiningOrgId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnterApprovedOrg = async (orgId: number) => {
|
||||||
|
try {
|
||||||
|
await refreshUser()
|
||||||
|
setActiveOrg(orgId)
|
||||||
|
navigate('/dashboard')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRequestsCount = myRequests.filter((r) => r.status === 'PENDING').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto py-8 space-y-8 animate-in fade-in duration-200">
|
<div className="max-w-4xl mx-auto py-4 sm:py-6 space-y-6 animate-in fade-in duration-200">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="text-center sm:text-left space-y-2">
|
<div className="text-center sm:text-left space-y-2">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-soft text-brand text-xs font-bold">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand/10 border border-brand/20 text-brand text-xs font-bold">
|
||||||
<Building2 className="w-3.5 h-3.5" />
|
<Building2 className="w-3.5 h-3.5" />
|
||||||
<span>Gestão de Workspaces Corporativos</span>
|
<span>Gestão de Workspaces & Organizações</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-extrabold text-text-main tracking-tight">
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-foreground tracking-tight">
|
||||||
Seus Workspaces & Organizações
|
Workspaces Corporativos
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-text-muted max-w-2xl">
|
<p className="text-xs sm:text-sm text-muted max-w-2xl">
|
||||||
Selecione uma organização ativa para acessar projetos, tarefas e métricas da equipe,
|
Navegue entre seus workspaces ativos, solicite entrada em uma organização existente ou
|
||||||
ou crie um novo workspace dedicado para sua empresa ou squad.
|
crie um novo ambiente dedicado para seu squad ou empresa.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<Alert className="bg-danger-soft border border-danger/30 text-danger rounded-2xl p-4 text-xs flex items-start gap-2">
|
<div className="p-4 rounded-2xl bg-rose-500/10 border border-rose-500/25 text-rose-400 text-xs flex items-start gap-3 animate-in fade-in">
|
||||||
|
<XCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<AlertTitle className="font-bold">Não foi possível criar</AlertTitle>
|
<p className="font-bold">Ocorreu um erro</p>
|
||||||
<AlertDescription className="text-xs mt-0.5">{errorMessage}</AlertDescription>
|
<p className="mt-0.5 text-rose-300/90">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setErrorMessage(null)}
|
||||||
|
className="text-muted hover:text-foreground text-xs"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{successMessage && (
|
{successMessage && (
|
||||||
<Alert className="bg-success-soft border border-success/30 text-success rounded-2xl p-4 text-xs flex items-start gap-2">
|
<div className="p-4 rounded-2xl bg-emerald-500/10 border border-emerald-500/25 text-emerald-400 text-xs flex items-start gap-3 animate-in fade-in">
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<AlertTitle className="font-bold">Sucesso</AlertTitle>
|
<p className="font-bold">Sucesso</p>
|
||||||
<AlertDescription className="text-xs mt-0.5">{successMessage}</AlertDescription>
|
<p className="mt-0.5 text-emerald-300/90">{successMessage}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSuccessMessage(null)}
|
||||||
|
className="text-muted hover:text-foreground text-xs"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
|
{/* Tabs Navigation */}
|
||||||
{/* Existing Organizations (7 cols) */}
|
<div className="flex items-center gap-2 border-b border-border-soft pb-2 overflow-x-auto">
|
||||||
<div className="md:col-span-7 space-y-4">
|
<button
|
||||||
<div className="flex items-center justify-between">
|
type="button"
|
||||||
<h2 className="text-base font-bold text-text-main flex items-center gap-2">
|
onClick={() => setActiveTab('workspaces')}
|
||||||
<Users className="w-4 h-4 text-brand" />
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap cursor-pointer ${
|
||||||
|
activeTab === 'workspaces'
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/20'
|
||||||
|
: 'text-muted hover:text-foreground hover:bg-surface-raised'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
<span>Meus Workspaces</span>
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.2 rounded-full text-[10px] ${
|
||||||
|
activeTab === 'workspaces'
|
||||||
|
? 'bg-white/20 text-white font-bold'
|
||||||
|
: 'bg-surface-sunken text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user?.organizations?.length || 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('join')
|
||||||
|
if (!hasSearched) handleSearch()
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap cursor-pointer ${
|
||||||
|
activeTab === 'join'
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/20'
|
||||||
|
: 'text-muted hover:text-foreground hover:bg-surface-raised'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5" />
|
||||||
|
<span>Solicitar Entrada</span>
|
||||||
|
{pendingRequestsCount > 0 && (
|
||||||
|
<span className="px-1.5 py-0.2 rounded-full bg-amber-500/20 text-amber-300 text-[10px] font-bold">
|
||||||
|
{pendingRequestsCount} pendente{pendingRequestsCount > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('create')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap cursor-pointer ${
|
||||||
|
activeTab === 'create'
|
||||||
|
? 'bg-brand text-white shadow-md shadow-brand/20'
|
||||||
|
: 'text-muted hover:text-foreground hover:bg-surface-raised'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Criar Workspace</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TAB 1: WORKSPACES VINCULADOS */}
|
||||||
|
{activeTab === 'workspaces' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
|
||||||
|
<Building2 className="w-4 h-4 text-brand" />
|
||||||
Workspaces Vinculados ({user?.organizations?.length || 0})
|
Workspaces Vinculados ({user?.organizations?.length || 0})
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="text-xs text-muted mt-0.5">
|
||||||
|
Organizações das quais você já é membro ou proprietário.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('join')
|
||||||
|
if (!hasSearched) handleSearch()
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-border-soft bg-surface-raised text-xs font-semibold text-foreground hover:border-brand/40 hover:text-brand transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5" />
|
||||||
|
<span>Entrar em Outra</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('create')}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-accent text-white text-xs font-bold hover:bg-accent-hover shadow-xs transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Novo Workspace</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user?.organizations && user.organizations.length > 0 ? (
|
{user?.organizations && user.organizations.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5">
|
||||||
{user.organizations.map((org) => {
|
{user.organizations.map((org) => {
|
||||||
const isActive = org.id === activeOrg?.id
|
const isActive = org.id === activeOrg?.id
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
key={org.id}
|
key={org.id}
|
||||||
className={`bg-surface border rounded-2xl p-4.5 transition-all flex items-center justify-between gap-4 cursor-pointer hover:shadow-panel ${
|
|
||||||
isActive
|
|
||||||
? 'border-brand ring-2 ring-brand/10'
|
|
||||||
: 'border-border-soft hover:border-brand/40'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleSelectOrg(org.id)}
|
onClick={() => handleSelectOrg(org.id)}
|
||||||
|
className={`rounded-2xl border p-4 transition-all duration-200 cursor-pointer flex items-center justify-between gap-3 shadow-panel ${
|
||||||
|
isActive
|
||||||
|
? 'border-brand/60 bg-brand/5 ring-1 ring-brand/30 shadow-md'
|
||||||
|
: 'border-border-soft bg-surface/90 backdrop-blur-md hover:border-brand/40 hover:shadow-lg'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3.5 min-w-0">
|
<div className="flex items-center gap-3.5 min-w-0">
|
||||||
<div
|
<div
|
||||||
className={`w-11 h-11 rounded-xl flex items-center justify-center font-bold text-sm shrink-0 shadow-xs ${
|
className={`w-11 h-11 rounded-xl flex items-center justify-center font-bold text-sm shrink-0 shadow-xs ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-brand text-white shadow-brand/20'
|
? 'bg-brand text-white shadow-brand/20'
|
||||||
: 'bg-surface-muted text-text-muted border border-border-soft'
|
: 'bg-surface-sunken text-muted border border-border-soft'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{org.name.slice(0, 2).toUpperCase()}
|
{org.name.slice(0, 2).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-sm font-bold text-text-main truncate">
|
<h3 className="text-sm font-bold text-foreground truncate">
|
||||||
{org.name}
|
{org.name}
|
||||||
</h3>
|
</h3>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<Chip className="bg-brand-soft text-brand text-[10px] font-bold px-2 py-0.2">
|
<span className="px-2 py-0.5 rounded-full bg-brand/15 text-brand text-[10px] font-extrabold tracking-wide uppercase border border-brand/30">
|
||||||
<ChipLabel>ATIVO</ChipLabel>
|
Ativo
|
||||||
</Chip>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-text-muted mt-0.5 font-medium">
|
<p className="text-xs text-muted mt-0.5 font-medium">
|
||||||
Papel: <span className="text-brand font-semibold">{org.roles.join(', ')}</span>
|
Papel:{' '}
|
||||||
|
<span className="text-brand font-semibold">
|
||||||
|
{org.roles.join(', ')}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleSelectOrg(org.id)
|
handleSelectOrg(org.id)
|
||||||
}}
|
}}
|
||||||
className={`text-xs font-bold px-3 py-1.5 rounded-xl shrink-0 ${
|
className={`text-xs font-bold px-3 py-1.5 rounded-xl shrink-0 transition-colors cursor-pointer ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-brand text-white shadow-sm'
|
? 'bg-brand text-white shadow-xs'
|
||||||
: 'bg-surface-muted text-text-muted hover:text-text-main hover:bg-border-soft'
|
: 'bg-surface-raised border border-border-soft text-muted hover:text-foreground hover:bg-surface-sunken'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isActive ? 'Acessar' : 'Selecionar'}
|
{isActive ? 'Acessar' : 'Selecionar'}
|
||||||
</Button>
|
</button>
|
||||||
</Card>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="bg-surface border border-border-soft rounded-2xl p-8 text-center space-y-3">
|
<div className="rounded-3xl border border-dashed border-border-soft bg-surface/60 backdrop-blur-md p-8 text-center space-y-3">
|
||||||
<div className="w-12 h-12 rounded-2xl bg-amber-50 text-amber-600 mx-auto flex items-center justify-center">
|
<div className="w-12 h-12 rounded-2xl bg-amber-500/15 text-amber-500 mx-auto flex items-center justify-center">
|
||||||
<Building2 className="w-6 h-6" />
|
<Building2 className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-bold text-text-main">
|
<h3 className="text-base font-bold text-foreground">
|
||||||
Nenhum workspace encontrado
|
Você ainda não está vinculado a nenhum workspace
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-text-muted max-w-sm mx-auto">
|
<p className="text-xs text-muted max-w-sm mx-auto">
|
||||||
Você ainda não faz parte de nenhuma organização. Crie o seu primeiro workspace ao lado para começar!
|
Você pode solicitar entrada em uma organização existente ou criar um novo workspace para sua equipe.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
<div className="pt-2 flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('join')
|
||||||
|
handleSearch()
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-brand text-white text-xs font-bold hover:bg-brand-hover shadow-sm transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5" />
|
||||||
|
<span>Buscar e Entrar</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('create')}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border-soft bg-surface-raised text-xs font-bold text-foreground hover:border-brand/40 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>Criar Novo</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 2: SOLICITAR ENTRADA EM UMA ORGANIZAÇÃO */}
|
||||||
|
{activeTab === 'join' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Search Box */}
|
||||||
|
<div className="p-5 sm:p-6 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
|
||||||
|
<Search className="w-4 h-4 text-brand" />
|
||||||
|
Buscar Organização
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted mt-0.5">
|
||||||
|
Digite o nome da empresa ou organização para solicitar participação como membro.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Ex: BeeTech, Empresa Alpha, Squad..."
|
||||||
|
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand transition-all pl-9"
|
||||||
|
/>
|
||||||
|
<Search className="w-4 h-4 text-muted absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSearching}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-brand text-white text-xs font-bold hover:bg-brand-hover transition-all shadow-xs cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSearching ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
<span>Buscar</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Search Results */}
|
||||||
|
{hasSearched && (
|
||||||
|
<div className="pt-2 space-y-2 border-t border-border-soft/60">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted font-medium">
|
||||||
|
<span>Resultados da busca:</span>
|
||||||
|
<span>{searchResults.length} encontrada{searchResults.length === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchResults.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-xs text-muted border border-dashed border-border-soft rounded-2xl">
|
||||||
|
Nenhuma organização encontrada com o termo "{searchQuery}".
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-72 overflow-y-auto">
|
||||||
|
{searchResults.map((org) => {
|
||||||
|
const isAlreadyMember = user?.organizations?.some((o) => o.id === org.id)
|
||||||
|
const pendingReq = myRequests.find(
|
||||||
|
(r) => r.organizationId === org.id && r.status === 'PENDING'
|
||||||
|
)
|
||||||
|
const isJoining = joiningOrgId === org.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={org.id}
|
||||||
|
className="p-3.5 rounded-2xl border border-border-soft bg-surface-raised/70 flex items-center justify-between gap-3 hover:border-brand/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-brand/15 text-brand flex items-center justify-center font-bold text-xs shrink-0 border border-brand/25">
|
||||||
|
{org.name.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
<h4 className="text-xs font-bold text-foreground truncate">
|
||||||
|
{org.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[10px] text-muted">ID: #{org.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 flex items-center gap-2">
|
||||||
|
{isAlreadyMember ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 text-[11px] font-bold">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
<span>Já é Membro</span>
|
||||||
|
</span>
|
||||||
|
) : pendingReq ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[11px] font-bold">
|
||||||
|
<Clock className="w-3 h-3 animate-pulse" />
|
||||||
|
<span>Pendente</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isJoining}
|
||||||
|
onClick={() => handleRequestJoin(org.id, org.name)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-brand/15 text-brand border border-brand/30 text-xs font-bold hover:bg-brand hover:text-white transition-all cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isJoining ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
<span>Solicitar Entrada</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create New Workspace Form (5 cols) */}
|
{/* My Join Requests Status Section */}
|
||||||
<div className="md:col-span-5 space-y-4">
|
<div className="p-5 sm:p-6 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel space-y-4">
|
||||||
<Card className="bg-surface border border-border-soft rounded-3xl p-6 shadow-panel space-y-5">
|
<div className="flex items-center justify-between">
|
||||||
<CardHeader className="p-0 space-y-1">
|
<div>
|
||||||
<div className="w-10 h-10 rounded-xl bg-accent-soft text-accent flex items-center justify-center mb-1">
|
<h2 className="text-base font-bold text-foreground flex items-center gap-2">
|
||||||
|
<Inbox className="w-4 h-4 text-accent" />
|
||||||
|
Minhas Solicitações de Entrada
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted mt-0.5">
|
||||||
|
Acompanhe o status dos seus pedidos para ingressar em organizações.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadMyRequests}
|
||||||
|
disabled={loadingRequests}
|
||||||
|
className="text-xs text-brand hover:underline font-semibold cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loadingRequests ? 'Atualizando...' : 'Atualizar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{myRequests.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-muted border border-dashed border-border-soft rounded-2xl">
|
||||||
|
Você ainda não enviou nenhuma solicitação de entrada.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{myRequests.map((req, idx) => {
|
||||||
|
const isPending = req.status === 'PENDING'
|
||||||
|
const isApproved = req.status === 'APPROVED'
|
||||||
|
const isRejected = req.status === 'REJECTED'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="p-3.5 rounded-2xl border border-border-soft bg-surface-raised/50 flex flex-col sm:flex-row sm:items-center justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`w-9 h-9 rounded-xl flex items-center justify-center font-bold text-xs shrink-0 ${
|
||||||
|
isApproved
|
||||||
|
? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30'
|
||||||
|
: isPending
|
||||||
|
? 'bg-amber-500/15 text-amber-300 border border-amber-500/30'
|
||||||
|
: 'bg-rose-500/15 text-rose-400 border border-rose-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
<h4 className="text-xs font-bold text-foreground truncate">
|
||||||
|
{req.organizationName}
|
||||||
|
</h4>
|
||||||
|
<p className="text-[10px] text-muted">
|
||||||
|
Solicitado em:{' '}
|
||||||
|
{req.createdAt
|
||||||
|
? new Date(req.createdAt).toLocaleDateString('pt-BR')
|
||||||
|
: 'Recentemente'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5 self-end sm:self-center">
|
||||||
|
{isPending && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30 text-[11px] font-bold">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
<span>Pendente de Aprovação</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isApproved && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 text-[11px] font-bold">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
<span>Aprovada</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEnterApprovedOrg(req.organizationId)}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-[11px] font-bold transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Entrar no Workspace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRejected && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-rose-500/15 text-rose-400 border border-rose-500/30 text-[11px] font-bold">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
<span>Recusada pelo Dono</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 3: CRIAR NOVO WORKSPACE */}
|
||||||
|
{activeTab === 'create' && (
|
||||||
|
<div className="p-6 sm:p-8 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md shadow-panel space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent flex items-center justify-center mb-2 border border-accent/25">
|
||||||
<Plus className="w-5 h-5 text-accent" />
|
<Plus className="w-5 h-5 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-base font-bold text-text-main">
|
<h2 className="text-lg font-bold text-foreground">
|
||||||
Criar Novo Workspace
|
Criar Novo Workspace
|
||||||
</CardTitle>
|
</h2>
|
||||||
<CardDescription className="text-xs text-text-muted">
|
<p className="text-xs text-muted">
|
||||||
Você será automaticamente configurado como <strong>OWNER</strong>.
|
Você será automaticamente configurado como <strong>PROPRIETÁRIO (OWNER)</strong> deste workspace.
|
||||||
</CardDescription>
|
</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
|
|
||||||
<CardContent className="p-0">
|
<form onSubmit={handleCreateOrg} className="space-y-4 max-w-lg">
|
||||||
<form onSubmit={handleCreateOrg} className="space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-bold text-text-main">
|
<label className="text-xs font-bold text-foreground">
|
||||||
Nome da Organização / Empresa
|
Nome da Organização / Empresa
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={newOrgName}
|
value={newOrgName}
|
||||||
onChange={(e) => setNewOrgName(e.target.value)}
|
onChange={(e) => setNewOrgName(e.target.value)}
|
||||||
placeholder="Ex: Minha Empresa Corp"
|
placeholder="Ex: Minha Empresa Corp, Tech Labs..."
|
||||||
className="w-full px-3.5 py-2.5 bg-surface-muted/60 border border-border-soft rounded-xl text-xs text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent transition-all"
|
className="w-full px-3.5 py-2.5 bg-surface border border-border-soft rounded-xl text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
isDisabled={isCreating || !newOrgName.trim()}
|
disabled={isCreating || !newOrgName.trim()}
|
||||||
className="w-full bg-accent hover:bg-[#b45309] text-white font-bold text-xs py-3 rounded-xl shadow-md shadow-accent/20 transition-all flex items-center justify-center gap-2"
|
className="w-full sm:w-auto px-6 py-2.5 bg-accent hover:bg-accent-hover text-white font-bold text-xs rounded-xl shadow-md shadow-accent/20 transition-all flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Criar Organização
|
<span>Criar Organização</span>
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<div className="pt-3 border-t border-border-soft flex items-center gap-2 text-[11px] text-text-muted font-medium">
|
<div className="pt-4 border-t border-border-soft flex items-center gap-2 text-[11px] text-muted font-medium">
|
||||||
<ShieldCheck className="w-3.5 h-3.5 text-accent" />
|
<ShieldCheck className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Multi-tenancy com isolamento de dados</span>
|
<span>Multi-tenancy com isolamento seguro de projetos, tarefas e métricas da equipe</span>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Mail,
|
Mail,
|
||||||
|
|
@ -9,9 +9,15 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Save,
|
Save,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Camera,
|
||||||
|
Edit2,
|
||||||
|
AlertCircle,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { taskService } from '../services/taskService'
|
import { taskService } from '../services/taskService'
|
||||||
|
import { authService } from '../services/authService'
|
||||||
import type { Task } from '../types/task'
|
import type { Task } from '../types/task'
|
||||||
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils'
|
||||||
import { CustomSelect } from '../components/ui/Select'
|
import { CustomSelect } from '../components/ui/Select'
|
||||||
|
|
@ -23,17 +29,101 @@ import {
|
||||||
} from '../lib/userGoals'
|
} from '../lib/userGoals'
|
||||||
|
|
||||||
export const ProfilePage: React.FC = () => {
|
export const ProfilePage: React.FC = () => {
|
||||||
const { user, activeOrg, setActiveOrg, logout } = useAuth()
|
const { user, activeOrg, setActiveOrg, logout, updateUser, refreshUser } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [myTasks, setMyTasks] = useState<Task[]>([])
|
const [myTasks, setMyTasks] = useState<Task[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Photo upload state
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [uploadingPhoto, setUploadingPhoto] = useState(false)
|
||||||
|
const [photoSuccess, setPhotoSuccess] = useState(false)
|
||||||
|
const [photoError, setPhotoError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Name edit state
|
||||||
|
const [editingName, setEditingName] = useState(false)
|
||||||
|
const [nameInput, setNameInput] = useState(user?.name || '')
|
||||||
|
const [savingName, setSavingName] = useState(false)
|
||||||
|
const [nameSuccess, setNameSuccess] = useState(false)
|
||||||
|
const [nameError, setNameError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.name) {
|
||||||
|
setNameInput(user.name)
|
||||||
|
}
|
||||||
|
}, [user?.name])
|
||||||
|
|
||||||
// Workload & Goal State (Global)
|
// Workload & Goal State (Global)
|
||||||
const [goalPeriod, setGoalPeriod] = useState<WorkGoalPeriod>('DAILY')
|
const [goalPeriod, setGoalPeriod] = useState<WorkGoalPeriod>('DAILY')
|
||||||
const [goalHours, setGoalHours] = useState<number>(8)
|
const [goalHours, setGoalHours] = useState<number>(8)
|
||||||
const [savedSuccess, setSavedSuccess] = useState(false)
|
const [savedSuccess, setSavedSuccess] = useState(false)
|
||||||
|
|
||||||
|
const handlePhotoSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Validate size (5 MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
setPhotoError('A imagem deve ter no máximo 5 MB.')
|
||||||
|
setTimeout(() => setPhotoError(null), 5000)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate image mime type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
setPhotoError('O arquivo selecionado deve ser uma imagem (JPG, PNG, WebP).')
|
||||||
|
setTimeout(() => setPhotoError(null), 5000)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadingPhoto(true)
|
||||||
|
setPhotoError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authService.uploadPhoto(file)
|
||||||
|
// Append timestamp query parameter to bust browser cache
|
||||||
|
const freshPhotoUrl = res.photoUrl.includes('?')
|
||||||
|
? `${res.photoUrl}&t=${Date.now()}`
|
||||||
|
: `${res.photoUrl}?t=${Date.now()}`
|
||||||
|
updateUser({ photo: freshPhotoUrl })
|
||||||
|
setPhotoSuccess(true)
|
||||||
|
setTimeout(() => setPhotoSuccess(false), 4000)
|
||||||
|
await refreshUser()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Falha ao atualizar a foto de perfil.'
|
||||||
|
setPhotoError(msg)
|
||||||
|
setTimeout(() => setPhotoError(null), 5000)
|
||||||
|
} finally {
|
||||||
|
setUploadingPhoto(false)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveName = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!user?.email || !nameInput.trim()) return
|
||||||
|
|
||||||
|
setSavingName(true)
|
||||||
|
setNameError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authService.updateProfile(nameInput.trim(), user.email)
|
||||||
|
updateUser({ name: res.name })
|
||||||
|
setNameSuccess(true)
|
||||||
|
setEditingName(false)
|
||||||
|
setTimeout(() => setNameSuccess(false), 4000)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Erro ao salvar o nome.'
|
||||||
|
setNameError(msg)
|
||||||
|
setTimeout(() => setNameError(null), 5000)
|
||||||
|
} finally {
|
||||||
|
setSavingName(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.email) {
|
if (user?.email) {
|
||||||
const g = getUserWorkGoal(user.email)
|
const g = getUserWorkGoal(user.email)
|
||||||
|
|
@ -97,10 +187,46 @@ export const ProfilePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Profile Info Card */}
|
{/* Main Profile Info Card */}
|
||||||
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-8 shadow-panel space-y-6">
|
<div className="rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-6 sm:p-8 shadow-panel space-y-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 border-b border-border-soft/60">
|
{/* Status Alerts */}
|
||||||
<div className="flex items-center gap-4">
|
{photoSuccess && (
|
||||||
<div className="w-16 h-16 rounded-full overflow-hidden border-2 border-brand/40 bg-gradient-to-br from-brand to-teal-700 flex items-center justify-center text-white font-extrabold text-2xl shadow-lg shadow-brand/20 shrink-0">
|
<div className="flex items-center gap-2 p-3.5 rounded-2xl bg-emerald-500/10 border border-emerald-600/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold animate-in fade-in duration-200">
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
|
<span>Foto de perfil atualizada com sucesso!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{photoError && (
|
||||||
|
<div className="flex items-center gap-2 p-3.5 rounded-2xl bg-rose-500/10 border border-rose-600/20 text-rose-300 text-xs font-semibold animate-in fade-in duration-200">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{photoError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nameSuccess && (
|
||||||
|
<div className="flex items-center gap-2 p-3.5 rounded-2xl bg-emerald-500/10 border border-emerald-600/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold animate-in fade-in duration-200">
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||||
|
<span>Nome atualizado com sucesso!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nameError && (
|
||||||
|
<div className="flex items-center gap-2 p-3.5 rounded-2xl bg-rose-500/10 border border-rose-600/20 text-rose-300 text-xs font-semibold animate-in fade-in duration-200">
|
||||||
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{nameError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 pb-6 border-b border-border-soft/60">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-5">
|
||||||
|
{/* Avatar with Camera Trigger */}
|
||||||
|
<div className="relative group shrink-0">
|
||||||
|
<div
|
||||||
|
onClick={() => !uploadingPhoto && fileInputRef.current?.click()}
|
||||||
|
className={`w-20 h-20 sm:w-24 sm:h-24 rounded-full overflow-hidden border-2 border-brand/50 bg-gradient-to-br from-brand to-teal-700 flex items-center justify-center text-white font-extrabold text-2xl sm:text-3xl shadow-lg shadow-brand/20 cursor-pointer relative transition-transform duration-200 group-hover:scale-[1.02] ${
|
||||||
|
uploadingPhoto ? 'opacity-80 pointer-events-none' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{user.photo ? (
|
{user.photo ? (
|
||||||
<img
|
<img
|
||||||
src={user.photo}
|
src={user.photo}
|
||||||
|
|
@ -112,20 +238,114 @@ export const ProfilePage: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
user.email.charAt(0).toUpperCase()
|
user.email.charAt(0).toUpperCase()
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hover Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-black/55 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center text-white text-[11px] font-bold gap-1">
|
||||||
|
<Camera className="w-5 h-5 text-white drop-shadow" />
|
||||||
|
<span>Alterar</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-bold text-foreground">{user.name || 'Usuário'}</h2>
|
{/* Uploading Overlay */}
|
||||||
<p className="text-xs text-muted flex items-center gap-1.5 mt-0.5">
|
{uploadingPhoto && (
|
||||||
|
<div className="absolute inset-0 bg-black/65 flex flex-col items-center justify-center text-white gap-1 z-10">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-brand" />
|
||||||
|
<span className="text-[10px] font-bold">Enviando...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Camera Badge Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploadingPhoto}
|
||||||
|
title="Alterar foto de perfil"
|
||||||
|
className="absolute -bottom-1 -right-1 p-2 rounded-full bg-brand hover:bg-brand-strong text-white shadow-md border-2 border-surface transition-transform duration-200 hover:scale-110 active:scale-95 cursor-pointer disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
onChange={handlePhotoSelected}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Details & Edit Form */}
|
||||||
|
<div className="space-y-1.5 flex-1 min-w-0">
|
||||||
|
{editingName ? (
|
||||||
|
<form onSubmit={handleSaveName} className="flex items-center gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nameInput}
|
||||||
|
onChange={(e) => setNameInput(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Seu nome"
|
||||||
|
className="px-3 py-1.5 rounded-xl border border-brand bg-surface text-sm font-bold text-foreground focus:outline-none focus:ring-2 focus:ring-brand/40"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingName || !nameInput.trim()}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded-xl bg-brand text-white text-xs font-bold hover:bg-brand-strong shadow-xs disabled:opacity-60 cursor-pointer"
|
||||||
|
>
|
||||||
|
{savingName ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||||
|
<span>Salvar</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setNameInput(user.name || '')
|
||||||
|
setEditingName(false)
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h2 className="text-lg sm:text-xl font-bold text-foreground">{user.name || 'Usuário'}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingName(true)}
|
||||||
|
title="Editar nome"
|
||||||
|
className="p-1 rounded-lg text-muted hover:text-brand hover:bg-brand/10 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted flex items-center gap-1.5">
|
||||||
<Mail className="w-3.5 h-3.5" />
|
<Mail className="w-3.5 h-3.5" />
|
||||||
{user.email}
|
{user.email}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploadingPhoto}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-surface-raised hover:bg-surface-sunken border border-border-soft text-xs font-semibold text-foreground transition-all cursor-pointer shadow-xs"
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5 text-brand" />
|
||||||
|
<span>Trocar foto</span>
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] text-muted">
|
||||||
|
PNG, JPG, WebP ou GIF (máx. 5MB)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="self-start sm:self-auto flex items-center gap-2 px-4 py-2.5 rounded-xl border border-rose-500/30 text-rose-300 hover:bg-rose-500/10 text-xs font-semibold transition-all hover:shadow-xs"
|
className="self-start sm:self-auto flex items-center gap-2 px-4 py-2.5 rounded-xl border border-rose-500/30 text-rose-300 hover:bg-rose-500/10 text-xs font-semibold transition-all hover:shadow-xs cursor-pointer"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
<span>Encerrar Sessão</span>
|
<span>Encerrar Sessão</span>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List as ListIcon,
|
List as ListIcon,
|
||||||
|
|
@ -38,6 +38,11 @@ export const TasksPage: React.FC = () => {
|
||||||
const { activeOrg, user } = useAuth()
|
const { activeOrg, user } = useAuth()
|
||||||
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
|
const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer()
|
||||||
|
|
||||||
|
const syncWithTasksRef = useRef(syncWithTasks)
|
||||||
|
useEffect(() => {
|
||||||
|
syncWithTasksRef.current = syncWithTasks
|
||||||
|
}, [syncWithTasks])
|
||||||
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -135,7 +140,7 @@ export const TasksPage: React.FC = () => {
|
||||||
|
|
||||||
setTasks(tasksRes || [])
|
setTasks(tasksRes || [])
|
||||||
setProjects(projsRes || [])
|
setProjects(projsRes || [])
|
||||||
syncWithTasks(tasksRes || [])
|
syncWithTasksRef.current(tasksRes || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load tasks', err)
|
console.error('Failed to load tasks', err)
|
||||||
setActionError('Falha ao carregar tarefas. Tente atualizar a página.')
|
setActionError('Falha ao carregar tarefas. Tente atualizar a página.')
|
||||||
|
|
@ -143,7 +148,7 @@ export const TasksPage: React.FC = () => {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
}
|
}
|
||||||
}, [activeOrgId, onlyMyTasks, syncWithTasks])
|
}, [activeOrgId, onlyMyTasks])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
|
|
@ -277,6 +282,29 @@ export const TasksPage: React.FC = () => {
|
||||||
setIsModalOpen(true)
|
setIsModalOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleDeleteTask = useCallback(
|
||||||
|
async (task: Task) => {
|
||||||
|
setActionError(null)
|
||||||
|
try {
|
||||||
|
if (activeTask?.id === task.id) {
|
||||||
|
await stopTimer(task)
|
||||||
|
}
|
||||||
|
await taskService.deleteTask(task.id)
|
||||||
|
setTasks((prev) => prev.filter((t) => t.id !== task.id))
|
||||||
|
if (editingTask?.id === task.id) {
|
||||||
|
setEditingTask(null)
|
||||||
|
setIsModalOpen(false)
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Failed to delete task', err)
|
||||||
|
setActionError(
|
||||||
|
err instanceof Error ? err.message : 'Falha ao excluir a tarefa.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeTask, stopTimer, editingTask]
|
||||||
|
)
|
||||||
|
|
||||||
// Summary counts
|
// Summary counts
|
||||||
const notStartedCount = tasks.filter((t) => t.status === 'NOT_STARTED').length
|
const notStartedCount = tasks.filter((t) => t.status === 'NOT_STARTED').length
|
||||||
const inProgressCount = tasks.filter((t) => t.status === 'IN_PROGRESS').length
|
const inProgressCount = tasks.filter((t) => t.status === 'IN_PROGRESS').length
|
||||||
|
|
@ -682,6 +710,7 @@ export const TasksPage: React.FC = () => {
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onEditTask={handleOpenEditModal}
|
onEditTask={handleOpenEditModal}
|
||||||
onOpenHistory={handleOpenHistoryModal}
|
onOpenHistory={handleOpenHistoryModal}
|
||||||
|
onDeleteTask={handleDeleteTask}
|
||||||
onAddTaskToStatus={handleOpenCreateModal}
|
onAddTaskToStatus={handleOpenCreateModal}
|
||||||
activeTaskId={activeTask?.id}
|
activeTaskId={activeTask?.id}
|
||||||
activeTaskSeconds={activeSeconds}
|
activeTaskSeconds={activeSeconds}
|
||||||
|
|
@ -697,6 +726,7 @@ export const TasksPage: React.FC = () => {
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onEditTask={handleOpenEditModal}
|
onEditTask={handleOpenEditModal}
|
||||||
onOpenHistory={handleOpenHistoryModal}
|
onOpenHistory={handleOpenHistoryModal}
|
||||||
|
onDeleteTask={handleDeleteTask}
|
||||||
activeTaskId={activeTask?.id}
|
activeTaskId={activeTask?.id}
|
||||||
activeTaskSeconds={activeSeconds}
|
activeTaskSeconds={activeSeconds}
|
||||||
/>
|
/>
|
||||||
|
|
@ -711,6 +741,7 @@ export const TasksPage: React.FC = () => {
|
||||||
setEditingTask(null)
|
setEditingTask(null)
|
||||||
}}
|
}}
|
||||||
onSubmit={handleModalSubmit}
|
onSubmit={handleModalSubmit}
|
||||||
|
onDelete={handleDeleteTask}
|
||||||
organizationId={activeOrgId}
|
organizationId={activeOrgId}
|
||||||
initialStatus={modalInitialStatus}
|
initialStatus={modalInitialStatus}
|
||||||
initialTask={editingTask}
|
initialTask={editingTask}
|
||||||
|
|
|
||||||
|
|
@ -63,4 +63,20 @@ export const authService = {
|
||||||
body: { preferences },
|
body: { preferences },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async uploadPhoto(file: File): Promise<{ photoUrl: string }> {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('photo', file)
|
||||||
|
return apiFetch<{ photoUrl: string }>('/auth/profile/photo', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateProfile(name: string, email: string): Promise<{ name: string; email: string; photo: string }> {
|
||||||
|
return apiFetch<{ name: string; email: string; photo: string }>('/auth/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { name, email },
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,4 +75,10 @@ export const taskService = {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteTask(id: number): Promise<void> {
|
||||||
|
return apiFetch<void>(`/tasks/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export interface Task {
|
||||||
project?: Project;
|
project?: Project;
|
||||||
category?: Category;
|
category?: Category;
|
||||||
user?: TaskUser;
|
user?: TaskUser;
|
||||||
|
createdBy?: TaskUser;
|
||||||
finishedAt?: string;
|
finishedAt?: string;
|
||||||
history?: TaskHistoryItem[];
|
history?: TaskHistoryItem[];
|
||||||
externalUrl?: string;
|
externalUrl?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue