Merge pull request #1 from AndrewTrieu/wip-front-end
Wip front end
This commit was merged in pull request #1.
This commit is contained in:
3890
tasker-client/package-lock.json
generated
3890
tasker-client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,13 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-amplify/ui-react": "^6.6.0",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/material": "^6.1.6",
|
||||
"@mui/x-data-grid": "^7.22.0",
|
||||
"@reduxjs/toolkit": "^2.3.0",
|
||||
"aws-amplify": "^6.8.2",
|
||||
"axios": "^1.7.7",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -30,6 +32,7 @@
|
||||
"redux-persist": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/node": "^20",
|
||||
"@types/numeral": "^2.0.5",
|
||||
"@types/react": "^18",
|
||||
|
||||
64
tasker-client/src/app/authProvider.tsx
Normal file
64
tasker-client/src/app/authProvider.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
import { Authenticator } from "@aws-amplify/ui-react";
|
||||
import { Amplify } from "aws-amplify";
|
||||
import "@aws-amplify/ui-react/styles.css";
|
||||
|
||||
Amplify.configure({
|
||||
Auth: {
|
||||
Cognito: {
|
||||
userPoolId: process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID || "",
|
||||
userPoolClientId:
|
||||
process.env.NEXT_PUBLIC_COGNITO_USER_POOL_CLIENT_ID || "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const formFields = {
|
||||
signUp: {
|
||||
username: {
|
||||
order: 1,
|
||||
placeholder: "Choose a username",
|
||||
label: "Username",
|
||||
inputProps: { required: true },
|
||||
},
|
||||
email: {
|
||||
order: 2,
|
||||
placeholder: "Enter your email address",
|
||||
label: "Email",
|
||||
inputProps: { type: "email", required: true },
|
||||
},
|
||||
password: {
|
||||
order: 3,
|
||||
placeholder: "Enter your password",
|
||||
label: "Password",
|
||||
inputProps: { type: "password", required: true },
|
||||
},
|
||||
confirm_password: {
|
||||
order: 4,
|
||||
placeholder: "Confirm your password",
|
||||
label: "Confirm Password",
|
||||
inputProps: { type: "password", required: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const AuthProvider = ({ children }: any) => {
|
||||
return (
|
||||
<div>
|
||||
<Authenticator formFields={formFields}>
|
||||
{({ user }: any) =>
|
||||
user ? (
|
||||
<div>{children}</div>
|
||||
) : (
|
||||
<div>
|
||||
<h1>Please sign in below:</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Authenticator>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthProvider;
|
||||
23
tasker-client/src/app/components/Header/index.tsx
Normal file
23
tasker-client/src/app/components/Header/index.tsx
Normal file
@@ -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 (
|
||||
<div className="mb-5 flex w-full items-center justify-between">
|
||||
<h1
|
||||
className={`${isSmallText ? "text-lg" : "text-2xl"} font-semibold dark:text-white`}
|
||||
>
|
||||
{name}
|
||||
</h1>
|
||||
{buttonComponent}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
38
tasker-client/src/app/components/Modal/index.tsx
Normal file
38
tasker-client/src/app/components/Modal/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import Header from "../Header";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const Modal = ({ children, isOpen, onClose, name }: Props) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-gray-600 bg-opacity-50 p-4">
|
||||
<div className="w-full max-w-2xl rounded-lg bg-white p-4 shadow-lg dark:bg-dark-secondary">
|
||||
<Header
|
||||
name={name}
|
||||
buttonComponent={
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-blue-primary text-white hover:bg-blue-600"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
}
|
||||
isSmallText
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
169
tasker-client/src/app/components/ModalNewTask/index.tsx
Normal file
169
tasker-client/src/app/components/ModalNewTask/index.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import Modal from "@/app/components/Modal";
|
||||
import { Priority, Status, useCreateTaskMutation } from "@/state/api";
|
||||
import React, { useState } from "react";
|
||||
import { formatISO } from "date-fns";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
id?: string | null;
|
||||
};
|
||||
|
||||
const ModalNewTask = ({ isOpen, onClose, id = null }: Props) => {
|
||||
const [createTask, { isLoading }] = useCreateTaskMutation();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [status, setStatus] = useState<Status>(Status.ToDo);
|
||||
const [priority, setPriority] = useState<Priority>(Priority.Backlog);
|
||||
const [tags, setTags] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const [authorUserId, setAuthorUserId] = useState("");
|
||||
const [assignedUserId, setAssignedUserId] = useState("");
|
||||
const [projectId, setProjectId] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title || !authorUserId || !(id !== null || projectId)) return;
|
||||
|
||||
const formattedStartDate = formatISO(new Date(startDate), {
|
||||
representation: "complete",
|
||||
});
|
||||
const formattedDueDate = formatISO(new Date(dueDate), {
|
||||
representation: "complete",
|
||||
});
|
||||
|
||||
await createTask({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
tags,
|
||||
startDate: formattedStartDate,
|
||||
dueDate: formattedDueDate,
|
||||
authorUserId: parseInt(authorUserId),
|
||||
assignedUserId: parseInt(assignedUserId),
|
||||
projectId: id !== null ? Number(id) : Number(projectId),
|
||||
});
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
return title && authorUserId && !(id !== null || projectId);
|
||||
};
|
||||
|
||||
const selectStyles =
|
||||
"mb-4 block w-full rounded border border-gray-300 px-3 py-2 dark:border-dark-tertiary dark:bg-dark-tertiary dark:text-white dark:focus:outline-none";
|
||||
|
||||
const inputStyles =
|
||||
"w-full rounded border border-gray-300 p-2 shadow-sm dark:border-dark-tertiary dark:bg-dark-tertiary dark:text-white dark:focus:outline-none";
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} name="Create New Task">
|
||||
<form
|
||||
className="mt-4 space-y-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyles}
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={inputStyles}
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 sm:gap-2">
|
||||
<select
|
||||
className={selectStyles}
|
||||
value={status}
|
||||
onChange={(e) =>
|
||||
setStatus(Status[e.target.value as keyof typeof Status])
|
||||
}
|
||||
>
|
||||
<option value="">Select Status</option>
|
||||
<option value={Status.ToDo}>Backlog</option>
|
||||
<option value={Status.InProgress}>In Progress</option>
|
||||
<option value={Status.TestReview}>Test/Review</option>
|
||||
<option value={Status.Done}>Done</option>
|
||||
</select>
|
||||
<select
|
||||
className={selectStyles}
|
||||
value={priority}
|
||||
onChange={(e) =>
|
||||
setPriority(Priority[e.target.value as keyof typeof Priority])
|
||||
}
|
||||
>
|
||||
<option value="">Select Priority</option>
|
||||
<option value={Priority.Urgent}>Urgent</option>
|
||||
<option value={Priority.High}>High</option>
|
||||
<option value={Priority.Medium}>Medium</option>
|
||||
<option value={Priority.Low}>Low</option>
|
||||
<option value={Priority.Backlog}>Backlog</option>
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyles}
|
||||
placeholder="Tags (comma separated)"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 sm:gap-2">
|
||||
<input
|
||||
type="date"
|
||||
className={inputStyles}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
className={inputStyles}
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyles}
|
||||
placeholder="Author User ID"
|
||||
value={authorUserId}
|
||||
onChange={(e) => setAuthorUserId(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyles}
|
||||
placeholder="Assigned User ID"
|
||||
value={assignedUserId}
|
||||
onChange={(e) => setAssignedUserId(e.target.value)}
|
||||
/>
|
||||
{id === null && (
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyles}
|
||||
placeholder="Project ID"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className={`focus-offset-2 mt-4 flex w-full justify-center rounded-md border border-transparent bg-blue-primary px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-600 ${
|
||||
!isFormValid() || isLoading ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}
|
||||
disabled={!isFormValid() || isLoading}
|
||||
>
|
||||
{isLoading ? "Creating..." : "Create Task"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalNewTask;
|
||||
@@ -1,8 +1,11 @@
|
||||
import React from "react";
|
||||
import { Menu, Moon, Search, Settings, Sun } from "lucide-react";
|
||||
import { Menu, Moon, Search, Settings, Sun, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useAppDispatch, useAppSelector } from "@/app/redux";
|
||||
import { setIsDarkMode, setIsSidebarCollapsed } from "@/state";
|
||||
import { useGetAuthUserQuery } from "@/state/api";
|
||||
import { signOut } from "aws-amplify/auth";
|
||||
|
||||
const Navbar = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -11,6 +14,18 @@ const Navbar = () => {
|
||||
);
|
||||
const isDarkMode = useAppSelector((state) => state.global.isDarkMode);
|
||||
|
||||
const { data: currentUser } = useGetAuthUserQuery({});
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch (error) {
|
||||
console.error("Error signing out: ", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentUser) return null;
|
||||
const currentUserDetails = currentUser?.userDetails;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between bg-white px-4 py-3 dark:bg-black">
|
||||
<div className="flex items-center gap-8">
|
||||
@@ -57,7 +72,31 @@ const Navbar = () => {
|
||||
>
|
||||
<Settings className="h-6 w-6 cursor-pointer dark:text-white" />
|
||||
</Link>
|
||||
<div className="ml-2 mr-2 hidden min-h-[2em] w-[0.1rem] bg-gray-200 md:inline-block"></div>
|
||||
<div className="ml-2 mr-5 hidden min-h-[2em] w-[0.1rem] bg-gray-200 md:inline-block"></div>
|
||||
<div className="hidden items-center justify-between md:flex">
|
||||
<div className="align-center flex h-9 w-9 justify-center">
|
||||
{!!currentUserDetails?.profilePictureUrl ? (
|
||||
<Image
|
||||
src={`/${currentUserDetails?.profilePictureUrl}`}
|
||||
alt={currentUserDetails?.username || "User Profile Picture"}
|
||||
width={100}
|
||||
height={50}
|
||||
className="h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User className="h-6 w-6 cursor-pointer self-center rounded-full dark:text-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className="mx-3 text-gray-800 dark:text-white">
|
||||
{currentUserDetails?.username}
|
||||
</span>
|
||||
<button
|
||||
className="hidden rounded bg-blue-400 px-4 py-2 text-xs font-bold text-white hover:bg-blue-500 md:block"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
19
tasker-client/src/app/components/ProjectCard/index.tsx
Normal file
19
tasker-client/src/app/components/ProjectCard/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Project } from "@/state/api";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
project: Project;
|
||||
};
|
||||
|
||||
const ProjectCard = ({ project }: Props) => {
|
||||
return (
|
||||
<div className="rounded border p-4 shadow">
|
||||
<h3>{project.name}</h3>
|
||||
<p>{project.description}</p>
|
||||
<p>Start Date: {project.startDate}</p>
|
||||
<p>End Date: {project.endDate}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectCard;
|
||||
@@ -25,16 +25,30 @@ import { usePathname } from "next/navigation";
|
||||
import { setIsSidebarCollapsed } from "@/state";
|
||||
import { useAppDispatch, useAppSelector } from "@/app/redux";
|
||||
import Link from "next/link";
|
||||
import { useGetAuthUserQuery, useGetProjectsQuery } from "@/state/api";
|
||||
import { signOut } from "aws-amplify/auth";
|
||||
|
||||
const Sidebar = () => {
|
||||
const [showProjects, setShowProjects] = React.useState(true);
|
||||
const [showPriority, setShowPriority] = React.useState(true);
|
||||
|
||||
const { data: projects } = useGetProjectsQuery();
|
||||
const dispatch = useAppDispatch();
|
||||
const isSidebarCollapsed = useAppSelector(
|
||||
(state) => state.global.isSidebarCollapsed,
|
||||
);
|
||||
|
||||
const { data: currentUser } = useGetAuthUserQuery({});
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch (error) {
|
||||
console.error("Error signing out: ", error);
|
||||
}
|
||||
};
|
||||
if (!currentUser) return null;
|
||||
const currentUserDetails = currentUser?.userDetails;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed z-40 flex h-full w-64 flex-col justify-between overflow-y-auto bg-white shadow-xl transition-all duration-300 dark:bg-black ${isSidebarCollapsed ? "hidden w-0" : "w-64"}`}
|
||||
@@ -77,7 +91,12 @@ const Sidebar = () => {
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={() => setShowProjects((prev) => !prev)}
|
||||
onClick={() =>
|
||||
setShowProjects((prev) => {
|
||||
console.log(prev);
|
||||
return !prev;
|
||||
})
|
||||
}
|
||||
className="flex w-full items-center justify-between px-8 py-3 text-gray-500"
|
||||
>
|
||||
<span className="">Projects</span>
|
||||
@@ -87,6 +106,15 @@ const Sidebar = () => {
|
||||
<ChevronUpCircleIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
{showProjects &&
|
||||
projects?.map((project) => (
|
||||
<SidebarLink
|
||||
key={project.id}
|
||||
icon={Briefcase}
|
||||
label={project.name}
|
||||
href={`/projects/${project.id}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setShowPriority((prev) => !prev)}
|
||||
@@ -125,6 +153,32 @@ const Sidebar = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="z-10 mt-32 flex w-full flex-col items-center gap-4 bg-white px-8 py-4 dark:bg-black md:hidden">
|
||||
<div className="flex w-full items-center">
|
||||
<div className="align-center flex h-9 w-9 justify-center">
|
||||
{!!currentUserDetails?.profilePictureUrl ? (
|
||||
<Image
|
||||
src={`/${currentUserDetails?.profilePictureUrl}`}
|
||||
alt={currentUserDetails?.username || "User Profile Picture"}
|
||||
width={100}
|
||||
height={50}
|
||||
className="h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<User className="h-6 w-6 cursor-pointer self-center rounded-full dark:text-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className="mx-3 text-gray-800 dark:text-white">
|
||||
{currentUserDetails?.username}
|
||||
</span>
|
||||
<button
|
||||
className="self-start rounded bg-blue-400 px-4 py-2 text-xs font-bold text-white hover:bg-blue-500 md:block"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
68
tasker-client/src/app/components/TaskCard/index.tsx
Normal file
68
tasker-client/src/app/components/TaskCard/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Task } from "@/state/api";
|
||||
import { format } from "date-fns";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
task: Task;
|
||||
};
|
||||
|
||||
const TaskCard = ({ task }: Props) => {
|
||||
return (
|
||||
<div className="mb-3 rounded bg-white p-4 shadow dark:bg-dark-secondary dark:text-white">
|
||||
{task.attachments && task.attachments.length > 0 && (
|
||||
<div>
|
||||
<strong>Attachments:</strong>
|
||||
<div className="flex flex-wrap">
|
||||
{task.attachments && task.attachments.length > 0 && (
|
||||
<Image
|
||||
src={`/${task.attachments[0].fileURL}`}
|
||||
alt={task.attachments[0].fileName}
|
||||
width={400}
|
||||
height={200}
|
||||
className="rounded-md"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
<strong>ID:</strong> {task.id}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Title:</strong> {task.title}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Description:</strong>{" "}
|
||||
{task.description || "No description provided"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Status:</strong> {task.status}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Priority:</strong> {task.priority}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Tags:</strong> {task.tags || "No tags"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Start Date:</strong>{" "}
|
||||
{task.startDate ? format(new Date(task.startDate), "P") : "Not set"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Due Date:</strong>{" "}
|
||||
{task.dueDate ? format(new Date(task.dueDate), "P") : "Not set"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Author:</strong>{" "}
|
||||
{task.author ? task.author.username : "Unknown"}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Assignee:</strong>{" "}
|
||||
{task.assignee ? task.assignee.username : "Unassigned"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCard;
|
||||
29
tasker-client/src/app/components/UserCard/index.tsx
Normal file
29
tasker-client/src/app/components/UserCard/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { User } from "@/state/api";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
const UserCard = ({ user }: Props) => {
|
||||
return (
|
||||
<div className="flex items-center rounded border p-4 shadow">
|
||||
{user.profilePictureUrl && (
|
||||
<Image
|
||||
src={`/${user.profilePictureUrl}`}
|
||||
alt="profile picture"
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h3>{user.username}</h3>
|
||||
<p>{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserCard;
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import Navbar from "@/app/(components)/Navbar";
|
||||
import Sidebar from "@/app/(components)/Sidebar";
|
||||
import Navbar from "@/app/components/Navbar";
|
||||
import Sidebar from "@/app/components/Sidebar";
|
||||
import StoreProvider, { useAppSelector } from "./redux";
|
||||
import AuthProvider from "./authProvider";
|
||||
|
||||
const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const isSidebarCollapsed = useAppSelector(
|
||||
@@ -35,7 +36,9 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const DashboardWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<StoreProvider>
|
||||
<DashboardLayout>{children}</DashboardLayout>
|
||||
<AuthProvider>
|
||||
<DashboardLayout>{children}</DashboardLayout>
|
||||
</AuthProvider>
|
||||
</StoreProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,11 @@ body,
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@apply text-sm;
|
||||
@apply bg-gray-500;
|
||||
@apply text-gray-900;
|
||||
@apply bg-white;
|
||||
@apply dark:bg-black;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
206
tasker-client/src/app/home/page.tsx
Normal file
206
tasker-client/src/app/home/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Priority,
|
||||
Project,
|
||||
Task,
|
||||
useGetProjectsQuery,
|
||||
useGetTasksByUserQuery,
|
||||
} from "@/state/api";
|
||||
import React from "react";
|
||||
import { useAppSelector } from "../redux";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import Header from "@/app/components/Header";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { dataGridSxStyles } from "@/lib/utils";
|
||||
|
||||
const taskColumns: GridColDef[] = [
|
||||
{ field: "title", headerName: "Title", width: 200 },
|
||||
{
|
||||
field: "status",
|
||||
headerName: "Status",
|
||||
width: 150,
|
||||
renderCell: (params) => (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
|
||||
params.value === "In Progress"
|
||||
? "bg-green-200 text-green-600"
|
||||
: params.value === "Test/Review"
|
||||
? "bg-green-200 text-green-600"
|
||||
: params.value === "Done"
|
||||
? "bg-green-400 text-green-800"
|
||||
: "bg-gray-400 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{params.value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "priority",
|
||||
headerName: "Priority",
|
||||
width: 150,
|
||||
renderCell: (params) => (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
|
||||
params.value === "Urgent"
|
||||
? "bg-red-200 text-red-700"
|
||||
: params.value === "High"
|
||||
? "bg-yellow-200 text-yellow-700"
|
||||
: params.value === "Medium"
|
||||
? "bg-green-200 text-green-700"
|
||||
: params.value === "Low"
|
||||
? "bg-blue-200 text-blue-700"
|
||||
: "bg-gray-200 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{params.value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ field: "dueDate", headerName: "Due Date", width: 150 },
|
||||
];
|
||||
|
||||
const statusColors = ["#0088FE", "#00C49F", "#FFBB28", "#FF8042"];
|
||||
|
||||
const HomePage = () => {
|
||||
const userId = 1;
|
||||
const {
|
||||
data: tasks,
|
||||
isLoading: tasksLoading,
|
||||
isError: tasksError,
|
||||
} = useGetTasksByUserQuery(userId || 0, {
|
||||
skip: userId === null,
|
||||
});
|
||||
const { data: projects, isLoading: isProjectsLoading } =
|
||||
useGetProjectsQuery();
|
||||
|
||||
const isDarkMode = useAppSelector((state) => state.global.isDarkMode);
|
||||
|
||||
if (tasksLoading || isProjectsLoading) return <div>Loading..</div>;
|
||||
if (tasksError || !tasks || !projects) return <div>Error fetching data</div>;
|
||||
|
||||
const priorityCount = tasks.reduce(
|
||||
(acc: Record<string, number>, task: Task) => {
|
||||
const { priority } = task;
|
||||
acc[priority as Priority] = (acc[priority as Priority] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const taskDistribution = Object.keys(priorityCount).map((key) => ({
|
||||
name: key,
|
||||
count: priorityCount[key],
|
||||
}));
|
||||
|
||||
const statusCount = projects.reduce(
|
||||
(acc: Record<string, number>, project: Project) => {
|
||||
const status = project.endDate ? "Completed" : "Active";
|
||||
acc[status] = (acc[status] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const projectStatus = Object.keys(statusCount).map((key) => ({
|
||||
name: key,
|
||||
count: statusCount[key],
|
||||
}));
|
||||
|
||||
const chartColors = isDarkMode
|
||||
? {
|
||||
bar: "#8884d8",
|
||||
barGrid: "#303030",
|
||||
pieFill: "#4A90E2",
|
||||
text: "#FFFFFF",
|
||||
}
|
||||
: {
|
||||
bar: "#8884d8",
|
||||
barGrid: "#E0E0E0",
|
||||
pieFill: "#82ca9d",
|
||||
text: "#000000",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container h-full w-[100%] bg-gray-100 bg-transparent p-8">
|
||||
<Header name="Project Management Dashboard" />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg bg-white p-4 shadow dark:bg-dark-secondary">
|
||||
<h3 className="mb-4 text-lg font-semibold dark:text-white">
|
||||
Task Priority Distribution
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={taskDistribution}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke={chartColors.barGrid}
|
||||
/>
|
||||
<XAxis dataKey="name" stroke={chartColors.text} />
|
||||
<YAxis stroke={chartColors.text} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
width: "min-content",
|
||||
height: "min-content",
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar dataKey="count" fill={chartColors.bar} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-4 shadow dark:bg-dark-secondary">
|
||||
<h3 className="mb-4 text-lg font-semibold dark:text-white">
|
||||
Project Status
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie dataKey="count" data={projectStatus} fill="#82ca9d" label>
|
||||
{projectStatus.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={statusColors[index % statusColors.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-4 shadow dark:bg-dark-secondary md:col-span-2">
|
||||
<h3 className="mb-4 text-lg font-semibold dark:text-white">
|
||||
Your Tasks
|
||||
</h3>
|
||||
<div style={{ height: 400, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={tasks}
|
||||
columns={taskColumns}
|
||||
checkboxSelection
|
||||
loading={tasksLoading}
|
||||
getRowClassName={() => "data-grid-row"}
|
||||
getCellClassName={() => "data-grid-cell"}
|
||||
className="border border-gray-200 bg-white shadow dark:border-stroke-dark dark:bg-dark-secondary dark:text-gray-200"
|
||||
sx={dataGridSxStyles(isDarkMode)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
@@ -1,7 +1,5 @@
|
||||
import HomePage from "./home/page";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center h-screen">
|
||||
New
|
||||
</main>
|
||||
);
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
9
tasker-client/src/app/priority/backlog/page.tsx
Normal file
9
tasker-client/src/app/priority/backlog/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReusablePriorityPage from "../reusablePriorityPage";
|
||||
import { Priority } from "@/state/api";
|
||||
|
||||
const Backlog = () => {
|
||||
return <ReusablePriorityPage priority={Priority.Backlog} />;
|
||||
};
|
||||
|
||||
export default Backlog;
|
||||
9
tasker-client/src/app/priority/high/page.tsx
Normal file
9
tasker-client/src/app/priority/high/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReusablePriorityPage from "../reusablePriorityPage";
|
||||
import { Priority } from "@/state/api";
|
||||
|
||||
const High = () => {
|
||||
return <ReusablePriorityPage priority={Priority.High} />;
|
||||
};
|
||||
|
||||
export default High;
|
||||
9
tasker-client/src/app/priority/low/page.tsx
Normal file
9
tasker-client/src/app/priority/low/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReusablePriorityPage from "../reusablePriorityPage";
|
||||
import { Priority } from "@/state/api";
|
||||
|
||||
const Low = () => {
|
||||
return <ReusablePriorityPage priority={Priority.Low} />;
|
||||
};
|
||||
|
||||
export default Low;
|
||||
9
tasker-client/src/app/priority/medium/page.tsx
Normal file
9
tasker-client/src/app/priority/medium/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReusablePriorityPage from "../reusablePriorityPage";
|
||||
import { Priority } from "@/state/api";
|
||||
|
||||
const Medium = () => {
|
||||
return <ReusablePriorityPage priority={Priority.Medium} />;
|
||||
};
|
||||
|
||||
export default Medium;
|
||||
160
tasker-client/src/app/priority/reusablePriorityPage/index.tsx
Normal file
160
tasker-client/src/app/priority/reusablePriorityPage/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useAppSelector } from "@/app/redux";
|
||||
import Header from "@/app/components/Header";
|
||||
import ModalNewTask from "@/app/components/ModalNewTask";
|
||||
import TaskCard from "@/app/components/TaskCard";
|
||||
import { dataGridSxStyles } from "@/lib/utils";
|
||||
import {
|
||||
Priority,
|
||||
Task,
|
||||
useGetAuthUserQuery,
|
||||
useGetTasksByUserQuery,
|
||||
} from "@/state/api";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import React, { useState } from "react";
|
||||
|
||||
type Props = {
|
||||
priority: Priority;
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "title",
|
||||
headerName: "Title",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: "description",
|
||||
headerName: "Description",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
field: "status",
|
||||
headerName: "Status",
|
||||
width: 130,
|
||||
renderCell: (params) => (
|
||||
<span className="inline-flex rounded-full bg-green-100 px-2 text-xs font-semibold leading-5 text-green-800">
|
||||
{params.value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "priority",
|
||||
headerName: "Priority",
|
||||
width: 75,
|
||||
},
|
||||
{
|
||||
field: "tags",
|
||||
headerName: "Tags",
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
field: "startDate",
|
||||
headerName: "Start Date",
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
field: "dueDate",
|
||||
headerName: "Due Date",
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
field: "author",
|
||||
headerName: "Author",
|
||||
width: 150,
|
||||
renderCell: (params) => params.value.username || "Unknown",
|
||||
},
|
||||
{
|
||||
field: "assignee",
|
||||
headerName: "Assignee",
|
||||
width: 150,
|
||||
renderCell: (params) => params.value.username || "Unassigned",
|
||||
},
|
||||
];
|
||||
|
||||
const ReusablePriorityPage = ({ priority }: Props) => {
|
||||
const [view, setView] = useState("list");
|
||||
const [isModalNewTaskOpen, setIsModalNewTaskOpen] = useState(false);
|
||||
|
||||
const { data: currentUser } = useGetAuthUserQuery({});
|
||||
const userId = currentUser?.userDetails?.userId ?? null;
|
||||
const {
|
||||
data: tasks,
|
||||
isLoading,
|
||||
isError: isTasksError,
|
||||
} = useGetTasksByUserQuery(userId || 0, {
|
||||
skip: userId === null,
|
||||
});
|
||||
|
||||
const isDarkMode = useAppSelector((state) => state.global.isDarkMode);
|
||||
|
||||
const filteredTasks = tasks?.filter(
|
||||
(task: Task) => task.priority === priority,
|
||||
);
|
||||
|
||||
if (isTasksError || !tasks) return <div>Error fetching tasks</div>;
|
||||
|
||||
return (
|
||||
<div className="m-5 p-4">
|
||||
<ModalNewTask
|
||||
isOpen={isModalNewTaskOpen}
|
||||
onClose={() => setIsModalNewTaskOpen(false)}
|
||||
/>
|
||||
<Header
|
||||
name="Priority Page"
|
||||
buttonComponent={
|
||||
<button
|
||||
className="mr-3 rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
|
||||
onClick={() => setIsModalNewTaskOpen(true)}
|
||||
>
|
||||
Add Task
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="mb-4 flex justify-start">
|
||||
<button
|
||||
className={`px-4 py-2 ${
|
||||
view === "list" ? "bg-gray-300" : "bg-white"
|
||||
} rounded-l`}
|
||||
onClick={() => setView("list")}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 ${
|
||||
view === "table" ? "bg-gray-300" : "bg-white"
|
||||
} rounded-l`}
|
||||
onClick={() => setView("table")}
|
||||
>
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div>Loading tasks...</div>
|
||||
) : view === "list" ? (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{filteredTasks?.map((task: Task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
view === "table" &&
|
||||
filteredTasks && (
|
||||
<div className="z-0 w-full">
|
||||
<DataGrid
|
||||
rows={filteredTasks}
|
||||
columns={columns}
|
||||
checkboxSelection
|
||||
getRowId={(row) => row.id}
|
||||
className="border border-gray-200 bg-white shadow dark:border-stroke-dark dark:bg-dark-secondary dark:text-gray-200"
|
||||
sx={dataGridSxStyles(isDarkMode)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReusablePriorityPage;
|
||||
9
tasker-client/src/app/priority/urgent/page.tsx
Normal file
9
tasker-client/src/app/priority/urgent/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReusablePriorityPage from "../reusablePriorityPage";
|
||||
import { Priority } from "@/state/api";
|
||||
|
||||
const Urgent = () => {
|
||||
return <ReusablePriorityPage priority={Priority.Urgent} />;
|
||||
};
|
||||
|
||||
export default Urgent;
|
||||
260
tasker-client/src/app/projects/BoardView/index.tsx
Normal file
260
tasker-client/src/app/projects/BoardView/index.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useGetTasksQuery, useUpdateTaskStatusMutation } from "@/state/api";
|
||||
import React from "react";
|
||||
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 taskStatus = ["To Do", "In Progress", "Test/Review", "Done"];
|
||||
|
||||
const BoardView = ({ id, setIsModalNewTaskOpen }: BoardProps) => {
|
||||
const {
|
||||
data: tasks,
|
||||
isLoading,
|
||||
error,
|
||||
} = useGetTasksQuery({ projectId: Number(id) });
|
||||
const [updateTaskStatus] = useUpdateTaskStatusMutation();
|
||||
|
||||
const moveTask = (taskId: number, toStatus: string) => {
|
||||
updateTaskStatus({ taskId, status: toStatus });
|
||||
};
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>An error occurred while fetching tasks</div>;
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{taskStatus.map((status) => (
|
||||
<TaskColumn
|
||||
key={status}
|
||||
status={status}
|
||||
tasks={tasks || []}
|
||||
moveTask={moveTask}
|
||||
setIsModalNewTaskOpen={setIsModalNewTaskOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DndProvider>
|
||||
);
|
||||
};
|
||||
|
||||
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: any) => ({
|
||||
isOver: !!monitor.isOver(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const tasksCount = tasks.filter((task) => task.status === status).length;
|
||||
|
||||
const statusColors: any = {
|
||||
"To Do": "#800000",
|
||||
"In Progress": "#efcc00",
|
||||
"Test/Review": "#00008b",
|
||||
Done: "#006400",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(instance) => {
|
||||
drop(instance);
|
||||
}}
|
||||
className={`sl:py-4 rounded-lg py-2 xl:px-2 ${isOver ? "bg-blue-100 dark:bg-neutral-950" : ""}`}
|
||||
>
|
||||
<div className="mb-3 flex w-full">
|
||||
<div
|
||||
className={`w-2 !bg-[${statusColors[status]}] rounded-s-lg`}
|
||||
style={{ backgroundColor: statusColors[status] }}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between rounded-e-lg bg-white px-5 py-4 dark:bg-dark-secondary">
|
||||
<h3 className="flex items-center text-lg font-semibold dark:text-white">
|
||||
{status}{" "}
|
||||
<span
|
||||
className="ml-2 inline-block rounded-full bg-gray-200 p-1 text-center text-sm leading-none dark:bg-dark-tertiary"
|
||||
style={{ width: "1.5rem", height: "1.5rem" }}
|
||||
>
|
||||
{tasksCount}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<button className="flex h-6 w-5 items-center justify-center dark:text-neutral-500">
|
||||
<EllipsisVertical size={26} />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-gray-200 dark:bg-dark-tertiary dark:text-white"
|
||||
onClick={() => setIsModalNewTaskOpen(true)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tasks
|
||||
.filter((task) => task.status === status)
|
||||
.map((task) => (
|
||||
<Task key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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"] }) => (
|
||||
<div
|
||||
className={`rounded-full px-2 py-1 text-xs font-semibold ${
|
||||
priority === "Urgent"
|
||||
? "bg-red-200 text-red-700"
|
||||
: priority === "High"
|
||||
? "bg-yellow-200 text-yellow-700"
|
||||
: priority === "Medium"
|
||||
? "bg-green-200 text-green-700"
|
||||
: priority === "Low"
|
||||
? "bg-blue-200 text-blue-700"
|
||||
: "bg-gray-200 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{priority}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(instance) => {
|
||||
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 && (
|
||||
<Image
|
||||
src={`/${task.attachments[0].fileURL}`}
|
||||
alt={task.attachments[0].fileName}
|
||||
width={400}
|
||||
height={200}
|
||||
className="h-auto w-full rounded-t-md"
|
||||
/>
|
||||
)}
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
{task.priority && <PriorityTag priority={task.priority} />}
|
||||
<div className="flex gap-2">
|
||||
{taskTagsSplit.map((tag) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="rounded-full bg-blue-100 px-2 py-1 text-xs"
|
||||
>
|
||||
{" "}
|
||||
{tag}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button className="flex h-6 w-4 flex-shrink-0 items-center justify-center dark:text-neutral-500">
|
||||
<EllipsisVertical size={26} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="my-3 flex justify-between">
|
||||
<h4 className="text-md font-bold dark:text-white">{task.title}</h4>
|
||||
{typeof task.points === "number" && (
|
||||
<div className="text-xs font-semibold dark:text-white">
|
||||
{task.points} pts
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-neutral-500">
|
||||
{formattedStartDate && <span>{formattedStartDate} - </span>}
|
||||
{formattedDueDate && <span>{formattedDueDate}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-500">
|
||||
{task.description}
|
||||
</p>
|
||||
<div className="mt-4 border-t border-gray-200 dark:border-stroke-dark" />
|
||||
|
||||
{/* Users */}
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="flex -space-x-[6px] overflow-hidden">
|
||||
{task.assignee && (
|
||||
<Image
|
||||
key={task.assignee.userId}
|
||||
src={`/${task.assignee.profilePictureUrl!}`}
|
||||
alt={task.assignee.username}
|
||||
width={30}
|
||||
height={30}
|
||||
className="h-8 w-8 rounded-full border-2 border-white object-cover dark:border-dark-secondary"
|
||||
/>
|
||||
)}
|
||||
{task.author && (
|
||||
<Image
|
||||
key={task.author.userId}
|
||||
src={`/${task.author.profilePictureUrl!}`}
|
||||
alt={task.author.username}
|
||||
width={30}
|
||||
height={30}
|
||||
className="h-8 w-8 rounded-full border-2 border-white object-cover dark:border-dark-secondary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center text-gray-500 dark:text-neutral-500">
|
||||
<MessageSquareMore size={20} />
|
||||
<span className="ml-1 text-sm dark:text-neutral-400">
|
||||
{numberOfComments}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardView;
|
||||
44
tasker-client/src/app/projects/ListView/index.tsx
Normal file
44
tasker-client/src/app/projects/ListView/index.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import Header from "@/app/components/Header";
|
||||
import TaskCard from "@/app/components/TaskCard";
|
||||
import { Task, useGetTasksQuery } from "@/state/api";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
setIsModalNewTaskOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const ListView = ({ id, setIsModalNewTaskOpen }: Props) => {
|
||||
const {
|
||||
data: tasks,
|
||||
error,
|
||||
isLoading,
|
||||
} = useGetTasksQuery({ projectId: Number(id) });
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>An error occurred while fetching tasks</div>;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-8 xl:px-6">
|
||||
<div className="pt-5">
|
||||
<Header
|
||||
name="Task List"
|
||||
buttonComponent={
|
||||
<button
|
||||
className="flex items-center rounded bg-blue-primary px-3 py-2 text-white hover:bg-blue-600"
|
||||
onClick={() => setIsModalNewTaskOpen(true)}
|
||||
>
|
||||
Add Task
|
||||
</button>
|
||||
}
|
||||
isSmallText
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 lg:gap-6">
|
||||
{tasks?.map((task: Task) => <TaskCard key={task.id} task={task} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListView;
|
||||
93
tasker-client/src/app/projects/ModalNewProject/index.tsx
Normal file
93
tasker-client/src/app/projects/ModalNewProject/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import Modal from "@/app/components/Modal";
|
||||
import { useCreateProjectMutation } from "@/state/api";
|
||||
import React, { useState } from "react";
|
||||
import { formatISO } from "date-fns";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ModalNewProject = ({ isOpen, onClose }: Props) => {
|
||||
const [createProject, { isLoading }] = useCreateProjectMutation();
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!projectName || !startDate || !endDate) return;
|
||||
|
||||
const formattedStartDate = formatISO(new Date(startDate), {
|
||||
representation: "complete",
|
||||
});
|
||||
const formattedEndDate = formatISO(new Date(endDate), {
|
||||
representation: "complete",
|
||||
});
|
||||
|
||||
await createProject({
|
||||
name: projectName,
|
||||
description,
|
||||
startDate: formattedStartDate,
|
||||
endDate: formattedEndDate,
|
||||
});
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
return projectName && description && startDate && endDate;
|
||||
};
|
||||
|
||||
const inputStyles =
|
||||
"w-full rounded border border-gray-300 p-2 shadow-sm dark:border-dark-tertiary dark:bg-dark-tertiary dark:text-white dark:focus:outline-none";
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} name="Create New Project">
|
||||
<form
|
||||
className="mt-4 space-y-6"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className={inputStyles}
|
||||
placeholder="Project Name"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={inputStyles}
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 sm:gap-2">
|
||||
<input
|
||||
type="date"
|
||||
className={inputStyles}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
className={inputStyles}
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className={`focus-offset-2 mt-4 flex w-full justify-center rounded-md border border-transparent bg-blue-primary px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-600 ${
|
||||
!isFormValid() || isLoading ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}
|
||||
disabled={!isFormValid() || isLoading}
|
||||
>
|
||||
{isLoading ? "Creating..." : "Create Project"}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalNewProject;
|
||||
114
tasker-client/src/app/projects/ProjectHeader.tsx
Normal file
114
tasker-client/src/app/projects/ProjectHeader.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
Clock,
|
||||
Filter,
|
||||
Grid3x3,
|
||||
List,
|
||||
PlusSquare,
|
||||
Share2,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import Header from "@/app/components/Header";
|
||||
import ModalNewProject from "./ModalNewProject";
|
||||
|
||||
type Props = {
|
||||
activeTab: string;
|
||||
setActiveTab: (tab: string) => void;
|
||||
};
|
||||
|
||||
const ProjectHeader = ({ activeTab, setActiveTab }: Props) => {
|
||||
const [isModalNewProjectOpen, setIsModalNewProjectOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="px-4 xl:px-6">
|
||||
<ModalNewProject
|
||||
isOpen={isModalNewProjectOpen}
|
||||
onClose={() => setIsModalNewProjectOpen(false)}
|
||||
/>
|
||||
<div className="pb-6 pt-6 lg:pb-4 lg:pt-8">
|
||||
<Header
|
||||
name="Product Design Development"
|
||||
buttonComponent={
|
||||
<button
|
||||
className="flex items-center rounded-md bg-blue-primary px-3 py-2 text-white hover:bg-blue-600"
|
||||
onClick={() => setIsModalNewProjectOpen(true)}
|
||||
>
|
||||
<PlusSquare className="mr-2 h-5 w-5" /> New Boards
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TABS */}
|
||||
<div className="flex flex-wrap-reverse gap-2 border-y border-gray-200 pb-[8px] pt-2 dark:border-stroke-dark md:items-center">
|
||||
<div className="flex flex-1 items-center gap-2 md:gap-4">
|
||||
<TabButton
|
||||
name="Board"
|
||||
icon={<Grid3x3 className="h-5 w-5" />}
|
||||
setActiveTab={setActiveTab}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
<TabButton
|
||||
name="List"
|
||||
icon={<List className="h-5 w-5" />}
|
||||
setActiveTab={setActiveTab}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
<TabButton
|
||||
name="Timeline"
|
||||
icon={<Clock className="h-5 w-5" />}
|
||||
setActiveTab={setActiveTab}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
<TabButton
|
||||
name="Table"
|
||||
icon={<Table className="h-5 w-5" />}
|
||||
setActiveTab={setActiveTab}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="text-gray-500 hover:text-gray-600 dark:text-neutral-500 dark:hover:text-gray-300">
|
||||
<Filter className="h-5 w-5" />
|
||||
</button>
|
||||
<button className="text-gray-500 hover:text-gray-600 dark:text-neutral-500 dark:hover:text-gray-300">
|
||||
<Share2 className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for Task"
|
||||
className="rounded-md border py-1 pl-10 pr-4 focus:outline-none dark:border-dark-secondary dark:bg-dark-secondary dark:text-white"
|
||||
/>
|
||||
<Grid3x3 className="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-neutral-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<button
|
||||
className={`relative flex items-center gap-2 px-1 py-2 text-gray-500 after:absolute after:-bottom-[9px] after:left-0 after:h-[1px] after:w-full hover:text-blue-600 dark:text-neutral-500 dark:hover:text-white sm:px-2 lg:px-4 ${
|
||||
isActive ? "text-blue-600 after:bg-blue-600 dark:text-white" : ""
|
||||
}`}
|
||||
onClick={() => setActiveTab(name)}
|
||||
>
|
||||
{icon}
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectHeader;
|
||||
133
tasker-client/src/app/projects/TableView/index.tsx
Normal file
133
tasker-client/src/app/projects/TableView/index.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useAppSelector } from "@/app/redux";
|
||||
import Header from "@/app/components/Header";
|
||||
import { dataGridSxStyles } from "@/lib/utils";
|
||||
import { useGetTasksQuery } from "@/state/api";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
setIsModalNewTaskOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "title",
|
||||
headerName: "Title",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: "description",
|
||||
headerName: "Description",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
field: "status",
|
||||
headerName: "Status",
|
||||
width: 130,
|
||||
renderCell: (params) => (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
|
||||
params.value === "In Progress"
|
||||
? "bg-green-200 text-green-600"
|
||||
: params.value === "Test/Review"
|
||||
? "bg-green-200 text-green-600"
|
||||
: params.value === "Done"
|
||||
? "bg-green-400 text-green-800"
|
||||
: "bg-gray-400 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{params.value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "priority",
|
||||
headerName: "Priority",
|
||||
width: 75,
|
||||
renderCell: (params) => (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
|
||||
params.value === "Urgent"
|
||||
? "bg-red-200 text-red-700"
|
||||
: params.value === "High"
|
||||
? "bg-yellow-200 text-yellow-700"
|
||||
: params.value === "Medium"
|
||||
? "bg-green-200 text-green-700"
|
||||
: params.value === "Low"
|
||||
? "bg-blue-200 text-blue-700"
|
||||
: "bg-gray-200 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{params.value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "tags",
|
||||
headerName: "Tags",
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
field: "startDate",
|
||||
headerName: "Start Date",
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
field: "dueDate",
|
||||
headerName: "Due Date",
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
field: "author",
|
||||
headerName: "Author",
|
||||
width: 150,
|
||||
renderCell: (params) => params.value?.author || "Unknown",
|
||||
},
|
||||
{
|
||||
field: "assignee",
|
||||
headerName: "Assignee",
|
||||
width: 150,
|
||||
renderCell: (params) => params.value?.assignee || "Unassigned",
|
||||
},
|
||||
];
|
||||
|
||||
const TableView = ({ id, setIsModalNewTaskOpen }: Props) => {
|
||||
const isDarkMode = useAppSelector((state) => state.global.isDarkMode);
|
||||
const {
|
||||
data: tasks,
|
||||
error,
|
||||
isLoading,
|
||||
} = useGetTasksQuery({ projectId: Number(id) });
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error || !tasks) return <div>An error occurred while fetching tasks</div>;
|
||||
|
||||
return (
|
||||
<div className="h-[540px] w-full px-4 pb-8 xl:px-6">
|
||||
<div className="pt-5">
|
||||
<Header
|
||||
name="Table"
|
||||
buttonComponent={
|
||||
<button
|
||||
className="flex items-center rounded bg-blue-primary px-3 py-2 text-white hover:bg-blue-600"
|
||||
onClick={() => setIsModalNewTaskOpen(true)}
|
||||
>
|
||||
Add Task
|
||||
</button>
|
||||
}
|
||||
isSmallText
|
||||
/>
|
||||
</div>
|
||||
<DataGrid
|
||||
rows={tasks || []}
|
||||
columns={columns}
|
||||
className="border border-gray-200 bg-white shadow dark:border-stroke-dark dark:bg-dark-secondary dark:text-gray-200"
|
||||
sx={dataGridSxStyles(isDarkMode)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableView;
|
||||
96
tasker-client/src/app/projects/TimelineView/index.tsx
Normal file
96
tasker-client/src/app/projects/TimelineView/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useAppSelector } from "@/app/redux";
|
||||
import { useGetTasksQuery } from "@/state/api";
|
||||
import { DisplayOption, Gantt, ViewMode } from "gantt-task-react";
|
||||
import "gantt-task-react/dist/index.css";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
setIsModalNewTaskOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type TaskTypeItems = "task" | "milestone" | "project";
|
||||
|
||||
const Timeline = ({ id, setIsModalNewTaskOpen }: Props) => {
|
||||
const isDarkMode = useAppSelector((state) => state.global.isDarkMode);
|
||||
const {
|
||||
data: tasks,
|
||||
error,
|
||||
isLoading,
|
||||
} = useGetTasksQuery({ projectId: Number(id) });
|
||||
|
||||
const [displayOptions, setDisplayOptions] = useState<DisplayOption>({
|
||||
viewMode: ViewMode.Month,
|
||||
locale: "en-US",
|
||||
});
|
||||
|
||||
const ganttTasks = useMemo(() => {
|
||||
return (
|
||||
tasks?.map((task) => ({
|
||||
start: new Date(task.startDate as string),
|
||||
end: new Date(task.dueDate as string),
|
||||
name: task.title,
|
||||
id: `Task-${task.id}`,
|
||||
type: "task" as TaskTypeItems,
|
||||
progress: task.points ? (task.points / 10) * 100 : 0,
|
||||
isDisabled: false,
|
||||
})) || []
|
||||
);
|
||||
}, [tasks]);
|
||||
|
||||
const handleViewModeChange = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>,
|
||||
) => {
|
||||
setDisplayOptions((prev) => ({
|
||||
...prev,
|
||||
viewMode: event.target.value as ViewMode,
|
||||
}));
|
||||
};
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error || !tasks) return <div>An error occurred while fetching tasks</div>;
|
||||
|
||||
return (
|
||||
<div className="px-4 xl:px-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 py-5">
|
||||
<h1 className="me-2 text-lg font-bold dark:text-white">
|
||||
Project Timeline
|
||||
</h1>
|
||||
<div className="relative inline-block w-64">
|
||||
<select
|
||||
className="focus:shadow-outline block w-full appearance-none rounded border border-gray-400 bg-white px-4 py-2 pr-8 leading-tight shadow hover:border-gray-500 focus:outline-none dark:border-dark-secondary dark:bg-dark-secondary dark:text-white"
|
||||
value={displayOptions.viewMode}
|
||||
onChange={handleViewModeChange}
|
||||
>
|
||||
<option value={ViewMode.Day}>Day</option>
|
||||
<option value={ViewMode.Week}>Week</option>
|
||||
<option value={ViewMode.Month}>Month</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md bg-white shadow dark:bg-dark-secondary dark:text-white">
|
||||
<div className="timeline">
|
||||
<Gantt
|
||||
tasks={ganttTasks}
|
||||
{...displayOptions}
|
||||
columnWidth={displayOptions.viewMode === ViewMode.Month ? 150 : 100}
|
||||
listCellWidth="100px"
|
||||
barBackgroundColor={isDarkMode ? "#101214" : "#aeb8c2"}
|
||||
barBackgroundSelectedColor={isDarkMode ? "#000" : "#9ba1a6"}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pb-5 pt-1">
|
||||
<button
|
||||
className="flex items-center rounded bg-blue-primary px-3 py-2 text-white hover:bg-blue-600"
|
||||
onClick={() => setIsModalNewTaskOpen(true)}
|
||||
>
|
||||
Add New Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timeline;
|
||||
44
tasker-client/src/app/projects/[id]/page.tsx
Normal file
44
tasker-client/src/app/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import React, { use, useState } from "react";
|
||||
import ProjectHeader from "@/app/projects/ProjectHeader";
|
||||
import Board from "../BoardView";
|
||||
import List from "../ListView";
|
||||
import Timeline from "../TimelineView";
|
||||
import Table from "../TableView";
|
||||
import ModalNewTask from "@/app/components/ModalNewTask";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
const Project = ({ params }: Props) => {
|
||||
const { id } = use(params);
|
||||
const [activeTab, setActiveTab] = useState("Board");
|
||||
const [isModalNewTaskOpen, setIsModalNewTaskOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ModalNewTask
|
||||
isOpen={isModalNewTaskOpen}
|
||||
onClose={() => setIsModalNewTaskOpen(false)}
|
||||
id={id}
|
||||
/>
|
||||
<ProjectHeader activeTab={activeTab} setActiveTab={setActiveTab} />
|
||||
{activeTab === "Board" && (
|
||||
<Board id={id} setIsModalNewTaskOpen={setIsModalNewTaskOpen} />
|
||||
)}
|
||||
{activeTab === "List" && (
|
||||
<List id={id} setIsModalNewTaskOpen={setIsModalNewTaskOpen} />
|
||||
)}
|
||||
{activeTab === "Timeline" && (
|
||||
<Timeline id={id} setIsModalNewTaskOpen={setIsModalNewTaskOpen} />
|
||||
)}
|
||||
{activeTab === "Table" && (
|
||||
<Table id={id} setIsModalNewTaskOpen={setIsModalNewTaskOpen} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Project;
|
||||
75
tasker-client/src/app/search/page.tsx
Normal file
75
tasker-client/src/app/search/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import Header from "@/app/components/Header";
|
||||
import ProjectCard from "@/app/components/ProjectCard";
|
||||
import TaskCard from "@/app/components/TaskCard";
|
||||
import UserCard from "@/app/components/UserCard";
|
||||
import { useSearchQuery } from "@/state/api";
|
||||
import { debounce } from "lodash";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
const Search = () => {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const {
|
||||
data: searchResults,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useSearchQuery(searchTerm, {
|
||||
skip: searchTerm.length < 3,
|
||||
});
|
||||
|
||||
const handleSearch = debounce(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(event.target.value);
|
||||
},
|
||||
500,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return handleSearch.cancel;
|
||||
}, [handleSearch.cancel]);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Header name="Search" />
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-1/2 rounded border p-3 shadow"
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{isLoading && <p>Loading...</p>}
|
||||
{isError && <p>Error occurred while fetching search results.</p>}
|
||||
{!isLoading && !isError && searchResults && (
|
||||
<div>
|
||||
{searchResults.tasks && searchResults.tasks?.length > 0 && (
|
||||
<h2>Tasks</h2>
|
||||
)}
|
||||
{searchResults.tasks?.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
|
||||
{searchResults.projects && searchResults.projects?.length > 0 && (
|
||||
<h2>Projects</h2>
|
||||
)}
|
||||
{searchResults.projects?.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
|
||||
{searchResults.users && searchResults.users?.length > 0 && (
|
||||
<h2>Users</h2>
|
||||
)}
|
||||
{searchResults.users?.map((user) => (
|
||||
<UserCard key={user.userId} user={user} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
||||
40
tasker-client/src/app/settings/page.tsx
Normal file
40
tasker-client/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import Header from "@/app/components/Header";
|
||||
import React from "react";
|
||||
|
||||
const Settings = () => {
|
||||
const userSettings = {
|
||||
username: "mockuser",
|
||||
email: "mockuser@example.com",
|
||||
teamName: "mockteam",
|
||||
roleName: "mockrole",
|
||||
};
|
||||
const labelStyles = "block text-sm font-medium dark:text-white";
|
||||
const textStyles =
|
||||
"mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2 dark:text-white";
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Header name="Settings" />
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={labelStyles}>Username</label>
|
||||
<div className={textStyles}>{userSettings.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelStyles}>Email</label>
|
||||
<div className={textStyles}>{userSettings.email}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelStyles}>Team</label>
|
||||
<div className={textStyles}>{userSettings.teamName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelStyles}>Role</label>
|
||||
<div className={textStyles}>{userSettings.roleName}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
59
tasker-client/src/app/teams/page.tsx
Normal file
59
tasker-client/src/app/teams/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
import { useGetTeamsQuery } from "@/state/api";
|
||||
import React from "react";
|
||||
import { useAppSelector } from "../redux";
|
||||
import Header from "@/app/components/Header";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridToolbarContainer,
|
||||
GridToolbarExport,
|
||||
GridToolbarFilterButton,
|
||||
} from "@mui/x-data-grid";
|
||||
import { dataGridSxStyles } from "@/lib/utils";
|
||||
|
||||
const CustomToolbar = () => (
|
||||
<GridToolbarContainer className="toolbar flex gap-2">
|
||||
<GridToolbarFilterButton />
|
||||
<GridToolbarExport />
|
||||
</GridToolbarContainer>
|
||||
);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "id", headerName: "Team ID", width: 100 },
|
||||
{ field: "teamName", headerName: "Team Name", width: 200 },
|
||||
{ field: "productOwnerUsername", headerName: "Product Owner", width: 200 },
|
||||
{
|
||||
field: "projectManagerUsername",
|
||||
headerName: "Project Manager",
|
||||
width: 200,
|
||||
},
|
||||
];
|
||||
|
||||
const Teams = () => {
|
||||
const { data: teams, isLoading, isError } = useGetTeamsQuery();
|
||||
const isDarkMode = useAppSelector((state) => state.global.isDarkMode);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (isError || !teams) return <div>Error fetching teams</div>;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col p-8">
|
||||
<Header name="Teams" />
|
||||
<div style={{ height: 650, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={teams || []}
|
||||
columns={columns}
|
||||
pagination
|
||||
slots={{
|
||||
toolbar: CustomToolbar,
|
||||
}}
|
||||
className="border border-gray-200 bg-white shadow dark:border-stroke-dark dark:bg-dark-secondary dark:text-gray-200"
|
||||
sx={dataGridSxStyles(isDarkMode)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Teams;
|
||||
82
tasker-client/src/app/timeline/page.tsx
Normal file
82
tasker-client/src/app/timeline/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useAppSelector } from "@/app/redux";
|
||||
import Header from "@/app/components/Header";
|
||||
import { useGetProjectsQuery } from "@/state/api";
|
||||
import { DisplayOption, Gantt, ViewMode } from "gantt-task-react";
|
||||
import "gantt-task-react/dist/index.css";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
type TaskTypeItems = "task" | "milestone" | "project";
|
||||
|
||||
const Timeline = () => {
|
||||
const isDarkMode = useAppSelector((state) => state.global.isDarkMode);
|
||||
const { data: projects, isLoading, isError } = useGetProjectsQuery();
|
||||
|
||||
const [displayOptions, setDisplayOptions] = useState<DisplayOption>({
|
||||
viewMode: ViewMode.Month,
|
||||
locale: "en-US",
|
||||
});
|
||||
|
||||
const ganttTasks = useMemo(() => {
|
||||
return (
|
||||
projects?.map((project) => ({
|
||||
start: new Date(project.startDate as string),
|
||||
end: new Date(project.endDate as string),
|
||||
name: project.name,
|
||||
id: `Project-${project.id}`,
|
||||
type: "project" as TaskTypeItems,
|
||||
progress: 50,
|
||||
isDisabled: false,
|
||||
})) || []
|
||||
);
|
||||
}, [projects]);
|
||||
|
||||
const handleViewModeChange = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>,
|
||||
) => {
|
||||
setDisplayOptions((prev) => ({
|
||||
...prev,
|
||||
viewMode: event.target.value as ViewMode,
|
||||
}));
|
||||
};
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (isError || !projects)
|
||||
return <div>An error occurred while fetching projects</div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-full p-8">
|
||||
<header className="mb-4 flex items-center justify-between">
|
||||
<Header name="Projects Timeline" />
|
||||
<div className="relative inline-block w-64">
|
||||
<select
|
||||
className="focus:shadow-outline block w-full appearance-none rounded border border-gray-400 bg-white px-4 py-2 pr-8 leading-tight shadow hover:border-gray-500 focus:outline-none dark:border-dark-secondary dark:bg-dark-secondary dark:text-white"
|
||||
value={displayOptions.viewMode}
|
||||
onChange={handleViewModeChange}
|
||||
>
|
||||
<option value={ViewMode.Day}>Day</option>
|
||||
<option value={ViewMode.Week}>Week</option>
|
||||
<option value={ViewMode.Month}>Month</option>
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="overflow-hidden rounded-md bg-white shadow dark:bg-dark-secondary dark:text-white">
|
||||
<div className="timeline">
|
||||
<Gantt
|
||||
tasks={ganttTasks}
|
||||
{...displayOptions}
|
||||
columnWidth={displayOptions.viewMode === ViewMode.Month ? 150 : 100}
|
||||
listCellWidth="100px"
|
||||
projectBackgroundColor={isDarkMode ? "#101214" : "#1f2937"}
|
||||
projectProgressColor={isDarkMode ? "#1f2937" : "#aeb8c2"}
|
||||
projectProgressSelectedColor={isDarkMode ? "#000" : "#9ba1a6"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Timeline;
|
||||
73
tasker-client/src/app/users/page.tsx
Normal file
73
tasker-client/src/app/users/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import { useGetUsersQuery } from "@/state/api";
|
||||
import { useAppSelector } from "@/app/redux";
|
||||
import React from "react";
|
||||
import Header from "@/app/components/Header";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridToolbarContainer,
|
||||
GridToolbarExport,
|
||||
GridToolbarFilterButton,
|
||||
} from "@mui/x-data-grid";
|
||||
import Image from "next/image";
|
||||
import { dataGridSxStyles } from "@/lib/utils";
|
||||
|
||||
const CustomToolbar = () => (
|
||||
<GridToolbarContainer className="toolbar flex gap-2">
|
||||
<GridToolbarFilterButton />
|
||||
<GridToolbarExport />
|
||||
</GridToolbarContainer>
|
||||
);
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "userId", headerName: "ID", width: 100 },
|
||||
{ field: "username", headerName: "Username", width: 150 },
|
||||
{
|
||||
field: "profilePictureUrl",
|
||||
headerName: "Profile Picture",
|
||||
width: 100,
|
||||
renderCell: (params) => (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-9 w-9">
|
||||
<Image
|
||||
src={`/${params.value}`}
|
||||
alt={params.row.username}
|
||||
width={100}
|
||||
height={50}
|
||||
className="h-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const Users = () => {
|
||||
const { data: users, isLoading, isError } = useGetUsersQuery();
|
||||
const isDarkMode = useAppSelector((state) => state.global.isDarkMode);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (isError || !users) return <div>Error fetching users</div>;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col p-8">
|
||||
<Header name="Users" />
|
||||
<div style={{ height: 650, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={users || []}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.userId}
|
||||
pagination
|
||||
slots={{
|
||||
toolbar: CustomToolbar,
|
||||
}}
|
||||
className="border border-gray-200 bg-white shadow dark:border-stroke-dark dark:bg-dark-secondary dark:text-gray-200"
|
||||
sx={dataGridSxStyles(isDarkMode)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
29
tasker-client/src/lib/utils.ts
Normal file
29
tasker-client/src/lib/utils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export const dataGridSxStyles = (isDarkMode: boolean) => {
|
||||
return {
|
||||
"& .MuiDataGrid-columnHeaders": {
|
||||
color: `${isDarkMode ? "#e5e7eb" : ""}`,
|
||||
'& [role="row"] > *': {
|
||||
backgroundColor: `${isDarkMode ? "#1d1f21" : "white"}`,
|
||||
borderColor: `${isDarkMode ? "#2d3135" : ""}`,
|
||||
},
|
||||
},
|
||||
"& .MuiIconbutton-root": {
|
||||
color: `${isDarkMode ? "#a3a3a3" : ""}`,
|
||||
},
|
||||
"& .MuiTablePagination-root": {
|
||||
color: `${isDarkMode ? "#a3a3a3" : ""}`,
|
||||
},
|
||||
"& .MuiTablePagination-selectIcon": {
|
||||
color: `${isDarkMode ? "#a3a3a3" : ""}`,
|
||||
},
|
||||
"& .MuiDataGrid-cell": {
|
||||
border: "none",
|
||||
},
|
||||
"& .MuiDataGrid-row": {
|
||||
borderBottom: `1px solid ${isDarkMode ? "#2d3135" : "e5e7eb"}`,
|
||||
},
|
||||
"& .MuiDataGrid-withBorderColor": {
|
||||
borderColor: `${isDarkMode ? "#2d3135" : "e5e7eb"}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,10 +1,180 @@
|
||||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query";
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
||||
import { fetchAuthSession, getCurrentUser } from "aws-amplify/auth";
|
||||
|
||||
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 {
|
||||
ToDo = "To Do",
|
||||
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,
|
||||
prepareHeaders: async (headers) => {
|
||||
const session = await fetchAuthSession();
|
||||
const { accessToken } = session.tokens ?? {};
|
||||
if (accessToken) {
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
reducerPath: "api",
|
||||
tagTypes: ["Task"],
|
||||
endpoints: (builder) => ({}),
|
||||
tagTypes: ["Projects", "Tasks", "Users", "Teams"],
|
||||
endpoints: (build) => ({
|
||||
getAuthUser: build.query({
|
||||
queryFn: async (_, _queryApi, _extraoptions, fetchWithBQ) => {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
const session = await fetchAuthSession();
|
||||
if (!session) throw new Error("No session found");
|
||||
|
||||
const { userSub } = session;
|
||||
|
||||
const userDetailsResponse = await fetchWithBQ(`users/${userSub}`);
|
||||
const userDetails = userDetailsResponse.data as User;
|
||||
|
||||
return { data: { user, userSub, userDetails } };
|
||||
} catch (error: any) {
|
||||
return { error: error.message || "Could not fetch user data" };
|
||||
}
|
||||
},
|
||||
}),
|
||||
getProjects: build.query<Project[], void>({
|
||||
query: () => "projects",
|
||||
providesTags: ["Projects"],
|
||||
}),
|
||||
createProject: build.mutation<Project, Partial<Project>>({
|
||||
query: (project) => ({
|
||||
url: "projects",
|
||||
method: "POST",
|
||||
body: project,
|
||||
}),
|
||||
invalidatesTags: ["Projects"],
|
||||
}),
|
||||
getTasks: build.query<Task[], { projectId: number }>({
|
||||
query: ({ projectId }) => `tasks?projectId=${projectId}`,
|
||||
providesTags: (result) =>
|
||||
result
|
||||
? result.map(({ id }) => ({ type: "Tasks" as const, id }))
|
||||
: [{ type: "Tasks" as const }],
|
||||
}),
|
||||
getTasksByUser: build.query<Task[], number>({
|
||||
query: (userId) => `tasks/user/${userId}`,
|
||||
providesTags: (result, error, userId) =>
|
||||
result
|
||||
? result.map(({ id }) => ({ type: "Tasks", id }))
|
||||
: [{ type: "Tasks", id: userId }],
|
||||
}),
|
||||
createTask: build.mutation<Task, Partial<Task>>({
|
||||
query: (task) => ({
|
||||
url: "tasks",
|
||||
method: "POST",
|
||||
body: task,
|
||||
}),
|
||||
invalidatesTags: ["Tasks"],
|
||||
}),
|
||||
updateTaskStatus: build.mutation<Task, { taskId: number; status: string }>({
|
||||
query: ({ taskId, status }) => ({
|
||||
url: `tasks/${taskId}/status`,
|
||||
method: "PATCH",
|
||||
body: { status },
|
||||
}),
|
||||
invalidatesTags: (result, error, { taskId }) => [
|
||||
{ type: "Tasks", id: taskId },
|
||||
],
|
||||
}),
|
||||
getUsers: build.query<User[], void>({
|
||||
query: () => "users",
|
||||
providesTags: ["Users"],
|
||||
}),
|
||||
getTeams: build.query<Team[], void>({
|
||||
query: () => "teams",
|
||||
providesTags: ["Teams"],
|
||||
}),
|
||||
search: build.query<SearchResults, string>({
|
||||
query: (query) => `search?query=${query}`,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {} = api;
|
||||
export const {
|
||||
useGetAuthUserQuery,
|
||||
useGetProjectsQuery,
|
||||
useCreateProjectMutation,
|
||||
useGetTasksQuery,
|
||||
useCreateTaskMutation,
|
||||
useUpdateTaskStatusMutation,
|
||||
useSearchQuery,
|
||||
useGetUsersQuery,
|
||||
useGetTeamsQuery,
|
||||
useGetTasksByUserQuery,
|
||||
} = api;
|
||||
|
||||
Reference in New Issue
Block a user