listagem e board

This commit is contained in:
Gabriel Santana de Menezes 2026-04-30 13:36:05 -04:00
parent 3312f2276c
commit 251362852d
27 changed files with 1362 additions and 160 deletions

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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")
@ -58,6 +72,22 @@ public class TaskController {
public TaskResponseDTO stopTask(@PathParam("id") Long id) {
Task task = stopTaskUseCase.execute(TaskDTOMapper.toStopTaskInput(id));
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();
}
}

View file

@ -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);
}

View file

@ -0,0 +1,7 @@
package net.beetechgroup.beetask.interfaceadapters.dtos;
import java.time.LocalDateTime;
public record TaskHistoryItemDTO(LocalDateTime startAt, LocalDateTime endAt) {
}

View file

@ -3,9 +3,8 @@ package net.beetechgroup.beetask.interfaceadapters.dtos;
import net.beetechgroup.beetask.entities.TaskStatus;
public record TaskRequestDTO(
Long id,
String title,
String description,
TaskStatus status,
String project) {
String title,
String description,
TaskStatus status,
String project) {
}

View file

@ -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) {
}

View file

@ -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);
}
}

View file

@ -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();
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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"
}

View file

@ -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",

View 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>
)
}

View 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>
)
}

View 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))
}

View file

@ -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 (
<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>
<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 (
<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="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 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>
<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"
/>
</label>
<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.: 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">
Projeto
</span>
<input
name="project"
value={form.project}
{/* 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
</div>
<input
name="project"
value={form.project}
onChange={updateField}
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>
<select
name="status"
value={form.status}
onChange={updateField}
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}>
{TASK_STATUS_LABELS[status]}
</option>
))}
</select>
</label>
</div>
{/* 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}
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"
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>
<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"
{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"
>
{TASK_STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{statusLabels[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}
</motion.div>
)}
<div className="flex flex-wrap items-center gap-4">
<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>
);
}

View 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>
)
}

View 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>
)
}

View file

@ -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: <CreateTaskPage />,
element: <AppShell />,
children: [
{
index: true,
element: <CreateTaskPage />,
},
{
path: 'tasks',
element: <TaskListPage />,
},
{
path: 'board',
element: <TaskBoardPage />,
},
],
},
])

View 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',
})
},
}

View file

@ -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[]
}