├── .eslintrc.json ├── .gitignore ├── README.md ├── actions ├── issues.js ├── organizations.js ├── projects.js └── sprints.js ├── app ├── (auth) │ ├── layout.js │ ├── sign-in │ │ └── [[...sign-in]] │ │ │ └── page.jsx │ └── sign-up │ │ └── [[...sign-up]] │ │ └── page.jsx ├── (main) │ ├── layout.jsx │ ├── onboarding │ │ └── [[...onboarding]] │ │ │ └── page.jsx │ ├── organization │ │ └── [orgId] │ │ │ ├── _components │ │ │ ├── delete-project.jsx │ │ │ ├── project-list.jsx │ │ │ └── user-issues.jsx │ │ │ └── page.jsx │ └── project │ │ ├── [projectId] │ │ ├── layout.jsx │ │ └── page.jsx │ │ ├── _components │ │ ├── board-filters.jsx │ │ ├── create-issue.jsx │ │ ├── create-sprint.jsx │ │ ├── sprint-board.jsx │ │ └── sprint-manager.jsx │ │ └── create │ │ └── page.jsx ├── favicon.ico ├── globals.css ├── layout.js ├── lib │ └── validators.js ├── not-found.jsx └── page.js ├── components.json ├── components ├── company-carousel.jsx ├── header.jsx ├── issue-card.jsx ├── issue-details-dialog.jsx ├── org-switcher.jsx ├── theme-provider.jsx ├── ui │ ├── accordion.jsx │ ├── avatar.jsx │ ├── badge.jsx │ ├── button.jsx │ ├── card.jsx │ ├── carousel.jsx │ ├── dialog.jsx │ ├── drawer.jsx │ ├── input.jsx │ ├── popover.jsx │ ├── select.jsx │ ├── sonner.jsx │ ├── tabs.jsx │ └── textarea.jsx ├── user-avatar.jsx ├── user-loading.jsx └── user-menu.jsx ├── data ├── companies.json ├── faqs.json └── status.json ├── hooks └── use-fetch.js ├── jsconfig.json ├── lib ├── checkUser.js ├── prisma.js └── utils.js ├── middleware.js ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma ├── migrations │ ├── 20241008105335_created_models │ │ └── migration.sql │ ├── 20241008110923_update_user_table │ │ └── migration.sql │ ├── 20241021055013_add_cascading_deletes │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── companies │ ├── amazon.svg │ ├── atlassian.svg │ ├── google.webp │ ├── ibm.svg │ ├── meta.svg │ ├── microsoft.webp │ ├── netflix.png │ └── uber.svg ├── logo.png └── logo2.png └── tailwind.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "no-unused-vars": ["warn"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Jira Clone with Next JS, React, Tailwind CSS, Prisma, Neon, Clerk, Shadcn UI Tutorial 🔥🔥 2 | ## https://www.youtube.com/watch?v=R5dBYINNouY 3 | 4 | ![image](https://github.com/user-attachments/assets/783d4f3b-925d-44cf-aaf8-4ee4035b2f6c) 5 | 6 | ### Make sure to create a `.env` file with following variables - 7 | 8 | ``` 9 | DATABASE_URL= 10 | 11 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 12 | CLERK_SECRET_KEY= 13 | 14 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 15 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 16 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/onboarding 17 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding 18 | ``` 19 | -------------------------------------------------------------------------------- /actions/issues.js: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/lib/prisma"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | 6 | export async function getIssuesForSprint(sprintId) { 7 | const { userId, orgId } = auth(); 8 | 9 | if (!userId || !orgId) { 10 | throw new Error("Unauthorized"); 11 | } 12 | 13 | const issues = await db.issue.findMany({ 14 | where: { sprintId: sprintId }, 15 | orderBy: [{ status: "asc" }, { order: "asc" }], 16 | include: { 17 | assignee: true, 18 | reporter: true, 19 | }, 20 | }); 21 | 22 | return issues; 23 | } 24 | 25 | export async function createIssue(projectId, data) { 26 | const { userId, orgId } = auth(); 27 | 28 | if (!userId || !orgId) { 29 | throw new Error("Unauthorized"); 30 | } 31 | 32 | let user = await db.user.findUnique({ where: { clerkUserId: userId } }); 33 | 34 | const lastIssue = await db.issue.findFirst({ 35 | where: { projectId, status: data.status }, 36 | orderBy: { order: "desc" }, 37 | }); 38 | 39 | const newOrder = lastIssue ? lastIssue.order + 1 : 0; 40 | 41 | const issue = await db.issue.create({ 42 | data: { 43 | title: data.title, 44 | description: data.description, 45 | status: data.status, 46 | priority: data.priority, 47 | projectId: projectId, 48 | sprintId: data.sprintId, 49 | reporterId: user.id, 50 | assigneeId: data.assigneeId || null, // Add this line 51 | order: newOrder, 52 | }, 53 | include: { 54 | assignee: true, 55 | reporter: true, 56 | }, 57 | }); 58 | 59 | return issue; 60 | } 61 | 62 | export async function updateIssueOrder(updatedIssues) { 63 | const { userId, orgId } = auth(); 64 | 65 | if (!userId || !orgId) { 66 | throw new Error("Unauthorized"); 67 | } 68 | 69 | // Start a transaction 70 | await db.$transaction(async (prisma) => { 71 | // Update each issue 72 | for (const issue of updatedIssues) { 73 | await prisma.issue.update({ 74 | where: { id: issue.id }, 75 | data: { 76 | status: issue.status, 77 | order: issue.order, 78 | }, 79 | }); 80 | } 81 | }); 82 | 83 | return { success: true }; 84 | } 85 | 86 | export async function deleteIssue(issueId) { 87 | const { userId, orgId } = auth(); 88 | 89 | if (!userId || !orgId) { 90 | throw new Error("Unauthorized"); 91 | } 92 | 93 | const user = await db.user.findUnique({ 94 | where: { clerkUserId: userId }, 95 | }); 96 | 97 | if (!user) { 98 | throw new Error("User not found"); 99 | } 100 | 101 | const issue = await db.issue.findUnique({ 102 | where: { id: issueId }, 103 | include: { project: true }, 104 | }); 105 | 106 | if (!issue) { 107 | throw new Error("Issue not found"); 108 | } 109 | 110 | if ( 111 | issue.reporterId !== user.id && 112 | !issue.project.adminIds.includes(user.id) 113 | ) { 114 | throw new Error("You don't have permission to delete this issue"); 115 | } 116 | 117 | await db.issue.delete({ where: { id: issueId } }); 118 | 119 | return { success: true }; 120 | } 121 | 122 | export async function updateIssue(issueId, data) { 123 | const { userId, orgId } = auth(); 124 | 125 | if (!userId || !orgId) { 126 | throw new Error("Unauthorized"); 127 | } 128 | 129 | try { 130 | const issue = await db.issue.findUnique({ 131 | where: { id: issueId }, 132 | include: { project: true }, 133 | }); 134 | 135 | if (!issue) { 136 | throw new Error("Issue not found"); 137 | } 138 | 139 | if (issue.project.organizationId !== orgId) { 140 | throw new Error("Unauthorized"); 141 | } 142 | 143 | const updatedIssue = await db.issue.update({ 144 | where: { id: issueId }, 145 | data: { 146 | status: data.status, 147 | priority: data.priority, 148 | }, 149 | include: { 150 | assignee: true, 151 | reporter: true, 152 | }, 153 | }); 154 | 155 | return updatedIssue; 156 | } catch (error) { 157 | throw new Error("Error updating issue: " + error.message); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /actions/organizations.js: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/lib/prisma"; 4 | import { auth, clerkClient } from "@clerk/nextjs/server"; 5 | 6 | export async function getOrganization(slug) { 7 | const { userId } = auth(); 8 | if (!userId) { 9 | throw new Error("Unauthorized"); 10 | } 11 | 12 | const user = await db.user.findUnique({ 13 | where: { clerkUserId: userId }, 14 | }); 15 | 16 | if (!user) { 17 | throw new Error("User not found"); 18 | } 19 | 20 | // Get the organization details 21 | const organization = await clerkClient().organizations.getOrganization({ 22 | slug, 23 | }); 24 | 25 | if (!organization) { 26 | return null; 27 | } 28 | 29 | // Check if user belongs to this organization 30 | const { data: membership } = 31 | await clerkClient().organizations.getOrganizationMembershipList({ 32 | organizationId: organization.id, 33 | }); 34 | 35 | const userMembership = membership.find( 36 | (member) => member.publicUserData.userId === userId 37 | ); 38 | 39 | // If user is not a member, return null 40 | if (!userMembership) { 41 | return null; 42 | } 43 | 44 | return organization; 45 | } 46 | 47 | export async function getProjects(orgId) { 48 | const { userId } = auth(); 49 | if (!userId) { 50 | throw new Error("Unauthorized"); 51 | } 52 | 53 | const user = await db.user.findUnique({ 54 | where: { clerkUserId: userId }, 55 | }); 56 | 57 | if (!user) { 58 | throw new Error("User not found"); 59 | } 60 | 61 | const projects = await db.project.findMany({ 62 | where: { organizationId: orgId }, 63 | orderBy: { createdAt: "desc" }, 64 | }); 65 | 66 | return projects; 67 | } 68 | 69 | export async function getUserIssues(userId) { 70 | const { orgId } = auth(); 71 | 72 | if (!userId || !orgId) { 73 | throw new Error("No user id or organization id found"); 74 | } 75 | 76 | const user = await db.user.findUnique({ 77 | where: { clerkUserId: userId }, 78 | }); 79 | 80 | if (!user) { 81 | throw new Error("User not found"); 82 | } 83 | 84 | const issues = await db.issue.findMany({ 85 | where: { 86 | OR: [{ assigneeId: user.id }, { reporterId: user.id }], 87 | project: { 88 | organizationId: orgId, 89 | }, 90 | }, 91 | include: { 92 | project: true, 93 | assignee: true, 94 | reporter: true, 95 | }, 96 | orderBy: { updatedAt: "desc" }, 97 | }); 98 | 99 | return issues; 100 | } 101 | 102 | export async function getOrganizationUsers(orgId) { 103 | const { userId } = auth(); 104 | if (!userId) { 105 | throw new Error("Unauthorized"); 106 | } 107 | 108 | const user = await db.user.findUnique({ 109 | where: { clerkUserId: userId }, 110 | }); 111 | 112 | if (!user) { 113 | throw new Error("User not found"); 114 | } 115 | 116 | const organizationMemberships = 117 | await clerkClient().organizations.getOrganizationMembershipList({ 118 | organizationId: orgId, 119 | }); 120 | 121 | const userIds = organizationMemberships.data.map( 122 | (membership) => membership.publicUserData.userId 123 | ); 124 | 125 | const users = await db.user.findMany({ 126 | where: { 127 | clerkUserId: { 128 | in: userIds, 129 | }, 130 | }, 131 | }); 132 | 133 | return users; 134 | } 135 | -------------------------------------------------------------------------------- /actions/projects.js: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/lib/prisma"; 4 | import { auth, clerkClient } from "@clerk/nextjs/server"; 5 | 6 | export async function createProject(data) { 7 | const { userId, orgId } = auth(); 8 | 9 | if (!userId) { 10 | throw new Error("Unauthorized"); 11 | } 12 | 13 | if (!orgId) { 14 | throw new Error("No Organization Selected"); 15 | } 16 | 17 | // Check if the user is an admin of the organization 18 | const { data: membershipList } = 19 | await clerkClient().organizations.getOrganizationMembershipList({ 20 | organizationId: orgId, 21 | }); 22 | 23 | const userMembership = membershipList.find( 24 | (membership) => membership.publicUserData.userId === userId 25 | ); 26 | 27 | if (!userMembership || userMembership.role !== "org:admin") { 28 | throw new Error("Only organization admins can create projects"); 29 | } 30 | 31 | try { 32 | const project = await db.project.create({ 33 | data: { 34 | name: data.name, 35 | key: data.key, 36 | description: data.description, 37 | organizationId: orgId, 38 | }, 39 | }); 40 | 41 | return project; 42 | } catch (error) { 43 | throw new Error("Error creating project: " + error.message); 44 | } 45 | } 46 | 47 | export async function getProject(projectId) { 48 | const { userId, orgId } = auth(); 49 | 50 | if (!userId || !orgId) { 51 | throw new Error("Unauthorized"); 52 | } 53 | 54 | // Find user to verify existence 55 | const user = await db.user.findUnique({ 56 | where: { clerkUserId: userId }, 57 | }); 58 | 59 | if (!user) { 60 | throw new Error("User not found"); 61 | } 62 | 63 | // Get project with sprints and organization 64 | const project = await db.project.findUnique({ 65 | where: { id: projectId }, 66 | include: { 67 | sprints: { 68 | orderBy: { createdAt: "desc" }, 69 | }, 70 | }, 71 | }); 72 | 73 | if (!project) { 74 | throw new Error("Project not found"); 75 | } 76 | 77 | // Verify project belongs to the organization 78 | if (project.organizationId !== orgId) { 79 | return null; 80 | } 81 | 82 | return project; 83 | } 84 | 85 | export async function deleteProject(projectId) { 86 | const { userId, orgId, orgRole } = auth(); 87 | 88 | if (!userId || !orgId) { 89 | throw new Error("Unauthorized"); 90 | } 91 | 92 | if (orgRole !== "org:admin") { 93 | throw new Error("Only organization admins can delete projects"); 94 | } 95 | 96 | const project = await db.project.findUnique({ 97 | where: { id: projectId }, 98 | }); 99 | 100 | if (!project || project.organizationId !== orgId) { 101 | throw new Error( 102 | "Project not found or you don't have permission to delete it" 103 | ); 104 | } 105 | 106 | await db.project.delete({ 107 | where: { id: projectId }, 108 | }); 109 | 110 | return { success: true }; 111 | } 112 | -------------------------------------------------------------------------------- /actions/sprints.js: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/lib/prisma"; 4 | import { auth } from "@clerk/nextjs/server"; 5 | 6 | export async function createSprint(projectId, data) { 7 | const { userId, orgId } = auth(); 8 | 9 | if (!userId || !orgId) { 10 | throw new Error("Unauthorized"); 11 | } 12 | 13 | const project = await db.project.findUnique({ 14 | where: { id: projectId }, 15 | include: { sprints: { orderBy: { createdAt: "desc" } } }, 16 | }); 17 | 18 | if (!project || project.organizationId !== orgId) { 19 | throw new Error("Project not found"); 20 | } 21 | 22 | const sprint = await db.sprint.create({ 23 | data: { 24 | name: data.name, 25 | startDate: data.startDate, 26 | endDate: data.endDate, 27 | status: "PLANNED", 28 | projectId: projectId, 29 | }, 30 | }); 31 | 32 | return sprint; 33 | } 34 | 35 | export async function updateSprintStatus(sprintId, newStatus) { 36 | const { userId, orgId, orgRole } = auth(); 37 | 38 | if (!userId || !orgId) { 39 | throw new Error("Unauthorized"); 40 | } 41 | 42 | try { 43 | const sprint = await db.sprint.findUnique({ 44 | where: { id: sprintId }, 45 | include: { project: true }, 46 | }); 47 | console.log(sprint, orgRole); 48 | 49 | if (!sprint) { 50 | throw new Error("Sprint not found"); 51 | } 52 | 53 | if (sprint.project.organizationId !== orgId) { 54 | throw new Error("Unauthorized"); 55 | } 56 | 57 | if (orgRole !== "org:admin") { 58 | throw new Error("Only Admin can make this change"); 59 | } 60 | 61 | const now = new Date(); 62 | const startDate = new Date(sprint.startDate); 63 | const endDate = new Date(sprint.endDate); 64 | 65 | if (newStatus === "ACTIVE" && (now < startDate || now > endDate)) { 66 | throw new Error("Cannot start sprint outside of its date range"); 67 | } 68 | 69 | if (newStatus === "COMPLETED" && sprint.status !== "ACTIVE") { 70 | throw new Error("Can only complete an active sprint"); 71 | } 72 | 73 | const updatedSprint = await db.sprint.update({ 74 | where: { id: sprintId }, 75 | data: { status: newStatus }, 76 | }); 77 | 78 | return { success: true, sprint: updatedSprint }; 79 | } catch (error) { 80 | throw new Error(error.message); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/(auth)/layout.js: -------------------------------------------------------------------------------- 1 | const AuthLayout = ({ children }) => { 2 | return
{children}
; 3 | }; 4 | 5 | export default AuthLayout; 6 | -------------------------------------------------------------------------------- /app/(auth)/sign-in/[[...sign-in]]/page.jsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(auth)/sign-up/[[...sign-up]]/page.jsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(main)/layout.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Layout = ({ children }) => { 4 | return
{children}
; 5 | }; 6 | 7 | export default Layout; 8 | -------------------------------------------------------------------------------- /app/(main)/onboarding/[[...onboarding]]/page.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { OrganizationList, useOrganization } from "@clerk/nextjs"; 4 | import { useRouter } from "next/navigation"; 5 | import { useEffect } from "react"; 6 | 7 | export default function Onboarding() { 8 | const { organization } = useOrganization(); 9 | const router = useRouter(); 10 | 11 | useEffect(() => { 12 | if (organization) { 13 | router.push(`/organization/${organization.slug}`); 14 | } 15 | // eslint-disable-next-line react-hooks/exhaustive-deps 16 | }, [organization]); 17 | 18 | return ( 19 |
20 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /app/(main)/organization/[orgId]/_components/delete-project.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Trash2 } from "lucide-react"; 6 | import { useOrganization } from "@clerk/nextjs"; 7 | import { deleteProject } from "@/actions/projects"; 8 | import { useRouter } from "next/navigation"; 9 | import useFetch from "@/hooks/use-fetch"; 10 | 11 | export default function DeleteProject({ projectId }) { 12 | const { membership } = useOrganization(); 13 | const router = useRouter(); 14 | 15 | const { 16 | loading: isDeleting, 17 | error, 18 | fn: deleteProjectFn, 19 | data: deleted, 20 | } = useFetch(deleteProject); 21 | 22 | const isAdmin = membership?.role === "org:admin"; 23 | 24 | const handleDelete = async () => { 25 | if (window.confirm("Are you sure you want to delete this project?")) { 26 | deleteProjectFn(projectId); 27 | } 28 | }; 29 | 30 | useEffect(() => { 31 | if (deleted) { 32 | router.refresh(); 33 | } 34 | // eslint-disable-next-line react-hooks/exhaustive-deps 35 | }, [deleted]); 36 | 37 | if (!isAdmin) return null; 38 | 39 | return ( 40 | <> 41 | 50 | {error &&

{error.message}

} 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /app/(main)/organization/[orgId]/_components/project-list.jsx: -------------------------------------------------------------------------------- 1 | // components/ProjectList.jsx 2 | import Link from "next/link"; 3 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; 4 | import { getProjects } from "@/actions/organizations"; 5 | import DeleteProject from "./delete-project"; 6 | 7 | export default async function ProjectList({ orgId }) { 8 | const projects = await getProjects(orgId); 9 | 10 | if (projects.length === 0) { 11 | return ( 12 |

13 | No projects found.{" "} 14 | 18 | Create New. 19 | 20 |

21 | ); 22 | } 23 | 24 | return ( 25 |
26 | {projects.map((project) => ( 27 | 28 | 29 | 30 | {project.name} 31 | 32 | 33 | 34 | 35 |

{project.description}

36 | 40 | View Project 41 | 42 |
43 |
44 | ))} 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/(main)/organization/[orgId]/_components/user-issues.jsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { getUserIssues } from "@/actions/organizations"; 3 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 4 | import IssueCard from "@/components/issue-card"; 5 | 6 | export default async function UserIssues({ userId }) { 7 | const issues = await getUserIssues(userId); 8 | 9 | if (issues.length === 0) { 10 | return null; 11 | } 12 | 13 | const assignedIssues = issues.filter( 14 | (issue) => issue.assignee.clerkUserId === userId 15 | ); 16 | const reportedIssues = issues.filter( 17 | (issue) => issue.reporter.clerkUserId === userId 18 | ); 19 | 20 | return ( 21 | <> 22 |

My Issues

23 | 24 | 25 | 26 | Assigned to You 27 | Reported by You 28 | 29 | 30 | Loading...}> 31 | 32 | 33 | 34 | 35 | Loading...}> 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | function IssueGrid({ issues }) { 45 | return ( 46 |
47 | {issues.map((issue) => ( 48 | 49 | ))} 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /app/(main)/organization/[orgId]/page.jsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs/server"; 2 | import { redirect } from "next/navigation"; 3 | import { getOrganization } from "@/actions/organizations"; 4 | import OrgSwitcher from "@/components/org-switcher"; 5 | import ProjectList from "./_components/project-list"; 6 | import UserIssues from "./_components/user-issues"; 7 | 8 | export default async function OrganizationPage({ params }) { 9 | const { orgId } = params; 10 | const { userId } = auth(); 11 | 12 | if (!userId) { 13 | redirect("/sign-in"); 14 | } 15 | 16 | const organization = await getOrganization(orgId); 17 | 18 | if (!organization) { 19 | return
Organization not found
; 20 | } 21 | 22 | return ( 23 |
24 |
25 |

26 | {organization.name}’s Projects 27 |

28 | 29 | 30 |
31 |
32 | 33 |
34 |
35 | 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/(main)/project/[projectId]/layout.jsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { BarLoader } from "react-spinners"; 3 | 4 | export default async function ProjectLayout({ children }) { 5 | return ( 6 |
7 | }> 8 | {children} 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/(main)/project/[projectId]/page.jsx: -------------------------------------------------------------------------------- 1 | import { getProject } from "@/actions/projects"; 2 | import { notFound } from "next/navigation"; 3 | import SprintCreationForm from "../_components/create-sprint"; 4 | import SprintBoard from "../_components/sprint-board"; 5 | 6 | export default async function ProjectPage({ params }) { 7 | const { projectId } = params; 8 | const project = await getProject(projectId); 9 | 10 | if (!project) { 11 | notFound(); 12 | } 13 | 14 | return ( 15 |
16 | 22 | 23 | {project.sprints.length > 0 ? ( 24 | 29 | ) : ( 30 |
Create a Sprint from button above
31 | )} 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/(main)/project/_components/board-filters.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import { Input } from "@/components/ui/input"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 7 | import { X } from "lucide-react"; 8 | import { 9 | Select, 10 | SelectContent, 11 | SelectItem, 12 | SelectTrigger, 13 | SelectValue, 14 | } from "@/components/ui/select"; 15 | 16 | const priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"]; 17 | 18 | export default function BoardFilters({ issues, onFilterChange }) { 19 | const [searchTerm, setSearchTerm] = useState(""); 20 | const [selectedAssignees, setSelectedAssignees] = useState([]); 21 | const [selectedPriority, setSelectedPriority] = useState(""); 22 | 23 | const assignees = issues 24 | .map((issue) => issue.assignee) 25 | .filter( 26 | (item, index, self) => index === self.findIndex((t) => t.id === item.id) 27 | ); 28 | 29 | useEffect(() => { 30 | const filteredIssues = issues.filter( 31 | (issue) => 32 | issue.title.toLowerCase().includes(searchTerm.toLowerCase()) && 33 | (selectedAssignees.length === 0 || 34 | selectedAssignees.includes(issue.assignee?.id)) && 35 | (selectedPriority === "" || issue.priority === selectedPriority) 36 | ); 37 | onFilterChange(filteredIssues); 38 | }, [searchTerm, selectedAssignees, selectedPriority, issues]); 39 | 40 | const toggleAssignee = (assigneeId) => { 41 | setSelectedAssignees((prev) => 42 | prev.includes(assigneeId) 43 | ? prev.filter((id) => id !== assigneeId) 44 | : [...prev, assigneeId] 45 | ); 46 | }; 47 | 48 | const clearFilters = () => { 49 | setSearchTerm(""); 50 | setSelectedAssignees([]); 51 | setSelectedPriority(""); 52 | }; 53 | 54 | const isFiltersApplied = 55 | searchTerm !== "" || 56 | selectedAssignees.length > 0 || 57 | selectedPriority !== ""; 58 | 59 | return ( 60 |
61 |
62 | setSearchTerm(e.target.value)} 67 | /> 68 | 69 |
70 |
71 | {assignees.map((assignee, i) => { 72 | const selected = selectedAssignees.includes(assignee.id); 73 | 74 | return ( 75 |
0 ? "-ml-6" : ""}`} 80 | style={{ 81 | zIndex: i, 82 | }} 83 | onClick={() => toggleAssignee(assignee.id)} 84 | > 85 | 86 | 87 | {assignee.name[0]} 88 | 89 |
90 | ); 91 | })} 92 |
93 |
94 | 95 | 107 | 108 | {isFiltersApplied && ( 109 | 116 | )} 117 |
118 |
119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /app/(main)/project/_components/create-issue.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { BarLoader } from "react-spinners"; 5 | import { useForm, Controller } from "react-hook-form"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import { 8 | Drawer, 9 | DrawerContent, 10 | DrawerHeader, 11 | DrawerTitle, 12 | } from "@/components/ui/drawer"; 13 | import { Input } from "@/components/ui/input"; 14 | import { Button } from "@/components/ui/button"; 15 | import { 16 | Select, 17 | SelectContent, 18 | SelectItem, 19 | SelectTrigger, 20 | SelectValue, 21 | } from "@/components/ui/select"; 22 | import MDEditor from "@uiw/react-md-editor"; 23 | import useFetch from "@/hooks/use-fetch"; 24 | import { createIssue } from "@/actions/issues"; 25 | import { getOrganizationUsers } from "@/actions/organizations"; 26 | import { issueSchema } from "@/app/lib/validators"; 27 | 28 | export default function IssueCreationDrawer({ 29 | isOpen, 30 | onClose, 31 | sprintId, 32 | status, 33 | projectId, 34 | onIssueCreated, 35 | orgId, 36 | }) { 37 | const { 38 | loading: createIssueLoading, 39 | fn: createIssueFn, 40 | error, 41 | data: newIssue, 42 | } = useFetch(createIssue); 43 | 44 | const { 45 | loading: usersLoading, 46 | fn: fetchUsers, 47 | data: users, 48 | } = useFetch(getOrganizationUsers); 49 | 50 | const { 51 | control, 52 | register, 53 | handleSubmit, 54 | formState: { errors }, 55 | reset, 56 | } = useForm({ 57 | resolver: zodResolver(issueSchema), 58 | defaultValues: { 59 | priority: "MEDIUM", 60 | description: "", 61 | assigneeId: "", 62 | }, 63 | }); 64 | 65 | useEffect(() => { 66 | if (isOpen && orgId) { 67 | fetchUsers(orgId); 68 | } 69 | }, [isOpen, orgId]); 70 | 71 | const onSubmit = async (data) => { 72 | await createIssueFn(projectId, { 73 | ...data, 74 | status, 75 | sprintId, 76 | }); 77 | }; 78 | 79 | useEffect(() => { 80 | if (newIssue) { 81 | reset(); 82 | onClose(); 83 | onIssueCreated(); 84 | } 85 | // eslint-disable-next-line react-hooks/exhaustive-deps 86 | }, [newIssue, createIssueLoading]); 87 | 88 | return ( 89 | 90 | 91 | 92 | Create New Issue 93 | 94 | {usersLoading && } 95 |
96 |
97 | 100 | 101 | {errors.title && ( 102 |

103 | {errors.title.message} 104 |

105 | )} 106 |
107 | 108 |
109 | 115 | ( 119 | 134 | )} 135 | /> 136 | {errors.assigneeId && ( 137 |

138 | {errors.assigneeId.message} 139 |

140 | )} 141 |
142 | 143 |
144 | 150 | ( 154 | 155 | )} 156 | /> 157 |
158 | 159 |
160 | 166 | ( 170 | 184 | )} 185 | /> 186 |
187 | 188 | {error &&

{error.message}

} 189 | 196 |
197 |
198 |
199 | ); 200 | } 201 | -------------------------------------------------------------------------------- /app/(main)/project/_components/create-sprint.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { Input } from "@/components/ui/input"; 7 | import { 8 | Popover, 9 | PopoverContent, 10 | PopoverTrigger, 11 | } from "@/components/ui/popover"; 12 | import { Card, CardContent } from "@/components/ui/card"; 13 | 14 | import { useForm, Controller } from "react-hook-form"; 15 | import { zodResolver } from "@hookform/resolvers/zod"; 16 | import { useRouter } from "next/navigation"; 17 | import { CalendarIcon } from "lucide-react"; 18 | import { DayPicker } from "react-day-picker"; 19 | import { format, addDays } from "date-fns"; 20 | 21 | import { sprintSchema } from "@/app/lib/validators"; 22 | import useFetch from "@/hooks/use-fetch"; 23 | import { createSprint } from "@/actions/sprints"; 24 | 25 | export default function SprintCreationForm({ 26 | projectTitle, 27 | projectKey, 28 | projectId, 29 | sprintKey, 30 | }) { 31 | const [showForm, setShowForm] = useState(false); 32 | const [dateRange, setDateRange] = useState({ 33 | from: new Date(), 34 | to: addDays(new Date(), 14), 35 | }); 36 | const router = useRouter(); 37 | 38 | const { loading: createSprintLoading, fn: createSprintFn } = 39 | useFetch(createSprint); 40 | 41 | const { 42 | register, 43 | control, 44 | handleSubmit, 45 | formState: { errors }, 46 | } = useForm({ 47 | resolver: zodResolver(sprintSchema), 48 | defaultValues: { 49 | name: `${projectKey}-${sprintKey}`, 50 | startDate: dateRange.from, 51 | endDate: dateRange.to, 52 | }, 53 | }); 54 | 55 | const onSubmit = async (data) => { 56 | await createSprintFn(projectId, { 57 | ...data, 58 | startDate: dateRange.from, 59 | endDate: dateRange.to, 60 | }); 61 | setShowForm(false); 62 | router.refresh(); // Refresh the page to show updated data 63 | }; 64 | 65 | return ( 66 | <> 67 |
68 |

69 | {projectTitle} 70 |

71 | 78 |
79 | {showForm && ( 80 | 81 | 82 |
86 |
87 | 93 | 99 | {errors.name && ( 100 |

101 | {errors.name.message} 102 |

103 | )} 104 |
105 |
106 | 109 | ( 113 | 114 | 115 | 130 | 131 | 135 | { 148 | if (range?.from && range?.to) { 149 | setDateRange(range); 150 | field.onChange(range); 151 | } 152 | }} 153 | /> 154 | 155 | 156 | )} 157 | /> 158 |
159 | 162 |
163 |
164 |
165 | )} 166 | 167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /app/(main)/project/_components/sprint-board.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import { toast } from "sonner"; 5 | import { Plus } from "lucide-react"; 6 | import { Button } from "@/components/ui/button"; 7 | import { BarLoader } from "react-spinners"; 8 | import { DragDropContext, Draggable, Droppable } from "@hello-pangea/dnd"; 9 | import useFetch from "@/hooks/use-fetch"; 10 | 11 | import statuses from "@/data/status"; 12 | import { getIssuesForSprint, updateIssueOrder } from "@/actions/issues"; 13 | 14 | import SprintManager from "./sprint-manager"; 15 | import IssueCreationDrawer from "./create-issue"; 16 | import IssueCard from "@/components/issue-card"; 17 | import BoardFilters from "./board-filters"; 18 | 19 | function reorder(list, startIndex, endIndex) { 20 | const result = Array.from(list); 21 | const [removed] = result.splice(startIndex, 1); 22 | result.splice(endIndex, 0, removed); 23 | 24 | return result; 25 | } 26 | 27 | export default function SprintBoard({ sprints, projectId, orgId }) { 28 | const [currentSprint, setCurrentSprint] = useState( 29 | sprints.find((spr) => spr.status === "ACTIVE") || sprints[0] 30 | ); 31 | 32 | const [isDrawerOpen, setIsDrawerOpen] = useState(false); 33 | const [selectedStatus, setSelectedStatus] = useState(null); 34 | 35 | const { 36 | loading: issuesLoading, 37 | error: issuesError, 38 | fn: fetchIssues, 39 | data: issues, 40 | setData: setIssues, 41 | } = useFetch(getIssuesForSprint); 42 | 43 | const [filteredIssues, setFilteredIssues] = useState(issues); 44 | 45 | const handleFilterChange = (newFilteredIssues) => { 46 | setFilteredIssues(newFilteredIssues); 47 | }; 48 | 49 | useEffect(() => { 50 | if (currentSprint.id) { 51 | fetchIssues(currentSprint.id); 52 | } 53 | // eslint-disable-next-line react-hooks/exhaustive-deps 54 | }, [currentSprint.id]); 55 | 56 | const handleAddIssue = (status) => { 57 | setSelectedStatus(status); 58 | setIsDrawerOpen(true); 59 | }; 60 | 61 | const handleIssueCreated = () => { 62 | fetchIssues(currentSprint.id); 63 | }; 64 | 65 | const { 66 | fn: updateIssueOrderFn, 67 | loading: updateIssuesLoading, 68 | error: updateIssuesError, 69 | } = useFetch(updateIssueOrder); 70 | 71 | const onDragEnd = async (result) => { 72 | if (currentSprint.status === "PLANNED") { 73 | toast.warning("Start the sprint to update board"); 74 | return; 75 | } 76 | if (currentSprint.status === "COMPLETED") { 77 | toast.warning("Cannot update board after sprint end"); 78 | return; 79 | } 80 | const { destination, source } = result; 81 | 82 | if (!destination) { 83 | return; 84 | } 85 | 86 | if ( 87 | destination.droppableId === source.droppableId && 88 | destination.index === source.index 89 | ) { 90 | return; 91 | } 92 | 93 | const newOrderedData = [...issues]; 94 | 95 | // source and destination list 96 | const sourceList = newOrderedData.filter( 97 | (list) => list.status === source.droppableId 98 | ); 99 | 100 | const destinationList = newOrderedData.filter( 101 | (list) => list.status === destination.droppableId 102 | ); 103 | 104 | if (source.droppableId === destination.droppableId) { 105 | const reorderedCards = reorder( 106 | sourceList, 107 | source.index, 108 | destination.index 109 | ); 110 | 111 | reorderedCards.forEach((card, i) => { 112 | card.order = i; 113 | }); 114 | } else { 115 | // remove card from the source list 116 | const [movedCard] = sourceList.splice(source.index, 1); 117 | 118 | // assign the new list id to the moved card 119 | movedCard.status = destination.droppableId; 120 | 121 | // add new card to the destination list 122 | destinationList.splice(destination.index, 0, movedCard); 123 | 124 | sourceList.forEach((card, i) => { 125 | card.order = i; 126 | }); 127 | 128 | // update the order for each card in destination list 129 | destinationList.forEach((card, i) => { 130 | card.order = i; 131 | }); 132 | } 133 | 134 | const sortedIssues = newOrderedData.sort((a, b) => a.order - b.order); 135 | setIssues(newOrderedData, sortedIssues); 136 | 137 | updateIssueOrderFn(sortedIssues); 138 | }; 139 | 140 | if (issuesError) return
Error loading issues
; 141 | 142 | return ( 143 |
144 | 150 | 151 | {issues && !issuesLoading && ( 152 | 153 | )} 154 | 155 | {updateIssuesError && ( 156 |

{updateIssuesError.message}

157 | )} 158 | {(updateIssuesLoading || issuesLoading) && ( 159 | 160 | )} 161 | 162 | 163 |
164 | {statuses.map((column) => ( 165 | 166 | {(provided) => ( 167 |
172 |

173 | {column.name} 174 |

175 | {filteredIssues 176 | ?.filter((issue) => issue.status === column.key) 177 | .map((issue, index) => ( 178 | 184 | {(provided) => ( 185 |
190 | fetchIssues(currentSprint.id)} 193 | onUpdate={(updated) => 194 | setIssues((issues) => 195 | issues.map((issue) => { 196 | if (issue.id === updated.id) return updated; 197 | return issue; 198 | }) 199 | ) 200 | } 201 | /> 202 |
203 | )} 204 |
205 | ))} 206 | {provided.placeholder} 207 | {column.key === "TODO" && 208 | currentSprint.status !== "COMPLETED" && ( 209 | 217 | )} 218 |
219 | )} 220 |
221 | ))} 222 |
223 |
224 | 225 | setIsDrawerOpen(false)} 228 | sprintId={currentSprint.id} 229 | status={selectedStatus} 230 | projectId={projectId} 231 | onIssueCreated={handleIssueCreated} 232 | orgId={orgId} 233 | /> 234 |
235 | ); 236 | } 237 | -------------------------------------------------------------------------------- /app/(main)/project/_components/sprint-manager.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Select, 7 | SelectContent, 8 | SelectItem, 9 | SelectTrigger, 10 | SelectValue, 11 | } from "@/components/ui/select"; 12 | import { Badge } from "@/components/ui/badge"; 13 | 14 | import { BarLoader } from "react-spinners"; 15 | import { formatDistanceToNow, isAfter, isBefore, format } from "date-fns"; 16 | 17 | import useFetch from "@/hooks/use-fetch"; 18 | import { useRouter, useSearchParams } from "next/navigation"; 19 | 20 | import { updateSprintStatus } from "@/actions/sprints"; 21 | 22 | export default function SprintManager({ 23 | sprint, 24 | setSprint, 25 | sprints, 26 | projectId, 27 | }) { 28 | const [status, setStatus] = useState(sprint.status); 29 | const router = useRouter(); 30 | const searchParams = useSearchParams(); 31 | 32 | const { 33 | fn: updateStatus, 34 | loading, 35 | error, 36 | data: updatedStatus, 37 | } = useFetch(updateSprintStatus); 38 | 39 | const startDate = new Date(sprint.startDate); 40 | const endDate = new Date(sprint.endDate); 41 | const now = new Date(); 42 | 43 | const canStart = 44 | isBefore(now, endDate) && isAfter(now, startDate) && status === "PLANNED"; 45 | 46 | const canEnd = status === "ACTIVE"; 47 | 48 | const handleStatusChange = async (newStatus) => { 49 | updateStatus(sprint.id, newStatus); 50 | }; 51 | 52 | useEffect(() => { 53 | if (updatedStatus && updatedStatus.success) { 54 | setStatus(updatedStatus.sprint.status); 55 | setSprint({ 56 | ...sprint, 57 | status: updatedStatus.sprint.status, 58 | }); 59 | } 60 | }, [updatedStatus, loading]); 61 | 62 | const getStatusText = () => { 63 | if (status === "COMPLETED") { 64 | return `Sprint Ended`; 65 | } 66 | if (status === "ACTIVE" && isAfter(now, endDate)) { 67 | return `Overdue by ${formatDistanceToNow(endDate)}`; 68 | } 69 | if (status === "PLANNED" && isBefore(now, startDate)) { 70 | return `Starts in ${formatDistanceToNow(startDate)}`; 71 | } 72 | return null; 73 | }; 74 | 75 | useEffect(() => { 76 | const sprintId = searchParams.get("sprint"); 77 | if (sprintId && sprintId !== sprint.id) { 78 | const selectedSprint = sprints.find((s) => s.id === sprintId); 79 | if (selectedSprint) { 80 | setSprint(selectedSprint); 81 | setStatus(selectedSprint.status); 82 | } 83 | } 84 | }, [searchParams, sprints]); 85 | 86 | const handleSprintChange = (value) => { 87 | const selectedSprint = sprints.find((s) => s.id === value); 88 | setSprint(selectedSprint); 89 | setStatus(selectedSprint.status); 90 | router.replace(`/project/${projectId}`, undefined, { shallow: true }); 91 | }; 92 | 93 | return ( 94 | <> 95 |
96 | 109 | 110 | {canStart && ( 111 | 118 | )} 119 | {canEnd && ( 120 | 127 | )} 128 |
129 | {loading && } 130 | {getStatusText() && ( 131 | 132 | {getStatusText()} 133 | 134 | )} 135 | 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /app/(main)/project/create/page.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import { useForm } from "react-hook-form"; 5 | import { zodResolver } from "@hookform/resolvers/zod"; 6 | import { useRouter } from "next/navigation"; 7 | import { useOrganization, useUser } from "@clerk/nextjs"; 8 | import { Button } from "@/components/ui/button"; 9 | import { Input } from "@/components/ui/input"; 10 | import { Textarea } from "@/components/ui/textarea"; 11 | import useFetch from "@/hooks/use-fetch"; 12 | import { projectSchema } from "@/app/lib/validators"; 13 | import { createProject } from "@/actions/projects"; 14 | import { BarLoader } from "react-spinners"; 15 | import OrgSwitcher from "@/components/org-switcher"; 16 | 17 | export default function CreateProjectPage() { 18 | const router = useRouter(); 19 | const { isLoaded: isOrgLoaded, membership } = useOrganization(); 20 | const { isLoaded: isUserLoaded } = useUser(); 21 | const [isAdmin, setIsAdmin] = useState(false); 22 | 23 | const { 24 | register, 25 | handleSubmit, 26 | formState: { errors }, 27 | } = useForm({ 28 | resolver: zodResolver(projectSchema), 29 | }); 30 | 31 | useEffect(() => { 32 | if (isOrgLoaded && isUserLoaded && membership) { 33 | setIsAdmin(membership.role === "org:admin"); 34 | } 35 | }, [isOrgLoaded, isUserLoaded, membership]); 36 | 37 | const { 38 | loading, 39 | error, 40 | data: project, 41 | fn: createProjectFn, 42 | } = useFetch(createProject); 43 | 44 | const onSubmit = async (data) => { 45 | if (!isAdmin) { 46 | alert("Only organization admins can create projects"); 47 | return; 48 | } 49 | 50 | createProjectFn(data); 51 | }; 52 | 53 | useEffect(() => { 54 | if (project) router.push(`/project/${project.id}`); 55 | }, [loading]); 56 | 57 | if (!isOrgLoaded || !isUserLoaded) { 58 | return null; 59 | } 60 | 61 | if (!isAdmin) { 62 | return ( 63 |
64 | 65 | Oops! Only Admins can create projects. 66 | 67 | 68 |
69 | ); 70 | } 71 | 72 | return ( 73 |
74 |

75 | Create New Project 76 |

77 | 78 |
82 |
83 | 89 | {errors.name && ( 90 |

{errors.name.message}

91 | )} 92 |
93 |
94 | 100 | {errors.key && ( 101 |

{errors.key.message}

102 | )} 103 |
104 |
105 |