Changed 'equity' to 'disadvantaged' and 'trends' to '3yr' to match the MetricDefinition category type. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
264 lines
9.3 KiB
TypeScript
264 lines
9.3 KiB
TypeScript
/**
|
|
* RankingsView Component
|
|
* Client-side rankings interface with filters
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
|
import { useComparison } from '@/hooks/useComparison';
|
|
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
|
import { formatPercentage, formatProgress } from '@/lib/utils';
|
|
import styles from './RankingsView.module.css';
|
|
|
|
interface RankingsViewProps {
|
|
rankings: RankingEntry[];
|
|
filters: Filters;
|
|
metrics: MetricDefinition[];
|
|
selectedMetric: string;
|
|
selectedArea?: string;
|
|
selectedYear?: number;
|
|
}
|
|
|
|
export function RankingsView({
|
|
rankings,
|
|
filters,
|
|
metrics,
|
|
selectedMetric,
|
|
selectedArea,
|
|
selectedYear,
|
|
}: RankingsViewProps) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const { addSchool, isSelected } = useComparison();
|
|
|
|
const updateFilters = (updates: Record<string, string | undefined>) => {
|
|
const params = new URLSearchParams(searchParams);
|
|
|
|
Object.entries(updates).forEach(([key, value]) => {
|
|
if (value) {
|
|
params.set(key, value);
|
|
} else {
|
|
params.delete(key);
|
|
}
|
|
});
|
|
|
|
router.push(`${pathname}?${params.toString()}`);
|
|
};
|
|
|
|
const handleMetricChange = (metric: string) => {
|
|
updateFilters({ metric });
|
|
};
|
|
|
|
const handleAreaChange = (area: string) => {
|
|
updateFilters({ local_authority: area || undefined });
|
|
};
|
|
|
|
const handleYearChange = (year: string) => {
|
|
updateFilters({ year: year || undefined });
|
|
};
|
|
|
|
const handleAddToCompare = (ranking: RankingEntry) => {
|
|
addSchool({
|
|
...ranking,
|
|
// Ensure required School fields are present
|
|
address: null,
|
|
postcode: null,
|
|
latitude: null,
|
|
longitude: null,
|
|
} as any);
|
|
};
|
|
|
|
// Get metric definition
|
|
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
|
const metricLabel = currentMetricDef?.label || selectedMetric;
|
|
const isProgressScore = selectedMetric.includes('progress');
|
|
const isPercentage = selectedMetric.includes('pct') || selectedMetric.includes('rate');
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
{/* Header */}
|
|
<header className={styles.header}>
|
|
<h1>School Rankings</h1>
|
|
<p className={styles.subtitle}>
|
|
Top-performing schools by {metricLabel.toLowerCase()}
|
|
</p>
|
|
</header>
|
|
|
|
{/* Filters */}
|
|
<section className={styles.filters}>
|
|
<div className={styles.filterGroup}>
|
|
<label htmlFor="metric-select" className={styles.filterLabel}>
|
|
Metric:
|
|
</label>
|
|
<select
|
|
id="metric-select"
|
|
value={selectedMetric}
|
|
onChange={(e) => handleMetricChange(e.target.value)}
|
|
className={styles.filterSelect}
|
|
>
|
|
<optgroup label="Expected Standard">
|
|
{metrics.filter(m => m.category === 'expected').map((metric) => (
|
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
))}
|
|
</optgroup>
|
|
<optgroup label="Higher Standard">
|
|
{metrics.filter(m => m.category === 'higher').map((metric) => (
|
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
))}
|
|
</optgroup>
|
|
<optgroup label="Progress Scores">
|
|
{metrics.filter(m => m.category === 'progress').map((metric) => (
|
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
))}
|
|
</optgroup>
|
|
<optgroup label="Average Scores">
|
|
{metrics.filter(m => m.category === 'average').map((metric) => (
|
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
))}
|
|
</optgroup>
|
|
<optgroup label="Gender Performance">
|
|
{metrics.filter(m => m.category === 'gender').map((metric) => (
|
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
))}
|
|
</optgroup>
|
|
<optgroup label="Equity (Disadvantaged)">
|
|
{metrics.filter(m => m.category === 'disadvantaged').map((metric) => (
|
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
))}
|
|
</optgroup>
|
|
<optgroup label="School Context">
|
|
{metrics.filter(m => m.category === 'context').map((metric) => (
|
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
))}
|
|
</optgroup>
|
|
<optgroup label="3-Year Trends">
|
|
{metrics.filter(m => m.category === '3yr').map((metric) => (
|
|
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
))}
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
|
|
<div className={styles.filterGroup}>
|
|
<label htmlFor="area-select" className={styles.filterLabel}>
|
|
Area:
|
|
</label>
|
|
<select
|
|
id="area-select"
|
|
value={selectedArea || ''}
|
|
onChange={(e) => handleAreaChange(e.target.value)}
|
|
className={styles.filterSelect}
|
|
>
|
|
<option value="">All Areas</option>
|
|
{filters.local_authorities.map((area) => (
|
|
<option key={area} value={area}>
|
|
{area}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className={styles.filterGroup}>
|
|
<label htmlFor="year-select" className={styles.filterLabel}>
|
|
Year:
|
|
</label>
|
|
<select
|
|
id="year-select"
|
|
value={selectedYear?.toString() || ''}
|
|
onChange={(e) => handleYearChange(e.target.value)}
|
|
className={styles.filterSelect}
|
|
>
|
|
<option value="">Latest</option>
|
|
{filters.years.map((year) => (
|
|
<option key={year} value={year}>
|
|
{year}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Rankings Table */}
|
|
<section className={styles.rankingsSection}>
|
|
{rankings.length === 0 ? (
|
|
<div className={styles.noResults}>
|
|
<p>No rankings available for the selected filters.</p>
|
|
</div>
|
|
) : (
|
|
<div className={styles.tableWrapper}>
|
|
<table className={styles.rankingsTable}>
|
|
<thead>
|
|
<tr>
|
|
<th className={styles.rankHeader}>Rank</th>
|
|
<th className={styles.schoolHeader}>School</th>
|
|
<th className={styles.areaHeader}>Area</th>
|
|
<th className={styles.typeHeader}>Type</th>
|
|
<th className={styles.valueHeader}>{metricLabel}</th>
|
|
<th className={styles.actionHeader}>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rankings.map((ranking, index) => {
|
|
const rank = index + 1;
|
|
const isTopThree = rank <= 3;
|
|
const alreadyInComparison = isSelected(ranking.urn);
|
|
|
|
// Format the value
|
|
let displayValue: string;
|
|
if (ranking.value === null || ranking.value === undefined) {
|
|
displayValue = '-';
|
|
} else if (isProgressScore) {
|
|
displayValue = formatProgress(ranking.value);
|
|
} else if (isPercentage) {
|
|
displayValue = formatPercentage(ranking.value);
|
|
} else {
|
|
displayValue = ranking.value.toFixed(1);
|
|
}
|
|
|
|
return (
|
|
<tr
|
|
key={ranking.urn}
|
|
className={isTopThree ? styles[`rank${rank}`] : ''}
|
|
>
|
|
<td className={styles.rankCell}>
|
|
{isTopThree ? (
|
|
<span className={`${styles.rankBadge} ${styles[`rankBadge${rank}`]}`}>
|
|
{rank}
|
|
</span>
|
|
) : (
|
|
<span className={styles.rankNumber}>{rank}</span>
|
|
)}
|
|
</td>
|
|
<td className={styles.schoolCell}>
|
|
<a href={`/school/${ranking.urn}`} className={styles.schoolLink}>
|
|
{ranking.school_name}
|
|
</a>
|
|
</td>
|
|
<td className={styles.areaCell}>{ranking.local_authority || '-'}</td>
|
|
<td className={styles.typeCell}>{ranking.school_type || '-'}</td>
|
|
<td className={styles.valueCell}>
|
|
<strong>{displayValue}</strong>
|
|
</td>
|
|
<td className={styles.actionCell}>
|
|
<button
|
|
onClick={() => handleAddToCompare(ranking)}
|
|
disabled={alreadyInComparison}
|
|
className={styles.addButton}
|
|
>
|
|
{alreadyInComparison ? '✓ Added' : '+ Add'}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|