Improve map
This commit is contained in:
parent
400f733956
commit
51967fa880
7 changed files with 794 additions and 353 deletions
224
frontend/src/components/PropertiesPane.tsx
Normal file
224
frontend/src/components/PropertiesPane.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { Property } from '../types';
|
||||
|
||||
interface PropertiesPaneProps {
|
||||
properties: Property[];
|
||||
total: number;
|
||||
loading: boolean;
|
||||
hexagonId: string | null;
|
||||
onLoadMore: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type SortBy = 'price' | 'size' | 'energy';
|
||||
|
||||
export function PropertiesPane({
|
||||
properties,
|
||||
total,
|
||||
loading,
|
||||
hexagonId,
|
||||
onLoadMore,
|
||||
onClose,
|
||||
}: PropertiesPaneProps) {
|
||||
const [sortBy, setSortBy] = useState<SortBy>('price');
|
||||
|
||||
// Sort properties
|
||||
const sortedProperties = useMemo(() => {
|
||||
return [...properties].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'price':
|
||||
return ((b.latest_price as number) || 0) - ((a.latest_price as number) || 0);
|
||||
case 'size':
|
||||
return ((b.total_floor_area as number) || 0) - ((a.total_floor_area as number) || 0);
|
||||
case 'energy':
|
||||
return (a.current_energy_rating || 'Z').localeCompare(
|
||||
b.current_energy_rating || 'Z'
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [properties, sortBy]);
|
||||
|
||||
if (!hexagonId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
Click a hexagon to view properties
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold">Properties in Hexagon</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {properties.length} of {total} properties
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sort controls */}
|
||||
<div className="p-2 border-b border-gray-200">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortBy)}
|
||||
className="w-full p-2 border border-gray-300 rounded text-sm"
|
||||
>
|
||||
<option value="price">Price (High to Low)</option>
|
||||
<option value="size">Size (Large to Small)</option>
|
||||
<option value="energy">Energy Rating (Best to Worst)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Properties list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && properties.length === 0 ? (
|
||||
<div className="p-4">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{sortedProperties.map((property, idx) => (
|
||||
<PropertyCard key={idx} property={property} />
|
||||
))}
|
||||
{properties.length < total && (
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={loading}
|
||||
className="w-full p-4 text-blue-600 hover:bg-blue-50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Loading...' : `Load More (${total - properties.length} remaining)`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Property card component showing all fields
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
const formatNumber = (value: number | undefined, decimals = 0): string => {
|
||||
if (value === undefined) return '';
|
||||
return decimals > 0 ? value.toFixed(decimals) : Math.round(value).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-gray-100 hover:bg-gray-50">
|
||||
{/* Address */}
|
||||
<div className="font-semibold">{property.address || 'Unknown Address'}</div>
|
||||
<div className="text-sm text-gray-600">{property.postcode}</div>
|
||||
|
||||
{/* Price */}
|
||||
{property.latest_price && (
|
||||
<div className="mt-2 text-lg font-bold text-green-700">
|
||||
£{formatNumber(property.latest_price as number)}
|
||||
{property.price_per_sqm && (
|
||||
<span className="text-sm font-normal text-gray-600">
|
||||
{' '}
|
||||
(£{formatNumber(property.price_per_sqm as number)}/m²)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Property details grid */}
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
||||
{property.property_type && (
|
||||
<div>
|
||||
<span className="text-gray-600">Type:</span> {property.property_type}
|
||||
</div>
|
||||
)}
|
||||
{property.built_form && (
|
||||
<div>
|
||||
<span className="text-gray-600">Form:</span> {property.built_form}
|
||||
</div>
|
||||
)}
|
||||
{property.total_floor_area && (
|
||||
<div>
|
||||
<span className="text-gray-600">Area:</span> {formatNumber(property.total_floor_area as number)}m²
|
||||
</div>
|
||||
)}
|
||||
{property.number_habitable_rooms && (
|
||||
<div>
|
||||
<span className="text-gray-600">Rooms:</span>{' '}
|
||||
{formatNumber(property.number_habitable_rooms as number)}
|
||||
</div>
|
||||
)}
|
||||
{property.current_energy_rating && (
|
||||
<div>
|
||||
<span className="text-gray-600">Energy:</span> {property.current_energy_rating}
|
||||
</div>
|
||||
)}
|
||||
{property.potential_energy_rating && (
|
||||
<div>
|
||||
<span className="text-gray-600">Potential:</span> {property.potential_energy_rating}
|
||||
</div>
|
||||
)}
|
||||
{property.construction_age_band !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Built (age):</span> {formatNumber(property.construction_age_band as number)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Journey times */}
|
||||
{property.public_transport_easy_minutes && (
|
||||
<div>
|
||||
<span className="text-gray-600">PT (easy):</span>{' '}
|
||||
{formatNumber(property.public_transport_easy_minutes as number)}min
|
||||
</div>
|
||||
)}
|
||||
{property.public_transport_quick_minutes && (
|
||||
<div>
|
||||
<span className="text-gray-600">PT (quick):</span>{' '}
|
||||
{formatNumber(property.public_transport_quick_minutes as number)}min
|
||||
</div>
|
||||
)}
|
||||
{property.cycling_minutes && (
|
||||
<div>
|
||||
<span className="text-gray-600">Cycling:</span>{' '}
|
||||
{formatNumber(property.cycling_minutes as number)}min
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deprivation scores */}
|
||||
{property.income_score !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Income:</span>{' '}
|
||||
{formatNumber(property.income_score as number, 1)}
|
||||
</div>
|
||||
)}
|
||||
{property.employment_score !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Employment:</span>{' '}
|
||||
{formatNumber(property.employment_score as number, 1)}
|
||||
</div>
|
||||
)}
|
||||
{property.education_score !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Education:</span>{' '}
|
||||
{formatNumber(property.education_score as number, 1)}
|
||||
</div>
|
||||
)}
|
||||
{property.health_score !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Health:</span>{' '}
|
||||
{formatNumber(property.health_score as number, 1)}
|
||||
</div>
|
||||
)}
|
||||
{property.crime_score !== undefined && (
|
||||
<div>
|
||||
<span className="text-gray-600">Crime:</span>{' '}
|
||||
{formatNumber(property.crime_score as number, 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue