From 08ed025efed5c96034be64daac64a68fcd1dad88 Mon Sep 17 00:00:00 2001 From: Philip Zhang Date: Thu, 13 Jun 2024 16:10:43 -0700 Subject: [PATCH] All Referrals Page (#114) ## Tracking Info Resolves #73 ## Changes - implement all referrals page ## Testing - verify all functionality works ## Confirmation of Change ![image](https://github.com/TritonSE/USHS-Housing-Portal/assets/24444266/c154acbc-f024-4eea-aebc-f4367891ebfa) --------- Co-authored-by: Yashwanth Ravipati Co-authored-by: PoliteUnicorn --- backend/src/controllers/referral.ts | 8 +- backend/src/controllers/user.ts | 14 + backend/src/routes/referral.ts | 16 +- backend/src/routes/user.ts | 2 + backend/src/services/referral.ts | 37 +- frontend/src/App.tsx | 2 + frontend/src/api/referrals.ts | 12 +- frontend/src/api/users.ts | 11 + frontend/src/components/FilterDropdown.tsx | 6 +- frontend/src/components/NavBar.tsx | 5 +- frontend/src/components/ReferralPopup.tsx | 46 +- frontend/src/components/TablePagination.tsx | 2 +- frontend/src/components/helpers.ts | 6 + frontend/src/pages/Referrals.tsx | 460 ++++++++++++++++++++ frontend/src/pages/UnitDetails.tsx | 7 +- frontend/src/pages/index.ts | 1 + 16 files changed, 597 insertions(+), 38 deletions(-) create mode 100644 frontend/src/pages/Referrals.tsx diff --git a/backend/src/controllers/referral.ts b/backend/src/controllers/referral.ts index f9618ba..f427ad5 100644 --- a/backend/src/controllers/referral.ts +++ b/backend/src/controllers/referral.ts @@ -3,7 +3,7 @@ import { ObjectId } from "mongoose"; import { asyncHandler } from "./wrappers"; -import { createReferral, deleteReferral, editReferral } from "@/services/referral"; +import { createReferral, deleteReferral, editReferral, getAllReferrals } from "@/services/referral"; type CreateReferralRequestBody = { renterCandidateId: string; @@ -53,3 +53,9 @@ export const deleteReferralHandler: RequestHandler = asyncHandler(async (req, re res.status(200).json(response); } }); + +export const getReferralsHandler: RequestHandler = asyncHandler(async (req, res, _) => { + const referrals = await getAllReferrals(); + + res.status(200).json(referrals); +}); diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index 755706e..f4145dd 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -6,6 +6,7 @@ import { RequestHandler } from "express"; import { asyncHandler } from "./wrappers"; +import { getReferralsForUser } from "@/services/referral"; import { createUser, demoteUser, elevateUser, getUserByID, getUsers } from "@/services/user"; export const getUsersHandler: RequestHandler = asyncHandler(async (_req, res, _next) => { @@ -53,3 +54,16 @@ export const demoteUserHandler: RequestHandler = asyncHandler(async (req, res, _ res.status(200).json(demotedUser); } }); + +export const getUserReferrals: RequestHandler = asyncHandler(async (req, res, _) => { + const id = req.params.id; + + const referrals = await getReferralsForUser(id); + + if (referrals === null) { + res.status(404); + return; + } + + res.status(200).json(referrals); +}); diff --git a/backend/src/routes/referral.ts b/backend/src/routes/referral.ts index 66dea69..e0807fc 100644 --- a/backend/src/routes/referral.ts +++ b/backend/src/routes/referral.ts @@ -1,18 +1,16 @@ import express from "express"; -import { - createReferralHandler, - deleteReferralHandler, - editReferralHandler, -} from "@/controllers/referral"; -import { requireUser } from "@/middleware/auth"; +import * as ReferralController from "../controllers/referral"; +import { requireUser } from "../middleware/auth"; const router = express.Router(); -router.post("/", requireUser, createReferralHandler); +router.post("/", requireUser, ReferralController.createReferralHandler); -router.put("/:id", requireUser, editReferralHandler); +router.get("/", requireUser, ReferralController.getReferralsHandler); -router.delete("/:id", requireUser, deleteReferralHandler); +router.put("/:id", requireUser, ReferralController.editReferralHandler); + +router.delete("/:id", requireUser, ReferralController.deleteReferralHandler); export default router; diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index 8123773..4eb4191 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -21,4 +21,6 @@ router.put("/:id/elevate", requireHousingLocator, UserController.elevateUserHand router.put("/:id/demote", requireHousingLocator, UserController.demoteUserHandler); +router.get("/:id/referrals", requireUser, UserController.getUserReferrals); + export default router; diff --git a/backend/src/services/referral.ts b/backend/src/services/referral.ts index d7820d0..6c58512 100644 --- a/backend/src/services/referral.ts +++ b/backend/src/services/referral.ts @@ -1,10 +1,11 @@ +import createHttpError from "http-errors"; import { ObjectId } from "mongoose"; +import { ReferralModel } from "../models/referral"; + import { sendEmail } from "./email"; import { getUserByID } from "./user"; -import { ReferralModel } from "@/models/referral"; - export async function getUnitReferrals(id: string) { const referrals = await ReferralModel.find({ unit: id }) .populate("renterCandidate") @@ -92,3 +93,35 @@ export async function deleteUnitReferrals(unitId: string) { export async function deleteReferral(id: string) { return await ReferralModel.deleteOne({ _id: id }); } + +export async function getReferralsForUser(id: string) { + const user = await getUserByID(id); + + if (!user) { + throw createHttpError(404, "User notfound."); + } + + let query; + const { isHousingLocator } = user; + + if (isHousingLocator) { + query = { assignedHousingLocator: id }; + } else { + query = { assignedReferringStaff: id }; + } + + const referrals = await ReferralModel.find(query) + .populate("renterCandidate") + .populate("assignedHousingLocator") + .populate("assignedReferringStaff"); + + return referrals; +} + +export async function getAllReferrals() { + const referrals = await ReferralModel.find({}) + .populate("renterCandidate") + .populate("assignedHousingLocator") + .populate("assignedReferringStaff"); + return referrals; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 13980d1..d3e93e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { LandlordListingForm, Login, Profile, + Referrals, RenterCandidatePage, UnitDetails, } from "@/pages"; @@ -36,6 +37,7 @@ function AppRouter() { } /> } /> } /> + } /> } /> )} diff --git a/frontend/src/api/referrals.ts b/frontend/src/api/referrals.ts index f2f64e3..57b8f23 100644 --- a/frontend/src/api/referrals.ts +++ b/frontend/src/api/referrals.ts @@ -1,4 +1,4 @@ -import { APIResult, deleteRequest, handleAPIError, post, put } from "./requests"; +import { APIResult, deleteRequest, get, handleAPIError, post, put } from "./requests"; import { Referral } from "./units"; export type CreateReferralRequest = { @@ -46,3 +46,13 @@ export async function deleteReferral(id: string): Promise> { return handleAPIError(error); } } + +export async function getAllReferrals(): Promise> { + try { + const response = await get(`/referrals`); + const json = (await response.json()) as Referral[]; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index ac6de58..af46ec0 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -7,6 +7,7 @@ */ import { APIResult, get, handleAPIError, post, put } from "./requests"; +import { Referral } from "./units"; export type User = { _id: string; @@ -63,3 +64,13 @@ export async function demoteUser(user: User): Promise> { return handleAPIError(error); } } + +export async function getReferralsForUser(user: User): Promise> { + try { + const response = await get(`/users/${user._id}/referrals`); + const json = (await response.json()) as Referral[]; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/frontend/src/components/FilterDropdown.tsx b/frontend/src/components/FilterDropdown.tsx index 8bcf259..071e981 100644 --- a/frontend/src/components/FilterDropdown.tsx +++ b/frontend/src/components/FilterDropdown.tsx @@ -25,7 +25,7 @@ const FiltersFirstRow = styled.div` flex-wrap: wrap; `; -const SearchBarInput = styled.input` +export const SearchBarInput = styled.input` padding: 3px; min-width: 16rem; border: 0; @@ -46,12 +46,12 @@ const SearchBarInput = styled.input` } `; -const SearchIcon = styled.img` +export const SearchIcon = styled.img` height: 20px; width: 20px; `; -const SearchBarContainer = styled.div` +export const SearchBarContainer = styled.div` display: flex; flex-direction: row; justify-content: flex-start; diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index 707b6d2..9a08c66 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -132,7 +132,7 @@ const ButtonsWrapper = styled.div` `; type NavBarProps = { - page?: "Home" | "Profile"; + page?: "Home" | "Profile" | "Referrals"; }; export function NavBar({ page }: NavBarProps) { @@ -163,6 +163,9 @@ export function NavBar({ page }: NavBarProps) { Home + + Referrals + Profile diff --git a/frontend/src/components/ReferralPopup.tsx b/frontend/src/components/ReferralPopup.tsx index e2e3112..084340e 100644 --- a/frontend/src/components/ReferralPopup.tsx +++ b/frontend/src/components/ReferralPopup.tsx @@ -180,11 +180,13 @@ type PopupProps = { active: boolean; onClose: () => void; onSubmit: () => void; + newCandidateOnly?: boolean; // only add a new renter candidate }; -export const ReferralPopup = ({ active, onClose, onSubmit }: PopupProps) => { +export const ReferralPopup = ({ active, onClose, onSubmit, newCandidateOnly }: PopupProps) => { const [popup, setPopup] = useState(false); - const [addRC, setAddRC] = useState(false); + console.log(newCandidateOnly); + const [addRC, setAddRC] = useState(newCandidateOnly ?? false); const [errorMsg, setErrorMsg] = useState(""); const [currentRC, setCurrentRC] = useState(); const [allRCs, setAllRCs] = useState([]); @@ -232,9 +234,13 @@ export const ReferralPopup = ({ active, onClose, onSubmit }: PopupProps) => { createRenterCandidate(data as CreateRenterCandidateRequest) .then((value) => { if (value.success) { - handleCreateReferral(value.data._id); reset(); - setAddRC(false); + if (newCandidateOnly) { + onClose(); + } else { + handleCreateReferral(value.data._id); + setAddRC(false); + } setErrorMsg(""); } else { if (value.error.includes("email")) { @@ -267,7 +273,7 @@ export const ReferralPopup = ({ active, onClose, onSubmit }: PopupProps) => { -

Add Referral

+ {newCandidateOnly ?

Add New Renter Candidate

:

Add Referral

} {!addRC ? (
Choose existing renter candidate:
@@ -364,16 +370,26 @@ export const ReferralPopup = ({ active, onClose, onSubmit }: PopupProps) => { /> - - + {newCandidateOnly ? ( + + ) : ( + + )} + + {errorMsg} diff --git a/frontend/src/components/TablePagination.tsx b/frontend/src/components/TablePagination.tsx index d54c9f7..7a496c0 100644 --- a/frontend/src/components/TablePagination.tsx +++ b/frontend/src/components/TablePagination.tsx @@ -53,7 +53,7 @@ type ReferralTablePaginationProps = { currPage: number; setPageNumber: (newPageNumber: number) => void; }; - +// TODO: Delete this file export const TablePagination = (props: ReferralTablePaginationProps) => { const [activePageNumber, setActivePageNumber] = useState(props.currPage); const handleClick = (increase: boolean): void => { diff --git a/frontend/src/components/helpers.ts b/frontend/src/components/helpers.ts index 35e47aa..ac85a9f 100644 --- a/frontend/src/components/helpers.ts +++ b/frontend/src/components/helpers.ts @@ -2,3 +2,9 @@ export const formatDate = (date: string): string => { const dateObj = new Date(date); return dateObj.toLocaleDateString("en-US"); }; + +export const formatPhoneNumber = (phoneNumber: string | undefined): string => { + if (!phoneNumber) return ""; + const phone = phoneNumber.match(/\d+/g)?.join(""); + return `(${phone?.substring(0, 3)}) ${phone?.substring(3, 6)}-${phone?.substring(6)}`; +}; diff --git a/frontend/src/pages/Referrals.tsx b/frontend/src/pages/Referrals.tsx new file mode 100644 index 0000000..0809ecc --- /dev/null +++ b/frontend/src/pages/Referrals.tsx @@ -0,0 +1,460 @@ +import { useContext, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import styled from "styled-components"; + +import { deleteReferral, getAllReferrals } from "@/api/referrals"; +import { Referral } from "@/api/units"; +import { getReferralsForUser } from "@/api/users"; +import { Button } from "@/components/Button"; +import { CheckboxRadioText } from "@/components/FilterCommon"; +import { SearchBarContainer, SearchBarInput, SearchIcon } from "@/components/FilterDropdown"; +import { CustomCheckboxRadio } from "@/components/ListingForm/CommonStyles"; +import { NavBar } from "@/components/NavBar"; +import { Page } from "@/components/Page"; +import { ReferralPopup } from "@/components/ReferralPopup"; +import { Table, TableCellContent } from "@/components/Table"; +import { formatPhoneNumber } from "@/components/helpers"; +import { DataContext } from "@/contexts/DataContext"; + +const TABLE_COLUMN_NAMES = [ + "Name", + "Email", + "Phone Number", + "ID", + "Referring Staff", + "Status", + "View", + "Delete", +]; +const ENTRIES_PER_PAGE = 6; + +const Content = styled.div` + display: flex; + flex-direction: column; + gap: 70px; + padding: 50px 96px; +`; + +const HeaderText = styled.h1` + color: black; + font-size: 32px; + font-weight: 700; + line-height: 48px; + letter-spacing: 0.64px; + word-wrap: break-word; +`; + +const TopRow = styled.div` + display: flex; + flex-direction: row; + gap: 16px; + justify-content: space-between; +`; + +const AddButton = styled(Button)` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const FilterContainer = styled.div` + display: flex; + flex-direction: row; + gap: 27px; +`; + +const RadioGroup = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +const Radio = styled(CustomCheckboxRadio)` + width: 32px; + height: 32px; +`; + +const RadioLabel = styled(CheckboxRadioText)` + font-size: 20px; +`; + +const DeleteIcon = styled.img` + align-items: center; + width: 20px; + height: 22px; + + cursor: pointer; + transition: filter 0.3s; + + &:hover { + filter: brightness(1.4); + } +`; + +const ViewButton = styled(Link)` + align-items: center; + width: 88px; + height: 40 px; + + padding: 8px 24px 8px 24px; + gap: 12px; + border-radius: 12px; + border: 1px solid #b64201; + + font-family: Montserrat; + font-size: 16px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.02em; + color: #b64201; + text-decoration: none; + + &:hover { + background: #ec85371a; + } +`; + +const Overlay = styled.div` + width: 100vw; + height: 100vh; + top: 0; + left: 0; + right: 0; + bottom: 0; + position: fixed; + background: rgba(0, 0, 0, 0.25); + z-index: 2; +`; + +const Modal = styled.div` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 612px; + height: 360px; + border-radius: 20px; + background: #fff; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 50px; + z-index: 2; +`; + +const HeadingWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 25px; + font-weight: 600; + font-size: 20px; + line-height: 30px; +`; + +const WarningMessageWrapper = styled.div` + display: inline; + align-items: center; + margin-left: 15%; + margin-right: 15%; + text-align: center; + margin-bottom: 0; +`; + +const ConfirmDelete = styled(Button)` + border-radius: 8px; + padding: 10px 24px; + font-size: 16px; + width: 117px; + height: 40px; + border-radius: 12px; +`; + +const ButtonsWrapper = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + margin-left: 15%; + margin-right: 15%; + gap: 240px; +`; + +const XWrapper = styled.div` + width: 100%; + display: flex; + flex-direction: row; + justify-content: flex-end; + padding: 10px 27px; + font-size: 30px; +`; + +const XButton = styled.div` + &:hover { + cursor: pointer; + } + height: 10px; + width: 10px; +`; + +const DoneMessageHeader = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 28px; + font-size: 20px; + line-height: 30px; +`; + +const DoneMessageHeaderWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 25px; +`; + +enum ReferralFilterOption { + MY_REFERRALS = "My Referrals", + ALL_REFERRALS = "All Referrals", +} + +export function Referrals() { + const dataContext = useContext(DataContext); + + const [referrals, setReferrals] = useState([]); + const [filteredReferrals, setFilteredReferrals] = useState([]); + const [filterMode, setFilterMode] = useState(ReferralFilterOption.MY_REFERRALS); + const [searchValue, setSearchValue] = useState(""); + const [selectedReferral, setSelectedReferral] = useState(null); + const [popup, setPopup] = useState(false); + const [showNewClientPopup, setShowNewClientPopup] = useState(false); + const [successfulRemovalPopup, setSuccessfulRemovalPopup] = useState(false); + + const fetchReferrals = () => { + if (filterMode === ReferralFilterOption.MY_REFERRALS) { + if (dataContext.currentUser) { + getReferralsForUser(dataContext.currentUser) + .then((res) => { + if (res.success) { + setReferrals(res.data); + } + }) + .catch(console.log); + } + } else { + getAllReferrals() + .then((res) => { + if (res.success) { + setReferrals(res.data); + } + }) + .catch(console.log); + } + }; + + useEffect(() => { + setFilteredReferrals( + referrals + .filter((referral) => { + return ( + referral.renterCandidate.firstName.toLowerCase().includes(searchValue.toLowerCase()) || + referral.renterCandidate.lastName.toLowerCase().includes(searchValue.toLowerCase()) + ); + }) + .sort((a, b) => a.renterCandidate.firstName.localeCompare(b.renterCandidate.firstName)), + ); + }, [referrals, searchValue]); + + useEffect(fetchReferrals, [filterMode, dataContext.currentUser]); + + const handleDelete = (referral: Referral) => { + deleteReferral(referral._id) + .then((response) => { + if (response.success) { + fetchReferrals(); + setPopup(false); + setSuccessfulRemovalPopup(true); + } + }) + .catch((error) => { + console.log(error); + }); + }; + + return ( + + + + + Referrals + + + { + setFilterMode(ReferralFilterOption.MY_REFERRALS); + }} + /> + My Referrals + + + { + setFilterMode(ReferralFilterOption.ALL_REFERRALS); + }} + /> + All Referrals + + + { + setSearchValue(e.target.value); + }} + /> + + + { + setShowNewClientPopup(true); + }} + > + + Add Client + + + + { + const { renterCandidate, assignedReferringStaff, status } = referral; + + return [ + renterCandidate.firstName + " " + renterCandidate.lastName, + renterCandidate.email, + formatPhoneNumber(renterCandidate.phone), + renterCandidate.uid, + assignedReferringStaff.firstName + " " + assignedReferringStaff.lastName, + status, + + View + , + { + if (referral !== null) { + setSelectedReferral(referral); + setPopup(true); + } + }} + />, + ] as TableCellContent[]; + }) + : [] + } + rowsPerPage={ENTRIES_PER_PAGE} + /> + { + setShowNewClientPopup(false); + }} + onSubmit={() => { + // + }} + newCandidateOnly + /> + {popup && selectedReferral && ( + <> + + + + { + setPopup(false); + }} + > + × + + + Remove Referral + + Are you sure you want to remove this referral for{" "} + + {selectedReferral.renterCandidate.firstName}{" "} + {selectedReferral.renterCandidate.lastName} + + ? + + + { + setPopup(false); + }} + > + Cancel + + + { + handleDelete(selectedReferral); + }} + > + Remove + + + + + )} + {successfulRemovalPopup && ( + <> + + + + { + setSuccessfulRemovalPopup(false); + }} + > + × + + + + + Complete + + The referral for {selectedReferral?.renterCandidate.firstName}{" "} + {selectedReferral?.renterCandidate.lastName} has been removed. + + + + { + setSuccessfulRemovalPopup(false); + }} + > + Done + + + + + + )} + + + ); +} diff --git a/frontend/src/pages/UnitDetails.tsx b/frontend/src/pages/UnitDetails.tsx index d5820f1..94c18e0 100644 --- a/frontend/src/pages/UnitDetails.tsx +++ b/frontend/src/pages/UnitDetails.tsx @@ -16,6 +16,7 @@ import { HousingLocatorFields } from "@/components/ListingForm/HousingLocatorFie import { ListingFormComponents } from "@/components/ListingFormComponents"; import { NavBar } from "@/components/NavBar"; import { ReferralTable } from "@/components/ReferralTable"; +import { formatPhoneNumber } from "@/components/helpers"; import { DataContext } from "@/contexts/DataContext"; import "react-responsive-carousel/lib/styles/carousel.min.css"; @@ -537,15 +538,11 @@ export function UnitDetails() { {rule} )); - const phone = unit.landlordPhone.match(/\d+/g)?.join(""); - const HousingLocatorComponent = () => { return ( Landlord: {unit.landlordFirstName + " " + unit.landlordLastName} - {`(${phone?.substring(0, 3)}) ${phone?.substring(3, 6)}-${phone?.substring( - 6, - )}`} + {formatPhoneNumber(unit.landlordPhone)} {unit.landlordEmail} ); diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 2556b46..00a6733 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -5,3 +5,4 @@ export { Profile } from "./Profile"; export { HousingLocatorForm } from "./HousingLocatorForm"; export { LandlordListingForm } from "./LandlordListingForm"; export { RenterCandidatePage } from "./RenterCandidatePage"; +export { Referrals } from "./Referrals";