diff --git a/src/app/api/volunteerSession/route.client.ts b/src/app/api/volunteerSession/route.client.ts index 184a185..83960b2 100644 --- a/src/app/api/volunteerSession/route.client.ts +++ b/src/app/api/volunteerSession/route.client.ts @@ -46,3 +46,8 @@ export const addVolunteerSession = async ( export const updateVolunteerSession = async (userId: string) => { return fetchApi("/api/volunteerSession", "PATCH", { userId }); }; + +export const getVolunteerSessions = async (userId: string) => { + const url = `/api/volunteerSession?userId=${userId}`; + return fetchApi(url, "GET"); +}; diff --git a/src/app/api/volunteerSession/route.ts b/src/app/api/volunteerSession/route.ts index 72a4884..441496e 100644 --- a/src/app/api/volunteerSession/route.ts +++ b/src/app/api/volunteerSession/route.ts @@ -109,6 +109,59 @@ export const POST = async (request: NextRequest) => { } }; +export const GET = async (request: NextRequest) => { + const { searchParams } = new URL(request.url); + const userId: string | undefined = searchParams.get("userId") || undefined; + + if (!userId) { + return NextResponse.json( + { + code: "BAD_REQUEST", + message: "User ID is required.", + }, + { + status: 400, + } + ); + } + + try { + const volunteerSessions = await prisma.volunteerSession.findMany({ + where: { + userId: userId, + NOT: [{ checkOutTime: null }, { durationHours: null }], + }, + }); + + if (!volunteerSessions) { + return NextResponse.json( + { + code: "NOT_FOUND", + message: "No volunteer sessions found", + }, + { status: 404 } + ); + } + + return NextResponse.json( + { + code: "SUCCESS", + data: volunteerSessions, + }, + { status: 200 } + ); + } catch (error) { + console.error("Error:", error); + return NextResponse.json( + { + code: "ERROR", + message: error, + }, + { status: 404 } + ); + } +}; + export const PATCH = async (request: NextRequest) => { try { const { userId } = await request.json(); diff --git a/src/app/private/events/page.tsx b/src/app/private/events/page.tsx index 1cf7e76..1449d9d 100644 --- a/src/app/private/events/page.tsx +++ b/src/app/private/events/page.tsx @@ -210,6 +210,7 @@ export default function EventsPage() {
diff --git a/src/app/private/profile/page.tsx b/src/app/private/profile/page.tsx index 6b33873..5c40641 100644 --- a/src/app/private/profile/page.tsx +++ b/src/app/private/profile/page.tsx @@ -3,15 +3,131 @@ import React from "react"; import { Icon } from "@iconify/react"; import ProfileAvatar from "@components/ProfileAvatar"; -import { Role } from "@prisma/client"; +import { Role, VolunteerSession } from "@prisma/client"; 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 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); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [showStartCalendar, showEndCalendar]); + if (!session) { return (
@@ -42,20 +158,202 @@ export default function ProfilePage() {
- + {session.user.role === Role.ADMIN ? ( + + ) : null} {/* Main Form */}
- {/* Are You Over 14 Field */} {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 */}
diff --git a/src/components/Calendar.tsx b/src/components/Calendar.tsx index 39d0fdf..695f98b 100644 --- a/src/components/Calendar.tsx +++ b/src/components/Calendar.tsx @@ -7,9 +7,14 @@ import "react-day-picker/style.css"; interface CalendarProps { selectedDate: Date | undefined; setSelectedDate: (date: Date | undefined) => void; + previousDisabled: boolean; } -export function Calendar({ selectedDate, setSelectedDate }: CalendarProps) { +export function Calendar({ + selectedDate, + setSelectedDate, + previousDisabled, +}: CalendarProps) { const customDays = ["s", "m", "t", "w", "t", "f", "s"]; return ( @@ -25,12 +30,16 @@ export function Calendar({ selectedDate, setSelectedDate }: CalendarProps) { }} showOutsideDays disabled={(date) => { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 0, 0, 0); + if (previousDisabled) { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); - const cutoff = new Date(tomorrow.getTime() - 24 * 60 * 60 * 1000); - return date < cutoff; + const cutoff = new Date(tomorrow.getTime() - 24 * 60 * 60 * 1000); + return date < cutoff; + } else { + return false; + } }} formatters={{ formatWeekdayName: (weekday) => customDays[weekday.getDay()], diff --git a/src/components/GroupSignUpModal.tsx b/src/components/GroupSignUpModal.tsx index 519f805..c9892f2 100644 --- a/src/components/GroupSignUpModal.tsx +++ b/src/components/GroupSignUpModal.tsx @@ -167,6 +167,7 @@ const GroupSignUpModal = ({ onClose }: { onClose: () => void }) => { setShowCalendar(false); } }} + previousDisabled />
)} diff --git a/src/components/SideNavBar.tsx b/src/components/SideNavBar.tsx index 61d278c..d077f17 100644 --- a/src/components/SideNavBar.tsx +++ b/src/components/SideNavBar.tsx @@ -102,23 +102,31 @@ const SideNavBar = ({ user }: SideNavBarProps) => { diff --git a/src/components/TimeTable.tsx b/src/components/TimeTable.tsx new file mode 100644 index 0000000..264c871 --- /dev/null +++ b/src/components/TimeTable.tsx @@ -0,0 +1,221 @@ +import { + Box, + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { VolunteerSession } from "@prisma/client"; +import React from "react"; + +interface TimeTableProps { + volunteerSessions: VolunteerSession[]; +} + +export default function TimeTable({ volunteerSessions }: TimeTableProps) { + const [page, setPage] = React.useState(0); + + const sortedSessions = [...volunteerSessions].sort((a, b) => { + const dateA = new Date(a.dateWorked).getTime(); + const dateB = new Date(b.dateWorked).getTime(); + + if (dateA !== dateB) { + return dateB - dateA; // descending by dateWorked + } + + const checkInA = new Date(a.checkInTime).getTime(); + const checkInB = new Date(b.checkInTime).getTime(); + + return checkInB - checkInA; // ascending by checkInTime if same date + }); + + const paginatedRows = sortedSessions.slice(page * 5, page * 5 + 5); + + return ( + + + + + + Date + + + + Total Hours Worked + + + Volunteer Session(s) + + + + + {paginatedRows.map((row) => { + const date = new Date(row.dateWorked).toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "2-digit", + }); + + const hoursWorked = row.durationHours?.toFixed(1) ?? "N/A"; + + const sessionRange = + row.checkInTime && row.checkOutTime + ? `${new Date(row.checkInTime).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + })} - ${new Date(row.checkOutTime).toLocaleTimeString( + "en-US", + { + hour: "numeric", + minute: "2-digit", + } + )}` + : "In Progress"; + + return ( + + + {date} + + + {hoursWorked} hours + + + {sessionRange} + + + ); + })} + +
+ + + Page {page + 1} of {Math.ceil((volunteerSessions?.length || 0) / 5)} + + + + + + + +
+ ); +}