Add assignee filtering to task listings
This commit is contained in:
parent
ea7ffaaffd
commit
7f05ebdf61
21 changed files with 413 additions and 75 deletions
|
|
@ -35,8 +35,8 @@ public class TaskUseCaseConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
public ListAllTasksUseCase listAllTasksUseCase(TaskRepository taskRepository) {
|
public ListAllTasksUseCase listAllTasksUseCase(TaskRepository taskRepository, UserRepository userRepository) {
|
||||||
return new ListAllTasksUseCase(taskRepository);
|
return new ListAllTasksUseCase(taskRepository, userRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Produces
|
@Produces
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,51 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Task> findAccessibleTasksFiltered(String email, Long organizationId, String text, List<Long> projectIds,
|
||||||
|
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds) {
|
||||||
|
StringBuilder query = new StringBuilder(
|
||||||
|
"select distinct t from TaskEntity t join t.project p left join p.users pu left join t.user tu " +
|
||||||
|
"where p.organization.id = ?1 and (pu.email = ?2 or tu.email = ?2)"
|
||||||
|
);
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
params.add(organizationId);
|
||||||
|
params.add(email);
|
||||||
|
|
||||||
|
int idx = 3;
|
||||||
|
if (Objects.nonNull(text) && !text.isBlank()) {
|
||||||
|
query.append(" and (lower(t.title) like ?").append(idx).append(" or lower(t.description) like ?").append(idx + 1).append(")");
|
||||||
|
String pattern = "%" + text.toLowerCase() + "%";
|
||||||
|
params.add(pattern);
|
||||||
|
params.add(pattern);
|
||||||
|
idx += 2;
|
||||||
|
}
|
||||||
|
if (Objects.nonNull(projectIds) && !projectIds.isEmpty()) {
|
||||||
|
query.append(" and p.id in ?").append(idx);
|
||||||
|
params.add(projectIds);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (Objects.nonNull(statuses) && !statuses.isEmpty()) {
|
||||||
|
query.append(" and t.status in ?").append(idx);
|
||||||
|
params.add(statuses);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (Objects.nonNull(categoryIds) && !categoryIds.isEmpty()) {
|
||||||
|
query.append(" and t.category.id in ?").append(idx);
|
||||||
|
params.add(categoryIds);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (Objects.nonNull(userIds) && !userIds.isEmpty()) {
|
||||||
|
query.append(" and tu.id in ?").append(idx);
|
||||||
|
params.add(userIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Task> tasks = find(query.toString(), params.toArray()).list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||||
|
LOGGER.infof("Loaded %d accessible tasks for user %s in organization %d with filters (text=%s, projectIds=%s, statuses=%s, categoryIds=%s, userIds=%s)",
|
||||||
|
tasks.size(), email, organizationId, text, projectIds, statuses, categoryIds, userIds);
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
@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) {
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@ public record CreateTaskResponse(
|
||||||
TaskStatus status,
|
TaskStatus status,
|
||||||
ProjectResponse project,
|
ProjectResponse project,
|
||||||
CategoryResponse category,
|
CategoryResponse category,
|
||||||
|
UserResponse user,
|
||||||
LocalDateTime finishedAt,
|
LocalDateTime finishedAt,
|
||||||
List<TaskHistoryItemResponse> history) {
|
List<TaskHistoryItemResponse> history) {
|
||||||
|
|
||||||
public record ProjectResponse(Long id, String name) {}
|
public record ProjectResponse(Long id, String name) {}
|
||||||
public record CategoryResponse(Long id, String name, String color, String icon) {}
|
public record CategoryResponse(Long id, String name, String color, String icon) {}
|
||||||
|
public record UserResponse(Long id, String name, String email, String photo) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
record TaskHistoryItemResponse(
|
record TaskHistoryItemResponse(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||||
|
|
||||||
|
public class ListTasksRequest {
|
||||||
|
|
||||||
|
@QueryParam("organizationId")
|
||||||
|
public Long organizationId;
|
||||||
|
|
||||||
|
@QueryParam("text")
|
||||||
|
public String text;
|
||||||
|
|
||||||
|
@QueryParam("projectId")
|
||||||
|
public List<Long> projectIds;
|
||||||
|
|
||||||
|
@QueryParam("status")
|
||||||
|
public List<TaskStatus> statuses;
|
||||||
|
|
||||||
|
@QueryParam("categoryId")
|
||||||
|
public List<Long> categoryIds;
|
||||||
|
|
||||||
|
@QueryParam("userId")
|
||||||
|
public List<Long> userIds;
|
||||||
|
}
|
||||||
|
|
@ -117,8 +117,9 @@ public class TaskController {
|
||||||
@Operation(summary = "Stop a task", description = "Stops a task in the system")
|
@Operation(summary = "Stop a task", description = "Stops a task in the system")
|
||||||
@APIResponse(responseCode = "200", description = "Task stopped successfully")
|
@APIResponse(responseCode = "200", description = "Task stopped successfully")
|
||||||
public CreateTaskResponse stopTask(@PathParam("id") Long id) {
|
public CreateTaskResponse stopTask(@PathParam("id") Long id) {
|
||||||
LOGGER.infof("Stop requested for task %d", id);
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
CreateTaskOutput output = stopTaskUseCase.execute(TaskControllerMapper.toStopTaskInput(id));
|
LOGGER.infof("User %s requested stop for task %d", email, id);
|
||||||
|
CreateTaskOutput output = stopTaskUseCase.execute(TaskControllerMapper.toStopTaskInput(id, email));
|
||||||
LOGGER.infof("Task %d stopped successfully", id);
|
LOGGER.infof("Task %d stopped successfully", id);
|
||||||
return TaskControllerMapper.toCreateTaskResponse(output);
|
return TaskControllerMapper.toCreateTaskResponse(output);
|
||||||
}
|
}
|
||||||
|
|
@ -137,9 +138,11 @@ public class TaskController {
|
||||||
@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")
|
||||||
public List<CreateTaskResponse> listTasks() {
|
public List<CreateTaskResponse> listTasks(@BeanParam ListTasksRequest request) {
|
||||||
List<CreateTaskResponse> tasks = listAllTasksUseCase.execute().stream().map(TaskControllerMapper::toCreateTaskResponse).toList();
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
LOGGER.infof("Listed %d tasks", tasks.size());
|
List<CreateTaskResponse> tasks = listAllTasksUseCase.execute(TaskControllerMapper.toListTasksInput(request, email))
|
||||||
|
.stream().map(TaskControllerMapper::toCreateTaskResponse).toList();
|
||||||
|
LOGGER.infof("Listed %d accessible tasks for user %s", tasks.size(), email);
|
||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
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.listall.ListTasksInput;
|
||||||
import net.beetechgroup.beetask.usecase.task.listall.ListMyTasksInput;
|
import net.beetechgroup.beetask.usecase.task.listall.ListMyTasksInput;
|
||||||
import net.beetechgroup.beetask.usecase.task.start.StartTaskInput;
|
import net.beetechgroup.beetask.usecase.task.start.StartTaskInput;
|
||||||
import net.beetechgroup.beetask.usecase.task.stop.StopTaskInput;
|
import net.beetechgroup.beetask.usecase.task.stop.StopTaskInput;
|
||||||
|
|
@ -11,6 +12,18 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput;
|
||||||
|
|
||||||
public class TaskControllerMapper {
|
public class TaskControllerMapper {
|
||||||
|
|
||||||
|
public static ListTasksInput toListTasksInput(ListTasksRequest request, String email) {
|
||||||
|
return new ListTasksInput(
|
||||||
|
email,
|
||||||
|
request.organizationId,
|
||||||
|
request.text,
|
||||||
|
request.projectIds,
|
||||||
|
request.statuses,
|
||||||
|
request.categoryIds,
|
||||||
|
request.userIds
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) {
|
public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) {
|
||||||
return new ListMyTasksInput(email, request.text, request.projectIds, request.statuses, request.categoryIds);
|
return new ListMyTasksInput(email, request.text, request.projectIds, request.statuses, request.categoryIds);
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +58,13 @@ public class TaskControllerMapper {
|
||||||
output.category().color(),
|
output.category().color(),
|
||||||
output.category().icon())
|
output.category().icon())
|
||||||
: null,
|
: null,
|
||||||
|
Objects.nonNull(output.user())
|
||||||
|
? new CreateTaskResponse.UserResponse(
|
||||||
|
output.user().id(),
|
||||||
|
output.user().name(),
|
||||||
|
output.user().email(),
|
||||||
|
output.user().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())).toList()
|
taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList()
|
||||||
|
|
@ -55,8 +75,8 @@ public class TaskControllerMapper {
|
||||||
return new StartTaskInput(id, email);
|
return new StartTaskInput(id, email);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StopTaskInput toStopTaskInput(Long id) {
|
public static StopTaskInput toStopTaskInput(Long id, String email) {
|
||||||
return new StopTaskInput(id);
|
return new StopTaskInput(id, email);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static UpdateTaskInput toUpdateTaskInput(Long id, CreateTaskRequest request, String userEmail) {
|
public static UpdateTaskInput toUpdateTaskInput(Long id, CreateTaskRequest request, String userEmail) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ public interface TaskRepository {
|
||||||
Task saveTask(Task task);
|
Task saveTask(Task task);
|
||||||
Task findTaskById(Long id);
|
Task findTaskById(Long id);
|
||||||
List<Task> findAllTasks();
|
List<Task> findAllTasks();
|
||||||
|
List<Task> findAccessibleTasksFiltered(String email, Long organizationId, String text, List<Long> projectIds,
|
||||||
|
List<TaskStatus> statuses, List<Long> categoryIds, List<Long> userIds);
|
||||||
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,24 @@ public class CreateTaskMapper {
|
||||||
task.getCategory().getColor(),
|
task.getCategory().getColor(),
|
||||||
task.getCategory().getIcon())
|
task.getCategory().getIcon())
|
||||||
: null,
|
: null,
|
||||||
|
Objects.nonNull(task.getUser())
|
||||||
|
? new CreateTaskOutput.UserOutput(
|
||||||
|
task.getUser().getId(),
|
||||||
|
task.getUser().getName(),
|
||||||
|
task.getUser().getEmail(),
|
||||||
|
getPhotoUrl(task.getUser().getName(), task.getUser().getPhoto()))
|
||||||
|
: null,
|
||||||
task.getFinishedAt(),
|
task.getFinishedAt(),
|
||||||
task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList());
|
task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String getPhotoUrl(String name, String photo) {
|
||||||
|
if (Objects.nonNull(photo) && !photo.isBlank()) {
|
||||||
|
return photo;
|
||||||
|
}
|
||||||
|
return "https://ui-avatars.com/api/?name=" + name.replace(" ", "+") + "&background=random";
|
||||||
|
}
|
||||||
|
|
||||||
private static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) {
|
private static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) {
|
||||||
return new TaskHistoryItemOutput(
|
return new TaskHistoryItemOutput(
|
||||||
taskHistoryItem.getId(),
|
taskHistoryItem.getId(),
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ public record CreateTaskOutput(
|
||||||
TaskStatus status,
|
TaskStatus status,
|
||||||
ProjectOutput project,
|
ProjectOutput project,
|
||||||
CategoryOutput category,
|
CategoryOutput category,
|
||||||
|
UserOutput user,
|
||||||
LocalDateTime finishedAt,
|
LocalDateTime finishedAt,
|
||||||
List<TaskHistoryItemOutput> history) {
|
List<TaskHistoryItemOutput> history) {
|
||||||
|
|
||||||
public record ProjectOutput(Long id, String name) {}
|
public record ProjectOutput(Long id, String name) {}
|
||||||
public record CategoryOutput(Long id, String name, String color, String icon) {}
|
public record CategoryOutput(Long id, String name, String color, String icon) {}
|
||||||
|
public record UserOutput(Long id, String name, String email, String photo) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package net.beetechgroup.beetask.usecase.task.listall;
|
package net.beetechgroup.beetask.usecase.task.listall;
|
||||||
|
|
||||||
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.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;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
@ -11,14 +12,40 @@ public class ListAllTasksUseCase {
|
||||||
private static final Logger LOGGER = Logger.getLogger(ListAllTasksUseCase.class);
|
private static final Logger LOGGER = Logger.getLogger(ListAllTasksUseCase.class);
|
||||||
|
|
||||||
private final TaskRepository taskRepository;
|
private final TaskRepository taskRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public ListAllTasksUseCase(TaskRepository taskRepository) {
|
public ListAllTasksUseCase(TaskRepository taskRepository, UserRepository userRepository) {
|
||||||
this.taskRepository = taskRepository;
|
this.taskRepository = taskRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<CreateTaskOutput> execute() {
|
public List<CreateTaskOutput> execute(ListTasksInput input) {
|
||||||
List<CreateTaskOutput> tasks = taskRepository.findAllTasks().stream().map(CreateTaskMapper::toCreateTaskOutput).toList();
|
if (input.organizationId() == null) {
|
||||||
LOGGER.infof("Loaded %d tasks from repository", tasks.size());
|
throw new IllegalArgumentException("organizationId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = userRepository.findByEmail(input.userEmail())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Authenticated user was not found"));
|
||||||
|
|
||||||
|
boolean belongsToOrganization = userRepository.findUserOrganizations(user.getId()).stream()
|
||||||
|
.anyMatch(membership -> membership.getOrganization().getId().equals(input.organizationId()));
|
||||||
|
|
||||||
|
if (!belongsToOrganization) {
|
||||||
|
throw new SecurityException("Você não tem permissão para listar tarefas desta organização.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CreateTaskOutput> tasks = taskRepository.findAccessibleTasksFiltered(
|
||||||
|
input.userEmail(),
|
||||||
|
input.organizationId(),
|
||||||
|
input.text(),
|
||||||
|
input.projectIds(),
|
||||||
|
input.statuses(),
|
||||||
|
input.categoryIds(),
|
||||||
|
input.userIds())
|
||||||
|
.stream()
|
||||||
|
.map(CreateTaskMapper::toCreateTaskOutput)
|
||||||
|
.toList();
|
||||||
|
LOGGER.infof("Loaded %d accessible tasks for user %s in organization %d", tasks.size(), input.userEmail(), input.organizationId());
|
||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package net.beetechgroup.beetask.usecase.task.listall;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||||
|
|
||||||
|
public record ListTasksInput(
|
||||||
|
String userEmail,
|
||||||
|
Long organizationId,
|
||||||
|
String text,
|
||||||
|
List<Long> projectIds,
|
||||||
|
List<TaskStatus> statuses,
|
||||||
|
List<Long> categoryIds,
|
||||||
|
List<Long> userIds) {}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package net.beetechgroup.beetask.usecase.task.stop;
|
package net.beetechgroup.beetask.usecase.task.stop;
|
||||||
|
|
||||||
public record StopTaskInput(Long id) {
|
public record StopTaskInput(Long id, String userEmail) {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,15 @@ public class StopTaskUseCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateTaskOutput execute(StopTaskInput input) {
|
public CreateTaskOutput execute(StopTaskInput input) {
|
||||||
LOGGER.infof("Stopping task %d", input.id());
|
LOGGER.infof("Stopping task %d for user %s", input.id(), input.userEmail());
|
||||||
Task task = taskRepository.findTaskById(input.id());
|
Task task = taskRepository.findTaskById(input.id());
|
||||||
|
|
||||||
|
if (task.getUser() != null && !task.getUser().getEmail().equals(input.userEmail())) {
|
||||||
|
LOGGER.warnf("User %s attempted to stop task %d owned by %s",
|
||||||
|
input.userEmail(), input.id(), task.getUser().getEmail());
|
||||||
|
throw new SecurityException("Você não tem permissão para parar esta tarefa.");
|
||||||
|
}
|
||||||
|
|
||||||
task.stop();
|
task.stop();
|
||||||
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
||||||
LOGGER.infof("Task %d stopped successfully", input.id());
|
LOGGER.infof("Task %d stopped successfully", input.id());
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,15 @@ import { MultiSelectChips } from './MultiSelectChips'
|
||||||
import { CategoryIcon } from './CategoryIcon'
|
import { CategoryIcon } from './CategoryIcon'
|
||||||
import { MultiSelectDropdown } from './MultiSelectDropdown'
|
import { MultiSelectDropdown } from './MultiSelectDropdown'
|
||||||
import type { TaskFilters } from './taskFilters'
|
import type { TaskFilters } from './taskFilters'
|
||||||
|
import type { TaskAssignee } from '../types/task'
|
||||||
|
import { UserAvatar } from './UserAvatar'
|
||||||
|
|
||||||
interface TaskFilterBarProps {
|
interface TaskFilterBarProps {
|
||||||
filters: TaskFilters
|
filters: TaskFilters
|
||||||
onFiltersChange: (filters: TaskFilters) => void
|
onFiltersChange: (filters: TaskFilters) => void
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
|
assignees: TaskAssignee[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
}
|
}
|
||||||
|
|
@ -21,6 +24,7 @@ export function TaskFilterBar({
|
||||||
onFiltersChange,
|
onFiltersChange,
|
||||||
projects,
|
projects,
|
||||||
categories,
|
categories,
|
||||||
|
assignees,
|
||||||
isLoading,
|
isLoading,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: TaskFilterBarProps) {
|
}: TaskFilterBarProps) {
|
||||||
|
|
@ -81,6 +85,22 @@ export function TaskFilterBar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{assignees.length > 0 && (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<MultiSelectDropdown
|
||||||
|
label="Funcionario"
|
||||||
|
allLabel="Todos os funcionarios"
|
||||||
|
options={assignees.map((assignee) => ({
|
||||||
|
value: assignee.id,
|
||||||
|
label: assignee.name,
|
||||||
|
icon: <UserAvatar name={assignee.name} photo={assignee.photo} size="sm" />,
|
||||||
|
}))}
|
||||||
|
selected={filters.userIds}
|
||||||
|
onChange={(userIds) => update({ userIds })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Filter size={16} className="shrink-0 text-text-muted" />
|
<Filter size={16} className="shrink-0 text-text-muted" />
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type { TaskResponse } from '../types/task'
|
||||||
interface TaskTimerProps {
|
interface TaskTimerProps {
|
||||||
task: TaskResponse
|
task: TaskResponse
|
||||||
onUpdate: () => void
|
onUpdate: () => void
|
||||||
|
canControl?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds: number) {
|
function formatDuration(seconds: number) {
|
||||||
|
|
@ -23,7 +24,7 @@ function formatDuration(seconds: number) {
|
||||||
].join(':')
|
].join(':')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
|
export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps) {
|
||||||
const [isActionLoading, setIsActionLoading] = useState(false)
|
const [isActionLoading, setIsActionLoading] = useState(false)
|
||||||
|
|
||||||
const history = task.history ?? []
|
const history = task.history ?? []
|
||||||
|
|
@ -127,6 +128,7 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canControl && (
|
||||||
<div className="ml-auto pr-1">
|
<div className="ml-auto pr-1">
|
||||||
{runningItem ? (
|
{runningItem ? (
|
||||||
<button
|
<button
|
||||||
|
|
@ -148,6 +150,7 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
frontend/src/components/UserAvatar.tsx
Normal file
47
frontend/src/components/UserAvatar.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { cn } from '../lib/utils'
|
||||||
|
|
||||||
|
interface UserAvatarProps {
|
||||||
|
name: string
|
||||||
|
photo?: string | null
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-8 w-8 rounded-xl text-xs',
|
||||||
|
md: 'h-10 w-10 rounded-2xl text-sm',
|
||||||
|
lg: 'h-14 w-14 rounded-2xl text-xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAvatar({ name, photo, size = 'md', className }: UserAvatarProps) {
|
||||||
|
const initials = name
|
||||||
|
.split(' ')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
|
if (photo) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={photo}
|
||||||
|
alt={name}
|
||||||
|
title={name}
|
||||||
|
className={cn('shrink-0 object-cover', sizeClasses[size], className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={name}
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center justify-center bg-brand/10 font-black text-brand',
|
||||||
|
sizeClasses[size],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ export interface TaskFilters {
|
||||||
statuses: TaskStatus[]
|
statuses: TaskStatus[]
|
||||||
categoryIds: number[]
|
categoryIds: number[]
|
||||||
projectIds: number[]
|
projectIds: number[]
|
||||||
|
userIds: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_TASK_FILTERS: TaskFilters = {
|
export const DEFAULT_TASK_FILTERS: TaskFilters = {
|
||||||
|
|
@ -12,4 +13,5 @@ export const DEFAULT_TASK_FILTERS: TaskFilters = {
|
||||||
statuses: [],
|
statuses: [],
|
||||||
categoryIds: [],
|
categoryIds: [],
|
||||||
projectIds: [],
|
projectIds: [],
|
||||||
|
userIds: [],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
List,
|
List,
|
||||||
|
type LucideIcon,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Plus,
|
Plus,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
|
@ -13,45 +14,78 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { taskService } from '../services/taskService'
|
import { taskService } from '../services/taskService'
|
||||||
import { projectService } from '../services/projectService'
|
|
||||||
import type { Project } from '../services/projectService'
|
import type { Project } from '../services/projectService'
|
||||||
import { categoryService } from '../services/categoryService'
|
import { categoryService } from '../services/categoryService'
|
||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import type { TaskResponse, TaskStatus } from '../types/task'
|
import type { TaskAssignee, TaskResponse, TaskStatus } from '../types/task'
|
||||||
import { TaskTimer } from '../components/TaskTimer'
|
import { TaskTimer } from '../components/TaskTimer'
|
||||||
import { TaskFilterBar } from '../components/TaskFilterBar'
|
import { TaskFilterBar } from '../components/TaskFilterBar'
|
||||||
import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
|
import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
|
||||||
import { CategoryBadge } from '../components/CategoryBadge'
|
import { CategoryBadge } from '../components/CategoryBadge'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
const COLUMNS: { id: TaskStatus; label: string; icon: any; color: string }[] = [
|
const COLUMNS: { id: TaskStatus; label: string; icon: LucideIcon; color: string }[] = [
|
||||||
{ id: 'NOT_STARTED', label: 'Pendente', icon: AlertCircle, color: 'text-text-muted' },
|
{ id: 'NOT_STARTED', label: 'Pendente', icon: AlertCircle, color: 'text-text-muted' },
|
||||||
{ id: 'IN_PROGRESS', label: 'Em Andamento', icon: Clock, color: 'text-warning' },
|
{ id: 'IN_PROGRESS', label: 'Em Andamento', icon: Clock, color: 'text-warning' },
|
||||||
{ id: 'COMPLETED', label: 'Concluída', icon: CheckCircle2, color: 'text-success' },
|
{ id: 'COMPLETED', label: 'Concluída', icon: CheckCircle2, color: 'text-success' },
|
||||||
{ id: 'CANCELED', label: 'Cancelada', icon: XCircle, color: 'text-danger' },
|
{ id: 'CANCELED', label: 'Cancelada', icon: XCircle, color: 'text-danger' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function mergeProjects(existing: Project[], tasks: TaskResponse[]) {
|
||||||
|
const byId = new Map(existing.map((project) => [project.id, project]))
|
||||||
|
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (task.project) {
|
||||||
|
byId.set(task.project.id, { id: task.project.id, name: task.project.name })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAssignees(tasks: TaskResponse[]) {
|
||||||
|
const byId = new Map<number, TaskAssignee>()
|
||||||
|
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (task.user) {
|
||||||
|
byId.set(task.user.id, task.user)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
export function TaskBoardPage() {
|
export function TaskBoardPage() {
|
||||||
const [tasks, setTasks] = useState<TaskResponse[]>([])
|
const [tasks, setTasks] = useState<TaskResponse[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [assignees, setAssignees] = useState<TaskAssignee[]>([])
|
||||||
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
|
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { activeOrg } = useAuth()
|
const { activeOrg, user } = useAuth()
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const loadTasks = async (currentFilters: TaskFilters, silent = false) => {
|
const loadTasks = async (currentFilters: TaskFilters, silent = false) => {
|
||||||
|
if (!activeOrg) return
|
||||||
if (!silent) setIsLoading(true)
|
if (!silent) setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await taskService.getMyTasks({
|
const response = await taskService.getTasks({
|
||||||
|
organizationId: activeOrg.id,
|
||||||
text: currentFilters.searchQuery || undefined,
|
text: currentFilters.searchQuery || undefined,
|
||||||
projectIds: currentFilters.projectIds.length > 0 ? currentFilters.projectIds : undefined,
|
projectIds: currentFilters.projectIds.length > 0 ? currentFilters.projectIds : undefined,
|
||||||
statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined,
|
statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined,
|
||||||
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
|
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
|
||||||
|
userIds: currentFilters.userIds.length > 0 ? currentFilters.userIds : undefined,
|
||||||
})
|
})
|
||||||
setTasks(response)
|
setTasks(response)
|
||||||
|
setProjects((current) => mergeProjects(current, response))
|
||||||
|
setAssignees((current) => {
|
||||||
|
const byId = new Map(current.map((assignee) => [assignee.id, assignee]))
|
||||||
|
extractAssignees(response).forEach((assignee) => byId.set(assignee.id, assignee))
|
||||||
|
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao carregar tarefas', error)
|
console.error('Erro ao carregar tarefas', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -60,12 +94,14 @@ export function TaskBoardPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTasks(filters)
|
if (!activeOrg) return
|
||||||
if (activeOrg) {
|
setFilters(DEFAULT_TASK_FILTERS)
|
||||||
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {})
|
setProjects([])
|
||||||
|
setAssignees([])
|
||||||
|
setCategories([])
|
||||||
|
void loadTasks(DEFAULT_TASK_FILTERS)
|
||||||
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
|
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
|
||||||
}
|
}, [activeOrg])
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleFiltersChange = (newFilters: TaskFilters) => {
|
const handleFiltersChange = (newFilters: TaskFilters) => {
|
||||||
setFilters(newFilters)
|
setFilters(newFilters)
|
||||||
|
|
@ -145,6 +181,7 @@ export function TaskBoardPage() {
|
||||||
onFiltersChange={handleFiltersChange}
|
onFiltersChange={handleFiltersChange}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
assignees={assignees}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onRefresh={() => void loadTasks(filters)}
|
onRefresh={() => void loadTasks(filters)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -180,7 +217,10 @@ export function TaskBoardPage() {
|
||||||
: 'bg-surface/50',
|
: 'bg-surface/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tasksByStatus(column.id).map((task, index) => (
|
{tasksByStatus(column.id).map((task, index) => {
|
||||||
|
const canControlTimer = Boolean(user?.email && task.user?.email === user.email)
|
||||||
|
|
||||||
|
return (
|
||||||
<Draggable
|
<Draggable
|
||||||
key={task.id}
|
key={task.id}
|
||||||
draggableId={task.id.toString()}
|
draggableId={task.id.toString()}
|
||||||
|
|
@ -211,7 +251,11 @@ export function TaskBoardPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
<div className="mt-4 space-y-4">
|
||||||
<TaskTimer task={task} onUpdate={() => loadTasks(filters, true)} />
|
<TaskTimer
|
||||||
|
task={task}
|
||||||
|
canControl={canControlTimer}
|
||||||
|
onUpdate={() => loadTasks(filters, true)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -229,7 +273,8 @@ export function TaskBoardPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Draggable>
|
</Draggable>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{provided.placeholder}
|
{provided.placeholder}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,16 @@ import { useEffect, useRef, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import { taskService } from '../services/taskService'
|
import { taskService } from '../services/taskService'
|
||||||
import { projectService } from '../services/projectService'
|
|
||||||
import type { Project } from '../services/projectService'
|
import type { Project } from '../services/projectService'
|
||||||
import { categoryService } from '../services/categoryService'
|
import { categoryService } from '../services/categoryService'
|
||||||
import type { Category } from '../types/category'
|
import type { Category } from '../types/category'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import type { TaskResponse } from '../types/task'
|
import type { TaskAssignee, TaskResponse } from '../types/task'
|
||||||
import { TaskTimer } from '../components/TaskTimer'
|
import { TaskTimer } from '../components/TaskTimer'
|
||||||
import { TaskFilterBar } from '../components/TaskFilterBar'
|
import { TaskFilterBar } from '../components/TaskFilterBar'
|
||||||
import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
|
import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
|
||||||
import { CategoryBadge } from '../components/CategoryBadge'
|
import { CategoryBadge } from '../components/CategoryBadge'
|
||||||
|
import { UserAvatar } from '../components/UserAvatar'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
function getStatusConfig(status: TaskResponse['status']) {
|
function getStatusConfig(status: TaskResponse['status']) {
|
||||||
|
|
@ -56,26 +56,60 @@ function getStatusConfig(status: TaskResponse['status']) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeProjects(existing: Project[], tasks: TaskResponse[]) {
|
||||||
|
const byId = new Map(existing.map((project) => [project.id, project]))
|
||||||
|
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (task.project) {
|
||||||
|
byId.set(task.project.id, { id: task.project.id, name: task.project.name })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAssignees(tasks: TaskResponse[]) {
|
||||||
|
const byId = new Map<number, TaskAssignee>()
|
||||||
|
|
||||||
|
tasks.forEach((task) => {
|
||||||
|
if (task.user) {
|
||||||
|
byId.set(task.user.id, task.user)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
export function TaskListPage() {
|
export function TaskListPage() {
|
||||||
const [tasks, setTasks] = useState<TaskResponse[]>([])
|
const [tasks, setTasks] = useState<TaskResponse[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
const [assignees, setAssignees] = useState<TaskAssignee[]>([])
|
||||||
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
|
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { activeOrg } = useAuth()
|
const { activeOrg, user } = useAuth()
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
const loadTasks = async (currentFilters: TaskFilters, silent = false) => {
|
const loadTasks = async (currentFilters: TaskFilters, silent = false) => {
|
||||||
|
if (!activeOrg) return
|
||||||
if (!silent) setIsLoading(true)
|
if (!silent) setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await taskService.getMyTasks({
|
const response = await taskService.getTasks({
|
||||||
|
organizationId: activeOrg.id,
|
||||||
text: currentFilters.searchQuery || undefined,
|
text: currentFilters.searchQuery || undefined,
|
||||||
projectIds: currentFilters.projectIds.length > 0 ? currentFilters.projectIds : undefined,
|
projectIds: currentFilters.projectIds.length > 0 ? currentFilters.projectIds : undefined,
|
||||||
statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined,
|
statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined,
|
||||||
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
|
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
|
||||||
|
userIds: currentFilters.userIds.length > 0 ? currentFilters.userIds : undefined,
|
||||||
})
|
})
|
||||||
setTasks(response)
|
setTasks(response)
|
||||||
|
setProjects((current) => mergeProjects(current, response))
|
||||||
|
setAssignees((current) => {
|
||||||
|
const byId = new Map(current.map((assignee) => [assignee.id, assignee]))
|
||||||
|
extractAssignees(response).forEach((assignee) => byId.set(assignee.id, assignee))
|
||||||
|
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao carregar tarefas', error)
|
console.error('Erro ao carregar tarefas', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -84,12 +118,14 @@ export function TaskListPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTasks(filters)
|
if (!activeOrg) return
|
||||||
if (activeOrg) {
|
setFilters(DEFAULT_TASK_FILTERS)
|
||||||
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {})
|
setProjects([])
|
||||||
|
setAssignees([])
|
||||||
|
setCategories([])
|
||||||
|
void loadTasks(DEFAULT_TASK_FILTERS)
|
||||||
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
|
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
|
||||||
}
|
}, [activeOrg])
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleFiltersChange = (newFilters: TaskFilters) => {
|
const handleFiltersChange = (newFilters: TaskFilters) => {
|
||||||
setFilters(newFilters)
|
setFilters(newFilters)
|
||||||
|
|
@ -139,6 +175,7 @@ export function TaskListPage() {
|
||||||
onFiltersChange={handleFiltersChange}
|
onFiltersChange={handleFiltersChange}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
assignees={assignees}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onRefresh={() => void loadTasks(filters)}
|
onRefresh={() => void loadTasks(filters)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -148,6 +185,7 @@ export function TaskListPage() {
|
||||||
{tasks.map((task, i) => {
|
{tasks.map((task, i) => {
|
||||||
const config = getStatusConfig(task.status)
|
const config = getStatusConfig(task.status)
|
||||||
const StatusIcon = config.icon
|
const StatusIcon = config.icon
|
||||||
|
const canControlTimer = Boolean(user?.email && task.user?.email === user.email)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.article
|
<motion.article
|
||||||
|
|
@ -184,9 +222,14 @@ export function TaskListPage() {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-4">
|
<div className="mt-6 flex flex-col gap-4">
|
||||||
<TaskTimer task={task} onUpdate={() => loadTasks(filters, true)} />
|
<TaskTimer
|
||||||
|
task={task}
|
||||||
|
canControl={canControlTimer}
|
||||||
|
onUpdate={() => loadTasks(filters, true)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 border-t border-border-soft/50 pt-4">
|
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border-soft/50 pt-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
|
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
|
||||||
<Tag size={14} className="text-brand" />
|
<Tag size={14} className="text-brand" />
|
||||||
{task.project?.name || 'Geral'}
|
{task.project?.name || 'Geral'}
|
||||||
|
|
@ -203,6 +246,13 @@ export function TaskListPage() {
|
||||||
{task.history?.length ?? 0} registros
|
{task.history?.length ?? 0} registros
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{task.user && (
|
||||||
|
<div className="flex items-center gap-2 text-xs font-bold text-text-muted">
|
||||||
|
<UserAvatar name={task.user.name} photo={task.user.photo} size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
import { apiFetch } from '../lib/api'
|
import { apiFetch } from '../lib/api'
|
||||||
import type { TaskResponse, TaskStatus } from '../types/task'
|
import type { TaskResponse, TaskStatus } from '../types/task'
|
||||||
|
|
||||||
export interface GetMyTasksFilters {
|
export interface GetTasksFilters {
|
||||||
|
organizationId: number
|
||||||
text?: string
|
text?: string
|
||||||
projectIds?: number[]
|
projectIds?: number[]
|
||||||
statuses?: TaskStatus[]
|
statuses?: TaskStatus[]
|
||||||
categoryIds?: number[]
|
categoryIds?: number[]
|
||||||
|
userIds?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const taskService = {
|
export const taskService = {
|
||||||
async getTasks(): Promise<TaskResponse[]> {
|
async getTasks(filters: GetTasksFilters): Promise<TaskResponse[]> {
|
||||||
return apiFetch<TaskResponse[]>('/tasks')
|
|
||||||
},
|
|
||||||
|
|
||||||
async getMyTasks(filters?: GetMyTasksFilters): Promise<TaskResponse[]> {
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
|
params.set('organizationId', String(filters.organizationId))
|
||||||
if (filters?.text) params.set('text', filters.text)
|
if (filters?.text) params.set('text', filters.text)
|
||||||
filters?.projectIds?.forEach((id) => params.append('projectId', String(id)))
|
filters?.projectIds?.forEach((id) => params.append('projectId', String(id)))
|
||||||
filters?.statuses?.forEach((s) => params.append('status', s))
|
filters?.statuses?.forEach((s) => params.append('status', s))
|
||||||
filters?.categoryIds?.forEach((id) => params.append('categoryId', String(id)))
|
filters?.categoryIds?.forEach((id) => params.append('categoryId', String(id)))
|
||||||
|
filters?.userIds?.forEach((id) => params.append('userId', String(id)))
|
||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
return apiFetch<TaskResponse[]>(`/tasks/mine${query ? `?${query}` : ''}`)
|
return apiFetch<TaskResponse[]>(`/tasks${query ? `?${query}` : ''}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
async createTask(task: {
|
async createTask(task: {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,13 @@ export type TaskCategory = {
|
||||||
icon: string
|
icon: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TaskAssignee = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
photo: string
|
||||||
|
}
|
||||||
|
|
||||||
export type TaskResponse = {
|
export type TaskResponse = {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -47,6 +54,7 @@ export type TaskResponse = {
|
||||||
status: TaskStatus
|
status: TaskStatus
|
||||||
project: TaskProject | null
|
project: TaskProject | null
|
||||||
category: TaskCategory | null
|
category: TaskCategory | null
|
||||||
|
user: TaskAssignee | null
|
||||||
finishedAt?: string
|
finishedAt?: string
|
||||||
history?: TaskHistoryItem[]
|
history?: TaskHistoryItem[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue