From 175c655643970ac9ff4db2f745fc3e9db0559cf7 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Sun, 21 Sep 2025 23:03:30 -0400 Subject: [PATCH 01/10] Added data tab with apartment data in admin page --- frontend/src/pages/AdminPage.tsx | 81 ++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 0584489d..dfe73fdf 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -17,6 +17,7 @@ import { CantFindApartmentFormWithId, QuestionFormWithId, ReviewWithId, + ApartmentWithId, } from '../../../common/types/db-types'; import { get } from '../utils/call'; import AdminReviewComponent from '../components/Admin/AdminReview'; @@ -81,6 +82,12 @@ const AdminPage = (): ReactElement => { const [declinedExpanded, setDeclinedExpanded] = useState(true); const [reportedData, setReportedData] = useState([]); const [reportedExpanded, setReportedExpanded] = useState(true); + const [apartments, setApartments] = useState([]); + + // Debug apartments state changes + useEffect(() => { + console.log('Apartments loaded:', apartments.length); + }, [apartments]); const { container, sectionHeader, expand, expandOpen } = useStyles(); const { carouselPhotos, @@ -154,6 +161,19 @@ const AdminPage = (): ReactElement => { }); }, [toggle]); + // Load all apartments data + useEffect(() => { + get(`/api/page-data/home/1000/numReviews`, { + callback: (data) => { + if (data && data.buildingData && Array.isArray(data.buildingData)) { + setApartments(data.buildingData); + } else { + console.error('Failed to load apartments data'); + } + }, + }); + }, []); + const Modals = ( <> { ); + // Data tab + const data = ( + + + + + All Apartments ({apartments.length}) + + + {apartments.map((apartment, index) => ( + + + {apartment.buildingData?.name || 'N/A'} + + } + secondary={ +
+ + Address: {apartment.buildingData?.address || 'N/A'} + + + Location: {apartment.buildingData?.area || 'N/A'} + + + Bedrooms: {apartment.buildingData?.numBeds || 'N/A'} + + + Bathrooms: {apartment.buildingData?.numBaths || 'N/A'} + + + Company: {apartment.company || 'N/A'} + + + Reviews: {apartment.numReviews || 0} + + + Avg Rating:{' '} + {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} + + + Avg Price:{' '} + {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} + +
+ } + /> +
+ ))} +
+
+
+
+ ); + return (
@@ -358,12 +437,14 @@ const AdminPage = (): ReactElement => { > + {selectedTab === 'Reviews' && reviews} {selectedTab === 'Contact' && contact} + {selectedTab === 'Data' && data} {Modals}
); From fca6e1ce9f7c9ec88a13e124f7d69f15be75c9ac Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Sun, 21 Sep 2025 23:34:11 -0400 Subject: [PATCH 02/10] doc for data tab --- frontend/src/pages/AdminPage.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index dfe73fdf..2f33b895 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -57,11 +57,15 @@ const useStyles = makeStyles((theme) => ({ /** * AdminPage Component * - * This component represents a page that only authorized admins can view. The page displays information about reviews, allows admins - * to approve or decline reviews. The page also displays contact information from the contact modals ("Can't Find Your Apartment" form - * and "Ask Us a Question" form). + * This component represents a page that only authorized admins can view. The page has three main tabs: * - * @returns The rendered AdminPage component. + * 1. Reviews - Displays review information and allows admins to approve/decline reviews + * 2. Contact - Shows contact form submissions ("Can't Find Your Apartment" and "Ask Us a Question") + * 3. Data - Displays a comprehensive list of all apartments with their key details + * + * Admins can manage content, respond to user inquiries, and view apartment data all in one place. + * + * @returns The rendered AdminPage component with tabbed navigation between Reviews, Contact, and Data sections. */ const AdminPage = (): ReactElement => { const [selectedTab, setSelectedTab] = useState('Reviews'); From 1f98ab212efd3000301923130cc587943d898103 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Wed, 24 Sep 2025 19:35:26 -0400 Subject: [PATCH 03/10] add export apartment name script --- backend/package.json | 1 + backend/scripts/export_apartment_names.ts | 89 +++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 backend/scripts/export_apartment_names.ts diff --git a/backend/package.json b/backend/package.json index e68559a6..aed810fe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,7 @@ "add_buildings": "ts-node scripts/add_buildings.ts", "add_landlords": "ts-node scripts/add_landlords.ts", "add_reviews": "ts-node scripts/add_reviews_nodups.ts", + "export_apartment_names": "ts-node scripts/export_apartment_names.ts", "build": "tsc", "tsc": "tsc", "start": "node dist/backend/src/server.js", diff --git a/backend/scripts/export_apartment_names.ts b/backend/scripts/export_apartment_names.ts new file mode 100644 index 00000000..89b3cd84 --- /dev/null +++ b/backend/scripts/export_apartment_names.ts @@ -0,0 +1,89 @@ +import axios from 'axios'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Define the structure of the API response based on the AdminPage usage +interface ApartmentData { + buildingData: { + id: string; + name: string; + address: string; + area: string; + numBeds: number; + numBaths: number; + landlordId: string; + photos: string[]; + urlName: string; + latitude: number; + longitude: number; + distanceToCampus: number; + }; + numReviews: number; + company: string; + avgRating: number; + avgPrice: number; +} + +interface ApiResponse { + buildingData: ApartmentData[]; + isEnded: boolean; +} + +/** + * Script to export apartment names to a CSV file + * + * This script fetches all apartment data from the API endpoint used in AdminPage + * and exports just the apartment names to a CSV file. + */ +const exportApartmentNames = async () => { + try { + console.log('Fetching apartment data...'); + + // Use the same API endpoint as AdminPage + const response = await axios.get( + 'http://localhost:8080/api/page-data/home/1000/numReviews' + ); + + if (!response.data || !response.data.buildingData) { + throw new Error('No apartment data received from API'); + } + + const apartments = response.data.buildingData; + console.log(`Found ${apartments.length} apartments`); + + // Extract apartment names + const apartmentNames = apartments.map((apt) => apt.buildingData.name); + + // Create CSV content + const csvHeader = 'Apartment Name\n'; + const csvContent = apartmentNames.map((name) => `"${name}"`).join('\n'); + const fullCsvContent = csvHeader + csvContent; + + // Write to CSV file + const outputPath = path.join(__dirname, 'apartment_names.csv'); + fs.writeFileSync(outputPath, fullCsvContent, 'utf8'); + + console.log(`Successfully exported ${apartmentNames.length} apartment names to: ${outputPath}`); + console.log('First few apartment names:'); + apartmentNames.slice(0, 5).forEach((name, index) => { + console.log(`${index + 1}. ${name}`); + }); + } catch (error) { + console.error('Error exporting apartment names:', error); + + if (axios.isAxiosError(error)) { + if (error.code === 'ECONNREFUSED') { + console.error( + 'Could not connect to the server. Make sure the backend server is running on localhost:3000' + ); + } else { + console.error('API request failed:', error.message); + } + } + + process.exit(1); + } +}; + +// Run the script +exportApartmentNames(); From 590a533eaffc3bcb46b9dcdf3f85401083021491 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Sun, 5 Oct 2025 22:37:15 -0400 Subject: [PATCH 04/10] Implemented Admin Page Apartment Editing UI\ \ - Implemented apartment information edit endpoint - Implemented create new apartment endpoint - Implemented admin page apartment pagination, sorting, and editing functionalities --- backend/src/app.ts | 260 ++++++++++++++++++- frontend/src/pages/AdminPage.tsx | 420 +++++++++++++++++++++++++++---- 2 files changed, 629 insertions(+), 51 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index c6c84a95..53acf3e8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -34,6 +34,9 @@ import { admins } from '../../frontend/src/constants/HomeConsts'; const cuaptsEmail = process.env.CUAPTS_EMAIL; const cuaptsEmailPassword = process.env.CUAPTS_EMAIL_APP_PASSWORD; +// Google Maps API key +const { REACT_APP_MAPS_API_KEY } = process.env; + // Collections in the Firestore database const reviewCollection = db.collection('reviews'); const landlordCollection = db.collection('landlords'); @@ -495,10 +498,10 @@ app.get('/api/search-with-query-and-filters', async (req, res) => { // Extract all query parameters const query = req.query.q as string; const locations = req.query.locations as string; - const minPrice = req.query.minPrice ? parseInt(req.query.minPrice as string, 10) : null; - const maxPrice = req.query.maxPrice ? parseInt(req.query.maxPrice as string, 10) : null; - const bedrooms = req.query.bedrooms ? parseInt(req.query.bedrooms as string, 10) : null; - const bathrooms = req.query.bathrooms ? parseInt(req.query.bathrooms as string, 10) : null; + // const minPrice = req.query.minPrice ? parseInt(req.query.minPrice as string, 10) : null; + // const maxPrice = req.query.maxPrice ? parseInt(req.query.maxPrice as string, 10) : null; + // const bedrooms = req.query.bedrooms ? parseInt(req.query.bedrooms as string, 10) : null; + // const bathrooms = req.query.bathrooms ? parseInt(req.query.bathrooms as string, 10) : null; const size = req.query.size ? parseInt(req.query.size as string, 10) : null; const sortBy = req.query.sortBy || 'numReviews'; @@ -1302,6 +1305,254 @@ app.post('/api/add-pending-building', authenticate, async (req, res) => { } }); +/** + * Update Apartment Information - Updates the information of an existing apartment. + * + * @remarks + * This endpoint allows admins to update apartment information including name, address, + * landlord, amenities, photos, and other details. Only admins can access this endpoint. + * + * @route PUT /api/admin/update-apartment/:apartmentId + * + * @input {string} req.params.apartmentId - The ID of the apartment to update + * @input {Partial} req.body - The updated apartment information + * + * @status + * - 200: Successfully updated apartment information + * - 400: Invalid apartment data or missing required fields + * - 401: Authentication failed + * - 403: Unauthorized - Admin access required + * - 404: Apartment not found + * - 500: Server error while updating apartment + */ +app.put('/api/admin/update-apartment/:apartmentId', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + + const { email } = req.user; + const isAdmin = email && admins.includes(email); + + if (!isAdmin) { + res.status(403).send('Unauthorized: Admin access required'); + return; + } + + try { + const { apartmentId } = req.params; + const updatedApartmentData = req.body as Partial; + + // Check if apartment exists + const apartmentDoc = buildingsCollection.doc(apartmentId); + const apartmentSnapshot = await apartmentDoc.get(); + + if (!apartmentSnapshot.exists) { + res.status(404).send('Apartment not found'); + return; + } + + // Validate required fields if they're being updated + if (updatedApartmentData.name !== undefined && updatedApartmentData.name === '') { + res.status(400).send('Error: Apartment name cannot be empty'); + return; + } + + if (updatedApartmentData.address !== undefined && updatedApartmentData.address === '') { + res.status(400).send('Error: Apartment address cannot be empty'); + return; + } + + // Update the apartment document + await apartmentDoc.update(updatedApartmentData); + + res.status(200).send('Apartment updated successfully'); + } catch (err) { + console.error(err); + res.status(500).send('Error updating apartment'); + } +}); + +/** + * geocodeAddress – Converts an address to latitude and longitude coordinates using Google Geocoding API. + * + * @remarks + * Makes an HTTP request to the Google Geocoding API to convert an address string into + * precise latitude and longitude coordinates. + * + * @param {string} address - The address to geocode + * @return {Promise<{latitude: number, longitude: number}>} - Object containing latitude and longitude + */ +async function geocodeAddress(address: string): Promise<{ latitude: number; longitude: number }> { + const response = await axios.get( + `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent( + address + )}&key=${REACT_APP_MAPS_API_KEY}` + ); + + if (response.data.status !== 'OK' || !response.data.results.length) { + throw new Error('Geocoding failed: Invalid address or no results found'); + } + + const {location} = response.data.results[0].geometry; + return { + latitude: location.lat, + longitude: location.lng, + }; +} + +/** + * Add New Apartment - Creates a new apartment with duplicate checking and distance calculation. + * + * @remarks + * This endpoint allows admins to create new apartments. It includes a multi-step process: + * 1. Validates input data and checks for existing apartments at the same coordinates + * 2. Calculates latitude/longitude and distance to campus using Google Maps API + * 3. Returns preliminary data for admin confirmation + * 4. Creates the apartment in the database after confirmation + * + * @route POST /api/admin/add-apartment + * + * @input {object} req.body - Apartment creation data containing: + * - name: string (required) + * - address: string (required) + * - landlordId: string (required) + * - numBaths: number (optional) + * - numBeds: number (optional) + * - photos: string[] (optional) + * - area: 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER' (required) + * - confirm: boolean (optional) - if true, creates the apartment; if false, returns preliminary data + * + * @status + * - 200: Successfully created apartment (when confirm=true) + * - 201: Preliminary data returned for confirmation (when confirm=false) + * - 400: Invalid apartment data, missing required fields, or duplicate apartment found + * - 401: Authentication failed + * - 403: Unauthorized - Admin access required + * - 500: Server error while processing request + */ +app.post('/api/admin/add-apartment', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + + const { email } = req.user; + const isAdmin = email && admins.includes(email); + + if (!isAdmin) { + res.status(403).send('Unauthorized: Admin access required'); + return; + } + + try { + const { + name, + address, + landlordId, + numBaths, + numBeds, + photos = [], + area, + confirm = false, + } = req.body; + + // Validate required fields + if (!name || !address || !landlordId || !area) { + res.status(400).send('Error: Missing required fields (name, address, landlordId, area)'); + return; + } + + // Validate area is one of the allowed values + const validAreas = ['COLLEGETOWN', 'WEST', 'NORTH', 'DOWNTOWN', 'OTHER']; + if (!validAreas.includes(area)) { + res + .status(400) + .send('Error: Invalid area. Must be one of: COLLEGETOWN, WEST, NORTH, DOWNTOWN, OTHER'); + return; + } + + // Check if landlord exists + const landlordDoc = landlordCollection.doc(landlordId); + const landlordSnapshot = await landlordDoc.get(); + if (!landlordSnapshot.exists) { + res.status(400).send('Error: Landlord not found'); + return; + } + + // Geocode the address to get coordinates + const coordinates = await geocodeAddress(address); + const { latitude, longitude } = coordinates; + + // Calculate travel times using the coordinates + const { protocol } = req; + const host = req.get('host'); + const baseUrl = `${protocol}://${host}`; + + const travelTimesResponse = await axios.post(`${baseUrl}/api/calculate-travel-times`, { + origin: `${latitude},${longitude}`, + }); + + const travelTimes = travelTimesResponse.data; + const distanceToCampus = travelTimes.hoPlazaWalking; + + // Check for existing apartments at the same coordinates (with small tolerance) + const tolerance = 0.0001; // Approximately 11 meters + const existingApartments = await buildingsCollection + .where('latitude', '>=', latitude - tolerance) + .where('latitude', '<=', latitude + tolerance) + .where('longitude', '>=', longitude - tolerance) + .where('longitude', '<=', longitude + tolerance) + .get(); + + if (!existingApartments.empty) { + res.status(400).send('Error: An apartment already exists at this location'); + return; + } + + // Prepare apartment data + const apartmentData: Apartment = { + name, + address, + landlordId, + numBaths: numBaths || null, + numBeds: numBeds || null, + photos, + area, + latitude, + longitude, + price: 0, // Default price, can be updated later + distanceToCampus, + }; + + if (!confirm) { + // Return preliminary data for admin confirmation + res.status(201).json({ + message: 'Preliminary data calculated. Please confirm to create apartment.', + apartmentData, + travelTimes, + coordinates: { latitude, longitude }, + }); + return; + } + + // Create the apartment in the database + const apartmentDoc = buildingsCollection.doc(); + await apartmentDoc.set(apartmentData); + + // Update landlord's properties list + const landlordData = landlordSnapshot.data(); + const updatedProperties = [...(landlordData?.properties || []), apartmentDoc.id]; + await landlordDoc.update({ properties: updatedProperties }); + + // Store travel times for the new apartment + await travelTimesCollection.doc(apartmentDoc.id).set(travelTimes); + + res.status(200).json({ + message: 'Apartment created successfully', + apartmentId: apartmentDoc.id, + apartmentData, + }); + } catch (err) { + console.error(err); + res.status(500).send('Error creating apartment'); + } +}); + /** * Update Pending Building Status - Updates the status of a pending building report. * @@ -1427,7 +1678,6 @@ app.put( } ); -const { REACT_APP_MAPS_API_KEY } = process.env; const LANDMARKS = { eng_quad: '42.4445,-76.4836', // Duffield Hall ag_quad: '42.4489,-76.4780', // Mann Library diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 2f33b895..0ac223c6 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -12,6 +12,10 @@ import { Tabs, Tab, IconButton, + Button, + TextField, + Box, + Fab, } from '@material-ui/core'; import { CantFindApartmentFormWithId, @@ -25,12 +29,18 @@ import { useTitle } from '../utils'; import { Chart } from 'react-google-charts'; import { sortReviews } from '../utils/sortReviews'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import EditIcon from '@material-ui/icons/Edit'; +import SaveIcon from '@material-ui/icons/Save'; +import CancelIcon from '@material-ui/icons/Cancel'; +import SortIcon from '@material-ui/icons/Sort'; import clsx from 'clsx'; import { colors } from '../colors'; import PhotoCarousel from '../components/PhotoCarousel/PhotoCarousel'; import usePhotoCarousel from '../components/PhotoCarousel/usePhotoCarousel'; import AdminCantFindApt from '../components/Admin/AdminCantFindApt'; import AdminContactQuestion from '../components/Admin/AdminContactQuestion'; +import axios from 'axios'; +import { createAuthHeaders, getUser } from '../utils/firebase'; const useStyles = makeStyles((theme) => ({ container: { @@ -52,6 +62,34 @@ const useStyles = makeStyles((theme) => ({ expandOpen: { transform: 'rotate(180deg)', }, + headerContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: theme.spacing(3), + }, + sortButton: { + marginLeft: theme.spacing(2), + }, + editField: { + marginBottom: theme.spacing(1), + width: '100%', + }, + editButtons: { + display: 'flex', + gap: theme.spacing(1), + marginTop: theme.spacing(1), + }, + apartmentCard: { + borderBottom: '1px solid #e0e0e0', + padding: '15px 0', + position: 'relative', + }, + editButton: { + position: 'absolute', + top: theme.spacing(1), + right: theme.spacing(1), + }, })); /** @@ -88,11 +126,35 @@ const AdminPage = (): ReactElement => { const [reportedExpanded, setReportedExpanded] = useState(true); const [apartments, setApartments] = useState([]); + // Sorting and editing state + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const [editingApartment, setEditingApartment] = useState(null); + const [editFormData, setEditFormData] = useState<{ + numBeds: number | null; + numBaths: number | null; + price: number; + }>({ numBeds: null, numBaths: null, price: 0 }); + + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const apartmentsPerPage = 50; + // Debug apartments state changes useEffect(() => { console.log('Apartments loaded:', apartments.length); }, [apartments]); - const { container, sectionHeader, expand, expandOpen } = useStyles(); + const { + container, + sectionHeader, + expand, + expandOpen, + headerContainer, + sortButton, + editField, + editButtons, + apartmentCard, + editButton, + } = useStyles(); const { carouselPhotos, carouselStartIndex, @@ -103,6 +165,146 @@ const AdminPage = (): ReactElement => { useTitle('Admin'); + // Helper functions for sorting and editing + const toggleSortOrder = () => { + setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); + }; + + // First, sort all apartments by ID to get the correct page ranges + const allApartmentsSorted = [...apartments].sort((a, b) => { + const aId = a.buildingData?.id || ''; + const bId = b.buildingData?.id || ''; + const aNum = parseInt(aId, 10) || 0; + const bNum = parseInt(bId, 10) || 0; + return aNum - bNum; // Always sort ascending to get correct page ranges + }); + + // Filter test apartments (IDs > 1000) + const testApartments = allApartmentsSorted.filter((apt) => { + const id = parseInt(apt.buildingData?.id || '0', 10); + return id > 1000; + }); + + // Determine if we're showing test apartments or regular apartments + const isTestPage = currentPage > Math.ceil(allApartmentsSorted.length / apartmentsPerPage); + const apartmentsToShow = isTestPage ? testApartments : allApartmentsSorted; + + // Pagination logic - get the correct apartments for the current page + const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); + const testPages = Math.ceil(testApartments.length / apartmentsPerPage); + const totalPages = regularPages + (testPages > 0 ? 1 : 0); // +1 for test apartments page + + const startIndex = isTestPage ? 0 : (currentPage - 1) * apartmentsPerPage; + const endIndex = isTestPage ? apartmentsPerPage : startIndex + apartmentsPerPage; + const pageApartments = apartmentsToShow.slice(startIndex, endIndex); + + // Then sort within the page based on the sort order + const currentPageApartments = [...pageApartments].sort((a, b) => { + const aId = a.buildingData?.id || ''; + const bId = b.buildingData?.id || ''; + const aNum = parseInt(aId, 10) || 0; + const bNum = parseInt(bId, 10) || 0; + + if (sortOrder === 'asc') { + return aNum - bNum; + } else { + return bNum - aNum; + } + }); + + const handleEditClick = (apartment: any) => { + setEditingApartment(apartment.buildingData?.id); + setEditFormData({ + numBeds: apartment.buildingData?.numBeds || null, + numBaths: apartment.buildingData?.numBaths || null, + price: apartment.avgPrice || 0, + }); + }; + + const handleCancelEdit = () => { + setEditingApartment(null); + setEditFormData({ numBeds: null, numBaths: null, price: 0 }); + }; + + const handleSaveEdit = async (apartmentId: string) => { + try { + const user = await getUser(); + if (!user) { + alert('You must be logged in to edit apartments'); + return; + } + + const token = await user.getIdToken(true); + const updateData: any = {}; + + if (editFormData.numBeds !== null) { + updateData.numBeds = editFormData.numBeds; + } + if (editFormData.numBaths !== null) { + updateData.numBaths = editFormData.numBaths; + } + if (editFormData.price !== 0) { + updateData.price = editFormData.price; + } + + await axios.put( + `/api/admin/update-apartment/${apartmentId}`, + updateData, + createAuthHeaders(token) + ); + + // Update local state + setApartments((prev) => + prev.map((apt) => { + if (apt.buildingData?.id === apartmentId) { + return { + ...apt, + buildingData: { + ...apt.buildingData, + numBeds: editFormData.numBeds, + numBaths: editFormData.numBaths, + }, + avgPrice: editFormData.price, + }; + } + return apt; + }) + ); + + setEditingApartment(null); + setEditFormData({ numBeds: null, numBaths: null, price: 0 }); + } catch (error) { + console.error('Error updating apartment:', error); + alert('Failed to update apartment. Please try again.'); + } + }; + + const handleInputChange = (field: 'numBeds' | 'numBaths' | 'price', value: string) => { + const numValue = field === 'price' ? parseFloat(value) || 0 : parseInt(value) || null; + setEditFormData((prev) => ({ + ...prev, + [field]: numValue, + })); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const getPageRange = (page: number) => { + const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); + + if (page > regularPages) { + // This is the test apartments page + return `Test (${testApartments.length})`; + } else { + // Regular page + const start = (page - 1) * apartmentsPerPage + 1; + const end = Math.min(page * apartmentsPerPage, allApartmentsSorted.length); + return `${start}-${end}`; + } + }; + // calls the APIs and the callback function to set the reviews for each review type useEffect(() => { const reviewTypes = new Map>>([ @@ -375,54 +577,180 @@ const AdminPage = (): ReactElement => { - - All Apartments ({apartments.length}) - - - {apartments.map((apartment, index) => ( - +
+ + All Apartments ({apartments.length}) + + + Sorted within page: {sortOrder === 'asc' ? 'Ascending' : 'Descending'} | + {isTestPage ? ( + <>Showing: Test Apartments ({testApartments.length}) + ) : ( + <> + Showing: {getPageRange(currentPage)} of {apartments.length} + + )} + +
+
+ +
+ + + {/* Pagination Buttons */} +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { + const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); + const isTestPageButton = page > regularPages; + + return ( + + ); + })} +
+ + {currentPageApartments.map((apartment, index) => { + const isEditing = editingApartment === apartment.buildingData?.id; + + return ( + + {!isEditing && ( + handleEditClick(apartment)} + size="small" + title="Edit apartment" + > + + + )} + + + {apartment.buildingData?.name || 'N/A'} - - } - /> - - ))} + } + secondary={ +
+ + Apartment ID: {apartment.buildingData?.id || 'N/A'} + + + Address: {apartment.buildingData?.address || 'N/A'} + + + Location: {apartment.buildingData?.area || 'N/A'} + + + {isEditing ? ( +
+ handleInputChange('numBeds', e.target.value)} + size="small" + /> + handleInputChange('numBaths', e.target.value)} + size="small" + /> + handleInputChange('price', e.target.value)} + size="small" + /> + + + + +
+ ) : ( +
+ + Bedrooms: {apartment.buildingData?.numBeds || 'N/A'} + + + Bathrooms:{' '} + {apartment.buildingData?.numBaths || 'N/A'} + + + Company: {apartment.company || 'N/A'} + + + Reviews: {apartment.numReviews || 0} + + + Avg Rating:{' '} + {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} + + + Avg Price:{' '} + {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} + +
+ )} +
+ } + /> +
+ ); + })}
From f5f8c653f2d4e18e580e5b6b11576942d79eff56 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 00:13:39 -0500 Subject: [PATCH 05/10] Refactor apartment schema to support multiple room types Backend Changes: - Add RoomType interface with UUID-based IDs (beds, baths, price) - Replace single numBeds/numBaths/price fields with roomTypes array - Add migration endpoint POST /api/admin/migrate-all-apartments-schema - Supports dry run mode for preview - Batch processing (100 apartments per batch) - Initializes roomTypes as empty array and removes old fields - Update PUT /api/admin/update-apartment to handle roomTypes - Generate UUIDs for new room types - Validate beds/baths/price >= 1 (integers only) - Check for duplicate room type combinations - Update POST /api/admin/add-apartment to accept roomTypes - Same validation and UUID generation - Allow empty roomTypes array - Update add_buildings.ts script to use new schema Frontend Changes (Temporary): - Comment out old bed/bath/price displays in ApartmentCard - Comment out bed display in NewApartmentCard - Update price sorting to use avgPrice temporarily - Proper room type display will be implemented in future commits - Proper room type sorting will be implemented in future commits Note: All apartments will start with empty roomTypes after migration. Frontend integer validation for admin UI will be added in following commits. --- backend/scripts/add_buildings.ts | 4 +- backend/src/app.ts | 282 +++++++++++++++++- common/types/db-types.ts | 11 +- .../ApartmentCard/ApartmentCard.tsx | 5 +- .../ApartmentCard/NewApartmentCard.tsx | 9 +- frontend/src/pages/SearchResultsPage.tsx | 6 +- 6 files changed, 294 insertions(+), 23 deletions(-) diff --git a/backend/scripts/add_buildings.ts b/backend/scripts/add_buildings.ts index a40cf380..11657eb8 100644 --- a/backend/scripts/add_buildings.ts +++ b/backend/scripts/add_buildings.ts @@ -43,13 +43,11 @@ const formatBuilding = ({ name, address, landlordId: landlordId.toString(), - numBaths: 0, - numBeds: 0, + roomTypes: [], // Initialize with empty room types photos: [], area: getAreaType(area), latitude, longitude, - price: 0, distanceToCampus: 0, }); diff --git a/backend/src/app.ts b/backend/src/app.ts index 53acf3e8..5f8c3105 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,6 +2,7 @@ import express, { Express, RequestHandler } from 'express'; import cors from 'cors'; import Fuse from 'fuse.js'; import morgan from 'morgan'; +import { randomUUID } from 'crypto'; import { Review, Landlord, @@ -17,6 +18,7 @@ import { QuestionForm, QuestionFormWithId, LocationTravelTimes, + RoomType, } from '@common/types/db-types'; // Import Firebase configuration and types import { auth } from 'firebase-admin'; @@ -1360,10 +1362,75 @@ app.put('/api/admin/update-apartment/:apartmentId', authenticate, async (req, re return; } - // Update the apartment document - await apartmentDoc.update(updatedApartmentData); + // Validate and process roomTypes if provided + if (updatedApartmentData.roomTypes !== undefined) { + const roomTypes = updatedApartmentData.roomTypes as RoomType[]; - res.status(200).send('Apartment updated successfully'); + // Generate UUIDs for room types without IDs and validate + const processedRoomTypes: RoomType[] = []; + const seen = new Set(); + + // Validate each room type + const validationErrors = roomTypes + .map((rt: RoomType): string | null => { + // Validate beds, baths, price + if (!Number.isInteger(rt.beds) || rt.beds < 1) { + return 'Error: Beds must be an integer >= 1'; + } + if (!Number.isInteger(rt.baths) || rt.baths < 1) { + return 'Error: Baths must be an integer >= 1'; + } + if (!Number.isInteger(rt.price) || rt.price < 1) { + return 'Error: Price must be an integer >= 1'; + } + + // Check for duplicates using (beds, baths, price) combination + const key = `${rt.beds}-${rt.baths}-${rt.price}`; + if (seen.has(key)) { + return 'Duplicate room type exists'; + } + seen.add(key); + + // Generate UUID if not provided + const id = rt.id && rt.id.trim() !== '' ? rt.id : randomUUID(); + + processedRoomTypes.push({ + id, + beds: rt.beds, + baths: rt.baths, + price: rt.price, + }); + + return null; + }) + .filter((error: string | null): error is string => error !== null); + + if (validationErrors.length > 0) { + res.status(400).send(validationErrors[0]); + return; + } + + // Create updated data with processed room types + const dataToUpdate = { + ...updatedApartmentData, + roomTypes: processedRoomTypes, + }; + + // Update the apartment document + await apartmentDoc.update(dataToUpdate); + } else { + // Update the apartment document without roomTypes changes + await apartmentDoc.update(updatedApartmentData); + } + + // Fetch the updated apartment to return with generated UUIDs + const updatedSnapshot = await apartmentDoc.get(); + const updatedApartment = { id: updatedSnapshot.id, ...updatedSnapshot.data() }; + + res.status(200).json({ + message: 'Apartment updated successfully', + apartment: updatedApartment, + }); } catch (err) { console.error(err); res.status(500).send('Error updating apartment'); @@ -1391,7 +1458,7 @@ async function geocodeAddress(address: string): Promise<{ latitude: number; long throw new Error('Geocoding failed: Invalid address or no results found'); } - const {location} = response.data.results[0].geometry; + const { location } = response.data.results[0].geometry; return { latitude: location.lat, longitude: location.lng, @@ -1444,8 +1511,7 @@ app.post('/api/admin/add-apartment', authenticate, async (req, res) => { name, address, landlordId, - numBaths, - numBeds, + roomTypes = [], photos = [], area, confirm = false, @@ -1466,6 +1532,50 @@ app.post('/api/admin/add-apartment', authenticate, async (req, res) => { return; } + // Validate and process roomTypes if provided + const processedRoomTypes: RoomType[] = []; + if (roomTypes && roomTypes.length > 0) { + const seen = new Set(); + + // Validate each room type + const validationErrors = roomTypes + .map((rt: RoomType): string | null => { + // Validate beds, baths, price + if (!Number.isInteger(rt.beds) || rt.beds < 1) { + return 'Error: Beds must be an integer >= 1'; + } + if (!Number.isInteger(rt.baths) || rt.baths < 1) { + return 'Error: Baths must be an integer >= 1'; + } + if (!Number.isInteger(rt.price) || rt.price < 1) { + return 'Error: Price must be an integer >= 1'; + } + + // Check for duplicates using (beds, baths, price) combination + const key = `${rt.beds}-${rt.baths}-${rt.price}`; + if (seen.has(key)) { + return 'Duplicate room type exists'; + } + seen.add(key); + + // Generate UUID for each room type + processedRoomTypes.push({ + id: randomUUID(), + beds: rt.beds, + baths: rt.baths, + price: rt.price, + }); + + return null; + }) + .filter((error: string | null): error is string => error !== null); + + if (validationErrors.length > 0) { + res.status(400).send(validationErrors[0]); + return; + } + } + // Check if landlord exists const landlordDoc = landlordCollection.doc(landlordId); const landlordSnapshot = await landlordDoc.get(); @@ -1509,13 +1619,11 @@ app.post('/api/admin/add-apartment', authenticate, async (req, res) => { name, address, landlordId, - numBaths: numBaths || null, - numBeds: numBeds || null, + roomTypes: processedRoomTypes as readonly RoomType[], photos, area, latitude, longitude, - price: 0, // Default price, can be updated later distanceToCampus, }; @@ -1553,6 +1661,162 @@ app.post('/api/admin/add-apartment', authenticate, async (req, res) => { } }); +/** + * Migrate All Apartments Schema - Migrates all apartments from old schema to new room types schema. + * + * @remarks + * This endpoint performs a one-time migration of all apartments in the database: + * 1. Initializes roomTypes as empty array [] + * 2. Deletes old fields: numBeds, numBaths, price + * 3. Processes apartments in batches of 100 (3 batches total for ~300 apartments) + * 4. Returns summary of migration success/failures + * + * @route POST /api/admin/migrate-all-apartments-schema + * + * @input {object} req.body - Migration options containing: + * - dryRun: boolean (optional) - if true, returns count without making changes + * + * @status + * - 200: Migration completed successfully (returns summary) + * - 401: Authentication failed + * - 403: Unauthorized - Admin access required + * - 500: Server error during migration + */ +app.post('/api/admin/migrate-all-apartments-schema', authenticate, async (req, res) => { + if (!req.user) throw new Error('Not authenticated'); + + const { email } = req.user; + const isAdmin = email && admins.includes(email); + + if (!isAdmin) { + res.status(403).send('Unauthorized: Admin access required'); + return; + } + + try { + const { dryRun = false } = req.body; + + // Fetch all apartments + const apartmentsSnapshot = await buildingsCollection.get(); + const totalApartments = apartmentsSnapshot.size; + + if (dryRun) { + // Dry run: just return count and preview + const sampleApartments = apartmentsSnapshot.docs.slice(0, 3).map((doc) => { + const data = doc.data(); + return { + id: doc.id, + name: data.name, + hasOldSchema: 'numBeds' in data || 'numBaths' in data || 'price' in data, + currentData: { numBeds: data.numBeds, numBaths: data.numBaths, price: data.price }, + }; + }); + + res.status(200).json({ + dryRun: true, + totalApartments, + message: `Would migrate ${totalApartments} apartments`, + sampleApartments, + }); + return; + } + + // Actual migration + const startTime = Date.now(); + const batchSize = 100; + const batches = Math.ceil(totalApartments / batchSize); + + let migrated = 0; + let failed = 0; + const errors: string[] = []; + + console.log(`Starting migration of ${totalApartments} apartments in ${batches} batches...`); + + // Process in batches + const batchIndices = Array.from({ length: batches }, (_, i) => i); + + await batchIndices.reduce(async (promise, batchIndex) => { + await promise; + + const batchStartTime = Date.now(); + const start = batchIndex * batchSize; + const end = Math.min(start + batchSize, totalApartments); + const batchDocs = apartmentsSnapshot.docs.slice(start, end); + + console.log( + `Processing batch ${batchIndex + 1}/${batches} (${batchDocs.length} apartments)...` + ); + + // Process batch + const batch = db.batch(); + + batchDocs.forEach((doc) => { + try { + const data = doc.data(); + + // Check for corrupted data + if (!data.name || !data.address) { + errors.push(`Apartment ${doc.id}: Missing required fields (name or address)`); + failed += 1; + return; + } + + // Create migrated apartment data + const migratedData: Record = { + name: data.name, + address: data.address, + landlordId: data.landlordId || null, + roomTypes: [], // Initialize as empty array + photos: data.photos || [], + area: data.area || 'OTHER', + latitude: data.latitude || 0, + longitude: data.longitude || 0, + distanceToCampus: data.distanceToCampus || 0, + }; + + // Use batch.set to replace the document entirely (deletes old fields) + batch.set(doc.ref, migratedData); + migrated += 1; + } catch (err) { + const errorMsg = `Apartment ${doc.id}: ${ + err instanceof Error ? err.message : 'Unknown error' + }`; + errors.push(errorMsg); + failed += 1; + console.error(errorMsg); + } + }); + + // Commit batch + await batch.commit(); + + const batchDuration = Date.now() - batchStartTime; + console.log(`Batch ${batchIndex + 1}/${batches} completed in ${batchDuration}ms`); + }, Promise.resolve()); + + const totalDuration = Date.now() - startTime; + + const summary = { + success: true, + totalApartments, + migrated, + failed, + errors, + durationMs: totalDuration, + message: `Migration completed: ${migrated} migrated, ${failed} failed`, + }; + + console.log('Migration summary:', summary); + + res.status(200).json(summary); + } catch (err) { + console.error('Migration error:', err); + res + .status(500) + .send(`Error during migration: ${err instanceof Error ? err.message : 'Unknown error'}`); + } +}); + /** * Update Pending Building Status - Updates the status of a pending building report. * diff --git a/common/types/db-types.ts b/common/types/db-types.ts index 20463657..09bcca94 100644 --- a/common/types/db-types.ts +++ b/common/types/db-types.ts @@ -51,17 +51,22 @@ export type Landlord = { export type LandlordWithId = Landlord & Id; export type LandlordWithLabel = LandlordWithId & { readonly label: 'LANDLORD' }; +export type RoomType = { + readonly id: string; // UUID generated by backend + readonly beds: number; // >= 1, integer + readonly baths: number; // >= 1, integer + readonly price: number; // >= 1, integer (in dollars) +}; + export type Apartment = { readonly name: string; readonly address: string; // may change to placeID for Google Maps integration readonly landlordId: string | null; - readonly numBaths: number | null; - readonly numBeds: number | null; + readonly roomTypes: readonly RoomType[]; // can be empty array readonly photos: readonly string[]; // can be empty readonly area: 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER'; readonly latitude: number; readonly longitude: number; - readonly price: number; readonly distanceToCampus: number; // walking distance to ho plaza in minutes }; diff --git a/frontend/src/components/ApartmentCard/ApartmentCard.tsx b/frontend/src/components/ApartmentCard/ApartmentCard.tsx index 12dfa90e..4384e359 100644 --- a/frontend/src/components/ApartmentCard/ApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/ApartmentCard.tsx @@ -353,7 +353,8 @@ const ApartmentCard = ({ > {`Rating: ${avgRating.toFixed(1)}`} - {`Price: ${buildingData.price}`} - + */} )} {/* Sample Review */} diff --git a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx index a5510374..e1b8281a 100644 --- a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx @@ -193,7 +193,7 @@ const NewApartmentCard = ({ user, setUser, }: Props): ReactElement => { - const { id, name, photos, address, numBeds = 0, distanceToCampus = 0 } = buildingData; + const { id, name, photos, address, distanceToCampus = 0 } = buildingData; const saved = savedIcon; const unsaved = unsavedIcon; const img = photos.length > 0 ? photos[0] : ApartmentImg; @@ -320,12 +320,13 @@ const NewApartmentCard = ({ money $2K - $3K -
+ {/* TODO: Room type display - will be implemented in Phase 3 */} + {/*
bed - {numBeds ? `${numBeds - 1}-${numBeds + 1}` : '0'} bed + TBD bed -
+
*/} diff --git a/frontend/src/pages/SearchResultsPage.tsx b/frontend/src/pages/SearchResultsPage.tsx index 4c14f447..4fe82aa3 100644 --- a/frontend/src/pages/SearchResultsPage.tsx +++ b/frontend/src/pages/SearchResultsPage.tsx @@ -170,14 +170,16 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { { item: 'Lowest Price', callback: () => { - setSortBy('price'); + // TODO: Phase 4 - Update to sort by room type prices + setSortBy('avgPrice'); setSortLowToHigh(true); }, }, { item: 'Highest Price', callback: () => { - setSortBy('price'); + // TODO: Phase 4 - Update to sort by room type prices + setSortBy('avgPrice'); setSortLowToHigh(false); }, }, From f7a2dfbf8246e62b4d4e57531615553d4e93c586 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 00:33:23 -0500 Subject: [PATCH 06/10] Implement Room Types Admin UI and Display Utilities Admin Page Room Type Management - Added room types editing modal with table UI for beds/baths/price - Displays existing room types with inline editing and delete functionality - Includes "Add Room Type" button with validation (>= 1 for all fields) - Checks for duplicate room types (same beds/baths/price combination) - Backend generates UUIDs for new room types automatically - Shows room type count in Data tab instead of old numBeds/numBaths fields - Displays room type IDs (truncated) for debugging Room Type Display Utilities and Card Updates - Created roomTypeUtils.ts with comprehensive display functions: - formatPrice: Formats prices with K suffix (e.g., $1.5K, $3K) - formatBeds/formatBaths: Formats bed/bath counts (e.g., "2 beds", "1 bath") - getRoomTypeRange: Gets min/max values for beds/baths/price - formatPriceRange: Formats price ranges (e.g., "$1.5K - $3K") - formatRoomTypesDisplay: Complete display string for cards - getMinPrice/getMaxPrice: Helper functions for sorting - Updated NewApartmentCard to display dynamic price ranges from room types - Shows "Coming soon" when no room types are available --- .../ApartmentCard/NewApartmentCard.tsx | 18 +- frontend/src/pages/AdminPage.tsx | 551 ++++++++++++++---- frontend/src/utils/roomTypeUtils.ts | 181 ++++++ 3 files changed, 612 insertions(+), 138 deletions(-) create mode 100644 frontend/src/utils/roomTypeUtils.ts diff --git a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx index e1b8281a..59d3c5cc 100644 --- a/frontend/src/components/ApartmentCard/NewApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/NewApartmentCard.tsx @@ -18,6 +18,7 @@ import { createAuthHeaders, getUser } from '../../utils/firebase'; import { ApartmentWithId } from '../../../../common/types/db-types'; import FavoriteIcon from '@material-ui/icons/Favorite'; import { colors } from '../../colors'; +import { formatPriceRange, getRoomTypeRange } from '../../utils/roomTypeUtils'; type Props = { buildingData: ApartmentWithId; @@ -193,7 +194,7 @@ const NewApartmentCard = ({ user, setUser, }: Props): ReactElement => { - const { id, name, photos, address, distanceToCampus = 0 } = buildingData; + const { id, name, photos, address, distanceToCampus = 0, roomTypes } = buildingData; const saved = savedIcon; const unsaved = unsavedIcon; const img = photos.length > 0 ? photos[0] : ApartmentImg; @@ -202,6 +203,12 @@ const NewApartmentCard = ({ const [isHovered, setIsHovered] = useState(false); const [savedIsHovered, setSavedIsHovered] = useState(false); + // Get price range from room types + const roomTypeRange = getRoomTypeRange(roomTypes); + const priceDisplay = roomTypeRange + ? formatPriceRange(roomTypeRange.minPrice, roomTypeRange.maxPrice) + : 'Coming soon'; + const { root, redHighlight, @@ -318,15 +325,8 @@ const NewApartmentCard = ({
money - $2K - $3K + {priceDisplay}
- {/* TODO: Room type display - will be implemented in Phase 3 */} - {/*
- bed - - TBD bed - -
*/}
diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 0ac223c6..2a441175 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -16,6 +16,15 @@ import { TextField, Box, Fab, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Table, + TableBody, + TableCell, + TableHead, + TableRow, } from '@material-ui/core'; import { CantFindApartmentFormWithId, @@ -129,16 +138,20 @@ const AdminPage = (): ReactElement => { // Sorting and editing state const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const [editingApartment, setEditingApartment] = useState(null); - const [editFormData, setEditFormData] = useState<{ - numBeds: number | null; - numBaths: number | null; - price: number; - }>({ numBeds: null, numBaths: null, price: 0 }); + const [editModalOpen, setEditModalOpen] = useState(false); + const [editingRoomTypes, setEditingRoomTypes] = useState([]); // Pagination state const [currentPage, setCurrentPage] = useState(1); const apartmentsPerPage = 50; + // Migration state + const [migrationStatus, setMigrationStatus] = useState< + 'idle' | 'preview' | 'running' | 'complete' | 'error' + >('idle'); + const [migrationSummary, setMigrationSummary] = useState(null); + const [migrationProgress, setMigrationProgress] = useState(''); + // Debug apartments state changes useEffect(() => { console.log('Apartments loaded:', apartments.length); @@ -212,21 +225,118 @@ const AdminPage = (): ReactElement => { } }); + // Migration handler functions + const handleMigrationPreview = async () => { + try { + setMigrationStatus('preview'); + setMigrationProgress('Running dry run...'); + + const user = await getUser(); + if (!user) { + alert('You must be logged in to run migration'); + setMigrationStatus('idle'); + return; + } + + const token = await user.getIdToken(true); + const response = await axios.post( + '/api/admin/migrate-all-apartments-schema', + { dryRun: true }, + createAuthHeaders(token) + ); + + setMigrationSummary(response.data); + setMigrationProgress('Preview complete'); + setMigrationStatus('idle'); + } catch (error) { + console.error('Migration preview error:', error); + setMigrationProgress(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + setMigrationStatus('error'); + } + }; + + const handleMigrationRun = async () => { + if ( + !window.confirm( + 'Are you sure you want to run the migration? This will modify ALL apartment records in the database.' + ) + ) { + return; + } + + try { + setMigrationStatus('running'); + setMigrationProgress('Migrating apartments...'); + + const user = await getUser(); + if (!user) { + alert('You must be logged in to run migration'); + setMigrationStatus('idle'); + return; + } + + const token = await user.getIdToken(true); + const response = await axios.post( + '/api/admin/migrate-all-apartments-schema', + { dryRun: false }, + createAuthHeaders(token) + ); + + setMigrationSummary(response.data); + setMigrationProgress('Migration complete'); + setMigrationStatus('complete'); + + // Reload apartments after migration + setTimeout(() => { + window.location.reload(); + }, 3000); + } catch (error) { + console.error('Migration error:', error); + setMigrationProgress(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + setMigrationStatus('error'); + } + }; + + // Room Types Edit Handlers const handleEditClick = (apartment: any) => { setEditingApartment(apartment.buildingData?.id); - setEditFormData({ - numBeds: apartment.buildingData?.numBeds || null, - numBaths: apartment.buildingData?.numBaths || null, - price: apartment.avgPrice || 0, - }); + setEditingRoomTypes(apartment.buildingData?.roomTypes || []); + setEditModalOpen(true); }; const handleCancelEdit = () => { setEditingApartment(null); - setEditFormData({ numBeds: null, numBaths: null, price: 0 }); + setEditingRoomTypes([]); + setEditModalOpen(false); + }; + + const handleAddRoomType = () => { + setEditingRoomTypes((prev) => [ + ...prev, + { id: '', beds: 1, baths: 1, price: 1 }, // id will be generated by backend + ]); + }; + + const handleRemoveRoomType = (index: number) => { + setEditingRoomTypes((prev) => prev.filter((_, i) => i !== index)); }; - const handleSaveEdit = async (apartmentId: string) => { + const handleRoomTypeChange = ( + index: number, + field: 'beds' | 'baths' | 'price', + value: string + ) => { + const numValue = parseInt(value) || 1; + if (numValue < 1) return; // Enforce >= 1 constraint + + setEditingRoomTypes((prev) => + prev.map((rt, i) => (i === index ? { ...rt, [field]: numValue } : rt)) + ); + }; + + const handleSaveEdit = async () => { + if (!editingApartment) return; + try { const user = await getUser(); if (!user) { @@ -234,59 +344,46 @@ const AdminPage = (): ReactElement => { return; } - const token = await user.getIdToken(true); - const updateData: any = {}; - - if (editFormData.numBeds !== null) { - updateData.numBeds = editFormData.numBeds; - } - if (editFormData.numBaths !== null) { - updateData.numBaths = editFormData.numBaths; - } - if (editFormData.price !== 0) { - updateData.price = editFormData.price; + // Check for duplicate room types + const seen = new Set(); + for (const rt of editingRoomTypes) { + const key = `${rt.beds}-${rt.baths}-${rt.price}`; + if (seen.has(key)) { + alert(`Duplicate room type exists: ${rt.beds} beds, ${rt.baths} baths, $${rt.price}`); + return; + } + seen.add(key); } - await axios.put( - `/api/admin/update-apartment/${apartmentId}`, - updateData, + const token = await user.getIdToken(true); + const response = await axios.put( + `/api/admin/update-apartment/${editingApartment}`, + { roomTypes: editingRoomTypes }, createAuthHeaders(token) ); - // Update local state + // Update local state with the response (which includes generated UUIDs) setApartments((prev) => prev.map((apt) => { - if (apt.buildingData?.id === apartmentId) { + if (apt.buildingData?.id === editingApartment) { return { ...apt, - buildingData: { - ...apt.buildingData, - numBeds: editFormData.numBeds, - numBaths: editFormData.numBaths, - }, - avgPrice: editFormData.price, + buildingData: response.data.apartment, }; } return apt; }) ); - setEditingApartment(null); - setEditFormData({ numBeds: null, numBaths: null, price: 0 }); - } catch (error) { + alert('Room types updated successfully!'); + handleCancelEdit(); + } catch (error: any) { console.error('Error updating apartment:', error); - alert('Failed to update apartment. Please try again.'); + const errorMsg = error.response?.data || 'Failed to update apartment. Please try again.'; + alert(errorMsg); } }; - const handleInputChange = (field: 'numBeds' | 'numBaths' | 'price', value: string) => { - const numValue = field === 'price' ? parseFloat(value) || 0 : parseInt(value) || null; - setEditFormData((prev) => ({ - ...prev, - [field]: numValue, - })); - }; - const handlePageChange = (page: number) => { setCurrentPage(page); }; @@ -572,6 +669,136 @@ const AdminPage = (): ReactElement => {
); + // Developer Tools tab + const developerTools = ( + + + + + Developer Tools + + + {/* Migration Section */} + + + Schema Migration + + + Migrate all apartments from old schema (numBeds, numBaths, price) to new room types + schema. All apartments will be initialized with empty roomTypes array. + + + + + + + + {/* Migration Progress */} + {migrationProgress && ( + + + Status: {migrationProgress} + + + )} + + {/* Migration Summary */} + {migrationSummary && ( + + + {migrationSummary.dryRun ? 'Dry Run Results:' : 'Migration Results:'} + + + Total Apartments: {migrationSummary.totalApartments} + + {!migrationSummary.dryRun && ( + <> + + Migrated: {migrationSummary.migrated} + + + Failed: {migrationSummary.failed} + + + Duration: {migrationSummary.durationMs}ms + + + )} + {migrationSummary.errors && migrationSummary.errors.length > 0 && ( + + + Errors: + + {migrationSummary.errors.slice(0, 10).map((error: string, idx: number) => ( + + {error} + + ))} + + )} + {migrationSummary.sampleApartments && ( + + + Sample Apartments (first 3): + + {migrationSummary.sampleApartments.map((apt: any, idx: number) => ( + + {apt.id}: {apt.name} (hasOldSchema: {apt.hasOldSchema ? 'yes' : 'no'}) + + ))} + + )} + + )} + + + + + ); + // Data tab const data = ( @@ -641,20 +868,16 @@ const AdminPage = (): ReactElement => { {currentPageApartments.map((apartment, index) => { - const isEditing = editingApartment === apartment.buildingData?.id; - return ( - {!isEditing && ( - handleEditClick(apartment)} - size="small" - title="Edit apartment" - > - - - )} + handleEditClick(apartment)} + size="small" + title="Edit room types" + > + + { Location: {apartment.buildingData?.area || 'N/A'} - {isEditing ? ( -
- handleInputChange('numBeds', e.target.value)} - size="small" - /> - handleInputChange('numBaths', e.target.value)} - size="small" - /> - handleInputChange('price', e.target.value)} - size="small" - /> - - - - -
- ) : ( -
- - Bedrooms: {apartment.buildingData?.numBeds || 'N/A'} - - - Bathrooms:{' '} - {apartment.buildingData?.numBaths || 'N/A'} - - - Company: {apartment.company || 'N/A'} - - - Reviews: {apartment.numReviews || 0} - - - Avg Rating:{' '} - {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} - - - Avg Price:{' '} - {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} - -
- )} +
+ + Room Types:{' '} + {apartment.buildingData?.roomTypes && + apartment.buildingData.roomTypes.length > 0 + ? `${apartment.buildingData.roomTypes.length} type${ + apartment.buildingData.roomTypes.length > 1 ? 's' : '' + }` + : 'No room types'} + + + Company: {apartment.company || 'N/A'} + + + Reviews: {apartment.numReviews || 0} + + + Avg Rating:{' '} + {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} + + + Avg Price:{' '} + {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} + +
} /> @@ -757,6 +934,119 @@ const AdminPage = (): ReactElement => {
); + // Room Types Edit Modal + const roomTypesModal = ( + + + Edit Room Types + {editingApartment && ( + + Apartment ID: {editingApartment} + + )} + + + {editingRoomTypes.length === 0 ? ( + + No room types. Click "Add Room Type" below to add one. + + ) : ( + + + + + Beds + + + Baths + + + Price + + + Actions + + + + + {editingRoomTypes.map((roomType, index) => ( + + + handleRoomTypeChange(index, 'beds', e.target.value)} + inputProps={{ min: 1, step: 1 }} + size="small" + style={{ width: '80px' }} + /> + + + handleRoomTypeChange(index, 'baths', e.target.value)} + inputProps={{ min: 1, step: 1 }} + size="small" + style={{ width: '80px' }} + /> + + + handleRoomTypeChange(index, 'price', e.target.value)} + inputProps={{ min: 1, step: 1 }} + size="small" + style={{ width: '100px' }} + /> + + + handleRemoveRoomType(index)} + size="small" + color="secondary" + title="Remove this room type" + > + + + {roomType.id && ( + + ID: {roomType.id.slice(0, 8)}... + + )} + + + ))} + +
+ )} + + + + +
+ + + + +
+ ); + return (
@@ -770,6 +1060,7 @@ const AdminPage = (): ReactElement => { + @@ -777,7 +1068,9 @@ const AdminPage = (): ReactElement => { {selectedTab === 'Reviews' && reviews} {selectedTab === 'Contact' && contact} {selectedTab === 'Data' && data} + {selectedTab === 'DevTools' && developerTools} {Modals} + {roomTypesModal}
); }; diff --git a/frontend/src/utils/roomTypeUtils.ts b/frontend/src/utils/roomTypeUtils.ts new file mode 100644 index 00000000..9c73eb5f --- /dev/null +++ b/frontend/src/utils/roomTypeUtils.ts @@ -0,0 +1,181 @@ +import { RoomType } from '../../../common/types/db-types'; + +/** + * Formats a price value into a readable string with K suffix for thousands. + * Examples: 1500 → "$1.5K", 3200 → "$3.2K", 800 → "$800" + * + * @param {number} price - The price in dollars + * @returns {string} The formatted price string + */ +export const formatPrice = (price: number): string => { + if (price >= 1000) { + const priceInK = price / 1000; + // Format with 1 decimal place, but remove .0 if it's a whole number + const formatted = priceInK % 1 === 0 ? priceInK.toFixed(0) : priceInK.toFixed(1); + return `$${formatted}K`; + } + return `$${price}`; +}; + +/** + * Formats a bedroom count into a readable string. + * Examples: 1 → "1 bed", 2 → "2 beds", 0 → "Studio" + * + * @param {number} beds - The number of bedrooms + * @returns {string} The formatted bedroom string + */ +export const formatBeds = (beds: number): string => { + if (beds === 0) return 'Studio'; + if (beds === 1) return '1 bed'; + return `${beds} beds`; +}; + +/** + * Formats a bathroom count into a readable string. + * Examples: 1 → "1 bath", 2 → "2 baths", 1.5 → "1.5 baths" + * + * @param {number} baths - The number of bathrooms + * @returns {string} The formatted bathroom string + */ +export const formatBaths = (baths: number): string => { + if (baths === 1) return '1 bath'; + return `${baths} baths`; +}; + +/** + * Gets the min and max values for beds, baths, and price from a room types array. + * Returns null if the array is empty. + * + * @param {readonly RoomType[]} roomTypes - Array of room types + * @returns {{ minBeds: number; maxBeds: number; minBaths: number; maxBaths: number; minPrice: number; maxPrice: number } | null} + */ +export const getRoomTypeRange = ( + roomTypes: readonly RoomType[] +): { + minBeds: number; + maxBeds: number; + minBaths: number; + maxBaths: number; + minPrice: number; + maxPrice: number; +} | null => { + if (!roomTypes || roomTypes.length === 0) { + return null; + } + + const beds = roomTypes.map((rt) => rt.beds); + const baths = roomTypes.map((rt) => rt.baths); + const prices = roomTypes.map((rt) => rt.price); + + return { + minBeds: Math.min(...beds), + maxBeds: Math.max(...beds), + minBaths: Math.min(...baths), + maxBaths: Math.max(...baths), + minPrice: Math.min(...prices), + maxPrice: Math.max(...prices), + }; +}; + +/** + * Formats a price range into a readable string. + * Examples: (1500, 3000) → "$1.5K - $3K", (2000, 2000) → "$2K" + * + * @param {number} minPrice - The minimum price + * @param {number} maxPrice - The maximum price + * @returns {string} The formatted price range string + */ +export const formatPriceRange = (minPrice: number, maxPrice: number): string => { + const min = formatPrice(minPrice); + const max = formatPrice(maxPrice); + + if (minPrice === maxPrice) { + return min; + } + + return `${min} - ${max}`; +}; + +/** + * Formats a bedroom range into a readable string. + * Examples: (1, 3) → "1-3 beds", (2, 2) → "2 beds", (0, 2) → "Studio-2 beds" + * + * @param {number} minBeds - The minimum number of bedrooms + * @param {number} maxBeds - The maximum number of bedrooms + * @returns {string} The formatted bedroom range string + */ +export const formatBedsRange = (minBeds: number, maxBeds: number): string => { + if (minBeds === maxBeds) { + return formatBeds(minBeds); + } + + // Handle studio to X beds case + if (minBeds === 0) { + return `Studio-${maxBeds} beds`; + } + + return `${minBeds}-${maxBeds} beds`; +}; + +/** + * Formats a bathroom range into a readable string. + * Examples: (1, 2) → "1-2 baths", (1.5, 1.5) → "1.5 baths" + * + * @param {number} minBaths - The minimum number of bathrooms + * @param {number} maxBaths - The maximum number of bathrooms + * @returns {string} The formatted bathroom range string + */ +export const formatBathsRange = (minBaths: number, maxBaths: number): string => { + if (minBaths === maxBaths) { + return formatBaths(minBaths); + } + + return `${minBaths}-${maxBaths} baths`; +}; + +/** + * Formats room type information for display on apartment cards. + * Returns "Coming soon" if no room types, otherwise returns formatted ranges. + * Example outputs: + * - No room types: "Coming soon" + * - Single room type: "$2K | 2 beds | 1 bath" + * - Multiple room types: "$1.5K - $3K | 1-3 beds | 1-2 baths" + * + * @param {readonly RoomType[]} roomTypes - Array of room types + * @returns {string} The formatted display string + */ +export const formatRoomTypesDisplay = (roomTypes: readonly RoomType[]): string => { + const range = getRoomTypeRange(roomTypes); + + if (!range) { + return 'Coming soon'; + } + + const priceStr = formatPriceRange(range.minPrice, range.maxPrice); + const bedsStr = formatBedsRange(range.minBeds, range.maxBeds); + const bathsStr = formatBathsRange(range.minBaths, range.maxBaths); + + return `${priceStr} | ${bedsStr} | ${bathsStr}`; +}; + +/** + * Gets the minimum price from a room types array, or null if empty. + * + * @param {readonly RoomType[]} roomTypes - Array of room types + * @returns {number | null} The minimum price or null if no room types + */ +export const getMinPrice = (roomTypes: readonly RoomType[]): number | null => { + const range = getRoomTypeRange(roomTypes); + return range ? range.minPrice : null; +}; + +/** + * Gets the maximum price from a room types array, or null if empty. + * + * @param {readonly RoomType[]} roomTypes - Array of room types + * @returns {number | null} The maximum price or null if no room types + */ +export const getMaxPrice = (roomTypes: readonly RoomType[]): number | null => { + const range = getRoomTypeRange(roomTypes); + return range ? range.maxPrice : null; +}; From 887d5ca80f000f405877fe47d93b0ec44ff4f1e6 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 15:24:31 -0500 Subject: [PATCH 07/10] Implement apartment card display and search filtering for room types - Update ApartmentCard component to display room type information - Add imports for formatPriceRange, formatBedsRange, getRoomTypeRange utilities - Replace TODO section with dynamic room types display - Show "Coming soon" message for apartments with empty roomTypes - Display formatted price and bed ranges for apartments with room types data - Implement comprehensive search filtering with room types support - Re-enable price, bedroom, and bathroom filter parameters - Add exact match filtering for beds/baths (not >=) - Implement additional search result sections (location, price, bed/bath) - Return structured response with main results + 3 additional sections - Filter apartments based on roomTypes array instead of old schema - Handle empty roomTypes arrays in filter logic - Update search results page to support additional sections - Add state variables for additionalLocation, additionalPrice, additionalBedBath - Prepare frontend for displaying multiple result sections - Enhance apartment sorting with room types - Use getMinPrice/getMaxPrice utilities for sorting by price - Sort by minimum price (cheapest) or maximum price (most expensive) - Handle apartments with empty roomTypes arrays This completes the frontend display implementation for the room types refactor. Backend migration tools and admin CRUD are already in place from previous commits. Ready for data migration and testing. --- backend/src/app.ts | 166 +++++++--- .../ApartmentCard/ApartmentCard.tsx | 63 ++-- frontend/src/pages/AdminPage.tsx | 284 +++++++++++++++--- frontend/src/pages/SearchResultsPage.tsx | 171 ++++++++++- frontend/src/utils/sortApartments.ts | 21 +- 5 files changed, 595 insertions(+), 110 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 5f8c3105..ef250825 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -500,10 +500,10 @@ app.get('/api/search-with-query-and-filters', async (req, res) => { // Extract all query parameters const query = req.query.q as string; const locations = req.query.locations as string; - // const minPrice = req.query.minPrice ? parseInt(req.query.minPrice as string, 10) : null; - // const maxPrice = req.query.maxPrice ? parseInt(req.query.maxPrice as string, 10) : null; - // const bedrooms = req.query.bedrooms ? parseInt(req.query.bedrooms as string, 10) : null; - // const bathrooms = req.query.bathrooms ? parseInt(req.query.bathrooms as string, 10) : null; + const minPrice = req.query.minPrice ? parseInt(req.query.minPrice as string, 10) : null; + const maxPrice = req.query.maxPrice ? parseInt(req.query.maxPrice as string, 10) : null; + const bedrooms = req.query.bedrooms ? parseInt(req.query.bedrooms as string, 10) : null; + const bathrooms = req.query.bathrooms ? parseInt(req.query.bathrooms as string, 10) : null; const size = req.query.size ? parseInt(req.query.size as string, 10) : null; const sortBy = req.query.sortBy || 'numReviews'; @@ -511,78 +511,148 @@ app.get('/api/search-with-query-and-filters', async (req, res) => { const apts = req.app.get('apts'); const aptsWithType: ApartmentWithId[] = apts; - // Start with text search if query is provided - let filteredResults: ApartmentWithId[] = []; + // STEP 1: Apply text search first + let baseResults: ApartmentWithId[] = []; if (query && query.trim() !== '') { const options = { keys: ['name', 'address'], }; const fuse = new Fuse(aptsWithType, options); const searchResults = fuse.search(query); - filteredResults = searchResults.map((result) => result.item); + baseResults = searchResults.map((result) => result.item); } else { // If no query, start with all apartments - filteredResults = aptsWithType; + baseResults = aptsWithType; } - // Apply location filter if provided - if (locations && locations.trim() !== '') { + // STEP 2: Count active filter categories + const hasLocation = locations && locations.trim() !== ''; + const hasPrice = minPrice !== null || maxPrice !== null; + const hasBedBath = (bedrooms !== null && bedrooms > 0) || (bathrooms !== null && bathrooms > 0); + const activeFilterCount = [hasLocation, hasPrice, hasBedBath].filter(Boolean).length; + + // Helper: Filter by location only + const filterByLocation = (apts: ApartmentWithId[]): ApartmentWithId[] => { + if (!hasLocation) return []; const locationArray = locations.split(',').map((loc) => loc.toUpperCase()); - filteredResults = filteredResults.filter((apt) => - locationArray.includes(apt.area ? apt.area.toUpperCase() : '') - ); + return apts.filter((apt) => locationArray.includes(apt.area ? apt.area.toUpperCase() : '')); + }; + + // Helper: Filter by price only + const filterByPrice = (apts: ApartmentWithId[]): ApartmentWithId[] => { + if (!hasPrice) return []; + return apts.filter((apt) => { + if (!apt.roomTypes || apt.roomTypes.length === 0) return false; + return apt.roomTypes.some((roomType) => { + if (minPrice !== null && roomType.price < minPrice) return false; + if (maxPrice !== null && roomType.price > maxPrice) return false; + return true; + }); + }); + }; + + // Helper: Filter by bed/bath only + const filterByBedBath = (apts: ApartmentWithId[]): ApartmentWithId[] => { + if (!hasBedBath) return []; + return apts.filter((apt) => { + if (!apt.roomTypes || apt.roomTypes.length === 0) return false; + return apt.roomTypes.some((roomType) => { + if (bedrooms !== null && bedrooms > 0 && roomType.beds !== bedrooms) return false; + if (bathrooms !== null && bathrooms > 0 && roomType.baths !== bathrooms) return false; + return true; + }); + }); + }; + + // Helper: Filter by ALL criteria (main results) + const filterByAll = (apts: ApartmentWithId[]): ApartmentWithId[] => { + let filtered = apts; + + // Apply location filter + if (hasLocation) { + filtered = filterByLocation(filtered); + } + + // Apply price filter + if (hasPrice) { + filtered = filterByPrice(filtered); + } + + // Apply bed/bath filter + if (hasBedBath) { + filtered = filterByBedBath(filtered); + } + + return filtered; + }; + + // STEP 3: Calculate results based on active filter count + let mainResults: ApartmentWithId[] = []; + let additionalLocation: ApartmentWithId[] = []; + let additionalPrice: ApartmentWithId[] = []; + let additionalBedBath: ApartmentWithId[] = []; + + if (activeFilterCount === 0) { + // No filters: return all base results as main + mainResults = baseResults; + } else if (activeFilterCount === 1) { + // 1 filter: main results only, no additional sections + mainResults = filterByAll(baseResults); + } else { + // 2+ filters: calculate main + additional sections + // Main: matches ALL filters + mainResults = filterByAll(baseResults); + const mainIds = new Set(mainResults.map((apt) => apt.id)); + + // Additional: each individual filter (excluding main results) + if (hasLocation) { + additionalLocation = filterByLocation(baseResults).filter((apt) => !mainIds.has(apt.id)); + } + if (hasPrice) { + additionalPrice = filterByPrice(baseResults).filter((apt) => !mainIds.has(apt.id)); + } + if (hasBedBath) { + additionalBedBath = filterByBedBath(baseResults).filter((apt) => !mainIds.has(apt.id)); + } } - // TODO: Right now we disable the price filter because of lack of data - // Apply price range filters - // if (minPrice !== null) { - // filteredResults = filteredResults.filter((apt) => apt.price >= minPrice); - // } - - // if (maxPrice !== null) { - // filteredResults = filteredResults.filter((apt) => apt.price <= maxPrice); - // } - - // Apply bedroom filter - // TODO: Right now we disable the bedroom filter because of lack of data - // if (bedrooms !== null && bedrooms > 0) { - // filteredResults = filteredResults.filter( - // (apt) => apt.numBeds !== null && apt.numBeds >= bedrooms - // ); - // } - - // // Apply bathroom filter - // if (bathrooms !== null && bathrooms > 0) { - // filteredResults = filteredResults.filter( - // (apt) => apt.numBaths !== null && apt.numBaths >= bathrooms - // ); - // } - - // Process the filtered results through pageData to include reviews, ratings, etc. - let enrichedResults = await pageData(filteredResults); - - // If size is specified and less than available, slice the results - if (size && size > 0 && enrichedResults.length > size) { + // STEP 4: Enrich all result sections with pageData + const enrichMain = await pageData(mainResults); + const enrichLocation = await pageData(additionalLocation); + const enrichPrice = await pageData(additionalPrice); + const enrichBedBath = await pageData(additionalBedBath); + + // STEP 5: Apply sorting and size limit to main results + let finalMain = enrichMain; + if (size && size > 0 && finalMain.length > size) { console.log('endpoint sortBy', sortBy); switch (sortBy) { case 'numReviews': - enrichedResults.sort((a, b) => b.numReviews - a.numReviews); + finalMain.sort((a, b) => b.numReviews - a.numReviews); break; case 'avgRating': - enrichedResults.sort((a, b) => b.avgRating - a.avgRating); + finalMain.sort((a, b) => b.avgRating - a.avgRating); break; case 'distanceToCampus': - enrichedResults.sort( + finalMain.sort( (a, b) => a.buildingData.distanceToCampus - b.buildingData.distanceToCampus ); break; default: break; } - enrichedResults = enrichedResults.slice(0, size); + finalMain = finalMain.slice(0, size); } - res.status(200).send(JSON.stringify(enrichedResults)); + // STEP 6: Return structured response + const response = { + main: finalMain, + additionalLocation: enrichLocation, + additionalPrice: enrichPrice, + additionalBedBath: enrichBedBath, + }; + + res.status(200).send(JSON.stringify(response)); } catch (err) { console.error(err); res.status(400).send('Error'); diff --git a/frontend/src/components/ApartmentCard/ApartmentCard.tsx b/frontend/src/components/ApartmentCard/ApartmentCard.tsx index 4384e359..ce155f79 100644 --- a/frontend/src/components/ApartmentCard/ApartmentCard.tsx +++ b/frontend/src/components/ApartmentCard/ApartmentCard.tsx @@ -21,6 +21,7 @@ import HeartRating from '../utils/HeartRating'; import { getAverageRating } from '../../utils/average'; import { colors } from '../../colors'; import { Link as RouterLink } from 'react-router-dom'; +import { formatPriceRange, formatBedsRange, getRoomTypeRange } from '../../utils/roomTypeUtils'; type Props = { buildingData: ApartmentWithId; @@ -353,28 +354,46 @@ const ApartmentCard = ({ > {`Rating: ${avgRating.toFixed(1)}`} - {/* TODO: Room type display - will be implemented in Phase 3 */} - {/* - {`Beds: ${buildingData.numBeds}`} - - - {`Baths: ${buildingData.numBaths}`} - - - {`Price: ${buildingData.price}`} - */} + {/* Room Types Display */} + {(() => { + const roomTypeRange = getRoomTypeRange(buildingData.roomTypes); + if (!roomTypeRange) { + // No room types - show coming soon message + return ( + + Room type information coming soon + + ); + } + // Has room types - show price and beds + return ( + <> + + {formatPriceRange(roomTypeRange.minPrice, roomTypeRange.maxPrice)} + + + {formatBedsRange(roomTypeRange.minBeds, roomTypeRange.maxBeds)} + + + ); + })()} )} {/* Sample Review */} diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 2a441175..c3de50b1 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -15,7 +15,6 @@ import { Button, TextField, Box, - Fab, Dialog, DialogTitle, DialogContent, @@ -30,7 +29,6 @@ import { CantFindApartmentFormWithId, QuestionFormWithId, ReviewWithId, - ApartmentWithId, } from '../../../common/types/db-types'; import { get } from '../utils/call'; import AdminReviewComponent from '../components/Admin/AdminReview'; @@ -39,7 +37,6 @@ import { Chart } from 'react-google-charts'; import { sortReviews } from '../utils/sortReviews'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import EditIcon from '@material-ui/icons/Edit'; -import SaveIcon from '@material-ui/icons/Save'; import CancelIcon from '@material-ui/icons/Cancel'; import SortIcon from '@material-ui/icons/Sort'; import clsx from 'clsx'; @@ -80,15 +77,6 @@ const useStyles = makeStyles((theme) => ({ sortButton: { marginLeft: theme.spacing(2), }, - editField: { - marginBottom: theme.spacing(1), - width: '100%', - }, - editButtons: { - display: 'flex', - gap: theme.spacing(1), - marginTop: theme.spacing(1), - }, apartmentCard: { borderBottom: '1px solid #e0e0e0', padding: '15px 0', @@ -139,7 +127,21 @@ const AdminPage = (): ReactElement => { const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const [editingApartment, setEditingApartment] = useState(null); const [editModalOpen, setEditModalOpen] = useState(false); + const [isCreatingNew, setIsCreatingNew] = useState(false); const [editingRoomTypes, setEditingRoomTypes] = useState([]); + const [validationErrors, setValidationErrors] = useState([]); + + // Apartment field editing state + const [editingName, setEditingName] = useState(''); + const [editingAddress, setEditingAddress] = useState(''); + const [editingLandlordId, setEditingLandlordId] = useState(''); + const [editingArea, setEditingArea] = useState< + 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER' + >('COLLEGETOWN'); + const [editingPhotos, setEditingPhotos] = useState([]); + const [editingLatitude, setEditingLatitude] = useState(0); + const [editingLongitude, setEditingLongitude] = useState(0); + const [editingDistance, setEditingDistance] = useState(0); // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -163,8 +165,6 @@ const AdminPage = (): ReactElement => { expandOpen, headerContainer, sortButton, - editField, - editButtons, apartmentCard, editButton, } = useStyles(); @@ -299,14 +299,32 @@ const AdminPage = (): ReactElement => { // Room Types Edit Handlers const handleEditClick = (apartment: any) => { - setEditingApartment(apartment.buildingData?.id); - setEditingRoomTypes(apartment.buildingData?.roomTypes || []); + const aptData = apartment.buildingData; + setEditingApartment(aptData?.id || null); + setEditingRoomTypes(aptData?.roomTypes || []); + setEditingName(aptData?.name || ''); + setEditingAddress(aptData?.address || ''); + setEditingLandlordId(aptData?.landlordId || ''); + setEditingArea(aptData?.area || 'COLLEGETOWN'); + setEditingPhotos(aptData?.photos || []); + setEditingLatitude(aptData?.latitude || 0); + setEditingLongitude(aptData?.longitude || 0); + setEditingDistance(aptData?.distanceToCampus || 0); setEditModalOpen(true); }; const handleCancelEdit = () => { setEditingApartment(null); setEditingRoomTypes([]); + setValidationErrors([]); + setEditingName(''); + setEditingAddress(''); + setEditingLandlordId(''); + setEditingArea('COLLEGETOWN'); + setEditingPhotos([]); + setEditingLatitude(0); + setEditingLongitude(0); + setEditingDistance(0); setEditModalOpen(false); }; @@ -318,7 +336,44 @@ const AdminPage = (): ReactElement => { }; const handleRemoveRoomType = (index: number) => { - setEditingRoomTypes((prev) => prev.filter((_, i) => i !== index)); + const newRoomTypes = editingRoomTypes.filter((_, i) => i !== index); + setEditingRoomTypes(newRoomTypes); + setValidationErrors(validateRoomTypes(newRoomTypes)); + }; + + // Validation function + const validateRoomTypes = (roomTypes: any[]): string[] => { + const errors: string[] = []; + + // Check for invalid values + roomTypes.forEach((rt, index) => { + if (!rt.beds || rt.beds < 1 || !Number.isInteger(rt.beds)) { + errors.push(`Row ${index + 1}: Beds must be a whole number ≥ 1`); + } + if (!rt.baths || rt.baths < 1 || !Number.isInteger(rt.baths)) { + errors.push(`Row ${index + 1}: Baths must be a whole number ≥ 1`); + } + if (!rt.price || rt.price < 1 || !Number.isInteger(rt.price)) { + errors.push(`Row ${index + 1}: Price must be a whole number ≥ 1`); + } + }); + + // Check for duplicates + const seen = new Map(); + roomTypes.forEach((rt, index) => { + const key = `${rt.beds}-${rt.baths}-${rt.price}`; + if (seen.has(key)) { + errors.push( + `Duplicate room type: ${rt.beds} bed${rt.beds > 1 ? 's' : ''}, ${rt.baths} bath${ + rt.baths > 1 ? 's' : '' + }, $${rt.price} (rows ${seen.get(key)! + 1} and ${index + 1})` + ); + } else { + seen.set(key, index); + } + }); + + return errors; }; const handleRoomTypeChange = ( @@ -329,14 +384,33 @@ const AdminPage = (): ReactElement => { const numValue = parseInt(value) || 1; if (numValue < 1) return; // Enforce >= 1 constraint - setEditingRoomTypes((prev) => - prev.map((rt, i) => (i === index ? { ...rt, [field]: numValue } : rt)) + const newRoomTypes = editingRoomTypes.map((rt, i) => + i === index ? { ...rt, [field]: numValue } : rt ); + setEditingRoomTypes(newRoomTypes); + setValidationErrors(validateRoomTypes(newRoomTypes)); }; const handleSaveEdit = async () => { if (!editingApartment) return; + // Validate before saving + const errors = validateRoomTypes(editingRoomTypes); + if (errors.length > 0) { + setValidationErrors(errors); + return; + } + + // Validate required fields + if (!editingName.trim()) { + alert('Apartment name is required'); + return; + } + if (!editingAddress.trim()) { + alert('Address is required'); + return; + } + try { const user = await getUser(); if (!user) { @@ -344,21 +418,20 @@ const AdminPage = (): ReactElement => { return; } - // Check for duplicate room types - const seen = new Set(); - for (const rt of editingRoomTypes) { - const key = `${rt.beds}-${rt.baths}-${rt.price}`; - if (seen.has(key)) { - alert(`Duplicate room type exists: ${rt.beds} beds, ${rt.baths} baths, $${rt.price}`); - return; - } - seen.add(key); - } - const token = await user.getIdToken(true); const response = await axios.put( `/api/admin/update-apartment/${editingApartment}`, - { roomTypes: editingRoomTypes }, + { + name: editingName.trim(), + address: editingAddress.trim(), + landlordId: editingLandlordId.trim() || null, + area: editingArea, + photos: editingPhotos, + latitude: editingLatitude, + longitude: editingLongitude, + distanceToCampus: editingDistance, + roomTypes: editingRoomTypes, + }, createAuthHeaders(token) ); @@ -936,9 +1009,9 @@ const AdminPage = (): ReactElement => { // Room Types Edit Modal const roomTypesModal = ( - + - Edit Room Types + Edit Apartment {editingApartment && ( Apartment ID: {editingApartment} @@ -946,6 +1019,113 @@ const AdminPage = (): ReactElement => { )} + {/* Apartment Information Section */} + + + Apartment Information + + + + setEditingName(e.target.value)} + required + size="small" + /> + + + setEditingAddress(e.target.value)} + required + size="small" + /> + + + setEditingLandlordId(e.target.value)} + size="small" + /> + + + setEditingArea(e.target.value as any)} + SelectProps={{ native: true }} + size="small" + > + + + + + + + + + setEditingLatitude(parseFloat(e.target.value) || 0)} + size="small" + /> + + + setEditingLongitude(parseFloat(e.target.value) || 0)} + size="small" + /> + + + setEditingDistance(parseFloat(e.target.value) || 0)} + size="small" + /> + + + + setEditingPhotos( + e.target.value + .split(',') + .map((url) => url.trim()) + .filter(Boolean) + ) + } + size="small" + multiline + rows={2} + /> + + + + + {/* Room Types Section */} + + Room Types + {editingRoomTypes.length === 0 ? ( No room types. Click "Add Room Type" below to add one. @@ -1035,12 +1215,46 @@ const AdminPage = (): ReactElement => { + Add Room Type + + {/* Validation Errors */} + {validationErrors.length > 0 && ( + + + Please fix the following errors: + + {validationErrors.map((error, idx) => ( + + • {error} + + ))} + + )} - @@ -1053,7 +1267,7 @@ const AdminPage = (): ReactElement => { setSelectedTab(newValue)} + onChange={(_event, newValue) => setSelectedTab(newValue)} aria-label="navigation tabs" variant="fullWidth" > diff --git a/frontend/src/pages/SearchResultsPage.tsx b/frontend/src/pages/SearchResultsPage.tsx index 4fe82aa3..cd548139 100644 --- a/frontend/src/pages/SearchResultsPage.tsx +++ b/frontend/src/pages/SearchResultsPage.tsx @@ -77,6 +77,9 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { const path = useLocation(); const [pathName] = useState(path.pathname); const [searchResults, setSearchResults] = useState([]); + const [additionalLocation, setAdditionalLocation] = useState([]); + const [additionalPrice, setAdditionalPrice] = useState([]); + const [additionalBedBath, setAdditionalBedBath] = useState([]); const [sortBy, setSortBy] = useState( 'originalOrder' ); @@ -134,8 +137,28 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { params.append('sortLowToHigh', filters.initialSortLowToHigh.toString()); - get(`/api/search-with-query-and-filters?${params.toString()}&size=21`, { - callback: setSearchResults, + get<{ + main: CardData[]; + additionalLocation: CardData[]; + additionalPrice: CardData[]; + additionalBedBath: CardData[]; + }>(`/api/search-with-query-and-filters?${params.toString()}&size=21`, { + callback: (data) => { + // Defensive: handle both old and new API response formats + if (data && typeof data === 'object' && 'main' in data) { + setSearchResults(data.main || []); + setAdditionalLocation(data.additionalLocation || []); + setAdditionalPrice(data.additionalPrice || []); + setAdditionalBedBath(data.additionalBedBath || []); + } else { + // Fallback for unexpected response format + console.warn('Unexpected API response format:', data); + setSearchResults([]); + setAdditionalLocation([]); + setAdditionalPrice([]); + setAdditionalBedBath([]); + } + }, }); setSortBy(filters.initialSortBy); @@ -148,6 +171,32 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { sessionStorage.setItem(`resultsCount_search_${query}`, count.toString()); }; + // Helper: Generate section title for additional results + const getAdditionalSectionTitle = (type: 'location' | 'price' | 'bedBath'): string => { + if (type === 'location' && filters.locations && filters.locations.length > 0) { + return `More at ${filters.locations.join(', ')}`; + } + if (type === 'price' && (filters.minPrice || filters.maxPrice)) { + const min = filters.minPrice ? `$${filters.minPrice}` : ''; + const max = filters.maxPrice ? `$${filters.maxPrice}` : ''; + if (min && max) return `More within ${min} - ${max}`; + if (min) return `More above ${min}`; + if (max) return `More under ${max}`; + } + if (type === 'bedBath') { + const bedText = + filters.bedrooms > 0 ? `${filters.bedrooms} bedroom${filters.bedrooms > 1 ? 's' : ''}` : ''; + const bathText = + filters.bathrooms > 0 + ? `${filters.bathrooms} bathroom${filters.bathrooms > 1 ? 's' : ''}` + : ''; + if (bedText && bathText) return `More with ${bedText} and ${bathText}`; + if (bedText) return `More with ${bedText}`; + if (bathText) return `More with ${bathText}`; + } + return ''; + }; + const sortSection = () => { return (
@@ -170,7 +219,6 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { { item: 'Lowest Price', callback: () => { - // TODO: Phase 4 - Update to sort by room type prices setSortBy('avgPrice'); setSortLowToHigh(true); }, @@ -178,7 +226,6 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { { item: 'Highest Price', callback: () => { - // TODO: Phase 4 - Update to sort by room type prices setSortBy('avgPrice'); setSortLowToHigh(false); }, @@ -221,6 +268,7 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => { {!isMobile ? (
+ {/* Main Results */} { sortMethod={sortBy} orderLowToHigh={sortLowToHigh} /> + + {/* Additional Location Section */} + {additionalLocation.length > 0 && ( + <> + + {getAdditionalSectionTitle('location')} + + + + )} + + {/* Additional Price Section */} + {additionalPrice.length > 0 && ( + <> + + {getAdditionalSectionTitle('price')} + + + + )} + + {/* Additional Bed/Bath Section */} + {additionalBedBath.length > 0 && ( + <> + + {getAdditionalSectionTitle('bedBath')} + + + + )}
{ />
+ {/* Main Results */} { sortMethod={sortBy} orderLowToHigh={sortLowToHigh} /> + + {/* Additional Location Section */} + {additionalLocation.length > 0 && ( + <> + + {getAdditionalSectionTitle('location')} + + + + )} + + {/* Additional Price Section */} + {additionalPrice.length > 0 && ( + <> + + {getAdditionalSectionTitle('price')} + + + + )} + + {/* Additional Bed/Bath Section */} + {additionalBedBath.length > 0 && ( + <> + + {getAdditionalSectionTitle('bedBath')} + + + + )}
)} diff --git a/frontend/src/utils/sortApartments.ts b/frontend/src/utils/sortApartments.ts index d3b43dad..cc2038b7 100644 --- a/frontend/src/utils/sortApartments.ts +++ b/frontend/src/utils/sortApartments.ts @@ -1,5 +1,7 @@ import { CardData } from '../App'; import { ApartmentWithId } from '../../../common/types/db-types'; +import { getMinPrice, getMaxPrice } from './roomTypeUtils'; + export type AptSortFields = keyof CardData | keyof ApartmentWithId | 'originalOrder'; /** @@ -8,6 +10,9 @@ export type AptSortFields = keyof CardData | keyof ApartmentWithId | 'originalOr * @remarks * Creates a shallow copy of the input array and sorts it based on either CardData or ApartmentWithId properties. * If the property values are equal, sorts by apartment ID as a tiebreaker. + * For price sorting, uses room types data to get min/max prices: + * - orderLowToHigh = true: Uses minimum price from room types (cheapest option) + * - orderLowToHigh = false: Uses maximum price from room types (most expensive option) * * @param {CardData[]} arr - Array of apartment card data to sort * @param {AptSortFields} property - Property to sort apartments by @@ -24,8 +29,22 @@ const sortApartments = (arr: CardData[], property: AptSortFields, orderLowToHigh return clonedArr.sort((r1, r2) => { let first, second; + // Special handling for price sorting with room types + if (property === 'avgPrice') { + const roomTypes1 = r1.buildingData?.roomTypes; + const roomTypes2 = r2.buildingData?.roomTypes; + + // For lowest price sorting, use min price; for highest price, use max price + if (orderLowToHigh) { + first = getMinPrice(roomTypes1 || []) ?? r1.avgPrice ?? 0; + second = getMinPrice(roomTypes2 || []) ?? r2.avgPrice ?? 0; + } else { + first = getMaxPrice(roomTypes1 || []) ?? r1.avgPrice ?? 0; + second = getMaxPrice(roomTypes2 || []) ?? r2.avgPrice ?? 0; + } + } //if property is a key of ApartmentWithId, then sort by that property using r1?.buildingData[property] - if (property in r1.buildingData) { + else if (property in r1.buildingData) { const prop = property as keyof ApartmentWithId; first = r1.buildingData?.[prop] ?? 0; second = r2.buildingData?.[prop] ?? 0; From 38314891420955ae6c15bfe7101e4f21d23f328f Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 15:43:54 -0500 Subject: [PATCH 08/10] Enhance Admin Apartment Data tab with search, filters, and improved layout - Rename "Data" tab to "Apartment Data" for clarity - Remove test apartments pagination and filtering logic - Add real-time search functionality: - Search by apartment name - Search by apartment address - Both searches reset pagination to first page - Add room types filter dropdown: - All Apartments (default) - With Room Types (show only apartments with room types data) - Without Room Types (show only apartments missing room types) - Reorganize apartment display with horizontal card layout: - Replace vertical list with compact card-based design - Organize information in 3 responsive columns (Location, Details, Stats) - Add visual distinction for apartments without room types (gray italic) - Position edit button in top-right corner of each card - Reduce vertical space usage by ~50% - Update display counter to show filtered vs total apartment counts - Simplify pagination logic by removing test apartment handling - Improve visual hierarchy with section labels and consistent spacing This makes it easier for admins to find and manage apartments, especially when populating room types data after migration. --- frontend/src/pages/AdminPage.tsx | 338 +++++++++++++++++++++---------- 1 file changed, 228 insertions(+), 110 deletions(-) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index c3de50b1..1511b434 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -147,6 +147,11 @@ const AdminPage = (): ReactElement => { const [currentPage, setCurrentPage] = useState(1); const apartmentsPerPage = 50; + // Search and filter state for Apartment Data tab + const [searchName, setSearchName] = useState(''); + const [searchAddress, setSearchAddress] = useState(''); + const [roomTypeFilter, setRoomTypeFilter] = useState<'all' | 'with' | 'without'>('all'); + // Migration state const [migrationStatus, setMigrationStatus] = useState< 'idle' | 'preview' | 'running' | 'complete' | 'error' @@ -183,8 +188,34 @@ const AdminPage = (): ReactElement => { setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); }; - // First, sort all apartments by ID to get the correct page ranges - const allApartmentsSorted = [...apartments].sort((a, b) => { + // Apply search and filter + const filteredApartments = apartments.filter((apt) => { + const aptData = apt.buildingData; + if (!aptData) return false; + + // Apply name search + if (searchName && !aptData.name?.toLowerCase().includes(searchName.toLowerCase())) { + return false; + } + + // Apply address search + if (searchAddress && !aptData.address?.toLowerCase().includes(searchAddress.toLowerCase())) { + return false; + } + + // Apply room type filter + if (roomTypeFilter === 'with' && (!aptData.roomTypes || aptData.roomTypes.length === 0)) { + return false; + } + if (roomTypeFilter === 'without' && aptData.roomTypes && aptData.roomTypes.length > 0) { + return false; + } + + return true; + }); + + // Sort all apartments by ID to get the correct page ranges + const allApartmentsSorted = [...filteredApartments].sort((a, b) => { const aId = a.buildingData?.id || ''; const bId = b.buildingData?.id || ''; const aNum = parseInt(aId, 10) || 0; @@ -192,24 +223,11 @@ const AdminPage = (): ReactElement => { return aNum - bNum; // Always sort ascending to get correct page ranges }); - // Filter test apartments (IDs > 1000) - const testApartments = allApartmentsSorted.filter((apt) => { - const id = parseInt(apt.buildingData?.id || '0', 10); - return id > 1000; - }); - - // Determine if we're showing test apartments or regular apartments - const isTestPage = currentPage > Math.ceil(allApartmentsSorted.length / apartmentsPerPage); - const apartmentsToShow = isTestPage ? testApartments : allApartmentsSorted; - - // Pagination logic - get the correct apartments for the current page - const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); - const testPages = Math.ceil(testApartments.length / apartmentsPerPage); - const totalPages = regularPages + (testPages > 0 ? 1 : 0); // +1 for test apartments page - - const startIndex = isTestPage ? 0 : (currentPage - 1) * apartmentsPerPage; - const endIndex = isTestPage ? apartmentsPerPage : startIndex + apartmentsPerPage; - const pageApartments = apartmentsToShow.slice(startIndex, endIndex); + // Pagination logic + const totalPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); + const startIndex = (currentPage - 1) * apartmentsPerPage; + const endIndex = startIndex + apartmentsPerPage; + const pageApartments = allApartmentsSorted.slice(startIndex, endIndex); // Then sort within the page based on the sort order const currentPageApartments = [...pageApartments].sort((a, b) => { @@ -462,17 +480,9 @@ const AdminPage = (): ReactElement => { }; const getPageRange = (page: number) => { - const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); - - if (page > regularPages) { - // This is the test apartments page - return `Test (${testApartments.length})`; - } else { - // Regular page - const start = (page - 1) * apartmentsPerPage + 1; - const end = Math.min(page * apartmentsPerPage, allApartmentsSorted.length); - return `${start}-${end}`; - } + const start = (page - 1) * apartmentsPerPage + 1; + const end = Math.min(page * apartmentsPerPage, allApartmentsSorted.length); + return `${start}-${end}`; }; // calls the APIs and the callback function to set the reviews for each review type @@ -883,14 +893,9 @@ const AdminPage = (): ReactElement => { All Apartments ({apartments.length}) - Sorted within page: {sortOrder === 'asc' ? 'Ascending' : 'Descending'} | - {isTestPage ? ( - <>Showing: Test Apartments ({testApartments.length}) - ) : ( - <> - Showing: {getPageRange(currentPage)} of {apartments.length} - - )} + Sorted within page: {sortOrder === 'asc' ? 'Ascending' : 'Descending'} | Showing:{' '} + {getPageRange(currentPage)} of {filteredApartments.length} filtered ( + {apartments.length} total)
@@ -906,6 +911,67 @@ const AdminPage = (): ReactElement => {
+ {/* Search and Filter Controls */} + + + + { + setSearchName(e.target.value); + setCurrentPage(1); // Reset to first page on search + }} + placeholder="Enter apartment name..." + /> + + + { + setSearchAddress(e.target.value); + setCurrentPage(1); // Reset to first page on search + }} + placeholder="Enter address..." + /> + + + { + setRoomTypeFilter(e.target.value as 'all' | 'with' | 'without'); + setCurrentPage(1); // Reset to first page on filter change + }} + SelectProps={{ native: true }} + > + + + + + + + + {/* Pagination Buttons */}
{ justifyContent: 'center', }} > - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => { - const regularPages = Math.ceil(allApartmentsSorted.length / apartmentsPerPage); - const isTestPageButton = page > regularPages; + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + ))} +
+
+ {currentPageApartments.map((apartment, index) => { + const aptData = apartment.buildingData; + const hasRoomTypes = aptData?.roomTypes && aptData.roomTypes.length > 0; return ( - - ); - })} -
- - {currentPageApartments.map((apartment, index) => { - return ( - + {/* Edit Button */} handleEditClick(apartment)} size="small" - title="Edit room types" + title="Edit apartment" > - + - - {apartment.buildingData?.name || 'N/A'} - - } - secondary={ -
- - Apartment ID: {apartment.buildingData?.id || 'N/A'} + {/* Apartment Name and ID */} + + {aptData?.name || 'N/A'} + + ID: {aptData?.id || 'N/A'} + + + + {/* Main Info Grid */} + + {/* Left Column - Location Info */} + + + + ADDRESS - - Address: {apartment.buildingData?.address || 'N/A'} + + {aptData?.address || 'N/A'} - - Location: {apartment.buildingData?.area || 'N/A'} + + AREA - -
- - Room Types:{' '} - {apartment.buildingData?.roomTypes && - apartment.buildingData.roomTypes.length > 0 - ? `${apartment.buildingData.roomTypes.length} type${ - apartment.buildingData.roomTypes.length > 1 ? 's' : '' - }` - : 'No room types'} - - - Company: {apartment.company || 'N/A'} - - - Reviews: {apartment.numReviews || 0} - - - Avg Rating:{' '} - {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} - - - Avg Price:{' '} - {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} - -
-
- } - /> -
+ {aptData?.area || 'N/A'} + + + + {/* Middle Column - Room Types & Company */} + + + + ROOM TYPES + + + {hasRoomTypes + ? `${aptData.roomTypes.length} type${ + aptData.roomTypes.length > 1 ? 's' : '' + }` + : 'No room types'} + + + COMPANY + + {apartment.company || 'N/A'} + + + + {/* Right Column - Stats */} + + + + + + REVIEWS + + {apartment.numReviews || 0} + + + + AVG RATING + + + {apartment.avgRating ? apartment.avgRating.toFixed(1) : 'N/A'} + + + + + AVG PRICE + + + {apartment.avgPrice ? `$${apartment.avgPrice.toFixed(0)}` : 'N/A'} + + + + + + + ); })} -
+ @@ -1273,7 +1391,7 @@ const AdminPage = (): ReactElement => { > - +
From f26cff8f53c0eae5b73dd1ba7be2a822ce7ef4b4 Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 15:59:47 -0500 Subject: [PATCH 09/10] Add Create New Apartment functionality to Admin Data tab - Add "Create New Apartment" button in Apartment Data tab header - Implement two-step apartment creation workflow: 1. Preview mode: Calculate location data from address 2. Confirm mode: Create apartment in database - Create modal dialog with required fields: - Apartment Name (text input) - Full Address (text input with geocoding support) - Landlord ID (text input) - Area (dropdown: Collegetown, West, North, Downtown, Other) - Display preview of calculated location data before creation: - Show latitude and longitude from geocoded address - Show calculated distance to campus - Confirm before final creation - Add comprehensive error handling and validation: - Form validation for required fields - Backend error display in modal - Loading states for preview and create actions - Integrate with existing `/api/admin/add-apartment` endpoint - Endpoint automatically geocodes address to get coordinates - Calculates walking distance to Ho Plaza - Validates landlord exists - Checks for duplicate locations - Auto-reload apartment list after successful creation - Show success message with generated apartment ID This streamlines the apartment creation process for admins by automating location data calculation and providing validation before committing changes to the database. --- frontend/src/pages/AdminPage.tsx | 249 +++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index 1511b434..2d717910 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -159,6 +159,20 @@ const AdminPage = (): ReactElement => { const [migrationSummary, setMigrationSummary] = useState(null); const [migrationProgress, setMigrationProgress] = useState(''); + // Create new apartment state + const [createModalOpen, setCreateModalOpen] = useState(false); + const [newApartmentName, setNewApartmentName] = useState(''); + const [newApartmentAddress, setNewApartmentAddress] = useState(''); + const [newApartmentLandlordId, setNewApartmentLandlordId] = useState(''); + const [newApartmentArea, setNewApartmentArea] = useState< + 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER' + >('COLLEGETOWN'); + const [createStatus, setCreateStatus] = useState<'idle' | 'preview' | 'creating' | 'error'>( + 'idle' + ); + const [previewData, setPreviewData] = useState(null); + const [createError, setCreateError] = useState(''); + // Debug apartments state changes useEffect(() => { console.log('Apartments loaded:', apartments.length); @@ -243,6 +257,105 @@ const AdminPage = (): ReactElement => { } }); + // Create new apartment handlers + const handleOpenCreateModal = () => { + setCreateModalOpen(true); + setCreateStatus('idle'); + setPreviewData(null); + setCreateError(''); + setNewApartmentName(''); + setNewApartmentAddress(''); + setNewApartmentLandlordId(''); + setNewApartmentArea('COLLEGETOWN'); + }; + + const handleCloseCreateModal = () => { + setCreateModalOpen(false); + setCreateStatus('idle'); + setPreviewData(null); + setCreateError(''); + }; + + const handlePreviewApartment = async () => { + if (!newApartmentName.trim() || !newApartmentAddress.trim() || !newApartmentLandlordId.trim()) { + setCreateError('Please fill in all required fields'); + return; + } + + try { + setCreateStatus('preview'); + setCreateError(''); + + const user = await getUser(); + if (!user) { + setCreateError('You must be logged in'); + setCreateStatus('error'); + return; + } + + const token = await user.getIdToken(true); + const response = await axios.post( + '/api/admin/add-apartment', + { + name: newApartmentName.trim(), + address: newApartmentAddress.trim(), + landlordId: newApartmentLandlordId.trim(), + area: newApartmentArea, + confirm: false, // Preview mode + }, + createAuthHeaders(token) + ); + + setPreviewData(response.data); + setCreateStatus('idle'); + } catch (error: any) { + console.error('Preview error:', error); + setCreateError(error.response?.data || error.message || 'Error previewing apartment'); + setCreateStatus('error'); + } + }; + + const handleConfirmCreate = async () => { + try { + setCreateStatus('creating'); + setCreateError(''); + + const user = await getUser(); + if (!user) { + setCreateError('You must be logged in'); + setCreateStatus('error'); + return; + } + + const token = await user.getIdToken(true); + const response = await axios.post( + '/api/admin/add-apartment', + { + name: newApartmentName.trim(), + address: newApartmentAddress.trim(), + landlordId: newApartmentLandlordId.trim(), + area: newApartmentArea, + confirm: true, // Create mode + }, + createAuthHeaders(token) + ); + + alert(`Apartment created successfully! ID: ${response.data.apartmentId}`); + setCreateModalOpen(false); + setCreateStatus('idle'); + setPreviewData(null); + + // Reload apartments list + setTimeout(() => { + window.location.reload(); + }, 1000); + } catch (error: any) { + console.error('Create error:', error); + setCreateError(error.response?.data || error.message || 'Error creating apartment'); + setCreateStatus('error'); + } + }; + // Migration handler functions const handleMigrationPreview = async () => { try { @@ -899,6 +1012,14 @@ const AdminPage = (): ReactElement => {
+ + {!previewData ? ( + + ) : ( + + )} + +
); }; From e2839fe2f3697711ab508d83858fdc339fe5a82a Mon Sep 17 00:00:00 2001 From: CasperL1218 Date: Fri, 21 Nov 2025 22:37:30 -0500 Subject: [PATCH 10/10] Fix search UX and improve results page layout - Persist filter state and query text across search navigation - Auto-close autocomplete dropdown after search submission - Add red color indicator for active filter sections on search results page - Improve search results layout: scrollable apartment list (3/5 width) with sticky map (2/5 width, max 650px) - Reduce apartment card gaps (6px column, 12px row) and fix clickable regions - Show empty state message when no exact matches found - Only display "No search results" in autocomplete when user has typed text --- .../SearchResultsPageApartmentCards.tsx | 51 ++++------ .../src/components/Search/Autocomplete.tsx | 70 +++++++++++--- .../src/components/Search/FilterDropDown.tsx | 16 +++- .../src/components/Search/FilterSection.tsx | 28 +++++- frontend/src/pages/SearchResultsPage.tsx | 96 +++++++++++++++---- 5 files changed, 197 insertions(+), 64 deletions(-) diff --git a/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx b/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx index 0fc0773b..ba05055f 100644 --- a/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx +++ b/frontend/src/components/ApartmentCard/SearchResultsPageApartmentCards.tsx @@ -1,6 +1,6 @@ import React, { ReactElement, useEffect } from 'react'; import NewApartmentCard from './NewApartmentCard'; -import { Grid, Link, makeStyles, Button, Box, Typography } from '@material-ui/core'; +import { Link, makeStyles } from '@material-ui/core'; import { Link as RouterLink } from 'react-router-dom'; import { CardData } from '../../App'; import { loadingLength } from '../../constants/HomeConsts'; @@ -45,16 +45,9 @@ const useStyles = makeStyles({ cardsContainer: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', - gap: '12px', + columnGap: '6px', + rowGap: '12px', padding: '4px', - height: '600px', - overflowY: 'auto', - scrollbarWidth: 'none', // Firefox - '&::-webkit-scrollbar': { - // Chrome, Safari, Edge - display: 'none', - }, - '-ms-overflow-style': 'none', '@media (max-width: 600px)': { gridTemplateColumns: 'repeat(2, 1fr)', gap: '4px', @@ -127,27 +120,23 @@ const ApartmentCards = ({ ({ buildingData, numReviews, company, avgRating }, index) => { const { id } = buildingData; return ( - <> -
- - - -
- + + + ); } )} diff --git a/frontend/src/components/Search/Autocomplete.tsx b/frontend/src/components/Search/Autocomplete.tsx index ddd02569..75b56acd 100644 --- a/frontend/src/components/Search/Autocomplete.tsx +++ b/frontend/src/components/Search/Autocomplete.tsx @@ -55,12 +55,27 @@ const defaultFilters: FilterState = { const Autocomplete = ({ drawerOpen }: Props): ReactElement => { const [isMobile, setIsMobile] = useState(false); - const [filters, setFilters] = useState(defaultFilters); - const [openFilter, setOpenFilter] = useState(false); const location = useLocation(); const isHome = location.pathname === '/'; const isSearchResults = location.pathname.startsWith('/search'); + // Initialize filters and query from URL if on search results page + const getInitialState = () => { + if (isSearchResults) { + const params = new URLSearchParams(location.search); + const query = params.get('q') || ''; + const filtersParam = params.get('filters'); + const filters = filtersParam ? JSON.parse(decodeURIComponent(filtersParam)) : defaultFilters; + return { query, filters }; + } + return { query: '', filters: defaultFilters }; + }; + + const initialState = getInitialState(); + const [filters, setFilters] = useState(initialState.filters); + const [initialQuery] = useState(initialState.query); + const [openFilter, setOpenFilter] = useState(false); + const useStyles = makeStyles((theme) => ({ menuList: { position: 'absolute', @@ -181,14 +196,43 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { } = useStyles(); const inputRef = useRef(document.createElement('div')); const [loading, setLoading] = useState(false); - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(initialQuery); const [width, setWidth] = useState(inputRef.current?.offsetWidth); const [focus, setFocus] = useState(false); const [openMenu, setOpenMenu] = useState(false); const [options, setOptions] = useState([]); const [selected, setSelected] = useState(null); + const [isUserTyping, setIsUserTyping] = useState(false); const history = useHistory(); + // Update query and filters when URL changes (for search results page) + useEffect(() => { + if (isSearchResults) { + const params = new URLSearchParams(location.search); + const urlQuery = params.get('q') || ''; + const filtersParam = params.get('filters'); + const urlFilters = filtersParam + ? JSON.parse(decodeURIComponent(filtersParam)) + : defaultFilters; + + // Mark that this update is from URL, not user typing + setIsUserTyping(false); + setQuery(urlQuery); + setFilters(urlFilters); + setOpenMenu(false); + + // Blur the input to ensure dropdown doesn't show + setTimeout(() => { + if (inputRef.current) { + const textField = inputRef.current.querySelector('input'); + if (textField) { + textField.blur(); + } + } + }, 0); + } + }, [location.search, isSearchResults]); + useEffect(() => { const handleResize = () => setIsMobile(window.innerWidth <= 600); handleResize(); @@ -217,13 +261,13 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { if (event.key === 'ArrowDown') { setFocus(true); } else if (event.key === 'Enter' && checkIfSearchable()) { - setFocus(true); + setFocus(false); console.log('Current filter state:', filters); const filterParams = encodeURIComponent(JSON.stringify(filters)); console.log('Encoded filter params:', filterParams); - history.push(`/search?q=${query}&filters=${filterParams}`); - setQuery(''); + setIsUserTyping(false); setOpenMenu(false); + history.push(`/search?q=${query}&filters=${filterParams}`); } } @@ -232,8 +276,9 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { console.log('Current filter state:', filters); const filterParams = encodeURIComponent(JSON.stringify(filters)); console.log('Encoded filter params:', filterParams); + setIsUserTyping(false); + setOpenMenu(false); history.push(`/search?q=${query}&filters=${filterParams}`); - setQuery(''); } }; @@ -251,6 +296,7 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { setQuery(query); setSelected(null); setOpenFilter(false); + setIsUserTyping(true); // Mark that user is actively typing if (query !== '') { setLoading(true); } else { @@ -288,9 +334,9 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { autoFocusItem={focus} onKeyDown={handleListKeyDown} > - {options.length === 0 ? ( + {options.length === 0 && query.trim().length > 0 ? ( No search results. - ) : ( + ) : options.length === 0 ? null : ( options.map(({ id, name, address, label }, index) => { return ( { useEffect(() => { if (query === '') { setOpenMenu(false); - } else if (selected === null) { + } else if (selected === null && isUserTyping) { + // Only open menu if user is actively typing, not from URL setOpenMenu(true); } else { setOpenMenu(false); } - }, [query, selected]); + }, [query, selected, isUserTyping]); useEffect(() => { const handleResize = () => { @@ -578,6 +625,7 @@ const Autocomplete = ({ drawerOpen }: Props): ReactElement => { open={openFilter} handleSearch={handleSearchIconClick} isMobile={isMobile} + isSearchResultsPage={isSearchResults} /> diff --git a/frontend/src/components/Search/FilterDropDown.tsx b/frontend/src/components/Search/FilterDropDown.tsx index fd3c4c93..44c5a564 100644 --- a/frontend/src/components/Search/FilterDropDown.tsx +++ b/frontend/src/components/Search/FilterDropDown.tsx @@ -193,7 +193,21 @@ export default function FilterDropDown({ filters, onChange, label, isMobile, onA amenitiesRow, } = useStyles(); - const color = open ? '#000' : '#898989'; + // Check if this filter has active selections + const hasActiveFilters = () => { + switch (label) { + case 'Location': + return filters.locations.length > 0; + case 'Price': + return filters.minPrice !== '' || filters.maxPrice !== ''; + case 'Beds & Baths': + return filters.bedrooms > 0 || filters.bathrooms > 0; + default: + return false; + } + }; + + const color = hasActiveFilters() ? '#B94630' : open ? '#000' : '#898989'; const handleClick = (event: React.MouseEvent) => { setOpen(!open); diff --git a/frontend/src/components/Search/FilterSection.tsx b/frontend/src/components/Search/FilterSection.tsx index 90919df7..078d9b79 100644 --- a/frontend/src/components/Search/FilterSection.tsx +++ b/frontend/src/components/Search/FilterSection.tsx @@ -258,6 +258,7 @@ export type FilterSectionProps = { open: boolean; handleSearch: () => void; isMobile: boolean | undefined; + isSearchResultsPage?: boolean; }; const LOCATIONS: LocationType[] = ['Collegetown', 'North', 'West', 'Downtown']; @@ -317,6 +318,7 @@ const FilterSection: React.FC = ({ open, handleSearch, isMobile, + isSearchResultsPage = false, }) => { const { filterContainer, @@ -338,6 +340,13 @@ const FilterSection: React.FC = ({ searchButton, } = useStyles(); + // Check if each section has selections (only for search results page) + const hasLocationSelection = isSearchResultsPage && filters.locations.length > 0; + const hasPriceSelection = + isSearchResultsPage && (filters.minPrice !== '' || filters.maxPrice !== ''); + const hasBedBathSelection = + isSearchResultsPage && (filters.bedrooms > 0 || filters.bathrooms > 0); + const handleLocationChange = (location: LocationType) => { const newLocations = filters.locations.includes(location) ? filters.locations.filter((loc: LocationType) => loc !== location) @@ -365,6 +374,7 @@ const FilterSection: React.FC = ({ className={sectionTitle} style={{ marginLeft: '10px', + color: hasLocationSelection ? '#B94630' : 'rgba(0, 0, 0, 0.50)', }} > Location @@ -391,7 +401,14 @@ const FilterSection: React.FC = ({
- Price + + Price +
= ({
- Beds & Baths + + Beds & Baths +
Bedrooms
diff --git a/frontend/src/pages/SearchResultsPage.tsx b/frontend/src/pages/SearchResultsPage.tsx index cd548139..8a772110 100644 --- a/frontend/src/pages/SearchResultsPage.tsx +++ b/frontend/src/pages/SearchResultsPage.tsx @@ -28,16 +28,34 @@ const useStyles = makeStyles({ display: 'flex', flexDirection: 'row', gap: '12px', - height: '80vh', + height: 'calc(100vh - 200px)', + marginBottom: '40px', }, searchResultsContainer: { - flex: 1, + flex: 3, + overflowY: 'auto', + paddingRight: '8px', + '&::-webkit-scrollbar': { + width: '8px', + }, + '&::-webkit-scrollbar-track': { + background: '#f1f1f1', + borderRadius: '4px', + }, + '&::-webkit-scrollbar-thumb': { + background: '#888', + borderRadius: '4px', + '&:hover': { + background: '#555', + }, + }, }, mapContainer: { - flex: 1, + flex: 2, height: '100%', - width: '100%', - minHeight: '300px', + maxWidth: '650px', + position: 'sticky', + top: 0, }, sortDropDown: { display: 'flex', @@ -269,13 +287,33 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => {
{/* Main Results */} - + {searchResults.length === 0 ? ( +
+ + No exact matching apartments found + + + Try adjusting your filters or check the additional results below for similar + apartments. + +
+ ) : ( + + )} {/* Additional Location Section */} {additionalLocation.length > 0 && ( @@ -366,13 +404,33 @@ const SearchResultsPage = ({ user, setUser }: Props): ReactElement => {
{/* Main Results */} - + {searchResults.length === 0 ? ( +
+ + No exact matching apartments found + + + Try adjusting your filters or check the additional results below for similar + apartments. + +
+ ) : ( + + )} {/* Additional Location Section */} {additionalLocation.length > 0 && (