π Building a Full-Stack Project Management System T3 Stack
βLearn how to build a scalable project management system β from wireframing and authentication to dashboards and CRUD APIs β using modern web technologies like Next.js, NextAuth, and Lucid React.
π§© Step 0: Create a Wireframe and Preplan the Database Model
Before diving into code, begin by designing your wireframe. This helps visualize your appβs structure β how pages connect, what sections appear on each screen, and where users perform key actions like creating or managing projects.

After wireframing, plan your database model. Identify the entities and their relationships early to avoid future refactoring.
At minimum, youβll need tables or models for:
Users
Projects
Tasks
UserProject (to manage relationships like admin, user, etc.)

ποΈ Step 1: Create the Database Schema
Define the database structure that supports your appβs functionality. Each model should represent one part of your system β projects, tasks, users, and their associations.
model Project {
id String @id @default(cuid())
projectName String
image String @default("https://img.freepik.com/free-photo/project-management-planning-development-message-box-notification-graphic_53876-123902.jpg?semt=ais_hybrid&w=740")
description String?
task Task[]
userProject UserProject[]
}
enum Status {
Todo
In_Progress
Done
}
model Task {
id String @id @default(cuid())
deadline DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
description String?
status Status @default(Todo)
assignedTo String?
projectId String
project Project @relation(fields: [projectId], references: [id])
user User? @relation(fields: [assignedTo], references: [id])
}
enum Role {
Admin
User
}
model UserProject {
id String @id @default(cuid())
ProjectId String
userId String
role Role @default(User)
project Project @relation(fields: [ProjectId], references: [id])
user User @relation(fields: [userId], references: [id])
}
// Necessary for Next auth
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? // @db.Text
access_token String? // @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? // @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
refresh_token_expires_in Int?
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
Task Task[]
userProject UserProject[]
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
π Step 2: Set Up Authentication with NextAuth (Google & GitHub)
Add secure, user-friendly authentication using NextAuth.js. This enables login via Google and GitHub to simplify onboarding.
Steps:
- Add Google and GitHub Client IDs and secrets to your environment variables.
# Next Auth Google Provider
AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET=""
# Next Auth Github Provider
AUTH_GITHUB_ID=""
AUTH_GITHUB_SECRET=""
- Update
env.jsto load them correctly.
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
AUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string()
: z.string().optional(),
AUTH_GOOGLE_ID: z.string(),
AUTH_GOOGLE_SECRET: z.string(),
AUTH_GITHUB_ID: z.string(),
AUTH_GITHUB_SECRET: z.string(),
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
},
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
runtimeEnv: {
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,
});
Add both providers in your NextAuth configuration.
Set
allowDangerousEmailAccountLinkingtotrueto automatically link accounts with the same email.
providers: [
Google({
allowDangerousEmailAccountLinking: true,
}),
GitHub({
allowDangerousEmailAccountLinking: true,
}),
]
β οΈ Important: As noted in the Auth.js security documentation, automatic account linking between arbitrary providers can be insecure. Enable this only if you understand the implications.
π§ Step 3: Create Backend Routes
The backend handles your projectβs logic and data flow. Create routes for every major feature your app needs.
Routes to implement:
Project Management
Create project
Edit project
Delete project
Get all projects of a user
Add member
//routers/project.ts
getAllProject: protectedProcedure.query(async ({ ctx }) => {
const userProjects = await ctx.db.userProject.findMany({
where: { userId: ctx.session.user.id },
});
const projects = await Promise.all(
userProjects.map(async (up) => {
return await ctx.db.project.findMany({ where: { id: up.ProjectId } });
}),
);
return projects.flat();
}),
createProject: protectedProcedure
.input(
z.object({
projectName: z.string(),
image: z.string().optional(),
description: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const userID = ctx.session.user.id;
const project = await ctx.db.project.create({
data: {
projectName: input.projectName,
image: input.image,
description: input.description,
},
});
return await ctx.db.userProject.create({
data: {
userId: userID,
role: "Admin",
ProjectId: project.id,
},
});
}),
addMembers: protectedProcedure
.input(
z.object({
projectId: z.string(),
userEmail: z.string(),
role: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const curentUser = await ctx.db.userProject.findFirst({
where: {
userId: ctx.session.user.id,
ProjectId: input.projectId,
},
});
if (curentUser?.role === "User")
throw new TRPCError({
code: "BAD_REQUEST",
message: "user must be admin",
});
const userAdd = await ctx.db.user.findFirst({
where: {
email: input.userEmail,
},
});
if (!userAdd)
throw new TRPCError({
code: "BAD_REQUEST",
message: "user does not exist",
});
await ctx.db.userProject.create({
data: {
userId: userAdd?.id,
role: input.role as Role,
ProjectId: input.projectId,
},
});
return { success: true };
}),
editProject: protectedProcedure
.input(
z.object({
projectId: z.string(),
projectName: z.string().optional(),
image: z.string().optional(),
description: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const curentUser = await ctx.db.userProject.findFirst({
where: {
userId: ctx.session.user.id,
ProjectId: input.projectId,
},
});
if (curentUser?.role === "User")
throw new TRPCError({
code: "BAD_REQUEST",
message: "user must be admin",
});
await ctx.db.project.update({
where: {
id: input.projectId,
},
data: {
projectName: input.projectName,
image: input.image,
description: input.description,
},
});
return { success: true };
}),
deleteProject: protectedProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const curentUser = await ctx.db.userProject.findFirst({
where: {
userId: ctx.session.user.id,
ProjectId: input.projectId,
},
});
if (curentUser?.role === "User")
throw new TRPCError({
code: "BAD_REQUEST",
message: "user must be admin",
});
await ctx.db.userProject.deleteMany({
where: { ProjectId: input.projectId },
});
await ctx.db.project.delete({
where: { id: input.projectId },
});
return { success: true };
}),
Task Management
Create task
Edit task
Delete task
Get all tasks
//routers/task.ts
getAllTask: protectedProcedure.query(async ({ ctx }) => {
const userProjects = await ctx.db.userProject.findMany({
where: {
userId: ctx.session.user.id,
},
});
const task = await Promise.all(
userProjects.map(async (up) => {
const tasks = await ctx.db.task.findMany({
where: {
projectId: up.ProjectId,
},
});
return tasks;
}),
);
return task.flat();
}),
createTask: protectedProcedure
.input(
z.object({
deadline: z.date().optional(),
title: z.string(),
description: z.string().optional(),
status: z.string().optional(),
assignedTo: z.string().optional(),
projectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
type Status = "Todo" | "In_Progress" | "Done" | undefined;
return await ctx.db.task.create({
data: {
deadline: input.deadline,
title: input.title,
description: input.description,
status: input.status as Status,
projectId: input.projectId,
assignedTo: input.assignedTo,
},
});
}),
editTask: protectedProcedure
.input(
z.object({
taskId: z.string(),
deadline: z.date().optional(),
title: z.string().optional(),
description: z.string().optional(),
status: z.string().optional(),
assignedTo: z.string().optional(),
projectId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
type Status = "Todo" | "In_Progress" | "Done" | undefined;
return await ctx.db.task.update({
where: {
id: input.taskId,
},
data: {
deadline: input.deadline,
title: input.title,
description: input.description,
status: input.status as Status,
projectId: input.projectId,
assignedTo: input.assignedTo,
},
});
}),
deleteTask: protectedProcedure
.input(z.object({ taskId: z.string() }))
.mutation(async ({ input, ctx }) => {
await ctx.db.task.delete({ where: { id: input.taskId } });
return { success: true };
}),
User Management
Promote / demote member
Kick user from project
Get all
userProjectrelationsGet all users by ID
//routers/user.ts
getUserByIds: protectedProcedure
.input(z.array(z.string()))
.query(async ({ ctx, input }) => {
const users = await ctx.db.user.findMany({
where: { id: { in: input } },
});
return users;
}),
getUserProjectRelation: protectedProcedure.query(async ({ ctx }) => {
const userProjects = await ctx.db.userProject.findMany({
where: { userId: ctx.session.user.id },
});
const projects = await Promise.all(
userProjects.map(async (up) => {
return await ctx.db.userProject.findMany({
where: { ProjectId: up.ProjectId },
});
}),
);
return projects.flat();
}),
promoteUser: protectedProcedure
.input(z.object({ projectId: z.string(), userEmail: z.string() }))
.mutation(async ({ input, ctx }) => {
const curentUser = await ctx.db.userProject.findFirst({
where: {
userId: ctx.session.user.id,
ProjectId: input.projectId,
},
});
if (curentUser?.role === "User")
throw new TRPCError({
code: "BAD_REQUEST",
message: "user must be admin",
});
const userAdd = await ctx.db.user.findFirst({
where: {
email: input.userEmail,
},
});
if (!userAdd)
throw new TRPCError({
code: "BAD_REQUEST",
message: "user does not exist",
});
const userProject = await ctx.db.userProject.findFirst({
where: {
ProjectId: input.projectId,
userId: userAdd.id,
},
});
if (!userProject)
throw new TRPCError({
code: "BAD_REQUEST",
message: "user does not exist in project",
});
await ctx.db.userProject.update({
where: {
id: userProject?.id,
},
data: {
userId: userAdd.id,
role: "Admin",
ProjectId: input.projectId,
},
});
return { success: true };
}),
kickUser: protectedProcedure
.input(z.object({ projectId: z.string(), userEmail: z.string() }))
.mutation(async ({ ctx, input }) => {
const curentUser = await ctx.db.userProject.findFirst({
where: {
userId: ctx.session.user.id,
ProjectId: input.projectId,
},
});
if (curentUser?.role === "User")
throw new TRPCError({
code: "BAD_REQUEST",
message: "user must be admin",
});
const userAdd = await ctx.db.user.findFirst({
where: {
email: input.userEmail,
},
});
if (!userAdd)
throw new TRPCError({
code: "BAD_REQUEST",
message: "user does not exist",
});
const userProject = await ctx.db.userProject.findFirst({
where: {
ProjectId: input.projectId,
userId: userAdd.id,
},
});
if (!userProject)
throw new TRPCError({
code: "BAD_REQUEST",
message: "user does not exist in project",
});
await ctx.db.userProject.delete({
where: {
id: userProject?.id,
},
});
return { success: true };
}),
demoteUser: protectedProcedure
.input(z.object({ projectId: z.string(), userEmail: z.string() }))
.mutation(async ({ input, ctx }) => {
const curentUser = await ctx.db.userProject.findFirst({
where: {
userId: ctx.session.user.id,
ProjectId: input.projectId,
},
});
if (curentUser?.role === "User")
throw new TRPCError({
code: "BAD_REQUEST",
message: "user must be admin",
});
const userAdd = await ctx.db.user.findFirst({
where: {
email: input.userEmail,
},
});
if (!userAdd)
throw new TRPCError({
code: "BAD_REQUEST",
message: "user does not exist",
});
const userProject = await ctx.db.userProject.findFirst({
where: {
ProjectId: input.projectId,
userId: userAdd.id,
},
});
if (!userProject)
throw new TRPCError({
code: "BAD_REQUEST",
message: "user does not exist in project",
});
await ctx.db.userProject.update({
where: {
id: userProject?.id,
},
data: {
userId: userAdd.id,
role: "User",
ProjectId: input.projectId,
},
});
return { success: true };
}),
πΌοΈ Step 4: Add Logo and Metadata
Your appβs branding makes it memorable. Add a random logo from Logoipsum or generate your own. Then, update your metadata:
App title
Description
Favicon or logo icon
//app/layout.tsx;
export const metadata: Metadata = {
title: "FlowTrack",
description:
"A simple project management tool to track your tasks and projects efficiently.",
icons: [{ rel: "icon", url: "/logo.svg" }],
};
π Step 5: Create the Landing Page
Design a clean and simple landing page that introduces your app
//app/page.tsx
import { auth } from "@/server/auth";
import { HydrateClient } from "@/trpc/server";
import Image from "next/image";
import Link from "next/link";
export default async function Home() {
const session = await auth();
return (
<HydrateClient>
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
<Image
src="/logo.svg"
alt="Logo"
width={128}
height={128}
className="mb-8"
/>
<h1 className="mt-8 mb-4 text-4xl font-bold">Welcome to FlowTrack</h1>
<p className="mb-8 text-lg text-gray-300">
A simple project management tool to track your tasks and projects
efficiently.
</p>
<Link
href={session ? "/dashboard" : "/api/auth/signin"}
className="rounded-full bg-white/10 px-10 py-3 font-semibold no-underline transition hover:bg-white/20"
>
{session ? "dashboard" : "Sign in"}
</Link>
</div>
</HydrateClient>
);
}
π§° Step 6: Install Lucid React
Lucid React provides modern, minimalistic UI components that integrate seamlessly with Next.js. Install it via npm or yarn and use it throughout your app for icons and UI elements.
npm install lucide-react
π§ Step 7: Create the Sidebar Component
Build a reusable Sidebar under your _components directory. This component should handle navigation between key sections β Dashboard, Projects, Members, and Tasks.
"use client";
import { usePathname } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { Users, FolderOpen, Plus, ListTodo, Home } from "lucide-react";
import { api } from "@/trpc/react";
export const Sidebar = () => {
const activeMenu = usePathname();
const projects = api.project.getAllProject.useQuery().data;
return (
<nav className="flex h-screen w-64 flex-col border-r border-gray-200 bg-white">
<Link href={"/"} className="border-b border-gray-200 p-6">
<div className="flex items-center space-x-3">
<Image src={"/logo.svg"} alt="flow logo" width={50} height={50} />
<span className="text-lg font-semibold text-gray-800">FlowTrack</span>
</div>
</Link>
<nav className="flex-1 p-4">
<Link href={"/dashboard"}>
<button
className={`flex w-full items-center space-x-3 rounded-lg px-4 py-3 ${
activeMenu === "/dashboard"
? "bg-blue-50 text-blue-600"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<Home size={20} />
<span className="font-medium">Home</span>
</button>
</Link>
<Link href={"/task"}>
<button
className={`flex w-full items-center space-x-3 rounded-lg px-4 py-3 ${
activeMenu === "/task"
? "bg-blue-50 text-blue-600"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<ListTodo size={20} />
<span className="font-medium">My task</span>
</button>
</Link>
<Link href={"/members"}>
<button
className={`flex w-full items-center space-x-3 rounded-lg px-4 py-3 ${
activeMenu === "/members"
? "bg-blue-50 text-blue-600"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<Users size={20} />
<span className="font-medium">Members</span>
</button>
</Link>
<div className="mt-6">
<Link
href={"/project/add"}
className="mb-2 flex items-center justify-between px-4 text-gray-400 hover:text-gray-600"
>
<span className="text-sm font-semibold text-gray-500 uppercase">
project
</span>
<Plus size={18} />
</Link>
</div>
{projects?.map((p) => {
return (
<Link href={`/project/${p.id}`} key={p.id}>
<button
className={`mt-1 flex w-full items-center space-x-3 rounded-lg px-4 py-3 ${
activeMenu === `/project/${p.id}`
? "bg-blue-50 text-blue-600"
: "text-gray-600 hover:bg-gray-50"
}`}
>
<FolderOpen size={20} />
<span className="truncate font-medium">{p.projectName}</span>
</button>
</Link>
);
})}
</nav>
</nav>
);
};
β³ Step 8: Create a Fullscreen Loader
Add a fullscreen loader to improve user experience while data loads or authentication runs. Place it in _components for easy reuse across pages.
import { LoaderIcon } from "lucide-react";
interface FullscreenLoaderProps {
label?: string;
}
export const FullscreenLoader = ({ label }: FullscreenLoaderProps) => {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-2">
<LoaderIcon className="text-muted-foreground size-6 animate-spin" />
{label && <p className="text-muted-foreground text-sm">{label}</p>}
</div>
);
};
π Step 9: Build the Dashboard
Your dashboard provides an instant summary to the users of your application.
Steps:
- Create the dashboard layout.
import { Sidebar } from "../_components/sidebar";
export default function DashboardLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<div>
<div className="flex">
<Sidebar />
<div className="w-full p-8">{children}</div>
</div>
</div>
);
}
- Add status cards to display progress summaries.
import type { AppRouter } from "@/server/api/root";
import { auth } from "@/server/auth";
import type { inferRouterOutputs } from "@trpc/server";
export const StatusCard = async (props: {
tasks: inferRouterOutputs<AppRouter>["task"]["getAllTask"];
projects: inferRouterOutputs<AppRouter>["project"]["getAllProject"];
}) => {
const session = await auth();
const tasks = props.tasks;
const assignedTasks = tasks.filter(
(task) => task.assignedTo === session?.user.id
);
const completedTasks = assignedTasks.filter((task) => task.status === "Done");
const overdueTasks = assignedTasks.filter((task) => {
if (!task.deadline) return false;
return (Date.now() - task.deadline.getTime()) / 1000 > 0;
});
return (
<div className="mb-8 grid w-full grid-cols-5 gap-4">
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-1 text-sm text-gray-500">Total project</div>
<div className="text-3xl font-bold text-gray-900">
{props.projects.length}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-1 text-sm text-gray-500">Total task</div>
<div className="text-3xl font-bold text-gray-900">{tasks.length}</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-1 text-sm text-gray-500">Assigned Task</div>
<div className="text-3xl font-bold text-gray-900">
{assignedTasks.length}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-1 text-sm text-gray-500">Completed task</div>
<div className="text-3xl font-bold text-green-600">
{completedTasks.length}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-1 text-sm text-gray-500">Overdue</div>
<div className="text-3xl font-bold text-red-600">
{overdueTasks.length}
</div>
</div>
</div>
);
};
- Build sections for tasks assigned and projects assigned.
//tasks assigned
import type { AppRouter } from "@/server/api/root";
import { auth } from "@/server/auth";
import type { inferRouterOutputs } from "@trpc/server";
import { Calendar, ChevronRight, FolderOpen } from "lucide-react";
import Link from "next/link";
export const TaskAssigned = async (props: {
tasks: inferRouterOutputs<AppRouter>["task"]["getAllTask"];
projects: inferRouterOutputs<AppRouter>["project"]["getAllProject"];
}) => {
const session = await auth();
const tasks = props.tasks.filter((t) => t.assignedTo === session?.user.id);
return (
<div className="rounded-xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900">Task Assigned</h2>
</div>
<div className="p-6">
<div className="space-y-3">
{tasks.map((task) => (
<div
key={task.id}
className="cursor-pointer rounded-lg bg-gray-50 p-4 transition-colors hover:bg-gray-100"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="mb-1 font-medium text-gray-900">
{task.title}
</h3>
<div className="flex items-center text-sm text-gray-500">
<FolderOpen size={14} className="mr-1" />
<span>
{
props.projects.find((p) => p.id === task.projectId)
?.projectName
}
</span>
</div>
</div>
{task.deadline ? (
<div
className={`flex items-center rounded px-2 py-1 text-xs ${
task.deadline.getTime() < Date.now()
? "bg-red-100 text-red-700"
: "bg-blue-100 text-blue-700"
}`}
>
<Calendar size={12} className="mr-1" />
<span>{task.deadline.toDateString()}</span>
</div>
) : (
<></>
)}
</div>
</div>
))}
</div>
<Link
href="/task"
className="mt-4 flex w-full items-center justify-center py-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
view all
<ChevronRight size={16} className="ml-1" />
</Link>
</div>
</div>
);
};
//projects assigned
import type { AppRouter } from "@/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
export const ProjectAssigned = (props: {
projects: inferRouterOutputs<AppRouter>["project"]["getAllProject"];
}) => {
return (
<div className="rounded-xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900">Projects</h2>
</div>
<div className="p-6">
<div className="space-y-4">
{props.projects.map((project) => (
<div
key={project.id}
className="cursor-pointer rounded-lg bg-gray-50 p-4 transition-colors hover:bg-gray-100"
>
<div className="mb-3 flex items-center justify-between">
<h3 className="font-medium text-gray-900">
{project.projectName}
</h3>
</div>
</div>
))}
</div>
</div>
</div>
);
};
- Create
dashboard/page.tsxfor the main view.
import { api } from "@/trpc/server";
import { StatusCard } from "./statsCard";
import { TaskAssigned } from "./taskAssigned";
import { ProjectAssigned } from "./projectAssigned";
import { redirect } from "next/navigation";
export default async function dashboard() {
type TRPCErrorShape = {
data?: {
code?: string;
};
message?: string;
};
try {
const tasks = await api.task.getAllTask();
const projects = await api.project.getAllProject();
return (
<div className="flex h-fit w-full flex-wrap gap-4">
<StatusCard tasks={tasks} projects={projects} />
<div className="grid w-full grid-cols-2 gap-6">
<TaskAssigned tasks={tasks} projects={projects} />
<ProjectAssigned projects={projects} />
</div>
</div>
);
} catch (err: unknown) {
const error = err as TRPCErrorShape;
if (
error.data?.code === "UNAUTHORIZED" ||
error.message?.includes("UNAUTHORIZED")
) {
return redirect("/");
}
throw err;
}
}
- Add
loading.tsxfor smoother transitions.
import { FullscreenLoader } from "../_components/fullScreenLoader";
export default function loading() {
return <FullscreenLoader label="Dashboard loading..." />;
}
β Step 10: Create the Task Module
The task section manages creation, filtering, and visualization of tasks.
Steps:
Create the task layout having the same layout as dashboard
Create the
page.tsxjust call jsx element Mytask.
import { Mytask } from "./myTask";
export default async function Task() {
return <Mytask />;
}
In
mytask.tsx, add:View modes (table, Kanban, calendar)
Filters
Components like
TaskTable,KanbanBoard, andCalendar
Build
AddTaskcomponent for creating and editing tasks.Allow
AddTaskto open prefilled data when editing existing tasks.
"use client";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/trpc/react";
import type { Status } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server";
import { ArrowUpDown, Calendar1, FolderOpen } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { FullscreenLoader } from "../_components/fullScreenLoader";
export const Mytask = () => {
const router = useRouter();
const [cuurentView, setViewMode] = useState("table");
const [showNewTaskModal, setShowNewTaskModal] = useState(false);
const [showEditTaskModal, setShowEditTaskModal] = useState(false);
const [editTaskId, setEditTaskId] = useState("");
const [filterStatus, setFilterStatus] = useState("all");
const [filterAssignee, setFilterAssignee] = useState("all");
const [filterProject, setFilterProject] = useState("all");
const { data: userProjects, error } =
api.user.getUserProjectRelation.useQuery();
if (error?.data?.code === "UNAUTHORIZED") router.push("/");
const userIds = userProjects?.map((userProject) => userProject.userId) ?? [];
const uniqueAssignees = api.user.getUserByIds.useQuery(userIds).data ?? [];
const projects = api.project.getAllProject.useQuery().data ?? [];
const { data: tasks, isLoading } = api.task.getAllTask.useQuery();
const filteredTasks =
tasks?.filter((task) => {
if (filterAssignee !== "all" && task.assignedTo !== filterAssignee)
return false;
if (filterStatus !== "all" && task.status !== filterStatus) return false;
if (filterProject !== "all" && task.projectId !== filterProject)
return false;
return true;
}) ?? [];
return (
<>
{isLoading && <FullscreenLoader label="Task loading..." />}
{!isLoading && (
<div>
<div className="mb-6">
<h1 className="mb-1 text-2xl font-bold text-gray-900">My Task</h1>
<p className="text-gray-500">All of your task can be seen here</p>
</div>
<div className="mb-6 flex items-center justify-between">
<div className="flex space-x-2">
<button
onClick={() => setViewMode("table")}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
cuurentView === "table"
? "bg-gray-900 text-white"
: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
}`}
>
Table
</button>
<button
onClick={() => setViewMode("kanban")}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
cuurentView === "kanban"
? "bg-gray-900 text-white"
: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
}`}
>
kanban
</button>
<button
onClick={() => setViewMode("calendar")}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
cuurentView === "calendar"
? "bg-gray-900 text-white"
: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
}`}
>
calendar
</button>
</div>
<button
onClick={() => setShowNewTaskModal(true)}
className="rounded-lg bg-gray-900 px-4 py-2 font-medium text-white transition-colors hover:bg-gray-800"
>
New Task
</button>
</div>
</div>
)}
{/* Filters */}
<div className="mb-6 flex space-x-3">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 font-medium text-gray-700 hover:border-gray-400 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="all">All status</option>
<option value="Todo">Pending</option>
<option value="In_Progress">In Progress</option>
<option value="Done">Completed</option>
</select>
<select
value={filterAssignee}
onChange={(e) => setFilterAssignee(e.target.value)}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 font-medium text-gray-700 hover:border-gray-400 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="all">All Assignee</option>
{uniqueAssignees.map((assignee) => (
<option key={assignee.id} value={assignee.id}>
{assignee.name}
</option>
))}
</select>
<select
value={filterProject}
onChange={(e) => setFilterProject(e.target.value)}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 font-medium text-gray-700 hover:border-gray-400 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="all">All projects</option>
{projects.map((project) => (
<option key={project.id} value={project.projectName}>
{project.projectName}
</option>
))}
</select>
</div>
{cuurentView === "table" ? (
<TaskTable
tasks={filteredTasks}
projects={projects}
users={uniqueAssignees}
setShowEditTaskModal={setShowEditTaskModal}
setEditTaskId={setEditTaskId}
/>
) : cuurentView === "kanban" ? (
<Kanban
tasks={filteredTasks}
projects={projects}
users={uniqueAssignees}
setShowEditTaskModal={setShowEditTaskModal}
setEditTaskId={setEditTaskId}
/>
) : (
<Calendar
tasks={filteredTasks}
projects={projects}
users={uniqueAssignees}
setShowEditTaskModal={setShowEditTaskModal}
setEditTaskId={setEditTaskId}
/>
)}
{showNewTaskModal && (
<NewTask
tasks={filteredTasks}
projects={projects}
users={uniqueAssignees}
isEditTask={false}
setShowNewTaskModal={setShowNewTaskModal}
editTaskId={editTaskId}
/>
)}
{showEditTaskModal && (
<NewTask
tasks={filteredTasks}
projects={projects}
users={uniqueAssignees}
isEditTask={true}
setShowNewTaskModal={setShowEditTaskModal}
editTaskId={editTaskId}
/>
)}
</>
);
};
const NewTask = (props: {
tasks: inferRouterOutputs<AppRouter>["task"]["getAllTask"];
projects: inferRouterOutputs<AppRouter>["project"]["getAllProject"];
users: inferRouterOutputs<AppRouter>["user"]["getUserByIds"];
setShowNewTaskModal: Dispatch<SetStateAction<boolean>>;
isEditTask: boolean;
editTaskId: string;
}) => {
const utils = api.useUtils();
const createTask = api.task.createTask.useMutation({
onSuccess: () => utils.task.getAllTask.invalidate(),
});
const editTask = api.task.editTask.useMutation({
onSuccess: () => utils.task.getAllTask.invalidate(),
});
const [newTask, setNewTask] = useState({
title: "",
deadline: new Date().toDateString(),
status: "Todo",
description: "",
projectId: props.projects[0]?.id ?? "",
assignedTo: props.users[0]?.id ?? "",
});
useEffect(() => {
if (props.isEditTask) {
const taskToEdit = props.tasks.find((t) => t.id === props.editTaskId);
if (taskToEdit)
setNewTask({
title: taskToEdit.title,
deadline:
taskToEdit.deadline?.toDateString() ?? new Date().toDateString(),
status: taskToEdit.status,
description: taskToEdit.description ?? "",
projectId: taskToEdit.projectId,
assignedTo: taskToEdit.assignedTo ?? "",
});
} else {
setNewTask({
title: "",
deadline: new Date().toDateString(),
status: "Todo",
description: "",
projectId: props.projects[0]?.id ?? "",
assignedTo: props.users[0]?.id ?? "",
});
}
}, [props.projects, props.users, props.isEditTask]);
const handleCancelTask = () => {
props.setShowNewTaskModal(false);
setNewTask({
title: "",
deadline: new Date().toDateString(),
status: "Todo",
description: "",
projectId: props.projects[0]?.id ?? "",
assignedTo: props.users[0]?.id ?? "",
});
};
const handleSaveTask = () => {
const task = { ...newTask, deadline: new Date(newTask.deadline) };
if (props.isEditTask)
editTask.mutate({ ...task, taskId: props.editTaskId });
else createTask.mutate(task);
handleCancelTask();
};
const isMutation = createTask.isPending ?? editTask.isPending;
return (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div className="m-4 w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl">
{isMutation && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-white/70 backdrop-blur-sm">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-gray-400 border-t-gray-900"></div>
</div>
)}
{props.isEditTask ? (
<h2 className="mb-6 text-2xl font-bold text-gray-900">Edit Task</h2>
) : (
<h2 className="mb-6 text-2xl font-bold text-gray-900">
Create New Task
</h2>
)}
<div className="space-y-4">
{/* Task name, Due Date, and Status row */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Task name
</label>
<input
type="text"
value={newTask.title}
onChange={(e) =>
setNewTask({ ...newTask, title: e.target.value })
}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Enter task name"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Due Date
</label>
<input
type="date"
value={newTask.deadline}
onChange={(e) =>
setNewTask({ ...newTask, deadline: e.target.value })
}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
status
</label>
<select
value={newTask.status}
onChange={(e) =>
setNewTask({ ...newTask, status: e.target.value })
}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="Todo">Pending</option>
<option value="In_Progress">In Progress</option>
<option value="Done">Completed</option>
</select>
</div>
</div>
{/* Description */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Description:
</label>
<textarea
value={newTask.description}
onChange={(e) =>
setNewTask({ ...newTask, description: e.target.value })
}
className="h-32 w-full resize-none rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Enter task description"
/>
</div>
{/* Project Name and Assignee Name row */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Project Name
</label>
<select
value={newTask.projectId}
onChange={(e) =>
setNewTask({ ...newTask, projectId: e.target.value })
}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{props.projects.map((p) => (
<option key={p.id} value={p.id}>
{p.projectName}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Assignee name
</label>
<select
value={newTask.assignedTo}
onChange={(e) =>
setNewTask({ ...newTask, assignedTo: e.target.value })
}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{props.users.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
</div>
{/* Buttons */}
<div className="grid grid-cols-2 gap-4 pt-4">
<button
onClick={handleCancelTask}
className="w-full rounded-lg border border-gray-300 px-4 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleSaveTask}
className="w-full rounded-lg bg-gray-900 px-4 py-3 font-medium text-white transition-colors hover:bg-gray-800"
>
Save
</button>
</div>
</div>
</div>
</div>
);
};
const TaskTable = (props: {
tasks: inferRouterOutputs<AppRouter>["task"]["getAllTask"];
projects: inferRouterOutputs<AppRouter>["project"]["getAllProject"];
users: inferRouterOutputs<AppRouter>["user"]["getUserByIds"];
setShowEditTaskModal: Dispatch<SetStateAction<boolean>>;
setEditTaskId: Dispatch<SetStateAction<string>>;
}) => {
const filteredTasks = props.tasks;
const toggleTaskComplete = api.task.editTask.useMutation();
return (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="w-full">
<thead className="border-b border-gray-200 bg-gray-50">
<tr>
<th className="w-12 px-4 py-3 text-left">
<input type="checkbox" className="rounded border-gray-300" />
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
Task Name
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
Project
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
Assignee
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
<div className="flex cursor-pointer items-center hover:text-gray-900">
Due Date
<ArrowUpDown size={14} className="ml-1" />
</div>
</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-700">
status
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{filteredTasks.map((task) => (
<tr
key={task.id}
onClick={() => {
props.setEditTaskId(task.id);
props.setShowEditTaskModal(true);
}}
className="transition-colors hover:bg-gray-50"
>
<td className="px-4 py-4">
<input
type="checkbox"
checked={task.status === "Done"}
onClick={(e) => e.stopPropagation()}
onChange={() => {
task.status =
task.status === "Done" ? "In_Progress" : "Done";
toggleTaskComplete.mutate({
status: task.status === "Done" ? "Done" : "In_Progress",
taskId: task.id,
});
}}
className="rounded border-gray-300"
/>
</td>
<td className="px-4 py-4">
<span
className={`text-sm ${
task.status === "Done"
? "text-gray-400 line-through"
: "text-gray-900"
}`}
>
{task.title}
</span>
</td>
<td className="px-4 py-4">
<span className="text-sm text-gray-600">
{
props.projects.find(
(element) => element.id === task.projectId
)?.projectName
}
</span>
</td>
<td className="px-4 py-4">
<div className="flex items-center">
<div className="mr-2 flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600 text-xs font-medium text-white">
{props.users.find((u) => u.id === task.assignedTo)?.name}
</div>
<span className="text-sm text-gray-600">
{props.users.find((u) => u.id === task.assignedTo)?.name}
</span>
</div>
</td>
<td className="px-4 py-4">
<span
className={`text-sm ${
task.deadline &&
(Date.now() - task.deadline.getTime()) / 1000 > 0 &&
task.status !== "Done"
? "font-medium text-red-600"
: "text-gray-600"
}`}
>
{task.deadline?.toDateString()}
</span>
</td>
<td className="px-4 py-4">
<span
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${
task.status === "Done"
? "bg-green-100 text-green-700"
: task.status === "In_Progress"
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-700"
}`}
>
{task.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
{filteredTasks.length === 0 && (
<div className="py-12 text-center text-gray-500">
No tasks found matching the selected filters
</div>
)}
</div>
);
};
const Kanban = (props: {
tasks: inferRouterOutputs<AppRouter>["task"]["getAllTask"];
projects: inferRouterOutputs<AppRouter>["project"]["getAllProject"];
users: inferRouterOutputs<AppRouter>["user"]["getUserByIds"];
setShowEditTaskModal: Dispatch<SetStateAction<boolean>>;
setEditTaskId: Dispatch<SetStateAction<string>>;
}) => {
const [tasks, setTasks] = useState(props.tasks);
const kanbanColumns = {
"To Do": tasks.filter((t) => t.status === "Todo"),
"In progress": tasks.filter((t) => t.status === "In_Progress"),
Done: tasks.filter((t) => t.status === "Done"),
};
const [draggedTask, setDraggedTask] = useState<
inferRouterOutputs<AppRouter>["task"]["getAllTask"][0] | null
>(null);
const utils = api.useUtils();
const editTask = api.task.editTask.useMutation({
onSuccess: () => utils.task.getAllTask.invalidate(),
});
const handleDrop = (newStatus: string) => {
if (draggedTask) {
const statusMap = {
"To Do": "Todo",
"In progress": "In_Progress",
Done: "Done",
};
const newStat = statusMap[newStatus as keyof typeof statusMap];
const newTasks = tasks.map((task) =>
task.id === draggedTask.id
? {
...task,
status: newStat as Status,
}
: task
);
setTasks(() => newTasks);
editTask.mutate({
taskId: draggedTask.id,
status: newStat,
});
setDraggedTask(null);
}
};
const isOverdue = (deadline: Date | null) => {
return deadline !== null
? deadline.getTime() < new Date().getTime()
: false;
};
const assignedTo = (assigned: string) => {
return props.users.find((u) => u.id === assigned)?.name;
};
return (
<div className="grid grid-cols-3 gap-6">
{Object.entries(kanbanColumns).map(([columnName, columnTasks]) => (
<div
key={columnName}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={() => handleDrop(columnName)}
className="min-h-96 rounded-xl bg-gray-50 p-4"
>
<h3 className="mb-4 flex items-center justify-between font-semibold text-gray-900">
<span>{columnName}</span>
<span className="rounded bg-white px-2 py-1 text-sm font-normal text-gray-500">
{columnTasks.length}
</span>
</h3>
<div className="space-y-3">
{columnTasks.map((task) => (
<div
key={task.id}
draggable
onDragStart={() => setDraggedTask(task)}
className={`cursor-move rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-all hover:shadow-md ${
draggedTask?.id === task.id ? "opacity-50" : ""
}`}
>
<h4 className="mb-2 font-medium text-gray-900">{task.title}</h4>
<div className="mb-3 text-sm text-gray-600">
<div className="mb-1 flex items-center">
<FolderOpen size={14} className="mr-1" />
<span>
{
props.projects.find((p) => p.id === task.projectId)
?.projectName
}
</span>
</div>
<div className="flex items-center">
<Calendar1 size={14} className="mr-1" />
<span
className={
isOverdue(task.deadline) && task.status !== "Done"
? "font-medium text-red-600"
: ""
}
>
{task.deadline?.toDateString()}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-purple-600 text-xs font-medium text-white">
{props.users.find((u) => u.id === task.assignedTo)?.name}
</div>
</div>
{isOverdue(task.deadline) && task.status !== "Done" && (
<span className="text-xs font-medium text-red-600">
Overdue
</span>
)}
</div>
</div>
))}
{columnTasks.length === 0 && (
<div className="rounded-lg border-2 border-dashed border-gray-300 py-8 text-center text-sm text-gray-400">
Drop tasks here
</div>
)}
</div>
</div>
))}
</div>
);
};
const Calendar = (props: {
tasks: inferRouterOutputs<AppRouter>["task"]["getAllTask"];
projects: inferRouterOutputs<AppRouter>["project"]["getAllProject"];
users: inferRouterOutputs<AppRouter>["user"]["getUserByIds"];
setShowEditTaskModal: Dispatch<SetStateAction<boolean>>;
setEditTaskId: Dispatch<SetStateAction<string>>;
}) => {
const [tasks, setTasks] = useState(props.tasks);
const [currentMonth, setCurrentMonth] = useState(new Date());
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const navigateMonth = (direction: 1 | -1) => {
setCurrentMonth(
new Date(
currentMonth.getFullYear(),
currentMonth.getMonth() + direction,
1
)
);
};
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear();
const month = date.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDayOfWeek = firstDay.getDay();
return { daysInMonth, startingDayOfWeek, year, month };
};
const getTasksForDate = (date: Date) => {
return (
tasks?.filter((task) => {
if (!task.deadline) return;
const taskDate = new Date(task.deadline);
return (
taskDate.getFullYear() === date.getFullYear() &&
taskDate.getMonth() === date.getMonth() &&
taskDate.getDate() === date.getDate()
);
}) ?? []
);
};
useEffect(() => {
setTasks(props.tasks);
}, [props.tasks]);
return (
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
</h2>
<div className="flex space-x-2">
<button
onClick={() => navigateMonth(-1)}
className="rounded border border-gray-300 px-3 py-1 hover:bg-gray-50"
>
<
</button>
<button
onClick={() => navigateMonth(1)}
className="rounded border border-gray-300 px-3 py-1 hover:bg-gray-50"
>
>
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-2">
{["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => (
<div
key={day}
className="py-2 text-center text-sm font-semibold text-gray-600"
>
{day}
</div>
))}
{(() => {
const { daysInMonth, startingDayOfWeek, year, month } =
getDaysInMonth(currentMonth);
const days = [];
for (let i = 0; i < startingDayOfWeek; i++) {
days.push(
<div
key={`empty-${i}`}
className="h-24 rounded-lg bg-gray-50"
></div>
);
}
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const tasksForDay = getTasksForDate(date);
const isToday = new Date().toDateString() === date.toDateString();
days.push(
<div
key={day}
className={`h-24 rounded-lg border p-2 transition-colors hover:bg-gray-50 ${
isToday
? "border-blue-300 bg-blue-50"
: "border-gray-200 bg-white"
}`}
>
<div
className={`mb-1 text-sm font-medium ${
isToday ? "text-blue-600" : "text-gray-700"
}`}
>
{day}
</div>
<div className="space-y-1">
{tasksForDay.slice(0, 2).map((task) => (
<div
key={task.id}
className="truncate rounded bg-blue-100 px-2 py-1 text-xs text-blue-700"
title={task.title}
>
{task.title}
</div>
))}
{tasksForDay.length > 2 && (
<div className="text-xs text-gray-500">
+{tasksForDay.length - 2} more
</div>
)}
</div>
</div>
);
}
return days;
})()}
</div>
</div>
);
};
π₯ Step 11: Build the Members Section
Handle all member-related features β viewing, adding, or managing users within projects.
Steps:
Create member layout having the same layout as dashboard and
page.tsxwhich just return the jsx element MembersAdd a
Memberscomponent to display all team members.Include
AddMembercomponent to add new member to a project
"use client";
import { api } from "@/trpc/react";
import { User } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { FullscreenLoader } from "../_components/fullScreenLoader";
export const Members = () => {
const router = useRouter();
const utils = api.useUtils();
const [showAddMemberModal, setShowAddMemberModal] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const promoteUser = api.user.promoteUser.useMutation();
const kickUser = api.user.kickUser.useMutation({
onSuccess: () => utils.user.invalidate(),
});
const { data: userProjects, error } =
api.user.getUserProjectRelation.useQuery();
if (error?.data?.code) router.push("/");
const userIds = userProjects?.map((userProject) => userProject.userId) ?? [];
const { data, isLoading } = api.user.getUserByIds.useQuery(userIds);
const members = data ?? [];
const handlePromoteUser = (projectId: string | undefined, userId: string) => {
const userEmail = members.find((member) => member.id === userId)?.email;
if (userEmail && projectId) promoteUser.mutate({ projectId, userEmail });
};
const handleKickUser = (projectId: string | undefined, userId: string) => {
const userEmail = members.find((member) => member.id === userId)?.email;
if (userEmail && projectId) kickUser.mutate({ projectId, userEmail });
};
const [debouncedSearch, setDebouncedSearch] = useState("");
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(searchTerm), 500);
return () => clearTimeout(handler);
}, [searchTerm]);
const filteredMembers = members.filter((member) => {
const query = debouncedSearch.toLowerCase();
return (
member.name?.toLowerCase().includes(query) ??
member.email?.toLowerCase().includes(query)
);
});
return (
<div>
{isLoading && <FullscreenLoader label="Members loading..." />}
<div className="mb-6">
<h1 className="mb-1 text-2xl font-bold text-gray-900">Team Members</h1>
<p className="text-gray-500">
Manage your team members and permissions
</p>
</div>
<div className="mb-6 flex items-center space-x-3">
<input
type="text"
placeholder="Search members..."
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<button
onClick={() => setShowAddMemberModal(true)}
className="rounded-lg bg-gray-900 px-4 py-2 font-medium text-white transition-colors hover:bg-gray-800"
>
Add People
</button>
</div>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="w-full">
<thead className="border-b border-gray-200 bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700">
User
</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-700">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{filteredMembers.map((member) => {
const role = userProjects?.find(
(up) => member.id === up.userId
)?.role;
const projectId = userProjects?.find(
(up) => member.id === up.userId
)?.ProjectId;
return (
<tr key={member.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div>
<div className="font-medium text-gray-900">
{member.name}
</div>
<div className="text-sm text-gray-500">
{member.email} <{role}>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center space-x-2">
<button className="flex h-8 w-8 items-center justify-center rounded bg-gray-100 hover:bg-gray-200">
<User size={16} className="text-gray-600" />
</button>
{role !== "Admin" && (
<>
<button
onClick={() =>
handlePromoteUser(projectId, member.id)
}
className="rounded border border-gray-300 px-3 py-1 text-sm hover:bg-gray-50"
>
promot user
</button>
<button
onClick={() => handleKickUser(projectId, member.id)}
className="rounded border border-gray-300 px-3 py-1 text-sm hover:bg-gray-50"
>
kick user
</button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{showAddMemberModal && (
<AddMemberButton setShowAddMemberModal={setShowAddMemberModal} />
)}
</div>
);
};
const AddMemberButton = (props: {
setShowAddMemberModal: Dispatch<SetStateAction<boolean>>;
}) => {
const [isError, setIsError] = useState(false);
const [error, setError] = useState("");
const utils = api.useUtils();
const projects = api.project.getAllProject.useQuery().data ?? [];
const addMember = api.project.addMembers.useMutation({
onSuccess: async () => {
await utils.user.getUserByIds.invalidate();
handleCancelMember();
},
onError: (error) => {
if (error.message === "user does not exist") {
setIsError(true);
setError(error.message);
}
},
});
const [newMember, setNewMember] = useState({
userEmail: "",
projectId: projects[0]?.id ?? "",
role: "User",
});
const handleCancelMember = () => {
props.setShowAddMemberModal(false);
};
const handleAddMember = () => {
addMember.mutate(newMember);
};
return (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div className="m-4 w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl">
<h2 className="mb-6 text-2xl font-bold text-gray-900">
Add New Members
{isError && (
<div className="mt-2 text-sm font-medium text-red-700">{error}</div>
)}
</h2>
<div className="space-y-4">
{/* Task name, Due Date, and Status row */}
<div className="gap-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Email
</label>
<input
type="text"
value={newMember.userEmail}
onChange={(e) => {
setIsError(false);
setNewMember({ ...newMember, userEmail: e.target.value });
}}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Enter task name"
/>
</div>
</div>
{/* Description */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Project Name
</label>
<select
value={newMember.projectId}
onChange={(e) =>
setNewMember({ ...newMember, projectId: e.target.value })
}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.projectName}
</option>
))}
</select>
</div>
{/* Project Name and Assignee Name row */}
<div className="gap-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Role
</label>
<select
value={"User"}
onChange={(e) =>
setNewMember({ ...newMember, role: e.target.value })
}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value={"User"}>User</option>
<option value={"Admin"}>Admin</option>
</select>
</div>
</div>
{/* Buttons */}
<div className="grid grid-cols-2 gap-4 pt-4">
<button
onClick={handleCancelMember}
className="w-full rounded-lg border border-gray-300 px-4 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleAddMember}
className="w-full rounded-lg bg-gray-900 px-4 py-3 font-medium text-white transition-colors hover:bg-gray-800"
>
Save
</button>
</div>
</div>
</div>
</div>
);
};
π§± Step 12: Create the /project/add Page
Implement the ability to create new projects dynamically.
Steps:
Add
page.tsxwhich just return AddProject jsx element under/project/add.Create
AddProjectcomponent for input fields and logic.
"use client";
import { api } from "@/trpc/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export const AddProject = () => {
const router = useRouter();
const [newProject, setNewProject] = useState({
projectName: "",
description: "",
});
const utils = api.useUtils();
const addProject = api.project.createProject.useMutation({
onSuccess: () => utils.project.getAllProject.invalidate(),
onError: (error) => {
if (error.data?.code === "UNAUTHORIZED") router.push("/");
},
});
const handleCancelAddProject = () => {
router.push("/dashboard");
};
const handleAddProject = () => {
addProject.mutate(newProject);
handleCancelAddProject();
};
return (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div className="m-4 w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl">
<h2 className="mb-6 text-2xl font-bold text-gray-900">Add Project</h2>
<div className="space-y-4">
{/* Task name, Due Date, and Status row */}
<div className="gap-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Project Name:
</label>
<input
type="text"
value={newProject.projectName}
onChange={(e) =>
setNewProject({ ...newProject, projectName: e.target.value })
}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Enter task name"
/>
</div>
</div>
{/* Description */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Description:
</label>
<textarea
value={newProject.description}
onChange={(e) =>
setNewProject({ ...newProject, description: e.target.value })
}
className="h-32 w-full resize-none rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Enter task description"
/>
</div>
{/* Buttons */}
<div className="grid grid-cols-2 gap-4 pt-4">
<button
onClick={handleCancelAddProject}
className="w-full rounded-lg border border-gray-300 px-4 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleAddProject}
className="w-full rounded-lg bg-gray-900 px-4 py-3 font-medium text-white transition-colors hover:bg-gray-800"
>
Save
</button>
</div>
</div>
</div>
</div>
);
};
π§ Step 13: Create the /project/[projectId] Dynamic Route
This route handles displaying and editing individual projects.
Steps:
Create
layout.tsxhaving the same layout as dashboard andpage.tsxwhich just return jsx element Project.Add components:
ProjectProjectStatusCardStatusOverviewTeamOverloadEditProject
"use client";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/trpc/react";
import type { inferRouterOutputs } from "@trpc/server";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
export const Project = () => {
const [editProject, setEditProject] = useState(false);
const projectId = usePathname().split("/")[2];
const router = useRouter();
const deleteProject = api.project.deleteProject.useMutation({
onSuccess: () => router.push("/dashboard"),
});
const project = api.project.getAllProject
.useQuery()
.data?.find((p) => p.id === projectId);
const { data, error } = api.task.getAllTask.useQuery();
if (error?.data?.code === "UNAUTHORIZED") router.push("/");
const task = data?.filter((t) => t.projectId === projectId) ?? [];
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h2 className="border-b-2 border-transparent bg-transparent px-2 text-2xl font-bold text-gray-900 outline-none focus:border-blue-500">
{project?.projectName.replace(/^./, (c) => c.toUpperCase())}
</h2>
<div className="flex space-x-2">
<button
onClick={() => {
console.log(projectId);
if (projectId) deleteProject.mutate({ projectId: projectId });
}}
className="rounded-lg border border-gray-300 px-4 py-2 font-medium text-gray-700 hover:bg-gray-50"
>
Delete
</button>
<button
onClick={() => setEditProject(true)}
className="rounded-lg bg-gray-900 px-4 py-2 font-medium text-white hover:bg-gray-800"
>
Edit
</button>
</div>
</div>
<ProjectStatusCard project={project} tasks={task} />
<div className="grid grid-cols-2 gap-6">
<StatusOverview tasks={task} />
<TeamWorkLoad tasks={task} />
</div>
{editProject && <EditProject project={project} />}
</div>
);
};
const EditProject = (props: {
project:
| inferRouterOutputs<AppRouter>["project"]["getAllProject"][0]
| undefined;
}) => {
const [newProject, setNewProject] = useState({
projectName: props.project?.projectName ?? "",
description: props.project?.description ?? "",
});
const utils = api.useUtils();
const addProject = api.project.editProject.useMutation({
onSuccess: () => utils.project.getAllProject.invalidate(),
});
const router = useRouter();
const handleCancelAddProject = () => {
router.push("/dashboard");
};
const handleAddProject = () => {
addProject.mutate({
...newProject,
projectId: props.project ? props.project.id : "",
});
handleCancelAddProject();
};
return (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div className="m-4 w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl">
<h2 className="mb-6 text-2xl font-bold text-gray-900">Add Project</h2>
<div className="space-y-4">
{/* Task name, Due Date, and Status row */}
<div className="gap-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Project Name:
</label>
<input
type="text"
value={newProject.projectName}
onChange={(e) =>
setNewProject({ ...newProject, projectName: e.target.value })
}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Enter task name"
/>
</div>
</div>
{/* Description */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Description:
</label>
<textarea
value={newProject.description}
onChange={(e) =>
setNewProject({ ...newProject, description: e.target.value })
}
className="h-32 w-full resize-none rounded-lg border border-gray-300 px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Enter task description"
/>
</div>
{/* Buttons */}
<div className="grid grid-cols-2 gap-4 pt-4">
<button
onClick={handleCancelAddProject}
className="w-full rounded-lg border border-gray-300 px-4 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleAddProject}
className="w-full rounded-lg bg-gray-900 px-4 py-3 font-medium text-white transition-colors hover:bg-gray-800"
>
Save
</button>
</div>
</div>
</div>
</div>
);
};
const ProjectStatusCard = (props: {
project:
| inferRouterOutputs<AppRouter>["project"]["getAllProject"][0]
| undefined;
tasks: inferRouterOutputs<AppRouter>["task"]["getAllTask"] | undefined;
}) => {
const task = props.tasks ?? [];
const now = new Date();
const sevenDaysAgo = new Date(now);
sevenDaysAgo.setDate(now.getDate() - 7);
const completedTask = task.filter(
(t) => t.status === "Done" && new Date(t.updatedAt) >= sevenDaysAgo
);
const created = task.filter((t) => t.createdAt >= sevenDaysAgo);
const dueTasks = task.filter((t) =>
t.deadline ? t.deadline >= sevenDaysAgo && t.deadline <= now : false
);
return (
<div className="mb-6 grid grid-cols-3 gap-4">
<div className="rounded-xl border border-gray-200 bg-white p-4">
<div className="text-2xl font-bold text-gray-900">
{completedTask.length}
</div>
<div className="text-xs text-gray-500">
completed
<br />
in the last 7 day
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4">
<div className="text-2xl font-bold text-gray-900">{created.length}</div>
<div className="text-xs text-gray-500">
created
<br />
in the last 7 day
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4">
<div className="text-2xl font-bold text-gray-900">
{dueTasks.length}
</div>
<div className="text-xs text-gray-500">
due
<br />
in the last 7 day
</div>
</div>
</div>
);
};
const StatusOverview = (props: {
tasks: inferRouterOutputs<AppRouter>["task"]["getAllTask"] | undefined;
}) => {
return (
<div className="rounded-xl border border-gray-200 bg-white p-6">
<h3 className="mb-2 font-semibold text-gray-900">Status overview</h3>
<p className="mb-6 text-xs text-gray-500">
Get a snapshot of the status of your work items. View all work items.
</p>
<div className="flex items-center justify-center">
{(() => {
const stats = {
pending:
props.tasks?.filter((t) => t.status === "Todo").length ?? 0,
total: props.tasks?.length ?? 0,
inProgress:
props.tasks?.filter((t) => t.status === "In_Progress").length ??
0,
done: props.tasks?.filter((t) => t.status === "Done").length ?? 0,
};
const total = stats.total;
const pendingAngle = (stats.pending / total) * 360;
const inProgressAngle = (stats.inProgress / total) * 360;
const doneAngle = (stats.done / total) * 360;
return (
<div className="flex items-center gap-8">
<svg width="180" height="180" viewBox="0 0 180 180">
<circle
cx="90"
cy="90"
r="70"
fill="none"
stroke="#e5e7eb"
strokeWidth="40"
/>
{stats.pending > 0 && (
<circle
cx="90"
cy="90"
r="70"
fill="none"
stroke="#9ca3af"
strokeWidth="40"
strokeDasharray={`${(pendingAngle / 360) * 439.6} 439.6`}
transform="rotate(-90 90 90)"
/>
)}
{stats.inProgress > 0 && (
<circle
cx="90"
cy="90"
r="70"
fill="none"
stroke="#3b82f6"
strokeWidth="40"
strokeDasharray={`${(inProgressAngle / 360) * 439.6} 439.6`}
strokeDashoffset={`-${(pendingAngle / 360) * 439.6}`}
transform="rotate(-90 90 90)"
/>
)}
{stats.done > 0 && (
<circle
cx="90"
cy="90"
r="70"
fill="none"
stroke="#10b981"
strokeWidth="40"
strokeDasharray={`${(doneAngle / 360) * 439.6} 439.6`}
strokeDashoffset={`-${
((pendingAngle + inProgressAngle) / 360) * 439.6
}`}
transform="rotate(-90 90 90)"
/>
)}
</svg>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded bg-gray-400"></div>
<span>To Do: {stats.pending}</span>
</div>
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded bg-blue-500"></div>
<span>In Progress: {stats.inProgress}</span>
</div>
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded bg-green-500"></div>
<span>Done: {stats.done}</span>
</div>
</div>
</div>
);
})()}
</div>
</div>
);
};
const TeamWorkLoad = (props: {
tasks: inferRouterOutputs<AppRouter>["task"]["getAllTask"];
}) => {
const getTeamWorkload = () => {
const workloadMap: Record<string, number> = {};
const assignees: string[] = [];
props.tasks?.forEach((task) => {
const assignee = task.assignedTo;
if (assignee) {
workloadMap[assignee] = (workloadMap[assignee] ?? 0) + 1;
if (workloadMap[assignee] === 1) assignees.push(assignee);
}
});
const total = props.tasks?.length ?? 1;
const user = api.user.getUserByIds.useQuery(assignees).data ?? [];
return Object.entries(workloadMap).map(([id, count]) => ({
name: user.find((u) => u.id === id)?.name,
count,
percentage: Math.round((count / total) * 100),
}));
};
return (
<div className="rounded-xl border border-gray-200 bg-white p-6">
<h3 className="mb-2 font-semibold text-gray-900">Team workload</h3>
<p className="mb-6 text-xs text-gray-500">
Monitor the capacity of your team.
</p>
<div className="space-y-4">
<div className="flex items-center justify-between border-b pb-2 text-sm font-medium text-gray-700">
<span>Assignee</span>
<span>Work distribution</span>
</div>
{(() => {
const workload = getTeamWorkload();
return workload.length > 0 ? (
workload.map((person, idx) => (
<div
key={idx}
className="flex items-center justify-between gap-4"
>
<span className="w-32 text-sm text-gray-600">
{person.name}
</span>
<div className="flex flex-1 items-center gap-2">
<div className="h-6 flex-1 overflow-hidden rounded-full bg-gray-200">
<div
className="flex h-full items-center justify-center bg-gradient-to-r from-blue-500 to-purple-600 text-xs font-medium text-white"
style={{ width: `${person.percentage}%` }}
>
{person.percentage > 15 && `${person.percentage}%`}
</div>
</div>
{person.percentage <= 15 && (
<span className="text-xs text-gray-600">
{person.percentage}%
</span>
)}
</div>
</div>
))
) : (
<div className="py-4 text-center text-sm text-gray-400">
No tasks assigned yet
</div>
);
})()}
</div>
</div>
);
};
Each project page should summarize progress, show members, and allow project updates.
π Conclusion
Congratulations! π By following these steps, you now have a complete project management system featuring:
Secure login via Google and GitHub (NextAuth)
Full CRUD backend for projects, tasks, and members
Responsive dashboard and reusable components
Clean UI built with Lucid React
This system can easily be extended with features like notifications,or real-time updates using WebSockets.
ποΈ Project Folder Structure
.
βββ eslint.config.js
βββ next.config.js
βββ next-env.d.ts
βββ package.json
βββ package-lock.json
βββ postcss.config.js
βββ prettier.config.js
βββ prisma
β βββ schema.prisma
βββ public
β βββ image.jpg
β βββ logo.svg
βββ README.md
βββ src
β βββ app
β β βββ api
β β β βββ auth
β β β β βββ [...nextauth]
β β β β βββ route.ts
β β β βββ trpc
β β β βββ [trpc]
β β β βββ route.ts
β β βββ _components
β β β βββ Button.tsx
β β β βββ fullScreenLoader.tsx
β β β βββ sidebar.tsx
β β βββ dashboard
β β β βββ layout.tsx
β β β βββ loading.tsx
β β β βββ page.tsx
β β β βββ projectAssigned.tsx
β β β βββ statsCard.tsx
β β β βββ taskAssigned.tsx
β β βββ layout.tsx
β β βββ members
β β β βββ layout.tsx
β β β βββ members.tsx
β β β βββ page.tsx
β β βββ page.tsx
β β βββ project
β β β βββ add
β β β β βββ addProject.tsx
β β β β βββ page.tsx
β β β βββ loading.tsx
β β β βββ [projectId]
β β β βββ dataProject.tsx
β β β βββ layout.tsx
β β β βββ page.tsx
β β βββ task
β β βββ layout.tsx
β β βββ myTask.tsx
β β βββ page.tsx
β βββ env.js
β βββ server
β β βββ api
β β β βββ root.ts
β β β βββ routers
β β β β βββ project.ts
β β β β βββ task.ts
β β β β βββ user.ts
β β β βββ trpc.ts
β β βββ auth
β β β βββ config.ts
β β β βββ index.ts
β β βββ db.ts
β βββ styles
β β βββ globals.css
β βββ trpc
β βββ query-client.ts
β βββ react.tsx
β βββ server.ts
βββ tsconfig.json
23 directories, 50 files
Written by shubham narnolia β full-stack developer passionate about building efficient web systems.