Fix city admin panel layout

This commit is contained in:
InfoX 2026-10-03 17:08:38 +02:00
parent 9d4b857893
commit 1e234013c6
Signed by: infox
GPG key ID: 0C5A10019B6B50F6
11 changed files with 481 additions and 219 deletions

View file

@ -4,7 +4,9 @@ import { chromium } from 'playwright-core';
const axeSrc = await readFile('node_modules/axe-core/axe.min.js', 'utf8');
const URL = process.env.URL ?? 'http://localhost:4173/';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, locale: 'pl-PL' });
// VIEWPORT=390x844 runs the same scan with a phone-sized (touch) viewport
const [vw, vh] = (process.env.VIEWPORT ?? '1280x900').split('x').map(Number);
const page = await browser.newPage({ viewport: { width: vw, height: vh }, isMobile: vw < 700, hasTouch: vw < 700, locale: 'pl-PL' });
let bad = 0;
async function scan(name) {
await page.addScriptTag({ content: axeSrc });
@ -15,8 +17,11 @@ async function scan(name) {
await page.goto(URL, { waitUntil: 'networkidle' });
await scan('login');
await page.click('text=Zaloguj jako Maria Wiśniewska');
await page.waitForSelector('.kbb-kpis');
await scan('dashboard');
await page.waitForSelector('[role=tablist]');
for (const [tab, name] of [['Zgłoszenia', 'dashboard: reports'], ['Przejścia', 'dashboard: crossings'], ['Jakość', 'dashboard: quality'], ['Eksport', 'dashboard: export']]) {
await page.click(`[role=tab]:has-text("${tab}")`);
await scan(name);
}
await page.click('nav >> text=Miejsca');
await page.waitForSelector('#q');
await scan('places');

View file

@ -35,12 +35,17 @@ ok((await page.evaluate(() => document.documentElement.style.fontSize)) === '125
await page.click('nav >> text=Profil');
await page.click('text=Wyloguj');
await page.click('text=Zaloguj jako Maria Wiśniewska');
await page.waitForSelector('.kbb-kpis');
ok(await page.locator('text=0 / 82').first().isVisible(), 'dashboard shows operator data gap (0 / 82 stops)');
await page.waitForSelector('[role=tablist]');
const pending = await page.locator('button:has-text("Zatwierdź")').count();
ok(pending >= 3, `moderation queue has ${pending} pending reports`);
ok(pending >= 3, `moderation queue (first tab) has ${pending} pending reports`);
await page.locator('button:has-text("Zatwierdź")').first().click();
ok((await page.locator('button:has-text("Zatwierdź")').count()) === pending - 1, 'accepting removes it from the queue');
await page.click('button:has-text("Cofnij"):not(:has-text("decyzję"))');
ok((await page.locator('button:has-text("Zatwierdź")').count()) === pending, 'undo puts it back');
await page.locator('button:has-text("Zatwierdź")').first().click();
await page.click('[role=tab]:has-text("Jakość")');
await page.waitForSelector('.kbb-kpis');
ok(await page.locator('text=0 / 82').first().isVisible(), 'data tab shows operator data gap (0 / 82 stops)');
// map pins + route render without worker errors
await page.click('nav >> text=Trasa');

View file

@ -269,8 +269,13 @@ export default function App() {
.sort((a, b) => b.views - a.views).slice(0, 5),
[demand, placeById, assessments],
);
const decide = (id: string, status: 'accepted' | 'rejected') =>
setDecisions((d) => ({ ...d, [id]: { status, reviewedAt: new Date().toISOString(), reviewedBy: account ? t(`auth.${account.id}` as Key) : '' } }));
const decide = (ids: string[], status: 'accepted' | 'rejected') => {
const stamp = { status, reviewedAt: new Date().toISOString(), reviewedBy: account ? t(`auth.${account.id}` as Key) : '' };
setDecisions((d) => ({ ...d, ...Object.fromEntries(ids.map((id) => [id, stamp])) }));
};
const undoDecisions = (ids: string[]) =>
setDecisions((d) => Object.fromEntries(Object.entries(d).filter(([id]) => !ids.includes(id))));
const pendingCount = useMemo(() => reports.filter((r) => !r.status || r.status === 'pending').length, [reports]);
const doExport = (kind: 'csv' | 'geojson') => {
const stamp = new Date().toISOString().slice(0, 10);
const name = `krakow-bez-barier-${stamp}.${kind === 'csv' ? 'csv' : 'geojson'}`;
@ -312,10 +317,10 @@ export default function App() {
};
const official = account?.role === 'official';
const nav: { id: View; label: string; icon: string }[] = [
const nav: { id: View; label: string; icon: string; badge?: number }[] = [
{ id: 'places', label: t('nav.places'), icon: 'location_on' },
{ id: 'route', label: t('nav.route'), icon: 'directions_walk' },
...(official ? [{ id: 'dashboard' as View, label: t('nav.dashboard'), icon: 'assessment' }] : []),
...(official ? [{ id: 'dashboard' as View, label: t(sheetMode ? 'nav.dashboard.short' : 'nav.dashboard'), icon: 'assessment', badge: pendingCount }] : []),
{ id: 'about', label: t('nav.about'), icon: 'info' },
{ id: 'profile', label: t('nav.profile'), icon: 'account_circle' },
];
@ -365,6 +370,12 @@ export default function App() {
>
<Icon name={n.icon} size={3} className="kbb-nav__icon" />
<span>{n.label}</span>
{n.badge ? (
<>
<span className="kbb-nav__badge" aria-hidden="true">{n.badge}</span>
<span className="usa-sr-only">{t('dash.nav.pending', { n: n.badge })}</span>
</>
) : null}
</button>
</li>
))}
@ -432,7 +443,7 @@ export default function App() {
<DashboardView
stats={stats} hotspots={hotspots} reports={reports} placeById={placeById} demand={demandList}
officialName={t(`auth.${account.id}` as Key)}
onDecide={decide} onExport={doExport} exportMsg={exportMsg}
onDecide={decide} onUndo={undoDecisions} onOpenPlace={(id) => { setView('places'); select(id); }} onExport={doExport} exportMsg={exportMsg}
onShowHotspot={(h) => { setHotMode(true); setHotFocus(h); setSelectedId(null); setView('places'); setPane('map'); }}
/>
)

View file

@ -0,0 +1,97 @@
import { useI18n, type Key } from '../lib/i18n';
import type { Stats } from '../lib/civic';
import type { Place, UserReport } from '../lib/types';
import { Alert } from './ui';
const pct = (n: number, d: number) => (d ? Math.round((n / d) * 100) : 0);
/** Share of decided reports where the automatic triage pointed the same way as the official (fast-track→accepted, likely-reject→rejected). */
function agreement(reports: UserReport[]): { agree: number; total: number } {
let agree = 0, total = 0;
for (const r of reports) {
if ((r.status !== 'accepted' && r.status !== 'rejected') || r.vetting?.state !== 'done') continue;
const triage = r.vetting.result.triage;
if (triage === 'review') continue;
total++;
if ((triage === 'fast-track' && r.status === 'accepted') || (triage === 'likely-reject' && r.status === 'rejected')) agree++;
}
return { agree, total };
}
/** Data-quality overview: KPIs, the transit finding, gaps by category and anonymous demand signals. */
export function DashData({ stats, reports, demand }: { stats: Stats; reports: UserReport[]; demand: { place: Place; views: number }[] }) {
const { t } = useI18n();
const ag = agreement(reports);
const kpis: { label: string; value: string; sub?: string }[] = [
{ label: t('dash.kpi.places'), value: String(stats.total) },
{ label: t('dash.kpi.entrance'), value: `${pct(stats.entranceKnown, stats.total)}%`, sub: `${stats.entranceKnown} / ${stats.total}` },
{ label: t('dash.kpi.fresh'), value: `${pct(stats.fresh, stats.total)}%`, sub: `${stats.fresh} / ${stats.total}` },
{ label: t('dash.kpi.verified'), value: `${stats.verified}`, sub: `${pct(stats.verified, stats.total)}%` },
{ label: t('dash.kpi.stops'), value: `${stats.transitOperatorKnown} / ${stats.transitTotal}` },
{ label: t('dash.kpi.stopsOsm'), value: `${stats.transitCommunityKnown} / ${stats.transitTotal}` },
{ label: t('dash.kpi.agreement'), value: ag.total ? `${Math.round((ag.agree / ag.total) * 100)}%` : t('dash.kpi.agreement.none'), ...(ag.total ? { sub: t('dash.kpi.agreement.n', { n: ag.total }) } : {}) },
];
return (
<div>
<p className="text-base-dark margin-top-0">{t('dash.intro')}</p>
<ul className="kbb-kpis usa-list--unstyled margin-0 padding-0">
{kpis.map((k) => (
<li key={k.label} className="kbb-kpi">
<span className="kbb-kpi__v">{k.value}</span>
<span className="kbb-kpi__l">{k.label}</span>
{k.sub && <span className="kbb-kpi__s">{k.sub}</span>}
</li>
))}
</ul>
<Alert kind="info">
{t('dash.insight', { n: stats.transitTotal - stats.transitOperatorKnown, total: stats.transitTotal, osm: stats.transitCommunityKnown })}
</Alert>
<section aria-labelledby="gaps" className="margin-top-4">
<h2 id="gaps">{t('dash.gaps')}</h2>
<table className="usa-table usa-table--borderless usa-table--stacked width-full">
<caption className="usa-sr-only">{t('dash.gaps.caption')}</caption>
<thead>
<tr>
<th scope="col">{t('dash.gaps.cat')}</th>
<th scope="col">{t('dash.gaps.total')}</th>
<th scope="col">{t('dash.gaps.known')}</th>
<th scope="col">{t('dash.gaps.stale')}</th>
<th scope="col">{t('dash.gaps.unknown')}</th>
<th scope="col"><span className="usa-sr-only">{t('dash.gaps.caption')}</span></th>
</tr>
</thead>
<tbody>
{stats.byCat.map((r) => (
<tr key={r.cat}>
<th scope="row" data-label={t('dash.gaps.cat')}>{t(`cat.${r.cat}` as Key)}</th>
<td data-label={t('dash.gaps.total')}>{r.total}</td>
<td data-label={t('dash.gaps.known')}>{r.known} ({pct(r.known, r.total)}%)</td>
<td data-label={t('dash.gaps.stale')}>{r.stale} ({pct(r.stale, r.total)}%)</td>
<td data-label={t('dash.gaps.unknown')}>{r.unknown} ({pct(r.unknown, r.total)}%)</td>
<td aria-hidden="true" className="kbb-bar">
<span className="kbb-bar__k" style={{ width: `${pct(r.known, r.total)}%` }} />
<span className="kbb-bar__s" style={{ width: `${pct(r.stale, r.total)}%` }} />
<span className="kbb-bar__u" style={{ width: `${pct(r.unknown, r.total)}%` }} />
</td>
</tr>
))}
</tbody>
</table>
</section>
<section aria-labelledby="demand" className="margin-top-4">
<h2 id="demand">{t('dash.demand')}</h2>
<p className="text-base-dark">{t('dash.demand.intro')}</p>
{demand.length === 0 ? <p>{t('dash.demand.none')}</p> : (
<ul className="usa-list">
{demand.map(({ place, views }) => (
<li key={place.id}><strong>{place.name}</strong> — {t('dash.demand.views', { n: views })}</li>
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,40 @@
import { useI18n } from '../lib/i18n';
import type { Hotspot } from '../lib/civic';
import { Icon } from './ui';
/** "Fix first" ranking of pedestrian crossings. */
export function DashPriorities({ hotspots, onShowHotspot }: { hotspots: Hotspot[]; onShowHotspot: (h: Hotspot) => void }) {
const { t } = useI18n();
const first = hotspots[0];
return (
<div>
<p className="text-base-dark margin-top-0">{t('dash.hot.intro')}</p>
{first && (
<button type="button" className="usa-button usa-button--outline margin-bottom-2" onClick={() => onShowHotspot(first)}>
<Icon name="map" size={3} className="margin-right-05" />{t('dash.hot.showAll', { n: hotspots.length })}
</button>
)}
{hotspots.length === 0 ? <p>{t('dash.hot.none')}</p> : (
<ol className="usa-list--unstyled margin-0 padding-0">
{hotspots.map((h, i) => (
<li key={h.id} className="kbb-hot border-1px border-base-lighter radius-md padding-2 margin-bottom-1">
<span className="kbb-rank" aria-label={`${t('dash.hot.rank')} ${i + 1}`}>{i + 1}</span>
<div className="kbb-hot__body">
<p className="margin-0">
<strong>{h.street ?? '—'}</strong>{' '}
<span className={`usa-tag ${h.kerb === 'raised' ? 'bg-error-dark text-white' : 'bg-base-lighter text-ink'}`}>
{t(h.kerb === 'raised' ? 'dash.hot.kerbRaised' : 'dash.hot.kerbUnknown')}
</span>
</p>
<p className="font-sans-xs text-base-dark margin-top-05 margin-bottom-0">{t('dash.hot.near', { n: h.near })}{h.more > 0 ? ` · ${t('dash.hot.more', { n: h.more })}` : ''}</p>
</div>
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={() => onShowHotspot(h)}>
<Icon name="map" size={3} className="margin-right-05" />{t('dash.hot.show')}
</button>
</li>
))}
</ol>
)}
</div>
);
}

View file

@ -0,0 +1,145 @@
import { useState } from 'react';
import { useI18n, type Key } from '../lib/i18n';
import { usePhoto } from '../lib/usePhoto';
import type { Place, UserReport } from '../lib/types';
import { Alert, VettingSummary } from './ui';
type Lane = 'all' | 'fast-track' | 'review' | 'likely-reject';
type Decision = 'accepted' | 'rejected';
const LANES: Lane[] = ['all', 'fast-track', 'review', 'likely-reject'];
const SEVERITY_RANK = { critical: 0, major: 1, minor: 2, none: 3 } as const;
const TRIAGE_RANK = { 'fast-track': 0, review: 1, 'likely-reject': 2 } as const;
/** A report without a finished automatic assessment still needs a human look. */
const laneOf = (r: UserReport): Exclude<Lane, 'all'> => (r.vetting?.state === 'done' ? r.vetting.result.triage : 'review');
const rank = (r: UserReport): [number, number] => {
const v = r.vetting?.state === 'done' ? r.vetting.result : null;
return v ? [TRIAGE_RANK[v.triage], SEVERITY_RANK[v.severity]] : [1, 3];
};
function Thumb({ id }: { id: string | undefined }) {
const { t } = useI18n();
const src = usePhoto(id);
return src ? <img className="kbb-thumb" src={src} alt={t('rep.photo.alt')} /> : null;
}
interface Props {
reports: UserReport[];
placeById: Map<string, Place>;
onDecide: (ids: string[], status: Decision) => void;
onUndo: (ids: string[]) => void;
onOpenPlace: (id: string) => void;
}
/** The inbox: pending user reports, triaged by the automatic first look, with bulk decisions that can be undone. */
export function DashQueue({ reports, placeById, onDecide, onUndo, onOpenPlace }: Props) {
const { t, date } = useI18n();
const [lane, setLane] = useState<Lane>('all');
const [sort, setSort] = useState<'ai' | 'new'>('ai');
const [last, setLast] = useState<{ ids: string[]; status: Decision } | null>(null);
const pending = reports.filter((r) => !r.status || r.status === 'pending');
const decided = reports.filter((r) => r.status === 'accepted' || r.status === 'rejected');
const count = (l: Lane) => (l === 'all' ? pending.length : pending.filter((r) => laneOf(r) === l).length);
const shown = pending
.filter((r) => lane === 'all' || laneOf(r) === lane)
.sort((a, b) => {
if (sort === 'new') return b.createdAt.localeCompare(a.createdAt);
const [ta, sa] = rank(a), [tb, sb] = rank(b);
return ta - tb || sa - sb || b.createdAt.localeCompare(a.createdAt);
});
const decide = (ids: string[], status: Decision) => { onDecide(ids, status); setLast({ ids, status }); };
const bulk: Decision | null = lane === 'fast-track' ? 'accepted' : lane === 'likely-reject' ? 'rejected' : null;
return (
<div>
<p className="text-base-dark font-sans-xs margin-top-0">{t('dash.queue.intro')}</p>
<div className="kbb-chips" role="group" aria-label={t('dash.q.filter')}>
{LANES.filter((l) => l === 'all' || l === lane || count(l) > 0).map((l) => (
<button key={l} type="button" aria-pressed={lane === l} className={`kbb-chip ${lane === l ? 'is-on' : ''}`} onClick={() => setLane(l)}>
{l === 'all' ? t('dash.q.all') : t(`vet.triage.${l}` as Key)}
<span className="kbb-chip__n">{count(l)}</span>
</button>
))}
</div>
<div className="kbb-q-bar">
{bulk && shown.length > 1 && (
<button type="button" className={`usa-button margin-0 ${bulk === 'rejected' ? 'usa-button--secondary' : ''}`} onClick={() => decide(shown.map((r) => r.id), bulk)}>
{t(bulk === 'accepted' ? 'dash.q.acceptAll' : 'dash.q.rejectAll', { n: shown.length })}
</button>
)}
{pending.length > 1 && (
<span className="kbb-q-sort">
<label className="usa-label margin-0" htmlFor="queue-sort">{t('dash.queue.sort')}</label>
<select id="queue-sort" className="usa-select margin-0" value={sort} onChange={(e) => setSort(e.target.value as 'ai' | 'new')}>
<option value="ai">{t('dash.queue.sort.ai')}</option>
<option value="new">{t('dash.queue.sort.new')}</option>
</select>
</span>
)}
</div>
<div aria-live="polite" className="margin-bottom-2">
{last && (
<Alert kind="success" slim>
{t(last.status === 'accepted' ? 'dash.q.didAccept' : 'dash.q.didReject', { n: last.ids.length })}{' '}
<button type="button" className="usa-button usa-button--unstyled" onClick={() => { onUndo(last.ids); setLast(null); }}>{t('dash.q.undo')}</button>
</Alert>
)}
</div>
{pending.length === 0 ? <Alert kind="success">{t('dash.queue.empty')}</Alert> : shown.length === 0 ? <p>{t('dash.q.emptyLane')}</p> : (
<ul className="usa-list--unstyled margin-0 padding-0">
{shown.map((r) => {
const p = placeById.get(r.placeId);
const name = p?.name ?? r.placeId;
return (
<li key={r.id} className="kbb-q-item">
<Thumb id={r.photoId} />
<div className="kbb-q-item__body">
<h2 className="margin-0 font-sans-md">
{name}{' '}
{r.demo && <span className="usa-tag bg-ink text-white">{t('detail.reports.demo')}</span>}
</h2>
<p className="margin-y-05">
{t(`rep.attr.${r.attr}` as Key)}: <strong>{typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}` as Key)}</strong>
<span className="text-base-dark"> · {date(r.createdAt.slice(0, 10))}</span>
</p>
{r.measure && <p className="font-sans-xs margin-y-05">{t('dash.queue.measure')}: <strong>{r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'}</strong> ({t(`vet.method.${r.measure.method}` as Key)})</p>}
{r.note && <p className="font-sans-xs text-base-dark margin-y-05">{r.note}</p>}
<VettingSummary v={r.vetting} detailed />
<div className="kbb-q-actions">
<button type="button" className="usa-button margin-0" onClick={() => decide([r.id], 'accepted')} aria-label={`${t('dash.queue.accept')}: ${name}`}>{t('dash.queue.accept')}</button>
<button type="button" className="usa-button usa-button--secondary margin-0" onClick={() => decide([r.id], 'rejected')} aria-label={`${t('dash.queue.reject')}: ${name}`}>{t('dash.queue.reject')}</button>
{p && <button type="button" className="usa-button usa-button--unstyled" onClick={() => onOpenPlace(p.id)}>{t('dash.q.openPlace')}</button>}
</div>
</div>
</li>
);
})}
</ul>
)}
{decided.length > 0 && (
<details className="margin-top-2">
<summary>{t('dash.queue.decided', { n: decided.length })}</summary>
<ul className="usa-list--unstyled margin-0 padding-0">
{decided.map((r) => (
<li key={r.id} className="kbb-q-done">
<span>
{placeById.get(r.placeId)?.name ?? r.placeId} — {t(`rep.attr.${r.attr}` as Key)} —{' '}
<strong>{t(`dash.queue.status.${r.status === 'accepted' ? 'accepted' : 'rejected'}` as Key)}</strong>
{r.reviewedBy ? ` (${r.reviewedBy})` : ''}
</span>
{!r.demo && <button type="button" className="usa-button usa-button--unstyled" onClick={() => onUndo([r.id])}>{t('dash.q.revert')}</button>}
</li>
))}
</ul>
</details>
)}
</div>
);
}

View file

@ -1,9 +1,16 @@
import { useState } from 'react';
import { useI18n, type Key } from '../lib/i18n';
import { usePhoto } from '../lib/usePhoto';
import { useI18n } from '../lib/i18n';
import { useLocalState } from '../lib/store';
import type { Hotspot, Stats } from '../lib/civic';
import type { Place, UserReport } from '../lib/types';
import { Alert, Icon, VettingSummary } from './ui';
import { Alert } from './ui';
import { DashData } from './DashData';
import { DashPriorities } from './DashPriorities';
import { DashQueue } from './DashQueue';
import { Tabs, tabPanelProps } from './Tabs';
type Tab = 'queue' | 'priorities' | 'data' | 'export';
const TABS: Tab[] = ['queue', 'priorities', 'data', 'export'];
const isTab = (v: unknown): v is Tab => typeof v === 'string' && (TABS as string[]).includes(v);
interface Props {
stats: Stats;
@ -12,217 +19,48 @@ interface Props {
placeById: Map<string, Place>;
demand: { place: Place; views: number }[];
officialName: string;
onDecide: (id: string, status: 'accepted' | 'rejected') => void;
onDecide: (ids: string[], status: 'accepted' | 'rejected') => void;
onUndo: (ids: string[]) => void;
onOpenPlace: (id: string) => void;
onShowHotspot: (h: Hotspot) => void;
onExport: (kind: 'csv' | 'geojson') => void;
exportMsg: string | null;
}
const pct = (n: number, d: number) => (d ? Math.round((n / d) * 100) : 0);
const TRIAGE_RANK = { 'fast-track': 0, review: 1, 'likely-reject': 2 } as const;
const SEVERITY_RANK = { critical: 0, major: 1, minor: 2, none: 3 } as const;
/** Lower = look at it first. Reports without a finished automatic assessment sit in the middle. */
function priority(r: UserReport): [number, number] {
const v = r.vetting?.state === 'done' ? r.vetting.result : null;
return v ? [TRIAGE_RANK[v.triage], SEVERITY_RANK[v.severity]] : [1, 3];
}
function QueueThumb({ id }: { id: string | undefined }) {
/** City-office panel: one task per tab, the inbox first. */
export function DashboardView(p: Props) {
const { t } = useI18n();
const src = usePhoto(id);
return src ? <img className="kbb-thumb margin-right-2" src={src} alt={t('rep.photo.alt')} /> : null;
}
export function DashboardView({ stats, hotspots, reports, placeById, demand, officialName, onDecide, onShowHotspot, onExport, exportMsg }: Props) {
const { t, date } = useI18n();
const [sort, setSort] = useState<'ai' | 'new'>('ai');
const pending = reports
.filter((r) => !r.status || r.status === 'pending')
.sort((a, b) => {
if (sort === 'new') return b.createdAt.localeCompare(a.createdAt);
const [ta, sa] = priority(a), [tb, sb] = priority(b);
return ta - tb || sa - sb || b.createdAt.localeCompare(a.createdAt);
});
const decided = reports.filter((r) => r.status === 'accepted' || r.status === 'rejected');
const kpis: { label: string; value: string; sub?: string }[] = [
{ label: t('dash.kpi.places'), value: String(stats.total) },
{ label: t('dash.kpi.entrance'), value: `${pct(stats.entranceKnown, stats.total)}%`, sub: `${stats.entranceKnown} / ${stats.total}` },
{ label: t('dash.kpi.fresh'), value: `${pct(stats.fresh, stats.total)}%`, sub: `${stats.fresh} / ${stats.total}` },
{ label: t('dash.kpi.verified'), value: `${stats.verified}`, sub: `${pct(stats.verified, stats.total)}%` },
{ label: t('dash.kpi.stops'), value: `${stats.transitOperatorKnown} / ${stats.transitTotal}` },
{ label: t('dash.kpi.stopsOsm'), value: `${stats.transitCommunityKnown} / ${stats.transitTotal}` },
{ label: t('dash.kpi.pending'), value: String(pending.length) },
{ label: t('dash.kpi.agreement'), value: agreementValue(reports, t('dash.kpi.agreement.none')), sub: agreementSub(reports, (n) => t('dash.kpi.agreement.n', { n })) },
];
const [tab, setTab] = useLocalState<Tab>('kbb.dash.tab', 'queue', isTab);
const pending = p.reports.filter((r) => !r.status || r.status === 'pending').length;
return (
<div className="grid-container padding-y-4 kbb-wide">
<h1 className="margin-top-0">{t('dash.title')}</h1>
<p className="usa-intro">{t('dash.intro')}</p>
<div className="grid-container padding-y-2 kbb-wide">
<h1 className="margin-top-0 margin-bottom-1">{t('dash.title')}</h1>
<p className="font-sans-3xs text-base-dark margin-top-0">{p.officialName}</p>
<ul className="kbb-kpis usa-list--unstyled margin-0 padding-0">
{kpis.map((k) => (
<li key={k.label} className="kbb-kpi">
<span className="kbb-kpi__v">{k.value}</span>
<span className="kbb-kpi__l">{k.label}</span>
{k.sub && <span className="kbb-kpi__s">{k.sub}</span>}
</li>
))}
</ul>
<Tabs
idPrefix="dash" label={t('dash.tabs')} active={tab} onChange={setTab}
tabs={[
{ id: 'queue', label: t('dash.tab.queue'), badge: pending },
{ id: 'priorities', label: t('dash.tab.priorities'), badge: p.hotspots.length },
{ id: 'data', label: t('dash.tab.data') },
{ id: 'export', label: t('dash.tab.export') },
]}
/>
<Alert kind="info">
{t('dash.insight', { n: stats.transitTotal - stats.transitOperatorKnown, total: stats.transitTotal, osm: stats.transitCommunityKnown })}
</Alert>
<section aria-labelledby="gaps" className="margin-top-4">
<h2 id="gaps">{t('dash.gaps')}</h2>
<div className="usa-table-container--scrollable" tabIndex={0} role="region" aria-label={t('dash.gaps.caption')}>
<table className="usa-table usa-table--borderless width-full">
<caption className="usa-sr-only">{t('dash.gaps.caption')}</caption>
<thead>
<tr>
<th scope="col">{t('dash.gaps.cat')}</th>
<th scope="col" data-sortable>{t('dash.gaps.total')}</th>
<th scope="col">{t('dash.gaps.known')}</th>
<th scope="col">{t('dash.gaps.stale')}</th>
<th scope="col">{t('dash.gaps.unknown')}</th>
<th scope="col"><span className="usa-sr-only">{t('dash.gaps.caption')}</span></th>
</tr>
</thead>
<tbody>
{stats.byCat.map((r) => (
<tr key={r.cat}>
<th scope="row">{t(`cat.${r.cat}` as Key)}</th>
<td>{r.total}</td>
<td>{r.known} ({pct(r.known, r.total)}%)</td>
<td>{r.stale} ({pct(r.stale, r.total)}%)</td>
<td>{r.unknown} ({pct(r.unknown, r.total)}%)</td>
<td aria-hidden="true" className="kbb-bar">
<span className="kbb-bar__k" style={{ width: `${pct(r.known, r.total)}%` }} />
<span className="kbb-bar__s" style={{ width: `${pct(r.stale, r.total)}%` }} />
<span className="kbb-bar__u" style={{ width: `${pct(r.unknown, r.total)}%` }} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section aria-labelledby="hot" className="margin-top-4">
<h2 id="hot">{t('dash.hot')}</h2>
<p className="text-base-dark">{t('dash.hot.intro')}</p>
{hotspots.length === 0 ? <p>{t('dash.hot.none')}</p> : (
<ol className="usa-list--unstyled margin-0 padding-0">
{hotspots.map((h, i) => (
<li key={h.id} className="border-1px border-base-lighter radius-md padding-2 margin-bottom-1 display-flex flex-justify flex-align-center flex-wrap">
<span>
<span className="kbb-rank" aria-label={`${t('dash.hot.rank')} ${i + 1}`}>{i + 1}</span>
<strong>{h.street ?? '—'}</strong>
<span className={`usa-tag margin-left-1 ${h.kerb === 'raised' ? 'bg-error-dark text-white' : 'bg-base-lighter text-ink'}`}>
{t(h.kerb === 'raised' ? 'dash.hot.kerbRaised' : 'dash.hot.kerbUnknown')}
</span>
<span className="display-block font-sans-xs text-base-dark margin-top-05 margin-left-4">{t('dash.hot.near', { n: h.near })}{h.more > 0 ? ` · ${t('dash.hot.more', { n: h.more })}` : ''}</span>
</span>
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={() => onShowHotspot(h)}>
<Icon name="map" size={3} className="margin-right-05" />{t('dash.hot.show')}
</button>
</li>
))}
</ol>
)}
</section>
<section aria-labelledby="queue" className="margin-top-4">
<h2 id="queue">{t('dash.queue')}</h2>
<p className="text-base-dark">{t('dash.queue.intro')}</p>
{pending.length > 1 && (
<div className="margin-bottom-2">
<label className="usa-label margin-top-0" htmlFor="queue-sort">{t('dash.queue.sort')}</label>
<select id="queue-sort" className="usa-select" value={sort} onChange={(e) => setSort(e.target.value as 'ai' | 'new')}>
<option value="ai">{t('dash.queue.sort.ai')}</option>
<option value="new">{t('dash.queue.sort.new')}</option>
</select>
<div {...tabPanelProps('dash', tab)}>
{tab === 'queue' && <DashQueue reports={p.reports} placeById={p.placeById} onDecide={p.onDecide} onUndo={p.onUndo} onOpenPlace={p.onOpenPlace} />}
{tab === 'priorities' && <DashPriorities hotspots={p.hotspots} onShowHotspot={p.onShowHotspot} />}
{tab === 'data' && <DashData stats={p.stats} reports={p.reports} demand={p.demand} />}
{tab === 'export' && (
<div>
<p className="text-base-dark margin-top-0">{t('dash.export.intro')}</p>
<button type="button" className="usa-button" onClick={() => p.onExport('csv')}>{t('dash.export.csv')}</button>
<button type="button" className="usa-button usa-button--outline" onClick={() => p.onExport('geojson')}>{t('dash.export.geojson')}</button>
<div aria-live="polite" className="margin-top-1">{p.exportMsg && <Alert kind="success" slim>{p.exportMsg}</Alert>}</div>
</div>
)}
{pending.length === 0 ? <p>{t('dash.queue.empty')}</p> : (
<ul className="usa-list--unstyled margin-0 padding-0">
{pending.map((r) => {
const p = placeById.get(r.placeId);
return (
<li key={r.id} className="border-1px border-base-lighter radius-md padding-2 margin-bottom-1 display-flex">
<QueueThumb id={r.photoId} />
<div className="flex-1">
<p className="margin-0">
<strong>{t('dash.queue.place')}:</strong> {p?.name ?? r.placeId}{' '}
{r.demo && <span className="usa-tag bg-ink text-white">{t('detail.reports.demo')}</span>}
</p>
<p className="margin-y-05">
{t(`rep.attr.${r.attr}` as Key)}: <strong>{typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}` as Key)}</strong>
<span className="text-base-dark"> · {date(r.createdAt.slice(0, 10))}</span>
</p>
{r.measure && <p className="font-sans-xs margin-y-05">{t('dash.queue.measure')}: <strong>{r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'}</strong> ({t(`vet.method.${r.measure.method}` as Key)})</p>}
{r.note && <p className="font-sans-xs text-base-dark margin-y-05">{r.note}</p>}
<VettingSummary v={r.vetting} detailed />
<button type="button" className="usa-button usa-button--small" onClick={() => onDecide(r.id, 'accepted')} aria-label={`${t('dash.queue.accept')}: ${p?.name ?? ''}`}>{t('dash.queue.accept')}</button>
<button type="button" className="usa-button usa-button--secondary usa-button--small" onClick={() => onDecide(r.id, 'rejected')} aria-label={`${t('dash.queue.reject')}: ${p?.name ?? ''}`}>{t('dash.queue.reject')}</button>
</div>
</li>
);
})}
</ul>
)}
{decided.length > 0 && (
<details className="margin-top-1">
<summary>{t('dash.queue.decided', { n: decided.length })}</summary>
<ul className="usa-list">
{decided.map((r) => (
<li key={r.id}>
{placeById.get(r.placeId)?.name ?? r.placeId} — {t(`rep.attr.${r.attr}` as Key)} — {t(`dash.queue.status.${r.status === 'accepted' ? 'accepted' : 'rejected'}` as Key)}
{r.reviewedBy ? ` (${r.reviewedBy})` : ''}
</li>
))}
</ul>
</details>
)}
<p className="font-sans-3xs text-base-dark">{officialName}</p>
</section>
<section aria-labelledby="demand" className="margin-top-4">
<h2 id="demand">{t('dash.demand')}</h2>
<p className="text-base-dark">{t('dash.demand.intro')}</p>
{demand.length === 0 ? <p>{t('dash.demand.none')}</p> : (
<ul className="usa-list">
{demand.map(({ place, views }) => (
<li key={place.id}><strong>{place.name}</strong> — {t('dash.demand.views', { n: views })}</li>
))}
</ul>
)}
</section>
<section aria-labelledby="export" className="margin-top-4">
<h2 id="export">{t('dash.export')}</h2>
<p className="text-base-dark">{t('dash.export.intro')}</p>
<button type="button" className="usa-button" onClick={() => onExport('csv')}>{t('dash.export.csv')}</button>
<button type="button" className="usa-button usa-button--outline" onClick={() => onExport('geojson')}>{t('dash.export.geojson')}</button>
<div aria-live="polite" className="margin-top-1">{exportMsg && <Alert kind="success" slim>{exportMsg}</Alert>}</div>
</section>
</div>
);
}
/** Share of decided reports where the automatic triage pointed the same way as the official (fast-track→accepted, likely-reject→rejected). */
function agreement(reports: UserReport[]): { agree: number; total: number } {
let agree = 0, total = 0;
for (const r of reports) {
if ((r.status !== 'accepted' && r.status !== 'rejected') || r.vetting?.state !== 'done') continue;
const triage = r.vetting.result.triage;
if (triage === 'review') continue;
total++;
if ((triage === 'fast-track' && r.status === 'accepted') || (triage === 'likely-reject' && r.status === 'rejected')) agree++;
}
return { agree, total };
}
const agreementValue = (reports: UserReport[], none: string) => { const a = agreement(reports); return a.total ? `${Math.round((a.agree / a.total) * 100)}%` : none; };
const agreementSub = (reports: UserReport[], fmt: (n: number) => string) => { const a = agreement(reports); return a.total ? fmt(a.total) : undefined; };

View file

@ -52,11 +52,11 @@ export function ProfileView({ account, profile, onChange, onDone, onSignOut, pre
<p className="usa-intro">{t('profile.intro')}</p>
<div className="border-1px border-base-lighter radius-md padding-2 bg-base-lightest display-flex flex-justify flex-align-center flex-wrap">
<p className="margin-0">
<Icon name="accessibility_new" size={3} className="margin-right-1 text-primary" />
<p className="kbb-acct margin-0">
<Icon name="accessibility_new" size={3} className="text-primary" />
<strong>{account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)}</strong>
<span className="text-base-dark"> · {t(`auth.role.${account.role}` as Key)}</span>
{account.id !== 'guest' && <span className="usa-tag bg-ink text-white margin-left-1">{t('auth.demoBadge')}</span>}
<span className="text-base-dark">{t(`auth.role.${account.role}` as Key)}</span>
{account.id !== 'guest' && <span className="usa-tag bg-ink text-white">{t('auth.demoBadge')}</span>}
</p>
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={onSignOut}>
{t('auth.signOut')}

37
src/components/Tabs.tsx Normal file
View file

@ -0,0 +1,37 @@
import { useRef, type KeyboardEvent } from 'react';
export interface TabDef<Id extends string> { id: Id; label: string; badge?: number | undefined }
/** WAI-ARIA tablist (roving tabindex, arrow/Home/End keys). Panels are rendered by the caller with `tabPanelProps`. */
export function Tabs<Id extends string>({ label, tabs, active, onChange, idPrefix }: {
label: string; tabs: TabDef<Id>[]; active: Id; onChange: (id: Id) => void; idPrefix: string;
}) {
const refs = useRef(new Map<Id, HTMLButtonElement>());
const move = (e: KeyboardEvent, i: number) => {
const last = tabs.length - 1;
const to = e.key === 'ArrowRight' ? (i === last ? 0 : i + 1) : e.key === 'ArrowLeft' ? (i === 0 ? last : i - 1) : e.key === 'Home' ? 0 : e.key === 'End' ? last : -1;
const next = tabs[to];
if (!next) return;
e.preventDefault();
onChange(next.id);
refs.current.get(next.id)?.focus();
};
return (
<div role="tablist" aria-label={label} className="kbb-tabs">
{tabs.map((tab, i) => (
<button
key={tab.id} type="button" role="tab" id={`${idPrefix}-tab-${tab.id}`} aria-controls={`${idPrefix}-panel-${tab.id}`}
aria-selected={active === tab.id} tabIndex={active === tab.id ? 0 : -1} className="kbb-tab"
ref={(el) => { if (el) refs.current.set(tab.id, el); else refs.current.delete(tab.id); }}
onClick={() => onChange(tab.id)} onKeyDown={(e) => move(e, i)}
>
{tab.label}
{tab.badge ? <span className="kbb-tab__badge">{tab.badge}</span> : null}
</button>
))}
</div>
);
}
export const tabPanelProps = (idPrefix: string, id: string) =>
({ role: 'tabpanel', id: `${idPrefix}-panel-${id}`, 'aria-labelledby': `${idPrefix}-tab-${id}`, tabIndex: 0, className: 'kbb-tabpanel' }) as const;

View file

@ -324,6 +324,7 @@ const pl = {
'f.entrance.cityNone': 'Przewoźnik nie podaje informacji o dostępności tego przystanku.',
'f.entrance.cityNoneHint': 'W oficjalnych danych ZTP pole „wheelchair_boarding” ma wartość „brak informacji”. Nie oznacza to, że przystanek jest niedostępny ani że jest dostępny.',
'nav.dashboard': 'Panel urzędu',
'nav.dashboard.short': 'Urząd',
'nav.settings': 'Ustawienia',
'nav.account': 'Konto',
'auth.title': 'Zaloguj się',
@ -613,6 +614,23 @@ const pl = {
'dash.queue.sort': 'Sortuj',
'dash.queue.sort.ai': 'Priorytet wg oceny automatycznej',
'dash.queue.sort.new': 'Najnowsze',
'dash.tabs': 'Sekcje panelu urzędu',
'dash.tab.queue': 'Zgłoszenia',
'dash.tab.priorities': 'Przejścia',
'dash.tab.data': 'Jakość',
'dash.tab.export': 'Eksport',
'dash.nav.pending': '{n} zgłoszeń do decyzji',
'dash.q.filter': 'Filtr zgłoszeń według oceny automatycznej',
'dash.q.all': 'Wszystkie',
'dash.q.acceptAll': 'Zatwierdź wszystkie ({n})',
'dash.q.rejectAll': 'Odrzuć wszystkie ({n})',
'dash.q.didAccept': 'Zatwierdzono: {n}.',
'dash.q.didReject': 'Odrzucono: {n}.',
'dash.q.undo': 'Cofnij',
'dash.q.revert': 'Cofnij decyzję',
'dash.q.openPlace': 'Otwórz miejsce',
'dash.q.emptyLane': 'Brak zgłoszeń w tej grupie.',
'dash.hot.showAll': 'Pokaż ranking na mapie ({n})',
'dash.queue.measure': 'Pomiar',
'dash.queue.reasons': 'Uzasadnienie automatu',
'dash.kpi.agreement': 'Zgodność oceny automatu z decyzją urzędu',
@ -989,6 +1007,7 @@ const en: Dict = {
'f.entrance.cityNone': 'The operator gives no information about this stop’s accessibility.',
'f.entrance.cityNoneHint': 'In the official ZTP data the “wheelchair_boarding” field says “no information”. That does not mean the stop is inaccessible, nor that it is accessible.',
'nav.dashboard': 'City panel',
'nav.dashboard.short': 'City',
'nav.settings': 'Settings',
'nav.account': 'Account',
'auth.title': 'Sign in',
@ -1278,6 +1297,23 @@ const en: Dict = {
'dash.queue.sort': 'Sort',
'dash.queue.sort.ai': 'Priority by automatic assessment',
'dash.queue.sort.new': 'Newest',
'dash.tabs': 'City panel sections',
'dash.tab.queue': 'Reports',
'dash.tab.priorities': 'Crossings',
'dash.tab.data': 'Quality',
'dash.tab.export': 'Export',
'dash.nav.pending': '{n} reports to review',
'dash.q.filter': 'Filter reports by automatic assessment',
'dash.q.all': 'All',
'dash.q.acceptAll': 'Approve all ({n})',
'dash.q.rejectAll': 'Reject all ({n})',
'dash.q.didAccept': 'Approved: {n}.',
'dash.q.didReject': 'Rejected: {n}.',
'dash.q.undo': 'Undo',
'dash.q.revert': 'Undo decision',
'dash.q.openPlace': 'Open place',
'dash.q.emptyLane': 'No reports in this group.',
'dash.hot.showAll': 'Show ranking on map ({n})',
'dash.queue.measure': 'Measurement',
'dash.queue.reasons': 'Why the automation says so',
'dash.kpi.agreement': 'Automation agrees with the office decision',

View file

@ -164,7 +164,7 @@ body { margin: 0; }
.usa-button { min-height: 2.75rem; }
.usa-input, .usa-select { height: 2.75rem; }
.kbb-pin::after { content: ''; position: absolute; inset: -0.45rem; }
.font-sans-3xs, .font-sans-2xs { font-size: 0.875rem !important; }
.font-sans-3xs, .font-sans-2xs { font-size: 0.875rem !important; line-height: 1.4 !important; }
}
@media (max-width: 63.99em) {
@ -224,3 +224,51 @@ body { margin: 0; }
.kbb-legend { top: 0.5rem; right: 3.75rem; }
}
@media (prefers-reduced-motion: reduce) { .kbb-panel, .kbb-map-wrap .maplibregl-ctrl-bottom-left, .kbb-map-wrap .maplibregl-ctrl-bottom-right { transition: none; } }
/* USWDS styles bare [type=search] for its header search (no right border, 2rem tall); undo that for our form field */
.usa-input[type='search'] { float: none; width: 100%; height: 2.5rem; border: 1px solid #565c65; border-radius: 0; padding: 0 0.5rem; font-size: 1.06rem; }
@media (max-width: 63.99em), (pointer: coarse) { .usa-input[type='search'] { height: 2.75rem; } }
.kbb-acct { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.5rem; flex: 1 1 14rem; min-width: 0; }
.kbb-hot { display: flex; align-items: flex-start; gap: 0.6rem; flex-wrap: wrap; }
.kbb-hot .kbb-rank { margin-right: 0; flex: none; }
.kbb-hot__body { flex: 1 1 10rem; min-width: 0; }
.kbb-hot .usa-button { flex: none; }
@media (max-width: 39.99em) { .kbb-hot .usa-button { width: 100%; } }
@media (max-width: 23em) { .kbb-head__brand { font-size: 0.9rem; } .kbb-head__brand .usa-icon { width: 1.5rem; height: 1.5rem; margin-right: 0.4rem !important; } }
/* ---- city panel: tabs, inbox ---- */
.kbb-tabs { display: flex; gap: 0.25rem; margin: 0.5rem 0 1rem; border-bottom: 2px solid #dfe1e2; overflow-x: auto; scrollbar-width: none; }
.kbb-tabs::-webkit-scrollbar { display: none; }
.kbb-tab { appearance: none; flex: none; display: inline-flex; align-items: center; gap: 0.4rem; min-height: 2.75rem; padding: 0.5rem 0.9rem; margin-bottom: -2px; background: none; border: 0; border-bottom: 4px solid transparent; font: inherit; font-weight: 700; color: #454545; cursor: pointer; white-space: nowrap; }
.kbb-tab:hover { background: #f0f0f0; }
.kbb-tab[aria-selected='true'] { color: #005ea2; border-bottom-color: #005ea2; }
.kbb-tab:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: -0.25rem; }
.kbb-tab__badge, .kbb-nav__badge { display: inline-flex; align-items: center; justify-content: center; min-width: 1.4rem; height: 1.4rem; padding: 0 0.35rem; border-radius: 0.7rem; background: #b50909; color: #fff; font-size: 0.8rem; font-weight: 700; line-height: 1; }
.kbb-tabpanel:focus { outline: none; }
.kbb-nav__badge { margin-left: 0.35rem; }
.kbb-nav__btn { position: relative; }
.kbb-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; }
.kbb-chip { appearance: none; display: inline-flex; align-items: center; gap: 0.4rem; min-height: 2.75rem; padding: 0.4rem 0.9rem; border: 2px solid #005ea2; border-radius: 1.5rem; background: #fff; color: #005ea2; font: inherit; font-weight: 700; cursor: pointer; }
.kbb-chip.is-on { background: #005ea2; color: #fff; }
.kbb-chip:focus-visible { outline: 0.25rem solid #2491ff; outline-offset: 2px; }
.kbb-chip__n { padding: 0 0.45rem; border-radius: 0.7rem; background: rgba(0, 0, 0, 0.12); font-size: 0.85rem; }
.kbb-chip.is-on .kbb-chip__n { background: rgba(255, 255, 255, 0.25); }
.kbb-q-bar { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.75rem; margin-bottom: 0.75rem; }
.kbb-q-sort { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
.kbb-q-sort .usa-select { width: auto; max-width: 16rem; }
.kbb-q-item { display: flex; gap: 0.75rem; padding: 1rem; margin-bottom: 0.75rem; border: 1px solid #dfe1e2; border-radius: 0.25rem; background: #fff; }
.kbb-q-item__body { flex: 1; min-width: 0; }
.kbb-q-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-top: 0.75rem; }
.kbb-q-done { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.25rem 1rem; padding: 0.25rem 0; border-bottom: 1px solid #dfe1e2; }
@media (max-width: 39.99em) {
.kbb-q-item { flex-direction: column; }
.kbb-q-actions .usa-button:not(.usa-button--unstyled) { flex: 1 1 0; }
.kbb-q-sort { margin-left: 0; width: 100%; }
.kbb-q-sort .usa-select { flex: 1; max-width: none; }
}
@media (max-width: 63.99em) {
/* tab bar: badge floats on the icon instead of widening the label */
.kbb-nav__badge { position: absolute; top: 0.2rem; left: calc(50% + 0.4rem); margin: 0; min-width: 1.15rem; height: 1.15rem; font-size: 0.7rem; }
}