Fix city admin panel layout
This commit is contained in:
parent
9d4b857893
commit
1e234013c6
11 changed files with 481 additions and 219 deletions
|
|
@ -4,7 +4,9 @@ import { chromium } from 'playwright-core';
|
||||||
const axeSrc = await readFile('node_modules/axe-core/axe.min.js', 'utf8');
|
const axeSrc = await readFile('node_modules/axe-core/axe.min.js', 'utf8');
|
||||||
const URL = process.env.URL ?? 'http://localhost:4173/';
|
const URL = process.env.URL ?? 'http://localhost:4173/';
|
||||||
const browser = await chromium.launch();
|
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;
|
let bad = 0;
|
||||||
async function scan(name) {
|
async function scan(name) {
|
||||||
await page.addScriptTag({ content: axeSrc });
|
await page.addScriptTag({ content: axeSrc });
|
||||||
|
|
@ -15,8 +17,11 @@ async function scan(name) {
|
||||||
await page.goto(URL, { waitUntil: 'networkidle' });
|
await page.goto(URL, { waitUntil: 'networkidle' });
|
||||||
await scan('login');
|
await scan('login');
|
||||||
await page.click('text=Zaloguj jako Maria Wiśniewska');
|
await page.click('text=Zaloguj jako Maria Wiśniewska');
|
||||||
await page.waitForSelector('.kbb-kpis');
|
await page.waitForSelector('[role=tablist]');
|
||||||
await scan('dashboard');
|
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.click('nav >> text=Miejsca');
|
||||||
await page.waitForSelector('#q');
|
await page.waitForSelector('#q');
|
||||||
await scan('places');
|
await scan('places');
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,17 @@ ok((await page.evaluate(() => document.documentElement.style.fontSize)) === '125
|
||||||
await page.click('nav >> text=Profil');
|
await page.click('nav >> text=Profil');
|
||||||
await page.click('text=Wyloguj');
|
await page.click('text=Wyloguj');
|
||||||
await page.click('text=Zaloguj jako Maria Wiśniewska');
|
await page.click('text=Zaloguj jako Maria Wiśniewska');
|
||||||
await page.waitForSelector('.kbb-kpis');
|
await page.waitForSelector('[role=tablist]');
|
||||||
ok(await page.locator('text=0 / 82').first().isVisible(), 'dashboard shows operator data gap (0 / 82 stops)');
|
|
||||||
const pending = await page.locator('button:has-text("Zatwierdź")').count();
|
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();
|
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');
|
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
|
// map pins + route render without worker errors
|
||||||
await page.click('nav >> text=Trasa');
|
await page.click('nav >> text=Trasa');
|
||||||
|
|
|
||||||
21
src/App.tsx
21
src/App.tsx
|
|
@ -269,8 +269,13 @@ export default function App() {
|
||||||
.sort((a, b) => b.views - a.views).slice(0, 5),
|
.sort((a, b) => b.views - a.views).slice(0, 5),
|
||||||
[demand, placeById, assessments],
|
[demand, placeById, assessments],
|
||||||
);
|
);
|
||||||
const decide = (id: string, status: 'accepted' | 'rejected') =>
|
const decide = (ids: string[], status: 'accepted' | 'rejected') => {
|
||||||
setDecisions((d) => ({ ...d, [id]: { status, reviewedAt: new Date().toISOString(), reviewedBy: account ? t(`auth.${account.id}` as Key) : '' } }));
|
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 doExport = (kind: 'csv' | 'geojson') => {
|
||||||
const stamp = new Date().toISOString().slice(0, 10);
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
const name = `krakow-bez-barier-${stamp}.${kind === 'csv' ? 'csv' : 'geojson'}`;
|
const name = `krakow-bez-barier-${stamp}.${kind === 'csv' ? 'csv' : 'geojson'}`;
|
||||||
|
|
@ -312,10 +317,10 @@ export default function App() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const official = account?.role === 'official';
|
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: 'places', label: t('nav.places'), icon: 'location_on' },
|
||||||
{ id: 'route', label: t('nav.route'), icon: 'directions_walk' },
|
{ 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: 'about', label: t('nav.about'), icon: 'info' },
|
||||||
{ id: 'profile', label: t('nav.profile'), icon: 'account_circle' },
|
{ 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" />
|
<Icon name={n.icon} size={3} className="kbb-nav__icon" />
|
||||||
<span>{n.label}</span>
|
<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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -432,7 +443,7 @@ export default function App() {
|
||||||
<DashboardView
|
<DashboardView
|
||||||
stats={stats} hotspots={hotspots} reports={reports} placeById={placeById} demand={demandList}
|
stats={stats} hotspots={hotspots} reports={reports} placeById={placeById} demand={demandList}
|
||||||
officialName={t(`auth.${account.id}` as Key)}
|
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'); }}
|
onShowHotspot={(h) => { setHotMode(true); setHotFocus(h); setSelectedId(null); setView('places'); setPane('map'); }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
97
src/components/DashData.tsx
Normal file
97
src/components/DashData.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/DashPriorities.tsx
Normal file
40
src/components/DashPriorities.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/components/DashQueue.tsx
Normal file
145
src/components/DashQueue.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import { useState } from 'react';
|
import { useI18n } from '../lib/i18n';
|
||||||
import { useI18n, type Key } from '../lib/i18n';
|
import { useLocalState } from '../lib/store';
|
||||||
import { usePhoto } from '../lib/usePhoto';
|
|
||||||
import type { Hotspot, Stats } from '../lib/civic';
|
import type { Hotspot, Stats } from '../lib/civic';
|
||||||
import type { Place, UserReport } from '../lib/types';
|
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 {
|
interface Props {
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
|
|
@ -12,217 +19,48 @@ interface Props {
|
||||||
placeById: Map<string, Place>;
|
placeById: Map<string, Place>;
|
||||||
demand: { place: Place; views: number }[];
|
demand: { place: Place; views: number }[];
|
||||||
officialName: string;
|
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;
|
onShowHotspot: (h: Hotspot) => void;
|
||||||
onExport: (kind: 'csv' | 'geojson') => void;
|
onExport: (kind: 'csv' | 'geojson') => void;
|
||||||
exportMsg: string | null;
|
exportMsg: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pct = (n: number, d: number) => (d ? Math.round((n / d) * 100) : 0);
|
/** City-office panel: one task per tab, the inbox first. */
|
||||||
|
export function DashboardView(p: Props) {
|
||||||
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 }) {
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const src = usePhoto(id);
|
const [tab, setTab] = useLocalState<Tab>('kbb.dash.tab', 'queue', isTab);
|
||||||
return src ? <img className="kbb-thumb margin-right-2" src={src} alt={t('rep.photo.alt')} /> : null;
|
const pending = p.reports.filter((r) => !r.status || r.status === 'pending').length;
|
||||||
}
|
|
||||||
|
|
||||||
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 })) },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid-container padding-y-4 kbb-wide">
|
<div className="grid-container padding-y-2 kbb-wide">
|
||||||
<h1 className="margin-top-0">{t('dash.title')}</h1>
|
<h1 className="margin-top-0 margin-bottom-1">{t('dash.title')}</h1>
|
||||||
<p className="usa-intro">{t('dash.intro')}</p>
|
<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">
|
<Tabs
|
||||||
{kpis.map((k) => (
|
idPrefix="dash" label={t('dash.tabs')} active={tab} onChange={setTab}
|
||||||
<li key={k.label} className="kbb-kpi">
|
tabs={[
|
||||||
<span className="kbb-kpi__v">{k.value}</span>
|
{ id: 'queue', label: t('dash.tab.queue'), badge: pending },
|
||||||
<span className="kbb-kpi__l">{k.label}</span>
|
{ id: 'priorities', label: t('dash.tab.priorities'), badge: p.hotspots.length },
|
||||||
{k.sub && <span className="kbb-kpi__s">{k.sub}</span>}
|
{ id: 'data', label: t('dash.tab.data') },
|
||||||
</li>
|
{ id: 'export', label: t('dash.tab.export') },
|
||||||
))}
|
]}
|
||||||
</ul>
|
/>
|
||||||
|
|
||||||
<Alert kind="info">
|
<div {...tabPanelProps('dash', tab)}>
|
||||||
{t('dash.insight', { n: stats.transitTotal - stats.transitOperatorKnown, total: stats.transitTotal, osm: stats.transitCommunityKnown })}
|
{tab === 'queue' && <DashQueue reports={p.reports} placeById={p.placeById} onDecide={p.onDecide} onUndo={p.onUndo} onOpenPlace={p.onOpenPlace} />}
|
||||||
</Alert>
|
{tab === 'priorities' && <DashPriorities hotspots={p.hotspots} onShowHotspot={p.onShowHotspot} />}
|
||||||
|
{tab === 'data' && <DashData stats={p.stats} reports={p.reports} demand={p.demand} />}
|
||||||
<section aria-labelledby="gaps" className="margin-top-4">
|
{tab === 'export' && (
|
||||||
<h2 id="gaps">{t('dash.gaps')}</h2>
|
<div>
|
||||||
<div className="usa-table-container--scrollable" tabIndex={0} role="region" aria-label={t('dash.gaps.caption')}>
|
<p className="text-base-dark margin-top-0">{t('dash.export.intro')}</p>
|
||||||
<table className="usa-table usa-table--borderless width-full">
|
<button type="button" className="usa-button" onClick={() => p.onExport('csv')}>{t('dash.export.csv')}</button>
|
||||||
<caption className="usa-sr-only">{t('dash.gaps.caption')}</caption>
|
<button type="button" className="usa-button usa-button--outline" onClick={() => p.onExport('geojson')}>{t('dash.export.geojson')}</button>
|
||||||
<thead>
|
<div aria-live="polite" className="margin-top-1">{p.exportMsg && <Alert kind="success" slim>{p.exportMsg}</Alert>}</div>
|
||||||
<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>
|
</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>
|
</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>
|
</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; };
|
|
||||||
|
|
|
||||||
|
|
@ -52,11 +52,11 @@ export function ProfileView({ account, profile, onChange, onDone, onSignOut, pre
|
||||||
<p className="usa-intro">{t('profile.intro')}</p>
|
<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">
|
<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">
|
<p className="kbb-acct margin-0">
|
||||||
<Icon name="accessibility_new" size={3} className="margin-right-1 text-primary" />
|
<Icon name="accessibility_new" size={3} className="text-primary" />
|
||||||
<strong>{account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)}</strong>
|
<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>
|
<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>}
|
{account.id !== 'guest' && <span className="usa-tag bg-ink text-white">{t('auth.demoBadge')}</span>}
|
||||||
</p>
|
</p>
|
||||||
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={onSignOut}>
|
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={onSignOut}>
|
||||||
{t('auth.signOut')}
|
{t('auth.signOut')}
|
||||||
|
|
|
||||||
37
src/components/Tabs.tsx
Normal file
37
src/components/Tabs.tsx
Normal 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;
|
||||||
|
|
@ -324,6 +324,7 @@ const pl = {
|
||||||
'f.entrance.cityNone': 'Przewoźnik nie podaje informacji o dostępności tego przystanku.',
|
'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.',
|
'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': 'Panel urzędu',
|
||||||
|
'nav.dashboard.short': 'Urząd',
|
||||||
'nav.settings': 'Ustawienia',
|
'nav.settings': 'Ustawienia',
|
||||||
'nav.account': 'Konto',
|
'nav.account': 'Konto',
|
||||||
'auth.title': 'Zaloguj się',
|
'auth.title': 'Zaloguj się',
|
||||||
|
|
@ -613,6 +614,23 @@ const pl = {
|
||||||
'dash.queue.sort': 'Sortuj',
|
'dash.queue.sort': 'Sortuj',
|
||||||
'dash.queue.sort.ai': 'Priorytet wg oceny automatycznej',
|
'dash.queue.sort.ai': 'Priorytet wg oceny automatycznej',
|
||||||
'dash.queue.sort.new': 'Najnowsze',
|
'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.measure': 'Pomiar',
|
||||||
'dash.queue.reasons': 'Uzasadnienie automatu',
|
'dash.queue.reasons': 'Uzasadnienie automatu',
|
||||||
'dash.kpi.agreement': 'Zgodność oceny automatu z decyzją urzędu',
|
'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.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.',
|
'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': 'City panel',
|
||||||
|
'nav.dashboard.short': 'City',
|
||||||
'nav.settings': 'Settings',
|
'nav.settings': 'Settings',
|
||||||
'nav.account': 'Account',
|
'nav.account': 'Account',
|
||||||
'auth.title': 'Sign in',
|
'auth.title': 'Sign in',
|
||||||
|
|
@ -1278,6 +1297,23 @@ const en: Dict = {
|
||||||
'dash.queue.sort': 'Sort',
|
'dash.queue.sort': 'Sort',
|
||||||
'dash.queue.sort.ai': 'Priority by automatic assessment',
|
'dash.queue.sort.ai': 'Priority by automatic assessment',
|
||||||
'dash.queue.sort.new': 'Newest',
|
'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.measure': 'Measurement',
|
||||||
'dash.queue.reasons': 'Why the automation says so',
|
'dash.queue.reasons': 'Why the automation says so',
|
||||||
'dash.kpi.agreement': 'Automation agrees with the office decision',
|
'dash.kpi.agreement': 'Automation agrees with the office decision',
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ body { margin: 0; }
|
||||||
.usa-button { min-height: 2.75rem; }
|
.usa-button { min-height: 2.75rem; }
|
||||||
.usa-input, .usa-select { height: 2.75rem; }
|
.usa-input, .usa-select { height: 2.75rem; }
|
||||||
.kbb-pin::after { content: ''; position: absolute; inset: -0.45rem; }
|
.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) {
|
@media (max-width: 63.99em) {
|
||||||
|
|
@ -224,3 +224,51 @@ body { margin: 0; }
|
||||||
.kbb-legend { top: 0.5rem; right: 3.75rem; }
|
.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; } }
|
@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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue