From 1e234013c624ef0a9c508cee7aaea7f28a1df237 Mon Sep 17 00:00:00 2001 From: InfoX Date: Sat, 3 Oct 2026 17:08:38 +0200 Subject: [PATCH] Fix city admin panel layout --- scripts/axe.mjs | 11 +- scripts/smoke.mjs | 11 +- src/App.tsx | 21 ++- src/components/DashData.tsx | 97 ++++++++++++ src/components/DashPriorities.tsx | 40 +++++ src/components/DashQueue.tsx | 145 ++++++++++++++++++ src/components/DashboardView.tsx | 244 +++++------------------------- src/components/ProfileView.tsx | 8 +- src/components/Tabs.tsx | 37 +++++ src/lib/i18n.ts | 36 +++++ src/styles/app.css | 50 +++++- 11 files changed, 481 insertions(+), 219 deletions(-) create mode 100644 src/components/DashData.tsx create mode 100644 src/components/DashPriorities.tsx create mode 100644 src/components/DashQueue.tsx create mode 100644 src/components/Tabs.tsx diff --git a/scripts/axe.mjs b/scripts/axe.mjs index 8bac565..1ac8bac 100644 --- a/scripts/axe.mjs +++ b/scripts/axe.mjs @@ -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'); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 6dd9da8..d2ff496 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -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'); diff --git a/src/App.tsx b/src/App.tsx index 841c026..93607a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { > {n.label} + {n.badge ? ( + <> + + {t('dash.nav.pending', { n: n.badge })} + + ) : null} ))} @@ -432,7 +443,7 @@ export default function App() { { setView('places'); select(id); }} onExport={doExport} exportMsg={exportMsg} onShowHotspot={(h) => { setHotMode(true); setHotFocus(h); setSelectedId(null); setView('places'); setPane('map'); }} /> ) diff --git a/src/components/DashData.tsx b/src/components/DashData.tsx new file mode 100644 index 0000000..8619715 --- /dev/null +++ b/src/components/DashData.tsx @@ -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 ( +
+

{t('dash.intro')}

+
    + {kpis.map((k) => ( +
  • + {k.value} + {k.label} + {k.sub && {k.sub}} +
  • + ))} +
+ + + {t('dash.insight', { n: stats.transitTotal - stats.transitOperatorKnown, total: stats.transitTotal, osm: stats.transitCommunityKnown })} + + +
+

{t('dash.gaps')}

+ + + + + + + + + + + + + + {stats.byCat.map((r) => ( + + + + + + + + + ))} + +
{t('dash.gaps.caption')}
{t('dash.gaps.cat')}{t('dash.gaps.total')}{t('dash.gaps.known')}{t('dash.gaps.stale')}{t('dash.gaps.unknown')}{t('dash.gaps.caption')}
{t(`cat.${r.cat}` as Key)}{r.total}{r.known} ({pct(r.known, r.total)}%){r.stale} ({pct(r.stale, r.total)}%){r.unknown} ({pct(r.unknown, r.total)}%)
+
+ +
+

{t('dash.demand')}

+

{t('dash.demand.intro')}

+ {demand.length === 0 ?

{t('dash.demand.none')}

: ( +
    + {demand.map(({ place, views }) => ( +
  • {place.name} — {t('dash.demand.views', { n: views })}
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/DashPriorities.tsx b/src/components/DashPriorities.tsx new file mode 100644 index 0000000..e281536 --- /dev/null +++ b/src/components/DashPriorities.tsx @@ -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 ( +
+

{t('dash.hot.intro')}

+ {first && ( + + )} + {hotspots.length === 0 ?

{t('dash.hot.none')}

: ( +
    + {hotspots.map((h, i) => ( +
  1. + {i + 1} +
    +

    + {h.street ?? '—'}{' '} + + {t(h.kerb === 'raised' ? 'dash.hot.kerbRaised' : 'dash.hot.kerbUnknown')} + +

    +

    {t('dash.hot.near', { n: h.near })}{h.more > 0 ? ` · ${t('dash.hot.more', { n: h.more })}` : ''}

    +
    + +
  2. + ))} +
+ )} +
+ ); +} diff --git a/src/components/DashQueue.tsx b/src/components/DashQueue.tsx new file mode 100644 index 0000000..f0e0858 --- /dev/null +++ b/src/components/DashQueue.tsx @@ -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 => (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 ? {t('rep.photo.alt')} : null; +} + +interface Props { + reports: UserReport[]; + placeById: Map; + 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('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 ( +
+

{t('dash.queue.intro')}

+ +
+ {LANES.filter((l) => l === 'all' || l === lane || count(l) > 0).map((l) => ( + + ))} +
+ +
+ {bulk && shown.length > 1 && ( + + )} + {pending.length > 1 && ( + + + + + )} +
+ +
+ {last && ( + + {t(last.status === 'accepted' ? 'dash.q.didAccept' : 'dash.q.didReject', { n: last.ids.length })}{' '} + + + )} +
+ + {pending.length === 0 ? {t('dash.queue.empty')} : shown.length === 0 ?

{t('dash.q.emptyLane')}

: ( +
    + {shown.map((r) => { + const p = placeById.get(r.placeId); + const name = p?.name ?? r.placeId; + return ( +
  • + +
    +

    + {name}{' '} + {r.demo && {t('detail.reports.demo')}} +

    +

    + {t(`rep.attr.${r.attr}` as Key)}: {typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}` as Key)} + · {date(r.createdAt.slice(0, 10))} +

    + {r.measure &&

    {t('dash.queue.measure')}: {r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'} ({t(`vet.method.${r.measure.method}` as Key)})

    } + {r.note &&

    {r.note}

    } + +
    + + + {p && } +
    +
    +
  • + ); + })} +
+ )} + + {decided.length > 0 && ( +
+ {t('dash.queue.decided', { n: decided.length })} +
    + {decided.map((r) => ( +
  • + + {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})` : ''} + + {!r.demo && } +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/DashboardView.tsx b/src/components/DashboardView.tsx index 661440c..a8ee152 100644 --- a/src/components/DashboardView.tsx +++ b/src/components/DashboardView.tsx @@ -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; 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 ? {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('kbb.dash.tab', 'queue', isTab); + const pending = p.reports.filter((r) => !r.status || r.status === 'pending').length; return ( -
-

{t('dash.title')}

-

{t('dash.intro')}

+
+

{t('dash.title')}

+

{p.officialName}

-
    - {kpis.map((k) => ( -
  • - {k.value} - {k.label} - {k.sub && {k.sub}} -
  • - ))} -
+ - - {t('dash.insight', { n: stats.transitTotal - stats.transitOperatorKnown, total: stats.transitTotal, osm: stats.transitCommunityKnown })} - - -
-

{t('dash.gaps')}

-
- - - - - - - - - - - - - - {stats.byCat.map((r) => ( - - - - - - - - - ))} - -
{t('dash.gaps.caption')}
{t('dash.gaps.cat')}{t('dash.gaps.total')}{t('dash.gaps.known')}{t('dash.gaps.stale')}{t('dash.gaps.unknown')}{t('dash.gaps.caption')}
{t(`cat.${r.cat}` as Key)}{r.total}{r.known} ({pct(r.known, r.total)}%){r.stale} ({pct(r.stale, r.total)}%){r.unknown} ({pct(r.unknown, r.total)}%)
-
-
- -
-

{t('dash.hot')}

-

{t('dash.hot.intro')}

- {hotspots.length === 0 ?

{t('dash.hot.none')}

: ( -
    - {hotspots.map((h, i) => ( -
  1. - - {i + 1} - {h.street ?? '—'} - - {t(h.kerb === 'raised' ? 'dash.hot.kerbRaised' : 'dash.hot.kerbUnknown')} - - {t('dash.hot.near', { n: h.near })}{h.more > 0 ? ` · ${t('dash.hot.more', { n: h.more })}` : ''} - - -
  2. - ))} -
- )} -
- -
-

{t('dash.queue')}

-

{t('dash.queue.intro')}

- {pending.length > 1 && ( -
- - +
+ {tab === 'queue' && } + {tab === 'priorities' && } + {tab === 'data' && } + {tab === 'export' && ( +
+

{t('dash.export.intro')}

+ + +
{p.exportMsg && {p.exportMsg}}
)} - {pending.length === 0 ?

{t('dash.queue.empty')}

: ( -
    - {pending.map((r) => { - const p = placeById.get(r.placeId); - return ( -
  • - -
    -

    - {t('dash.queue.place')}: {p?.name ?? r.placeId}{' '} - {r.demo && {t('detail.reports.demo')}} -

    -

    - {t(`rep.attr.${r.attr}` as Key)}: {typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}` as Key)} - · {date(r.createdAt.slice(0, 10))} -

    - {r.measure &&

    {t('dash.queue.measure')}: {r.measure.value} {r.measure.unit === 'cm' ? 'cm' : '%'} ({t(`vet.method.${r.measure.method}` as Key)})

    } - {r.note &&

    {r.note}

    } - - - -
    -
  • - ); - })} -
- )} - {decided.length > 0 && ( -
- {t('dash.queue.decided', { n: decided.length })} -
    - {decided.map((r) => ( -
  • - {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})` : ''} -
  • - ))} -
-
- )} -

{officialName}

-
- -
-

{t('dash.demand')}

-

{t('dash.demand.intro')}

- {demand.length === 0 ?

{t('dash.demand.none')}

: ( -
    - {demand.map(({ place, views }) => ( -
  • {place.name} — {t('dash.demand.views', { n: views })}
  • - ))} -
- )} -
- -
-

{t('dash.export')}

-

{t('dash.export.intro')}

- - -
{exportMsg && {exportMsg}}
-
+
); } - -/** 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; }; diff --git a/src/components/ProfileView.tsx b/src/components/ProfileView.tsx index 4af3a6a..2b270a3 100644 --- a/src/components/ProfileView.tsx +++ b/src/components/ProfileView.tsx @@ -52,11 +52,11 @@ export function ProfileView({ account, profile, onChange, onDone, onSignOut, pre

{t('profile.intro')}

-

- +

+ {account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)} - · {t(`auth.role.${account.role}` as Key)} - {account.id !== 'guest' && {t('auth.demoBadge')}} + {t(`auth.role.${account.role}` as Key)} + {account.id !== 'guest' && {t('auth.demoBadge')}}

+ ))} +
+ ); +} + +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; diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index f1bf6bf..abc45c4 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -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', diff --git a/src/styles/app.css b/src/styles/app.css index 0a27ab5..9230111 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -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; } +}