From 18e65e37dd43e8b75ce41087861aecb4f860da51 Mon Sep 17 00:00:00 2001 From: Nick Doan Date: Sat, 6 Apr 2024 21:02:50 -0400 Subject: [PATCH] Add assign president popup (#142) --- .../[uid] => admin}/edit-role/route.client.ts | 5 +- .../[uid] => admin}/edit-role/route.schema.ts | 7 +- src/app/api/admin/edit-role/route.ts | 52 ++++++ src/app/api/user/[uid]/edit-role/route.ts | 50 ----- .../chapter-leader/users/MembersHomePage.tsx | 12 +- src/components/DisplayChapterInfo.tsx | 71 +++++++- src/components/admin/DisplayChapter.tsx | 172 ------------------ src/components/admin/index.tsx | 1 - src/components/container/Popup.tsx | 2 + src/components/selector/Dropdown.tsx | 10 +- src/components/senior/DisplaySenior.tsx | 10 +- src/components/senior/assignment/index.tsx | 2 +- src/components/user/DisplayUserSeniors.tsx | 10 +- 13 files changed, 135 insertions(+), 269 deletions(-) rename src/app/api/{user/[uid] => admin}/edit-role/route.client.ts (72%) rename src/app/api/{user/[uid] => admin}/edit-role/route.schema.ts (67%) create mode 100644 src/app/api/admin/edit-role/route.ts delete mode 100644 src/app/api/user/[uid]/edit-role/route.ts delete mode 100644 src/components/admin/DisplayChapter.tsx delete mode 100644 src/components/admin/index.tsx diff --git a/src/app/api/user/[uid]/edit-role/route.client.ts b/src/app/api/admin/edit-role/route.client.ts similarity index 72% rename from src/app/api/user/[uid]/edit-role/route.client.ts rename to src/app/api/admin/edit-role/route.client.ts index 8ee36595..f2f7c836 100644 --- a/src/app/api/user/[uid]/edit-role/route.client.ts +++ b/src/app/api/admin/edit-role/route.client.ts @@ -3,11 +3,10 @@ import { z } from "zod"; import { EditRoleRequest, EditRoleResponse } from "./route.schema"; export const editRole = async ( - request: TypedRequest>, - uid: string + request: TypedRequest> ) => { const { body, ...options } = request; - const response = await fetch(`/api/user/${uid}/edit-role`, { + const response = await fetch(`/api/admin/edit-role`, { method: "PATCH", body: JSON.stringify(body), ...options, diff --git a/src/app/api/user/[uid]/edit-role/route.schema.ts b/src/app/api/admin/edit-role/route.schema.ts similarity index 67% rename from src/app/api/user/[uid]/edit-role/route.schema.ts rename to src/app/api/admin/edit-role/route.schema.ts index f14eba8a..20e5fe4a 100644 --- a/src/app/api/user/[uid]/edit-role/route.schema.ts +++ b/src/app/api/admin/edit-role/route.schema.ts @@ -1,11 +1,8 @@ -import { roleSchema } from "@server/schema"; import { z } from "zod"; export const EditRoleRequest = z.object({ - role: roleSchema.refine( - (value): value is "CHAPTER_LEADER" | "USER" => - value === "CHAPTER_LEADER" || value === "USER" - ), + chapterLeaders: z.array(z.string()), + users: z.array(z.string()), }); export const EditRoleResponse = z.discriminatedUnion("code", [ diff --git a/src/app/api/admin/edit-role/route.ts b/src/app/api/admin/edit-role/route.ts new file mode 100644 index 00000000..dcb018e7 --- /dev/null +++ b/src/app/api/admin/edit-role/route.ts @@ -0,0 +1,52 @@ +import { withRole, withSession } from "@server/decorator"; +import { EditRoleRequest, EditRoleResponse } from "./route.schema"; +import { NextResponse } from "next/server"; +import { prisma } from "@server/db/client"; +import { Role } from "@prisma/client"; + +/** + * Update multiple non-admin user's role. + */ +export const PATCH = withSession( + withRole(["ADMIN"], async ({ req, session }) => { + const maybeBody = EditRoleRequest.safeParse(await req.json()); + + if (!maybeBody.success || session.user.role !== "ADMIN") { + return NextResponse.json( + EditRoleResponse.parse({ + code: "INVALID_REQUEST", + message: "Invalid request", + }), + { status: 400 } + ); + } + + const updateManyStudents = (ids: string[], role: Role) => { + if (ids.length > 0) { + return prisma.user.updateMany({ + where: { + id: { + in: ids, + }, + }, + data: { + role: role, + }, + }); + } + return Promise.resolve(); + }; + + await Promise.allSettled([ + updateManyStudents(maybeBody.data.chapterLeaders, "CHAPTER_LEADER"), + updateManyStudents(maybeBody.data.users, "USER"), + ]); + + return NextResponse.json( + EditRoleResponse.parse({ + code: "SUCCESS", + message: "Updated user role", + }) + ); + }) +); diff --git a/src/app/api/user/[uid]/edit-role/route.ts b/src/app/api/user/[uid]/edit-role/route.ts deleted file mode 100644 index 75c69e8a..00000000 --- a/src/app/api/user/[uid]/edit-role/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { withRole, withSession } from "@server/decorator"; -import { EditRoleRequest, EditRoleResponse } from "./route.schema"; -import { NextResponse } from "next/server"; -import { prisma } from "@server/db/client"; - -/** - * Update a non-admin user's role. - */ -export const PATCH = withSession( - withRole(["ADMIN"], async ({ req, params }) => { - const { uid } = params.params; - const maybeBody = EditRoleRequest.safeParse(await req.json()); - const maybeUser = await prisma.user.findUnique({ where: { id: uid } }); - - if (!maybeBody.success || maybeUser == null || maybeUser.role === "ADMIN") { - return NextResponse.json( - EditRoleResponse.parse({ - code: "INVALID_REQUEST", - message: "Invalid request", - }), - { status: 400 } - ); - } - - try { - await prisma.user.update({ - where: { - id: uid, - }, - data: { - role: maybeBody.data.role, - }, - }); - - return NextResponse.json( - EditRoleResponse.parse({ - code: "SUCCESS", - message: "Updated user role", - }) - ); - } catch (e) { - return NextResponse.json( - EditRoleResponse.parse({ - code: "UNKNOWN", - message: "Unknown error occurred", - }) - ); - } - }) -); diff --git a/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx b/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx index 2c09cbcc..e7de0f72 100644 --- a/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx +++ b/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx @@ -73,22 +73,14 @@ const MembersHomePage = ({ members }: MembersHomePageProps) => { {`Members (${members.length})`} {uidToEdit != null && ( - + e.stopPropagation()}>
Assign to E-board
<>{element.position}} selected={selectedPosition} - setSelected={(element) => { - if (selectedPosition.some((other) => element.id === other.id)) { - setSelectedPosition((prev) => - prev.filter((other) => element.id !== other.id) - ); - } else { - setSelectedPosition([element]); - } - }} + setSelected={setSelectedPosition} onSave={async () => { await editPosition( { diff --git a/src/components/DisplayChapterInfo.tsx b/src/components/DisplayChapterInfo.tsx index 34f48150..308ad70e 100644 --- a/src/components/DisplayChapterInfo.tsx +++ b/src/components/DisplayChapterInfo.tsx @@ -1,7 +1,7 @@ "use client"; -import { Prisma, Resource, User, UserRequest } from "@prisma/client"; -import { CardGrid } from "./container"; +import { Prisma, Resource } from "@prisma/client"; +import { CardGrid, Popup } from "./container"; import { UserTile } from "./TileGrid"; import DisplayResources from "./DisplayResources"; import React from "react"; @@ -10,6 +10,9 @@ import PendingCard from "@components/PendingCard"; import { fullName } from "@utils"; import { RoleToUrlSegment } from "@constants/RoleAlias"; import { sortedStudents } from "@utils"; +import { Dropdown } from "./selector"; +import { editRole } from "@api/admin/edit-role/route.client"; +import { useRouter } from "next/navigation"; type ChapterWithUser = Prisma.ChapterGetPayload<{ include: { students: true }; @@ -32,6 +35,7 @@ const DisplayChapterInfo = ({ }: DisplayChapterInfoParams) => { const userContext = React.useContext(UserContext); const { user } = userContext; + const router = useRouter(); const students = user.role === "ADMIN" @@ -40,8 +44,48 @@ const DisplayChapterInfo = ({ (user) => user.role === "CHAPTER_LEADER" || user.position !== "" ); + const currentPresidents = chapter.students.filter( + (user) => user.role === "CHAPTER_LEADER" + ); + const [displayAssignPresident, setDisplayAssignPresident] = + React.useState(false); + const [assignedPresidents, setAssignedPresidents] = + React.useState(currentPresidents); + + const onSaveNewPresidents = async () => { + const previousPresidents = currentPresidents.filter( + (student) => + assignedPresidents.find((other) => student.id === other.id) == undefined + ); + await editRole({ + body: { + chapterLeaders: assignedPresidents.map((student) => student.id), + users: previousPresidents.map((student) => student.id), + }, + }); + router.refresh(); + }; + + const resetAssignment = () => { + setDisplayAssignPresident(false); + setAssignedPresidents(currentPresidents); + }; + return ( -
+
+ {displayAssignPresident && ( + e.stopPropagation()}> +
Assign President
+ fullName(student)} + selected={assignedPresidents} + setSelected={setAssignedPresidents} + onSave={onSaveNewPresidents} + /> +
+ )}
{chapter.chapterName}
@@ -86,10 +130,23 @@ const DisplayChapterInfo = ({
- {user.role === "ADMIN" - ? `Members (${chapter.students.length})` - : "Executive Board"} +
+ + {user.role === "ADMIN" + ? `Members (${chapter.students.length})` + : "Executive Board"} + + {user.role === "ADMIN" && ( +
{ + e.stopPropagation(); + setDisplayAssignPresident(true); + }} + > + Assign President +
+ )}
} tiles={sortedStudents(students).map((student) => { diff --git a/src/components/admin/DisplayChapter.tsx b/src/components/admin/DisplayChapter.tsx deleted file mode 100644 index 90ca8490..00000000 --- a/src/components/admin/DisplayChapter.tsx +++ /dev/null @@ -1,172 +0,0 @@ -"use client"; - -import { editRole } from "@api/user/[uid]/edit-role/route.client"; -import PathNav, { PathInfoType } from "@components/PathNav"; -import PendingCard from "@components/PendingCard"; -import { UserTile } from "@components/TileGrid"; -import { TileEdit } from "@components/TileGrid/TileEdit"; -import { CardGrid } from "@components/container"; -import { faArrowUpFromBracket } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { User } from "@prisma/client"; -import { ChapterWithStudent } from "@server/type"; -import { useRouter } from "next/navigation"; -import React from "react"; - -interface DisplayChapterProps { - uid: string; - chapter: ChapterWithStudent; - requestUsers: User[]; -} - -const DisplayChapter = (props: DisplayChapterProps) => { - const { uid, chapter, requestUsers } = props; - const router = useRouter(); - - const chapterPath: PathInfoType = { - display: "Chapters", - url: "chapters", - }; - - const currchapterPath: PathInfoType = { - display: chapter.chapterName, - url: chapter.id, - }; - - const eboardMembers = React.useMemo( - () => [ - ...chapter.students.filter((user) => user.role === "CHAPTER_LEADER"), - ...chapter.students.filter( - (user) => user.role === "USER" && user.position != null - ), - ], - [chapter] - ); - - const members = React.useMemo( - () => - chapter.students.filter( - (user) => - user.role === "USER" && - eboardMembers.find((other) => user.id === other.id) == undefined - ), - [chapter, eboardMembers] - ); - - return ( -
- -
- {chapter.chapterName} -
- {/* TODO(nickbar01234) - Ask Fa for new Styling changes */} - Executive Board
} - tiles={eboardMembers.map((user) => ( - - Assign Member - - ), - onClick: () => { - editRole({ body: { role: "USER" } }, user.id).then( - () => router.refresh() - ); - }, - color: "#22555A", - icon: ( - - ), - } - : { - name: ( - - Assign President - - ), - onClick: () => { - editRole( - { body: { role: "CHAPTER_LEADER" } }, - user.id - ).then(() => router.refresh()); - }, - color: "#22555A", - icon: , - }, - ]} - /> - } - /> - ))} - /> - - - Pending ({requestUsers.length}) -
- } - tiles={requestUsers.map((user) => ( - - ))} - /> - - - Members ( - {chapter.students.filter((user) => user.role == "USER").length}) -
- } - tiles={members.map((user) => { - return ( - - Assign President - - ), - onClick: () => { - editRole( - { body: { role: "CHAPTER_LEADER" } }, - user.id - ).then(() => router.refresh()); - }, - color: "#22555A", - icon: , - }, - ]} - /> - } - /> - ); - })} - /> -
- ); -}; - -export default DisplayChapter; diff --git a/src/components/admin/index.tsx b/src/components/admin/index.tsx deleted file mode 100644 index fe57a1d1..00000000 --- a/src/components/admin/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default as DisplayChapter } from "./DisplayChapter"; diff --git a/src/components/container/Popup.tsx b/src/components/container/Popup.tsx index 908f9736..05c2f2cd 100644 --- a/src/components/container/Popup.tsx +++ b/src/components/container/Popup.tsx @@ -1,6 +1,7 @@ interface PopupProps { children?: React.ReactNode; className?: string; + onClick?: React.MouseEventHandler; } const Popup = (props: PopupProps) => { @@ -10,6 +11,7 @@ const Popup = (props: PopupProps) => { className={`flex h-48 w-[30rem] flex-col gap-y-6 rounded-[16px] bg-dark-teal px-6 py-9 ${ props.className ?? "" }`} + onClick={props.onClick} > {props.children} diff --git a/src/components/selector/Dropdown.tsx b/src/components/selector/Dropdown.tsx index d0c58549..4acdbe81 100644 --- a/src/components/selector/Dropdown.tsx +++ b/src/components/selector/Dropdown.tsx @@ -10,7 +10,7 @@ interface DropdownProps { display: (ele: T) => React.ReactNode; elements: T[]; selected: T[]; - setSelected: (ele: T) => void; + setSelected: React.Dispatch>; onSave: () => Promise; multipleChoice?: boolean; } @@ -40,7 +40,13 @@ const Dropdown = (props: DropdownProps) => { ) => { e.stopPropagation(); startTransition(() => { - setSelected(element); + if (selected.some((other) => element.id === other.id)) { + setSelected((prev) => prev.filter((other) => element.id !== other.id)); + } else if (multipleChoice) { + setSelected((prev) => [...prev, element]); + } else { + setSelected([element]); + } }); }; diff --git a/src/components/senior/DisplaySenior.tsx b/src/components/senior/DisplaySenior.tsx index 1ea84526..4cc94504 100644 --- a/src/components/senior/DisplaySenior.tsx +++ b/src/components/senior/DisplaySenior.tsx @@ -57,15 +57,7 @@ const DisplaySenior = (props: DisplayProps) => { display={(user: User) => fullName(user)} elements={students} selected={assigned} - setSelected={(element) => { - if (assigned.some((other) => element.id === other.id)) { - setAssigned((prev) => - prev.filter((other) => element.id !== other.id) - ); - } else { - setAssigned((prev) => [...prev, element]); - } - }} + setSelected={setAssigned} onSave={onSave} /> { display: (ele: T) => React.ReactNode; elements: T[]; selected: T[]; - setSelected: (ele: T) => void; + setSelected: React.Dispatch>; onSave: () => Promise; multipleChoice?: boolean; } diff --git a/src/components/user/DisplayUserSeniors.tsx b/src/components/user/DisplayUserSeniors.tsx index b1e813fd..538b827e 100644 --- a/src/components/user/DisplayUserSeniors.tsx +++ b/src/components/user/DisplayUserSeniors.tsx @@ -54,15 +54,7 @@ const DisplayUserSenior = (props: DisplayProps) => { display={(senior: Senior) => seniorFullName(senior)} elements={seniors} selected={assigned} - setSelected={(element) => { - if (assigned.some((other) => element.id === other.id)) { - setAssigned((prev) => - prev.filter((other) => element.id !== other.id) - ); - } else { - setAssigned((prev) => [...prev, element]); - } - }} + setSelected={setAssigned} onSave={onSave} />