editar task

This commit is contained in:
Gabriel Santana de Menezes 2026-05-04 21:38:18 -04:00
parent 880d72d5d6
commit 4582663fc8
20 changed files with 324 additions and 20 deletions

View file

@ -4,10 +4,19 @@ import java.time.LocalDateTime;
import net.beetechgroup.beetask.entities.User;
public class TaskHistoryItem {
private Long id;
private LocalDateTime startAt;
private LocalDateTime endAt;
private User user;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public void setEndAt(LocalDateTime endAt) {
this.endAt = endAt;
}

View file

@ -10,6 +10,8 @@ import net.beetechgroup.beetask.usecase.task.listall.ListAllTasksUseCase;
import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase;
import net.beetechgroup.beetask.usecase.task.stop.StopTaskUseCase;
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskStatusUseCase;
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskUseCase;
import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase;
@ApplicationScoped
public class TaskUseCaseConfig {
@ -38,4 +40,14 @@ public class TaskUseCaseConfig {
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository) {
return new UpdateTaskStatusUseCase(taskRepository);
}
@Produces
public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository) {
return new UpdateTaskUseCase(taskRepository, projectRepository);
}
@Produces
public GetTaskUseCase getTaskUseCase(TaskRepository taskRepository) {
return new GetTaskUseCase(taskRepository);
}
}

View file

@ -6,6 +6,7 @@ import net.beetechgroup.beetask.frameworks.persistence.entities.TaskHistoryItemE
public class TaskHistoryItemMapper {
public static TaskHistoryItemEntity toEntity(TaskHistoryItem item) {
TaskHistoryItemEntity entity = new TaskHistoryItemEntity();
entity.setId(item.getId());
entity.setStartAt(item.getStartAt());
entity.setEndAt(item.getEndAt());
if (item.getUser() != null) {
@ -18,6 +19,7 @@ public class TaskHistoryItemMapper {
if (entity == null) return null;
TaskHistoryItem item = new TaskHistoryItem();
item.setId(entity.getId());
item.setStartAt(entity.getStartAt());
item.setEndAt(entity.getEndAt());
if (entity.getUser() != null) {

View file

@ -1,10 +1,13 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import java.util.List;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public record CreateTaskRequest(
String title,
String description,
TaskStatus status,
Long projectId) {
Long projectId,
List<TaskHistoryItemResponse> history) {
}

View file

@ -16,6 +16,7 @@ public record CreateTaskResponse(
}
record TaskHistoryItemResponse(
Long id,
LocalDateTime startAt,
LocalDateTime endAt) {
}

View file

@ -27,6 +27,8 @@ import net.beetechgroup.beetask.usecase.task.listall.ListAllTasksUseCase;
import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase;
import net.beetechgroup.beetask.usecase.task.stop.StopTaskUseCase;
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskStatusUseCase;
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskUseCase;
import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase;
@Path("/tasks")
@Produces(MediaType.APPLICATION_JSON)
@ -49,6 +51,12 @@ public class TaskController {
@Inject
UpdateTaskStatusUseCase updateTaskStatusUseCase;
@Inject
UpdateTaskUseCase updateTaskUseCase;
@Inject
GetTaskUseCase getTaskUseCase;
@Inject
SecurityIdentity securityIdentity;
@ -61,6 +69,23 @@ public class TaskController {
return TaskControllerMapper.toCreateTaskResponse(output);
}
@PUT
@Path("/{id}")
@Operation(summary = "Update a task", description = "Updates an existing task in the system")
@APIResponse(responseCode = "200", description = "Task updated successfully")
public CreateTaskResponse updateTask(@PathParam("id") Long id, CreateTaskRequest request) {
CreateTaskOutput output = updateTaskUseCase.execute(TaskControllerMapper.toUpdateTaskInput(id, request));
return TaskControllerMapper.toCreateTaskResponse(output);
}
@GET
@Path("/{id}")
@Operation(summary = "Get a task", description = "Gets a specific task by ID")
@APIResponse(responseCode = "200", description = "Task retrieved successfully")
public CreateTaskResponse getTask(@PathParam("id") Long id) {
return TaskControllerMapper.toCreateTaskResponse(getTaskUseCase.execute(id));
}
@PUT
@Path("/{id}/start")
@Operation(summary = "Start a task", description = "Starts a task in the system")

View file

@ -4,6 +4,8 @@ import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
import net.beetechgroup.beetask.usecase.task.start.StartTaskInput;
import net.beetechgroup.beetask.usecase.task.stop.StopTaskInput;
import net.beetechgroup.beetask.usecase.task.update.TaskHistoryItemInput;
import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput;
public class TaskControllerMapper {
@ -24,7 +26,7 @@ public class TaskControllerMapper {
output.status(),
output.projectName(),
output.finishedAt(),
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList()
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt())).toList()
);
}
@ -35,4 +37,17 @@ public class TaskControllerMapper {
public static StopTaskInput toStopTaskInput(Long id) {
return new StopTaskInput(id);
}
public static UpdateTaskInput toUpdateTaskInput(Long id, CreateTaskRequest request) {
return new UpdateTaskInput(
id,
request.title(),
request.description(),
request.status(),
request.projectId(),
request.history() != null ? request.history().stream()
.map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt()))
.toList() : null
);
}
}

View file

@ -17,6 +17,7 @@ public class CreateTaskMapper {
private static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) {
return new TaskHistoryItemOutput(
taskHistoryItem.getId(),
taskHistoryItem.getStartAt(),
taskHistoryItem.getEndAt());
}

View file

@ -3,6 +3,7 @@ package net.beetechgroup.beetask.usecase.task.create;
import java.time.LocalDateTime;
public record TaskHistoryItemOutput(
Long id,
LocalDateTime startAt,
LocalDateTime endAt) {
}

View file

@ -0,0 +1,18 @@
package net.beetechgroup.beetask.usecase.task.get;
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
public class GetTaskUseCase {
private final TaskRepository taskRepository;
public GetTaskUseCase(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public CreateTaskOutput execute(Long id) {
//TODO deve validar se o usuario logado é o dono da task
return CreateTaskMapper.toCreateTaskOutput(taskRepository.findTaskById(id));
}
}

View file

@ -0,0 +1,9 @@
package net.beetechgroup.beetask.usecase.task.update;
import java.time.LocalDateTime;
public record TaskHistoryItemInput(
Long id,
LocalDateTime startAt,
LocalDateTime endAt) {
}

View file

@ -0,0 +1,13 @@
package net.beetechgroup.beetask.usecase.task.update;
import java.util.List;
import net.beetechgroup.beetask.entities.task.TaskStatus;
public record UpdateTaskInput(
Long id,
String title,
String description,
TaskStatus status,
Long projectId,
List<TaskHistoryItemInput> history) {
}

View file

@ -0,0 +1,68 @@
package net.beetechgroup.beetask.usecase.task.update;
import java.util.List;
import net.beetechgroup.beetask.entities.Project;
import net.beetechgroup.beetask.entities.task.Task;
import net.beetechgroup.beetask.entities.task.TaskHistoryItem;
import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper;
import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput;
public class UpdateTaskUseCase {
private final TaskRepository taskRepository;
private final ProjectRepository projectRepository;
public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository) {
this.taskRepository = taskRepository;
this.projectRepository = projectRepository;
}
public CreateTaskOutput execute(UpdateTaskInput input) {
//TODO deve validar se o usuario logado é o dono da task
Task task = taskRepository.findTaskById(input.id());
task.setTitle(input.title());
task.setDescription(input.description());
task.setStatus(input.status());
if (input.projectId() != null) {
Project project = projectRepository.findProjectById(input.projectId())
.orElseThrow(() -> new IllegalArgumentException("Projeto não encontrado com ID: " + input.projectId()));
task.setProject(project);
} else {
task.setProject(null);
}
if (input.history() != null) {
List<TaskHistoryItem> updatedHistory = input.history().stream().map(hInput -> {
if (hInput.id() != null) {
return task.getHistory().stream()
.filter(existing -> existing.getId().equals(hInput.id()))
.findFirst()
.map(existing -> {
existing.setStartAt(hInput.startAt());
existing.setEndAt(hInput.endAt());
return existing;
})
.orElseGet(() -> {
TaskHistoryItem newItem = new TaskHistoryItem();
newItem.setStartAt(hInput.startAt());
newItem.setEndAt(hInput.endAt());
return newItem;
});
} else {
TaskHistoryItem newItem = new TaskHistoryItem();
newItem.setStartAt(hInput.startAt());
newItem.setEndAt(hInput.endAt());
return newItem;
}
}).toList();
task.getHistory().clear();
task.getHistory().addAll(updatedHistory);
}
return CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task));
}
}

View file

@ -71,7 +71,8 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
}
}, [runningItem, accumulatedSeconds])
async function handleStart() {
async function handleStart(e: React.MouseEvent) {
e.stopPropagation()
setIsActionLoading(true)
try {
await taskService.startTask(task.id)
@ -83,7 +84,8 @@ export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
}
}
async function handleStop() {
async function handleStop(e: React.MouseEvent) {
e.stopPropagation()
setIsActionLoading(true)
try {
await taskService.stopTask(task.id)

View file

@ -2,30 +2,36 @@ import { motion } from "framer-motion";
import {
ArrowLeft,
CheckCircle2,
Clock,
FileText,
Layout,
PlusCircle,
Send,
XCircle,
} from "lucide-react";
import { useState } from "react";
import type { ChangeEvent, FormEvent } from "react";
import { Link } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import { taskService } from "../services/taskService";
import { cn } from "../lib/utils";
import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from "../types/task";
import type { CreateTaskPayload, TaskResponse } from "../types/task";
import type { CreateTaskPayload, TaskResponse, TaskHistoryItem } from "../types/task";
import { useAuth } from "../contexts/AuthContext";
import { useEffect } from "react";
import { projectService, type Project } from "../services/projectService";
type FormState = Omit<CreateTaskPayload, 'projectId'> & { projectId: string };
type FormState = Omit<CreateTaskPayload, 'projectId'> & {
projectId: string;
history: TaskHistoryItem[];
};
const initialFormState: FormState = {
title: "",
description: "",
status: "NOT_STARTED",
projectId: "",
history: [],
};
export function CreateTaskPage() {
@ -35,6 +41,30 @@ export function CreateTaskPage() {
const [createdTask, setCreatedTask] = useState<TaskResponse | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const { activeOrg } = useAuth();
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
useEffect(() => {
if (isEdit) {
async function loadTask() {
try {
const data = await taskService.getTask(Number(id));
setForm({
title: data.title,
description: data.description || "",
status: data.status,
projectId: data.projectId?.toString() || "",
history: data.history || [],
});
} catch (error) {
console.error("Erro ao carregar tarefa:", error);
setErrorMessage("Não foi possível carregar os dados da tarefa.");
}
}
loadTask();
}
}, [id, isEdit]);
useEffect(() => {
async function loadProjects() {
@ -67,17 +97,22 @@ export function CreateTaskPage() {
setIsSubmitting(true);
try {
const payload: CreateTaskPayload = {
const payload = {
title: form.title.trim(),
description: form.description.trim(),
status: form.status,
projectId: form.projectId ? Number(form.projectId) : null,
history: form.history,
};
const response = await taskService.createTask(payload);
setCreatedTask(response);
setForm(initialFormState);
if (isEdit) {
await taskService.updateTask(Number(id), payload);
navigate("/");
} else {
const response = await taskService.createTask(payload);
setCreatedTask(response);
setForm(initialFormState);
}
} catch (error) {
setErrorMessage(
error instanceof Error
@ -168,10 +203,10 @@ export function CreateTaskPage() {
</div>
<div>
<h1 className="text-2xl font-black tracking-tight">
Nova Tarefa
{isEdit ? "Editar Tarefa" : "Nova Tarefa"}
</h1>
<p className="text-sm font-medium text-white/80">
Preencha os detalhes abaixo para começar.
{isEdit ? `Editando tarefa #${id}` : "Preencha os detalhes abaixo para começar."}
</p>
</div>
</div>
@ -254,6 +289,76 @@ export function CreateTaskPage() {
className="w-full rounded-2xl border border-border-soft bg-app-bg px-5 py-4 text-sm font-medium outline-none transition-all focus:border-brand focus:ring-4 focus:ring-brand/10"
/>
</label>
{/* History Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-bold text-text-main">
<Clock size={16} />
Histórico de Execução
</div>
<button
type="button"
onClick={() => {
setForm(prev => ({
...prev,
history: [...prev.history, { startAt: new Date().toISOString(), endAt: null }]
}))
}}
className="text-xs font-bold text-brand hover:underline"
>
+ Adicionar Registro Manual
</button>
</div>
<div className="space-y-3">
{form.history.map((item, index) => (
<div key={index} className="flex flex-wrap items-end gap-4 rounded-xl border border-border-soft bg-app-bg/50 p-4">
<div className="flex-1 space-y-1">
<label className="text-[10px] font-black uppercase text-text-muted">Início</label>
<input
type="datetime-local"
value={item.startAt ? item.startAt.slice(0, 16) : ""}
onChange={(e) => {
const newHistory = [...form.history];
newHistory[index] = { ...item, startAt: new Date(e.target.value).toISOString() };
setForm(prev => ({ ...prev, history: newHistory }));
}}
className="w-full rounded-lg border border-border-soft bg-surface px-3 py-2 text-xs outline-none focus:border-brand"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-[10px] font-black uppercase text-text-muted">Fim</label>
<input
type="datetime-local"
value={item.endAt ? item.endAt.slice(0, 16) : ""}
onChange={(e) => {
const newHistory = [...form.history];
newHistory[index] = { ...item, endAt: e.target.value ? new Date(e.target.value).toISOString() : null };
setForm(prev => ({ ...prev, history: newHistory }));
}}
className="w-full rounded-lg border border-border-soft bg-surface px-3 py-2 text-xs outline-none focus:border-brand"
/>
</div>
<button
type="button"
onClick={() => {
setForm(prev => ({
...prev,
history: prev.history.filter((_, i) => i !== index)
}))
}}
className="mb-1 rounded-lg p-2 text-danger hover:bg-danger-soft"
>
<XCircle size={16} />
</button>
</div>
))}
{form.history.length === 0 && (
<p className="py-4 text-center text-xs font-medium text-text-muted">Nenhum registro de execução encontrado.</p>
)}
</div>
</div>
</div>
{errorMessage && (
@ -282,11 +387,11 @@ export function CreateTaskPage() {
)}
>
{isSubmitting ? (
"Criando..."
isEdit ? "Salvando..." : "Criando..."
) : (
<>
<Send size={18} />
Criar Tarefa
{isEdit ? "Salvar Alterações" : "Criar Tarefa"}
</>
)}
</button>

View file

@ -12,7 +12,7 @@ import {
XCircle,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { taskService } from '../services/taskService'
import { cn } from '../lib/utils'
@ -50,6 +50,7 @@ import { TaskTimer } from '../components/TaskTimer'
export function TaskBoardPage() {
const [tasks, setTasks] = useState<TaskResponse[]>([])
const [isLoading, setIsLoading] = useState(true)
const navigate = useNavigate()
const loadTasks = async (silent = false) => {
if (!silent) setIsLoading(true)
@ -169,8 +170,9 @@ export function TaskBoardPage() {
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
onClick={() => navigate(`/edit/${task.id}`)}
className={cn(
'group rounded-2xl border border-border-soft bg-surface p-4 shadow-sm transition-all',
'group cursor-pointer rounded-2xl border border-border-soft bg-surface p-4 shadow-sm transition-all',
snapshot.isDragging ? 'shadow-2xl ring-2 ring-brand/20' : 'hover:border-brand/30',
)}
>

View file

@ -13,7 +13,7 @@ import {
XCircle,
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { taskService } from '../services/taskService'
import { cn } from '../lib/utils'
@ -55,6 +55,7 @@ export function TaskListPage() {
const [tasks, setTasks] = useState<TaskResponse[]>([])
const [isLoading, setIsLoading] = useState(true)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const navigate = useNavigate()
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<string | 'ALL'>('ALL')
@ -178,7 +179,8 @@ export function TaskListPage() {
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, delay: i * 0.05 }}
key={task.id}
className="group relative flex flex-col rounded-[2rem] border border-border-soft bg-surface p-6 shadow-sm transition-all hover:shadow-xl hover:shadow-brand/5"
onClick={() => navigate(`/edit/${task.id}`)}
className="group relative flex cursor-pointer flex-col rounded-[2rem] border border-border-soft bg-surface p-6 shadow-sm transition-all hover:shadow-xl hover:shadow-brand/5"
>
<div className="flex items-start justify-between gap-4">
<div

View file

@ -43,6 +43,10 @@ export const router = createBrowserRouter([
path: "new",
element: <CreateTaskPage />,
},
{
path: "edit/:id",
element: <CreateTaskPage />,
},
{
path: "board",
element: <TaskBoardPage />,

View file

@ -31,4 +31,15 @@ export const taskService = {
method: 'PUT',
})
},
async getTask(id: number): Promise<TaskResponse> {
return apiFetch<TaskResponse>(`/tasks/${id}`)
},
async updateTask(id: number, task: Partial<TaskResponse>): Promise<TaskResponse> {
return apiFetch<TaskResponse>(`/tasks/${id}`, {
method: 'PUT',
body: task,
})
},
}

View file

@ -22,6 +22,7 @@ export type CreateTaskPayload = {
}
export type TaskHistoryItem = {
id?: number
startAt: string
endAt: string | null
}