From 4a384631b87c646f446f9d9be5e556a5f938e83b Mon Sep 17 00:00:00 2001 From: wkim10 Date: Wed, 30 Apr 2025 02:08:47 -0400 Subject: [PATCH 1/4] stuff --- src/app/api/organization/route.client.ts | 6 + src/app/api/organization/route.ts | 60 +++- src/app/api/timeSlot/route.client.ts | 1 - src/app/api/timeSlot/route.ts | 1 + src/app/api/volunteerSession/route.client.ts | 7 +- src/app/api/volunteerSession/route.ts | 111 +++--- src/app/private/events/page.tsx | 22 +- src/app/private/page.tsx | 13 +- src/app/types/index.ts | 1 + src/components/CheckInOut.tsx | 340 +++++++++++++------ src/components/GroupSignUpModal.tsx | 36 +- src/components/TopHeader.tsx | 2 +- src/components/VolunteerTable.tsx | 8 +- src/lib/groupSignupMail.ts | 4 - 14 files changed, 418 insertions(+), 194 deletions(-) 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/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/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..97b7907 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); @@ -251,10 +251,12 @@ export default function EventsPage() { 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)); @@ -336,9 +338,7 @@ export default function EventsPage() {
{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 +358,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,6 +390,7 @@ export default function EventsPage() {
{formatTime(slot.start)} -{" "} {formatTime(slot.end)} + {slot.isGroup && " (Group)"}
{!isPastOrToday(selectedDate) ? ( 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 [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", - }, - }, - }, - }} - /> -