Skip to content

Commit

Permalink
volunteer list in progress (#42)
Browse files Browse the repository at this point in the history
* put search bar and table on page

* updates

* added pagination feature

* finish first draft

* add delete modal

* fix zipcode build issue

---------

Co-authored-by: wkim10 <wkim10@tufts.edu>
jmill-16 and wkim10 authored Dec 11, 2024
1 parent f0006d7 commit 7a44f2e
Showing 6 changed files with 500 additions and 61 deletions.
Binary file added public/empty_list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 7 additions & 2 deletions src/app/api/user/route.client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { User, VolunteerDetails } from "@prisma/client";
import { Role, User, VolunteerDetails } from "@prisma/client";

type CreateUserInput = Omit<User, "id" | "events" | "eventIds">;
type CreateVolunteerDetailsInput = Omit<
@@ -47,8 +47,13 @@ export const getUser = async (userID: string) => {
export const getUserByEmail = async (email: string) => {
const url = `/api/user?email=${email}`;
return fetchApi(url, "GET");
};

export const getUsersByRole = async (role: Role) => {
const url = `/api/user?role=${role}`;
return fetchApi(url, "GET");
};

}
export const deleteUser = async (userID: string) => {
const url = `/api/user?id=${userID}`;
return fetchApi(url, "DELETE");
39 changes: 37 additions & 2 deletions src/app/api/user/route.ts
Original file line number Diff line number Diff line change
@@ -76,6 +76,10 @@ export const DELETE = async (request: NextRequest) => {
}

try {
await prisma.code.delete({
where: { userId: id },
});

await prisma.volunteerDetails.delete({
where: { userId: id },
});
@@ -107,9 +111,9 @@ export const GET = async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const id: string | undefined = searchParams.get("id") || undefined;
const email: string | undefined = searchParams.get("email") || undefined;
const role: string | undefined = searchParams.get("role") || undefined;

// Check if id and email is null
if (!id && !email) {
if (!id && !email && !role) {
return NextResponse.json(
{
code: "BAD_REQUEST",
@@ -118,6 +122,37 @@ export const GET = async (request: NextRequest) => {
{ status: 400 }
);
}

if (role) {
try {
const users = await prisma.user.findMany({
where: { role: role === "ADMIN" ? "ADMIN" : "VOLUNTEER" },
include: { volunteerDetails: true },
});

if (!users || users.length === 0) {
return NextResponse.json(
{
code: "NOT_FOUND",
message: "No users found",
},
{ status: 404 }
);
}

return NextResponse.json({
code: "SUCCESS",
data: users,
});
} catch (error) {
console.error("Error:", error);
return NextResponse.json({
code: "ERROR",
message: error,
});
}
}

try {
const fetchedUser = await prisma.user.findUnique({
where: id ? { id } : { email },
176 changes: 174 additions & 2 deletions src/app/private/volunteers/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,181 @@
"use client";

import VolunteerTable from "@components/VolunteerTable/VolunteerTable";
import SearchBar from "@components/SearchBar";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { Icon } from "@iconify/react/dist/iconify.js";
import { Button } from "@mui/material";
import React from "react";
import { Role, User } from "@prisma/client";
import { deleteUser, getUsersByRole } from "@api/user/route.client";
import Image from "next/image";

export default function VolunteersPage() {
const [users, setUsers] = React.useState<User[]>();
const [selected, setSelected] = React.useState<string[]>([]);
const [searchText, setSearchText] = React.useState("");
const [isModalOpen, setIsModalOpen] = React.useState(false);

React.useEffect(() => {
const fetchUsers = async () => {
try {
const response = await getUsersByRole(Role.VOLUNTEER);
setUsers(response.data);
} catch (error) {
console.error("Error fetching volunteers:", error);
}
};

fetchUsers();
}, []);

// Filter users based on the search text
const filteredUsers = users?.filter(
(user) =>
user.firstName.toLowerCase().includes(searchText.toLowerCase()) ||
user.lastName.toLowerCase().includes(searchText.toLowerCase()) ||
user.email.toLowerCase().includes(searchText.toLowerCase())
);

const deleteUsers = async () => {
try {
const deletePromises = selected.map((id) => deleteUser(id));
const responses = await Promise.all(deletePromises);
const allDeleted = responses.every(
(response) => response.code === "SUCCESS"
);

if (allDeleted) {
setUsers((prevUsers) =>
prevUsers
? prevUsers.filter((user) => !selected.includes(user.id))
: []
);
setSelected([]);
console.log("All users deleted successfully", responses);
} else {
console.error("Not all deletions succeeded");
}
setIsModalOpen(false);
} catch (error) {
console.error("Error deleting users:", error);
}
};

return (
<div>
<h1>Volunteers Page</h1>
<div className="flex flex-col gap-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Icon icon="mdi:people" width="44" height="44" />
<div className="text-4xl font-['Kepler_Std'] font-semibold">
Volunteer List ({users ? users.length : 0})
</div>
</div>
{selected.length > 0 ? (
<div className="flex items-center gap-4">
<div>{selected.length} Selected</div>
<Button
sx={{
display: "flex",
padding: "10px 18px",
alignItems: "center",
gap: "8px",
borderRadius: "8px",
backgroundColor: "var(--Rose-600, #E61932)",
color: "white",
fontWeight: 600,
textTransform: "none",
"&:hover": {
backgroundColor: "var(--Rose-700, #C11429)",
},
}}
onClick={() => setIsModalOpen(true)}
>
<DeleteOutlineIcon sx={{ width: 20, color: "whitesmoke" }} />
<div>Delete</div>
</Button>
</div>
) : (
<div className="h-[44.5px]"></div>
)}
</div>
<SearchBar
onSearchChange={(value) => {
setSearchText(value);
setSelected([]);
}}
/>
{filteredUsers && filteredUsers.length > 0 ? (
<VolunteerTable
showPagination={true}
fromVolunteerPage
users={filteredUsers}
selected={selected}
setSelected={setSelected}
/>
) : (
<div className="text-center">
<div className="relative w-full h-[50vh]">
<Image
src="/empty_list.png"
alt="Empty List"
layout="fill"
objectFit="contain"
/>
</div>
<div className="text-[#344054] font-['Kepler_Std'] text-3xl font-semibold mt-8">
No volunteers found!
</div>
</div>
)}
{isModalOpen && (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="fixed inset-0 bg-[#101828] opacity-40"></div>
<div className="bg-white p-6 rounded-2xl shadow-lg z-10 max-w-[512px]">
<div className="text-[#101828] text-center font-['Kepler_Std'] text-4xl font-semibold">
Are you sure you want to delete {selected.length}{" "}
{selected.length === 1 ? "user" : "users"}?
</div>
<div className="text-[#667085] text-center text-lg mt-2">
You will not be able to recover {selected.length === 1 ? "a" : ""}{" "}
deleted {selected.length === 1 ? "profile" : "profiles"}.
</div>
<div className="flex justify-end gap-5 mt-8">
<Button
variant="outlined"
sx={{
borderRadius: "8px",
border: "1px solid var(--Grey-300, #D0D5DD)",
padding: "10px 18px",
color: "var(--Teal-800, #145A5A)",
fontWeight: 600,
textTransform: "none",
fontSize: 16,
}}
onClick={() => setIsModalOpen(false)}
>
Cancel
</Button>
<Button
variant="outlined"
sx={{
borderRadius: "8px",
padding: "10px 18px",
backgroundColor: "var(--Teal-600, #138D8A)",
color: "white",
fontWeight: 600,
textTransform: "none",
fontSize: 16,
"&:hover": { backgroundColor: "var(--Teal-700, #1D7A7A)" },
}}
onClick={deleteUsers}
>
Delete
</Button>
</div>
</div>
</div>
)}
</div>
);
}
46 changes: 46 additions & 0 deletions src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from "react";
import { Box, InputBase } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";

interface SearchBarProps {
onSearchChange: (searchText: string) => void;
}

const SearchBar = ({ onSearchChange }: SearchBarProps) => {
return (
<Box
sx={{
display: "flex",
padding: "5px 7px",
alignItems: "center",
gap: "8px",
borderRadius: "8px",
border: "1px solid var(--Grey-300, #D0D5DD)",
background: "var(--White, #FFF)",
boxShadow: "0px 1px 2px 0px rgba(16, 24, 40, 0.05)",
width: "100%",
maxWidth: 400,
}}
>
<SearchIcon sx={{ color: "var(--Grey-500, #667085)" }} />
<InputBase
placeholder="Search"
onChange={(e) => onSearchChange(e.target.value)}
sx={{
width: "100%",
fontSize: "14px",
color: "var(--Grey-700, #344054)",
"& input::placeholder": {
color: "var(--Grey-500, #667085)",
fontFamily: "Inter, sans-serif",
fontSize: "16px",
fontWeight: 400,
lineHeight: "24px",
},
}}
/>
</Box>
);
};

export default SearchBar;
291 changes: 236 additions & 55 deletions src/components/VolunteerTable/VolunteerTable.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
@@ -8,40 +10,62 @@ import Avatar from "@mui/material/Avatar";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import ArrowRightAltIcon from "@mui/icons-material/ArrowRightAlt";
import IconButton from "@mui/material/IconButton";
import Checkbox from "@mui/material/Checkbox";
import React, { useEffect, useRef, useState } from "react";

import profilePic from "../../../public/profile.png";
import { Icon } from "@iconify/react/dist/iconify.js";
import { User } from "@prisma/client";
import { Box, Button, Typography } from "@mui/material";

function createData(
name: string,
type: number,
email: string,
location: string
) {
return { name, type, email, location };
interface VolunteerTableProps {
showPagination: boolean;
fromVolunteerPage: boolean;
users: User[] | undefined;
selected: string[];
setSelected: React.Dispatch<React.SetStateAction<string[]>>;
}

function getUserRole(userType: number): string {
switch (userType) {
case 0:
return "Corporate Team";
case 1:
return "Community Group";
case 2:
return "Individual Volunteer";
default:
return "undefined user role";
}
}
export default function VolunteerTable({
showPagination,
fromVolunteerPage,
users,
selected,
setSelected,
}: VolunteerTableProps) {
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(5);
const tableContainerRef = useRef<HTMLDivElement | null>(null);

const handleCheckboxChange = (name: string) => {
setSelected((prevSelected) =>
prevSelected.includes(name)
? prevSelected.filter((item) => item !== name)
: [...prevSelected, name]
);
};

const isRowSelected = (name: string) => selected.includes(name);

const paginatedRows =
users?.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) || [];

const rows = [
createData("Name1", 0, "email1", "location1"),
createData("Name2", 1, "email2", "location2"),
createData("Name3", 2, "email3", "location2"),
];
useEffect(() => {
const updateRowsPerPage = () => {
if (tableContainerRef.current) {
const containerHeight = tableContainerRef.current.clientHeight;
const rowHeight = 73;
const calculatedRows = Math.floor(containerHeight / rowHeight);
setRowsPerPage(calculatedRows > 0 ? calculatedRows : 1);
}
};

updateRowsPerPage();
}, []);

export default function VolunteerTable() {
return (
<TableContainer
ref={tableContainerRef}
sx={{
border: "solid 1px #E4E7EC",
borderRadius: "12px",
@@ -57,6 +81,72 @@ export default function VolunteerTable() {
>
<TableHead>
<TableRow sx={{ backgroundColor: "#F9FAFB" }}>
{fromVolunteerPage ? (
<TableCell padding="checkbox">
<Checkbox
checked={paginatedRows.every((row) =>
selected.includes(row.id)
)}
onChange={() => {
const currentPageIds = paginatedRows.map((row) => row.id);
if (
paginatedRows.every((row) => selected.includes(row.id))
) {
setSelected((prevSelected) =>
prevSelected.filter(
(id) => !currentPageIds.includes(id)
)
);
} else {
setSelected((prevSelected) => [
...prevSelected,
...currentPageIds.filter(
(id) => !prevSelected.includes(id)
),
]);
}
}}
sx={{
"& .MuiTouchRipple-root": {
color: "var(--Rose-50, #FFF0F1)",
},
}}
icon={
<span
style={{
display: "inline-block",
width: "18px",
height: "18px",
borderRadius: "6px",
border: "1px solid var(--Grey-300, #D0D5DD)",
backgroundColor: "var(--White, #FFF)",
}}
/>
}
checkedIcon={
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "18px",
height: "18px",
borderRadius: "6px",
backgroundColor: "var(--Rose-50, #FFF0F1)",
border: "1px solid var(--Rose-600, #E61932)",
}}
>
<Icon
icon="ic:round-check"
height="14"
width="14"
style={{ color: "var(--Rose-600, #E61932)" }}
/>
</span>
}
/>
</TableCell>
) : null}
<TableCell
sx={{
color: "#667085",
@@ -67,17 +157,7 @@ export default function VolunteerTable() {
>
Name
</TableCell>
<TableCell
align="left"
sx={{
color: "#667085",
fontWeight: "bold",
height: "44px",
width: "255px",
}}
>
Type
</TableCell>

<TableCell
align="left"
sx={{
@@ -107,14 +187,60 @@ export default function VolunteerTable() {
</TableRow>
</TableHead>
<TableBody sx={{ borderColor: "pink" }}>
{rows.map((row) => (
{paginatedRows.map((row) => (
<TableRow
key={row.name}
key={row.id}
sx={{
"&:last-child td, &:last-child th": { border: 0 },
borderColor: "pink",
}}
>
{fromVolunteerPage ? (
<TableCell padding="checkbox">
<Checkbox
checked={isRowSelected(row.id)}
onChange={() => handleCheckboxChange(row.id)}
sx={{
"& .MuiTouchRipple-root": {
color: "var(--Rose-50, #FFF0F1)",
},
}}
icon={
<span
style={{
display: "inline-block",
width: "18px",
height: "18px",
borderRadius: "6px",
border: "1px solid var(--Grey-300, #D0D5DD)",
backgroundColor: "var(--White, #FFF)",
}}
/>
}
checkedIcon={
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "18px",
height: "18px",
borderRadius: "6px",
backgroundColor: "var(--Rose-50, #FFF0F1)",
border: "1px solid var(--Rose-600, #E61932)",
}}
>
<Icon
icon="ic:round-check"
height="14"
width="14"
style={{ color: "var(--Rose-600, #E61932)" }}
/>
</span>
}
/>
</TableCell>
) : null}
<TableCell
sx={{
borderColor: "#E4E7EC",
@@ -125,21 +251,11 @@ export default function VolunteerTable() {
scope="row"
>
<div className="flex items-center gap-4">
<Avatar alt={row.name} src={profilePic.src} />
{row.name}
<Avatar alt={row.firstName} src={profilePic.src} />
{row.firstName + " " + row.lastName}
</div>
</TableCell>
<TableCell
sx={{
borderColor: "#E4E7EC",
color: "#344054",
height: "72px",
width: "255px",
}}
align="left"
>
{getUserRole(row.type)}
</TableCell>

<TableCell
sx={{
borderColor: "#E4E7EC",
@@ -160,7 +276,7 @@ export default function VolunteerTable() {
}}
align="left"
>
{row.location}
[location here]
</TableCell>
<TableCell
sx={{
@@ -171,9 +287,13 @@ export default function VolunteerTable() {
}}
align="right"
>
<IconButton aria-label="delete volunteer">
<DeleteOutlineIcon sx={{ color: "#344054" }} />
</IconButton>
{fromVolunteerPage ? (
"View"
) : (
<IconButton aria-label="delete volunteer">
<DeleteOutlineIcon sx={{ color: "#344054" }} />
</IconButton>
)}
<IconButton aria-label="more information on volunteer">
<ArrowRightAltIcon sx={{ color: "#344054" }} />
</IconButton>
@@ -182,6 +302,67 @@ export default function VolunteerTable() {
))}
</TableBody>
</Table>
{showPagination && (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "16px",
borderTop: "1px solid #E4E7EC",
}}
>
<Typography
sx={{ fontSize: "14px", color: "#344054", fontWeight: 500 }}
>
Page {page + 1} of {Math.ceil((users?.length || 0) / rowsPerPage)}
</Typography>

<Box>
<Button
onClick={() => setPage((prev) => Math.max(prev - 1, 0))}
disabled={page === 0}
sx={{
marginRight: "8px",
textTransform: "none",
fontSize: "14px",
fontWeight: 600,
color: page === 0 ? "#D0D5DD" : "#145A5A",
borderRadius: 2,
border: "1px solid var(--Grey-300, #D0D5DD)",
}}
>
Previous
</Button>
<Button
onClick={() =>
setPage((prev) =>
Math.min(
prev + 1,
Math.ceil((users?.length || 0) / rowsPerPage) - 1
)
)
}
disabled={
page >= Math.ceil((users?.length || 0) / rowsPerPage) - 1
}
sx={{
textTransform: "none",
fontSize: "14px",
fontWeight: 600,
color:
page >= Math.ceil((users?.length || 0) / rowsPerPage) - 1
? "#D0D5DD"
: "#145A5A",
borderRadius: 2,
border: "1px solid var(--Grey-300, #D0D5DD)",
}}
>
Next
</Button>
</Box>
</Box>
)}
</TableContainer>
);
}

0 comments on commit 7a44f2e

Please sign in to comment.