diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f66deb5..eaedda6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -112,4 +112,5 @@ model CustomDay { endTime DateTime title String? description String? + capacity Int @default(10) } \ No newline at end of file diff --git a/src/app/api/customDay/route.client.ts b/src/app/api/customDay/route.client.ts index 18839b2..9f15120 100644 --- a/src/app/api/customDay/route.client.ts +++ b/src/app/api/customDay/route.client.ts @@ -38,13 +38,14 @@ export const fetchApi = async ( }; export const addCustomDay = async (customDay: CreateCustomDayInput) => { - const { date, startTime, endTime, title, description } = customDay; + const { date, startTime, endTime, title, description, capacity } = customDay; return fetchApi("/api/customDay", "POST", { date: date.toISOString().split("T")[0], startTime, endTime, title, description, + capacity, }); }; diff --git a/src/app/api/customDay/route.ts b/src/app/api/customDay/route.ts index decc2da..8cd093d 100644 --- a/src/app/api/customDay/route.ts +++ b/src/app/api/customDay/route.ts @@ -5,7 +5,7 @@ const prisma = new PrismaClient(); export const POST = async (request: NextRequest) => { try { - const { date, startTime, endTime, title, description } = + const { date, startTime, endTime, title, description, capacity } = await request.json(); const normalizedDate = new Date(date); @@ -24,7 +24,7 @@ export const POST = async (request: NextRequest) => { if (existing) { result = await prisma.customDay.update({ where: { id: existing.id }, - data: { startTime, endTime, title, description }, + data: { startTime, endTime, title, description, capacity }, }); } else { result = await prisma.customDay.create({ @@ -34,6 +34,7 @@ export const POST = async (request: NextRequest) => { endTime, title, description, + capacity, }, }); } diff --git a/src/app/api/organization/route.client.ts b/src/app/api/organization/route.client.ts index 80aa038..1d6cca1 100644 --- a/src/app/api/organization/route.client.ts +++ b/src/app/api/organization/route.client.ts @@ -40,6 +40,12 @@ export const getOrganizations = async () => { return fetchApi(url, "GET"); }; +export const getOrganizationsByDate = async (date: Date) => { + const isoDate = date.toISOString().split("T")[0]; + const url = `/api/organization?date=${isoDate}`; + return fetchApi(url, "GET"); +}; + export const deleteOrganization = async (organizationId: string) => { const url = `/api/organization?id=${organizationId}`; return fetchApi(url, "DELETE"); diff --git a/src/app/api/organization/route.ts b/src/app/api/organization/route.ts index 0dacfbf..b7d4955 100644 --- a/src/app/api/organization/route.ts +++ b/src/app/api/organization/route.ts @@ -50,12 +50,20 @@ export const GET = async (request: NextRequest) => { const { searchParams } = new URL(request.url); const id: string | undefined = searchParams.get("id") || undefined; + const date: string | undefined = searchParams.get("date") || undefined; try { if (id) { const fetchedOrganization = await prisma.organization.findUnique({ where: { id }, - include: { volunteerSessions: true, users: true }, + include: { + volunteerSessions: true, + users: { + include: { + volunteerSessions: true, + }, + }, + }, }); if (!fetchedOrganization) { @@ -77,6 +85,56 @@ export const GET = async (request: NextRequest) => { ); } + if (date) { + try { + const [year, month, day] = date.split("-").map(Number); + const start = new Date(year, month - 1, day); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(start.getDate() + 1); + + const orgsWithSlots = await prisma.organization.findMany({ + where: { + timeSlots: { + some: { + date: { + gte: start, + lt: end, + }, + }, + }, + }, + include: { + timeSlots: { + where: { + date: { + gte: start, + lt: end, + }, + }, + }, + }, + }); + + return NextResponse.json( + { + code: "SUCCESS", + data: orgsWithSlots, + }, + { status: 200 } + ); + } catch (error) { + console.error("Error:", error); + return NextResponse.json( + { + code: "ERROR", + message: error, + }, + { status: 404 } + ); + } + } + const fetchedOrganizations = await prisma.organization.findMany({ include: { users: true, diff --git a/src/app/api/password/route.ts b/src/app/api/password/route.ts index 81b1130..8169731 100644 --- a/src/app/api/password/route.ts +++ b/src/app/api/password/route.ts @@ -26,7 +26,7 @@ export const POST = async (request: NextRequest) => { const codeString = Math.floor(Math.random() * 10000) .toString() .padStart(4, "6"); - const expire = new Date(Date.now() + 1000 * 60); + const expire = new Date(Date.now() + 1000 * 60 * 15); await prisma.code.update({ where: { userId: user.id }, diff --git a/src/app/api/timeSlot/route.client.ts b/src/app/api/timeSlot/route.client.ts index 7ff0a41..bede5cf 100644 --- a/src/app/api/timeSlot/route.client.ts +++ b/src/app/api/timeSlot/route.client.ts @@ -40,7 +40,6 @@ export const fetchApi = async ( export const addTimeSlot = async ( timeSlot: CreateTimeSlotInput, groupSignupInfo?: { - eventTitle: string; date: string; startTime: string; endTime: string; diff --git a/src/app/api/timeSlot/route.ts b/src/app/api/timeSlot/route.ts index 13edd69..89f906f 100644 --- a/src/app/api/timeSlot/route.ts +++ b/src/app/api/timeSlot/route.ts @@ -17,6 +17,7 @@ export const POST = async (request: NextRequest) => { durationHours: timeSlot.durationHours, date: new Date(timeSlot.date), approved: timeSlot.approved, + numVolunteers: timeSlot.numVolunteers, }, }); diff --git a/src/app/api/user/route.client.ts b/src/app/api/user/route.client.ts index 4422c03..7992ffa 100644 --- a/src/app/api/user/route.client.ts +++ b/src/app/api/user/route.client.ts @@ -8,6 +8,9 @@ type CreateVolunteerDetailsInput = Omit< VolunteerDetails, "id" | "user" | "userId" >; +type UserUpdateInput = Omit & { + organizationName?: string; +}; /** * Sends an HTTP request to the specified endpoint with the provided method and body. @@ -76,7 +79,7 @@ export const deleteUser = async (userID: string) => { }; export const updateUser = async ( - user: User, + user: UserUpdateInput, volunteerDetails?: VolunteerDetails ) => { return fetchApi("/api/user", "PATCH", { user, volunteerDetails }); diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index f0c5a05..7e575e3 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -299,7 +299,11 @@ export const GET = async (request: NextRequest) => { try { const fetchedUser = await prisma.user.findUnique({ where: id ? { id } : { email }, - include: { volunteerDetails: true, volunteerSessions: true }, + include: { + volunteerDetails: true, + volunteerSessions: true, + organization: true, + }, }); if (!fetchedUser) { @@ -347,13 +351,28 @@ export const PATCH = async (request: NextRequest) => { volunteerDetails: _, // eslint-disable-next-line @typescript-eslint/no-unused-vars volunteerSessions: __, - ...userWithoutIdAndRelations + // eslint-disable-next-line @typescript-eslint/no-unused-vars + organizationId: ___, + organizationName, + ...userWithoutRelations } = user; + const organization = + organizationName?.trim() !== "" + ? await prisma.organization.findFirst({ + where: { + normalizedName: organizationName.trim().toLowerCase(), + }, + }) + : null; + const updatedUser = await prisma.user.update({ where: { id }, data: { - ...userWithoutIdAndRelations, + ...userWithoutRelations, + ...(organization + ? { organization: { connect: { id: organization.id } } } + : { organization: { disconnect: true } }), volunteerDetails: volunteerDetails ? { update: { @@ -383,7 +402,7 @@ export const PATCH = async (request: NextRequest) => { { status: 200 } ); } catch (error) { - console.error("Error:", error); + console.error("Error updating user:", error); return NextResponse.json( { code: "ERROR", diff --git a/src/app/api/volunteerSession/route.client.ts b/src/app/api/volunteerSession/route.client.ts index d82a858..581da0a 100644 --- a/src/app/api/volunteerSession/route.client.ts +++ b/src/app/api/volunteerSession/route.client.ts @@ -43,8 +43,11 @@ export const addVolunteerSession = async ( return fetchApi("/api/volunteerSession", "POST", { volunteerSession }); }; -export const updateVolunteerSession = async (userId: string) => { - return fetchApi("/api/volunteerSession", "PATCH", { userId }); +export const updateVolunteerSession = async (params: { + userId?: string; + organizationId?: string; +}) => { + return fetchApi("/api/volunteerSession", "PATCH", params); }; export const getVolunteerSessions = async (userId: string) => { diff --git a/src/app/api/volunteerSession/route.ts b/src/app/api/volunteerSession/route.ts index 4ca517a..1ee389d 100644 --- a/src/app/api/volunteerSession/route.ts +++ b/src/app/api/volunteerSession/route.ts @@ -3,63 +3,80 @@ import { NextRequest, NextResponse } from "next/server"; const prisma = new PrismaClient(); -/** - * Creates a new user with associated volunteerDetails. - * @param {NextRequest} request - The incoming request. - * @returns {NextResponse} - JSON response with user data or error. - */ export const POST = async (request: NextRequest) => { try { - /* @TODO: Add auth */ - const { volunteerSession } = await request.json(); - const { userId, dateWorked, timeSlotId } = volunteerSession; + const { userId, organizationId, dateWorked, timeSlotId } = volunteerSession; + + if (!userId && !organizationId) { + return NextResponse.json( + { + code: "MISSING_IDS", + message: "Either userId or organizationId must be provided.", + }, + { status: 400 } + ); + } const parsedDateWorked = new Date(dateWorked); const startOfDay = new Date(parsedDateWorked); startOfDay.setHours(0, 0, 0, 0); - const endOfDay = new Date(parsedDateWorked); endOfDay.setHours(23, 59, 59, 999); - const openSession = await prisma.volunteerSession.findFirst({ - where: { - userId, - checkOutTime: null, - durationHours: null, - dateWorked: { - gte: startOfDay, - lte: endOfDay, + // Check for duplicate active session (only for individuals) + if (userId) { + const openSession = await prisma.volunteerSession.findFirst({ + where: { + userId, + checkOutTime: null, + durationHours: null, + dateWorked: { + gte: startOfDay, + lte: endOfDay, + }, }, - }, + }); + + if (openSession) { + return NextResponse.json( + { + code: "ALREADY_CHECKED_IN", + message: "User is already checked in and has not checked out.", + }, + { status: 400 } + ); + } + } + + const timeSlot = await prisma.timeSlot.findFirst({ + where: { id: timeSlotId }, }); - if (openSession) { + if (!timeSlot) { return NextResponse.json( { - code: "ALREADY_CHECKED_IN", - message: "User is already checked in and has not checked out.", + code: "INVALID_TIME_SLOT", + message: "Time slot does not exist.", }, { status: 400 } ); } - // Check if user has a time slot for the current day before allowing check-in - const timeSlot = await prisma.timeSlot.findFirst({ - where: { id: timeSlotId }, - }); + const timeSlotDateStr = timeSlot.date.toISOString().slice(0, 10); + const sessionDateStr = parsedDateWorked.toISOString().slice(0, 10); + // Validate slot ownership if ( - !timeSlot || - timeSlot.userId !== userId || - timeSlot.date.toISOString().slice(0, 10) !== - parsedDateWorked.toISOString().slice(0, 10) + (userId && timeSlot.userId !== userId) || + (organizationId && timeSlot.organizationId !== organizationId) || + timeSlotDateStr !== sessionDateStr ) { return NextResponse.json( { code: "INVALID_TIME_SLOT", message: - "Time slot is invalid, not associated with user, or does not match today's date.", + "Time slot is not associated with the user/org or is for the wrong day.", }, { status: 400 } ); @@ -69,7 +86,7 @@ export const POST = async (request: NextRequest) => { return NextResponse.json( { code: "TIME_SLOT_USED", - message: "Time slot has already been used or checked in.", + message: "Time slot has already been used.", }, { status: 400 } ); @@ -77,7 +94,12 @@ export const POST = async (request: NextRequest) => { const newSession = await prisma.volunteerSession.create({ data: { - ...volunteerSession, + userId, + organizationId, + dateWorked: parsedDateWorked, + checkInTime: new Date(), + checkOutTime: null, + durationHours: null, timeSlotId, }, }); @@ -92,7 +114,9 @@ export const POST = async (request: NextRequest) => { return NextResponse.json( { code: "SUCCESS", - message: `Volunteer session created for user ${userId} successfully.`, + message: userId + ? `Volunteer session created for user ${userId}.` + : `Volunteer session created for organization ${organizationId}.`, data: newSession, }, { status: 201 } @@ -178,7 +202,17 @@ export const GET = async (request: NextRequest) => { export const PATCH = async (request: NextRequest) => { try { - const { userId } = await request.json(); + const { userId, organizationId } = await request.json(); + + if (!userId && !organizationId) { + return NextResponse.json( + { + code: "INVALID_REQUEST", + message: "Either userId or organizationId must be provided.", + }, + { status: 400 } + ); + } const today = new Date(); const startOfToday = new Date(today); @@ -187,15 +221,16 @@ export const PATCH = async (request: NextRequest) => { const endOfToday = new Date(today); endOfToday.setHours(23, 59, 59, 999); + // Find active session based on userId or organizationId const activeSession = await prisma.volunteerSession.findFirst({ where: { - userId, checkOutTime: null, durationHours: null, dateWorked: { gte: startOfToday, lte: endOfToday, }, + ...(userId ? { userId } : { organizationId }), }, }); @@ -203,7 +238,7 @@ export const PATCH = async (request: NextRequest) => { return NextResponse.json( { code: "ALREADY_CHECKED_OUT", - message: "User has already checked out or has no active session.", + message: "No active session found for user or organization.", }, { status: 400 } ); @@ -234,7 +269,7 @@ export const PATCH = async (request: NextRequest) => { return NextResponse.json( { code: "SUCCESS", - message: `User ${userId} checked out successfully.`, + message: `Checked out successfully.`, data: updatedSession, }, { status: 200 } @@ -244,7 +279,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 d4ba801..3f984e7 100644 --- a/src/app/private/events/page.tsx +++ b/src/app/private/events/page.tsx @@ -29,9 +29,9 @@ export default function EventsPage() { const [selectedDate, setSelectedDate] = React.useState( undefined ); - const [timeSlots, setTimeSlots] = React.useState([ - { start: "", end: "", submitted: false }, - ]); + const [timeSlots, setTimeSlots] = React.useState< + { start: string; end: string; submitted: boolean; isGroup?: boolean }[] + >([{ start: "", end: "", submitted: false }]); const [individuals, setIndividuals] = React.useState([]); const [groups, setGroups] = React.useState([]); const [page, setPage] = React.useState(0); @@ -42,11 +42,15 @@ export default function EventsPage() { }>({ start: "10:00", end: "18:00" }); const [customDayTitle, setCustomDayTitle] = React.useState(""); const [customDayDescription, setCustomDayDescription] = React.useState(""); + const [customDayCapacity, setCustomDayCapacity] = React.useState(10); const [activeTab, setActiveTab] = React.useState<"Individuals" | "Groups">( "Individuals" ); + const [isUserAlreadySignedUp, setIsUserAlreadySignedUp] = + React.useState(false); const handleAddTimeSlot = () => { + if (isCapacityFull) return; setTimeSlots((prev) => { const updated = [...prev]; updated[updated.length - 1].submitted = true; @@ -111,6 +115,9 @@ export default function EventsPage() { const hasSubmittedSlot = timeSlots.some((slot) => slot.submitted); + const isCapacityFull = + individuals.length >= customDayCapacity && !isUserAlreadySignedUp; + const isPastOrToday = (date?: Date) => { if (!date) return false; const today = new Date(); @@ -246,15 +253,21 @@ export default function EventsPage() { selectedDate ); const slots = result.data; + const isUserAlreadySignedUp = slots.some( + (slot: TimeSlot) => !slot.organizationId + ); + setIsUserAlreadySignedUp(isUserAlreadySignedUp); const formatted: { start: string; end: string; submitted: boolean; + isGroup: boolean; }[] = slots.map((slot: TimeSlot) => ({ start: new Date(slot.startTime).toTimeString().slice(0, 5), end: new Date(slot.endTime).toTimeString().slice(0, 5), submitted: true, + isGroup: !!slot.organizationId, })); formatted.sort((a, b) => a.start.localeCompare(b.start)); @@ -279,10 +292,12 @@ export default function EventsPage() { setCustomDayHours({ start, end }); setCustomDayTitle(customDay.title ?? ""); setCustomDayDescription(customDay.description ?? ""); + setCustomDayCapacity(customDay.capacity); } else { setCustomDayHours({ start: "10:00", end: "18:00" }); setCustomDayTitle(""); setCustomDayDescription(""); + setCustomDayCapacity(10); } })(), ]); @@ -332,13 +347,16 @@ export default function EventsPage() { height={175} />
+ {isCapacityFull && ( +
+ This event has reached its volunteer capacity. +
+ )}
{customDayTitle === "" ? !isPastOrToday(selectedDate) - ? `Sign up for your volunteering time! We are open from${" "} - ${formatTime(customDayHours.start)} -${" "} - ${formatTime(customDayHours.end)}.` + ? `Sign up for your volunteering time!` : "Your Volunteer Hours" : customDayTitle}
@@ -358,8 +376,13 @@ export default function EventsPage() {
{formattedDate ? !isPastOrToday(selectedDate) - ? `Choose Your Time (${formattedDate})` - : formattedDate + ? `Choose Your Time (${formattedDate}). We are open from${" "} + ${formatTime(customDayHours.start)} -${" "} + ${formatTime(customDayHours.end)}.` + : `${formattedDate} (${formatTime( + customDayHours.start + )} -${" "} + ${formatTime(customDayHours.end)})` : "Choose Your Time"}
@@ -385,9 +408,11 @@ export default function EventsPage() {
{formatTime(slot.start)} -{" "} {formatTime(slot.end)} + {slot.isGroup && " (Group)"}
- {!isPastOrToday(selectedDate) ? ( + {!isPastOrToday(selectedDate) && + !isCapacityFull ? ( ) : null} - ) : !isPastOrToday(selectedDate) ? ( + ) : !isPastOrToday(selectedDate) && + !isCapacityFull ? (
setPage(0) } - disabled={confirmLoading || (page === 0 && !hasSubmittedSlot)} + disabled={ + confirmLoading || + (page === 0 && (!hasSubmittedSlot || isCapacityFull)) + } > {page === 0 ? "Confirm" : "Close"} @@ -625,7 +654,9 @@ export default function EventsPage() { />
Total Individual Signups: {individuals.length}{" "} - {individuals.length === 1 ? "volunteer" : "volunteers"} + {individuals.length === 1 ? "volunteer" : "volunteers"} /{" "} + {customDayCapacity}{" "} + {customDayCapacity === 1 ? "volunteer" : "volunteers"}
diff --git a/src/app/private/page.tsx b/src/app/private/page.tsx index 405a4bd..d2d3a6b 100644 --- a/src/app/private/page.tsx +++ b/src/app/private/page.tsx @@ -46,9 +46,12 @@ export default function HomePage() { const userRes = await getUser(session?.user.id as string); const res = await getTimeSlots(session?.user.id as string); const now = new Date(); - const upcomingSlots = res.data.filter( - (slot: TimeSlot) => new Date(slot.startTime) > now - ); + const upcomingSlots = res.data + .filter((slot: TimeSlot) => new Date(slot.startTime) > now) + .sort( + (a: TimeSlot, b: TimeSlot) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); setSessions(userRes.data.volunteerSessions); setTimeSlots(upcomingSlots); @@ -285,7 +288,9 @@ export default function HomePage() { {timeSlots.slice(0, 6).map((timeSlot, index) => ( (null); + const [user, setUser] = useState(null); const [volunteerDetails, setVolunteerDetails] = useState(null); const [loadError, setLoadError] = useState(null); + const [organizations, setOrganizations] = useState([]); + const [organizationName, setOrganizationName] = useState(""); + const [loading, setLoading] = useState(true); useEffect(() => { if (status === "loading") return; @@ -36,6 +43,7 @@ export default function EditProfilePage() { setLoadError("No user ID found."); return; } + try { const response = await getUser(session?.user.id); if (!response?.data) { @@ -49,7 +57,28 @@ export default function EditProfilePage() { console.error("Error fetching user:", err); setLoadError("Failed to load user information."); } + + try { + const res = await getOrganizations(); + if (res?.data) { + const sorted = [...res.data].sort((a, b) => + a.normalizedName.localeCompare(b.normalizedName) + ); + setOrganizations(sorted); + } + + const userRes = await getUser(session.user.id); + setUser(userRes.data); + setVolunteerDetails(userRes.data.volunteerDetails); + setOrganizationName(userRes.data.organization?.name || ""); + } catch (err) { + console.error("Failed to load user/organization info:", err); + setLoadError("Failed to load user/organization info."); + } + + setLoading(false); }; + fetchData(); }, [router, session?.user.id, status, userId]); @@ -68,7 +97,7 @@ export default function EditProfilePage() { try { if (user) { await updateUser( - trimStrings(user), + { ...trimStrings(user), organizationName: organizationName.trim() }, volunteerDetails ? trimStrings(volunteerDetails) : undefined ); await update(); @@ -139,7 +168,7 @@ export default function EditProfilePage() { } }; - if (status === "loading") { + if (status === "loading" || loading) { return (
Loading... @@ -231,6 +260,46 @@ export default function EditProfilePage() { {/* Are You Over 14 Field */} {session.user.role === Role.VOLUNTEER ? ( <> +
+
+
Organization
+
+
+ org.name)} + inputValue={organizationName} + onInputChange={(_, value) => setOrganizationName(value)} + renderInput={(params) => ( + + )} + /> +
+
+
@@ -250,7 +319,8 @@ export default function EditProfilePage() { handleChange("ageOver14", false)} + onChange={() => {}} + disabled />
diff --git a/src/app/types/index.ts b/src/app/types/index.ts index 2850fbf..6877b07 100644 --- a/src/app/types/index.ts +++ b/src/app/types/index.ts @@ -15,5 +15,6 @@ export type UserWithVolunteerDetail = User & { export type OrganizationWithUsers = Organization & { users?: User[]; + timeSlots?: TimeSlot[]; volunteerSessions?: VolunteerSession[]; }; diff --git a/src/components/CheckInOut.tsx b/src/components/CheckInOut.tsx index d6f4bf4..f420d8e 100644 --- a/src/components/CheckInOut.tsx +++ b/src/components/CheckInOut.tsx @@ -8,7 +8,7 @@ import logo1 from "../../public/logo1.png"; import CheckConfirmation from "./CheckConfirmation"; import Autocomplete from "@mui/material/Autocomplete"; import confirmation from "../../public/confirmation.png"; -import { TimeSlot, User } from "@prisma/client"; +import { TimeSlot } from "@prisma/client"; import { getUsersByDate } from "@api/user/route.client"; import { getTimeSlotsByDate } from "@api/timeSlot/route.client"; import { @@ -17,6 +17,8 @@ import { } from "@api/volunteerSession/route.client"; import { useRouter } from "next/navigation"; import useApiThrottle from "../hooks/useApiThrottle"; +import { OrganizationWithUsers, UserWithVolunteerDetail } from "../app/types"; +import { getOrganizationsByDate } from "@api/organization/route.client"; export default function CheckInOutForm() { const router = useRouter(); @@ -25,7 +27,11 @@ export default function CheckInOutForm() { const [activeButton, setActiveButton] = useState< "checkin" | "checkout" | null >(null); - const [users, setUsers] = useState([]); + const [activeTab, setActiveTab] = useState<"individual" | "group">( + "individual" + ); + + const [users, setUsers] = useState([]); const [timeSlots, setTimeSlots] = useState([]); const [selectedTimeSlot, setSelectedTimeSlot] = useState( null @@ -34,24 +40,40 @@ export default function CheckInOutForm() { const [stage, setStage] = useState<"initial" | "shifts" | "confirmation">( "initial" ); + const [organizations, setOrganizations] = useState( + [] + ); useEffect(() => { - const fetchUsers = async () => { + const fetchData = async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + try { - const today = new Date(); - const res = await getUsersByDate(today); - if (res && res.data) { - const sortedUsers = [...res.data].sort((a, b) => + const [userRes, orgRes] = await Promise.all([ + getUsersByDate(today), + getOrganizationsByDate(today), + ]); + + if (userRes?.data) { + const sortedUsers = [...userRes.data].sort((a, b) => a.email.localeCompare(b.email) ); setUsers(sortedUsers); } + + if (orgRes?.data) { + const sortedOrgs = [...orgRes.data].sort((a, b) => + a.normalizedName.localeCompare(b.normalizedName) + ); + setOrganizations(sortedOrgs); + } } catch (err) { - console.error("Failed to fetch users by date:", err); + console.error("Failed to fetch users or organizations:", err); } }; - fetchUsers(); + fetchData(); }, []); const handleSubmit = async (e: React.FormEvent) => { @@ -59,59 +81,90 @@ export default function CheckInOutForm() { }; const handleContinue = async (e: React.FormEvent) => { - if ( - email !== "" && - activeButton !== null && - users.find((user) => user.email === email) - ) { - e.preventDefault(); + e.preventDefault(); + + if (!email || activeButton === null) return; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (activeTab === "individual") { const user = users.find((user) => user.email === email); + if (!user) return; if (activeButton === "checkin") { try { - const today = new Date(); - - if (user) { - const res = await getTimeSlotsByDate(user.id, today); - - if (res && res.data) { - const sortedSlots = [...res.data].sort( - (a, b) => + const res = await getTimeSlotsByDate(user.id, today); + if (res?.data) { + const filteredSlots = res.data + .filter((slot: TimeSlot) => slot.organizationId === null) // ✅ Exclude group slots + .sort( + (a: TimeSlot, b: TimeSlot) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime() ); - setTimeSlots(sortedSlots); - } + setTimeSlots(filteredSlots); } - setStage("shifts"); } catch (err) { console.error("Failed to fetch time slots by date:", err); } } else { try { - if (user) { - const res = await updateVolunteerSession(user.id); - if (res && res.code && res.code !== "ALREADY_CHECKED_OUT") { - const now = new Date(); - setCheckInOutTime(now); - setStage("confirmation"); - } + const res = await updateVolunteerSession({ userId: user.id }); + if (res && res.code !== "ALREADY_CHECKED_OUT") { + const now = new Date(); + setCheckInOutTime(now); + setStage("confirmation"); } } catch (err) { - if (err instanceof Error) { - const errorData = JSON.parse(err.message); + const errorData = JSON.parse((err as Error).message); + if (errorData.code === "ALREADY_CHECKED_OUT") { + alert("You must check in before checking out again."); + } else { + console.error("Check-out failed:", errorData.message); + alert("There was an error during check-out."); + } + } + } + } else { + const org = organizations.find((org) => org.name === email); + if (!org) { + console.error("Organization not found."); + return; + } - if (errorData.code === "ALREADY_CHECKED_OUT") { - alert( - "You have already checked out and must check in before checking out again." - ); - } else { - console.error("Check-out failed:", errorData.message); - alert("There was an error during check-out. Please try again."); - } + const allSlots = org.timeSlots ?? []; + + const todaySlots = allSlots + .filter((slot) => { + const slotDate = new Date(slot.date); + slotDate.setHours(0, 0, 0, 0); + return slotDate.getTime() === today.getTime(); + }) + .sort( + (a, b) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime() + ); + + if (activeButton === "checkin") { + setTimeSlots(todaySlots); + setStage("shifts"); + } else { + try { + const res = await updateVolunteerSession({ organizationId: org.id }); + if (res && res.code !== "ALREADY_CHECKED_OUT") { + const now = new Date(); + setCheckInOutTime(now); + setStage("confirmation"); + } + } catch (err) { + const errorData = JSON.parse((err as Error).message); + if (errorData.code === "ALREADY_CHECKED_OUT") { + alert("This group has already checked out."); } else { - console.error("Unexpected error:", err); + console.error("Group check-out failed:", errorData.message); + alert("There was an error during check-out."); } } } @@ -135,21 +188,42 @@ export default function CheckInOutForm() { return; } - const user = users.find((user) => user.email === email); - if (!user) { - console.error("User not found."); - return; - } + let volunteerSession; - const volunteerSession = { - userId: user.id, - organizationId: null, - durationHours: null, - checkInTime: now, - checkOutTime: null, - dateWorked: dateWorked, - timeSlotId: selectedTimeSlot.id, - }; + if (activeTab === "individual") { + const user = users.find((user) => user.email === email); + if (!user) { + console.error("User not found."); + return; + } + + volunteerSession = { + userId: user.id, + organizationId: null, + durationHours: null, + checkInTime: now, + checkOutTime: null, + dateWorked, + timeSlotId: selectedTimeSlot.id, + }; + } else { + if (!selectedTimeSlot.organizationId || !selectedTimeSlot.userId) { + console.error( + "Missing organizationId or userId in selected time slot." + ); + return; + } + + volunteerSession = { + userId: selectedTimeSlot.userId, + organizationId: selectedTimeSlot.organizationId, + durationHours: null, + checkInTime: now, + checkOutTime: null, + dateWorked, + timeSlotId: selectedTimeSlot.id, + }; + } try { await addVolunteerSession(volunteerSession); @@ -177,6 +251,15 @@ export default function CheckInOutForm() { fn: handleConfirm, }); + const canContinue = + !continueLoading && + email !== "" && + activeButton !== null && + ((activeTab === "individual" && + users.some((user) => user.email === email)) || + (activeTab === "group" && + organizations.some((org) => org.name === email))); + if (stage === "shifts") { return (
@@ -187,6 +270,7 @@ export default function CheckInOutForm() { type="button" onClick={() => { setSelectedTimeSlot(null); + setTimeSlots([]); setStage("initial"); }} > @@ -408,54 +492,116 @@ export default function CheckInOutForm() {
-
- Your email - user.email)} - filterOptions={(options, { inputValue }) => { - return options - .filter((option) => - option.toLowerCase().includes(inputValue.toLowerCase()) - ) - .slice(0, 5); - }} - inputValue={email} - onInputChange={(_, value) => { - setEmail(value); - }} - sx={{ width: "100%" }} - renderInput={(params) => ( - { - if (e.key === "Enter" && email !== "") { - handleSubmit(e); - } +
+ Select type +
+ + +
+ {activeTab === "individual" ? ( + <> +
Your Email
+ user.email)} + filterOptions={(options, { inputValue }) => + options + .filter((option) => + option + .toLowerCase() + .includes(inputValue.toLowerCase()) + ) + .slice(0, 5) + } + inputValue={email} + onInputChange={(_, value) => { + setEmail(value); }} + sx={{ width: "100%" }} + renderInput={(params) => ( + { + if (e.key === "Enter" && email !== "") { + handleSubmit(e); + } + }} + /> + )} /> - )} - /> + + ) : ( + <> +
Organization Name
+ org.name)} + filterOptions={(options, { inputValue }) => + options + .filter((option) => + option + .toLowerCase() + .includes(inputValue.toLowerCase()) + ) + .slice(0, 5) + } + inputValue={email} + onInputChange={(_, value) => { + setEmail(value); + }} + sx={{ width: "100%" }} + renderInput={(params) => ( + { + if (e.key === "Enter" && email !== "") { + handleSubmit(e); + } + }} + /> + )} + /> + + )}
-
- setTitle(e.target.value)} - error={submitted && errors.title} - slotProps={{ - input: { - sx: { - fontSize: 18, - fontWeight: 600, - color: "#6B7280", - padding: 0, - }, - }, - root: { - sx: { - "&::before": { - borderBottom: "1px solid #D0D5DD", - }, - }, - }, - }} - /> -
+ {/* Volunteer Reason Field */} +
+
Organization
+
+
+
+ {user.organization?.name || ""} +
+
+
+
+
{/* Are You Over 14 Field */}
diff --git a/src/components/RadioButton.tsx b/src/components/RadioButton.tsx index 29e4a6a..3c9fd89 100644 --- a/src/components/RadioButton.tsx +++ b/src/components/RadioButton.tsx @@ -4,15 +4,31 @@ type RadioButtonProps = { label: string; checked: boolean; onChange?: () => void; + disabled?: boolean; }; -const RadioButton = ({ label, checked, onChange }: RadioButtonProps) => { +const RadioButton = ({ + label, + checked, + onChange, + disabled = false, +}: RadioButtonProps) => { + const handleClick = () => { + if (!disabled && onChange) { + onChange(); + } + }; + return (
@@ -27,7 +43,7 @@ const RadioButton = ({ label, checked, onChange }: RadioButtonProps) => { @@ -47,8 +63,8 @@ const RadioButton = ({ label, checked, onChange }: RadioButtonProps) => { diff --git a/src/components/TopHeader.tsx b/src/components/TopHeader.tsx index d8e17af..1995d92 100644 --- a/src/components/TopHeader.tsx +++ b/src/components/TopHeader.tsx @@ -95,7 +95,7 @@ const TopHeader = ({ user }: TopHeaderProps) => { }} > -
Request Group Sign Up
+
Sign Up As a Group
{showModal && ( diff --git a/src/components/VolunteerTable.tsx b/src/components/VolunteerTable.tsx index 77a1628..c60a10f 100644 --- a/src/components/VolunteerTable.tsx +++ b/src/components/VolunteerTable.tsx @@ -171,7 +171,6 @@ export default function VolunteerTable({ > Name - - {fromAttendeePage ? "Time(s)" : "Hours Volunteered"} + {fromAttendeePage + ? `Time(s) ${showOrganizationName ? "and Group Size" : null}` + : "Hours Volunteered"} - {format(start)} - {format(end)} + {format(start)} - {format(end)} ( + {slot.numVolunteers})
); }) diff --git a/src/lib/groupSignupMail.ts b/src/lib/groupSignupMail.ts index 0af9c92..caa6067 100644 --- a/src/lib/groupSignupMail.ts +++ b/src/lib/groupSignupMail.ts @@ -1,7 +1,6 @@ import nodemailer from "nodemailer"; export const sendGroupSignupMail = async (fields: { - eventTitle: string; date: string; startTime: string; endTime: string; @@ -23,7 +22,6 @@ export const sendGroupSignupMail = async (fields: { }); const { - eventTitle, date, startTime, endTime, @@ -37,7 +35,6 @@ export const sendGroupSignupMail = async (fields: { const textBody = ` Group Sign-Up Request - Event Title: ${eventTitle} Date: ${date} Start Time: ${startTime} End Time: ${endTime} @@ -49,7 +46,6 @@ export const sendGroupSignupMail = async (fields: { const htmlBody = `

New Group Sign-Up Request

-

Event Title: ${eventTitle}

Date: ${date}

Start Time: ${startTime}

End Time: ${endTime}