perfect-postcode/frontend/src/components/map/PropertiesPane.tsx
2026-02-15 22:39:53 +00:00

253 lines
8.9 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
import { getNum } from '../../lib/property-fields';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { EmptyState } from '../ui/EmptyState';
import { InfoIcon } from '../ui/icons';
interface PropertiesPaneProps {
properties: Property[];
total: number;
loading: boolean;
hexagonId: string | null;
onLoadMore: () => void;
onClose: () => void;
onNavigateToSource?: (slug: string) => void;
}
export function PropertiesPane({
properties,
total,
loading,
hexagonId,
onLoadMore,
onClose: _onClose,
onNavigateToSource,
}: PropertiesPaneProps) {
const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return query
? properties.filter((p) => {
const addr = (p.address || '').toLowerCase();
const pc = (p.postcode || '').toLowerCase();
return addr.includes(query) || pc.includes(query);
})
: properties;
}, [properties, search]);
if (!hexagonId) {
return (
<EmptyState
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title="No area selected"
description="Click a hexagon or postcode to view area statistics"
centered
/>
);
}
return (
<div className="flex flex-col h-full">
{showInfo && (
<InfoPopup
title="Property Data"
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: 'View data source',
onClick: () => {
onNavigateToSource('epc');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price
Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy
ratings, construction age, and tenure from EPC surveys, plus the most recent sale price
from the Land Registry.
</p>
</InfoPopup>
)}
<div className="p-2 border-b border-warm-200 dark:border-navy-700">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by address or postcode..."
className="p-2"
/>
</div>
<div className="flex-1 overflow-y-auto">
{loading && properties.length === 0 ? (
<PropertyLoadingSkeleton />
) : (
<>
{filtered.map((property, idx) => (
<PropertyCard key={idx} property={property} />
))}
{properties.length < total && (
<button
onClick={onLoadMore}
disabled={loading}
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
Loading...
</span>
) : (
`Load More (${total - properties.length} remaining)`
)}
</button>
)}
</>
)}
</div>
</div>
);
}
function PropertyLoadingSkeleton() {
return (
<div className="space-y-0">
{Array.from({ length: 5 }).map((_, idx) => (
<div key={idx} className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse">
{/* Address */}
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
{/* Postcode */}
<div className="h-4 w-24 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Price */}
<div className="h-6 w-32 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
{/* Property details grid */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-4 bg-warm-200 dark:bg-warm-700 rounded" />
))}
</div>
</div>
))}
</div>
);
}
function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price');
const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm');
const estPricePerSqm = getNum(property, 'Est. price per sqm');
const floorArea = getNum(property, 'Total floor area (sqm)');
const rooms = getNum(property, 'Rooms (including bedrooms & bathrooms)');
const age = getNum(property, 'Approximate construction age');
const transactionDate = getNum(property, 'Date of last transaction');
const councilTax = getNum(property, 'Council tax (£/yr)');
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
return (
<div className="p-4 border-b border-warm-100 dark:border-navy-800 hover:bg-warm-50 dark:hover:bg-navy-800">
<div className="font-semibold dark:text-warm-100">
{property.address || 'Unknown Address'}
</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
{price !== undefined && (
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
£{formatNumber(price)}
{transactionDate !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
({formatTransactionDate(transactionDate)})
</span>
)}
{pricePerSqm !== undefined && (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
{' '}
£{formatNumber(pricePerSqm)}/m²
</span>
)}
</div>
)}
{estimatedPrice !== undefined && (
<div className="text-sm text-warm-600 dark:text-warm-400">
Est. value:{' '}
<span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(estimatedPrice)}
</span>
{estPricePerSqm !== undefined && (
<span> (£{formatNumber(estPricePerSqm)}/m²)</span>
)}
</div>
)}
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
{property.property_type && (
<div>
<span className="text-warm-500 dark:text-warm-400">Type:</span> {property.property_type}
</div>
)}
{property.built_form && (
<div>
<span className="text-warm-500 dark:text-warm-400">Built form:</span>{' '}
{property.built_form}
</div>
)}
{property.duration && (
<div>
<span className="text-warm-500 dark:text-warm-400">Tenure:</span>{' '}
{formatDuration(property.duration)}
</div>
)}
{floorArea !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Floor area:</span>{' '}
{formatNumber(floorArea)}m²
</div>
)}
{rooms !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
</div>
)}
{age !== undefined && (
<div>
<span className="text-warm-500 dark:text-warm-400">Built:</span>{' '}
{formatAge(age, property.is_construction_date_approximate ?? true)}
</div>
)}
{property.current_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">EPC rating:</span>{' '}
{property.current_energy_rating}
</div>
)}
{property.potential_energy_rating && (
<div>
<span className="text-warm-500 dark:text-warm-400">EPC potential:</span>{' '}
{property.potential_energy_rating}
</div>
)}
{councilTax !== undefined ? (
<div>
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
{formatNumber(councilTax)}/yr
</div>
) : councilTaxD !== undefined ? (
<div>
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
{formatNumber(councilTaxD)}/yr
</div>
) : null}
</div>
</div>
);
}