listagem e board
This commit is contained in:
parent
3312f2276c
commit
251362852d
27 changed files with 1362 additions and 160 deletions
|
|
@ -1,6 +1,5 @@
|
|||
package net.beetechgroup.beetask.entities;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
|
@ -95,10 +94,5 @@ public class Task {
|
|||
current.setEndAt(LocalDateTime.now());
|
||||
}
|
||||
|
||||
public Duration getTotalTime() {
|
||||
return history.stream()
|
||||
.map(TaskHistoryItem::getDuration)
|
||||
.reduce(Duration.ZERO, Duration::plus);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package net.beetechgroup.beetask.entities;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public class TaskHistoryItem {
|
||||
|
|
@ -23,13 +22,6 @@ public class TaskHistoryItem {
|
|||
return endAt;
|
||||
}
|
||||
|
||||
public Duration getDuration() {
|
||||
if (endAt == null) {
|
||||
return Duration.between(startAt, LocalDateTime.now());
|
||||
}
|
||||
return Duration.between(startAt, endAt);
|
||||
}
|
||||
|
||||
public boolean isRunning() {
|
||||
return endAt == null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import jakarta.enterprise.context.ApplicationScoped;
|
|||
import jakarta.enterprise.inject.Produces;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
||||
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;
|
||||
|
||||
@ApplicationScoped
|
||||
public class TaskUseCaseConfig {
|
||||
|
|
@ -24,4 +26,14 @@ public class TaskUseCaseConfig {
|
|||
public StopTaskUseCase stopTaskUseCase(TaskRepository taskRepository) {
|
||||
return new StopTaskUseCase(taskRepository);
|
||||
}
|
||||
|
||||
@Produces
|
||||
public ListAllTasksUseCase listAllTasksUseCase(TaskRepository taskRepository) {
|
||||
return new ListAllTasksUseCase(taskRepository);
|
||||
}
|
||||
|
||||
@Produces
|
||||
public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository) {
|
||||
return new UpdateTaskStatusUseCase(taskRepository);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
package net.beetechgroup.beetask.frameworks.persistence.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.transaction.Transactional;
|
||||
import net.beetechgroup.beetask.entities.Task;
|
||||
import net.beetechgroup.beetask.frameworks.persistence.entities.TaskEntity;
|
||||
|
|
@ -11,29 +12,29 @@ import net.beetechgroup.beetask.usecase.exceptions.TaskNotFoundException;
|
|||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
|
||||
@ApplicationScoped
|
||||
public class TaskRepositoryImpl implements TaskRepository {
|
||||
|
||||
@Inject
|
||||
EntityManager em;
|
||||
public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<TaskEntity> {
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Task save(Task task) {
|
||||
public Task saveTask(Task task) {
|
||||
TaskEntity entity = TaskMapper.toEntity(task);
|
||||
if (entity.getId() == null) {
|
||||
em.persist(entity);
|
||||
persist(entity);
|
||||
} else {
|
||||
entity = em.merge(entity);
|
||||
entity = getEntityManager().merge(entity);
|
||||
}
|
||||
return TaskMapper.toDomain(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task findById(Long id) {
|
||||
TaskEntity entity = em.find(TaskEntity.class, id);
|
||||
if (entity == null) {
|
||||
throw new TaskNotFoundException("Task not found");
|
||||
}
|
||||
public Task findTaskById(Long id) {
|
||||
TaskEntity entity = find("id = ?1", id).firstResultOptional()
|
||||
.orElseThrow(() -> new TaskNotFoundException(id));
|
||||
return TaskMapper.toDomain(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Task> findAllTasks() {
|
||||
return findAll().list().stream().map(TaskMapper::toDomain).toList();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,22 +2,30 @@ package net.beetechgroup.beetask.interfaceadapters.controllers;
|
|||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.PATCH;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
import net.beetechgroup.beetask.entities.Task;
|
||||
import net.beetechgroup.beetask.entities.TaskStatus;
|
||||
import net.beetechgroup.beetask.interfaceadapters.dtos.TaskDTOMapper;
|
||||
import net.beetechgroup.beetask.interfaceadapters.dtos.TaskRequestDTO;
|
||||
import net.beetechgroup.beetask.interfaceadapters.dtos.TaskResponseDTO;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase;
|
||||
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;
|
||||
|
||||
@Path("/tasks")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
|
|
@ -34,6 +42,12 @@ public class TaskController {
|
|||
@Inject
|
||||
StopTaskUseCase stopTaskUseCase;
|
||||
|
||||
@Inject
|
||||
ListAllTasksUseCase listAllTasksUseCase;
|
||||
|
||||
@Inject
|
||||
UpdateTaskStatusUseCase updateTaskStatusUseCase;
|
||||
|
||||
@POST
|
||||
@Operation(summary = "Create a new task", description = "Creates a new task in the system")
|
||||
@APIResponse(responseCode = "201", description = "Task created successfully")
|
||||
|
|
@ -60,4 +74,20 @@ public class TaskController {
|
|||
return TaskDTOMapper.toTaskResponseDTO(task);
|
||||
}
|
||||
|
||||
@PATCH
|
||||
@Path("/{id}/status")
|
||||
@Operation(summary = "Update task status", description = "Updates the status of a task")
|
||||
@APIResponse(responseCode = "200", description = "Task status updated successfully")
|
||||
public TaskResponseDTO updateTaskStatus(@PathParam("id") Long id, TaskStatus status) {
|
||||
Task task = updateTaskStatusUseCase.execute(id, status);
|
||||
return TaskDTOMapper.toTaskResponseDTO(task);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Operation(summary = "List all tasks", description = "Lists all tasks in the system")
|
||||
@APIResponse(responseCode = "200", description = "Tasks listed successfully")
|
||||
public List<TaskResponseDTO> listTasks() {
|
||||
return listAllTasksUseCase.execute().stream().map(TaskDTOMapper::toTaskResponseDTO).toList();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.dtos;
|
||||
|
||||
import net.beetechgroup.beetask.entities.Task;
|
||||
import net.beetechgroup.beetask.entities.TaskHistoryItem;
|
||||
import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput;
|
||||
import net.beetechgroup.beetask.usecase.task.start.StartTaskInput;
|
||||
import net.beetechgroup.beetask.usecase.task.stop.StopTaskInput;
|
||||
|
|
@ -23,10 +24,14 @@ public class TaskDTOMapper {
|
|||
task.getDescription(),
|
||||
task.getStatus(),
|
||||
task.getProject(),
|
||||
task.getHistory()
|
||||
task.getHistory().stream().map(TaskDTOMapper::toTaskHistoryItemDTO).toList()
|
||||
);
|
||||
}
|
||||
|
||||
private static TaskHistoryItemDTO toTaskHistoryItemDTO(TaskHistoryItem taskHistoryItem) {
|
||||
return new TaskHistoryItemDTO(taskHistoryItem.getStartAt(), taskHistoryItem.getEndAt());
|
||||
}
|
||||
|
||||
public static StartTaskInput toStartTaskInput(Long id) {
|
||||
return new StartTaskInput(id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.dtos;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record TaskHistoryItemDTO(LocalDateTime startAt, LocalDateTime endAt) {
|
||||
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package net.beetechgroup.beetask.interfaceadapters.dtos;
|
|||
import net.beetechgroup.beetask.entities.TaskStatus;
|
||||
|
||||
public record TaskRequestDTO(
|
||||
Long id,
|
||||
String title,
|
||||
String description,
|
||||
TaskStatus status,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package net.beetechgroup.beetask.interfaceadapters.dtos;
|
|||
|
||||
import java.util.List;
|
||||
|
||||
import net.beetechgroup.beetask.entities.TaskHistoryItem;
|
||||
import net.beetechgroup.beetask.entities.TaskStatus;
|
||||
|
||||
public record TaskResponseDTO(
|
||||
|
|
@ -11,5 +10,5 @@ public record TaskResponseDTO(
|
|||
String description,
|
||||
TaskStatus status,
|
||||
String project,
|
||||
List<TaskHistoryItem> history) {
|
||||
List<TaskHistoryItemDTO> history) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package net.beetechgroup.beetask.usecase.exceptions;
|
||||
|
||||
public class TaskNotFoundException extends RuntimeException {
|
||||
public TaskNotFoundException(String message) {
|
||||
super(message);
|
||||
public TaskNotFoundException(Long id) {
|
||||
super("Task not found with id: " + id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package net.beetechgroup.beetask.usecase.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import net.beetechgroup.beetask.entities.Task;
|
||||
|
||||
public interface TaskRepository {
|
||||
Task save(Task task);
|
||||
Task findById(Long id);
|
||||
Task saveTask(Task task);
|
||||
Task findTaskById(Long id);
|
||||
List<Task> findAllTasks();
|
||||
}
|
||||
|
|
@ -16,6 +16,6 @@ public class CreateTaskUseCase {
|
|||
task.setDescription(input.description());
|
||||
task.setStatus(input.status());
|
||||
task.setProject(input.project());
|
||||
return taskRepository.save(task);
|
||||
return taskRepository.saveTask(task);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package net.beetechgroup.beetask.usecase.task.listall;
|
||||
|
||||
import net.beetechgroup.beetask.entities.Task;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class ListAllTasksUseCase {
|
||||
|
||||
private final TaskRepository taskRepository;
|
||||
|
||||
public ListAllTasksUseCase(TaskRepository taskRepository) {
|
||||
this.taskRepository = taskRepository;
|
||||
}
|
||||
|
||||
public List<Task> execute() {
|
||||
return taskRepository.findAllTasks();
|
||||
}
|
||||
}
|
||||
|
|
@ -12,8 +12,8 @@ public class StartTaskUseCase {
|
|||
}
|
||||
|
||||
public Task execute(StartTaskInput input) {
|
||||
Task task = taskRepository.findById(input.id());
|
||||
Task task = taskRepository.findTaskById(input.id());
|
||||
task.start();
|
||||
return taskRepository.save(task);
|
||||
return taskRepository.saveTask(task);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ public class StopTaskUseCase {
|
|||
}
|
||||
|
||||
public Task execute(StopTaskInput input) {
|
||||
Task task = taskRepository.findById(input.id());
|
||||
Task task = taskRepository.findTaskById(input.id());
|
||||
task.stop();
|
||||
return taskRepository.save(task);
|
||||
return taskRepository.saveTask(task);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
package net.beetechgroup.beetask.usecase.task.update;
|
||||
|
||||
import net.beetechgroup.beetask.entities.Task;
|
||||
import net.beetechgroup.beetask.entities.TaskStatus;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
|
||||
public class UpdateTaskStatusUseCase {
|
||||
|
||||
private final TaskRepository taskRepository;
|
||||
|
||||
public UpdateTaskStatusUseCase(TaskRepository taskRepository) {
|
||||
this.taskRepository = taskRepository;
|
||||
}
|
||||
|
||||
public Task execute(Long id, TaskStatus newStatus) {
|
||||
Task task = taskRepository.findTaskById(id);
|
||||
|
||||
// Simple status update
|
||||
// In a more complex scenario, we might want to trigger start() or stop() logic here
|
||||
task.setStatus(newStatus);
|
||||
|
||||
return taskRepository.saveTask(task);
|
||||
}
|
||||
}
|
||||
221
frontend/package-lock.json
generated
221
frontend/package-lock.json
generated
|
|
@ -8,10 +8,16 @@
|
|||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.9.4"
|
||||
"react-router-dom": "^7.9.4",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
|
@ -62,7 +68,6 @@
|
|||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
|
|
@ -224,6 +229,15 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
|
|
@ -272,6 +286,27 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
|
|
@ -410,6 +445,23 @@
|
|||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@hello-pangea/dnd": {
|
||||
"version": "18.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz",
|
||||
"integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.7",
|
||||
"css-box-model": "^1.2.1",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"redux": "^5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
|
|
@ -1091,7 +1143,6 @@
|
|||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
|
|
@ -1100,9 +1151,8 @@
|
|||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -1117,6 +1167,12 @@
|
|||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
|
||||
|
|
@ -1162,7 +1218,6 @@
|
|||
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
|
|
@ -1393,7 +1448,6 @@
|
|||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -1521,7 +1575,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
|
|
@ -1557,6 +1610,15 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
|
|
@ -1592,13 +1654,32 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-box-model": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
||||
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tiny-invariant": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -1682,7 +1763,6 @@
|
|||
"integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
|
|
@ -1962,6 +2042,33 @@
|
|||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.38.0",
|
||||
"motion-utils": "^12.36.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -2447,6 +2554,15 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
||||
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -2472,6 +2588,21 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
||||
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.36.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.36.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
||||
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
@ -2592,7 +2723,6 @@
|
|||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -2619,7 +2749,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -2656,12 +2785,17 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/raf-schd": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -2671,7 +2805,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
|
|
@ -2679,6 +2812,29 @@
|
|||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
|
||||
|
|
@ -2717,6 +2873,12 @@
|
|||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||
|
|
@ -2810,6 +2972,16 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
|
||||
|
|
@ -2829,6 +3001,12 @@
|
|||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
|
|
@ -2862,8 +3040,7 @@
|
|||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
|
|
@ -2884,7 +3061,6 @@
|
|||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -2965,12 +3141,20 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
|
|
@ -3095,7 +3279,6 @@
|
|||
"integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,16 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.9.4"
|
||||
"react-router-dom": "^7.9.4",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
|
|
|||
91
frontend/src/components/AppShell.tsx
Normal file
91
frontend/src/components/AppShell.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { Layout, ListTodo, PlusCircle, Columns } from 'lucide-react'
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const navLinkClassName = ({ isActive }: { isActive: boolean }) =>
|
||||
cn(
|
||||
'inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-brand text-white shadow-md shadow-brand/20'
|
||||
: 'text-text-muted hover:bg-surface-muted hover:text-text-main',
|
||||
)
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-app-bg font-sans text-text-main">
|
||||
{/* Top Navigation */}
|
||||
<header className="sticky top-0 z-50 border-b border-border-soft bg-surface/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4 lg:px-10">
|
||||
<div className="flex items-center gap-8">
|
||||
<NavLink to="/" className="flex items-center gap-3 group">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand text-white shadow-lg shadow-brand/20 transition-transform group-hover:scale-105">
|
||||
<Layout size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-lg font-black tracking-tight text-text-main">
|
||||
BeeTask
|
||||
</span>
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-text-muted">
|
||||
Workspace
|
||||
</span>
|
||||
</div>
|
||||
</NavLink>
|
||||
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
<NavLink to="/" end className={navLinkClassName}>
|
||||
<PlusCircle size={18} />
|
||||
Nova Tarefa
|
||||
</NavLink>
|
||||
<NavLink to="/board" className={navLinkClassName}>
|
||||
<Columns size={18} />
|
||||
Quadro
|
||||
</NavLink>
|
||||
<NavLink to="/tasks" className={navLinkClassName}>
|
||||
<ListTodo size={18} />
|
||||
Minhas Tarefas
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden h-8 w-px bg-border-soft md:block" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-accent-soft border border-accent/20 flex items-center justify-center text-accent font-bold text-xs">
|
||||
GM
|
||||
</div>
|
||||
<span className="hidden text-sm font-medium text-text-muted lg:block">
|
||||
Gabriel Menezes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 px-6 py-8 lg:px-10">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer / Mobile Nav */}
|
||||
<footer className="border-t border-border-soft bg-surface py-6 md:hidden">
|
||||
<nav className="flex justify-around px-4">
|
||||
<NavLink to="/" end className={navLinkClassName}>
|
||||
<PlusCircle size={18} />
|
||||
Criar
|
||||
</NavLink>
|
||||
<NavLink to="/board" className={navLinkClassName}>
|
||||
<Columns size={18} />
|
||||
Quadro
|
||||
</NavLink>
|
||||
<NavLink to="/tasks" className={navLinkClassName}>
|
||||
<ListTodo size={18} />
|
||||
Tarefas
|
||||
</NavLink>
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
135
frontend/src/components/TaskTimer.tsx
Normal file
135
frontend/src/components/TaskTimer.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { differenceInSeconds, parseISO } from 'date-fns'
|
||||
import { Play, Square } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { taskService } from '../services/taskService'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { TaskResponse } from '../types/task'
|
||||
|
||||
interface TaskTimerProps {
|
||||
task: TaskResponse
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const hrs = Math.floor(seconds / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
return [
|
||||
hrs > 0 ? String(hrs).padStart(2, '0') : '00',
|
||||
String(mins).padStart(2, '0'),
|
||||
String(secs).padStart(2, '0'),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
export function TaskTimer({ task, onUpdate }: TaskTimerProps) {
|
||||
const [now, setNow] = useState(new Date())
|
||||
const [isActionLoading, setIsActionLoading] = useState(false)
|
||||
|
||||
const history = task.history ?? []
|
||||
const runningItem = useMemo(
|
||||
() => history.find((item) => !item.endAt),
|
||||
[history],
|
||||
)
|
||||
|
||||
const accumulatedSeconds = useMemo(() => {
|
||||
return history.reduce((acc, item) => {
|
||||
if (item.endAt) {
|
||||
return acc + differenceInSeconds(parseISO(item.endAt), parseISO(item.startAt))
|
||||
}
|
||||
return acc
|
||||
}, 0)
|
||||
}, [history])
|
||||
|
||||
const [displaySeconds, setDisplaySeconds] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null
|
||||
|
||||
if (runningItem) {
|
||||
interval = setInterval(() => {
|
||||
const runningSeconds = differenceInSeconds(new Date(), parseISO(runningItem.startAt))
|
||||
setDisplaySeconds(accumulatedSeconds + runningSeconds)
|
||||
}, 1000)
|
||||
} else {
|
||||
setDisplaySeconds(accumulatedSeconds)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
}
|
||||
}, [runningItem, accumulatedSeconds])
|
||||
|
||||
// Initial calculation
|
||||
useEffect(() => {
|
||||
if (runningItem) {
|
||||
const runningSeconds = differenceInSeconds(new Date(), parseISO(runningItem.startAt))
|
||||
setDisplaySeconds(accumulatedSeconds + runningSeconds)
|
||||
} else {
|
||||
setDisplaySeconds(accumulatedSeconds)
|
||||
}
|
||||
}, [runningItem, accumulatedSeconds])
|
||||
|
||||
async function handleStart() {
|
||||
setIsActionLoading(true)
|
||||
try {
|
||||
await taskService.startTask(task.id)
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
console.error('Failed to start task', error)
|
||||
} finally {
|
||||
setIsActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
setIsActionLoading(true)
|
||||
try {
|
||||
await taskService.stopTask(task.id)
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
console.error('Failed to stop task', error)
|
||||
} finally {
|
||||
setIsActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl bg-app-bg/50 p-2 pl-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-black uppercase tracking-wider text-text-muted/60">
|
||||
Tempo Gasto
|
||||
</span>
|
||||
<span className={cn(
|
||||
"font-mono text-sm font-bold tabular-nums",
|
||||
runningItem ? "text-brand" : "text-text-main"
|
||||
)}>
|
||||
{formatDuration(displaySeconds)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
{runningItem ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={isActionLoading}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-rose-500 text-white shadow-lg shadow-rose-500/20 transition-all hover:bg-rose-600 active:scale-95 disabled:opacity-50"
|
||||
title="Parar Cronômetro"
|
||||
>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isActionLoading}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand text-white shadow-lg shadow-brand/20 transition-all hover:bg-brand-strong active:scale-95 disabled:opacity-50"
|
||||
title="Iniciar Cronômetro"
|
||||
>
|
||||
<Play size={14} fill="currentColor" className="ml-0.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -1,13 +1,20 @@
|
|||
import { motion } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Layout,
|
||||
PlusCircle,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { ChangeEvent, FormEvent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { TASK_STATUS_OPTIONS } from "../types/task";
|
||||
import type {
|
||||
CreateTaskPayload,
|
||||
TaskResponse,
|
||||
TaskStatus,
|
||||
} from "../types/task";
|
||||
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";
|
||||
|
||||
type FormState = CreateTaskPayload;
|
||||
|
||||
|
|
@ -18,13 +25,6 @@ const initialFormState: FormState = {
|
|||
project: "",
|
||||
};
|
||||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
NOT_STARTED: "Nao iniciada",
|
||||
IN_PROGRESS: "Em andamento",
|
||||
COMPLETED: "Concluida",
|
||||
CANCELED: "Cancelada",
|
||||
};
|
||||
|
||||
export function CreateTaskPage() {
|
||||
const [form, setForm] = useState<FormState>(initialFormState);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
|
@ -56,10 +56,7 @@ export function CreateTaskPage() {
|
|||
project: form.project.trim(),
|
||||
};
|
||||
|
||||
const response = await apiFetch<TaskResponse>("/tasks", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
const response = await taskService.createTask(payload);
|
||||
|
||||
setCreatedTask(response);
|
||||
setForm(initialFormState);
|
||||
|
|
@ -67,106 +64,211 @@ export function CreateTaskPage() {
|
|||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Nao foi possivel criar a task.",
|
||||
: "Não foi possível criar a task.",
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (createdTask) {
|
||||
return (
|
||||
<main className="px-6 py-10 text-text-main">
|
||||
<section className="rounded-[2rem] border border-border-soft bg-surface p-8 shadow-[var(--shadow-panel)]">
|
||||
<div className="max-w-2xl">
|
||||
<div className="inline-flex items-center gap-3 rounded-full border border-border-soft bg-surface-muted px-4 py-2 text-sm font-semibold text-brand-strong">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-brand" />
|
||||
Criar task
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="max-w-md overflow-hidden rounded-[2.5rem] border border-border-soft bg-surface p-8 text-center shadow-2xl shadow-brand/10"
|
||||
>
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-brand-soft text-brand">
|
||||
<CheckCircle2 size={40} />
|
||||
</div>
|
||||
<h2 className="mt-6 text-3xl font-black tracking-tight text-text-main">
|
||||
Tarefa Criada!
|
||||
</h2>
|
||||
<p className="mt-3 text-text-muted">
|
||||
Sua nova tarefa foi registrada com sucesso e já está disponível na
|
||||
lista.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 rounded-2xl bg-app-bg p-4 text-left">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-text-muted">
|
||||
#{createdTask.id}
|
||||
</p>
|
||||
<h4 className="mt-1 font-bold text-text-main">
|
||||
{createdTask.title}
|
||||
</h4>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="rounded-md bg-white px-2 py-1 text-[10px] font-bold text-brand shadow-sm">
|
||||
{TASK_STATUS_LABELS[createdTask.status]}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-text-muted">
|
||||
{createdTask.project || "Geral"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="mt-10 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text-main">
|
||||
Titulo
|
||||
</span>
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
<Link
|
||||
to="/tasks"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-brand py-3 font-bold text-white shadow-lg shadow-brand/20 transition-all hover:bg-brand-strong"
|
||||
>
|
||||
Ver Minhas Tarefas
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setCreatedTask(null)}
|
||||
className="text-sm font-bold text-text-muted hover:text-text-main"
|
||||
>
|
||||
Criar outra tarefa
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl pb-20">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<Link
|
||||
to="/tasks"
|
||||
className="inline-flex items-center gap-2 text-sm font-bold text-text-muted transition-colors hover:text-brand"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Voltar para a lista
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="overflow-hidden rounded-[2.5rem] border border-border-soft bg-surface shadow-2xl shadow-brand/5"
|
||||
>
|
||||
<div className="bg-brand p-8 text-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm">
|
||||
<PlusCircle size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight">
|
||||
Nova Tarefa
|
||||
</h1>
|
||||
<p className="text-sm font-medium text-white/80">
|
||||
Preencha os detalhes abaixo para começar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-8 lg:p-10">
|
||||
<div className="space-y-8">
|
||||
{/* Title Section */}
|
||||
<div className="space-y-4">
|
||||
<label className="group block">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
|
||||
<FileText size={16} />
|
||||
Título da Tarefa
|
||||
</div>
|
||||
<input
|
||||
required
|
||||
name="title"
|
||||
value={form.title}
|
||||
onChange={updateField}
|
||||
placeholder="Ex.: Preparar retrospectiva da sprint"
|
||||
className="h-13 w-full rounded-2xl border border-border-soft bg-white px-4 text-base outline-none transition focus:border-brand focus:ring-4 focus:ring-brand/10"
|
||||
placeholder="Ex.: Implementar autenticação JWT"
|
||||
className="h-14 w-full rounded-2xl border border-border-soft bg-app-bg px-5 text-lg font-medium outline-none transition-all focus:border-brand focus:ring-4 focus:ring-brand/10"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text-main">
|
||||
{/* Project & Status Row */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<label className="group block">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
|
||||
<Layout size={16} />
|
||||
Projeto
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
name="project"
|
||||
value={form.project}
|
||||
onChange={updateField}
|
||||
placeholder="Ex.: Bee Task Web"
|
||||
className="h-13 w-full rounded-2xl border border-border-soft bg-white px-4 text-base outline-none transition focus:border-brand focus:ring-4 focus:ring-brand/10"
|
||||
placeholder="Ex.: Backend API"
|
||||
className="h-12 w-full rounded-xl border border-border-soft bg-app-bg px-4 text-sm font-medium outline-none transition-all focus:border-brand focus:ring-4 focus:ring-brand/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="group block">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
|
||||
<Layout size={16} />
|
||||
Status Inicial
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text-main">
|
||||
Descricao
|
||||
</span>
|
||||
<textarea
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={updateField}
|
||||
rows={6}
|
||||
placeholder="Adicione contexto, criterio de pronto ou observacoes importantes."
|
||||
className="w-full rounded-3xl border border-border-soft bg-white px-4 py-4 text-base outline-none transition focus:border-brand focus:ring-4 focus:ring-brand/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block max-w-xs">
|
||||
<span className="mb-2 block text-sm font-semibold text-text-main">
|
||||
Status inicial
|
||||
</span>
|
||||
<select
|
||||
name="status"
|
||||
value={form.status}
|
||||
onChange={updateField}
|
||||
className="h-13 w-full rounded-2xl border border-border-soft bg-white px-4 text-base outline-none transition focus:border-brand focus:ring-4 focus:ring-brand/10"
|
||||
className="h-12 w-full appearance-none rounded-xl border border-border-soft bg-app-bg px-4 text-sm font-medium outline-none transition-all focus:border-brand focus:ring-4 focus:ring-brand/10"
|
||||
>
|
||||
{TASK_STATUS_OPTIONS.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusLabels[status]}
|
||||
{TASK_STATUS_LABELS[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Description Section */}
|
||||
<label className="group block">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
|
||||
<FileText size={16} />
|
||||
Descrição
|
||||
</div>
|
||||
<textarea
|
||||
name="description"
|
||||
value={form.description}
|
||||
onChange={updateField}
|
||||
rows={5}
|
||||
placeholder="Descreva o que precisa ser feito..."
|
||||
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>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-8 rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm font-bold text-rose-700"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="mt-10 flex items-center justify-end gap-4">
|
||||
<Link
|
||||
to="/tasks"
|
||||
className="px-6 py-3 text-sm font-bold text-text-muted hover:text-text-main"
|
||||
>
|
||||
Cancelar
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex min-w-44 items-center justify-center rounded-2xl bg-brand px-6 py-3 text-sm font-semibold text-white transition hover:bg-brand-strong disabled:cursor-not-allowed disabled:opacity-70"
|
||||
className={cn(
|
||||
"inline-flex min-w-44 items-center justify-center gap-2 rounded-xl bg-brand px-8 py-3.5 text-sm font-black text-white shadow-lg shadow-brand/20 transition-all hover:scale-[1.02] hover:bg-brand-strong active:scale-95 disabled:opacity-50",
|
||||
isSubmitting && "cursor-wait",
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? "Criando task..." : "Criar task"}
|
||||
{isSubmitting ? (
|
||||
"Criando..."
|
||||
) : (
|
||||
<>
|
||||
<Send size={18} />
|
||||
Criar Tarefa
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-sm text-text-muted">
|
||||
O backend define os ids e devolve a task criada.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
215
frontend/src/pages/TaskBoardPage.tsx
Normal file
215
frontend/src/pages/TaskBoardPage.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd'
|
||||
import type { DropResult } from '@hello-pangea/dnd'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Tag,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { taskService } from '../services/taskService'
|
||||
import { cn } from '../lib/utils'
|
||||
import type { TaskResponse, TaskStatus } from '../types/task'
|
||||
|
||||
const COLUMNS: { id: TaskStatus; label: string; icon: any; color: string }[] = [
|
||||
{
|
||||
id: 'NOT_STARTED',
|
||||
label: 'Pendente',
|
||||
icon: AlertCircle,
|
||||
color: 'text-slate-400',
|
||||
},
|
||||
{
|
||||
id: 'IN_PROGRESS',
|
||||
label: 'Em Andamento',
|
||||
icon: Clock,
|
||||
color: 'text-amber-500',
|
||||
},
|
||||
{
|
||||
id: 'COMPLETED',
|
||||
label: 'Concluída',
|
||||
icon: CheckCircle2,
|
||||
color: 'text-emerald-500',
|
||||
},
|
||||
{
|
||||
id: 'CANCELED',
|
||||
label: 'Cancelada',
|
||||
icon: XCircle,
|
||||
color: 'text-rose-500',
|
||||
},
|
||||
]
|
||||
|
||||
import { TaskTimer } from '../components/TaskTimer'
|
||||
|
||||
export function TaskBoardPage() {
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const loadTasks = async (silent = false) => {
|
||||
if (!silent) setIsLoading(true)
|
||||
try {
|
||||
const response = await taskService.getTasks()
|
||||
setTasks(response)
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadTasks()
|
||||
}, [])
|
||||
|
||||
const onDragEnd = async (result: DropResult) => {
|
||||
const { destination, source, draggableId } = result
|
||||
|
||||
if (!destination) return
|
||||
if (
|
||||
destination.droppableId === source.droppableId &&
|
||||
destination.index === source.index
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskId = Number.parseInt(draggableId)
|
||||
const newStatus = destination.droppableId as TaskStatus
|
||||
|
||||
// Optimistic update
|
||||
const updatedTasks = tasks.map((t) =>
|
||||
t.id === taskId ? { ...t, status: newStatus } : t,
|
||||
)
|
||||
setTasks(updatedTasks)
|
||||
|
||||
try {
|
||||
// Use the new generic status update endpoint
|
||||
await taskService.updateTaskStatus(taskId, newStatus)
|
||||
} catch (error) {
|
||||
console.error('Failed to update task status', error)
|
||||
void loadTasks(true) // Revert on error
|
||||
}
|
||||
}
|
||||
|
||||
const tasksByStatus = (status: TaskStatus) =>
|
||||
tasks.filter((t) => t.status === status)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 pb-12">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-text-main">
|
||||
Quadro Kanban
|
||||
</h1>
|
||||
<p className="text-text-muted">
|
||||
Arraste e solte as tarefas para atualizar o progresso.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => void loadTasks()}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl bg-surface border border-border-soft text-text-muted transition-all hover:bg-surface-muted hover:text-brand"
|
||||
>
|
||||
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-brand px-6 py-3 text-sm font-bold text-white shadow-lg shadow-brand/20 transition-all hover:scale-[1.02] hover:bg-brand-strong active:scale-95"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Nova Tarefa
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{COLUMNS.map((column) => (
|
||||
<div key={column.id} className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<column.icon size={18} className={column.color} />
|
||||
<h2 className="text-sm font-black uppercase tracking-wider text-text-main">
|
||||
{column.label}
|
||||
</h2>
|
||||
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-surface-muted px-1.5 text-[10px] font-bold text-text-muted">
|
||||
{tasksByStatus(column.id).length}
|
||||
</span>
|
||||
</div>
|
||||
<button className="text-text-muted hover:text-text-main transition-colors">
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Droppable droppableId={column.id}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
className={cn(
|
||||
'flex min-h-[500px] flex-col gap-3 rounded-[2rem] border-2 border-transparent p-3 transition-colors',
|
||||
snapshot.isDraggingOver
|
||||
? 'bg-brand/5 border-dashed border-brand/20'
|
||||
: 'bg-surface/50',
|
||||
)}
|
||||
>
|
||||
{tasksByStatus(column.id).map((task, index) => (
|
||||
<Draggable
|
||||
key={task.id}
|
||||
draggableId={task.id.toString()}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className={cn(
|
||||
'group 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',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-bold leading-tight text-text-main group-hover:text-brand transition-colors">
|
||||
{task.title}
|
||||
</h3>
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="mt-2 text-xs text-text-muted line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<TaskTimer task={task} onUpdate={() => loadTasks(true)} />
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold text-text-muted">
|
||||
<Tag size={12} className="text-brand" />
|
||||
{task.project || 'Geral'}
|
||||
</div>
|
||||
<span className="font-mono text-[10px] font-bold text-text-muted/50">
|
||||
#{task.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
315
frontend/src/pages/TaskListPage.tsx
Normal file
315
frontend/src/pages/TaskListPage.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Filter,
|
||||
Layers,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Tag,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { taskService } from '../services/taskService'
|
||||
import { cn } from '../lib/utils'
|
||||
import { TASK_STATUS_LABELS } from '../types/task'
|
||||
import type { TaskResponse } from '../types/task'
|
||||
|
||||
function getStatusConfig(status: TaskResponse['status']) {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return {
|
||||
tone: 'bg-emerald-50 text-emerald-700 border-emerald-100',
|
||||
icon: CheckCircle2,
|
||||
label: 'Concluído',
|
||||
}
|
||||
case 'IN_PROGRESS':
|
||||
return {
|
||||
tone: 'bg-amber-50 text-amber-700 border-amber-100',
|
||||
icon: Clock,
|
||||
label: 'Em Andamento',
|
||||
}
|
||||
case 'CANCELED':
|
||||
return {
|
||||
tone: 'bg-rose-50 text-rose-700 border-rose-100',
|
||||
icon: XCircle,
|
||||
label: 'Cancelado',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
tone: 'bg-slate-50 text-slate-600 border-slate-100',
|
||||
icon: AlertCircle,
|
||||
label: 'Pendente',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import { TaskTimer } from '../components/TaskTimer'
|
||||
|
||||
export function TaskListPage() {
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string | 'ALL'>('ALL')
|
||||
|
||||
const loadTasks = async (silent = false) => {
|
||||
if (!silent) setIsLoading(true)
|
||||
setErrorMessage(null)
|
||||
|
||||
try {
|
||||
const response = await taskService.getTasks()
|
||||
setTasks(response)
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Não foi possível carregar as tarefas.',
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadTasks()
|
||||
}, [])
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
return tasks.filter((task) => {
|
||||
const matchesSearch =
|
||||
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
task.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
task.project?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const matchesStatus = statusFilter === 'ALL' || task.status === statusFilter
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
}, [tasks, searchQuery, statusFilter])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
total: tasks.length,
|
||||
completed: tasks.filter((t) => t.status === 'COMPLETED').length,
|
||||
inProgress: tasks.filter((t) => t.status === 'IN_PROGRESS').length,
|
||||
pending: tasks.filter((t) => t.status === 'NOT_STARTED').length,
|
||||
}
|
||||
}, [tasks])
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-12">
|
||||
{/* Header & Action */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tight text-text-main">
|
||||
Gerenciamento de Tarefas
|
||||
</h1>
|
||||
<p className="text-text-muted">
|
||||
Visualize e organize suas atividades em tempo real.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-brand px-6 py-3 text-sm font-bold text-white shadow-lg shadow-brand/20 transition-all hover:scale-[1.02] hover:bg-brand-strong active:scale-95"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Criar Tarefa
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, icon: Layers, color: 'text-text-main' },
|
||||
{
|
||||
label: 'Em Andamento',
|
||||
value: stats.inProgress,
|
||||
icon: Clock,
|
||||
color: 'text-amber-600',
|
||||
},
|
||||
{
|
||||
label: 'Concluídas',
|
||||
value: stats.completed,
|
||||
icon: CheckCircle2,
|
||||
color: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
label: 'Pendentes',
|
||||
value: stats.pending,
|
||||
icon: AlertCircle,
|
||||
color: 'text-brand',
|
||||
},
|
||||
].map((stat, i) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
key={stat.label}
|
||||
className="rounded-2xl border border-border-soft bg-surface p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<stat.icon size={20} className={stat.color} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-text-muted">
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-black text-text-main">{stat.value}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-surface p-4 shadow-sm lg:flex-row lg:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
|
||||
size={18}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar tarefas por título, projeto ou descrição..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-11 w-full rounded-xl border border-border-soft bg-app-bg pl-10 pr-4 text-sm outline-none transition-all focus:border-brand focus:ring-2 focus:ring-brand/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 lg:pb-0">
|
||||
<Filter size={18} className="mr-2 text-text-muted shrink-0" />
|
||||
{['ALL', 'NOT_STARTED', 'IN_PROGRESS', 'COMPLETED', 'CANCELED'].map(
|
||||
(status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setStatusFilter(status)}
|
||||
className={cn(
|
||||
'whitespace-nowrap rounded-lg px-4 py-2 text-xs font-bold transition-all',
|
||||
statusFilter === status
|
||||
? 'bg-brand text-white'
|
||||
: 'bg-app-bg text-text-muted hover:bg-surface-muted hover:text-text-main',
|
||||
)}
|
||||
>
|
||||
{status === 'ALL' ? 'Todos' : TASK_STATUS_LABELS[status]}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => void loadTasks()}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-xl bg-app-bg text-text-muted transition-all hover:bg-surface-muted hover:text-brand"
|
||||
title="Recarregar"
|
||||
>
|
||||
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filteredTasks.map((task, i) => {
|
||||
const config = getStatusConfig(task.status)
|
||||
const StatusIcon = config.icon
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border px-2.5 py-1 text-[10px] font-black uppercase tracking-wider',
|
||||
config.tone,
|
||||
)}
|
||||
>
|
||||
<StatusIcon size={12} />
|
||||
{config.label}
|
||||
</div>
|
||||
<span className="font-mono text-[10px] font-bold text-text-muted group-hover:text-brand transition-colors">
|
||||
#{task.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="mt-4 text-lg font-bold leading-tight text-text-main group-hover:text-brand transition-colors text-ellipsis overflow-hidden">
|
||||
{task.title}
|
||||
</h3>
|
||||
|
||||
<p className="mt-2 flex-1 text-sm leading-relaxed text-text-muted line-clamp-3">
|
||||
{task.description || 'Sem descrição informada.'}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
<TaskTimer task={task} onUpdate={() => loadTasks(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 || 'Geral'}
|
||||
</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-amber-500" />
|
||||
{task.history?.length ?? 0} registros
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Empty States */}
|
||||
{!isLoading && filteredTasks.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center rounded-[2rem] border-2 border-dashed border-border-soft bg-surface/50 py-20 text-center"
|
||||
>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-app-bg text-text-muted">
|
||||
<Search size={40} />
|
||||
</div>
|
||||
<h3 className="mt-6 text-xl font-bold text-text-main">
|
||||
Nenhuma tarefa encontrada
|
||||
</h3>
|
||||
<p className="mt-2 max-w-xs text-sm text-text-muted">
|
||||
Tente ajustar seus filtros ou busque por outro termo para encontrar o que
|
||||
procura.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setStatusFilter('ALL')
|
||||
}}
|
||||
className="mt-6 text-sm font-bold text-brand hover:underline"
|
||||
>
|
||||
Limpar todos os filtros
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && tasks.length === 0 && (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-56 animate-pulse rounded-[2rem] border border-border-soft bg-surface-muted"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,27 @@
|
|||
import { createBrowserRouter } from 'react-router-dom'
|
||||
|
||||
import { AppShell } from './components/AppShell'
|
||||
import { CreateTaskPage } from './pages/CreateTaskPage'
|
||||
import { TaskListPage } from './pages/TaskListPage'
|
||||
import { TaskBoardPage } from './pages/TaskBoardPage'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <CreateTaskPage />,
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
element: <TaskListPage />,
|
||||
},
|
||||
{
|
||||
path: 'board',
|
||||
element: <TaskBoardPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
|
|||
34
frontend/src/services/taskService.ts
Normal file
34
frontend/src/services/taskService.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { apiFetch } from '../lib/api'
|
||||
import type { TaskResponse, TaskStatus } from '../types/task'
|
||||
|
||||
export const taskService = {
|
||||
async getTasks(): Promise<TaskResponse[]> {
|
||||
return apiFetch<TaskResponse[]>('/tasks')
|
||||
},
|
||||
|
||||
async createTask(task: { title: string; description?: string; project?: string }): Promise<TaskResponse> {
|
||||
return apiFetch<TaskResponse>('/tasks', {
|
||||
method: 'POST',
|
||||
body: task,
|
||||
})
|
||||
},
|
||||
|
||||
async updateTaskStatus(taskId: number, status: TaskStatus): Promise<void> {
|
||||
await apiFetch(`/tasks/${taskId}/status`, {
|
||||
method: 'PATCH',
|
||||
body: status,
|
||||
})
|
||||
},
|
||||
|
||||
async startTask(taskId: number): Promise<void> {
|
||||
await apiFetch(`/tasks/${taskId}/start`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
},
|
||||
|
||||
async stopTask(taskId: number): Promise<void> {
|
||||
await apiFetch(`/tasks/${taskId}/stop`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
@ -7,6 +7,13 @@ export const TASK_STATUS_OPTIONS = [
|
|||
|
||||
export type TaskStatus = (typeof TASK_STATUS_OPTIONS)[number]
|
||||
|
||||
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
NOT_STARTED: 'Nao iniciada',
|
||||
IN_PROGRESS: 'Em andamento',
|
||||
COMPLETED: 'Concluida',
|
||||
CANCELED: 'Cancelada',
|
||||
}
|
||||
|
||||
export type CreateTaskPayload = {
|
||||
title: string
|
||||
description: string
|
||||
|
|
@ -14,10 +21,16 @@ export type CreateTaskPayload = {
|
|||
project: string
|
||||
}
|
||||
|
||||
export type TaskHistoryItem = {
|
||||
startAt: string
|
||||
endAt: string | null
|
||||
}
|
||||
|
||||
export type TaskResponse = {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
status: TaskStatus
|
||||
project: string
|
||||
history?: TaskHistoryItem[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue