diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2ef13f3..e89796c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,15 +61,17 @@ model VolunteerDetails { } model VolunteerSession { - id String @id @default(auto()) @map("_id") @db.ObjectId - user User @relation(fields: [userId], references: [id]) - userId String @db.ObjectId + id String @id @default(auto()) @map("_id") @db.ObjectId + user User @relation(fields: [userId], references: [id]) + userId String @db.ObjectId + organizationId String? @db.ObjectId + organization Organization? @relation(fields: [organizationId], references: [id]) checkInTime DateTime checkOutTime DateTime? durationHours Float? dateWorked DateTime - timeSlot TimeSlot? @relation(fields: [timeSlotId], references: [id]) - timeSlotId String? @db.ObjectId @unique + timeSlot TimeSlot? @relation(fields: [timeSlotId], references: [id]) + timeSlotId String? @db.ObjectId @unique } model Code { @@ -95,23 +97,28 @@ model Organization { name String @unique normalizedName String @unique users User[] + timeSlots TimeSlot[] + volunteerSessions VolunteerSession[] } model TimeSlot { - id String @id @default(auto()) @map("_id") @db.ObjectId - userId String @db.ObjectId - user User @relation(fields: [userId], references: [id]) + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + user User @relation(fields: [userId], references: [id]) + organizationId String? @db.ObjectId + organization Organization? @relation(fields: [organizationId], references: [id]) startTime DateTime endTime DateTime durationHours Float date DateTime - approved Boolean @default(true) - status TimeSlotStatus @default(AVAILABLE) + approved Boolean @default(true) + status TimeSlotStatus @default(AVAILABLE) volunteerSession VolunteerSession? + numVolunteers Int @default(1) } model CustomDay { - id String @id @default(auto()) @map("_id") @db.ObjectId + id String @id @default(auto()) @map("_id") @db.ObjectId date DateTime startTime DateTime endTime DateTime diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index 0807c79..3a850c4 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -59,6 +59,7 @@ export const getAuthOptions = (dynamicMaxAge?: number): NextAuthOptions => { email: user.email, volunteerDetails, rememberMe: rememberMe, + organizationId: user.organizationId, }; }, }), @@ -74,6 +75,7 @@ export const getAuthOptions = (dynamicMaxAge?: number): NextAuthOptions => { token.firstName = user.firstName; token.lastName = user.lastName; token.rememberMe = user.rememberMe; + token.organizationId = user.organizationId; if (user.role !== "ADMIN") { token.volunteerDetails = user.volunteerDetails || null; @@ -114,6 +116,7 @@ export const getAuthOptions = (dynamicMaxAge?: number): NextAuthOptions => { session.user.firstName = token.firstName; session.user.lastName = token.lastName; session.user.volunteerDetails = token.volunteerDetails || null; + session.user.organizationId = token.organizationId; session.expires = new Date(token.exp * 1000).toISOString(); return session; }, diff --git a/src/app/api/organization/route.client.ts b/src/app/api/organization/route.client.ts index e9626d7..93f1b66 100644 --- a/src/app/api/organization/route.client.ts +++ b/src/app/api/organization/route.client.ts @@ -30,6 +30,11 @@ export const addOrganization = async ( return fetchApi("/api/organization", "POST", { userId, organizationName }); }; +export const getOrganization = async (organizationId: string) => { + const url = `/api/organization?id=${organizationId}`; + return fetchApi(url, "GET"); +}; + export const getOrganizations = async () => { const url = `/api/organization`; return fetchApi(url, "GET"); diff --git a/src/app/api/organization/route.ts b/src/app/api/organization/route.ts index 526963b..c612d6d 100644 --- a/src/app/api/organization/route.ts +++ b/src/app/api/organization/route.ts @@ -46,9 +46,47 @@ export const POST = async (request: NextRequest) => { } }; -export const GET = async () => { +export const GET = async (request: NextRequest) => { + const { searchParams } = new URL(request.url); + + const id: string | undefined = searchParams.get("id") || undefined; + try { - const fetchedOrganizations = await prisma.organization.findMany(); + if (id) { + const fetchedOrganization = await prisma.organization.findUnique({ + where: { id }, + include: { volunteerSessions: true, users: true }, + }); + + if (!fetchedOrganization) { + return NextResponse.json( + { + code: "NOT_FOUND", + message: "Organization not found", + }, + { status: 404 } + ); + } + + return NextResponse.json( + { + code: "SUCCESS", + data: fetchedOrganization, + }, + { status: 200 } + ); + } + + const fetchedOrganizations = await prisma.organization.findMany({ + include: { + users: true, + volunteerSessions: { + select: { + durationHours: true, + }, + }, + }, + }); return NextResponse.json( { diff --git a/src/app/api/timeSlot/route.client.ts b/src/app/api/timeSlot/route.client.ts index d165971..7f04203 100644 --- a/src/app/api/timeSlot/route.client.ts +++ b/src/app/api/timeSlot/route.client.ts @@ -37,8 +37,19 @@ export const fetchApi = async ( return responseData; }; -export const addTimeSlot = async (timeSlot: CreateTimeSlotInput) => - fetchApi("/api/timeSlot", "POST", { timeSlot }); +export const addTimeSlot = async ( + timeSlot: CreateTimeSlotInput, + groupSignupInfo?: { + eventTitle: string; + date: string; + startTime: string; + endTime: string; + groupName: string; + groupDescription?: string; + groupReason?: string; + groupCapacity: number; + } +) => fetchApi("/api/timeSlot", "POST", { timeSlot, groupSignupInfo }); export const getTimeSlotsByDate = async (userId: string, date: Date) => { const isoDate = date.toISOString().split("T")[0]; diff --git a/src/app/api/timeSlot/route.ts b/src/app/api/timeSlot/route.ts index 8af80e9..6007bd1 100644 --- a/src/app/api/timeSlot/route.ts +++ b/src/app/api/timeSlot/route.ts @@ -1,15 +1,17 @@ import { PrismaClient } from "@prisma/client"; import { NextRequest, NextResponse } from "next/server"; +import { sendGroupSignupMail } from "../../../lib/groupSignupMail"; const prisma = new PrismaClient(); export const POST = async (request: NextRequest) => { try { - const { timeSlot } = await request.json(); + const { timeSlot, groupSignupInfo } = await request.json(); const newTimeSlot = await prisma.timeSlot.create({ data: { userId: timeSlot.userId, + organizationId: timeSlot.organizationId ?? null, startTime: new Date(timeSlot.startTime), endTime: new Date(timeSlot.endTime), durationHours: timeSlot.durationHours, @@ -18,6 +20,10 @@ export const POST = async (request: NextRequest) => { }, }); + if (timeSlot.organizationId && groupSignupInfo) { + await sendGroupSignupMail(groupSignupInfo); + } + return NextResponse.json( { code: "SUCCESS", diff --git a/src/app/api/user/route.client.ts b/src/app/api/user/route.client.ts index df2e6a8..4422c03 100644 --- a/src/app/api/user/route.client.ts +++ b/src/app/api/user/route.client.ts @@ -1,6 +1,9 @@ import { Role, User, VolunteerDetails } from "@prisma/client"; -type CreateUserInput = Omit; +type CreateUserInput = Omit< + User, + "id" | "events" | "eventIds" | "organizationId" +>; type CreateVolunteerDetailsInput = Omit< VolunteerDetails, "id" | "user" | "userId" diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index 68f4b7c..6cb692a 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -154,7 +154,10 @@ export const GET = async (request: NextRequest) => { try { const users = await prisma.user.findMany({ where: { role: role === "ADMIN" ? "ADMIN" : "VOLUNTEER" }, - include: { volunteerDetails: role === "VOLUNTEER" }, + include: { + volunteerDetails: role === "VOLUNTEER", + volunteerSessions: role === "VOLUNTEER", + }, }); if (!users || users.length === 0) { @@ -215,6 +218,7 @@ export const GET = async (request: NextRequest) => { }, }, volunteerDetails: true, + organization: true, }, }); @@ -240,7 +244,7 @@ export const GET = async (request: NextRequest) => { try { const fetchedUser = await prisma.user.findUnique({ where: id ? { id } : { email }, - include: { volunteerDetails: true }, + include: { volunteerDetails: true, volunteerSessions: true }, }); if (!fetchedUser) { @@ -253,13 +257,10 @@ export const GET = async (request: NextRequest) => { ); } - // Do not include volunteerDetails in user we return - const { volunteerDetails, ...user } = fetchedUser; - return NextResponse.json( { code: "SUCCESS", - data: { user, volunteerDetails }, + data: fetchedUser, }, { status: 200 } ); @@ -283,40 +284,46 @@ export const GET = async (request: NextRequest) => { export const PATCH = async (request: NextRequest) => { try { - /* @TODO: Add auth */ const { user, volunteerDetails } = await request.json(); + const { + id, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + volunteerDetails: _, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + volunteerSessions: __, + ...userWithoutIdAndRelations + } = user; + const updatedUser = await prisma.user.update({ - where: { - id: user.id, - }, + where: { id }, data: { - ...user, - id: undefined, + ...userWithoutIdAndRelations, + volunteerDetails: volunteerDetails + ? { + update: { + ageOver14: volunteerDetails.ageOver14, + firstTime: volunteerDetails.firstTime, + country: volunteerDetails.country, + address: volunteerDetails.address, + city: volunteerDetails.city, + state: volunteerDetails.state, + zipCode: volunteerDetails.zipCode, + hasLicense: volunteerDetails.hasLicense, + speaksEsp: volunteerDetails.speaksEsp, + whyJoin: volunteerDetails.whyJoin, + comments: volunteerDetails.comments, + }, + } + : undefined, }, }); - let updatedVD = undefined; - - if (volunteerDetails) { - updatedVD = await prisma.volunteerDetails.update({ - where: { - id: volunteerDetails.id, - }, - data: { - ...volunteerDetails, - id: undefined, - }, - }); - } - return NextResponse.json( { code: "SUCCESS", - message: `User update with email: ${updatedUser.email}`, - data: updatedVD - ? { user: updatedUser, volunteerDetails: updatedVD } - : { user: updatedUser }, + message: `User updated with email: ${updatedUser.email}`, + data: updatedUser, }, { status: 200 } ); @@ -325,7 +332,7 @@ export const PATCH = async (request: NextRequest) => { return NextResponse.json( { code: "ERROR", - message: error, + message: error instanceof Error ? error.message : "Unknown error", }, { status: 500 } ); diff --git a/src/app/private/events/page.tsx b/src/app/private/events/page.tsx index 89ff15d..5fa359b 100644 --- a/src/app/private/events/page.tsx +++ b/src/app/private/events/page.tsx @@ -26,7 +26,8 @@ export default function EventsPage() { const [timeSlots, setTimeSlots] = React.useState([ { start: "", end: "", submitted: false }, ]); - const [users, setUsers] = React.useState([]); + const [individuals, setIndividuals] = React.useState([]); + const [groups, setGroups] = React.useState([]); const [page, setPage] = React.useState(0); const [customDayHours, setCustomDayHours] = React.useState<{ @@ -35,6 +36,9 @@ export default function EventsPage() { }>({ start: "10:00", end: "18:00" }); const [customDayTitle, setCustomDayTitle] = React.useState(""); const [customDayDescription, setCustomDayDescription] = React.useState(""); + const [activeTab, setActiveTab] = React.useState<"Individuals" | "Groups">( + "Individuals" + ); const handleAddTimeSlot = () => { setTimeSlots((prev) => { @@ -139,12 +143,14 @@ export default function EventsPage() { await addTimeSlot({ userId: session.user.id, + organizationId: null, startTime: start, endTime: end, durationHours, date: selectedDate, approved: true, status: TimeSlotStatus.AVAILABLE, + numVolunteers: 1, }); } @@ -176,7 +182,28 @@ export default function EventsPage() { const result = await getUsersByDate(selectedDate); const users = result.data; - setUsers(users); + const individuals = users + .map((user: User & { timeSlots: TimeSlot[] }) => ({ + ...user, + timeSlots: user.timeSlots.filter((slot) => !slot.organizationId), + })) + .filter( + (user: User & { timeSlots: TimeSlot[] }) => + user.timeSlots.length > 0 + ); + + const groups = users + .map((user: User & { timeSlots: TimeSlot[] }) => ({ + ...user, + timeSlots: user.timeSlots.filter((slot) => slot.organizationId), + })) + .filter( + (user: User & { timeSlots: TimeSlot[] }) => + user.timeSlots.length > 0 + ); + + setIndividuals(individuals); + setGroups(groups); } else { const result = await getTimeSlotsByDate( session.user.id, @@ -551,24 +578,62 @@ export default function EventsPage() { height="20" />
- Total Signups: {users.length}{" "} - {users.length === 1 ? "volunteer" : "volunteers"} + Total Individual Signups: {individuals.length}{" "} + {individuals.length === 1 ? "volunteer" : "volunteers"}

-
- -
Attendees
+
+ {["Individuals", "Groups"].map((tab) => ( + + ))}
- {users.length === 0 ? ( + {activeTab === "Individuals" ? ( + individuals.length === 0 ? ( +
+
+ Empty List +
+
+ It seems like no individuals have signed up! +
+
+ ) : ( + + ) + ) : groups.length === 0 ? (
- It seems like no one has signed up! + It seems like no groups have signed up!
) : ( @@ -587,7 +652,8 @@ export default function EventsPage() { showPagination fromVolunteerPage={false} fromAttendeePage - users={users} + users={groups} + showOrganizationName /> )}
diff --git a/src/app/private/organization/[organizationId]/page.tsx b/src/app/private/organization/[organizationId]/page.tsx new file mode 100644 index 0000000..0242bf8 --- /dev/null +++ b/src/app/private/organization/[organizationId]/page.tsx @@ -0,0 +1,352 @@ +"use client"; + +import React from "react"; +import { Icon } from "@iconify/react"; +import ProfileAvatar from "@components/ProfileAvatar"; +import StatsCard from "@components/StatsCard"; +import TimeTable from "@components/TimeTable"; +import { Calendar } from "@components/Calendar"; +import { format } from "date-fns"; +import { InputAdornment, TextField } from "@mui/material"; +import Image from "next/image"; +import { useParams, useRouter } from "next/navigation"; +import { OrganizationWithUsers } from "../../../types"; +import { useSession } from "next-auth/react"; +import { getOrganization } from "@api/organization/route.client"; +import VolunteerTable from "@components/VolunteerTable"; + +export default function ProfileContent() { + const { organizationId } = useParams(); + const { data: session, status } = useSession(); + const router = useRouter(); + + const startButtonRef = React.useRef(null); + const startCalendarRef = React.useRef(null); + const endButtonRef = React.useRef(null); + const endCalendarRef = React.useRef(null); + + const [organization, setOrganization] = + React.useState(null); + const [showStartCalendar, setShowStartCalendar] = React.useState(false); + const [showEndCalendar, setShowEndCalendar] = React.useState(false); + const [selectedStartDate, setSelectedStartDate] = React.useState( + null + ); + const [selectedEndDate, setSelectedEndDate] = React.useState( + null + ); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + if (status === "loading" || !session?.user) return; + + if ( + session.user.organizationId !== organizationId && + session.user.role !== "ADMIN" + ) { + router.replace(`/private/profile/${session.user.id}`); + return; + } + + const fetchData = async () => { + try { + if (!organizationId) return; + + const response = await getOrganization(organizationId as string); + + if (response.data) { + setOrganization(response.data); + } + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [organizationId, router, session?.user, status]); + + const filteredSessions = React.useMemo(() => { + if (organization?.volunteerSessions) { + return organization.volunteerSessions.filter((session) => { + const sessionDate = new Date(session.dateWorked); + return ( + (!selectedStartDate || sessionDate >= selectedStartDate) && + (!selectedEndDate || sessionDate <= selectedEndDate) + ); + }); + } else { + return []; + } + }, [organization?.volunteerSessions, selectedStartDate, selectedEndDate]); + + const [hours, setHours] = React.useState("0.0"); + const [days, setDays] = React.useState(0); + + React.useEffect(() => { + const totalHours = filteredSessions.reduce((acc, session) => { + return acc + (session.durationHours ?? 0); + }, 0); + + const uniqueDays = new Set( + filteredSessions.map((session) => + new Date(session.dateWorked).toDateString() + ) + ); + + setHours(totalHours.toFixed(1)); + setDays(uniqueDays.size); + }, [filteredSessions]); + + React.useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + startCalendarRef.current && + event.target instanceof Node && + !startCalendarRef.current.contains(event.target) + ) { + setShowStartCalendar(false); + } + if ( + endCalendarRef.current && + event.target instanceof Node && + !endCalendarRef.current.contains(event.target) + ) { + setShowEndCalendar(false); + } + } + + if (showStartCalendar || showEndCalendar) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [showStartCalendar, showEndCalendar]); + + if (loading || !organization) { + return ( +
+ Loading... +
+ ); + } + + return ( +
+ {/* Top Bar */} +
+
+
+
+ +
+
+
+
+ {organization.name} +
+
Organization
+
+
+
+
+
+
+ +
+ Group Log +
+
+
+
+ setShowStartCalendar(!showStartCalendar)} + value={ + selectedStartDate + ? format(selectedStartDate, "MM/dd/yyyy") + : "" + } + slotProps={{ + input: { + endAdornment: ( + + + + ), + }, + inputLabel: { + shrink: true, + }, + }} + /> + {showStartCalendar && ( +
+ { + if (date) { + setSelectedStartDate(date); + setShowStartCalendar(false); + } + }} + previousDisabled={false} + /> +
+ )} +
+
+
+ setShowEndCalendar(!showEndCalendar)} + value={ + selectedEndDate ? format(selectedEndDate, "MM/dd/yyyy") : "" + } + slotProps={{ + input: { + endAdornment: ( + + + + ), + }, + inputLabel: { + shrink: true, + }, + }} + /> + {showEndCalendar && ( +
+ { + if (date) { + setSelectedEndDate(date); + setShowEndCalendar(false); + } + }} + previousDisabled={false} + /> +
+ )} +
+
+
+
+
Group Stats
+
+ + +
+
+
+
Group Timesheet
+
+ {filteredSessions.length === 0 ? ( +
+
+ Empty List +
+
+ It looks like there are no time slots in this range! +
+
+ ) : ( + + )} +
+
+
+
+ +
People
+
+
+ {organization.users?.length === 0 ? ( +
+
+ Empty List +
+
+ No volunteers found! +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/app/private/profile/edit/layout.tsx b/src/app/private/profile/[userId]/edit/layout.tsx similarity index 100% rename from src/app/private/profile/edit/layout.tsx rename to src/app/private/profile/[userId]/edit/layout.tsx diff --git a/src/app/private/profile/edit/page.tsx b/src/app/private/profile/[userId]/edit/page.tsx similarity index 73% rename from src/app/private/profile/edit/page.tsx rename to src/app/private/profile/[userId]/edit/page.tsx index 5b68813..e968a1d 100644 --- a/src/app/private/profile/edit/page.tsx +++ b/src/app/private/profile/[userId]/edit/page.tsx @@ -2,130 +2,15 @@ import React, { useState, useEffect, FormEvent } from "react"; import { useSession } from "next-auth/react"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { getUser, updateUser } from "@api/user/route.client"; import { Role, User, VolunteerDetails } from "@prisma/client"; import RadioButton from "@components/RadioButton"; -import useApiThrottle from "../../../../hooks/useApiThrottle"; - -// @TOOD: Add Upload Image -// interface UploadAreaProps { -// onFileChange?: (file: File) => void; -// } - -// const UploadArea: React.FC = ({ onFileChange }) => { -// const [file, setFile] = useState(null); -// const [errorMsg, setErrorMsg] = useState(""); -// const fileInputRef = useRef(null); - -// // Allowed MIME types and max file size (3MB) -// const allowedTypes = [ -// "image/svg+xml", -// "image/png", -// "image/jpeg", -// "image/gif", -// ]; -// const maxSize = 3 * 1024 * 1024; - -// const handleFile = (file: File) => { -// if (!allowedTypes.includes(file.type)) { -// setErrorMsg("Unsupported file."); -// setFile(null); -// return; -// } -// if (file.size > maxSize) { -// setErrorMsg("File exceeds maximum size of 3MB."); -// setFile(null); -// return; -// } -// setErrorMsg(""); -// setFile(file); -// if (onFileChange) { -// onFileChange(file); -// } -// }; - -// const handleInputChange = (e: React.ChangeEvent) => { -// const selectedFile = e.target.files && e.target.files[0]; -// if (selectedFile) { -// handleFile(selectedFile); -// } -// }; - -// const handleDragOver = (e: React.DragEvent) => { -// e.preventDefault(); -// }; - -// const handleDrop = (e: React.DragEvent) => { -// e.preventDefault(); -// if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { -// handleFile(e.dataTransfer.files[0]); -// e.dataTransfer.clearData(); -// } -// }; - -// const borderColorClass = errorMsg -// ? "border-[#E61932]" -// : "border-[rgba(0,0,0,0.23)]"; -// const iconFill = errorMsg ? "#E61932" : "#138D8A"; -// const clickToUpload = errorMsg ? "#E61932" : "#138D8A"; -// const bottomText = errorMsg ? errorMsg : "SVG, PNG, JPG or GIF (max. 3MB)"; -// const bottomTextColor = errorMsg ? "#E61932" : "#667085"; - -// return ( -//
fileInputRef.current && fileInputRef.current.click()} -// onDragOver={handleDragOver} -// onDrop={handleDrop} -// className={`w-[762px] h-[112px] px-6 py-4 bg-white rounded-lg flex flex-col justify-start items-center gap-1 inline-flex border ${borderColorClass}`} -// > -// -//
-// {/* Icon */} -//
-// -// -// -//
-// {/* Combined clickable upload text */} -//
-//
-// -// Click to upload -// {" "} -// -// or drag and drop -// -//
-//
-// {bottomText} -//
-//
-//
-//
-// ); -// }; +import useApiThrottle from "../../../../../hooks/useApiThrottle"; export default function EditProfilePage() { const router = useRouter(); + const { userId } = useParams(); const { data: session, status, update } = useSession(); const [user, setUser] = useState(null); @@ -134,6 +19,18 @@ export default function EditProfilePage() { const [loadError, setLoadError] = useState(null); useEffect(() => { + if (status === "loading") return; + + if (!session?.user.id) { + setLoadError("Not authenticated."); + return; + } + + if (session.user.id !== userId) { + router.replace(`/private/profile/${userId}`); + return; + } + const fetchData = async () => { if (!session?.user.id) { setLoadError("No user ID found."); @@ -146,7 +43,7 @@ export default function EditProfilePage() { return; } - setUser(response.data.user); + setUser(response.data); setVolunteerDetails(response.data.volunteerDetails); } catch (err) { console.error("Error fetching user:", err); @@ -154,7 +51,7 @@ export default function EditProfilePage() { } }; fetchData(); - }, [session?.user.id]); + }, [router, session?.user.id, status, userId]); const handleSave = async (e: FormEvent) => { e.preventDefault(); @@ -175,7 +72,7 @@ export default function EditProfilePage() { volunteerDetails ? trimStrings(volunteerDetails) : undefined ); await update(); - router.push(`/private/profile`); + router.push(`/private/profile/${userId}`); } } catch (err) { console.error("Error updating user:", err); @@ -188,7 +85,7 @@ export default function EditProfilePage() { }); const handleCancel = () => { - router.push(`/private/profile`); + router.push(`/private/profile/${userId}`); }; const isFormValid = () => { @@ -263,7 +160,7 @@ export default function EditProfilePage() {

{loadError}

- {/* @TODO: Upload Photo */} - {/*
-
-
- Your Photo -
-
- This will be displayed on your profile. -
-
-
- -
-
*/} - {/* Are You Over 14 Field */} {session.user.role === Role.VOLUNTEER ? ( <> diff --git a/src/app/private/profile/[userId]/page.tsx b/src/app/private/profile/[userId]/page.tsx new file mode 100644 index 0000000..cec8df1 --- /dev/null +++ b/src/app/private/profile/[userId]/page.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSession } from "next-auth/react"; +import { useParams, useRouter } from "next/navigation"; +import ProfileContent from "@components/ProfileContent"; +import { UserWithVolunteerDetail } from "../../../types"; +import { VolunteerSession } from "@prisma/client"; +import { getUser } from "@api/user/route.client"; + +export default function UserProfilePage() { + const { userId } = useParams(); + const { data: session, status } = useSession(); + const router = useRouter(); + + const [user, setUser] = useState(null); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (status === "loading" || !session?.user) return; + + if (session.user.id !== userId && session.user.role !== "ADMIN") { + router.replace(`/private/profile/${session.user.id}`); + return; + } + + const fetchData = async () => { + try { + if (!userId) return; + + const response = await getUser(userId as string); + + if (response.data) { + setUser(response.data); + setSessions(response.data.volunteerSessions); + } + } catch (error) { + console.error(error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [userId, router, status, session?.user]); + + if (loading || !user) { + return ( +
+ Loading... +
+ ); + } + + const isEditable = session?.user.id === user.id; + + return ( + + ); +} diff --git a/src/app/private/profile/page.tsx b/src/app/private/profile/page.tsx index 5c40641..b45f2d4 100644 --- a/src/app/private/profile/page.tsx +++ b/src/app/private/profile/page.tsx @@ -1,520 +1,23 @@ "use client"; -import React from "react"; -import { Icon } from "@iconify/react"; -import ProfileAvatar from "@components/ProfileAvatar"; -import { Role, VolunteerSession } from "@prisma/client"; +import { useEffect } from "react"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; -import RadioButton from "@components/RadioButton"; -import StatsCard from "@components/StatsCard"; -import { getVolunteerSessions } from "@api/volunteerSession/route.client"; -import TimeTable from "@components/TimeTable"; -import { InputAdornment, TextField } from "@mui/material"; -import { format } from "date-fns"; -import { Calendar } from "@components/Calendar"; -import Image from "next/image"; export default function ProfilePage() { + const { data: session, status } = useSession(); const router = useRouter(); - const { data: session } = useSession(); - const startButtonRef = React.useRef(null); - const startCalendarRef = React.useRef(null); - const endButtonRef = React.useRef(null); - const endCalendarRef = React.useRef(null); - - const [hours, setHours] = React.useState("0.0"); - const [days, setDays] = React.useState(0); - - const [showStartCalendar, setShowStartCalendar] = React.useState(false); - const [showEndCalendar, setShowEndCalendar] = React.useState(false); - const [selectedStartDate, setSelectedStartDate] = React.useState( - null - ); - const [selectedEndDate, setSelectedEndDate] = React.useState( - null - ); - - const [volunteerSessions, setVolunteerSessions] = React.useState< - VolunteerSession[] - >([]); - - React.useEffect(() => { - const fetchData = async () => { - try { - const res = await getVolunteerSessions(session ? session.user.id : ""); - - if (res && res.data) { - const sessions = res.data; - - // Total hours worked (sum of defined durationHours) - const totalHours = sessions.reduce( - (acc: number, session: VolunteerSession) => { - return acc + (session.durationHours ?? 0); - }, - 0 - ); - - // Total days volunteered (unique days based on dateWorked) - const uniqueDays = new Set( - sessions.map((session: VolunteerSession) => - new Date(session.dateWorked).toDateString() - ) - ); - - setHours(totalHours.toFixed(1)); // rounded to 1 decimal - setDays(uniqueDays.size); - setVolunteerSessions(sessions); - } - } catch (err) { - console.error("Failed to fetch volunteer sessions:", err); - } - }; - - fetchData(); - }, [session]); - - const filteredSessions = React.useMemo(() => { - return volunteerSessions.filter((session) => { - const sessionDate = new Date(session.dateWorked); - return ( - (!selectedStartDate || sessionDate >= selectedStartDate) && - (!selectedEndDate || sessionDate <= selectedEndDate) - ); - }); - }, [volunteerSessions, selectedStartDate, selectedEndDate]); - - React.useEffect(() => { - const totalHours = filteredSessions.reduce((acc, session) => { - return acc + (session.durationHours ?? 0); - }, 0); - - const uniqueDays = new Set( - filteredSessions.map((session) => - new Date(session.dateWorked).toDateString() - ) - ); - - setHours(totalHours.toFixed(1)); - setDays(uniqueDays.size); - }, [filteredSessions]); - - React.useEffect(() => { - function handleClickOutside(event: MouseEvent) { - // if user clicks outside the calendar, close it - if ( - startCalendarRef.current && - event.target instanceof Node && - !startCalendarRef.current.contains(event.target) - ) { - setShowStartCalendar(false); - } - - if ( - endCalendarRef.current && - event.target instanceof Node && - !endCalendarRef.current.contains(event.target) - ) { - setShowEndCalendar(false); - } - } - - if (showStartCalendar || showEndCalendar) { - document.addEventListener("mousedown", handleClickOutside); + useEffect(() => { + if (status === "loading") return; + if (session?.user.id) { + router.replace(`/private/profile/${session.user.id}`); } - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [showStartCalendar, showEndCalendar]); - - if (!session) { - return ( -
- Loading... -
- ); - } + }, [session, status, router]); return ( -
- {/* Top Bar */} -
-
-
-
- -
-
-
-
- {session.user.firstName} {session.user.lastName} -
-
- {session.user.email} -
-
-
- {session.user.role === Role.ADMIN ? ( - - ) : null} -
- - {/* Main Form */} -
- {session.user.role === Role.VOLUNTEER ? ( - <> -
-
- -
- Volunteer Log -
-
-
-
- setShowStartCalendar(!showStartCalendar)} - value={ - selectedStartDate - ? format(selectedStartDate, "MM/dd/yyyy") - : "" - } - slotProps={{ - input: { - endAdornment: ( - - - - ), - }, - inputLabel: { - shrink: true, - }, - }} - /> - {showStartCalendar && ( -
- { - if (date) { - setSelectedStartDate(date); - setShowStartCalendar(false); - } - }} - previousDisabled={false} - /> -
- )} -
-
-
- setShowEndCalendar(!showEndCalendar)} - value={ - selectedEndDate - ? format(selectedEndDate, "MM/dd/yyyy") - : "" - } - slotProps={{ - input: { - endAdornment: ( - - - - ), - }, - inputLabel: { - shrink: true, - }, - }} - /> - {showEndCalendar && ( -
- { - if (date) { - setSelectedEndDate(date); - setShowEndCalendar(false); - } - }} - previousDisabled={false} - /> -
- )} -
-
-
-
-
Personal Stats
-
- - -
-
-
-
Volunteer Timesheet
-
- {filteredSessions.length === 0 ? ( -
-
- Empty List -
-
- It looks like there are no time slots in this range! -
-
- ) : ( - - )} -
-
-
-
- -
- Personal Information -
-
- -
- {/* Are You Over 14 Field */} -
-
-
- Are you over 14? * -
-
- Note: we require volunteers to be over 14 years old to work - with us. -
-
-
- - -
-
- {/* First Time Volunteering Field */} -
-
- Is this your first time volunteering with us?{" "} - * -
-
- - -
-
- {/* Address Field */} -
-
- Address * -
-
- {/* Street Field */} -
-
-
- {session.user.volunteerDetails?.address || ""} -
-
-
-
- {/* City, Zip Code, State, Country Row */} -
- {/* City Field */} -
-
-
- {session.user.volunteerDetails?.city || ""} -
-
-
-
- {/* Zip Code Field */} -
-
-
- {session.user.volunteerDetails?.zipCode || ""} -
-
-
-
-
-
- {/* State Field */} -
-
-
- {session.user.volunteerDetails?.state || ""} -
-
-
-
- {/* Country Field */} -
-
-
- {session.user.volunteerDetails?.country || ""} -
-
-
-
-
-
-
- {/* Driver's License Field */} -
-
- Do you have a driver's license?{" "} - * -
-
- - -
-
- {/* Speak Spanish Field */} -
-
- Do you speak Spanish? * -
-
- - -
-
- {/* Volunteer Reason Field */} -
-
- Why do you want to volunteer with us?{" "} - * -
-
-
-
- {session.user.volunteerDetails?.whyJoin || ""} -
-
-
-
-
- {/* Questions/Comments Field */} -
-
- Do you have any other questions or comments? -
-
-
-
- {session.user.volunteerDetails?.comments || ""} -
-
-
-
-
- - ) : null} -
+
+ Loading...
); } diff --git a/src/app/private/volunteers/page.tsx b/src/app/private/volunteers/page.tsx index 029a0b7..ef2b136 100644 --- a/src/app/private/volunteers/page.tsx +++ b/src/app/private/volunteers/page.tsx @@ -6,28 +6,37 @@ 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 { Organization, Role, User } from "@prisma/client"; import { deleteUser, getUsersByRole } from "@api/user/route.client"; import Image from "next/image"; import useApiThrottle from "../../../hooks/useApiThrottle"; +import { getOrganizations } from "@api/organization/route.client"; +import OrganizationTable from "@components/OrganizationTable"; export default function VolunteersPage() { const [users, setUsers] = React.useState(); + const [organizations, setOrganizations] = React.useState(); const [selected, setSelected] = React.useState([]); const [searchText, setSearchText] = React.useState(""); const [isModalOpen, setIsModalOpen] = React.useState(false); + const [activeTab, setActiveTab] = React.useState< + "Individuals" | "Organizations" + >("Individuals"); React.useEffect(() => { - const fetchUsers = async () => { + const fetchData = async () => { try { - const response = await getUsersByRole(Role.VOLUNTEER); - setUsers(response.data); + const usersResponse = await getUsersByRole(Role.VOLUNTEER); + const orgResponse = await getOrganizations(); + + setUsers(usersResponse.data); + setOrganizations(orgResponse.data); } catch (error) { - console.error("Error fetching volunteers:", error); + console.error("Error fetching volunteers or organizations:", error); } }; - fetchUsers(); + fetchData(); }, []); const normalizedSearchText = searchText.trim().split(/\s+/).join(" "); @@ -45,6 +54,10 @@ export default function VolunteersPage() { ).includes(normalizedSearchText.toLowerCase()) ); + const filteredOrganizations = organizations?.filter((organization) => + organization.name.toLowerCase().includes(searchText.toLowerCase()) + ); + const deleteUsers = async () => { try { const deletePromises = selected.map((id) => deleteUser(id)); @@ -68,16 +81,52 @@ export default function VolunteersPage() { console.error("Error deleting users:", error); } }; - const { fetching: disableFetching, fn: throttledDeleteUsers } = + + // @TODO Delete organizations functionality + const deleteOrganizations = async () => { + // try { + // const deletePromises = selected.map((id) => deleteOrganization(id)); + // const responses = await Promise.all(deletePromises); + // const allDeleted = responses.every( + // (response) => response.code === "SUCCESS" + // ); + // if (allDeleted) { + // setOrganizations((prevOrganizations) => + // prevOrganizations + // ? prevOrganizations.filter( + // (organization) => !selected.includes(organization.id) + // ) + // : [] + // ); + // setSelected([]); + // } else { + // console.error("Not all deletions succeeded"); + // } + // setIsModalOpen(false); + // } catch (error) { + // console.error("Error deleting organizations:", error); + // } + }; + + const { fetching: disableUsersFetching, fn: throttledDeleteUsers } = useApiThrottle({ fn: deleteUsers }); + const { fetching: disableOrgsFetching, fn: throttledDeleteOrganizations } = + useApiThrottle({ fn: deleteOrganizations }); + return (
- Volunteer List ({users ? users.length : 0}) + {activeTab === "Individuals" ? "Volunteer" : "Organizations"} List ( + {activeTab === "Individuals" && users + ? users.length + : organizations + ? organizations.length + : 0} + )
{selected.length > 0 ? ( @@ -114,7 +163,32 @@ export default function VolunteersPage() { setSelected([]); }} /> - {filteredUsers && filteredUsers.length > 0 ? ( +
+ {["Individuals", "Organizations"].map((tab) => ( + + ))} +
+ {activeTab === "Individuals" && + filteredUsers && + filteredUsers.length > 0 ? ( + ) : activeTab === "Organizations" && + filteredOrganizations && + filteredOrganizations.length > 0 ? ( + ) : (
@@ -134,7 +217,8 @@ export default function VolunteersPage() { />
- No volunteers found! + No {activeTab === "Individuals" ? "individuals" : "organizations"}{" "} + found!
)} @@ -144,7 +228,14 @@ export default function VolunteersPage() {
Are you sure you want to delete {selected.length}{" "} - {selected.length === 1 ? "user" : "users"}? + {activeTab === "Individuals" + ? selected.length === 1 + ? "user" + : "users" + : selected.length === 1 + ? "organization" + : "organizations"}{" "} + ?
You will not be able to recover {selected.length === 1 ? "a" : ""}{" "} @@ -168,7 +259,7 @@ export default function VolunteersPage() {