From 177cf5928c0c6ce32216ceae82bba688fe07579f Mon Sep 17 00:00:00 2001 From: Kevin Guo <116191563+kvnguo@users.noreply.github.com> Date: Mon, 11 May 2026 16:57:38 -0500 Subject: [PATCH 1/3] feat: mobile responsive pass for staff + volunteer portals (#145) --- package.json | 1 + pnpm-lock.yaml | 12 + src/app/auth/login/page.module.css | 24 +- src/app/layout.tsx | 12 +- src/app/staff/dashboard/page.tsx | 15 +- src/app/staff/opportunities/page.tsx | 385 +++++++---- src/app/staff/profile/page.tsx | 42 +- .../email-templates-settings-client.tsx | 274 +++++--- .../event-categories-settings-client.tsx | 333 +++++---- .../skills/skills-settings-client.tsx | 635 ++++++++++++------ .../volunteer-types-settings-client.tsx | 326 +++++---- src/app/volunteer/calendar/page.tsx | 3 + src/app/volunteer/dashboard/page.tsx | 94 +-- src/app/volunteer/hours/page.tsx | 12 +- src/app/volunteer/opportunities/page.tsx | 133 ++-- src/app/volunteer/profile/page.tsx | 118 +++- src/components/layout/base-sidebar.tsx | 275 ++++---- src/components/layout/mobile-top-bar.tsx | 119 ++++ src/components/layout/profile-menu.tsx | 113 ++++ src/components/layout/role-layout.tsx | 52 +- src/components/layout/staff-sidebar.tsx | 18 +- src/components/layout/volunteer-sidebar.tsx | 18 +- src/components/shared/filter-drawer.tsx | 156 +++++ src/components/shared/index.ts | 3 + src/components/shared/mobile-dialog.tsx | 24 + src/components/shared/modal-title-bar.tsx | 11 +- src/components/shared/page-header.tsx | 20 +- src/components/shared/responsive-table.tsx | 57 ++ .../staff/approvals/attendance-modal.tsx | 7 +- src/components/staff/approvals/hours-tab.tsx | 11 +- .../staff/approvals/log-hours-modal.tsx | 7 +- src/components/staff/approvals/rsvps-tab.tsx | 11 +- src/components/staff/calendar/calendar.tsx | 78 ++- .../staff/calendar/event-detail-modal.tsx | 11 +- .../staff/calendar/event-form-modal.tsx | 32 +- .../communications/communications-list.tsx | 23 +- .../staff/communications/compose-modal.tsx | 6 +- .../staff/onboarding/document-manager.tsx | 21 +- .../staff/people-management/people-list.tsx | 204 ++---- .../staff/people-management/people-table.tsx | 406 +++++++---- .../people-management/staff-add-modal.tsx | 10 +- .../volunteer-management/import-modal.tsx | 7 +- .../volunteer-management/skills-modal.tsx | 6 +- .../volunteer-add-modal.tsx | 10 +- .../volunteer-management/volunteer-list.tsx | 170 ++--- .../volunteer-overview-tab.tsx | 16 +- .../volunteer-management/volunteer-table.tsx | 341 ++++++---- .../volunteer/announcement-detail-modal.tsx | 7 +- .../volunteer/availability-editor.tsx | 6 +- .../volunteer/dashboard-recommendations.tsx | 8 +- .../volunteer/onboarding/document-viewer.tsx | 234 +++++-- .../onboarding/onboarding-checklist.tsx | 20 +- .../volunteer/opportunity-detail-modal.tsx | 17 +- .../volunteer/upcoming-sessions.tsx | 48 +- .../volunteer/volunteer-edit-hours-modal.tsx | 27 +- .../volunteer-hours-detail-modal.tsx | 7 +- .../volunteer/volunteer-hours-table.tsx | 183 +++-- .../volunteer/volunteer-log-hours-modal.tsx | 31 +- src/hooks/use-communications.ts | 6 + src/hooks/use-is-mobile.ts | 16 + src/hooks/use-people.ts | 5 +- src/styles/theme.ts | 72 +- 62 files changed, 3627 insertions(+), 1722 deletions(-) create mode 100644 src/components/layout/mobile-top-bar.tsx create mode 100644 src/components/layout/profile-menu.tsx create mode 100644 src/components/shared/filter-drawer.tsx create mode 100644 src/components/shared/mobile-dialog.tsx create mode 100644 src/components/shared/responsive-table.tsx create mode 100644 src/hooks/use-is-mobile.ts diff --git a/package.json b/package.json index 965c39b..37e40e8 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@fullcalendar/core": "^6.1.19", "@fullcalendar/daygrid": "^6.1.19", "@fullcalendar/interaction": "^6.1.19", + "@fullcalendar/list": "^6.1.20", "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", "@hookform/resolvers": "^5.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d62dec..c9df68a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@fullcalendar/interaction': specifier: ^6.1.19 version: 6.1.19(@fullcalendar/core@6.1.19) + '@fullcalendar/list': + specifier: ^6.1.20 + version: 6.1.20(@fullcalendar/core@6.1.19) '@fullcalendar/react': specifier: ^6.1.19 version: 6.1.19(@fullcalendar/core@6.1.19)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -678,6 +681,11 @@ packages: peerDependencies: '@fullcalendar/core': ~6.1.19 + '@fullcalendar/list@6.1.20': + resolution: {integrity: sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==} + peerDependencies: + '@fullcalendar/core': ~6.1.20 + '@fullcalendar/react@6.1.19': resolution: {integrity: sha512-FP78vnyylaL/btZeHig8LQgfHgfwxLaIG6sKbNkzkPkKEACv11UyyBoTSkaavPsHtXvAkcTED1l7TOunAyPEnA==} peerDependencies: @@ -3930,6 +3938,10 @@ snapshots: dependencies: '@fullcalendar/core': 6.1.19 + '@fullcalendar/list@6.1.20(@fullcalendar/core@6.1.19)': + dependencies: + '@fullcalendar/core': 6.1.19 + '@fullcalendar/react@6.1.19(@fullcalendar/core@6.1.19)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@fullcalendar/core': 6.1.19 diff --git a/src/app/auth/login/page.module.css b/src/app/auth/login/page.module.css index ac99119..2f527a3 100644 --- a/src/app/auth/login/page.module.css +++ b/src/app/auth/login/page.module.css @@ -1,6 +1,7 @@ /*login page layout */ .page { min-height: 100vh; + min-height: 100dvh; display: flex; background-color: #ffffff; font-family: @@ -9,6 +10,8 @@ BlinkMacSystemFont, "Segoe UI", sans-serif; + padding-top: env(safe-area-inset-top, 0px); + padding-bottom: env(safe-area-inset-bottom, 0px); } /* left side 60% */ @@ -64,7 +67,8 @@ .input { width: 100%; padding: 1.1rem 1.5rem; - font-size: 1rem; + /* 16px minimum prevents iOS Safari from auto-zooming on focus. */ + font-size: max(1rem, 16px); border-radius: 999px; /* pill shape */ border: 1px solid #d4d4d8; outline: none; @@ -157,7 +161,7 @@ @media (max-width: 640px) { .hero { - min-height: 220px; + display: none; } .main { @@ -166,5 +170,21 @@ .heading { font-size: 2rem; + margin-bottom: 1.75rem; + } +} + +@media (max-width: 400px) { + .hero { + min-height: 160px; + } + + .main { + padding: 1.5rem 1.25rem 2.5rem; + } + + .heading { + font-size: 1.75rem; + margin-bottom: 1.5rem; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b584064..3a41ad1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,7 @@ import { CssBaseline } from "@mui/material"; import { ThemeProvider } from "@mui/material/styles"; import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter"; -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Roboto } from "next/font/google"; import { ReactNode } from "react"; @@ -17,8 +17,14 @@ const roboto = Roboto({ }); export const metadata: Metadata = { - title: "Insert project title here", - description: "Insert project description here", + title: "Persevere", + description: "Volunteer management for the Persevere community.", +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", }; type RootLayoutProps = { diff --git a/src/app/staff/dashboard/page.tsx b/src/app/staff/dashboard/page.tsx index 9a0d438..6ca7d66 100644 --- a/src/app/staff/dashboard/page.tsx +++ b/src/app/staff/dashboard/page.tsx @@ -391,7 +391,7 @@ export default function StaffDashboardPage(): JSX.Element { key={opp.id} sx={{ display: "grid", - gridTemplateColumns: "1.5fr 1fr", + gridTemplateColumns: { xs: "1fr", md: "1.5fr 1fr" }, alignItems: "center", gap: 1.5, py: 1.5, @@ -462,9 +462,13 @@ export default function StaffDashboardPage(): JSX.Element { Review diff --git a/src/app/staff/opportunities/page.tsx b/src/app/staff/opportunities/page.tsx index 007dec4..5aa1009 100644 --- a/src/app/staff/opportunities/page.tsx +++ b/src/app/staff/opportunities/page.tsx @@ -11,6 +11,7 @@ import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; import InputAdornment from "@mui/material/InputAdornment"; import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; import Tab from "@mui/material/Tab"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; @@ -24,7 +25,7 @@ import Typography from "@mui/material/Typography"; import { enqueueSnackbar } from "notistack"; import { JSX, useCallback, useEffect, useMemo, useState } from "react"; -import { PageHeader } from "@/components/shared"; +import { FilterDrawer, PageHeader, ResponsiveTable } from "@/components/shared"; import EventDetailModal from "@/components/staff/calendar/event-detail-modal"; import EventFormModal from "@/components/staff/calendar/event-form-modal"; import { EmptyState } from "@/components/ui"; @@ -106,6 +107,14 @@ export default function StaffOpportunitiesPage(): JSX.Element { }); }, [events, activeTab, now, search, categoryId, locationFilter]); + const activeFilterCount = + (categoryId === "" ? 0 : 1) + (locationFilter === "" ? 0 : 1); + + const clearFilters = (): void => { + setCategoryId(""); + setLocationFilter(""); + }; + const selectedEvent = useMemo( () => selectedEventId @@ -188,42 +197,47 @@ export default function StaffOpportunitiesPage(): JSX.Element { }, }} /> - { - setCategoryId( - e.target.value === "" ? "" : Number(e.target.value), - ); - }} - sx={{ minWidth: 180 }} - slotProps={{ select: { displayEmpty: true } }} + - All categories - {activeCategories.map((c) => ( - - {c.name} - - ))} - - { - setLocationFilter(e.target.value); - }} - sx={{ minWidth: 160 }} - slotProps={{ select: { displayEmpty: true } }} - > - All locations - {locations.map((loc) => ( - - {loc} - - ))} - + { + setCategoryId( + e.target.value === "" ? "" : Number(e.target.value), + ); + }} + sx={{ minWidth: 180 }} + > + All categories + {activeCategories.map((c) => ( + + {c.name} + + ))} + + { + setLocationFilter(e.target.value); + }} + sx={{ minWidth: 160 }} + > + All locations + {locations.map((loc) => ( + + {loc} + + ))} + + {!loading && ( @@ -268,28 +282,168 @@ export default function StaffOpportunitiesPage(): JSX.Element { overflow: "hidden", }} > - - - - - - Title - - - Category - - - Date - - - RSVPs - - - - + +
+ + + + Title + + + Category + + + Date + + + RSVPs + + + + + {filtered.map((event) => { + const { maxVolunteers, rsvpCount, categoryName } = + event.extendedProps ?? {}; + const filled = rsvpCount ?? 0; + const pct = maxVolunteers ? filled / maxVolunteers : 0; + const isNearFull = pct > 0.9; + + return ( + { + setSelectedEventId(event.id); + }} + sx={{ cursor: "pointer" }} + > + + + {event.title} + + {event.location && ( + + + + {event.location} + + + )} + + + {categoryName ? ( + + ) : ( + + — + + )} + + + + {formatDate(event.start)} + + + + {maxVolunteers == null ? ( + + No limit + + ) : ( + + + {filled} / {maxVolunteers} + + + + + + )} + + + ); + })} + +
+
+ } + mobile={ + {filtered.map((event) => { const { maxVolunteers, rsvpCount, categoryName } = event.extendedProps ?? {}; @@ -298,15 +452,18 @@ export default function StaffOpportunitiesPage(): JSX.Element { const isNearFull = pct > 0.9; return ( - { setSelectedEventId(event.id); }} - sx={{ cursor: "pointer" }} + sx={{ + cursor: "pointer", + "&:hover": { bgcolor: "action.hover" }, + }} > - + )} - - - {categoryName ? ( - - ) : ( - - — - - )} - - - - {formatDate(event.start)} - - - - {maxVolunteers == null ? ( - - No limit - - ) : ( - + + {formatDate(event.start)} + + {categoryName && ( + + )} + {maxVolunteers == null ? ( - {filled} / {maxVolunteers} + No limit - - - - - )} - - + ) : ( + + )} + + + ); })} - - - + + } + /> )} diff --git a/src/app/staff/profile/page.tsx b/src/app/staff/profile/page.tsx index ee6df92..680cfa9 100644 --- a/src/app/staff/profile/page.tsx +++ b/src/app/staff/profile/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Box, Typography } from "@mui/material"; +import { Box, Tab, Tabs, Typography } from "@mui/material"; import { useRouter, useSearchParams } from "next/navigation"; import { useSession } from "next-auth/react"; import { type JSX } from "react"; @@ -55,11 +55,19 @@ export default function StaffProfilePage(): JSX.Element { }; return ( - - {/* ── Left nav ──────────────────────────────────────────── */} + + {/* ── Left nav (desktop) ────────────────────────────────── */} + {/* ── Mobile tabs ───────────────────────────────────────── */} + + handleNavClick(v)} + variant="scrollable" + scrollButtons="auto" + > + {ACCOUNT_ITEMS.map((item) => ( + + ))} + {isAdmin && + ADMIN_NAV_ITEMS.map((item) => ( + + ))} + + + {/* ── Content ───────────────────────────────────────────── */} - + {activeTab === "settings" ? ( ) : activeTab === "skills" && isAdmin ? ( diff --git a/src/app/staff/settings/email-templates/email-templates-settings-client.tsx b/src/app/staff/settings/email-templates/email-templates-settings-client.tsx index fc01b98..85e7987 100644 --- a/src/app/staff/settings/email-templates/email-templates-settings-client.tsx +++ b/src/app/staff/settings/email-templates/email-templates-settings-client.tsx @@ -6,9 +6,9 @@ import EditIcon from "@mui/icons-material/Edit"; import { Box, Button, + Card, Chip, CircularProgress, - Dialog, DialogActions, DialogContent, IconButton, @@ -27,7 +27,12 @@ import { import { useSnackbar } from "notistack"; import { JSX, useCallback, useEffect, useState } from "react"; -import { ConfirmDialog, ModalTitleBar } from "@/components/shared"; +import { + ConfirmDialog, + MobileDialog, + ModalTitleBar, + ResponsiveTable, +} from "@/components/shared"; import RichTextEditor from "@/components/staff/communications/rich-text-editor"; import { EmptyState } from "@/components/ui"; import { @@ -206,97 +211,175 @@ export default function EmailTemplatesSettingsClient(): JSX.Element { position: "relative", }} > - - - - - - Name - - - Subject - - - Actions - - - - - {loading && ( - - + +
+ + + + Name + + + Subject + + + Actions + + + + + {loading && ( + + + + + + + + )} + {templates.length === 0 ? ( + loading ? null : ( + + + + Add the first template + + } + /> + + + ) + ) : ( + templates.map((template, index) => ( + + + {template.name} + + + + {template.subject} + + + + + + openEdit(template)} + > + + + + + setDeleteConfirm(template)} + > + + + + + + + )) + )} + +
+
+ } + mobile={ + + {loading ? ( + + + + ) : templates.length === 0 ? ( + - + Add the first template
- - - )} - {templates.length === 0 ? ( - loading ? null : ( - - - - Add the first template -
- } - /> - - - ) + } + /> ) : ( - templates.map((template, index) => ( - - - {template.name} - - - + templates.map((template) => ( + + + + {template.name} + + {template.subject} - - - - - - +
+
+ )) )} - - - + + } + /> {/* Add/Edit Dialog */} - + - + {/* Delete Confirmation */} - - - - - - Name - - - Status - - - Actions - - - - - {loading && ( - - + +
+ + + + Name + + + Status + + + Actions + + + + + {loading && ( + + + + + + + + )} + {allCategories.length === 0 ? ( + loading ? null : ( + + + + Add the first category + + } + /> + + + ) + ) : ( + allCategories.map((category, index) => ( + + + {category.name} + + + + + + + + openEdit(category)} + > + + + + {category.isActive ? ( + + setDeactivateConfirm(category)} + > + + + + ) : ( + + + void handleReactivate(category) + } + > + + + + )} + + + + )) + )} + +
+
+ } + mobile={ + + {loading ? ( + + + + ) : allCategories.length === 0 ? ( + - + Add the first category
- - - )} - {allCategories.length === 0 ? ( - loading ? null : ( - - - - Add the first category -
- } - /> - - - ) + } + /> ) : ( - allCategories.map((category, index) => ( - ( + - - {category.name} - - + + + {category.name} + - - - - + + openEdit(category)} + > + + + + {category.isActive ? ( + openEdit(category)} + color="error" + onClick={() => setDeactivateConfirm(category)} > - + - {category.isActive ? ( - - setDeactivateConfirm(category)} - > - - - - ) : ( - - void handleReactivate(category)} - > - - - - )} - - - + ) : ( + + void handleReactivate(category)} + > + + + + )} + + )) )} - - - + + } + /> {/* Add / Edit Dialog */} - setDialogOpen(false)} maxWidth="xs" @@ -362,7 +461,7 @@ export default function EventCategoriesSettingsClient(): JSX.Element { {saving ? "Saving..." : "Save"} - + - - - - - - Name - - - Description - - - Category - - - Actions - - - - - {skillsLoading && ( - - - +
+ + + - - - - - )} - {skills.length === 0 ? ( - skillsLoading ? null : ( - - - - Add the first skill - - } - /> + Name - - ) - ) : ( - skills.map((skill, index) => ( - - - {skill.name} + + Description + + + Category - {skill.description ?? "—"} + Actions - - {skill.category ? ( - - ) : ( + + + + {skillsLoading && ( + + + + + + + + )} + {skills.length === 0 ? ( + skillsLoading ? null : ( + + + + Add the first skill + + } + /> + + + ) + ) : ( + skills.map((skill, index) => ( + + + {skill.name} + + + {skill.description ?? "—"} + + + {skill.category ? ( + + ) : ( + + — + + )} + + + + + openEditSkill(skill)} + > + + + + + setDeleteSkillConfirm(skill)} + > + + + + + + + )) + )} + +
+
+ } + mobile={ + + {skillsLoading ? ( + + + + ) : skills.length === 0 ? ( + + Add the first skill + + } + /> + ) : ( + skills.map((skill) => ( + + + - — + {skill.name} - )} - - - + {skill.category && ( + + )} - - - + + {skill.description && ( + + {skill.description} + + )} + + )) )} - - - + + } + /> @@ -489,99 +608,194 @@ export default function SkillsSettingsClient(): JSX.Element { position: "relative", }} > - - - - - - Name - - - Description - - - Actions - - - - - {interestsLoading && ( - - - +
+ + + - - - - - )} - {interests.length === 0 ? ( - interestsLoading ? null : ( - - - - Add the first interest - - } - /> + Name - - ) - ) : ( - interests.map((interest, index) => ( - - - {interest.name} + + Description - {interest.description ?? "—"} + Actions - - + + + {interestsLoading && ( + + + + + + + + )} + {interests.length === 0 ? ( + interestsLoading ? null : ( + + + + Add the first interest + + } + /> + + + ) + ) : ( + interests.map((interest, index) => ( + + + {interest.name} + + + {interest.description ?? "—"} + + + + + openEditInterest(interest)} + > + + + + + + setDeleteInterestConfirm(interest) + } + > + + + + + + + )) + )} + +
+
+ } + mobile={ + + {interestsLoading ? ( + + + + ) : interests.length === 0 ? ( + + Add the first interest + + } + /> + ) : ( + interests.map((interest) => ( + + + + + {interest.name} + - - - + + {interest.description && ( + + {interest.description} + + )} + + )) )} - - - + + } + /> {/* Skill Add/Edit Dialog */} - setSkillDialogOpen(false)} maxWidth="sm" @@ -670,9 +893,9 @@ export default function SkillsSettingsClient(): JSX.Element { {skillSaving ? "Saving..." : "Save"} - + {/* Interest Add/Edit Dialog */} - setInterestDialogOpen(false)} maxWidth="sm" @@ -721,7 +944,7 @@ export default function SkillsSettingsClient(): JSX.Element { {interestSaving ? "Saving..." : "Save"} - + - - - - - - Name - - - Status - - - Actions - - - - - {loading && ( - - + +
+ + + + Name + + + Status + + + Actions + + + + + {loading && ( + + + + + + + + )} + {allTypes.length === 0 ? ( + loading ? null : ( + + + + Add the first type + + } + /> + + + ) + ) : ( + allTypes.map((type, index) => ( + + + {type.name} + + + + + + + + openEdit(type)} + > + + + + {type.isActive ? ( + + setDeactivateConfirm(type)} + > + + + + ) : ( + + void handleReactivate(type)} + > + + + + )} + + + + )) + )} + +
+
+ } + mobile={ + + {loading ? ( + + + + ) : allTypes.length === 0 ? ( + - + Add the first type - - - )} - {allTypes.length === 0 ? ( - loading ? null : ( - - - - Add the first type - - } - /> - - - ) + } + /> ) : ( - allTypes.map((type, index) => ( - ( + - {type.name} - + + + {type.name} + - - - - + + openEdit(type)}> + + + + {type.isActive ? ( + openEdit(type)} + color="error" + onClick={() => setDeactivateConfirm(type)} > - + - {type.isActive ? ( - - setDeactivateConfirm(type)} - > - - - - ) : ( - - void handleReactivate(type)} - > - - - - )} - - - + ) : ( + + void handleReactivate(type)} + > + + + + )} + + )) )} - - - + + } + /> {/* Add / Edit Dialog */} - setDialogOpen(false)} maxWidth="xs" @@ -356,7 +452,7 @@ export default function VolunteerTypesSettingsClient(): JSX.Element { {saving ? "Saving..." : "Save"} - + (null); + const isMobile = useIsMobile(); const { rsvpedIds, @@ -111,6 +113,7 @@ export default function VolunteerCalendarPage(): JSX.Element { { setSelectedOpportunityId(Number.parseInt(id, 10)); diff --git a/src/app/volunteer/dashboard/page.tsx b/src/app/volunteer/dashboard/page.tsx index e6c46eb..5ea9446 100644 --- a/src/app/volunteer/dashboard/page.tsx +++ b/src/app/volunteer/dashboard/page.tsx @@ -4,16 +4,17 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Grid from "@mui/material/Grid"; import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; import Link from "next/link"; import { useSession } from "next-auth/react"; import { JSX, useCallback, useState } from "react"; +import { PageHeader } from "@/components/shared"; import AnnouncementsCard from "@/components/volunteer/communications-card"; import DashboardRecommendations from "@/components/volunteer/dashboard-recommendations"; import MyRsvps from "@/components/volunteer/my-rsvps"; import RecentHours from "@/components/volunteer/recent-hours"; import VolunteerStats from "@/components/volunteer/volunteer-stats"; +import { useIsMobile } from "@/hooks/use-is-mobile"; /** Volunteer dashboard with portal overview. */ export default function VolunteerDashboardPage(): JSX.Element { @@ -21,6 +22,7 @@ export default function VolunteerDashboardPage(): JSX.Element { const firstName = session?.user?.name?.split(" ")[0] || "Volunteer"; const [rsvpVersion, setRsvpVersion] = useState(0); const handleRsvpChange = useCallback(() => setRsvpVersion((v) => v + 1), []); + const isMobile = useIsMobile(); return ( - {/* Page Header Area */} - - - Volunteer Portal - - - - - Welcome back, {firstName} - - - Check out your upcoming sessions and new opportunities. - - - + @@ -84,33 +54,39 @@ export default function VolunteerDashboardPage(): JSX.Element { > Browse Opportunities - - - + + } + /> - {/* Row 1 — Stats */} - {/* Row 2 — Main Grid */} - - {/* Left Column: Recommendations & Upcoming */} - - + {isMobile ? ( + // Mobile: single-column stack ordered by actionability — + // upcoming RSVPs first so volunteers see what's next at a glance. + - - - {/* Right Column: Announcements & Hours */} - + + + ) : ( + + + + + + + + + - + )} ); } diff --git a/src/app/volunteer/hours/page.tsx b/src/app/volunteer/hours/page.tsx index 280dc53..bafce66 100644 --- a/src/app/volunteer/hours/page.tsx +++ b/src/app/volunteer/hours/page.tsx @@ -192,10 +192,13 @@ export default function HoursPage(): JSX.Element { ) : ( @@ -205,7 +208,6 @@ export default function HoursPage(): JSX.Element { {"JFMAMJJASOND"[i]}
diff --git a/src/app/volunteer/opportunities/page.tsx b/src/app/volunteer/opportunities/page.tsx index 98a0bf4..8339f6d 100644 --- a/src/app/volunteer/opportunities/page.tsx +++ b/src/app/volunteer/opportunities/page.tsx @@ -14,7 +14,7 @@ import Typography from "@mui/material/Typography"; import NextLink from "next/link"; import { JSX, useState } from "react"; -import { PageHeader } from "@/components/shared"; +import { FilterDrawer, PageHeader } from "@/components/shared"; import { EmptyState } from "@/components/ui"; import { OpportunityCard } from "@/components/volunteer/opportunity-card"; import OpportunityDetailModal from "@/components/volunteer/opportunity-detail-modal"; @@ -47,6 +47,17 @@ export default function OpportunitiesPage(): JSX.Element { const { categories } = useOpportunityCategories(); const { locations } = useOpportunityLocations(); + const activeFilterCount = + (categoryId === "" ? 0 : 1) + + (locationFilter === "" ? 0 : 1) + + (dateRange === "" ? 0 : 1); + + const clearFilters = (): void => { + setCategoryId(""); + setLocationFilter(""); + setDateRange(""); + }; + return ( - + - { - setCategoryId( - e.target.value === "" ? "" : Number(e.target.value), - ); - }} - sx={{ minWidth: 180 }} - slotProps={{ select: { displayEmpty: true } }} - > - All categories - {categories.map((c) => ( - - {c.name} - - ))} - - { - setLocationFilter(e.target.value); - }} - sx={{ minWidth: 160 }} - slotProps={{ select: { displayEmpty: true } }} - > - Any location - {locations.map((loc) => ( - - {loc} - - ))} - - { - setDateRange(e.target.value as "week" | "month" | ""); - }} - sx={{ minWidth: 150 }} - slotProps={{ select: { displayEmpty: true } }} + - Any date - This week - This month - + { + setCategoryId( + e.target.value === "" ? "" : Number(e.target.value), + ); + }} + label="Category" + sx={{ minWidth: 180 }} + > + All categories + {categories.map((c) => ( + + {c.name} + + ))} + + { + setLocationFilter(e.target.value); + }} + label="Location" + sx={{ minWidth: 160 }} + > + Any location + {locations.map((loc) => ( + + {loc} + + ))} + + { + setDateRange(e.target.value as "week" | "month" | ""); + }} + label="Date" + sx={{ minWidth: 150 }} + > + Any date + This week + This month + + {loading ? ( @@ -200,7 +224,10 @@ export default function OpportunitiesPage(): JSX.Element { diff --git a/src/app/volunteer/profile/page.tsx b/src/app/volunteer/profile/page.tsx index a9b86a5..17c001b 100644 --- a/src/app/volunteer/profile/page.tsx +++ b/src/app/volunteer/profile/page.tsx @@ -19,6 +19,8 @@ import { Skeleton, Stack, type SxProps, + Tab, + Tabs, Typography, } from "@mui/material"; import { useRouter, useSearchParams } from "next/navigation"; @@ -33,6 +35,7 @@ import type { } from "@/components/volunteer/availability-editor"; import ProfileEditForm from "@/components/volunteer/profile-edit-form"; import VolunteerAccountSettings from "@/components/volunteer/volunteer-account-settings"; +import { useIsMobile } from "@/hooks/use-is-mobile"; import { useOnboardingDocuments } from "@/hooks/use-onboarding-documents"; import { useVolunteerProfile } from "@/hooks/use-volunteer-profile"; @@ -124,6 +127,7 @@ function AvailabilityGrid({ }: { availability: AvailabilityData | null | undefined; }): JSX.Element { + const isMobile = useIsMobile(); const DAYS: Day[] = [ "monday", "tuesday", @@ -147,6 +151,84 @@ function AvailabilityGrid({ ); } + if (isMobile) { + return ( + + {DAYS.map((day, i) => { + const ranges = availability?.[day] ?? []; + return ( + + + {SHORT[i]} + + + {ranges.map((r, ri) => { + const sh = parseHMM(r.start); + const eh = parseHMM(r.end); + const left = + Math.max(0, (sh - DISPLAY_START) / DISPLAY_SPAN) * 100; + const width = Math.min( + 100 - left, + ((eh - sh) / DISPLAY_SPAN) * 100, + ); + return ( + + ); + })} + + + ); + })} + + + 6 AM + + + 12 PM + + + 12 AM + + + + ); + } + return ( @@ -504,11 +586,19 @@ export default function VolunteerProfilePage(): JSX.Element { : "Volunteer"; return ( - - {/* ── Left nav ──────────────────────────────────────────── */} + + {/* ── Side nav (desktop only) ─────────────────────────── */} + {/* ── Mobile-only top tabs ──────────────────────────────── */} + + + + + + + {/* ── Content ───────────────────────────────────────────── */} - + {activeTab === "settings" ? ( void; }; export default function BaseSidebar({ navItems, + mobileOpen = false, + onMobileClose, }: BaseSidebarProps): ReactElement { - const pathname = usePathname(); - const router = useRouter(); - const { data: session } = useSession(); + const isMobile = useIsMobile(); + + if (isMobile) { + return ( + + + + ); + } + + return ; +} +function DesktopSidebar({ navItems }: { navItems: NavItem[] }): ReactElement { const [collapsed, setCollapsed] = useState(false); - const [anchorEl, setAnchorEl] = useState(null); + return ( + + ); +} - const getDashboardRoute = (): string => { - if (pathname?.startsWith("/staff")) return "/staff/dashboard"; - if (pathname?.startsWith("/volunteer")) return "/volunteer/dashboard"; - return "/home"; - }; +type SidebarContentsProps = { + navItems: NavItem[]; + collapsed: boolean; + onToggleCollapsed?: () => void; + onNavClick?: () => void; +}; - const handleSignOut = useSignOut(); - const popoverOpen = Boolean(anchorEl); +function SidebarContents({ + navItems, + collapsed, + onToggleCollapsed, + onNavClick, +}: SidebarContentsProps): JSX.Element { + const pathname = usePathname(); + const { data: session } = useSession(); + const [anchorEl, setAnchorEl] = useState(null); - const openPopover = (e: React.MouseEvent): void => { - setAnchorEl(e.currentTarget); - }; - const closePopover = (): void => setAnchorEl(null); + const dashboardHref = pathname?.startsWith("/staff") + ? "/staff/dashboard" + : pathname?.startsWith("/volunteer") + ? "/volunteer/dashboard" + : "/home"; - const avatarEl = session?.user?.image ? ( + const avatar = session?.user?.image ? ( - {/* Header row: logo + collapse toggle */} + <> + {/* Header row: logo + collapse toggle (collapse only on desktop) */}
{!collapsed && ( )} - - setCollapsed((c) => !c)} - aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} - sx={{ - color: "rgba(255,255,255,0.75)", - "&:hover": { - color: "#fff", - bgcolor: "rgba(255,255,255,0.12)", - }, - ...(collapsed ? {} : { position: "absolute", right: "0.75rem" }), - }} + {onToggleCollapsed && ( + - {collapsed ? ( - - ) : ( - - )} - - + + {collapsed ? ( + + ) : ( + + )} + + + )}
{/* Navigation */} @@ -139,6 +196,7 @@ export default function BaseSidebar({ href={item.href} className={styles.link} aria-current={isActive ? "page" : undefined} + onClick={onNavClick} >
- {/* span needed so Tooltip can attach ref to a DOM element */} {itemEl} ) : ( @@ -167,8 +224,7 @@ export default function BaseSidebar({ })} - {/* Spacer */} -
+ {/* Profile zone */}
@@ -177,13 +233,13 @@ export default function BaseSidebar({ placement="right" > setAnchorEl(e.currentTarget)} className={`${styles.profileZone} ${collapsed ? styles.profileZoneCollapsed : ""}`} aria-label="Open profile menu" aria-haspopup="true" - aria-expanded={popoverOpen} + aria-expanded={Boolean(anchorEl)} > - {avatarEl} + {avatar} {!collapsed && session?.user?.name && (
- {/* Profile popover */} - - {session?.user?.email && ( - <> - - {session.user.email} - - - - )} - {session?.user?.role === "volunteer" ? ( - <> - { - closePopover(); - router.push("/volunteer/profile"); - }} - sx={{ py: 1.25, gap: 1.5 }} - > - - - - Profile - - { - closePopover(); - router.push("/volunteer/profile?tab=settings"); - }} - sx={{ py: 1.25, gap: 1.5 }} - > - - - - Settings - - - ) : ( - <> - { - closePopover(); - router.push("/staff/profile"); - }} - sx={{ py: 1.25, gap: 1.5 }} - > - - - - Profile - - { - closePopover(); - router.push("/staff/profile?tab=settings"); - }} - sx={{ py: 1.25, gap: 1.5 }} - > - - - - Settings - - - )} - { - closePopover(); - handleSignOut(); - }} - sx={{ py: 1.25, gap: 1.5, color: "error.main" }} - > - - - - Sign Out - - - + open={Boolean(anchorEl)} + onClose={() => setAnchorEl(null)} + /> + ); } diff --git a/src/components/layout/mobile-top-bar.tsx b/src/components/layout/mobile-top-bar.tsx new file mode 100644 index 0000000..fa2cb17 --- /dev/null +++ b/src/components/layout/mobile-top-bar.tsx @@ -0,0 +1,119 @@ +"use client"; +import MenuIcon from "@mui/icons-material/Menu"; +import PersonIcon from "@mui/icons-material/Person"; +import { Avatar, Box, ButtonBase, IconButton } from "@mui/material"; +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { type JSX, useState } from "react"; + +import ProfileMenu from "./profile-menu"; + +type MobileTopBarProps = { + onOpenNav: () => void; +}; + +/** + * MobileTopBar + * + * Sticky app bar shown only on viewports below the md breakpoint. + * Houses the hamburger (opens the sidebar drawer), the Persevere logo + * (links back to the active portal's dashboard), and the profile avatar + * (opens the shared ProfileMenu popover). + */ +export default function MobileTopBar({ + onOpenNav, +}: MobileTopBarProps): JSX.Element { + const { data: session } = useSession(); + const pathname = usePathname(); + const [anchorEl, setAnchorEl] = useState(null); + + const dashboardHref = pathname?.startsWith("/staff") + ? "/staff/dashboard" + : pathname?.startsWith("/volunteer") + ? "/volunteer/dashboard" + : "/home"; + + return ( + t.zIndex.appBar, + bgcolor: "primary.main", + color: "primary.contrastText", + pt: "env(safe-area-inset-top, 0px)", + px: 1, + alignItems: "center", + justifyContent: "space-between", + flexShrink: 0, + }} + > + + + + + + Persevere + + + setAnchorEl(e.currentTarget)} + aria-label="Open profile menu" + aria-haspopup="true" + aria-expanded={Boolean(anchorEl)} + sx={{ + minWidth: 44, + minHeight: 44, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "50%", + "&:hover": { bgcolor: "rgba(255,255,255,0.12)" }, + }} + > + {session?.user?.image ? ( + + ) : ( + + + + )} + + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "right" }} + /> + + ); +} diff --git a/src/components/layout/profile-menu.tsx b/src/components/layout/profile-menu.tsx new file mode 100644 index 0000000..dc6c6ec --- /dev/null +++ b/src/components/layout/profile-menu.tsx @@ -0,0 +1,113 @@ +"use client"; +import LogoutIcon from "@mui/icons-material/Logout"; +import PersonIcon from "@mui/icons-material/Person"; +import SettingsIcon from "@mui/icons-material/Settings"; +import { + Divider, + ListItemIcon, + MenuItem, + Popover, + Typography, +} from "@mui/material"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import type { JSX } from "react"; + +import { useSignOut } from "@/hooks/use-auth"; + +type ProfileMenuProps = { + anchorEl: HTMLElement | null; + open: boolean; + onClose: () => void; + /** + * Anchor origin for the popover. Defaults match the sidebar footer + * placement (popover opens upward from a bottom-anchored trigger). + */ + anchorOrigin?: React.ComponentProps["anchorOrigin"]; + transformOrigin?: React.ComponentProps["transformOrigin"]; +}; + +/** + * ProfileMenu + * + * Shared identity popover used by both the desktop sidebar footer and the + * mobile top-bar profile avatar. Reads the active session and routes to the + * correct portal's profile/settings pages. + */ +export default function ProfileMenu({ + anchorEl, + open, + onClose, + anchorOrigin = { vertical: "top", horizontal: "center" }, + transformOrigin = { vertical: "bottom", horizontal: "center" }, +}: ProfileMenuProps): JSX.Element { + const router = useRouter(); + const { data: session } = useSession(); + const handleSignOut = useSignOut(); + + const isVolunteer = session?.user?.role === "volunteer"; + const profileBase = isVolunteer ? "/volunteer/profile" : "/staff/profile"; + + const go = (href: string): void => { + onClose(); + router.push(href); + }; + + return ( + + {session?.user?.email && ( + <> + + {session.user.email} + + + + )} + go(profileBase)} sx={{ py: 1.25, gap: 1.5 }}> + + + + Profile + + go(`${profileBase}?tab=settings`)} + sx={{ py: 1.25, gap: 1.5 }} + > + + + + Settings + + { + onClose(); + handleSignOut(); + }} + sx={{ py: 1.25, gap: 1.5, color: "error.main" }} + > + + + + Sign Out + + + ); +} diff --git a/src/components/layout/role-layout.tsx b/src/components/layout/role-layout.tsx index e42fac1..45fe6e3 100644 --- a/src/components/layout/role-layout.tsx +++ b/src/components/layout/role-layout.tsx @@ -1,35 +1,63 @@ "use client"; import { Box } from "@mui/material"; -import { ReactNode } from "react"; +import { + cloneElement, + isValidElement, + type JSX, + type ReactElement, + type ReactNode, + useState, +} from "react"; + +import MobileTopBar from "./mobile-top-bar"; type RoleLayoutProps = { - sidebar: ReactNode; + /** + * The portal sidebar (StaffSidebar or VolunteerSidebar). On mobile, + * RoleLayout injects `mobileOpen` and `onMobileClose` so the sidebar + * renders inside a temporary Drawer. + */ + sidebar: ReactElement<{ + mobileOpen?: boolean; + onMobileClose?: () => void; + }>; children: ReactNode; }; /** * RoleLayout * - * Shared layout wrapper combining a sidebar and main content area. Used by all - * role-specific layouts (staff, admin, volunteer). The sidebar handles its own - * session/profile state; this wrapper is a pure structural shell. - * - * Note: overflow is "hidden" so each page controls its own scrolling behavior. + * Shared shell combining a sidebar and main content area. Used by the staff, + * admin, and volunteer route trees. On viewports below the md breakpoint the + * sidebar collapses into a drawer opened by the MobileTopBar's hamburger; on + * larger viewports the sidebar remains permanent on the left. */ export default function RoleLayout({ sidebar, children, -}: RoleLayoutProps): ReactNode { +}: RoleLayoutProps): JSX.Element { + const [mobileOpen, setMobileOpen] = useState(false); + + const sidebarWithDrawerProps = isValidElement(sidebar) + ? cloneElement(sidebar, { + mobileOpen, + onMobileClose: () => setMobileOpen(false), + }) + : sidebar; + return ( -
- {sidebar} + setMobileOpen(true)} /> + + {sidebarWithDrawerProps} {children} -
+
); } diff --git a/src/components/layout/staff-sidebar.tsx b/src/components/layout/staff-sidebar.tsx index 5508637..09fe08b 100644 --- a/src/components/layout/staff-sidebar.tsx +++ b/src/components/layout/staff-sidebar.tsx @@ -150,7 +150,15 @@ const ApprovalsIcon = (): JSX.Element => ( ); -export default function StaffSidebar(): JSX.Element { +type StaffSidebarProps = { + mobileOpen?: boolean; + onMobileClose?: () => void; +}; + +export default function StaffSidebar({ + mobileOpen, + onMobileClose, +}: StaffSidebarProps = {}): JSX.Element { const { data: session } = useSession(); const user = session?.user as { role?: string } | undefined; @@ -193,5 +201,11 @@ export default function StaffSidebar(): JSX.Element { ]; }, [isAdmin]); - return ; + return ( + + ); } diff --git a/src/components/layout/volunteer-sidebar.tsx b/src/components/layout/volunteer-sidebar.tsx index 0963a8b..689a85c 100644 --- a/src/components/layout/volunteer-sidebar.tsx +++ b/src/components/layout/volunteer-sidebar.tsx @@ -143,12 +143,26 @@ const volunteerNavItems: NavItem[] = [ }, ]; +type VolunteerSidebarProps = { + mobileOpen?: boolean; + onMobileClose?: () => void; +}; + /** * VolunteerSidebar * * Sidebar navigation for volunteer portal routes. Profile is accessible * via the header profile icon. */ -export default function VolunteerSidebar(): JSX.Element { - return ; +export default function VolunteerSidebar({ + mobileOpen, + onMobileClose, +}: VolunteerSidebarProps = {}): JSX.Element { + return ( + + ); } diff --git a/src/components/shared/filter-drawer.tsx b/src/components/shared/filter-drawer.tsx new file mode 100644 index 0000000..1b6997a --- /dev/null +++ b/src/components/shared/filter-drawer.tsx @@ -0,0 +1,156 @@ +"use client"; +import CloseIcon from "@mui/icons-material/Close"; +import TuneIcon from "@mui/icons-material/Tune"; +import { + Badge, + Box, + Button, + Drawer, + IconButton, + Stack, + Typography, +} from "@mui/material"; +import { type JSX, type ReactNode, useState } from "react"; + +import { useIsMobile } from "@/hooks/use-is-mobile"; + +type FilterDrawerProps = { + /** + * Filter controls. Rendered inline (in a flex row) on desktop, stacked + * vertically inside a bottom drawer on mobile. + */ + children: ReactNode; + /** Active filter count — drives the badge on the mobile trigger button. */ + activeFilterCount?: number; + /** Optional clear-all action shown in the drawer footer. */ + onClear?: () => void; + /** Title for the drawer header (default "Filters"). */ + title?: string; +}; + +/** + * FilterDrawer + * + * Wraps a set of secondary filter controls (selects, date range pickers, etc). + * On viewports ≥ md the children render inline, matching the existing inline + * filter-bar pattern. On smaller viewports the children collapse behind a + * single `[Filters (N)]` button that opens a bottom drawer containing the same + * controls in a vertical stack. + * + * Search inputs should stay outside this wrapper — they belong inline at all + * sizes since their value is the user's primary feedback signal while typing. + */ +export function FilterDrawer({ + children, + activeFilterCount = 0, + onClear, + title = "Filters", +}: FilterDrawerProps): JSX.Element { + const isMobile = useIsMobile(); + const [open, setOpen] = useState(false); + + if (!isMobile) { + return ( + + {children} + + ); + } + + return ( + <> + + + + + setOpen(false)} + slotProps={{ + paper: { + sx: { + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + maxHeight: "85vh", + pb: "env(safe-area-inset-bottom, 0px)", + }, + }, + }} + > + + + {title} + + setOpen(false)} + sx={{ color: "text.secondary" }} + > + + + + + + {children} + + + + {onClear && ( + + )} + + + + + ); +} diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 1f0adbc..fa9df8b 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -2,6 +2,9 @@ export { AsyncContent } from "./async-content"; export { ChangePasswordSection } from "./change-password-section"; export { ConfirmDialog } from "./confirm-dialog"; export { DetailField } from "./detail-field"; +export { FilterDrawer } from "./filter-drawer"; +export { MobileDialog } from "./mobile-dialog"; export { ModalTitleBar } from "./modal-title-bar"; export { PageHeader } from "./page-header"; +export { ResponsiveTable } from "./responsive-table"; export { TablePaginationFooter } from "./table-pagination-footer"; diff --git a/src/components/shared/mobile-dialog.tsx b/src/components/shared/mobile-dialog.tsx new file mode 100644 index 0000000..9c2d4d3 --- /dev/null +++ b/src/components/shared/mobile-dialog.tsx @@ -0,0 +1,24 @@ +"use client"; +import Dialog, { type DialogProps } from "@mui/material/Dialog"; +import type { JSX } from "react"; + +import { useIsMobile } from "@/hooks/use-is-mobile"; + +/** + * MobileDialog + * + * Drop-in replacement for MUI `` that automatically goes full-screen + * on viewports below the md breakpoint. Forwards every other prop. + * + * The theme already collapses the paper border-radius to 0 and applies + * safe-area padding when `fullScreen` is true (see `MuiDialog.styleOverrides`), + * so consumers just need to swap the import. + * + * Pass `fullScreen` explicitly to override (e.g. `fullScreen={false}` for + * dialogs that should stay centered even on mobile). + */ +export function MobileDialog(props: DialogProps): JSX.Element { + const isMobile = useIsMobile(); + const { fullScreen, ...rest } = props; + return ; +} diff --git a/src/components/shared/modal-title-bar.tsx b/src/components/shared/modal-title-bar.tsx index c5cd2ff..68c443b 100644 --- a/src/components/shared/modal-title-bar.tsx +++ b/src/components/shared/modal-title-bar.tsx @@ -15,7 +15,16 @@ export function ModalTitleBar({ onClose, }: ModalTitleBarProps): ReactElement { return ( - + + {eyebrow && ( {title} @@ -63,7 +67,15 @@ export function PageHeader({ )} {actions && ( - + *": { width: { xs: "100%", sm: "auto" } }, + }} + > {actions} )} diff --git a/src/components/shared/responsive-table.tsx b/src/components/shared/responsive-table.tsx new file mode 100644 index 0000000..421e7e4 --- /dev/null +++ b/src/components/shared/responsive-table.tsx @@ -0,0 +1,57 @@ +"use client"; +import { Box } from "@mui/material"; +import { type JSX, type ReactNode } from "react"; + +import { useIsMobile } from "@/hooks/use-is-mobile"; + +type ResponsiveTableProps = { + /** Rendered when viewport is at md or wider. Typically a ``. */ + desktop: ReactNode; + /** Rendered below md. Typically a vertical stack of cards. */ + mobile: ReactNode; +}; + +/** + * ResponsiveTable + * + * Branches between a desktop table view and a mobile card-list view at the + * md breakpoint. The component itself is intentionally thin — it does not + * own loading or empty states; consumers provide those inside the slot they + * pass. This keeps the per-page table semantics (sticky header, fixed + * heights, overlay spinners) intact on desktop while letting mobile use a + * looser card-stack layout. + * + * @example + * + * + * {items.map(renderRow)} + *
+ * } + * mobile={ + * + * {items.map(renderMobileCard)} + * + * } + * /> + */ +export function ResponsiveTable({ + desktop, + mobile, +}: ResponsiveTableProps): JSX.Element { + const isMobile = useIsMobile(); + return ( + + {isMobile ? mobile : desktop} + + ); +} diff --git a/src/components/staff/approvals/attendance-modal.tsx b/src/components/staff/approvals/attendance-modal.tsx index 98512e1..862322a 100644 --- a/src/components/staff/approvals/attendance-modal.tsx +++ b/src/components/staff/approvals/attendance-modal.tsx @@ -7,7 +7,6 @@ import { Box, Button, CircularProgress, - Dialog, DialogContent, Stack, Table, @@ -19,7 +18,7 @@ import { } from "@mui/material"; import { JSX } from "react"; -import { ModalTitleBar } from "@/components/shared"; +import { MobileDialog, ModalTitleBar } from "@/components/shared"; import { StatusBadge } from "@/components/ui"; import type { AttendanceEvent, @@ -74,7 +73,7 @@ export default function AttendanceModal({ }); return ( - + @@ -225,6 +224,6 @@ export default function AttendanceModal({ )} - + ); } diff --git a/src/components/staff/approvals/hours-tab.tsx b/src/components/staff/approvals/hours-tab.tsx index 8e71927..f548595 100644 --- a/src/components/staff/approvals/hours-tab.tsx +++ b/src/components/staff/approvals/hours-tab.tsx @@ -94,9 +94,9 @@ export default function HoursTab({ - + -
+ ); } diff --git a/src/components/staff/approvals/rsvps-tab.tsx b/src/components/staff/approvals/rsvps-tab.tsx index 558ec4f..87560d0 100644 --- a/src/components/staff/approvals/rsvps-tab.tsx +++ b/src/components/staff/approvals/rsvps-tab.tsx @@ -106,9 +106,9 @@ export default function RsvpsTab({ - + -
+ ); } diff --git a/src/components/staff/calendar/event-detail-modal.tsx b/src/components/staff/calendar/event-detail-modal.tsx index b5805a6..7942b3c 100644 --- a/src/components/staff/calendar/event-detail-modal.tsx +++ b/src/components/staff/calendar/event-detail-modal.tsx @@ -9,7 +9,6 @@ import { Chip, CircularProgress, Divider, - Drawer, FormControl, IconButton, InputLabel, @@ -21,6 +20,7 @@ import { import { enqueueSnackbar } from "notistack"; import { JSX, useEffect, useState } from "react"; +import { MobileDialog } from "@/components/shared"; import { EmptyState, getRsvpStatusColor, StatusBadge } from "@/components/ui"; import type { CalendarEvent } from "@/hooks/use-calendar-events"; import { useCalendarEvents } from "@/hooks/use-calendar-events"; @@ -301,12 +301,7 @@ export default function EventDetailModal({ const maxVol = event?.extendedProps?.maxVolunteers; return ( - + {/* Header */} - + ); } diff --git a/src/components/staff/calendar/event-form-modal.tsx b/src/components/staff/calendar/event-form-modal.tsx index d3b518e..0b6def4 100644 --- a/src/components/staff/calendar/event-form-modal.tsx +++ b/src/components/staff/calendar/event-form-modal.tsx @@ -6,7 +6,6 @@ import { Box, Button, Chip, - Drawer, FormControl, FormControlLabel, FormLabel, @@ -23,6 +22,7 @@ import { import { enqueueSnackbar } from "notistack"; import { JSX, useEffect, useState } from "react"; +import { MobileDialog } from "@/components/shared"; import { useCalendarEvents } from "@/hooks/use-calendar-events"; import { useEventCategories } from "@/hooks/use-event-categories"; import { useOpportunitySkills } from "@/hooks/use-opportunity-skills"; @@ -207,12 +207,7 @@ export default function EventFormModal({ }; return ( - + {/* Header */} - + - + )} occurrences @@ -555,6 +563,6 @@ export default function EventFormModal({ - + ); } diff --git a/src/components/staff/communications/communications-list.tsx b/src/components/staff/communications/communications-list.tsx index 2e5d4ca..ab64a6e 100644 --- a/src/components/staff/communications/communications-list.tsx +++ b/src/components/staff/communications/communications-list.tsx @@ -1,5 +1,6 @@ "use client"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import CreateIcon from "@mui/icons-material/Create"; import DeleteIcon from "@mui/icons-material/Delete"; import PersonIcon from "@mui/icons-material/Person"; @@ -8,6 +9,7 @@ import { Alert, Avatar, Box, + Button, CircularProgress, Divider, Fab, @@ -26,6 +28,7 @@ import { type ReactElement, useCallback, useState } from "react"; import { ConfirmDialog } from "@/components/shared/confirm-dialog"; import { EmptyState } from "@/components/ui"; import { useCommunications } from "@/hooks/use-communications"; +import { useIsMobile } from "@/hooks/use-is-mobile"; import ComposeModal from "./compose-modal"; @@ -42,6 +45,7 @@ export type CommunicationsListProps = { export default function CommunicationsList({ userRole, }: CommunicationsListProps): ReactElement { + const isMobile = useIsMobile(); const { communications, selectedCommunication, @@ -53,6 +57,7 @@ export default function CommunicationsList({ loadCommunications, selectCommunication, deleteCommunication, + clearSelection, } = useCommunications(); const [composeModalOpen, setComposeModalOpen] = useState(false); const [confirmDelete, setConfirmDelete] = useState<{ @@ -116,6 +121,7 @@ export default function CommunicationsList({ + {/* Back button (mobile only) */} + {isMobile && ( + + )} + {/* Subject */} - + ); } diff --git a/src/components/staff/onboarding/document-manager.tsx b/src/components/staff/onboarding/document-manager.tsx index 1320ebb..24777b8 100644 --- a/src/components/staff/onboarding/document-manager.tsx +++ b/src/components/staff/onboarding/document-manager.tsx @@ -12,7 +12,6 @@ import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import FormControl from "@mui/material/FormControl"; @@ -39,7 +38,12 @@ import { useState, } from "react"; -import { ConfirmDialog, ModalTitleBar, PageHeader } from "@/components/shared"; +import { + ConfirmDialog, + MobileDialog, + ModalTitleBar, + PageHeader, +} from "@/components/shared"; import { EmptyState } from "@/components/ui"; import { type CreateDocumentInput, @@ -473,7 +477,12 @@ export default function DocumentManager(): JSX.Element { {/* Add / Edit Modal */} - + - + {/* Preview Modal */} - setPreviewTarget(null)} maxWidth="lg" @@ -689,7 +698,7 @@ export default function DocumentManager(): JSX.Element { )} )} - + ); } diff --git a/src/components/staff/people-management/people-list.tsx b/src/components/staff/people-management/people-list.tsx index f8bdd6f..15f3428 100644 --- a/src/components/staff/people-management/people-list.tsx +++ b/src/components/staff/people-management/people-list.tsx @@ -1,7 +1,6 @@ "use client"; import DownloadIcon from "@mui/icons-material/Download"; -import FilterListIcon from "@mui/icons-material/FilterList"; import GroupAddIcon from "@mui/icons-material/GroupAdd"; import PersonAddIcon from "@mui/icons-material/PersonAdd"; import UploadFileIcon from "@mui/icons-material/UploadFile"; @@ -11,28 +10,20 @@ import { Button, Checkbox, CircularProgress, - Dialog, - DialogActions, - DialogContent, - Divider, Drawer, FormControl, FormControlLabel, - FormGroup, - IconButton, InputLabel, MenuItem, Select, SelectChangeEvent, - Stack, TextField, - Tooltip, Typography, } from "@mui/material"; import { useSnackbar } from "notistack"; import { type ReactElement, useCallback, useState } from "react"; -import { ModalTitleBar, PageHeader } from "@/components/shared"; +import { FilterDrawer, ModalTitleBar, PageHeader } from "@/components/shared"; import ImportVolunteerModal from "@/components/staff/volunteer-management/import-modal"; import AddVolunteerModal from "@/components/staff/volunteer-management/volunteer-add-modal"; import VolunteerProfile from "@/components/staff/volunteer-management/volunteer-profile"; @@ -56,10 +47,12 @@ export default function PeopleList(): ReactElement { const [statusFilter, setStatusFilter] = useState(""); const [typeFilter, setTypeFilter] = useState(); const [alumniFilter, setAlumniFilter] = useState(); - const [filterModalOpen, setFilterModalOpen] = useState(false); - const hasAdditionalFilters = - typeFilter !== undefined || alumniFilter !== undefined; + const activeFilterCount = + (roleFilter ? 1 : 0) + + (statusFilter ? 1 : 0) + + (typeFilter ? 1 : 0) + + (alumniFilter ? 1 : 0); const { activeTypes } = useVolunteerTypes(); @@ -151,6 +144,8 @@ export default function PeopleList(): ReactElement { ); const handleClearFilters = useCallback((): void => { + setRoleFilter(""); + setStatusFilter(""); setTypeFilter(undefined); setAlumniFilter(undefined); setPage(1); @@ -204,8 +199,7 @@ export default function PeopleList(): ReactElement { } }, [selectedVolunteerId, resendCredentials, enqueueSnackbar]); - // Show pagination only when role-filtered (paginated fetch); hide for "all" (full fetch) - const showPagination = roleFilter !== ""; + // Pagination is now applied to all views (even when no role filter is active) return ( - - Role - - - - Status - - - - setFilterModalOpen(true)} - sx={{ - backgroundColor: hasAdditionalFilters - ? "primary.main" - : "transparent", - color: hasAdditionalFilters - ? "primary.contrastText" - : "primary.main", - "&:hover": { - backgroundColor: hasAdditionalFilters - ? "primary.dark" - : "action.hover", - }, - }} - > - - - + + + Role + + + + Status + + + + Volunteer Type + + + + } + label="Alumni only" + /> + {total} result{total === 1 ? "" : "s"} @@ -370,7 +373,6 @@ export default function PeopleList(): ReactElement { onLimitChange={handleLimitChange} onPersonClick={(p) => void handlePersonClick(p)} loading={loading} - showPagination={showPagination} /> @@ -463,78 +465,6 @@ export default function PeopleList(): ReactElement { void loadPeople(); }} /> - - {/* More filters dialog (volunteer type + alumni) */} - setFilterModalOpen(false)} - fullWidth - maxWidth="sm" - > - setFilterModalOpen(false)} - /> - - - - - - Volunteer Type - - - - Volunteer Type - - - - - - - - Additional Filters - - - - } - label="Alumni only" - /> - - - - - - - - - - - ); } diff --git a/src/components/staff/people-management/people-table.tsx b/src/components/staff/people-management/people-table.tsx index d592086..84fc270 100644 --- a/src/components/staff/people-management/people-table.tsx +++ b/src/components/staff/people-management/people-table.tsx @@ -3,6 +3,7 @@ import { Avatar, Box, + Card, Chip, type ChipProps, CircularProgress, @@ -17,7 +18,7 @@ import { } from "@mui/material"; import { type ReactElement, useCallback } from "react"; -import { TablePaginationFooter } from "@/components/shared"; +import { ResponsiveTable, TablePaginationFooter } from "@/components/shared"; import { EmptyState } from "@/components/ui"; import type { Person, PersonRole } from "@/hooks/use-people"; @@ -95,156 +96,299 @@ export default function PeopleTable({ overflow: "hidden", }} > - - {loading && ( - - - - )} - - - - - Name - - - Role - - - Status - - - Hours - - - Joined - - - - - {people.length > 0 ? ( - people.map((person) => { - const status = deriveStatus(person); - const roleMeta = ROLE_META[person.personType]; - return ( - handleRowClick(person)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleRowClick(person); - } + + + )} +
+ + + + Name + + - + Role + + + Status + + + Hours + + + Joined + + + + + {people.length > 0 ? ( + people.map((person) => { + const status = deriveStatus(person); + const roleMeta = ROLE_META[person.personType]; + return ( + handleRowClick(person)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleRowClick(person); + } + }} + tabIndex={0} + role="button" + aria-label={`View profile for ${person.firstName} ${person.lastName}`} + sx={{ + cursor: "pointer", + "&:hover": { backgroundColor: "action.hover" }, + }} + > + + + + {`${person.firstName?.[0] ?? ""}${person.lastName?.[0] ?? ""}`.toUpperCase() || + "?"} + + + + {person.firstName} {person.lastName} + + + {person.email} + + + + + + + + + + + + + {person.personType === "volunteer" + ? (person.totalHours ?? 0).toFixed(1) + : "—"} + + + + + {fmtJoined(person.createdAt)} + + + + ); + }) + ) : loading ? null : ( + + + + + + )} + +
+
+ } + mobile={ + + {loading ? ( + + + + ) : people.length === 0 ? ( + + ) : ( + + {people.map((person) => { + const status = deriveStatus(person); + const roleMeta = ROLE_META[person.personType]; + const initials = + `${person.firstName?.[0] ?? ""}${person.lastName?.[0] ?? ""}`.toUpperCase() || + "?"; + return ( + handleRowClick(person)} + sx={{ + cursor: "pointer", + "&:hover": { bgcolor: "action.hover" }, + }} + > - {`${person.firstName?.[0] ?? ""}${person.lastName?.[0] ?? ""}`.toUpperCase() || - "?"} + {initials} - - + + {person.firstName} {person.lastName} - + {person.email} + + + + + {person.personType === "volunteer" + ? `${(person.totalHours ?? 0).toFixed(1)} hrs` + : "—"}{" "} + · {fmtJoined(person.createdAt)} + + - - - - - - - - - - {person.personType === "volunteer" - ? (person.totalHours ?? 0).toFixed(1) - : "—"} - - - - - {fmtJoined(person.createdAt)} - - - - ); - }) - ) : loading ? null : ( - - - - - + + ); + })} + )} - - - + + } + /> {showPagination && ( - : "Add Staff Member"} - + {/* Success Dialog */} - - + ); } diff --git a/src/components/staff/volunteer-management/import-modal.tsx b/src/components/staff/volunteer-management/import-modal.tsx index 56e3747..8989e38 100644 --- a/src/components/staff/volunteer-management/import-modal.tsx +++ b/src/components/staff/volunteer-management/import-modal.tsx @@ -9,7 +9,6 @@ import { Box, Button, CircularProgress, - Dialog, DialogActions, DialogContent, Paper, @@ -25,7 +24,7 @@ import { import Papa from "papaparse"; import { type ReactElement, useCallback, useRef, useState } from "react"; -import { ModalTitleBar } from "@/components/shared"; +import { MobileDialog, ModalTitleBar } from "@/components/shared"; import { useVolunteerImport } from "@/hooks/use-volunteer-import"; type Stage = "idle" | "preview" | "importing" | "results"; @@ -149,7 +148,7 @@ export default function ImportVolunteerModal({ }, [reset]); return ( - + @@ -365,6 +364,6 @@ export default function ImportVolunteerModal({ )} - + ); } diff --git a/src/components/staff/volunteer-management/skills-modal.tsx b/src/components/staff/volunteer-management/skills-modal.tsx index c155103..d2f4586 100644 --- a/src/components/staff/volunteer-management/skills-modal.tsx +++ b/src/components/staff/volunteer-management/skills-modal.tsx @@ -5,7 +5,6 @@ import { Button, Checkbox, CircularProgress, - Dialog, DialogActions, DialogContent, DialogTitle, @@ -16,6 +15,7 @@ import { import { useSnackbar } from "notistack"; import { JSX, useCallback, useEffect, useState } from "react"; +import { MobileDialog } from "@/components/shared"; import { useSkills } from "@/hooks/use-skills"; type CurrentSkill = { @@ -191,7 +191,7 @@ export default function SkillsModal({ ]); return ( - + Edit {mode === "skills" ? "Skills" : "Interests"} @@ -279,6 +279,6 @@ export default function SkillsModal({ {saving ? "Saving..." : "Save"} - + ); } diff --git a/src/components/staff/volunteer-management/volunteer-add-modal.tsx b/src/components/staff/volunteer-management/volunteer-add-modal.tsx index 1a6dbae..e8e2437 100644 --- a/src/components/staff/volunteer-management/volunteer-add-modal.tsx +++ b/src/components/staff/volunteer-management/volunteer-add-modal.tsx @@ -9,7 +9,6 @@ import { Button, Checkbox, CircularProgress, - Dialog, DialogActions, DialogContent, DialogTitle, @@ -31,6 +30,7 @@ import { import { type ReactElement, useCallback, useMemo, useState } from "react"; import validator from "validator"; +import { MobileDialog } from "@/components/shared"; import { useVolunteerTypes } from "@/hooks/use-volunteer-types"; import { useVolunteers } from "@/hooks/use-volunteers"; @@ -237,7 +237,7 @@ export default function AddVolunteerModal({ ); return ( - +
{/* Success Dialog */} - { setSuccessDialogOpen(false); @@ -587,7 +587,7 @@ export default function AddVolunteerModal({ Done - -
+ + ); } diff --git a/src/components/staff/volunteer-management/volunteer-list.tsx b/src/components/staff/volunteer-management/volunteer-list.tsx index de6dbf8..e2cd13b 100644 --- a/src/components/staff/volunteer-management/volunteer-list.tsx +++ b/src/components/staff/volunteer-management/volunteer-list.tsx @@ -2,7 +2,6 @@ import AddIcon from "@mui/icons-material/Add"; import DownloadIcon from "@mui/icons-material/Download"; -import FilterListIcon from "@mui/icons-material/FilterList"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import { Alert, @@ -10,28 +9,20 @@ import { Button, Checkbox, CircularProgress, - Dialog, - DialogActions, - DialogContent, - Divider, Drawer, FormControl, FormControlLabel, - FormGroup, - IconButton, InputLabel, MenuItem, Select, SelectChangeEvent, - Stack, TextField, - Tooltip, Typography, } from "@mui/material"; import { useSnackbar } from "notistack"; import { type ReactElement, useCallback, useState } from "react"; -import { ModalTitleBar, PageHeader } from "@/components/shared"; +import { FilterDrawer, ModalTitleBar, PageHeader } from "@/components/shared"; import { useVolunteerDetail } from "@/hooks/use-volunteer-detail"; import { useVolunteerTypes } from "@/hooks/use-volunteer-types"; import { @@ -89,7 +80,6 @@ export default function VolunteerList(): ReactElement { const [addModalOpen, setAddModalOpen] = useState(false); const [importModalOpen, setImportModalOpen] = useState(false); - const [filterModalOpen, setFilterModalOpen] = useState(false); const handleSearchChange = useCallback( (event: React.ChangeEvent): void => { @@ -127,6 +117,7 @@ export default function VolunteerList(): ReactElement { ); const handleClearFilters = useCallback((): void => { + setStatusFilter(""); setFilters({}); setPage(1); }, [setPage]); @@ -171,14 +162,14 @@ export default function VolunteerList(): ReactElement { } }, [selectedVolunteerId, resendCredentials, enqueueSnackbar]); - const hasAdditionalFilters = - filters.type !== undefined || filters.alumni !== undefined; + const activeFilterCount = + (statusFilter ? 1 : 0) + (filters.type ? 1 : 0) + (filters.alumni ? 1 : 0); return ( - - Status - - - - setFilterModalOpen(true)} - sx={{ - backgroundColor: hasAdditionalFilters - ? "primary.main" - : "transparent", - color: hasAdditionalFilters - ? "primary.contrastText" - : "primary.main", - "&:hover": { - backgroundColor: hasAdditionalFilters - ? "primary.dark" - : "action.hover", - }, - }} - > - - - + + + Status + + + + Volunteer Type + + + + } + label="Alumni only" + /> + {total} result{total === 1 ? "" : "s"} @@ -375,78 +375,6 @@ export default function VolunteerList(): ReactElement { void loadVolunteers(); }} /> - - {/* More filters modal (type + alumni) */} - setFilterModalOpen(false)} - fullWidth - maxWidth="sm" - > - setFilterModalOpen(false)} - /> - - - - - - Volunteer Type - - - - Volunteer Type - - - - - - - - Additional Filters - - - - } - label="Alumni only" - /> - - - - - - - - - - - ); } diff --git a/src/components/staff/volunteer-management/volunteer-overview-tab.tsx b/src/components/staff/volunteer-management/volunteer-overview-tab.tsx index 950763d..b42c5c5 100644 --- a/src/components/staff/volunteer-management/volunteer-overview-tab.tsx +++ b/src/components/staff/volunteer-management/volunteer-overview-tab.tsx @@ -22,7 +22,6 @@ import { Chip, CircularProgress, Collapse, - Dialog, DialogActions, DialogContent, Divider, @@ -42,7 +41,12 @@ import { import { useSnackbar } from "notistack"; import { JSX, useCallback, useEffect, useState } from "react"; -import { ConfirmDialog, DetailField, ModalTitleBar } from "@/components/shared"; +import { + ConfirmDialog, + DetailField, + MobileDialog, + ModalTitleBar, +} from "@/components/shared"; import { getBackgroundCheckColor, getBackgroundCheckLabel, @@ -344,7 +348,7 @@ function StaffEditVolunteerModal({ }; return ( - + void handleSubmit(e)}> @@ -539,7 +543,7 @@ function StaffEditVolunteerModal({ - + ); } @@ -565,7 +569,7 @@ function StaffSignDocumentDialog({ signing: boolean; }): JSX.Element { return ( - + {doc && ( <> @@ -609,7 +613,7 @@ function StaffSignDocumentDialog({ )} - + ); } diff --git a/src/components/staff/volunteer-management/volunteer-table.tsx b/src/components/staff/volunteer-management/volunteer-table.tsx index 7d9f287..e14b0dc 100644 --- a/src/components/staff/volunteer-management/volunteer-table.tsx +++ b/src/components/staff/volunteer-management/volunteer-table.tsx @@ -4,6 +4,7 @@ import PersonIcon from "@mui/icons-material/Person"; import { Avatar, Box, + Card, Chip, type ChipProps, CircularProgress, @@ -18,7 +19,7 @@ import { } from "@mui/material"; import { type ReactElement, useCallback } from "react"; -import { TablePaginationFooter } from "@/components/shared"; +import { ResponsiveTable, TablePaginationFooter } from "@/components/shared"; import { EmptyState } from "@/components/ui"; import { type Volunteer } from "./types"; @@ -84,140 +85,254 @@ export default function VolunteerTable({ overflow: "hidden", }} > - - {loading && ( - - - - )} - - - - - Name - - - Status - - - Hours - - - Joined - - - - - {volunteers.length > 0 ? ( - volunteers.map((volunteer) => { - const status = deriveStatus(volunteer); - return ( - handleRowClick(volunteer.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleRowClick(volunteer.id); - } + + + )} +
+ + + + Name + + + Status + + + Hours + + - + Joined + + + + + {volunteers.length > 0 ? ( + volunteers.map((volunteer) => { + const status = deriveStatus(volunteer); + return ( + handleRowClick(volunteer.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleRowClick(volunteer.id); + } + }} + tabIndex={0} + role="button" + aria-label={`View profile for ${volunteer.firstName} ${volunteer.lastName}`} + sx={{ + cursor: "pointer", + "&:hover": { backgroundColor: "action.hover" }, + }} + > + + + + {!volunteer.profilePicture && ( + + )} + + + + {volunteer.firstName} {volunteer.lastName} + + + {volunteer.email} + + + + + + + + + + {(volunteer.totalHours ?? 0).toFixed(1)} + + + + + {fmtJoined(volunteer.createdAt)} + + + + ); + }) + ) : loading ? null : ( + + + + + + )} + +
+
+ } + mobile={ + + {loading ? ( + + + + ) : volunteers.length === 0 ? ( + + ) : ( + + {volunteers.map((volunteer) => { + const status = deriveStatus(volunteer); + return ( + handleRowClick(volunteer.id)} + sx={{ + cursor: "pointer", + "&:hover": { bgcolor: "action.hover" }, + }} + > - {!volunteer.profilePicture && ( - - )} + {!volunteer.profilePicture && } - - + - {volunteer.firstName} {volunteer.lastName} + + {volunteer.firstName} {volunteer.lastName} + + + + + {volunteer.email} - {volunteer.email} + {(volunteer.totalHours ?? 0).toFixed(1)} hrs ·{" "} + {fmtJoined(volunteer.createdAt)} - - - - - - - {(volunteer.totalHours ?? 0).toFixed(1)} - - - - - {fmtJoined(volunteer.createdAt)} - - - - ); - }) - ) : loading ? null : ( - - - - - + + ); + })} + )} - - - + + } + /> + - + ); } diff --git a/src/components/volunteer/availability-editor.tsx b/src/components/volunteer/availability-editor.tsx index 32a7af5..3c5f43a 100644 --- a/src/components/volunteer/availability-editor.tsx +++ b/src/components/volunteer/availability-editor.tsx @@ -99,8 +99,10 @@ export default function AvailabilityEditor({ border: 1, borderColor: "grey.200", borderRadius: 2, - maxHeight: 340, - overflowY: "scroll", + // On mobile, let the parent scroll naturally — an inner scrollbar + // fights with native touch scroll and cramps the time pickers. + maxHeight: { xs: "none", sm: 340 }, + overflowY: { xs: "visible", sm: "scroll" }, width: "100%", }} > diff --git a/src/components/volunteer/dashboard-recommendations.tsx b/src/components/volunteer/dashboard-recommendations.tsx index c66e8d1..055be0f 100644 --- a/src/components/volunteer/dashboard-recommendations.tsx +++ b/src/components/volunteer/dashboard-recommendations.tsx @@ -41,7 +41,7 @@ export default function DashboardRecommendations({ effectiveRsvpStatus !== "declined"; return ( - + {recommendations.map((opp) => ( diff --git a/src/components/volunteer/onboarding/document-viewer.tsx b/src/components/volunteer/onboarding/document-viewer.tsx index 3eeeb50..1993d1f 100644 --- a/src/components/volunteer/onboarding/document-viewer.tsx +++ b/src/components/volunteer/onboarding/document-viewer.tsx @@ -8,9 +8,9 @@ import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; +import Stack from "@mui/material/Stack"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; @@ -21,7 +21,11 @@ import Typography from "@mui/material/Typography"; import { useSnackbar } from "notistack"; import { type JSX, useCallback, useEffect, useState } from "react"; -import { ModalTitleBar } from "@/components/shared"; +import { + MobileDialog, + ModalTitleBar, + ResponsiveTable, +} from "@/components/shared"; import { useOnboardingDocuments } from "@/hooks/use-onboarding-documents"; type DocumentViewerProps = { @@ -107,66 +111,159 @@ export default function DocumentViewer({ )} {!loading && !error && documents.length > 0 && ( - - - - - - Name - - - Category - - - Status - - - Completed - - - - + +
+ + + + Name + + + Category + + + Status + + + Completed + + + + + {documents.map((doc) => { + const response = responseMap.get(doc.id); + const hasResponded = response !== undefined; + + return ( + setSelectedDocId(doc.id)} + sx={{ cursor: "pointer" }} + > + + + + + + {doc.title} + + {doc.description && ( + + {doc.description} + + )} + + + + + + {typeLabel(doc.type)} + + + + {doc.actionType === "informational" ? ( + + — + + ) : hasResponded ? ( + } + label="Completed" + size="small" + color="success" + /> + ) : ( + + )} + + + + {response?.signedAt + ? new Date( + response.signedAt, + ).toLocaleDateString() + : "—"} + + + + ); + })} + +
+
+ } + mobile={ + {documents.map((doc) => { const response = responseMap.get(doc.id); const hasResponded = response !== undefined; return ( - setSelectedDocId(doc.id)} - sx={{ cursor: "pointer" }} + sx={{ + cursor: "pointer", + display: "flex", + alignItems: "center", + gap: 1.5, + py: 1.5, + px: 0.5, + borderBottom: "1px solid", + borderColor: "grey.100", + "&:last-child": { borderBottom: "none" }, + "&:hover": { bgcolor: "action.hover" }, + borderRadius: 1, + }} > - - - - - - {doc.title} - - {doc.description && ( - - {doc.description} - - )} - - - - - - {typeLabel(doc.type)} + + + + {doc.title} - - + {doc.description && ( + + {doc.description} + + )} +
+ {doc.actionType === "informational" ? ( - + ) : hasResponded ? ( @@ -183,20 +280,18 @@ export default function DocumentViewer({ variant="outlined" /> )} - - - + {response?.signedAt ? new Date(response.signedAt).toLocaleDateString() : "—"} - - + + ); })} - - - + + } + /> )}
@@ -284,7 +379,7 @@ function DocumentModal({ const hasResponded = response !== undefined; return ( - + {doc && ( <> {doc.type === "pdf" && ( - +