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
|
||||
public ListAllTasksUseCase listAllTasksUseCase(TaskRepository taskRepository) {
|
||||
return new ListAllTasksUseCase(taskRepository);
|
||||
public ListAllTasksUseCase listAllTasksUseCase(TaskRepository taskRepository, UserRepository userRepository) {
|
||||
return new ListAllTasksUseCase(taskRepository, userRepository);
|
||||
}
|
||||
|
||||
@Produces
|
||||
|
|
|
|||
|
|
@ -52,6 +52,51 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
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
|
||||
public List<Task> findTasksByUserFiltered(String email, String text, List<Long> projectIds,
|
||||
List<TaskStatus> statuses, List<Long> categoryIds) {
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ public record CreateTaskResponse(
|
|||
TaskStatus status,
|
||||
ProjectResponse project,
|
||||
CategoryResponse category,
|
||||
UserResponse user,
|
||||
LocalDateTime finishedAt,
|
||||
List<TaskHistoryItemResponse> history) {
|
||||
|
||||
public record ProjectResponse(Long id, String name) {}
|
||||
public record CategoryResponse(Long id, String name, String color, String icon) {}
|
||||
public record UserResponse(Long id, String name, String email, String photo) {}
|
||||
}
|
||||
|
||||
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")
|
||||
@APIResponse(responseCode = "200", description = "Task stopped successfully")
|
||||
public CreateTaskResponse stopTask(@PathParam("id") Long id) {
|
||||
LOGGER.infof("Stop requested for task %d", id);
|
||||
CreateTaskOutput output = stopTaskUseCase.execute(TaskControllerMapper.toStopTaskInput(id));
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
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);
|
||||
return TaskControllerMapper.toCreateTaskResponse(output);
|
||||
}
|
||||
|
|
@ -137,9 +138,11 @@ public class TaskController {
|
|||
@GET
|
||||
@Operation(summary = "List all tasks", description = "Lists all tasks in the system")
|
||||
@APIResponse(responseCode = "200", description = "Tasks listed successfully")
|
||||
public List<CreateTaskResponse> listTasks() {
|
||||
List<CreateTaskResponse> tasks = listAllTasksUseCase.execute().stream().map(TaskControllerMapper::toCreateTaskResponse).toList();
|
||||
LOGGER.infof("Listed %d tasks", tasks.size());
|
||||
public List<CreateTaskResponse> listTasks(@BeanParam ListTasksRequest request) {
|
||||
String email = securityIdentity.getPrincipal().getName();
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task;
|
|||
import java.util.Objects;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
|
||||
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.start.StartTaskInput;
|
||||
import net.beetechgroup.beetask.usecase.task.stop.StopTaskInput;
|
||||
|
|
@ -11,6 +12,18 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput;
|
|||
|
||||
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) {
|
||||
return new ListMyTasksInput(email, request.text, request.projectIds, request.statuses, request.categoryIds);
|
||||
}
|
||||
|
|
@ -45,6 +58,13 @@ public class TaskControllerMapper {
|
|||
output.category().color(),
|
||||
output.category().icon())
|
||||
: null,
|
||||
Objects.nonNull(output.user())
|
||||
? new CreateTaskResponse.UserResponse(
|
||||
output.user().id(),
|
||||
output.user().name(),
|
||||
output.user().email(),
|
||||
output.user().photo())
|
||||
: null,
|
||||
output.finishedAt(),
|
||||
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(
|
||||
taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList()
|
||||
|
|
@ -55,8 +75,8 @@ public class TaskControllerMapper {
|
|||
return new StartTaskInput(id, email);
|
||||
}
|
||||
|
||||
public static StopTaskInput toStopTaskInput(Long id) {
|
||||
return new StopTaskInput(id);
|
||||
public static StopTaskInput toStopTaskInput(Long id, String email) {
|
||||
return new StopTaskInput(id, email);
|
||||
}
|
||||
|
||||
public static UpdateTaskInput toUpdateTaskInput(Long id, CreateTaskRequest request, String userEmail) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ public interface TaskRepository {
|
|||
Task saveTask(Task task);
|
||||
Task findTaskById(Long id);
|
||||
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> findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end);
|
||||
List<Task> findTasksByUser(String email);
|
||||
|
|
|
|||
|
|
@ -21,10 +21,24 @@ public class CreateTaskMapper {
|
|||
task.getCategory().getColor(),
|
||||
task.getCategory().getIcon())
|
||||
: 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.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) {
|
||||
return new TaskHistoryItemOutput(
|
||||
taskHistoryItem.getId(),
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ public record CreateTaskOutput(
|
|||
TaskStatus status,
|
||||
ProjectOutput project,
|
||||
CategoryOutput category,
|
||||
UserOutput user,
|
||||
LocalDateTime finishedAt,
|
||||
List<TaskHistoryItemOutput> history) {
|
||||
|
||||
public record ProjectOutput(Long id, String name) {}
|
||||
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;
|
||||
|
||||
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.CreateTaskOutput;
|
||||
import org.jboss.logging.Logger;
|
||||
|
|
@ -11,14 +12,40 @@ public class ListAllTasksUseCase {
|
|||
private static final Logger LOGGER = Logger.getLogger(ListAllTasksUseCase.class);
|
||||
|
||||
private final TaskRepository taskRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public ListAllTasksUseCase(TaskRepository taskRepository) {
|
||||
public ListAllTasksUseCase(TaskRepository taskRepository, UserRepository userRepository) {
|
||||
this.taskRepository = taskRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
public List<CreateTaskOutput> execute() {
|
||||
List<CreateTaskOutput> tasks = taskRepository.findAllTasks().stream().map(CreateTaskMapper::toCreateTaskOutput).toList();
|
||||
LOGGER.infof("Loaded %d tasks from repository", tasks.size());
|
||||
public List<CreateTaskOutput> execute(ListTasksInput input) {
|
||||
if (input.organizationId() == null) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
public record StopTaskInput(Long id) {
|
||||
public record StopTaskInput(Long id, String userEmail) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,8 +16,15 @@ public class StopTaskUseCase {
|
|||
}
|
||||
|
||||
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());
|
||||
|
||||
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();
|
||||
CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
|
||||
LOGGER.infof("Task %d stopped successfully", input.id());
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ import { MultiSelectChips } from './MultiSelectChips'
|
|||
import { CategoryIcon } from './CategoryIcon'
|
||||
import { MultiSelectDropdown } from './MultiSelectDropdown'
|
||||
import type { TaskFilters } from './taskFilters'
|
||||
import type { TaskAssignee } from '../types/task'
|
||||
import { UserAvatar } from './UserAvatar'
|
||||
|
||||
interface TaskFilterBarProps {
|
||||
filters: TaskFilters
|
||||
onFiltersChange: (filters: TaskFilters) => void
|
||||
projects: Project[]
|
||||
categories: Category[]
|
||||
assignees: TaskAssignee[]
|
||||
isLoading?: boolean
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
|
@ -21,6 +24,7 @@ export function TaskFilterBar({
|
|||
onFiltersChange,
|
||||
projects,
|
||||
categories,
|
||||
assignees,
|
||||
isLoading,
|
||||
onRefresh,
|
||||
}: TaskFilterBarProps) {
|
||||
|
|
@ -81,6 +85,22 @@ export function TaskFilterBar({
|
|||
/>
|
||||
</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 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={16} className="shrink-0 text-text-muted" />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { TaskResponse } from '../types/task'
|
|||
interface TaskTimerProps {
|
||||
task: TaskResponse
|
||||
onUpdate: () => void
|
||||
canControl?: boolean
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
|
|
@ -23,7 +24,7 @@ function formatDuration(seconds: number) {
|
|||
].join(':')
|
||||
}
|
||||
|
||||
export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
|
||||
export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps) {
|
||||
const [isActionLoading, setIsActionLoading] = useState(false)
|
||||
|
||||
const history = task.history ?? []
|
||||
|
|
@ -127,8 +128,9 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto pr-1">
|
||||
{runningItem ? (
|
||||
{canControl && (
|
||||
<div className="ml-auto pr-1">
|
||||
{runningItem ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={isActionLoading}
|
||||
|
|
@ -137,7 +139,7 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
|
|||
>
|
||||
<Square size={16} fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isActionLoading}
|
||||
|
|
@ -146,8 +148,9 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
|
|||
>
|
||||
<Play size={16} fill="currentColor" className="ml-0.5" />
|
||||
</button>
|
||||
)}
|
||||
</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[]
|
||||
categoryIds: number[]
|
||||
projectIds: number[]
|
||||
userIds: number[]
|
||||
}
|
||||
|
||||
export const DEFAULT_TASK_FILTERS: TaskFilters = {
|
||||
|
|
@ -12,4 +13,5 @@ export const DEFAULT_TASK_FILTERS: TaskFilters = {
|
|||
statuses: [],
|
||||
categoryIds: [],
|
||||
projectIds: [],
|
||||
userIds: [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
CheckCircle2,
|
||||
Clock,
|
||||
List,
|
||||
type LucideIcon,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
XCircle,
|
||||
|
|
@ -13,45 +14,78 @@ import { useEffect, useRef, useState } from 'react'
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { taskService } from '../services/taskService'
|
||||
import { projectService } from '../services/projectService'
|
||||
import type { Project } from '../services/projectService'
|
||||
import { categoryService } from '../services/categoryService'
|
||||
import type { Category } from '../types/category'
|
||||
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 { TaskFilterBar } from '../components/TaskFilterBar'
|
||||
import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
|
||||
import { CategoryBadge } from '../components/CategoryBadge'
|
||||
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: 'IN_PROGRESS', label: 'Em Andamento', icon: Clock, color: 'text-warning' },
|
||||
{ id: 'COMPLETED', label: 'Concluída', icon: CheckCircle2, color: 'text-success' },
|
||||
{ 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() {
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [assignees, setAssignees] = useState<TaskAssignee[]>([])
|
||||
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
|
||||
const navigate = useNavigate()
|
||||
const { activeOrg } = useAuth()
|
||||
const { activeOrg, user } = useAuth()
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const loadTasks = async (currentFilters: TaskFilters, silent = false) => {
|
||||
if (!activeOrg) return
|
||||
if (!silent) setIsLoading(true)
|
||||
try {
|
||||
const response = await taskService.getMyTasks({
|
||||
const response = await taskService.getTasks({
|
||||
organizationId: activeOrg.id,
|
||||
text: currentFilters.searchQuery || undefined,
|
||||
projectIds: currentFilters.projectIds.length > 0 ? currentFilters.projectIds : undefined,
|
||||
statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined,
|
||||
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
|
||||
userIds: currentFilters.userIds.length > 0 ? currentFilters.userIds : undefined,
|
||||
})
|
||||
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) {
|
||||
console.error('Erro ao carregar tarefas', error)
|
||||
} finally {
|
||||
|
|
@ -60,12 +94,14 @@ export function TaskBoardPage() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadTasks(filters)
|
||||
if (activeOrg) {
|
||||
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {})
|
||||
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
if (!activeOrg) return
|
||||
setFilters(DEFAULT_TASK_FILTERS)
|
||||
setProjects([])
|
||||
setAssignees([])
|
||||
setCategories([])
|
||||
void loadTasks(DEFAULT_TASK_FILTERS)
|
||||
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
|
||||
}, [activeOrg])
|
||||
|
||||
const handleFiltersChange = (newFilters: TaskFilters) => {
|
||||
setFilters(newFilters)
|
||||
|
|
@ -145,6 +181,7 @@ export function TaskBoardPage() {
|
|||
onFiltersChange={handleFiltersChange}
|
||||
projects={projects}
|
||||
categories={categories}
|
||||
assignees={assignees}
|
||||
isLoading={isLoading}
|
||||
onRefresh={() => void loadTasks(filters)}
|
||||
/>
|
||||
|
|
@ -180,13 +217,16 @@ export function TaskBoardPage() {
|
|||
: 'bg-surface/50',
|
||||
)}
|
||||
>
|
||||
{tasksByStatus(column.id).map((task, index) => (
|
||||
<Draggable
|
||||
key={task.id}
|
||||
draggableId={task.id.toString()}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
{tasksByStatus(column.id).map((task, index) => {
|
||||
const canControlTimer = Boolean(user?.email && task.user?.email === user.email)
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={task.id}
|
||||
draggableId={task.id.toString()}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
|
|
@ -211,7 +251,11 @@ export function TaskBoardPage() {
|
|||
)}
|
||||
|
||||
<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 gap-2">
|
||||
|
|
@ -227,9 +271,10 @@ export function TaskBoardPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,16 +15,16 @@ import { useEffect, useRef, useState } from 'react'
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { taskService } from '../services/taskService'
|
||||
import { projectService } from '../services/projectService'
|
||||
import type { Project } from '../services/projectService'
|
||||
import { categoryService } from '../services/categoryService'
|
||||
import type { Category } from '../types/category'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { TaskResponse } from '../types/task'
|
||||
import type { TaskAssignee, TaskResponse } from '../types/task'
|
||||
import { TaskTimer } from '../components/TaskTimer'
|
||||
import { TaskFilterBar } from '../components/TaskFilterBar'
|
||||
import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
|
||||
import { CategoryBadge } from '../components/CategoryBadge'
|
||||
import { UserAvatar } from '../components/UserAvatar'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
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() {
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [assignees, setAssignees] = useState<TaskAssignee[]>([])
|
||||
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
|
||||
const navigate = useNavigate()
|
||||
const { activeOrg } = useAuth()
|
||||
const { activeOrg, user } = useAuth()
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const loadTasks = async (currentFilters: TaskFilters, silent = false) => {
|
||||
if (!activeOrg) return
|
||||
if (!silent) setIsLoading(true)
|
||||
try {
|
||||
const response = await taskService.getMyTasks({
|
||||
const response = await taskService.getTasks({
|
||||
organizationId: activeOrg.id,
|
||||
text: currentFilters.searchQuery || undefined,
|
||||
projectIds: currentFilters.projectIds.length > 0 ? currentFilters.projectIds : undefined,
|
||||
statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined,
|
||||
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
|
||||
userIds: currentFilters.userIds.length > 0 ? currentFilters.userIds : undefined,
|
||||
})
|
||||
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) {
|
||||
console.error('Erro ao carregar tarefas', error)
|
||||
} finally {
|
||||
|
|
@ -84,12 +118,14 @@ export function TaskListPage() {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadTasks(filters)
|
||||
if (activeOrg) {
|
||||
projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {})
|
||||
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
if (!activeOrg) return
|
||||
setFilters(DEFAULT_TASK_FILTERS)
|
||||
setProjects([])
|
||||
setAssignees([])
|
||||
setCategories([])
|
||||
void loadTasks(DEFAULT_TASK_FILTERS)
|
||||
categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {})
|
||||
}, [activeOrg])
|
||||
|
||||
const handleFiltersChange = (newFilters: TaskFilters) => {
|
||||
setFilters(newFilters)
|
||||
|
|
@ -139,6 +175,7 @@ export function TaskListPage() {
|
|||
onFiltersChange={handleFiltersChange}
|
||||
projects={projects}
|
||||
categories={categories}
|
||||
assignees={assignees}
|
||||
isLoading={isLoading}
|
||||
onRefresh={() => void loadTasks(filters)}
|
||||
/>
|
||||
|
|
@ -148,6 +185,7 @@ export function TaskListPage() {
|
|||
{tasks.map((task, i) => {
|
||||
const config = getStatusConfig(task.status)
|
||||
const StatusIcon = config.icon
|
||||
const canControlTimer = Boolean(user?.email && task.user?.email === user.email)
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
|
|
@ -184,24 +222,36 @@ export function TaskListPage() {
|
|||
</p>
|
||||
|
||||
<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 items-center gap-1.5 text-xs font-bold text-text-muted">
|
||||
<Tag size={14} className="text-brand" />
|
||||
{task.project?.name || 'Geral'}
|
||||
<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">
|
||||
<Tag size={14} className="text-brand" />
|
||||
{task.project?.name || 'Geral'}
|
||||
</div>
|
||||
{task.category && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-border-soft" />
|
||||
<CategoryBadge category={task.category} />
|
||||
</>
|
||||
)}
|
||||
<div className="h-4 w-px bg-border-soft" />
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
|
||||
<Calendar size={14} className="text-accent" />
|
||||
{task.history?.length ?? 0} registros
|
||||
</div>
|
||||
</div>
|
||||
{task.category && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-border-soft" />
|
||||
<CategoryBadge category={task.category} />
|
||||
</>
|
||||
|
||||
{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 className="h-4 w-px bg-border-soft" />
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold text-text-muted">
|
||||
<Calendar size={14} className="text-accent" />
|
||||
{task.history?.length ?? 0} registros
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
import { apiFetch } from '../lib/api'
|
||||
import type { TaskResponse, TaskStatus } from '../types/task'
|
||||
|
||||
export interface GetMyTasksFilters {
|
||||
export interface GetTasksFilters {
|
||||
organizationId: number
|
||||
text?: string
|
||||
projectIds?: number[]
|
||||
statuses?: TaskStatus[]
|
||||
categoryIds?: number[]
|
||||
userIds?: number[]
|
||||
}
|
||||
|
||||
export const taskService = {
|
||||
async getTasks(): Promise<TaskResponse[]> {
|
||||
return apiFetch<TaskResponse[]>('/tasks')
|
||||
},
|
||||
|
||||
async getMyTasks(filters?: GetMyTasksFilters): Promise<TaskResponse[]> {
|
||||
async getTasks(filters: GetTasksFilters): Promise<TaskResponse[]> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('organizationId', String(filters.organizationId))
|
||||
if (filters?.text) params.set('text', filters.text)
|
||||
filters?.projectIds?.forEach((id) => params.append('projectId', String(id)))
|
||||
filters?.statuses?.forEach((s) => params.append('status', s))
|
||||
filters?.categoryIds?.forEach((id) => params.append('categoryId', String(id)))
|
||||
filters?.userIds?.forEach((id) => params.append('userId', String(id)))
|
||||
const query = params.toString()
|
||||
return apiFetch<TaskResponse[]>(`/tasks/mine${query ? `?${query}` : ''}`)
|
||||
return apiFetch<TaskResponse[]>(`/tasks${query ? `?${query}` : ''}`)
|
||||
},
|
||||
|
||||
async createTask(task: {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,13 @@ export type TaskCategory = {
|
|||
icon: string
|
||||
}
|
||||
|
||||
export type TaskAssignee = {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
photo: string
|
||||
}
|
||||
|
||||
export type TaskResponse = {
|
||||
id: number
|
||||
title: string
|
||||
|
|
@ -47,6 +54,7 @@ export type TaskResponse = {
|
|||
status: TaskStatus
|
||||
project: TaskProject | null
|
||||
category: TaskCategory | null
|
||||
user: TaskAssignee | null
|
||||
finishedAt?: string
|
||||
history?: TaskHistoryItem[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue