updates for secondary schools
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 46s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m15s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 46s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m15s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 32s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
This commit is contained in:
@@ -218,6 +218,9 @@ async def get_schools(
|
|||||||
radius: float = Query(5.0, ge=0.1, le=50, description="Search radius in miles"),
|
radius: float = Query(5.0, ge=0.1, le=50, description="Search radius in miles"),
|
||||||
page: int = Query(1, ge=1, le=1000, description="Page number"),
|
page: int = Query(1, ge=1, le=1000, description="Page number"),
|
||||||
page_size: int = Query(None, ge=1, le=100, description="Results per page"),
|
page_size: int = Query(None, ge=1, le=100, description="Results per page"),
|
||||||
|
gender: Optional[str] = Query(None, description="Filter by gender (Mixed/Boys/Girls)", max_length=50),
|
||||||
|
admissions_policy: Optional[str] = Query(None, description="Filter by admissions policy", max_length=100),
|
||||||
|
has_sixth_form: Optional[str] = Query(None, description="Filter by sixth form presence: yes/no", max_length=3),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get list of schools with pagination.
|
Get list of schools with pagination.
|
||||||
@@ -253,6 +256,13 @@ async def get_schools(
|
|||||||
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
||||||
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
||||||
)
|
)
|
||||||
|
if "attainment_8_score" in df_prev.columns:
|
||||||
|
prev_rwm = prev_rwm.merge(
|
||||||
|
df_prev[["urn", "attainment_8_score"]].rename(
|
||||||
|
columns={"attainment_8_score": "prev_attainment_8_score"}
|
||||||
|
),
|
||||||
|
on="urn", how="outer"
|
||||||
|
)
|
||||||
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
|
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
|
||||||
|
|
||||||
# Phase filter
|
# Phase filter
|
||||||
@@ -270,6 +280,16 @@ async def get_schools(
|
|||||||
schools_df_phase_mask = df_latest["phase"].str.lower().str.contains(phase_substr, na=False)
|
schools_df_phase_mask = df_latest["phase"].str.lower().str.contains(phase_substr, na=False)
|
||||||
df_latest = df_latest[schools_df_phase_mask]
|
df_latest = df_latest[schools_df_phase_mask]
|
||||||
|
|
||||||
|
# Secondary-specific filters (after phase filter)
|
||||||
|
if gender:
|
||||||
|
df_latest = df_latest[df_latest["gender"].str.lower() == gender.lower()]
|
||||||
|
if admissions_policy:
|
||||||
|
df_latest = df_latest[df_latest["admissions_policy"].str.lower() == admissions_policy.lower()]
|
||||||
|
if has_sixth_form == "yes":
|
||||||
|
df_latest = df_latest[df_latest["age_range"].str.contains("18", na=False)]
|
||||||
|
elif has_sixth_form == "no":
|
||||||
|
df_latest = df_latest[~df_latest["age_range"].str.contains("18", na=False)]
|
||||||
|
|
||||||
# Include key result metrics for display on cards
|
# Include key result metrics for display on cards
|
||||||
location_cols = ["latitude", "longitude"]
|
location_cols = ["latitude", "longitude"]
|
||||||
result_cols = [
|
result_cols = [
|
||||||
@@ -278,6 +298,7 @@ async def get_schools(
|
|||||||
"rwm_expected_pct",
|
"rwm_expected_pct",
|
||||||
"rwm_high_pct",
|
"rwm_high_pct",
|
||||||
"prev_rwm_expected_pct",
|
"prev_rwm_expected_pct",
|
||||||
|
"prev_attainment_8_score",
|
||||||
"reading_expected_pct",
|
"reading_expected_pct",
|
||||||
"writing_expected_pct",
|
"writing_expected_pct",
|
||||||
"maths_expected_pct",
|
"maths_expected_pct",
|
||||||
@@ -510,14 +531,33 @@ async def get_filter_options(request: Request):
|
|||||||
# Phases: return values from data, ordered sensibly
|
# Phases: return values from data, ordered sensibly
|
||||||
phases = sorted(df["phase"].dropna().unique().tolist()) if "phase" in df.columns else []
|
phases = sorted(df["phase"].dropna().unique().tolist()) if "phase" in df.columns else []
|
||||||
|
|
||||||
|
secondary_df = df[df["attainment_8_score"].notna()] if "attainment_8_score" in df.columns else df.iloc[0:0]
|
||||||
|
genders = sorted(secondary_df["gender"].dropna().unique().tolist()) if "gender" in secondary_df.columns else []
|
||||||
|
admissions_policies = sorted(secondary_df["admissions_policy"].dropna().unique().tolist()) if "admissions_policy" in secondary_df.columns else []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
|
"local_authorities": sorted(df["local_authority"].dropna().unique().tolist()),
|
||||||
"school_types": sorted(df["school_type"].dropna().unique().tolist()),
|
"school_types": sorted(df["school_type"].dropna().unique().tolist()),
|
||||||
"years": sorted(df["year"].dropna().unique().tolist()),
|
"years": sorted(df["year"].dropna().unique().tolist()),
|
||||||
"phases": phases,
|
"phases": phases,
|
||||||
|
"genders": genders,
|
||||||
|
"admissions_policies": admissions_policies,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/la-averages")
|
||||||
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
|
async def get_la_averages(request: Request):
|
||||||
|
"""Get per-LA average Attainment 8 score for secondary schools in the latest year."""
|
||||||
|
df = load_school_data()
|
||||||
|
if df.empty:
|
||||||
|
return {"year": 0, "secondary": {"attainment_8_by_la": {}}}
|
||||||
|
latest_year = int(df["year"].max())
|
||||||
|
sec_df = df[(df["year"] == latest_year) & df["attainment_8_score"].notna()]
|
||||||
|
la_avg = sec_df.groupby("local_authority")["attainment_8_score"].mean().round(1).to_dict()
|
||||||
|
return {"year": latest_year, "secondary": {"attainment_8_by_la": la_avg}}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/national-averages")
|
@app.get("/api/national-averages")
|
||||||
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
|
||||||
async def get_national_averages(request: Request):
|
async def get_national_averages(request: Request):
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ _MAIN_QUERY = text("""
|
|||||||
s.religious_character AS religious_denomination,
|
s.religious_character AS religious_denomination,
|
||||||
s.gender,
|
s.gender,
|
||||||
s.age_range,
|
s.age_range,
|
||||||
|
s.admissions_policy,
|
||||||
s.capacity,
|
s.capacity,
|
||||||
s.headteacher_name,
|
s.headteacher_name,
|
||||||
s.website,
|
s.website,
|
||||||
@@ -211,6 +212,7 @@ _MAIN_QUERY = text("""
|
|||||||
s.religious_character AS religious_denomination,
|
s.religious_character AS religious_denomination,
|
||||||
s.gender,
|
s.gender,
|
||||||
s.age_range,
|
s.age_range,
|
||||||
|
s.admissions_policy,
|
||||||
s.capacity,
|
s.capacity,
|
||||||
s.headteacher_name,
|
s.headteacher_name,
|
||||||
s.website,
|
s.website,
|
||||||
|
|||||||
@@ -543,6 +543,10 @@ SCHOOL_COLUMNS = [
|
|||||||
"postcode",
|
"postcode",
|
||||||
"religious_denomination",
|
"religious_denomination",
|
||||||
"age_range",
|
"age_range",
|
||||||
|
"gender",
|
||||||
|
"admissions_policy",
|
||||||
|
"ofsted_grade",
|
||||||
|
"ofsted_date",
|
||||||
"latitude",
|
"latitude",
|
||||||
"longitude",
|
"longitude",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
return (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
initialSchools={schoolsData}
|
initialSchools={schoolsData}
|
||||||
filters={filtersData || { local_authorities: [], school_types: [], years: [], phases: [] }}
|
filters={filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||||
totalSchools={dataInfo?.total_schools ?? null}
|
totalSchools={dataInfo?.total_schools ?? null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -79,7 +79,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
return (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
||||||
filters={{ local_authorities: [], school_types: [], years: [], phases: [] }}
|
filters={{ local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||||
totalSchools={null}
|
totalSchools={null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
|||||||
return (
|
return (
|
||||||
<RankingsView
|
<RankingsView
|
||||||
rankings={rankingsResponse?.rankings || []}
|
rankings={rankingsResponse?.rankings || []}
|
||||||
filters={filtersResponse || { local_authorities: [], school_types: [], years: [], phases: [] }}
|
filters={filtersResponse || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||||
metrics={metricsArray}
|
metrics={metricsArray}
|
||||||
selectedMetric={metric}
|
selectedMetric={metric}
|
||||||
selectedArea={local_authority}
|
selectedArea={local_authority}
|
||||||
@@ -63,7 +63,7 @@ export default async function RankingsPage({ searchParams }: RankingsPageProps)
|
|||||||
return (
|
return (
|
||||||
<RankingsView
|
<RankingsView
|
||||||
rankings={[]}
|
rankings={[]}
|
||||||
filters={{ local_authorities: [], school_types: [], years: [], phases: [] }}
|
filters={{ local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
||||||
metrics={[]}
|
metrics={[]}
|
||||||
selectedMetric={metric}
|
selectedMetric={metric}
|
||||||
selectedArea={local_authority}
|
selectedArea={local_authority}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { fetchSchoolDetails } from '@/lib/api';
|
import { fetchSchoolDetails } from '@/lib/api';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||||
|
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
interface SchoolPageProps {
|
interface SchoolPageProps {
|
||||||
@@ -26,13 +27,18 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
const data = await fetchSchoolDetails(urn);
|
const data = await fetchSchoolDetails(urn);
|
||||||
const { school_info } = data;
|
const { school_info } = data;
|
||||||
|
|
||||||
|
const isSecondary = (school_info.phase ?? '').toLowerCase().includes('secondary');
|
||||||
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
|
const title = `${school_info.school_name} | ${school_info.local_authority || 'England'}`;
|
||||||
const description = `View KS2 performance data, results, and statistics for ${school_info.school_name}${school_info.local_authority ? ` in ${school_info.local_authority}` : ''}. Compare reading, writing, and maths results.`;
|
const description = isSecondary
|
||||||
|
? `View GCSE results, Attainment 8, Progress 8 and school statistics for ${school_info.school_name}${school_info.local_authority ? ` in ${school_info.local_authority}` : ''}.`
|
||||||
|
: `View KS2 performance data, results, and statistics for ${school_info.school_name}${school_info.local_authority ? ` in ${school_info.local_authority}` : ''}. Compare reading, writing, and maths results.`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
keywords: `${school_info.school_name}, KS2 results, primary school, ${school_info.local_authority}, school performance, SATs results`,
|
keywords: isSecondary
|
||||||
|
? `${school_info.school_name}, GCSE results, secondary school, ${school_info.local_authority}, Attainment 8, Progress 8`
|
||||||
|
: `${school_info.school_name}, KS2 results, primary school, ${school_info.local_authority}, school performance, SATs results`,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -79,6 +85,8 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
|
|
||||||
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
|
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance } = data;
|
||||||
|
|
||||||
|
const isSecondary = (school_info.phase ?? '').toLowerCase().includes('secondary');
|
||||||
|
|
||||||
// Generate JSON-LD structured data for SEO
|
// Generate JSON-LD structured data for SEO
|
||||||
const structuredData = {
|
const structuredData = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
@@ -112,6 +120,21 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||||
/>
|
/>
|
||||||
|
{isSecondary ? (
|
||||||
|
<SecondarySchoolDetailView
|
||||||
|
schoolInfo={school_info}
|
||||||
|
yearlyData={yearly_data}
|
||||||
|
absenceData={absence_data}
|
||||||
|
ofsted={ofsted ?? null}
|
||||||
|
parentView={parent_view ?? null}
|
||||||
|
census={census ?? null}
|
||||||
|
admissions={admissions ?? null}
|
||||||
|
senDetail={sen_detail ?? null}
|
||||||
|
phonics={phonics ?? null}
|
||||||
|
deprivation={deprivation ?? null}
|
||||||
|
finance={finance ?? null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<SchoolDetailView
|
<SchoolDetailView
|
||||||
schoolInfo={school_info}
|
schoolInfo={school_info}
|
||||||
yearlyData={yearly_data}
|
yearlyData={yearly_data}
|
||||||
@@ -125,6 +148,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
|||||||
const currentLA = searchParams.get('local_authority') || '';
|
const currentLA = searchParams.get('local_authority') || '';
|
||||||
const currentType = searchParams.get('school_type') || '';
|
const currentType = searchParams.get('school_type') || '';
|
||||||
const currentPhase = searchParams.get('phase') || '';
|
const currentPhase = searchParams.get('phase') || '';
|
||||||
|
const currentGender = searchParams.get('gender') || '';
|
||||||
|
const currentAdmissionsPolicy = searchParams.get('admissions_policy') || '';
|
||||||
|
const currentHasSixthForm = searchParams.get('has_sixth_form') || '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -87,7 +90,8 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters = currentSearch || currentLA || currentType || currentPhase || currentPostcode;
|
const hasActiveFilters = currentSearch || currentLA || currentType || currentPhase || currentPostcode || currentGender || currentAdmissionsPolicy || currentHasSixthForm;
|
||||||
|
const isSecondaryMode = currentPhase === 'secondary' || (filters.genders && filters.genders.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}>
|
<div className={`${styles.filterBar} ${isPending ? styles.isLoading : ''} ${isHero ? styles.heroMode : ''}`}>
|
||||||
@@ -169,6 +173,49 @@ export function FilterBar({ filters, isHero }: FilterBarProps) {
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isSecondaryMode && (
|
||||||
|
<>
|
||||||
|
{filters.genders && filters.genders.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={currentGender}
|
||||||
|
onChange={(e) => handleFilterChange('gender', e.target.value)}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="">Boys, Girls & Mixed</option>
|
||||||
|
{filters.genders.map((g) => (
|
||||||
|
<option key={g} value={g.toLowerCase()}>{g}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={currentHasSixthForm}
|
||||||
|
onChange={(e) => handleFilterChange('has_sixth_form', e.target.value)}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="">With or without sixth form</option>
|
||||||
|
<option value="yes">With sixth form (11–18)</option>
|
||||||
|
<option value="no">Without sixth form (11–16)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{filters.admissions_policies && filters.admissions_policies.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={currentAdmissionsPolicy}
|
||||||
|
onChange={(e) => handleFilterChange('admissions_policy', e.target.value)}
|
||||||
|
className={styles.filterSelect}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<option value="">All admissions types</option>
|
||||||
|
{filters.admissions_policies.map((p) => (
|
||||||
|
<option key={p} value={p.toLowerCase()}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
|
<button onClick={handleClearFilters} className={`btn btn-tertiary ${styles.clearButton}`} type="button" disabled={isPending}>
|
||||||
Clear Filters
|
Clear Filters
|
||||||
|
|||||||
@@ -549,3 +549,21 @@
|
|||||||
.chipRemove:hover {
|
.chipRemove:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loadMoreSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreCount {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreButton {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,14 +5,15 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { FilterBar } from './FilterBar';
|
import { FilterBar } from './FilterBar';
|
||||||
import { SchoolRow } from './SchoolRow';
|
import { SchoolRow } from './SchoolRow';
|
||||||
|
import { SecondarySchoolRow } from './SecondarySchoolRow';
|
||||||
import { SchoolMap } from './SchoolMap';
|
import { SchoolMap } from './SchoolMap';
|
||||||
import { Pagination } from './Pagination';
|
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { useComparisonContext } from '@/context/ComparisonContext';
|
import { useComparisonContext } from '@/context/ComparisonContext';
|
||||||
|
import { fetchSchools, fetchLAaverages } from '@/lib/api';
|
||||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||||
import styles from './HomeView.module.css';
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
@@ -28,19 +29,67 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
|
const [resultsView, setResultsView] = useState<'list' | 'map'>('list');
|
||||||
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
|
const [selectedMapSchool, setSelectedMapSchool] = useState<School | null>(null);
|
||||||
const [sortOrder, setSortOrder] = useState<string>('default');
|
const [sortOrder, setSortOrder] = useState<string>('default');
|
||||||
|
const [allSchools, setAllSchools] = useState<School[]>(initialSchools.schools);
|
||||||
|
const [currentPage, setCurrentPage] = useState(initialSchools.page);
|
||||||
|
const [hasMore, setHasMore] = useState(initialSchools.total_pages > 1);
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [laAverages, setLaAverages] = useState<Record<string, number>>({});
|
||||||
|
const prevSearchParamsRef = useRef(searchParams.toString());
|
||||||
|
|
||||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||||
const isLocationSearch = !!searchParams.get('postcode');
|
const isLocationSearch = !!searchParams.get('postcode');
|
||||||
const isSearchActive = !!(hasSearch || searchParams.get('local_authority') || searchParams.get('school_type'));
|
const isSearchActive = !!(hasSearch || searchParams.get('local_authority') || searchParams.get('school_type'));
|
||||||
|
const currentPhase = searchParams.get('phase') || '';
|
||||||
|
const hasSecondaryResults = allSchools.some(s => s.attainment_8_score != null);
|
||||||
|
const isSecondaryView = currentPhase.toLowerCase().includes('secondary') || hasSecondaryResults;
|
||||||
|
|
||||||
|
// Reset pagination state when search params change
|
||||||
|
useEffect(() => {
|
||||||
|
const newParamsStr = searchParams.toString();
|
||||||
|
if (newParamsStr !== prevSearchParamsRef.current) {
|
||||||
|
prevSearchParamsRef.current = newParamsStr;
|
||||||
|
setAllSchools(initialSchools.schools);
|
||||||
|
setCurrentPage(initialSchools.page);
|
||||||
|
setHasMore(initialSchools.total_pages > 1);
|
||||||
|
}
|
||||||
|
}, [searchParams, initialSchools]);
|
||||||
|
|
||||||
// Close bottom sheet if we change views or search
|
// Close bottom sheet if we change views or search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedMapSchool(null);
|
setSelectedMapSchool(null);
|
||||||
}, [resultsView, searchParams]);
|
}, [resultsView, searchParams]);
|
||||||
|
|
||||||
const sortedSchools = [...initialSchools.schools].sort((a, b) => {
|
// Fetch LA averages when secondary schools are visible
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSecondaryView) return;
|
||||||
|
fetchLAaverages({ cache: 'force-cache' })
|
||||||
|
.then(data => setLaAverages(data.secondary.attainment_8_by_la))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [isSecondaryView]);
|
||||||
|
|
||||||
|
const handleLoadMore = async () => {
|
||||||
|
if (isLoadingMore || !hasMore) return;
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
searchParams.forEach((value, key) => { params[key] = value; });
|
||||||
|
params.page = currentPage + 1;
|
||||||
|
const response = await fetchSchools(params, { cache: 'no-store' });
|
||||||
|
setAllSchools(prev => [...prev, ...response.schools]);
|
||||||
|
setCurrentPage(response.page);
|
||||||
|
setHasMore(response.page < response.total_pages);
|
||||||
|
} catch {
|
||||||
|
// silently ignore
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedSchools = [...allSchools].sort((a, b) => {
|
||||||
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
|
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
|
||||||
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
|
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
|
||||||
|
if (sortOrder === 'att8_desc') return (b.attainment_8_score ?? -Infinity) - (a.attainment_8_score ?? -Infinity);
|
||||||
|
if (sortOrder === 'att8_asc') return (a.attainment_8_score ?? Infinity) - (b.attainment_8_score ?? Infinity);
|
||||||
if (sortOrder === 'distance') return (a.distance ?? Infinity) - (b.distance ?? Infinity);
|
if (sortOrder === 'distance') return (a.distance ?? Infinity) - (b.distance ?? Infinity);
|
||||||
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
|
if (sortOrder === 'name_asc') return a.school_name.localeCompare(b.school_name);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -134,8 +183,10 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
</h2>
|
</h2>
|
||||||
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)} className={styles.sortSelect}>
|
<select value={sortOrder} onChange={e => setSortOrder(e.target.value)} className={styles.sortSelect}>
|
||||||
<option value="default">Sort: Relevance</option>
|
<option value="default">Sort: Relevance</option>
|
||||||
<option value="rwm_desc">Highest R, W & M %</option>
|
{!isSecondaryView && <option value="rwm_desc">Highest R, W & M %</option>}
|
||||||
<option value="rwm_asc">Lowest R, W & M %</option>
|
{!isSecondaryView && <option value="rwm_asc">Lowest R, W & M %</option>}
|
||||||
|
{isSecondaryView && <option value="att8_desc">Highest Attainment 8</option>}
|
||||||
|
{isSecondaryView && <option value="att8_asc">Lowest Attainment 8</option>}
|
||||||
{isLocationSearch && <option value="distance">Nearest first</option>}
|
{isLocationSearch && <option value="distance">Nearest first</option>}
|
||||||
<option value="name_asc">Name A–Z</option>
|
<option value="name_asc">Name A–Z</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -206,6 +257,17 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
<>
|
<>
|
||||||
<div className={styles.schoolList}>
|
<div className={styles.schoolList}>
|
||||||
{sortedSchools.map((school) => (
|
{sortedSchools.map((school) => (
|
||||||
|
isSecondaryView || school.attainment_8_score != null ? (
|
||||||
|
<SecondarySchoolRow
|
||||||
|
key={school.urn}
|
||||||
|
school={school}
|
||||||
|
isLocationSearch={isLocationSearch}
|
||||||
|
onAddToCompare={addSchool}
|
||||||
|
onRemoveFromCompare={removeSchool}
|
||||||
|
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||||
|
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<SchoolRow
|
<SchoolRow
|
||||||
key={school.urn}
|
key={school.urn}
|
||||||
school={school}
|
school={school}
|
||||||
@@ -214,15 +276,25 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
onRemoveFromCompare={removeSchool}
|
onRemoveFromCompare={removeSchool}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{initialSchools.total_pages > 1 && (
|
{(hasMore || allSchools.length < initialSchools.total) && (
|
||||||
<Pagination
|
<div className={styles.loadMoreSection}>
|
||||||
currentPage={initialSchools.page}
|
<p className={styles.loadMoreCount}>
|
||||||
totalPages={initialSchools.total_pages}
|
Showing {allSchools.length.toLocaleString()} of {initialSchools.total.toLocaleString()} schools
|
||||||
total={initialSchools.total}
|
</p>
|
||||||
/>
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
className={`btn btn-secondary ${styles.loadMoreButton}`}
|
||||||
|
>
|
||||||
|
{isLoadingMore ? 'Loading...' : 'Load more schools'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -670,10 +670,10 @@ export function SchoolDetailView({
|
|||||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions.first_preference_offers_pct != null && (
|
{admissions.first_preference_offer_pct != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Families who got their first-choice</div>
|
<div className={styles.metricLabel}>Families who got their first-choice</div>
|
||||||
<div className={styles.metricValue}>{admissions.first_preference_offers_pct}%</div>
|
<div className={styles.metricValue}>{admissions.first_preference_offer_pct}%</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
696
nextjs-app/components/SecondarySchoolDetailView.module.css
Normal file
696
nextjs-app/components/SecondarySchoolDetailView.module.css
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
/* SecondarySchoolDetailView — borrows heavily from SchoolDetailView.module.css */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────── */
|
||||||
|
.header {
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleSection {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schoolName {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeSelective {
|
||||||
|
background: rgba(180, 120, 0, 0.1);
|
||||||
|
color: #8a6200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeFaith {
|
||||||
|
background: rgba(45, 125, 125, 0.1);
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.address {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDetails {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDetail {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDetail strong {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDetail a {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDetail a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnAdd,
|
||||||
|
.btnRemove {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnAdd {
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnAdd:hover {
|
||||||
|
background: var(--accent-coral-dark, #c45a3f);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnRemove {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnRemove:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab Navigation (sticky) ─────────────────────────── */
|
||||||
|
.tabNav {
|
||||||
|
position: sticky;
|
||||||
|
top: 3.5rem;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 10px 10px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabNav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabNavInner {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.3rem 0.625rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backBtn:hover {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabNavDivider {
|
||||||
|
width: 1px;
|
||||||
|
height: 1rem;
|
||||||
|
background: var(--border-color, #e5dfd5);
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBtn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBtn:hover {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBtnActive {
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBtnActive:hover {
|
||||||
|
background: var(--accent-coral-dark, #c45a3f);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tab content area ────────────────────────────────── */
|
||||||
|
.tabContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0; /* cards have their own margin-bottom */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card ────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section Title ───────────────────────────────────── */
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 0.875rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 3px;
|
||||||
|
height: 1em;
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionSubtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
margin: -0.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subSectionTitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
margin: 1.25rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responseBadge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-dm-sans), sans-serif;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress 8 suspension banner ───────────────────── */
|
||||||
|
.p8Banner {
|
||||||
|
background: rgba(180, 120, 0, 0.1);
|
||||||
|
border: 1px solid rgba(180, 120, 0, 0.3);
|
||||||
|
color: #8a6200;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metrics Grid & Cards ────────────────────────────── */
|
||||||
|
.metricsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricCard {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricLabel {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricValue {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricHint {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress score colours ──────────────────────────── */
|
||||||
|
.progressPositive {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressNegative {
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status colours ──────────────────────────────────── */
|
||||||
|
.statusGood {
|
||||||
|
background: var(--accent-teal-bg);
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusWarn {
|
||||||
|
background: var(--accent-gold-bg);
|
||||||
|
color: #b8920e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metric table (row-based) ────────────────────────── */
|
||||||
|
.metricTable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricName {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricRow .metricValue {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Charts ──────────────────────────────────────────── */
|
||||||
|
.chartContainer {
|
||||||
|
width: 100%;
|
||||||
|
height: 280px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── History table ───────────────────────────────────── */
|
||||||
|
.tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historicalSubtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
margin: 1.25rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTable thead {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTable th {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTable td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTable tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTable tbody tr:hover {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yearCell {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-gold, #c9a227);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ofsted ──────────────────────────────────────────── */
|
||||||
|
.ofstedHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedGrade {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||||
|
.ofstedGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
||||||
|
.ofstedGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||||
|
.ofstedGrade4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||||
|
|
||||||
|
.rcGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||||
|
.rcGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
||||||
|
.rcGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||||
|
.rcGrade4 { background: rgba(249, 115, 22, 0.12); color: #c2410c; }
|
||||||
|
.rcGrade5 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||||
|
|
||||||
|
.safeguardingMet {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent-teal-bg);
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.safeguardingNotMet {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--accent-coral-bg);
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedDisclaimer {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedDate {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedPrevious {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedReportLink {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedReportLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Parent View ─────────────────────────────────────── */
|
||||||
|
.parentRecommendLine {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentRecommendLine strong {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewLabel {
|
||||||
|
flex: 0 0 18rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewBar {
|
||||||
|
flex: 1;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewFill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewPct {
|
||||||
|
flex: 0 0 2.75rem;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab link (inline button styled as link) */
|
||||||
|
.tabLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Admissions ──────────────────────────────────────── */
|
||||||
|
.admissionsTypeBadge {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsSelective {
|
||||||
|
background: rgba(180, 120, 0, 0.1);
|
||||||
|
color: #8a6200;
|
||||||
|
border: 1px solid rgba(180, 120, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsFaith {
|
||||||
|
background: rgba(45, 125, 125, 0.08);
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
border: 1px solid rgba(45, 125, 125, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sixthFormNote {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Deprivation ─────────────────────────────────────── */
|
||||||
|
.deprivationDots {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin: 0.75rem 0 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deprivationDot {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border: 2px solid var(--border-color, #e5dfd5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deprivationDotFilled {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
border-color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deprivationDesc {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deprivationScaleLabel {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.headerContent {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btnAdd,
|
||||||
|
.btnRemove {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schoolName {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parentViewLabel {
|
||||||
|
flex-basis: 10rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
801
nextjs-app/components/SecondarySchoolDetailView.tsx
Normal file
801
nextjs-app/components/SecondarySchoolDetailView.tsx
Normal file
@@ -0,0 +1,801 @@
|
|||||||
|
/**
|
||||||
|
* SecondarySchoolDetailView Component
|
||||||
|
* Dedicated detail view for secondary schools with tabbed navigation:
|
||||||
|
* Overview | Academic | Admissions | Wellbeing | Parents | Finance
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
|
import { PerformanceChart } from './PerformanceChart';
|
||||||
|
import { MetricTooltip } from './MetricTooltip';
|
||||||
|
import type {
|
||||||
|
School, SchoolResult, AbsenceData,
|
||||||
|
OfstedInspection, OfstedParentView, SchoolCensus,
|
||||||
|
SchoolAdmissions, SenDetail, Phonics,
|
||||||
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
||||||
|
import styles from './SecondarySchoolDetailView.module.css';
|
||||||
|
|
||||||
|
const OFSTED_LABELS: Record<number, string> = {
|
||||||
|
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RC_LABELS: Record<number, string> = {
|
||||||
|
1: 'Exceptional', 2: 'Strong', 3: 'Expected standard', 4: 'Needs attention', 5: 'Urgent improvement',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RC_CATEGORIES = [
|
||||||
|
{ key: 'rc_inclusion' as const, label: 'Inclusion' },
|
||||||
|
{ key: 'rc_curriculum_teaching' as const, label: 'Curriculum & Teaching' },
|
||||||
|
{ key: 'rc_achievement' as const, label: 'Achievement' },
|
||||||
|
{ key: 'rc_attendance_behaviour' as const, label: 'Attendance & Behaviour' },
|
||||||
|
{ key: 'rc_personal_development' as const, label: 'Personal Development' },
|
||||||
|
{ key: 'rc_leadership_governance' as const, label: 'Leadership & Governance' },
|
||||||
|
{ key: 'rc_early_years' as const, label: 'Early Years' },
|
||||||
|
{ key: 'rc_sixth_form' as const, label: 'Sixth Form' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Tab = 'overview' | 'academic' | 'admissions' | 'wellbeing' | 'parents' | 'finance';
|
||||||
|
|
||||||
|
interface SecondarySchoolDetailViewProps {
|
||||||
|
schoolInfo: School;
|
||||||
|
yearlyData: SchoolResult[];
|
||||||
|
absenceData: AbsenceData | null;
|
||||||
|
ofsted: OfstedInspection | null;
|
||||||
|
parentView: OfstedParentView | null;
|
||||||
|
census: SchoolCensus | null;
|
||||||
|
admissions: SchoolAdmissions | null;
|
||||||
|
senDetail: SenDetail | null;
|
||||||
|
phonics: Phonics | null;
|
||||||
|
deprivation: SchoolDeprivation | null;
|
||||||
|
finance: SchoolFinance | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressClass(val: number | null | undefined, modStyles: Record<string, string>): string {
|
||||||
|
if (val == null) return '';
|
||||||
|
if (val > 0) return modStyles.progressPositive;
|
||||||
|
if (val < 0) return modStyles.progressNegative;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function deprivationDesc(decile: number): string {
|
||||||
|
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
|
||||||
|
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
|
||||||
|
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecondarySchoolDetailView({
|
||||||
|
schoolInfo, yearlyData,
|
||||||
|
ofsted, parentView, admissions, senDetail, deprivation, finance, absenceData,
|
||||||
|
}: SecondarySchoolDetailViewProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||||
|
const isInComparison = isSelected(schoolInfo.urn);
|
||||||
|
|
||||||
|
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
|
const [nationalAvg, setNationalAvg] = useState<NationalAverages | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/national-averages')
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => { if (data) setNationalAvg(data); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
|
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
|
||||||
|
const hasFinance = finance != null && finance.per_pupil_spend != null;
|
||||||
|
const hasParents = parentView != null || ofsted != null;
|
||||||
|
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
|
||||||
|
const p8Suspended = latestResults != null && latestResults.year >= 202425;
|
||||||
|
|
||||||
|
const admissionsTag = (() => {
|
||||||
|
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||||
|
if (policy.includes('selective')) return 'Selective';
|
||||||
|
const denom = schoolInfo.religious_denomination ?? '';
|
||||||
|
if (denom && denom !== 'Does not apply') return 'Faith priority';
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string }[] = [
|
||||||
|
{ key: 'overview', label: 'Overview' },
|
||||||
|
{ key: 'academic', label: 'Academic' },
|
||||||
|
{ key: 'admissions', label: 'Admissions' },
|
||||||
|
{ key: 'wellbeing', label: 'Wellbeing' },
|
||||||
|
...(hasParents ? [{ key: 'parents' as Tab, label: 'Parents' }] : []),
|
||||||
|
...(hasFinance ? [{ key: 'finance' as Tab, label: 'Finance' }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleComparisonToggle = () => {
|
||||||
|
if (isInComparison) {
|
||||||
|
removeSchool(schoolInfo.urn);
|
||||||
|
} else {
|
||||||
|
addSchool(schoolInfo);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* ── Header ─────────────────────────────────────── */}
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.headerContent}>
|
||||||
|
<div className={styles.titleSection}>
|
||||||
|
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
||||||
|
<div className={styles.badges}>
|
||||||
|
{schoolInfo.school_type && (
|
||||||
|
<span className={styles.badge}>{schoolInfo.school_type}</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.gender && schoolInfo.gender !== 'Mixed' && (
|
||||||
|
<span className={styles.badge}>{schoolInfo.gender}'s school</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.age_range && (
|
||||||
|
<span className={styles.badge}>{schoolInfo.age_range}</span>
|
||||||
|
)}
|
||||||
|
{hasSixthForm && (
|
||||||
|
<span className={styles.badge}>Sixth form</span>
|
||||||
|
)}
|
||||||
|
{admissionsTag && (
|
||||||
|
<span className={`${styles.badge} ${admissionsTag === 'Selective' ? styles.badgeSelective : styles.badgeFaith}`}>
|
||||||
|
{admissionsTag}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{schoolInfo.address && (
|
||||||
|
<p className={styles.address}>
|
||||||
|
{schoolInfo.address}{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.headerDetails}>
|
||||||
|
{schoolInfo.headteacher_name && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Headteacher:</strong> {schoolInfo.headteacher_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.website && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<a href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`} target="_blank" rel="noopener noreferrer">
|
||||||
|
School website ↗
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{latestResults?.total_pupils != null && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
<strong>Pupils:</strong> {latestResults.total_pupils.toLocaleString()}
|
||||||
|
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{schoolInfo.trust_name && (
|
||||||
|
<span className={styles.headerDetail}>
|
||||||
|
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
onClick={handleComparisonToggle}
|
||||||
|
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
||||||
|
>
|
||||||
|
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Tab navigation (sticky) ─────────────────────── */}
|
||||||
|
<nav className={styles.tabNav} aria-label="Detail sections">
|
||||||
|
<div className={styles.tabNavInner}>
|
||||||
|
<button onClick={() => router.back()} className={styles.backBtn}>← Back</button>
|
||||||
|
<div className={styles.tabNavDivider} />
|
||||||
|
{tabs.map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setActiveTab(key)}
|
||||||
|
className={`${styles.tabBtn} ${activeTab === key ? styles.tabBtnActive : ''}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ── Overview Tab ─────────────────────────────────── */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
{/* Ofsted summary card */}
|
||||||
|
{ofsted && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||||
|
{ofsted.inspection_date && (
|
||||||
|
<span className={styles.ofstedDate}>
|
||||||
|
{' '}Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`https://reports.ofsted.gov.uk/provider/21/${schoolInfo.urn}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.ofstedReportLink}
|
||||||
|
>
|
||||||
|
Full report ↗
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
{ofsted.framework === 'ReportCard' ? (
|
||||||
|
<p className={styles.sectionSubtitle}>
|
||||||
|
From November 2025, Ofsted replaced single overall grades with Report Cards.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.ofstedHeader}>
|
||||||
|
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
|
||||||
|
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
|
||||||
|
<p className={styles.parentRecommendLine}>
|
||||||
|
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attainment 8 headline */}
|
||||||
|
{latestResults?.attainment_8_score != null && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
GCSE Performance at a Glance ({formatAcademicYear(latestResults.year)})
|
||||||
|
</h2>
|
||||||
|
<div className={styles.metricsGrid}>
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>Attainment 8</div>
|
||||||
|
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
||||||
|
{secondaryAvg.attainment_8_score != null && (
|
||||||
|
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{latestResults.english_maths_standard_pass_pct != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>English & Maths Grade 4+</div>
|
||||||
|
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
||||||
|
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
|
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{admissions?.oversubscribed != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>Admissions</div>
|
||||||
|
<div className={`${styles.metricValue} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||||
|
{admissions.oversubscribed ? 'Oversubscribed' : 'Places available'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricHint}>Last year</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{p8Suspended && (
|
||||||
|
<div className={styles.p8Banner}>
|
||||||
|
Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top Parent View scores */}
|
||||||
|
{parentView != null && parentView.total_responses != null && parentView.total_responses > 0 && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>What Parents Say</h2>
|
||||||
|
<div className={styles.parentViewGrid}>
|
||||||
|
{[
|
||||||
|
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
|
||||||
|
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
|
||||||
|
].filter(q => q.pct != null).map(({ label, pct }) => (
|
||||||
|
<div key={label} className={styles.parentViewRow}>
|
||||||
|
<span className={styles.parentViewLabel}>{label}</span>
|
||||||
|
<div className={styles.parentViewBar}>
|
||||||
|
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setActiveTab('parents')} className={styles.tabLink}>
|
||||||
|
See all parent feedback →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Academic Tab ─────────────────────────────────── */}
|
||||||
|
{activeTab === 'academic' && latestResults && (
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
GCSE Results ({formatAcademicYear(latestResults.year)})
|
||||||
|
</h2>
|
||||||
|
<p className={styles.sectionSubtitle}>
|
||||||
|
GCSE results for Year 11 pupils. National averages shown for comparison.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{p8Suspended && (
|
||||||
|
<div className={styles.p8Banner}>
|
||||||
|
Progress 8 scores for 2024/25 are not used for accountability purposes following the KS2 assessment disruption. Treat with caution.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.metricsGrid}>
|
||||||
|
{latestResults.attainment_8_score != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>
|
||||||
|
Attainment 8
|
||||||
|
<MetricTooltip metricKey="attainment_8_score" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
||||||
|
{secondaryAvg.attainment_8_score != null && (
|
||||||
|
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latestResults.progress_8_score != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>
|
||||||
|
Progress 8
|
||||||
|
<MetricTooltip metricKey="progress_8_score" />
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.metricValue} ${progressClass(latestResults.progress_8_score, styles)}`}>
|
||||||
|
{formatProgress(latestResults.progress_8_score)}
|
||||||
|
</div>
|
||||||
|
{(latestResults.progress_8_lower_ci != null || latestResults.progress_8_upper_ci != null) && (
|
||||||
|
<div className={styles.metricHint}>
|
||||||
|
CI: {latestResults.progress_8_lower_ci?.toFixed(2) ?? '?'} to {latestResults.progress_8_upper_ci?.toFixed(2) ?? '?'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latestResults.english_maths_standard_pass_pct != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>
|
||||||
|
English & Maths Grade 4+
|
||||||
|
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
||||||
|
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
|
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latestResults.english_maths_strong_pass_pct != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>
|
||||||
|
English & Maths Grade 5+
|
||||||
|
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
||||||
|
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
|
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* A8 component breakdown */}
|
||||||
|
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
|
||||||
|
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
|
||||||
|
<>
|
||||||
|
<h3 className={styles.subSectionTitle}>Attainment 8 Components (Progress 8 contribution)</h3>
|
||||||
|
<div className={styles.metricTable}>
|
||||||
|
{[
|
||||||
|
{ label: 'English', val: latestResults.progress_8_english },
|
||||||
|
{ label: 'Maths', val: latestResults.progress_8_maths },
|
||||||
|
{ label: 'EBacc subjects', val: latestResults.progress_8_ebacc },
|
||||||
|
{ label: 'Open (other GCSEs)', val: latestResults.progress_8_open },
|
||||||
|
].filter(r => r.val != null).map(({ label, val }) => (
|
||||||
|
<div key={label} className={styles.metricRow}>
|
||||||
|
<span className={styles.metricName}>{label}</span>
|
||||||
|
<span className={`${styles.metricValue} ${progressClass(val, styles)}`}>
|
||||||
|
{formatProgress(val!)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* EBacc */}
|
||||||
|
{(latestResults.ebacc_entry_pct != null || latestResults.ebacc_standard_pass_pct != null) && (
|
||||||
|
<>
|
||||||
|
<h3 className={styles.subSectionTitle} style={{ marginTop: '1rem' }}>
|
||||||
|
English Baccalaureate (EBacc)
|
||||||
|
<MetricTooltip metricKey="ebacc_entry_pct" />
|
||||||
|
</h3>
|
||||||
|
<div className={styles.metricTable}>
|
||||||
|
{latestResults.ebacc_entry_pct != null && (
|
||||||
|
<div className={styles.metricRow}>
|
||||||
|
<span className={styles.metricName}>Pupils entered for EBacc</span>
|
||||||
|
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_entry_pct)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latestResults.ebacc_standard_pass_pct != null && (
|
||||||
|
<div className={styles.metricRow}>
|
||||||
|
<span className={styles.metricName}>EBacc Grade 4+</span>
|
||||||
|
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_standard_pass_pct)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latestResults.ebacc_strong_pass_pct != null && (
|
||||||
|
<div className={styles.metricRow}>
|
||||||
|
<span className={styles.metricName}>EBacc Grade 5+</span>
|
||||||
|
<span className={styles.metricValue}>{formatPercentage(latestResults.ebacc_strong_pass_pct)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latestResults.ebacc_avg_score != null && (
|
||||||
|
<div className={styles.metricRow}>
|
||||||
|
<span className={styles.metricName}>EBacc average point score</span>
|
||||||
|
<span className={styles.metricValue}>{latestResults.ebacc_avg_score.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Performance over time */}
|
||||||
|
{yearlyData.length > 0 && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>Results Over Time</h2>
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<PerformanceChart
|
||||||
|
data={yearlyData}
|
||||||
|
schoolName={schoolInfo.school_name}
|
||||||
|
isSecondary={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{yearlyData.length > 1 && (
|
||||||
|
<>
|
||||||
|
<p className={styles.historicalSubtitle}>Detailed year-by-year figures</p>
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<table className={styles.dataTable}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Year</th>
|
||||||
|
<th>Attainment 8</th>
|
||||||
|
<th>Progress 8</th>
|
||||||
|
<th>Eng & Maths 4+</th>
|
||||||
|
<th>EBacc entry %</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{yearlyData.map((result) => (
|
||||||
|
<tr key={result.year}>
|
||||||
|
<td className={styles.yearCell}>{formatAcademicYear(result.year)}</td>
|
||||||
|
<td>{result.attainment_8_score != null ? result.attainment_8_score.toFixed(1) : '-'}</td>
|
||||||
|
<td>{result.progress_8_score != null ? formatProgress(result.progress_8_score) : '-'}</td>
|
||||||
|
<td>{result.english_maths_standard_pass_pct != null ? formatPercentage(result.english_maths_standard_pass_pct) : '-'}</td>
|
||||||
|
<td>{result.ebacc_entry_pct != null ? formatPercentage(result.ebacc_entry_pct) : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Admissions Tab ───────────────────────────────── */}
|
||||||
|
{activeTab === 'admissions' && (
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>Admissions</h2>
|
||||||
|
|
||||||
|
{admissionsTag && (
|
||||||
|
<div className={`${styles.admissionsTypeBadge} ${admissionsTag === 'Selective' ? styles.admissionsSelective : styles.admissionsFaith}`}>
|
||||||
|
<strong>{admissionsTag}</strong>{' '}
|
||||||
|
{admissionsTag === 'Selective'
|
||||||
|
? '— Entry to this school is by selective examination (e.g. 11+).'
|
||||||
|
: `— This school has a faith-based admissions priority (${schoolInfo.religious_denomination}).`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{admissions ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.metricsGrid}>
|
||||||
|
{admissions.published_admission_number != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>Year 7 places per year (PAN)</div>
|
||||||
|
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{admissions.total_applications != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>Total applications</div>
|
||||||
|
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{admissions.first_preference_applications != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>1st preference applications</div>
|
||||||
|
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{admissions.first_preference_offer_pct != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>Families who got their first choice</div>
|
||||||
|
<div className={styles.metricValue}>{admissions.first_preference_offer_pct}%</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{admissions.oversubscribed != null && (
|
||||||
|
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||||
|
{admissions.oversubscribed
|
||||||
|
? '⚠ Applications exceeded places last year'
|
||||||
|
: '✓ Places were available last year'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||||
|
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className={styles.sectionSubtitle}>Admissions data for this school is not yet available.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSixthForm && (
|
||||||
|
<div className={styles.sixthFormNote}>
|
||||||
|
This school has a sixth form (Post-16 provision). Post-16 destination data coming soon.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Wellbeing Tab ────────────────────────────────── */}
|
||||||
|
{activeTab === 'wellbeing' && (
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
{/* SEN */}
|
||||||
|
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>Special Educational Needs (SEN)</h2>
|
||||||
|
<div className={styles.metricsGrid}>
|
||||||
|
{latestResults?.sen_support_pct != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>
|
||||||
|
Pupils receiving SEN support
|
||||||
|
<MetricTooltip metricKey="sen_support_pct" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
|
||||||
|
<div className={styles.metricHint}>SEN support without an EHCP</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latestResults?.sen_ehcp_pct != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>
|
||||||
|
Pupils with an EHCP
|
||||||
|
<MetricTooltip metricKey="sen_ehcp_pct" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
|
||||||
|
<div className={styles.metricHint}>Education, Health and Care Plan</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latestResults?.total_pupils != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>Total pupils</div>
|
||||||
|
<div className={styles.metricValue}>{latestResults.total_pupils.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deprivation */}
|
||||||
|
{hasDeprivation && deprivation && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
Local Area Context
|
||||||
|
<MetricTooltip metricKey="idaci_decile" />
|
||||||
|
</h2>
|
||||||
|
<div className={styles.deprivationDots}>
|
||||||
|
{Array.from({ length: 10 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`${styles.deprivationDot} ${i < deprivation.idaci_decile! ? styles.deprivationDotFilled : ''}`}
|
||||||
|
title={`Decile ${i + 1}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.deprivationScaleLabel}>
|
||||||
|
<span>Most deprived</span>
|
||||||
|
<span>Least deprived</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.deprivationDesc}>{deprivationDesc(deprivation.idaci_decile!)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{latestResults?.sen_support_pct == null && latestResults?.sen_ehcp_pct == null && !hasDeprivation && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<p className={styles.sectionSubtitle}>Wellbeing data is not yet available for this school.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Parents Tab ─────────────────────────────────── */}
|
||||||
|
{activeTab === 'parents' && (
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
{/* Full Ofsted detail */}
|
||||||
|
{ofsted && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
{ofsted.framework === 'ReportCard' ? 'Ofsted Report Card' : 'Ofsted Rating'}
|
||||||
|
{ofsted.inspection_date && (
|
||||||
|
<span className={styles.ofstedDate}>
|
||||||
|
{' '}Inspected {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`https://reports.ofsted.gov.uk/provider/21/${schoolInfo.urn}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.ofstedReportLink}
|
||||||
|
>
|
||||||
|
Full report ↗
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
{ofsted.framework === 'ReportCard' ? (
|
||||||
|
<>
|
||||||
|
<p className={styles.ofstedDisclaimer}>
|
||||||
|
From November 2025, Ofsted replaced single overall grades with Report Cards rating schools across several areas.
|
||||||
|
</p>
|
||||||
|
<div className={styles.metricsGrid}>
|
||||||
|
{ofsted.rc_safeguarding_met != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>Safeguarding</div>
|
||||||
|
<div className={`${styles.metricValue} ${ofsted.rc_safeguarding_met ? styles.safeguardingMet : styles.safeguardingNotMet}`}>
|
||||||
|
{ofsted.rc_safeguarding_met ? 'Met' : 'Not met'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{RC_CATEGORIES.filter(({ key }) => key !== 'rc_early_years' || ofsted[key] != null).map(({ key, label }) => {
|
||||||
|
const value = ofsted[key] as number | null;
|
||||||
|
return value != null ? (
|
||||||
|
<div key={key} className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>{label}</div>
|
||||||
|
<div className={`${styles.metricValue} ${styles[`rcGrade${value}`]}`}>
|
||||||
|
{RC_LABELS[value]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.ofstedHeader}>
|
||||||
|
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
|
||||||
|
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
|
||||||
|
</span>
|
||||||
|
{ofsted.previous_overall != null &&
|
||||||
|
ofsted.previous_overall !== ofsted.overall_effectiveness && (
|
||||||
|
<span className={styles.ofstedPrevious}>
|
||||||
|
Previously: {OFSTED_LABELS[ofsted.previous_overall]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={styles.ofstedDisclaimer}>
|
||||||
|
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections.
|
||||||
|
</p>
|
||||||
|
<div className={styles.metricsGrid}>
|
||||||
|
{[
|
||||||
|
{ label: 'Quality of Teaching', value: ofsted.quality_of_education },
|
||||||
|
{ label: 'Behaviour in School', value: ofsted.behaviour_attitudes },
|
||||||
|
{ label: 'Pupils\' Wider Development', value: ofsted.personal_development },
|
||||||
|
{ label: 'School Leadership', value: ofsted.leadership_management },
|
||||||
|
...(ofsted.early_years_provision != null
|
||||||
|
? [{ label: 'Early Years (Reception)', value: ofsted.early_years_provision }]
|
||||||
|
: []),
|
||||||
|
].map(({ label, value }) => value != null && (
|
||||||
|
<div key={label} className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>{label}</div>
|
||||||
|
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
||||||
|
{OFSTED_LABELS[value]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Parent View survey */}
|
||||||
|
{parentView && parentView.total_responses != null && parentView.total_responses > 0 && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
What Parents Say
|
||||||
|
<span className={styles.responseBadge}>
|
||||||
|
{parentView.total_responses.toLocaleString()} responses
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className={styles.sectionSubtitle}>
|
||||||
|
From the Ofsted Parent View survey — parents share their experience of this school.
|
||||||
|
</p>
|
||||||
|
<div className={styles.parentViewGrid}>
|
||||||
|
{[
|
||||||
|
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
|
||||||
|
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
|
||||||
|
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
|
||||||
|
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
|
||||||
|
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
|
||||||
|
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
|
||||||
|
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
|
||||||
|
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
|
||||||
|
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
|
||||||
|
].filter(q => q.pct != null).map(({ label, pct }) => (
|
||||||
|
<div key={label} className={styles.parentViewRow}>
|
||||||
|
<span className={styles.parentViewLabel}>{label}</span>
|
||||||
|
<div className={styles.parentViewBar}>
|
||||||
|
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!ofsted && (!parentView || parentView.total_responses == null || parentView.total_responses === 0) && (
|
||||||
|
<div className={styles.card}>
|
||||||
|
<p className={styles.sectionSubtitle}>Parent and Ofsted data is not yet available for this school.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Finance Tab ─────────────────────────────────── */}
|
||||||
|
{activeTab === 'finance' && hasFinance && finance && (
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.sectionTitle}>School Finances ({formatAcademicYear(finance.year)})</h2>
|
||||||
|
<p className={styles.sectionSubtitle}>
|
||||||
|
Per-pupil spending shows how much the school has to spend on each child's education.
|
||||||
|
</p>
|
||||||
|
<div className={styles.metricsGrid}>
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>Total spend per pupil per year</div>
|
||||||
|
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend!).toLocaleString()}</div>
|
||||||
|
<div className={styles.metricHint}>How much the school has to spend on each pupil annually</div>
|
||||||
|
</div>
|
||||||
|
{finance.teacher_cost_pct != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>Share of budget spent on teachers</div>
|
||||||
|
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{finance.staff_cost_pct != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>Share of budget spent on all staff</div>
|
||||||
|
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{finance.premises_cost_pct != null && (
|
||||||
|
<div className={styles.metricCard}>
|
||||||
|
<div className={styles.metricLabel}>Share of budget spent on premises</div>
|
||||||
|
<div className={styles.metricValue}>{finance.premises_cost_pct.toFixed(1)}%</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
nextjs-app/components/SecondarySchoolRow.module.css
Normal file
219
nextjs-app/components/SecondarySchoolRow.module.css
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
animation: rowFadeIn 0.3s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
border-left-color: var(--accent-coral, #e07256);
|
||||||
|
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowInCompare {
|
||||||
|
border-left-color: var(--accent-teal, #2d7d7d);
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rowFadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Left content column ─────────────────────────────── */
|
||||||
|
.rowContent {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line 1: name + type + ofsted */
|
||||||
|
.line1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.625rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schoolName {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schoolName:hover {
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schoolType {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line 2: KS4 stats */
|
||||||
|
.line2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValueLarge {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deltaPositive { color: #3c8c3c; }
|
||||||
|
.deltaNegative { color: var(--accent-coral, #e07256); }
|
||||||
|
|
||||||
|
/* Line 3: location + tags */
|
||||||
|
.line3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line3 span:not(:last-child)::after {
|
||||||
|
content: '·';
|
||||||
|
margin: 0 0.4rem;
|
||||||
|
color: var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distanceBadge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
color: white;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provisionTag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
color: var(--text-secondary, #5c5650);
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectiveTag {
|
||||||
|
background: rgba(180, 120, 0, 0.1);
|
||||||
|
color: #8a6200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ofsted badge ────────────────────────────────────── */
|
||||||
|
.ofstedBadge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedDate {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofsted1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
||||||
|
.ofsted2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
||||||
|
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
|
||||||
|
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
||||||
|
|
||||||
|
/* ── Right actions column ────────────────────────────── */
|
||||||
|
.rowActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActions > * {
|
||||||
|
height: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ──────────────────────────────────────────── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowContent {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schoolName {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line2 {
|
||||||
|
gap: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActions {
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowActions > * {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
nextjs-app/components/SecondarySchoolRow.tsx
Normal file
168
nextjs-app/components/SecondarySchoolRow.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* SecondarySchoolRow Component
|
||||||
|
* Three-line row for secondary school search results
|
||||||
|
*
|
||||||
|
* Line 1: School name · School type · Ofsted badge
|
||||||
|
* Line 2: Attainment 8 (large) · ±LA avg delta · Eng & Maths 4+ · Pupils
|
||||||
|
* Line 3: LA name · gender tag · sixth form tag · admissions tag · distance
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { School } from '@/lib/types';
|
||||||
|
|
||||||
|
import styles from './SecondarySchoolRow.module.css';
|
||||||
|
|
||||||
|
const OFSTED_LABELS: Record<number, string> = {
|
||||||
|
1: 'Outstanding',
|
||||||
|
2: 'Good',
|
||||||
|
3: 'Req. Improvement',
|
||||||
|
4: 'Inadequate',
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectAdmissionsTag(school: School): string | null {
|
||||||
|
const policy = school.admissions_policy?.toLowerCase() ?? '';
|
||||||
|
if (policy.includes('selective')) return 'Selective';
|
||||||
|
const denom = school.religious_denomination ?? '';
|
||||||
|
if (denom && denom !== 'Does not apply') return 'Faith priority';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSixthForm(school: School): boolean {
|
||||||
|
return school.age_range?.includes('18') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecondarySchoolRowProps {
|
||||||
|
school: School;
|
||||||
|
isLocationSearch?: boolean;
|
||||||
|
isInCompare?: boolean;
|
||||||
|
onAddToCompare?: (school: School) => void;
|
||||||
|
onRemoveFromCompare?: (urn: number) => void;
|
||||||
|
laAvgAttainment8?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecondarySchoolRow({
|
||||||
|
school,
|
||||||
|
isLocationSearch,
|
||||||
|
isInCompare = false,
|
||||||
|
onAddToCompare,
|
||||||
|
onRemoveFromCompare,
|
||||||
|
laAvgAttainment8,
|
||||||
|
}: SecondarySchoolRowProps) {
|
||||||
|
const handleCompareClick = () => {
|
||||||
|
if (isInCompare) {
|
||||||
|
onRemoveFromCompare?.(school.urn);
|
||||||
|
} else {
|
||||||
|
onAddToCompare?.(school);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const att8 = school.attainment_8_score ?? null;
|
||||||
|
const laDelta =
|
||||||
|
att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null;
|
||||||
|
|
||||||
|
const admissionsTag = detectAdmissionsTag(school);
|
||||||
|
const sixthForm = hasSixthForm(school);
|
||||||
|
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.row} ${isInCompare ? styles.rowInCompare : ''}`}>
|
||||||
|
{/* Left: three content lines */}
|
||||||
|
<div className={styles.rowContent}>
|
||||||
|
|
||||||
|
{/* Line 1: School name + type + Ofsted badge */}
|
||||||
|
<div className={styles.line1}>
|
||||||
|
<a href={`/school/${school.urn}`} className={styles.schoolName}>
|
||||||
|
{school.school_name}
|
||||||
|
</a>
|
||||||
|
{school.school_type && (
|
||||||
|
<span className={styles.schoolType}>{school.school_type}</span>
|
||||||
|
)}
|
||||||
|
{school.ofsted_grade && (
|
||||||
|
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}>
|
||||||
|
{OFSTED_LABELS[school.ofsted_grade]}
|
||||||
|
{school.ofsted_date && (
|
||||||
|
<span className={styles.ofstedDate}>
|
||||||
|
{' '}({new Date(school.ofsted_date).getFullYear()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line 2: KS4 stats */}
|
||||||
|
<div className={styles.line2}>
|
||||||
|
<span className={styles.stat}>
|
||||||
|
<strong className={styles.statValueLarge}>
|
||||||
|
{att8 != null ? att8.toFixed(1) : '—'}
|
||||||
|
</strong>
|
||||||
|
<span className={styles.statLabel}>Attainment 8</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{laDelta != null && (
|
||||||
|
<span className={`${styles.delta} ${laDelta >= 0 ? styles.deltaPositive : styles.deltaNegative}`}>
|
||||||
|
{laDelta >= 0 ? '+' : ''}{laDelta.toFixed(1)} vs LA avg
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{school.english_maths_standard_pass_pct != null && (
|
||||||
|
<span className={styles.stat}>
|
||||||
|
<strong className={styles.statValue}>
|
||||||
|
{school.english_maths_standard_pass_pct.toFixed(0)}%
|
||||||
|
</strong>
|
||||||
|
<span className={styles.statLabel}>Eng & Maths 4+</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 3: Location + tags */}
|
||||||
|
<div className={styles.line3}>
|
||||||
|
{school.local_authority && (
|
||||||
|
<span>{school.local_authority}</span>
|
||||||
|
)}
|
||||||
|
{showGender && (
|
||||||
|
<span className={styles.provisionTag}>{school.gender}</span>
|
||||||
|
)}
|
||||||
|
{sixthForm && (
|
||||||
|
<span className={styles.provisionTag}>Sixth form</span>
|
||||||
|
)}
|
||||||
|
{admissionsTag && (
|
||||||
|
<span className={`${styles.provisionTag} ${admissionsTag === 'Selective' ? styles.selectiveTag : ''}`}>
|
||||||
|
{admissionsTag}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLocationSearch && school.distance != null && (
|
||||||
|
<span className={styles.distanceBadge}>
|
||||||
|
{school.distance.toFixed(1)} mi
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: actions */}
|
||||||
|
<div className={styles.rowActions}>
|
||||||
|
<a href={`/school/${school.urn}`} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
SchoolSearchParams,
|
SchoolSearchParams,
|
||||||
RankingsParams,
|
RankingsParams,
|
||||||
APIError,
|
APIError,
|
||||||
|
LAaveragesResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -241,6 +242,25 @@ export async function fetchMetrics(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch per-LA average Attainment 8 score for secondary schools
|
||||||
|
*/
|
||||||
|
export async function fetchLAaverages(
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<LAaveragesResponse> {
|
||||||
|
const url = `${API_BASE_URL}/la-averages`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
next: {
|
||||||
|
revalidate: 3600,
|
||||||
|
...options.next,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleResponse<LAaveragesResponse>(response);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch database statistics and info
|
* Fetch database statistics and info
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface School {
|
|||||||
// KS4 card metrics
|
// KS4 card metrics
|
||||||
attainment_8_score?: number | null;
|
attainment_8_score?: number | null;
|
||||||
english_maths_standard_pass_pct?: number | null;
|
english_maths_standard_pass_pct?: number | null;
|
||||||
|
prev_attainment_8_score?: number | null;
|
||||||
|
|
||||||
// GIAS enrichment fields
|
// GIAS enrichment fields
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
@@ -61,6 +62,7 @@ export interface School {
|
|||||||
capacity?: number | null;
|
capacity?: number | null;
|
||||||
trust_name?: string | null;
|
trust_name?: string | null;
|
||||||
gender?: string | null;
|
gender?: string | null;
|
||||||
|
admissions_policy?: string | null;
|
||||||
|
|
||||||
// Ofsted (for list view — summary only)
|
// Ofsted (for list view — summary only)
|
||||||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
||||||
@@ -127,9 +129,12 @@ export interface SchoolCensus {
|
|||||||
|
|
||||||
export interface SchoolAdmissions {
|
export interface SchoolAdmissions {
|
||||||
year: number;
|
year: number;
|
||||||
|
school_phase?: string | null;
|
||||||
published_admission_number: number | null;
|
published_admission_number: number | null;
|
||||||
total_applications: number | null;
|
total_applications: number | null;
|
||||||
first_preference_offers_pct: number | null;
|
first_preference_applications?: number | null;
|
||||||
|
first_preference_offers?: number | null;
|
||||||
|
first_preference_offer_pct: number | null;
|
||||||
oversubscribed: boolean | null;
|
oversubscribed: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +335,8 @@ export interface Filters {
|
|||||||
school_types: string[];
|
school_types: string[];
|
||||||
years: number[];
|
years: number[];
|
||||||
phases: string[];
|
phases: string[];
|
||||||
|
genders: string[];
|
||||||
|
admissions_policies: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NationalAverages {
|
export interface NationalAverages {
|
||||||
@@ -338,6 +345,13 @@ export interface NationalAverages {
|
|||||||
secondary: Record<string, number>;
|
secondary: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LAaveragesResponse {
|
||||||
|
year: number;
|
||||||
|
secondary: {
|
||||||
|
attainment_8_by_la: Record<string, number>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Backend returns filters directly, not wrapped
|
// Backend returns filters directly, not wrapped
|
||||||
export type FiltersResponse = Filters;
|
export type FiltersResponse = Filters;
|
||||||
|
|
||||||
@@ -375,6 +389,9 @@ export interface SchoolSearchParams {
|
|||||||
radius?: number;
|
radius?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
|
gender?: string;
|
||||||
|
admissions_policy?: string;
|
||||||
|
has_sixth_form?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RankingsParams {
|
export interface RankingsParams {
|
||||||
|
|||||||
Reference in New Issue
Block a user