spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { ArrowDown, ArrowUp } from 'lucide-react';3import Link from 'next/link';4import { useMemo, useState } from 'react';5import { t } from '@/i18n';6import { cn } from '@/lib/cn';7import { grouped } from '@/lib/format';8import { routes } from '@/lib/site';9import type { AdminCoverageCountry, AdminCoverageIndicator } from '@/lib/types-explore';1011function useSort<T extends object>(rows: T[], initial: { key: keyof T & string; dir: 'asc' | 'desc' }) {12 const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' }>(initial);13 const sorted = useMemo(() => {14 const copy = [...rows];15 const get = (r: T): unknown => (r as Record<string, unknown>)[sort.key];16 copy.sort((a, b) => {17 const va = get(a);18 const vb = get(b);19 if (va == null && vb == null) return 0;20 if (va == null) return 1;21 if (vb == null) return -1;22 const cmp = typeof va === 'number' && typeof vb === 'number' ? va - vb : String(va).localeCompare(String(vb));23 return sort.dir === 'asc' ? cmp : -cmp;24 });25 return copy;26 }, [rows, sort]);27 const toggle = (key: string) => setSort((s) => (s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'desc' }));28 return { sorted, sort, toggle };29}3031function Th({ k, label, sort, toggle, numeric }: { k: string; label: string; sort: { key: string; dir: 'asc' | 'desc' }; toggle: (k: string) => void; numeric?: boolean }) {32 const active = sort.key === k;33 return (34 <th scope="col" aria-sort={active ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'} className={cn('py-1.5 pr-3 font-medium', numeric && 'text-right')}>35 <button type="button" onClick={() => toggle(k)} className={cn('inline-flex min-h-[28px] items-center gap-1 whitespace-nowrap text-2xs uppercase tracking-wide hover:text-ink', active ? 'text-ink' : 'text-ink-3')}>36 {label}37 {active ? sort.dir === 'asc' ? <ArrowUp size={11} aria-hidden /> : <ArrowDown size={11} aria-hidden /> : null}38 </button>39 </th>40 );41}4243/** Coverage matrix: indicators × countries (two sortable tables, filter box). */44export function CoverageTable({ indicators, countries }: { indicators: AdminCoverageIndicator[]; countries: AdminCoverageCountry[] }) {45 const [view, setView] = useState<'indicators' | 'countries'>('indicators');46 const [q, setQ] = useState('');47 const ql = q.trim().toLowerCase();48 const ind = useSort(indicators.filter((r) => !ql || r.indicator_id.includes(ql) || (r.topic ?? '').includes(ql) || (r.primary_source_id ?? '').includes(ql)), { key: 'n_countries', dir: 'asc' });49 const cty = useSort(countries.filter((r) => !ql || r.id.toLowerCase().includes(ql) || (r.short_name ?? '').toLowerCase().includes(ql)), { key: 'coverage_pct', dir: 'desc' });5051 return (52 <div className="min-w-0">53 <div className="mb-2 flex flex-wrap items-center gap-2">54 <div role="tablist" className="flex gap-0.5">55 {(['indicators', 'countries'] as const).map((v) => (56 <button key={v} role="tab" type="button" aria-selected={view === v} onClick={() => setView(v)} className={cn('inline-flex h-9 items-center rounded-sm px-2.5 text-xs', view === v ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2')}>57 {t(`admin.coverage.${v}` as 'admin.coverage.indicators')} <span className="tnum ml-1 opacity-70">{v === 'indicators' ? indicators.length : countries.length}</span>58 </button>59 ))}60 </div>61 <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('search.placeholder.short')} aria-label={t('nav.search')} className="h-9 w-56 rounded-sm border border-rule bg-surface px-2 text-xs outline-none focus:border-accent" />62 </div>63 <div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0">64 {view === 'indicators' ? (65 <table className="w-full min-w-[760px] border-collapse text-xs">66 <thead>67 <tr className="border-b border-rule text-left">68 <Th k="indicator_id" label={t('admin.coverage.indicator')} sort={ind.sort} toggle={ind.toggle} />69 <Th k="topic" label={t('admin.coverage.topic')} sort={ind.sort} toggle={ind.toggle} />70 <Th k="primary_source_id" label={t('admin.coverage.source')} sort={ind.sort} toggle={ind.toggle} />71 <Th k="n_countries" label={t('admin.coverage.nCountries')} sort={ind.sort} toggle={ind.toggle} numeric />72 <Th k="n_latest" label={t('admin.coverage.nLatest')} sort={ind.sort} toggle={ind.toggle} numeric />73 <Th k="n_observations" label={t('admin.coverage.nObs')} sort={ind.sort} toggle={ind.toggle} numeric />74 <Th k="first_year" label={t('admin.coverage.years')} sort={ind.sort} toggle={ind.toggle} numeric />75 <Th k="latest_year" label={t('admin.coverage.latestYear')} sort={ind.sort} toggle={ind.toggle} numeric />76 <Th k="latest_source_updated_at" label={t('admin.coverage.updated')} sort={ind.sort} toggle={ind.toggle} />77 </tr>78 </thead>79 <tbody className="divide-y divide-rule">80 {ind.sorted.map((r) => (81 <tr key={r.indicator_id} className="hover:bg-surface-2/60">82 <td className="py-1 pr-3">83 <Link href={routes.indicator(r.indicator_id)} className="font-mono text-ink hover:text-accent">84 {r.indicator_id}85 </Link>86 </td>87 <td className="py-1 pr-3 text-ink-2">{r.topic}</td>88 <td className="py-1 pr-3 text-ink-2">{r.primary_source_id}</td>89 <td className={cn('tnum py-1 pr-3 text-right', (r.n_countries ?? 0) < 50 && 'text-warn')}>{r.n_countries != null ? grouped(r.n_countries) : '—'}</td>90 <td className="tnum py-1 pr-3 text-right">{r.n_latest != null ? grouped(r.n_latest) : '—'}</td>91 <td className="tnum py-1 pr-3 text-right">{r.n_observations != null ? grouped(r.n_observations) : '—'}</td>92 <td className="tnum py-1 pr-3 text-right">{r.first_year && r.last_year ? `${r.first_year}–${r.last_year}` : '—'}</td>93 <td className="tnum py-1 pr-3 text-right">{r.latest_year ?? '—'}</td>94 <td className="tnum py-1 pr-3 text-ink-2">{r.latest_source_updated_at?.slice(0, 10) ?? '—'}</td>95 </tr>96 ))}97 </tbody>98 </table>99 ) : (100 <table className="w-full min-w-[560px] border-collapse text-xs">101 <thead>102 <tr className="border-b border-rule text-left">103 <Th k="short_name" label={t('admin.coverage.country')} sort={cty.sort} toggle={cty.toggle} />104 <Th k="id" label="ISO3" sort={cty.sort} toggle={cty.toggle} />105 <Th k="n_indicators" label={t('admin.coverage.indicators')} sort={cty.sort} toggle={cty.toggle} numeric />106 <Th k="n_observations" label={t('admin.coverage.nObs')} sort={cty.sort} toggle={cty.toggle} numeric />107 <Th k="latest_year" label={t('admin.coverage.latestYear')} sort={cty.sort} toggle={cty.toggle} numeric />108 <Th k="coverage_pct" label={t('admin.coverage.pct')} sort={cty.sort} toggle={cty.toggle} numeric />109 </tr>110 </thead>111 <tbody className="divide-y divide-rule">112 {cty.sorted.map((r) => (113 <tr key={r.id} className="hover:bg-surface-2/60">114 <td className="py-1 pr-3 text-ink">{r.short_name ?? r.id}</td>115 <td className="py-1 pr-3 font-mono text-ink-2">{r.id}</td>116 <td className="tnum py-1 pr-3 text-right">{r.n_indicators != null ? grouped(r.n_indicators) : '—'}</td>117 <td className="tnum py-1 pr-3 text-right">{r.n_observations != null ? grouped(r.n_observations) : '—'}</td>118 <td className="tnum py-1 pr-3 text-right">{r.latest_year ?? '—'}</td>119 <td className={cn('tnum py-1 pr-3 text-right', (r.coverage_pct ?? 0) < 50 && 'text-warn')}>{r.coverage_pct != null ? `${r.coverage_pct.toFixed(1)} %` : '—'}</td>120 </tr>121 ))}122 </tbody>123 </table>124 )}125 </div>126 </div>127 );128}129