diff --git a/backend/src/controllers/volunteerController.ts b/backend/src/controllers/volunteerController.ts index a4bbae30..2138163c 100644 --- a/backend/src/controllers/volunteerController.ts +++ b/backend/src/controllers/volunteerController.ts @@ -131,6 +131,68 @@ export const createVolunteer: RequestHandler = async (req, res, next) => { } }; +type UpdateVolunteerBody = { + firstName: string; + lastName: string; + email: string; + phoneNumber: string; + tags?: string[]; + status?: "returning" | "new"; + volunteerTypeTags?: string[]; + events?: string[]; + additionalNotes?: string; +}; + +export const updateVolunteer: RequestHandler = async (req, res, next) => { + const errors = validationResult(req); + const volunteerId = req.params.id; + const { + firstName, + lastName, + email, + phoneNumber, + tags, + status, + volunteerTypeTags, + events, + additionalNotes, + } = req.body as UpdateVolunteerBody; + + try { + validationErrorParser(errors); + + const updatePayload: Record = { + firstName, + lastName, + email, + phoneNumber, + volunteerTypeTags, + events, + additionalNotes, + }; + + if (Array.isArray(tags)) { + updatePayload.tags = tags; + } + if (status === "new" || status === "returning") { + updatePayload.status = status; + } + + const volunteer = await VolunteerModel.findByIdAndUpdate(volunteerId, updatePayload, { + new: true, + runValidators: true, + }); + + if (!volunteer) { + return res.status(404).json({ error: "Could not find volunteer" }); + } + + res.status(200).json(volunteer); + } catch (err) { + next(err); + } +}; + type UpdateVolunteerContactBody = { email: string; phoneNumber: string; diff --git a/backend/src/routes/volunteerRoutes.ts b/backend/src/routes/volunteerRoutes.ts index 5f7eb672..a8231696 100644 --- a/backend/src/routes/volunteerRoutes.ts +++ b/backend/src/routes/volunteerRoutes.ts @@ -34,6 +34,7 @@ router.get("/", volunteer.getVolunteers); router.delete("/:id", volunteer.deleteVolunteer); router.post("/", VolunteerValidator.createVolunteerValidator, volunteer.createVolunteer); +router.put("/:id", VolunteerValidator.updateVolunteerValidator, volunteer.updateVolunteer); router.put( "/contact/:id", VolunteerValidator.updateVolunteerContactValidator, diff --git a/backend/src/validators/volunteerValidator.ts b/backend/src/validators/volunteerValidator.ts index 755d8efc..4216f81f 100644 --- a/backend/src/validators/volunteerValidator.ts +++ b/backend/src/validators/volunteerValidator.ts @@ -44,9 +44,14 @@ const makePhoneValidator = (path = "phoneNumber") => body(path) .exists() .withMessage("phone is required") - .bail() // What kind of phone number do we want to enforce? - .isMobilePhone("any") - .withMessage("phoneNumber must be a valid mobile phone number") + .bail() + .custom((value: string) => { + const digitsOnly = value.replace(/\D/g, ""); + if (digitsOnly.length !== 10) { + throw new Error("phoneNumber must be a valid phone number"); + } + return true; + }) .customSanitizer((value: string) => value.replace(/\D/g, "")); /* * makeStatusValidator is not currently used, but we may want to add a route in the future @@ -61,6 +66,10 @@ const makeStatusValidator = (path = "status") => */ const tagsValidator = () => body("tags").optional().isArray(); +const statusValidator = () => body("status").optional().isIn(["new", "returning"]); +const volunteerTypeTagsValidator = () => body("volunteerTypeTags").optional().isArray(); +const eventsValidator = () => body("events").optional().isArray(); +const additionalNotesValidator = () => body("additionalNotes").optional().isString(); const batchUploadVolunteersValidator = () => body("volunteers") @@ -85,6 +94,23 @@ export const createVolunteerValidator = [ makeEmailValidator(), makePhoneValidator(), tagsValidator(), + statusValidator(), + volunteerTypeTagsValidator(), + eventsValidator(), + additionalNotesValidator(), +]; + +export const updateVolunteerValidator = [ + makeParamIDValidator(), + makeFirstNameValidator(), + makeLastNameValidator(), + makeEmailValidator(), + makePhoneValidator(), + tagsValidator(), + statusValidator(), + volunteerTypeTagsValidator(), + eventsValidator(), + additionalNotesValidator(), ]; export const updateVolunteerContactValidator = [ diff --git a/frontend/src/app/api/volunteer/[id]/route.ts b/frontend/src/app/api/volunteer/[id]/route.ts new file mode 100644 index 00000000..9b6b61e8 --- /dev/null +++ b/frontend/src/app/api/volunteer/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; + +import type { NextRequest } from "next/server"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL; + +export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) { + if (!API_URL) { + return NextResponse.json({ error: "NEXT_PUBLIC_API_URL is not configured" }, { status: 500 }); + } + + const token = request.cookies.get("firebaseAuthToken")?.value; + + if (!token) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const { id } = await context.params; + + try { + const requestBody: unknown = await request.json(); + const response = await fetch(`${API_URL}/api/volunteer/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(requestBody), + cache: "no-store", + }); + + const contentType = response.headers.get("content-type") ?? ""; + const payload: unknown = contentType.includes("application/json") + ? await response.json() + : { error: await response.text() }; + + return NextResponse.json(payload, { status: response.status }); + } catch { + return NextResponse.json( + { error: "Unable to reach backend volunteer service" }, + { status: 502 }, + ); + } +} diff --git a/frontend/src/app/volunteers/page.tsx b/frontend/src/app/volunteers/page.tsx index 10041999..2d9b72aa 100644 --- a/frontend/src/app/volunteers/page.tsx +++ b/frontend/src/app/volunteers/page.tsx @@ -12,6 +12,7 @@ import PageBar from "@/components/PageBar"; import SearchBar from "@/components/SearchBar"; import Sidebar from "@/components/Sidebar"; import TitleBar from "@/components/TitleBar"; +import VolunteerProfileModal from "@/components/VolunteerProfileModal"; import VolunteerTable from "@/components/VolunteerTable"; type PopulatedAssignment = { @@ -41,6 +42,8 @@ export default function Page() { // Pagination state const [currentPage, setCurrentPage] = useState(1); const [showImportSuccess, setShowImportSuccess] = useState(false); + const [selectedVolunteer, setSelectedVolunteer] = useState(null); + const [isSheetOpen, setIsSheetOpen] = useState(false); const [selectedCount, setSelectedCount] = useState(0); const [sortOption, setSortOption] = useState< "Newest" | "Oldest" | "First Name A-Z" | "First Name Z-A" | "Last Name A-Z" | "Last Name Z-A" @@ -250,6 +253,10 @@ export default function Page() { setShowImportSuccess(true); }; + const handleSheetClose = () => { + setIsSheetOpen(false); + }; + return (
@@ -297,6 +304,10 @@ export default function Page() { volunteers={displayedVolunteers} selectableVolunteers={filteredVolunteers} onSelectedCountChange={setSelectedCount} + onVolunteerSelect={(v) => { + setSelectedVolunteer(v); + setIsSheetOpen(true); + }} />
@@ -314,6 +325,19 @@ export default function Page() { onPageChange={setCurrentPage} /> + { + setSelectedVolunteer(updatedVolunteer); + setVolunteers((prev) => + prev.map((volunteer) => + volunteer._id === updatedVolunteer._id ? updatedVolunteer : volunteer, + ), + ); + }} + />
); diff --git a/frontend/src/assets/plus.svg b/frontend/src/assets/plus.svg new file mode 100644 index 00000000..a40d571b --- /dev/null +++ b/frontend/src/assets/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/redx.svg b/frontend/src/assets/redx.svg new file mode 100644 index 00000000..0bfe381a --- /dev/null +++ b/frontend/src/assets/redx.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/VolunteerProfileModal.module.css b/frontend/src/components/VolunteerProfileModal.module.css new file mode 100644 index 00000000..84ee904d --- /dev/null +++ b/frontend/src/components/VolunteerProfileModal.module.css @@ -0,0 +1,462 @@ +@import url("https://fonts.googleapis.com/css2?family=Viga&display=swap"); + +.backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + z-index: 1000; +} + +.modal { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 40px 0 34px; + position: fixed; + width: 460px; + right: 0; + top: 0; + bottom: 0; + background: #ffffff; + box-shadow: -50px 0px 100px rgba(118, 135, 165, 0.4); + border-radius: 16px 0 0 16px; + z-index: 1001; +} + +.heading { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + width: 100%; + padding-bottom: 15px; +} + +.topper { + height: 24px; + width: 100%; +} + +.headerRow { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 0 32px; + width: 100%; + box-sizing: border-box; +} + +.modalHeader { + margin: 0; + font-family: "Viga", sans-serif; + font-weight: 400; + font-size: 24px; + line-height: 24px; + letter-spacing: 1.2px; + color: #000000; +} + +.closeButton { + width: 24px; + height: 24px; + background: none; + border: none; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.tabs { + display: flex; + flex-direction: row; + width: 100%; + height: 32px; + border-bottom: 1px solid #e8e8e8; + box-sizing: border-box; +} + +.tabButton { + display: flex; + justify-content: center; + align-items: center; + flex: 1; + height: 32px; + border: none; + background: none; + cursor: pointer; + border-radius: 4px 4px 0 0; + font-family: "Open Sans", sans-serif; + font-size: 12px; + line-height: 16px; + text-align: center; + box-sizing: border-box; +} + +.tabActive { + font-weight: 700; + color: #1d3a6b; + border-bottom: 2px solid #1d3a6b; +} + +.tabInactive { + font-weight: 400; + color: #676767; + border-bottom: 2px solid transparent; +} + +.content { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 16px 32px; + gap: 24px; + width: 100%; + flex: 1; + overflow-y: auto; + box-sizing: border-box; +} + +.infoSection { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.infoField { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; +} + +.fieldLabel { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #676767; +} + +.fieldValue { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 20px; + letter-spacing: 0.5px; + color: #000000; +} + +.editSection { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +} + +.editField { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.editLabel { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #979797; +} + +.editInput { + box-sizing: border-box; + width: 100%; + padding: 12px 16px; + border: 1px solid #e8e8e8; + border-radius: 8px; + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 20px; + letter-spacing: 0.5px; + color: #141414; + outline: none; +} + +.editInput:focus { + border-color: #1d3a6b; +} + +.tagsSection { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.sectionTitle { + font-family: "Open Sans", sans-serif; + font-weight: 700; + font-size: 16px; + line-height: 20px; + letter-spacing: 0.5px; + color: #000000; +} + +.sectionHeaderWithHint { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.sectionHint { + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #676767; +} + +.tagsRow { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag { + display: flex; + justify-content: center; + align-items: center; + padding: 8px 12px; + gap: 8px; + border-radius: 100px; + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; +} + +.tagTeal { + background: #e6f2f3; + color: #007a8a; +} + +.tagOrange { + background: #f9efe6; + color: #c46200; +} + +.tagGreen { + background: #e6f2ec; + color: #007f3f; +} + +.tagRemove { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + padding: 0; + width: 16px; + height: 16px; +} + +.searchAddRow { + display: flex; + flex-direction: row; + gap: 8px; + width: 100%; +} + +.searchAddField { + display: flex; + align-items: center; + box-sizing: border-box; + flex: 1; + padding: 12px 16px; + border: 1px solid #e8e8e8; + border-radius: 8px; + gap: 12px; +} + +.searchAddInput { + flex: 1; + border: none; + outline: none; + font-family: "Open Sans", sans-serif; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: #141414; +} + +.searchAddInput::placeholder { + color: #676767; +} + +.caretIcon { + width: 24px; + height: 24px; +} + +.addButton { + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + width: 44px; + height: 48px; + border: 1px solid #e8e8e8; + border-radius: 4px; + background: none; + cursor: pointer; + padding: 8px 12px; +} + +.saveError { + font-family: "Open Sans", sans-serif; + font-size: 12px; + line-height: 16px; + color: #a40026; +} + +.saveButton { + display: flex; + justify-content: center; + align-items: center; + padding: 12px 16px; + width: 100%; + border: none; + border-radius: 8px; + background: #1d3a6b; + color: #ffffff; + font-family: "Open Sans", sans-serif; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.saveButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +@media (max-width: 768px) { + .backdrop { + display: block; + } + + .modal { + width: 100%; + left: 0; + right: 0; + top: 62px; + bottom: 16px; + padding: 0 0 16px; + border-radius: 16px 16px 0 0; + box-shadow: 0 -20px 60px rgba(118, 135, 165, 0.25); + } + + .heading { + gap: 4px; + padding-bottom: 8px; + } + + .topper { + height: 0; + } + + .headerRow { + padding: 0 16px; + } + + .modalHeader { + font-size: 20px; + line-height: 24px; + letter-spacing: 0.8px; + margin-top: 34px; + } + + .tabs { + height: 36px; + } + + .tabButton { + height: 36px; + font-size: 12px; + } + + .content { + padding: 16px; + gap: 16px; + } + + .infoSection { + gap: 12px; + } + + .fieldValue { + font-size: 14px; + line-height: 18px; + } + + .editSection { + gap: 10px; + } + + .editField { + gap: 6px; + } + + .editInput { + padding: 10px 12px; + font-size: 14px; + } + + .sectionTitle { + font-size: 14px; + line-height: 18px; + } + + .sectionHint { + font-size: 11px; + } + + .tagsRow { + gap: 6px; + } + + .tag { + padding: 6px 10px; + font-size: 11px; + line-height: 14px; + } + + .searchAddRow { + gap: 6px; + } + + .searchAddField { + padding: 10px 12px; + } + + .searchAddInput { + font-size: 12px; + } + + .addButton { + width: 40px; + height: 44px; + } + + .saveButton { + padding: 12px; + font-size: 13px; + } +} diff --git a/frontend/src/components/VolunteerProfileModal.tsx b/frontend/src/components/VolunteerProfileModal.tsx new file mode 100644 index 00000000..a0af8781 --- /dev/null +++ b/frontend/src/components/VolunteerProfileModal.tsx @@ -0,0 +1,257 @@ +"use client"; +import Image from "next/image"; +import { useEffect, useState } from "react"; + +import styles from "./VolunteerProfileModal.module.css"; + +import type { Volunteer } from "../types/volunteer"; + +import icCloseAsset from "@/assets/ic_close.svg"; + +type VolunteerProfileModalProps = { + volunteer: Volunteer | null; + isOpen: boolean; + onClose: () => void; + onVolunteerUpdated?: (volunteer: Volunteer) => void; +}; + +const RETURNING_STATUS_LABELS = new Set(["returner", "returning", "expert"]); + +const deriveStatus = (volunteer: Volunteer): "new" | "returning" => { + if (volunteer.status === "new" || volunteer.status === "returning") { + return volunteer.status; + } + + const statusCandidates = volunteer.tags.map((tag) => tag.name.toLowerCase()); + return statusCandidates.some((candidate) => RETURNING_STATUS_LABELS.has(candidate)) + ? "returning" + : "new"; +}; + +const formatStatus = (status: "new" | "returning") => + status === "returning" ? "Returning" : "New"; + +function ViewContent({ volunteer }: { volunteer: Volunteer }) { + const status = deriveStatus(volunteer); + + return ( + <> +
+
+ First Name + {volunteer.firstName} +
+
+ Last Name + {volunteer.lastName} +
+
+ Email + {volunteer.email} +
+
+ Phone + {volunteer.phoneNumber} +
+
+ +
+ Status +
+ {formatStatus(status)} +
+
+ +
+ Additional Notes + {volunteer.additionalNotes ?? ""} +
+ + ); +} + +export default function VolunteerProfileModal({ + volunteer, + isOpen, + onClose, + onVolunteerUpdated, +}: VolunteerProfileModalProps) { + const [activeTab, setActiveTab] = useState("view"); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [status, setStatus] = useState<"new" | "returning">("new"); + const [additionalNotes, setAdditionalNotes] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(""); + + useEffect(() => { + if (!volunteer) return; + setFirstName(volunteer.firstName); + setLastName(volunteer.lastName); + setEmail(volunteer.email); + setPhoneNumber(volunteer.phoneNumber); + + setStatus(deriveStatus(volunteer)); + setAdditionalNotes(volunteer.additionalNotes ?? ""); + setSaveError(""); + }, [volunteer]); + + const handleSave = async () => { + if (!volunteer) return; + setIsSaving(true); + setSaveError(""); + try { + const response = await fetch(`/api/volunteer/${volunteer._id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + firstName, + lastName, + email, + phoneNumber, + status, + additionalNotes, + }), + }); + + if (!response.ok) { + const payload = (await response.json()) as { error?: string }; + throw new Error(payload.error || "Failed to update volunteer"); + } + + const updated = (await response.json()) as Volunteer; + onVolunteerUpdated?.(updated); + } catch (error) { + setSaveError(error instanceof Error ? error.message : "Failed to update volunteer"); + } finally { + setIsSaving(false); + } + }; + + if (!isOpen || !volunteer) return null; + + return ( + <> +