Files
school_compare/nextjs-app/components/SchoolRow.tsx
T

186 lines
7.1 KiB
TypeScript
Raw Normal View History

/**
* SchoolRow Component
* Four-line row for primary school search results
*
* Line 1: School name · Ofsted badge (framework-aware)
* Line 2: School type · Age range · Denomination · Gender
* Line 3: Reading, Writing & Maths % · trend arrow · vs-national delta · Pupils
* Line 4: Local authority · Distance
*/
import type { School } from '@/lib/types';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
import styles from './SchoolRow.module.css';
interface SchoolRowProps {
school: School;
isLocationSearch?: boolean;
isInCompare?: boolean;
onAddToCompare?: (school: School) => void;
onRemoveFromCompare?: (urn: number) => void;
nationalAvgRwm?: number | null;
}
export function SchoolRow({
school,
isLocationSearch,
isInCompare = false,
onAddToCompare,
onRemoveFromCompare,
nationalAvgRwm,
}: SchoolRowProps) {
const trend = calculateTrend(school.rwm_expected_pct, school.prev_rwm_expected_pct);
const phase = getPhaseStyle(school.phase);
const ofstedBadge = buildOfstedListBadge(school);
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
const showDenomination =
school.religious_denomination &&
school.religious_denomination !== 'Does not apply';
// A placeholder all-zero row (every subject 0 — a special/suppressed
// signature, matching SchoolDetailView's ks2Placeholder) isn't a real score,
// so its figure is hidden. A genuine 0% combined (some pupils met individual
// subjects but not all three) is NOT all-zero and stays shown.
const rwmPlaceholder =
school.rwm_expected_pct === 0 &&
(school.reading_expected_pct ?? 0) === 0 &&
(school.writing_expected_pct ?? 0) === 0 &&
(school.maths_expected_pct ?? 0) === 0;
// The school's OWN figure and its year-over-year trend are same-school
// measures — shown whenever there's a real value, special schools included.
const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder;
// The vs-England delta is a mainstream benchmark: additionally dropped for
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
const rwmDelta =
showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null
? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm)
: null;
const handleCompareClick = () => {
if (isInCompare) {
onRemoveFromCompare?.(school.urn);
} else {
onAddToCompare?.(school);
}
};
return (
<div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}>
{/* Left: four content lines */}
<div className={styles.rowContent}>
{/* Line 1: School name + Ofsted badge */}
<div className={styles.line1}>
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
{school.school_name}
</a>
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
{ofstedBadge.label}
</span>
</div>
{/* Line 2: Context tags */}
<div className={styles.line2}>
{phase.label && (
<span className={`${styles.phaseLabel} ${styles[`phaseLabel${phase.key}`]}`}>
{phase.label}
</span>
)}
{school.school_type && <span className={styles.attr}>{school.school_type}</span>}
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>}
{showGender && <span className={styles.attr}>{school.gender}</span>}
{isProposedToClose(school) && (
<span className={`${styles.attr} ${styles.attrClosing}`}> Proposed to close</span>
)}
</div>
{/* Line 3: Key stats */}
<div className={styles.line3}>
<span className={styles.stat}>
<strong className={styles.statValue}>
{showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
</strong>
{showRwmValue && school.prev_rwm_expected_pct != null && (
<span
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
>
{trend === 'up' && (
<svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend up">
<path d="M8 3L14 10H2L8 3Z" fill="currentColor" />
</svg>
)}
{trend === 'down' && (
<svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend down">
<path d="M8 13L2 6H14L8 13Z" fill="currentColor" />
</svg>
)}
{trend === 'stable' && (
<svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend stable">
<rect x="2" y="7" width="12" height="2" rx="1" fill="currentColor" />
</svg>
)}
</span>
)}
<span className={styles.statLabel}>Reading, Writing &amp; Maths</span>
{rwmDelta != null && (
<span
className={
rwmDelta >= 2
? styles.vsNational
: rwmDelta <= -2
? styles.vsNationalNeg
: styles.vsNationalFlat
}
>
{rwmDelta >= 2
? `+${rwmDelta} pts vs national`
: rwmDelta <= -2
? `${rwmDelta} pts vs national`
: '≈ national avg'}
</span>
)}
</span>
{school.total_pupils != null && (
<span className={styles.stat}>
<strong className={styles.statValue}>{school.total_pupils.toLocaleString()}</strong>
<span className={styles.statLabel}>pupils</span>
</span>
)}
</div>
{/* Line 4: Location + distance */}
<div className={styles.line4}>
{school.local_authority && (
<span>{school.local_authority}</span>
)}
{isLocationSearch && school.distance != null && (
<span className={styles.distanceBadge}>
{school.distance.toFixed(1)} mi
</span>
)}
</div>
</div>
{/* Right: actions, vertically centred */}
<div className={styles.rowActions}>
<a href={schoolUrl(school.urn, school.school_name)} className="btn btn-tertiary btn-sm">
View
</a>
{(onAddToCompare || onRemoveFromCompare) && (
<button
onClick={handleCompareClick}
className={isInCompare ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm'}
>
{isInCompare ? '✓ Comparing' : '+ Compare'}
</button>
)}
</div>
</div>
);
}