SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
8.2 KB · 129 lines tsx
Raw Blame History
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