From f8394173f10bfe8e35ecd9412811ece00ca399dc Mon Sep 17 00:00:00 2001 From: Andrew Trieu Date: Mon, 4 Nov 2024 12:13:00 +0200 Subject: [PATCH] feat: Add components and update global styles --- .../src/app/(components)/Header/index.tsx | 23 ++ tasker-client/src/app/globals.css | 4 +- .../src/app/projects/BoardView/index.tsx | 263 ++++++++++++++++++ .../src/app/projects/ProjectHeader.tsx | 109 ++++++++ tasker-client/src/app/projects/[id]/page.tsx | 42 +++ tasker-client/src/state/api.ts | 151 +++++++++- 6 files changed, 585 insertions(+), 7 deletions(-) create mode 100644 tasker-client/src/app/(components)/Header/index.tsx create mode 100644 tasker-client/src/app/projects/BoardView/index.tsx create mode 100644 tasker-client/src/app/projects/ProjectHeader.tsx create mode 100644 tasker-client/src/app/projects/[id]/page.tsx diff --git a/tasker-client/src/app/(components)/Header/index.tsx b/tasker-client/src/app/(components)/Header/index.tsx new file mode 100644 index 0000000..1c7cd9d --- /dev/null +++ b/tasker-client/src/app/(components)/Header/index.tsx @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; + +type Props = { + name: string; + buttonComponent?: any; + isSmallText?: boolean; +}; + +const Header = ({ name, buttonComponent, isSmallText = false }: Props) => { + return ( +
+

+ {name} +

+ {buttonComponent} +
+ ); +}; + +export default Header; diff --git a/tasker-client/src/app/globals.css b/tasker-client/src/app/globals.css index 01e9907..ab91f01 100644 --- a/tasker-client/src/app/globals.css +++ b/tasker-client/src/app/globals.css @@ -15,6 +15,6 @@ body, height: 100%; width: 100%; @apply text-sm; - @apply bg-gray-500; - @apply text-gray-900; + @apply bg-white; + @apply dark:bg-black; } diff --git a/tasker-client/src/app/projects/BoardView/index.tsx b/tasker-client/src/app/projects/BoardView/index.tsx new file mode 100644 index 0000000..679ed3d --- /dev/null +++ b/tasker-client/src/app/projects/BoardView/index.tsx @@ -0,0 +1,263 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from "react"; +import { useGetTasksQuery, useUpdateTaskStatusMutation } from "@/state/api"; +import { DndProvider, useDrag, useDrop } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { Task as TaskType } from "@/state/api"; +import { EllipsisVertical, MessageSquareMore, Plus } from "lucide-react"; +import { format } from "date-fns"; +import Image from "next/image"; + +type BoardProps = { + id: string; + setIsModalNewTaskOpen: (isOpen: boolean) => void; +}; + +const taskStatuses = ["Backlog", "In Progress", "Test/Review", "Done"]; + +function BoardView({ id, setIsModalNewTaskOpen }: BoardProps) { + const { + data: tasks, + isLoading, + error, + } = useGetTasksQuery({ projectId: Number(id) }); + + const [updateTaskStatus] = useUpdateTaskStatusMutation(); + + const moveTask = (taskId: number, status: string) => { + updateTaskStatus({ taskId, status }); + }; + + if (isLoading) return
Loading...
; + if (error) return
Error fetching tasks
; + + return ( + +
+ {taskStatuses.map((status) => ( + + ))} +
+
+ ); +} + +type TaskColumnProps = { + status: string; + tasks: TaskType[]; + moveTask: (taskId: number, toStatus: string) => void; + setIsModalNewTaskOpen: (isOpen: boolean) => void; +}; + +const TaskColumn = ({ + status, + tasks, + moveTask, + setIsModalNewTaskOpen, +}: TaskColumnProps) => { + const [{ isOver }, drop] = useDrop(() => ({ + accept: "task", + drop: (item: { id: number }) => { + moveTask(item.id, status); + }, + collect: (monitor) => ({ + isOver: !!monitor.isOver(), + }), + })); + + const tasksCount = tasks.filter((task) => task.status === status).length; + + const statusColors: any = { + Backlog: "#800000", + "In Progress": "#efcc00", + "Test/Review": "#00008b", + Done: "#006400", + }; + + return ( +
{ + drop(instance); + }} + className={`sl:py-4 rounded-lg py-2 xl:px-2 ${isOver ? "bg-blue-100 dark:bg-neutral-950" : ""}`} + > +
+
+
+

+ {status}{" "} + + {tasksCount} + +

+
+ + +
+
+
+ + {tasks + .filter((task) => task.status === status) + .map((task) => ( + + ))} +
+ ); +}; + +type TaskProps = { + task: TaskType; +}; + +const Task = ({ task }: TaskProps) => { + const [{ isDragging }, drag] = useDrag(() => ({ + type: "task", + item: { id: task.id }, + collect: (monitor: any) => ({ + isDragging: !!monitor.isDragging(), + }), + })); + + const taskTagsSplit = task.tags ? task.tags.split(",") : []; + + const formattedStartDate = task.startDate + ? format(new Date(task.startDate), "P") + : ""; + const formattedDueDate = task.dueDate + ? format(new Date(task.dueDate), "P") + : ""; + + const numberOfComments = (task.comments && task.comments.length) || 0; + + const PriorityTag = ({ priority }: { priority: TaskType["priority"] }) => ( +
+ {priority} +
+ ); + + return ( +
{ + drag(instance); + }} + className={`mb-4 rounded-md bg-white shadow dark:bg-dark-secondary ${ + isDragging ? "opacity-50" : "opacity-100" + }`} + > + {task.attachments && task.attachments.length > 0 && ( + {task.attachments[0].fileName} + )} +
+
+
+ {task.priority && } +
+ {taskTagsSplit.map((tag) => ( +
+ {" "} + {tag} +
+ ))} +
+
+ +
+ +
+

{task.title}

+ {typeof task.points === "number" && ( +
+ {task.points} pts +
+ )} +
+ +
+ {formattedStartDate && {formattedStartDate} - } + {formattedDueDate && {formattedDueDate}} +
+

+ {task.description} +

+
+ + {/* Users */} +
+
+ {task.assignee && ( + {task.assignee.username} + )} + {task.author && ( + {task.author.username} + )} +
+
+ + + {numberOfComments} + +
+
+
+
+ ); +}; + +export default BoardView; diff --git a/tasker-client/src/app/projects/ProjectHeader.tsx b/tasker-client/src/app/projects/ProjectHeader.tsx new file mode 100644 index 0000000..d555f59 --- /dev/null +++ b/tasker-client/src/app/projects/ProjectHeader.tsx @@ -0,0 +1,109 @@ +import { + Clock, + Filter, + Grid3x3, + List, + PlusSquare, + Share2, + Table, +} from "lucide-react"; +import React, { useState } from "react"; +import Header from "../(components)/Header"; + +type Props = { + activeTab: string; + setActiveTab: (tab: string) => void; +}; + +const ProjectHeader = ({ activeTab, setActiveTab }: Props) => { + const [isModalNewProjectOpen, setIsModalNewProjectOpen] = useState(false); + + return ( +
+
+
setIsModalNewProjectOpen(true)} + > + New Boards + + } + /> +
+ + {/* TABS */} +
+
+ } + setActiveTab={setActiveTab} + activeTab={activeTab} + /> + } + setActiveTab={setActiveTab} + activeTab={activeTab} + /> + } + setActiveTab={setActiveTab} + activeTab={activeTab} + /> + } + setActiveTab={setActiveTab} + activeTab={activeTab} + /> +
+
+ + +
+ + +
+
+
+
+ ); +}; + +type TabButtonProps = { + name: string; + icon: React.ReactNode; + setActiveTab: (tabName: string) => void; + activeTab: string; +}; + +const TabButton = ({ name, icon, setActiveTab, activeTab }: TabButtonProps) => { + const isActive = activeTab === name; + + return ( + + ); +}; + +export default ProjectHeader; diff --git a/tasker-client/src/app/projects/[id]/page.tsx b/tasker-client/src/app/projects/[id]/page.tsx new file mode 100644 index 0000000..c43a37f --- /dev/null +++ b/tasker-client/src/app/projects/[id]/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React, { useState } from "react"; +import ProjectHeader from "@/app/projects/ProjectHeader"; +import BoardView from "@/app/projects/BoardView"; + +type Props = { + params: { id: string }; +}; + +const Project = ({ params }: Props) => { + const { id } = params; + const [activeTab, setActiveTab] = useState("Board"); + const [isModalNewTaskOpen, setIsModalNewTaskOpen] = useState(false); + + return ( +
+ {/* setIsModalNewTaskOpen(false)} + id={id} + /> */} + + { + activeTab === "Board" && ( + + ) + // {activeTab === "List" && ( + // + // )} + // {activeTab === "Timeline" && ( + // + // )} + // {activeTab === "Table" && ( + // + // )} + } + + ); +}; + +export default Project; diff --git a/tasker-client/src/state/api.ts b/tasker-client/src/state/api.ts index 87d580d..2e30af5 100644 --- a/tasker-client/src/state/api.ts +++ b/tasker-client/src/state/api.ts @@ -1,10 +1,151 @@ -import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query"; +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; + +export interface Project { + id: number; + name: string; + description?: string; + startDate?: string; + endDate?: string; +} + +export enum Priority { + Urgent = "Urgent", + High = "High", + Medium = "Medium", + Low = "Low", + Backlog = "Backlog", +} + +export enum Status { + Backlog = "Backlog", + InProgress = "In Progress", + TestReview = "Test/Review", + Done = "Done", +} + +export interface User { + userId?: number; + username: string; + email: string; + profilePictureUrl?: string; + cognitoId?: string; + teamId?: number; +} + +export interface Attachment { + id: number; + fileURL: string; + fileName: string; + taskId: number; + uploadedById: number; +} + +export interface Task { + id: number; + title: string; + description?: string; + status?: Status; + priority?: Priority; + tags?: string; + startDate?: string; + dueDate?: string; + points?: number; + projectId: number; + authorUserId?: number; + assignedUserId?: number; + + author?: User; + assignee?: User; + comments?: Comment[]; + attachments?: Attachment[]; +} + +export interface SearchResults { + tasks?: Task[]; + projects?: Project[]; + users?: User[]; +} + +export interface Team { + teamId: number; + teamName: string; + productOwnerUserId?: number; + projectManagerUserId?: number; +} export const api = createApi({ - baseQuery: fetchBaseQuery({ baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL }), + baseQuery: fetchBaseQuery({ + baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL, + }), reducerPath: "api", - tagTypes: ["Task"], - endpoints: (builder) => ({}), + tagTypes: ["Projects", "Tasks", "Users", "Teams"], + endpoints: (build) => ({ + getProjects: build.query({ + query: () => "projects", + providesTags: ["Projects"], + }), + createProject: build.mutation>({ + query: (project) => ({ + url: "projects", + method: "POST", + body: project, + }), + invalidatesTags: ["Projects"], + }), + getTasks: build.query({ + query: ({ projectId }) => `tasks?projectId=${projectId}`, + providesTags: (result) => + result + ? result.map(({ id }) => ({ type: "Tasks" as const, id })) + : [{ type: "Tasks" as const }], + }), + getTasksByUser: build.query({ + query: (userId) => `tasks/user/${userId}`, + providesTags: (result, error, userId) => + result + ? result.map(({ id }) => ({ type: "Tasks", id })) + : [{ type: "Tasks", id: userId }], + }), + createTask: build.mutation>({ + query: (task) => ({ + url: "tasks", + method: "POST", + body: task, + }), + invalidatesTags: ["Tasks"], + }), + updateTaskStatus: build.mutation({ + query: ({ taskId, status }) => ({ + url: `tasks/${taskId}/status`, + method: "PATCH", + body: { status }, + }), + invalidatesTags: (result, error, { taskId }) => [ + { type: "Tasks", id: taskId }, + ], + }), + getUsers: build.query({ + query: () => "users", + providesTags: ["Users"], + }), + getTeams: build.query({ + query: () => "teams", + providesTags: ["Teams"], + }), + search: build.query({ + query: (query) => `search?query=${query}`, + }), + }), }); -export const {} = api; +export const { + useGetProjectsQuery, + useCreateProjectMutation, + useGetTasksQuery, + useCreateTaskMutation, + useUpdateTaskStatusMutation, + useSearchQuery, + useGetUsersQuery, + useGetTeamsQuery, + useGetTasksByUserQuery, +} = api;