merge: release Fleet Ops multilingual demo
@@ -8,13 +8,19 @@ POSTGRES_DB=mobilityops
|
||||
POSTGRES_USER=mobilityops
|
||||
POSTGRES_PASSWORD=mobilityops
|
||||
APP_SECRET=replace-in-production
|
||||
DEMO_TODAY=2026-08-01
|
||||
TZ=Europe/Brussels
|
||||
# Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the
|
||||
# current Unraid review environment); set true only once MobilityOps is served over HTTPS,
|
||||
# otherwise browsers will silently drop the cookie and no one can log in.
|
||||
SESSION_COOKIE_SECURE=false
|
||||
|
||||
# Demo presentation (fictional org identity, badge/manifest, reset safety valve).
|
||||
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent
|
||||
# of role -- a safety valve for any environment where the dataset must not be rebuildable.
|
||||
DEMO_ORGANIZATION_NAME=Northstar Mobility
|
||||
DEMO_TIMEZONE=Europe/Brussels
|
||||
DEMO_ALLOW_RESET=true
|
||||
|
||||
# n8n
|
||||
N8N_BASE_URL=http://n8n:5678
|
||||
N8N_WEBHOOK_URL=http://n8n:5678/webhook/mobilityops-return
|
||||
|
||||
@@ -507,3 +507,314 @@ any change at `docs/functional-completion/server-baseline.md`.
|
||||
re-verified against the live server after every batch. See
|
||||
`artifacts/functional-completion/final-summary.md` for the definitive acceptance
|
||||
evidence.
|
||||
|
||||
## Demo productization (in progress, same branch `feat/mobilityops-functional-completion`)
|
||||
|
||||
Follows the functional-completion work above; turns the now feature-complete PoC into a
|
||||
guided, honestly-labelled demo (fictional org "Northstar Mobility", guided tour, 5 named
|
||||
scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-demo-gap-audit.md`.
|
||||
|
||||
### Batch 1 — seed date anchoring (complete)
|
||||
|
||||
- **Real bug fixed**: `seed/bookings.csv` etc. store absolute ISO timestamps authored
|
||||
around a fixed anchor (`2026-08-01`). Nothing previously re-anchored them at seed/reset
|
||||
time, so scenario bookings (e.g. `BK-DEMO-RETURN`) silently drifted into the past every
|
||||
day the environment wasn't reset. `dashboard.py::_today()` compounded this by filtering
|
||||
"today's movements" against the same frozen `demo_today` setting instead of real time.
|
||||
- Fix: `seed_loader.py` now computes `shift = today - SEED_AUTHORED_ANCHOR` once per
|
||||
`load_seed()` call and applies it to every seeded booking/inspection/maintenance/outbox
|
||||
datetime column, so scenarios stay "today"/"near-future" relative to the actual reset
|
||||
moment. `SeedResult` now also carries `anchor_date`/`seeded_at`; `POST /api/v1/demo/reset`
|
||||
returns them; a `demo_data_seeded` audit event records the anchor for traceability.
|
||||
`dashboard.py::_today()` switched from the frozen `demo_today` setting to real wall-clock
|
||||
UTC date. The now-dead `demo_today` setting/env var was removed from `config.py`,
|
||||
`compose.yaml`, `.env`, `.env.example` (nothing else referenced it).
|
||||
- Added seed-validation tests (`backend/tests/test_seed.py`) proving S1 (`BK-DEMO-RETURN`/
|
||||
`MO-024`), S2 (`CUS-0012`/`CUS-0178`/`DQ-DEMO-DUPLICATE`), S4 (`MO-016`/
|
||||
`BK-DEMO-OVERLAP-A`/`-B`/`DQ-DEMO-OVERLAP`) and S5 (seeded failed outbox event
|
||||
`00000000-0000-4000-8000-000000000020`, confirmed genuinely `failed` immediately after a
|
||||
fresh reset, not silently auto-healed by the background dispatcher since it only claims
|
||||
`pending` rows) are fully present after every reset, plus a dedicated anchoring test
|
||||
asserting the shift and the audit marker.
|
||||
- Live-verified locally: reseeded and confirmed via `psql` that `BK-DEMO-RETURN` now ends
|
||||
today and `BK-DEMO-NEXT`/overlap bookings sit in the near future (today = 2026-08-03).
|
||||
- Evidence: `pytest` **122 passed** (117 + 5 new/expanded seed tests), `ruff check .`
|
||||
clean, `mypy app` clean (46 files, canonical `make` scope).
|
||||
- Deployed to Unraid (commit `8989ffb`): pushed to Gitea, `git archive` tarball extracted
|
||||
over `/mnt/user/appdata/mobilityops` preserving `.env`/volumes, `api` rebuilt (`db`/`web`
|
||||
untouched — no frontend changes this batch), migrations confirmed at `e7b08389f47f
|
||||
(head)`, reseeded, live-verified via `psql` that `BK-DEMO-RETURN`/`BK-DEMO-NEXT`/overlap
|
||||
bookings sit at the same real-time-relative positions as local. `curl` to
|
||||
`http://192.168.10.150:1236/` returns 200.
|
||||
- Exact next action: `GET /api/v1/demo/manifest` + Dutch demo entry screen + permanent
|
||||
demo badge (task #30), then the Demo Guide + scenario overview (task #31).
|
||||
|
||||
### Batch 2 — demo manifest, Dutch demo entry, permanent demo badge, About page (complete)
|
||||
|
||||
- `GET /api/v1/demo/manifest` (unauthenticated): single source of truth for demo org
|
||||
identity, synthetic-data flag, reset allowance/timestamp/anchor date, guide
|
||||
availability, and the 5 named scenarios with **live** readiness (queries the actual
|
||||
`BK-DEMO-RETURN`/`DQ-DEMO-DUPLICATE`/`DQ-DEMO-OVERLAP`/seeded-failed-event/knowledge-
|
||||
provider records — not hardcoded), plus plain-language integration summaries. Backed by
|
||||
new `backend/app/services/demo_manifest.py`. Refactored the n8n status derivation out of
|
||||
`integration_status.py` into a shared `services/integration_status.py` so the manifest
|
||||
and the existing authenticated `/integrations/status` endpoint reuse one implementation.
|
||||
- New settings (`backend/app/core/config.py`, wired through `compose.yaml`/`.env.example`):
|
||||
`DEMO_ORGANIZATION_NAME` (default "Northstar Mobility" — surfaces the project's already-
|
||||
locked fictitious tenant, previously only used internally as the `ragcore_tenant` slug),
|
||||
`DEMO_TIMEZONE`, `DEMO_ALLOW_RESET` (a safety valve — `false` makes `POST
|
||||
/api/v1/demo/reset` return 403 regardless of role; the now-dead `demo_today` setting
|
||||
removed in Batch 1 stays removed).
|
||||
- Rewrote `Login.tsx` in Dutch: names the fictional org, one-sentence explanation sourced
|
||||
from the manifest, no password shown/copyable anywhere, "Start begeleide demo" primary
|
||||
CTA (logs in as Operations Manager, navigates to `/dashboard?guide=start` for task #31 to
|
||||
consume) plus "Verken als Operations Manager"/"Verken als Rental Employee" secondary
|
||||
actions. Added a permanent demo badge (topbar pill + popover: synthetic notice,
|
||||
"workflows are real" reassurance, last-reset timestamp, link to `/about`) replacing the
|
||||
old full-width static `.demo-banner` bar — subtle by design per the brief, not a warning
|
||||
bar. New `/about`
|
||||
page (`AboutDemo.tsx`) covering the fictional problem, what's really implemented, what's
|
||||
synthetic, honest per-integration labels (via the manifest), and a reset pointer —
|
||||
reachable from the badge popover, not added to primary nav (preserves the existing Control
|
||||
Rail nav per the "not a redesign" constraint). Frontend nav/design otherwise untouched.
|
||||
- Evidence: `pytest` **127 passed**, `ruff check .` clean, `mypy app` clean (48 files);
|
||||
frontend `tsc -b` clean, `npm run build` clean; full Playwright suite **41 passed**
|
||||
(37 existing + 4 new `demo-entry.spec.ts` covering entry copy/no-password, guided-demo
|
||||
login redirect, badge popover content + About link, and Escape/outside-click close).
|
||||
Updated stale English login-button aria-labels and login-copy assertions across the
|
||||
existing specs to match the new Dutch copy.
|
||||
- Deployed to Unraid (commit `ac427f4`): pushed to Gitea, `git archive` tarball extracted
|
||||
preserving `.env`/volumes, both `api` and `web` rebuilt (frontend changed this batch),
|
||||
both healthy, migrations unchanged, reseeded. Live-verified: `GET
|
||||
/api/v1/demo/manifest` returns `organization_name: "Northstar Mobility"`,
|
||||
`allow_reset: true`, and all 5 scenarios `ready: true` right after reset. Ran
|
||||
`demo-entry.spec.ts` (4 tests) and the five-minute demo script directly against
|
||||
`http://192.168.10.150:1236` — **5/5 passed**. Reseeded again afterward to leave the
|
||||
server demo-ready.
|
||||
- Exact next action: Demo Guide (collapsible panel, 8 steps) + scenario overview (5 cards
|
||||
on the dashboard, consuming `/api/v1/demo/manifest`'s `scenarios` array) — task #31.
|
||||
|
||||
### Batch 3 — Demo Guide + scenario overview (complete)
|
||||
|
||||
- New `/scenarios` page (`Scenarios.tsx`): all 5 named scenarios as cards (title,
|
||||
operational problem, duration, required role(s), "toont aan", ready/blocked status
|
||||
from the manifest, "Start scenario" linking to the live `start_path`). Dashboard gets
|
||||
one compact "Probeer een demonstratiescenario" panel (not 5 more cards — keeps the
|
||||
existing dashboard uncluttered per the brief) showing readiness count and, for
|
||||
Operations Managers, a guide resume/start control.
|
||||
- New Demo Guide: `DemoGuideContext` (sessionStorage-persisted `currentIndex`/`completed`
|
||||
set — browser-only, never touches auth or business logic), 8 static steps
|
||||
(`data/demoGuideSteps.ts`) each with what-you'll-see/why/start-action/expected-outcome,
|
||||
resolving live routes from the manifest for the two scenario-backed steps (return,
|
||||
duplicate-merge) so they can't drift from actual records. `DemoGuide.tsx` renders a
|
||||
fixed side panel (desktop) that becomes a bottom sheet at ≤700px via CSS only (no
|
||||
layout duplication); `DemoGuideTrigger` (topbar, Operations-Manager-only — the 8 steps
|
||||
require OM throughout) shows a live `completed/8` pill. "Demo opnieuw voorbereiden"
|
||||
calls the real reset endpoint, resets guide progress, and returns to `/login` (mirrors
|
||||
the existing sidebar reset flow). Login's "Start begeleide demo" logs in as OM and
|
||||
passes a one-shot `?guide=start` marker the dashboard consumes once then strips.
|
||||
- Fixed a real regression caught by the responsive-overflow tests: the new topbar guide
|
||||
trigger pushed `.topbar-meta` past the viewport at ≤420px; fixed by hiding the guide
|
||||
trigger (icon+pill) at that breakpoint — the Dashboard's own "Start demo-gids" control
|
||||
remains reachable there. Also fixed a genuine mobile overflow in the new
|
||||
`.demo-start-panel` (flex items without `min-width:0`/wrap on narrow screens).
|
||||
- Fixed one fragile new test (asserted on the transient `?guide=start` URL param, which
|
||||
the app intentionally strips immediately — changed to assert the guide's actual open
|
||||
state instead) and two Playwright strict-mode ambiguous-match errors; confirmed the
|
||||
full 41+6=47-test suite passes twice in a row after these fixes (ruling out flakiness).
|
||||
- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite
|
||||
**47 passed** (41 existing + 6 new `demo-guide.spec.ts`: scenario overview shows 5
|
||||
ready cards after reset, starting a scenario navigates to its fixed record, guide
|
||||
step navigation/jump/close, progress persists across page navigation, guide hidden
|
||||
from Rental Employee, restart-from-guide resets data and returns to login). Backend
|
||||
untouched this batch (no re-run needed; last backend gate was 127 passed/ruff/mypy
|
||||
clean in Batch 2).
|
||||
- Deployed to Unraid (commits `9fff84d`, then `14c2ad3` for a test-only fix): pushed to
|
||||
Gitea, tarball extracted, `web` rebuilt (frontend-only batch), healthy. Live-verified:
|
||||
ran `demo-guide.spec.ts` (6), `demo-entry.spec.ts` (4) and the five-minute demo script
|
||||
directly against `http://192.168.10.150:1236` — **11/11 passed**. Caught and fixed one
|
||||
real environment-sensitive test bug in the process: two tests navigated straight to
|
||||
`/scenarios` right after a login click without waiting for the `/dashboard` redirect,
|
||||
which raced harmlessly on localhost but flaked against Unraid's higher latency — fixed
|
||||
by asserting the redirect first, no app-code change needed. Reseeded afterward to leave
|
||||
the server demo-ready.
|
||||
- Exact next action: layer plain-language Dutch explanation onto the return flow, the 5
|
||||
data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33.
|
||||
|
||||
### Batch 4 — return/data-quality/knowledge demo legibility (complete)
|
||||
|
||||
- **Fixed a real honesty bug**: `Knowledge.tsx` named "RAGcore" in the body copy and the
|
||||
retrieval-flow diagram even though the active provider is the demo TF-IDF one (the
|
||||
small badge below was already honest, contradicting the prose one line above). Now
|
||||
derives a `providerLabel` ("Demo knowledge base" vs "RAGcore") from the real health
|
||||
check and uses it everywhere; added an explicit disclosure note when not RAGcore.
|
||||
Added 4 suggested-question chips. **Discovered and fixed a second real bug in the
|
||||
process**: the brief's suggested Dutch questions (and my own Demo Guide step 6 wording)
|
||||
would have returned "insufficient evidence" against the demo provider, because the
|
||||
indexed procedures are English-only — verified empirically (Dutch question →
|
||||
`insufficient`, its English equivalent → `grounded`). Fixed by keeping suggested
|
||||
questions in English (matching the indexed content) and rewording the Guide step to
|
||||
explain the knowledge base is English, rather than mistranslating the demo's
|
||||
centerpiece feature into silently returning wrong answers.
|
||||
- Return flow: `BookingDetail.tsx` now detects the one named return-anomaly scenario
|
||||
booking (via the manifest, not a hardcoded ref) and fetches that vehicle's real
|
||||
canonical odometer to pre-fill `ReturnForm`'s "End odometer" field with a suspicious
|
||||
value below it, plus a callout explaining why — the brief explicitly requires the demo
|
||||
not ask a visitor to invent a suspicious number themselves. Scoped narrowly to that one
|
||||
scenario booking; ordinary returns are unaffected. `ReturnResultPanel` now links to
|
||||
Automation and Audit trail (previously only the vehicle), and shows a "Ga verder met de
|
||||
demo" button when the Demo Guide is open (advances the guide and navigates to the next
|
||||
step). **Fixed a real regression caught by the existing return-review e2e test**: the
|
||||
async pre-fill could silently overwrite odometer text a visitor had already started
|
||||
typing, if the vehicle-detail fetch resolved after they began typing — fixed with an
|
||||
`odometerEditedByUser` ref guard.
|
||||
- Data quality: added a shared `RuleExplainer` (what's wrong / why it matters, in plain
|
||||
language) for all 5 rule types on `DataQualityIssueDetail.tsx`; added a generic
|
||||
post-resolution confirmation (audit-trail link, vehicle link, "Ga verder met de demo")
|
||||
for the 4 rule types that previously just silently flipped their status badge with no
|
||||
explicit confirmation, and extended `VehicleStatusConflictPanel`'s existing confirmation
|
||||
with the same links rather than duplicating it. Added a "Demo scenario's only" checkbox
|
||||
filter on `DataQuality.tsx` (client-side `public_ref.startsWith("DQ-DEMO-")`, no new
|
||||
business logic) so the curated issues are easy to find among the full queue.
|
||||
- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite
|
||||
**51 passed** (47 existing + 4 new `demo-legibility.spec.ts`: return pre-fill + why-
|
||||
suspicious explanation + result links, rule explainer visible, demo-scenario filter
|
||||
narrows correctly, knowledge suggested question returns grounded evidence with the
|
||||
correct provider label). Backend untouched this batch.
|
||||
- Deployed to Unraid (commit `ddc3a98`): pushed to Gitea, tarball extracted, `web`
|
||||
rebuilt (frontend-only), healthy. Live-verified: ran `demo-legibility.spec.ts` (4) and
|
||||
the five-minute demo script directly against `http://192.168.10.150:1236` —
|
||||
**5/5 passed**. Reseeded afterward to leave the server demo-ready.
|
||||
- Exact next action: plain-language integration-status labels, richer audit narration,
|
||||
the full "Over deze demo" page content (currently a first pass from Batch 2), and
|
||||
wiring reset into the guide/About/OM menu narrative — task #34.
|
||||
|
||||
### Batch 5 — integration-status UX, audit UX, About page, reset integrity (complete)
|
||||
|
||||
- Plain-language integration status: extracted `frontend/src/data/integrationLabels.ts`
|
||||
(`N8N_STATE_META`/`MCP_STATE_META`) mapping raw backend states to honest labels
|
||||
("Operational"/"Not connected"/"Prepared"/"Delivery failed"/"Retry available") while
|
||||
keeping each mapped onto an existing `.status-*` CSS colour class (a few raw values like
|
||||
`degraded`/`disabled`/`configured` had no matching CSS rule at all before this — a real,
|
||||
pre-existing colour-coding gap). `StatusBadge` gained an optional `label` override prop
|
||||
(backward compatible) so the badge's colour class and its displayed text can differ.
|
||||
Wired into both `Automation.tsx` and `Dashboard.tsx`'s integration cards; also renamed
|
||||
the "RAGcore" card heading to "Knowledge assistant" and made its text honestly name the
|
||||
actual active provider (same bug class fixed in Knowledge.tsx in Batch 4).
|
||||
- Audit trail: added a "Follow-up" column with a "View related events" action per row that
|
||||
filters the same list by `correlation_id` (reuses the backend's existing, already-tested
|
||||
`correlation_id` query param — no new business logic), with a "Clear this filter"
|
||||
affordance. This is how a visitor sees "what else happened as a result of this action"
|
||||
(e.g. a return's linked vehicle-status-changed / workflow-queued events) without a
|
||||
bigger grouped-timeline rebuild.
|
||||
- About page: added target-audience/scope, a short architecture summary, security
|
||||
principles, and a testing-approach section (previously only covered the fictional
|
||||
problem/real/synthetic/integrations/reset); added a "Start begeleide demo" CTA for
|
||||
Operations Managers that opens the Demo Guide directly from this page.
|
||||
- Reset integrity: added `scenario_integrity_report()` (`backend/app/services/
|
||||
demo_manifest.py`), reusing the exact same scenario-readiness derivation the manifest
|
||||
and scenario overview already use (so it can't drift), and wired it into `POST
|
||||
/api/v1/demo/reset` — both the response body and the `demo_reset` audit event's
|
||||
metadata now carry `scenario_integrity: {all_ready, not_ready}`. This is the
|
||||
server-side post-reset integrity check the brief asks for; visible today via the audit
|
||||
event's raw-detail view, satisfying the requirement without adding a UI banner to a
|
||||
flow that immediately logs the user out and redirects to `/login`.
|
||||
- **Fixed a second real regression this batch, caught by the existing return-review
|
||||
e2e test**: restructured the odometer pre-fill so `BookingDetail.tsx` withholds
|
||||
rendering `ReturnForm` until the scenario's canonical odometer has resolved (with a
|
||||
brief "Scenario voorbereiden…" loading state), instead of mounting the form immediately
|
||||
and patching its value in asynchronously. The previous approach raced visibly with
|
||||
Playwright's `fill()` (and would have raced with a real visitor typing quickly),
|
||||
producing a corrupted concatenated value in one observed failure. This also let the
|
||||
now-unnecessary `odometerEditedByUser` ref guard be removed — simpler and more robust
|
||||
than the effect-based patch it replaced.
|
||||
- Evidence: `pytest` **127 passed**, `ruff check .` clean, `mypy app` clean (48 files);
|
||||
frontend `tsc -b` clean, `npm run build` clean; full Playwright suite **51 passed**,
|
||||
confirmed stable across three consecutive full runs (given how many timing races this
|
||||
batch and the previous one surfaced, stability was verified deliberately rather than
|
||||
assumed from a single green run).
|
||||
- Deployed to Unraid (commit `5fa4fe0`): pushed to Gitea, tarball extracted, both `api`
|
||||
and `web` rebuilt, healthy, migrations unchanged at `e7b08389f47f (head)`, reseeded.
|
||||
Live-verified: ran `demo-legibility.spec.ts` (4), the five-minute demo script, and the
|
||||
full `interactive-elements.spec.ts` suite (26) directly against
|
||||
`http://192.168.10.150:1236` — **31/31 passed**. Reseeded afterward to leave the
|
||||
server demo-ready.
|
||||
- Exact next action: full guided-demo Playwright test + remaining targeted demo tests per
|
||||
section 19 (mobile guide, keyboard nav, all scenario flows, About page, accessibility/
|
||||
reduced-motion/console/network checks) — task #35.
|
||||
|
||||
### Batch 6 — full guided-demo test + targeted demo tests (complete)
|
||||
|
||||
- **Found and fixed a real, fairly serious desktop layout bug** while writing the full
|
||||
guided-demo test: the Demo Guide's fixed right-side panel (400px wide) overlapped the
|
||||
main content area at normal desktop widths with no reflow, so its own step-list buttons
|
||||
intercepted pointer events meant for the page underneath (concretely: the return form's
|
||||
"Review return" button was unclickable while the guide was open, at exactly the
|
||||
viewport size Playwright's default test browser uses — this would have hit real
|
||||
visitors on ordinary laptop screens too). Fixed by adding a `guide-open` class to
|
||||
`.app-workspace` that reserves `padding-right: min(400px, 92vw)` while the guide is open
|
||||
(≥701px only; the ≤700px bottom-sheet layout is unaffected), so content reflows aside
|
||||
instead of sitting underneath the panel.
|
||||
- Added `frontend/e2e/guided-demo-full.spec.ts`: one comprehensive test walking a fresh
|
||||
Operations Manager session through all 8 Demo Guide steps in order, performing the
|
||||
**real** action at each step (not just verifying copy) — processes the actual
|
||||
odometer-anomaly return, resolves the resulting data-quality issue, merges the
|
||||
duplicate customer, asks a suggested knowledge question, checks automation + audit,
|
||||
reviews the About page — using the guide's own progression controls
|
||||
("Volgende"/"Ga naar deze stap"/"Ga verder met de demo") throughout, then resets the
|
||||
demo data again at the end to restore the environment per the brief's requirement.
|
||||
- Added `frontend/e2e/demo-accessibility.spec.ts` (4 tests): the guide renders as a
|
||||
correctly-anchored bottom sheet on a 390px mobile viewport with no horizontal overflow;
|
||||
the guide never covers the return form's action buttons on desktop (regression test for
|
||||
the bug above); the demo badge and guide trigger are keyboard-focusable and operable
|
||||
(Enter to open, explicit close controls); key demo pages (dashboard, scenarios, about,
|
||||
guide open) load with no unexpected console errors (the one expected benign 401 from
|
||||
the app's own session-probe on first load is explicitly allow-listed, not silenced
|
||||
blindly).
|
||||
- Evidence: full Playwright suite **56 passed** (51 existing + 1 guided-demo-full + 4
|
||||
demo-accessibility), confirmed stable across two consecutive full runs. Backend
|
||||
untouched this batch (last gate: 127 passed/ruff/mypy clean, Batch 5).
|
||||
- Deployed to Unraid (commit `07d5605`): pushed to Gitea, tarball extracted, `web`
|
||||
rebuilt (frontend-only), healthy, reseeded. Live-verified: ran
|
||||
`guided-demo-full.spec.ts` and `demo-accessibility.spec.ts` directly against
|
||||
`http://192.168.10.150:1236` — **5/5 passed**, confirming the desktop-overlay layout
|
||||
fix holds on the real deployment too. Reseeded afterward to leave the server
|
||||
demo-ready.
|
||||
- Exact next action: clean-checkout demo drill, final documentation set (demo-concept/
|
||||
demo-scenarios/demo-data/demo-guide/demo-runbook, README, .env.example), final Unraid
|
||||
deploy + live evidence with screenshots, `artifacts/demo-release/final-summary.md` —
|
||||
task #36 (final).
|
||||
|
||||
### Batch 7 (final) — clean-checkout drill, docs, final Unraid evidence (complete)
|
||||
|
||||
- **Clean-checkout drill**: fresh `git clone` into an isolated scratch directory,
|
||||
isolated Compose project (`mobilityops-cleandrill`) + remapped ports via
|
||||
`compose.override.yaml`, `up --build -d` from empty volumes. Migrations ran
|
||||
automatically to `e7b08389f47f (head)`; seeded; full backend gate **127 passed**,
|
||||
ruff/mypy clean; `npm ci` clean (same pre-existing advisories as before, unchanged);
|
||||
`tsc -b`/`vite build` clean; full Playwright suite **56 passed** against the isolated
|
||||
stack; reseeded and confirmed all 5 scenarios `ready: true` via the manifest; torn down
|
||||
(`down -v` on the isolated project only — the working dev stack was untouched
|
||||
throughout).
|
||||
- Added the full demo-release documentation set: `docs/demo-release/demo-concept.md`,
|
||||
`demo-scenarios.md`, `demo-data.md`, `demo-guide.md`, `demo-runbook.md`; updated
|
||||
`README.md` (current test counts, links to the new docs, a "Demo" section) and
|
||||
`docs/17-runbook.md` (cross-reference to the demo-specific runbook).
|
||||
- Added `frontend/e2e/_capture-demo-screenshots.spec.ts` (tooling, excluded from the
|
||||
regular suite) and captured 17 evidence screenshots live against
|
||||
`http://192.168.10.150:1236` into `artifacts/demo-release/screenshots/`.
|
||||
- Final live acceptance: full Playwright suite re-run against the live server —
|
||||
**56 passed**; `docker compose ps` on the server shows `api`/`db`/`web` all healthy;
|
||||
`docker logs` for `api`/`web` show no errors; reseeded to leave the server
|
||||
demo-ready after evidence capture.
|
||||
- Wrote `artifacts/demo-release/final-summary.md` with the full required evidence
|
||||
(branches/commits, org/roles/guide/scenarios, seed/date-anchor/reset strategy, real vs.
|
||||
synthetic vs. not-connected, all test results, clean-checkout result, deployment/
|
||||
health/console/log results, responsive/accessibility results including the two real
|
||||
layout bugs found and fixed this work (mobile topbar overflow in Batch 3, desktop
|
||||
guide-panel overlap in Batch 6), known limitations, 5-/10-minute demo flows, redeploy/
|
||||
rollback commands, and the screenshot list).
|
||||
- Demo-productization work on this branch is complete. Every task (#29–#36) is done;
|
||||
every batch was tested locally, deployed to Unraid, and re-verified live before moving
|
||||
to the next. See `artifacts/demo-release/final-summary.md` for the definitive
|
||||
acceptance evidence.
|
||||
|
||||
@@ -12,6 +12,17 @@ design decision and visual evidence.
|
||||
|
||||
All people, companies, vehicles, bookings and documents are synthetic. The workflows, validation, integrations, audit logging and access boundaries are intended to be real.
|
||||
|
||||
## Demo
|
||||
|
||||
The demo presents itself as **Northstar Mobility**, a fictitious Belgian camper/van
|
||||
rental company — the login screen, a permanent "Synthetische demo" indicator, an in-app
|
||||
guided tour (Demo Guide), a curated `/scenarios` overview, and an "Over deze demo" page
|
||||
all make the fictional context, synthetic-data status, and real-vs-simulated boundaries
|
||||
explicit without any verbal explanation. See `docs/demo-release/` for the full demo
|
||||
concept, the five named scenarios, the seed/date-anchoring strategy, the guided-tour
|
||||
design, and the operational runbook (5-minute and 10-minute demo flows, reset, redeploy,
|
||||
rollback).
|
||||
|
||||
## Scope
|
||||
|
||||
The PoC implements:
|
||||
@@ -77,6 +88,9 @@ and `artifacts/final-acceptance/summary.md` for the original M0–M7 acceptance
|
||||
- `artifacts/design-validation/` — baseline audit, Stitch direction references and implemented responsive captures.
|
||||
- `docs/functional-completion/` — the functional-completion audit and pre-work server baseline.
|
||||
- `artifacts/functional-completion/` — functional-completion acceptance evidence.
|
||||
- `docs/demo-release/` — demo concept, scenarios, seed/date-anchoring strategy, guided
|
||||
tour, and runbook.
|
||||
- `artifacts/demo-release/` — demo-productization acceptance evidence.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -100,9 +114,9 @@ All defaults are configurable via `.env` (see `.env.example`).
|
||||
## Quality gates
|
||||
|
||||
```bash
|
||||
make test # backend: pytest (117 tests)
|
||||
make test # backend: pytest (127 tests)
|
||||
make lint # backend: ruff + mypy (strict, zero errors)
|
||||
make e2e # frontend: Playwright end-to-end (37 tests, live stack required)
|
||||
make e2e # frontend: Playwright end-to-end (56 tests, live stack required)
|
||||
```
|
||||
|
||||
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Demo-productization final summary
|
||||
|
||||
## Branches and commits
|
||||
|
||||
- **Gitea repository**: `ssh://git@192.168.10.150:222/Jens/MobilityOps.git` (browsable at
|
||||
`http://192.168.10.150:3000/Jens/MobilityOps`)
|
||||
- **Branch**: `feat/mobilityops-functional-completion` (no new branch created; no merge
|
||||
to `main`; no rebase/reset/squash/force-push; full git history preserved, as required)
|
||||
- **Start commit** (functional-completion baseline, already accepted):
|
||||
`e0c7ed60112510687627d20a957af91c8b9db7f8`
|
||||
- **Final commit**: `4a268c73515dc4f1d56c1aa2f231714654bffbb8` — verified via
|
||||
`git rev-parse HEAD` on `feat/mobilityops-functional-completion` and confirmed to match
|
||||
`/mnt/user/appdata/mobilityops/.deploy/source-revision` on the Unraid server exactly.
|
||||
(This corrects a self-reference gap in the immediately preceding pair of commits, which
|
||||
necessarily could not know their own hash at the time they were written; this is now
|
||||
the single, unambiguous, verified reference. The repository's primary branch is
|
||||
`master`, not `main` — no branch named `main` exists in this repository.)
|
||||
- **Live URL**: `http://192.168.10.150:1236`
|
||||
|
||||
## Demo organisation and context
|
||||
|
||||
**Northstar Mobility** — a fictitious Belgian camper/van rental company (~50 vehicles,
|
||||
one main location, rental team, an Operations Manager, a small workshop). This name was
|
||||
already a locked internal decision (`ragcore_tenant: northstar-mobility-demo`,
|
||||
`PROJECT_STATE.md`'s "Locked decisions") before this work — this pass surfaces it in the
|
||||
UI rather than inventing it. Full concept: `docs/demo-release/demo-concept.md`.
|
||||
|
||||
## Roles
|
||||
|
||||
- **Operations Manager** — full access: data-quality resolution, workflow retries, audit
|
||||
trail, demo reset, the Demo Guide.
|
||||
- **Rental Employee** — scoped access: bookings, returns, fleet, knowledge assistant.
|
||||
|
||||
Both are reachable from the login screen with no password.
|
||||
|
||||
## Demo Guide
|
||||
|
||||
An 8-step, sessionStorage-persisted guided tour (Operations-Manager-only, since every
|
||||
step requires that role). Full design: `docs/demo-release/demo-guide.md`. Steps: (1)
|
||||
understand operational state, (2) open the booking needing attention, (3) process the
|
||||
odometer-anomaly return, (4) handle the created data-quality issue, (5) merge the
|
||||
duplicate customer, (6) ask the knowledge assistant, (7) check automation + audit, (8)
|
||||
review real vs. synthetic vs. not-connected.
|
||||
|
||||
## Scenarios (all 5, full detail in `docs/demo-release/demo-scenarios.md`)
|
||||
|
||||
| # | Scenario | Fixed records | Role |
|
||||
|---|---|---|---|
|
||||
| 1 | Odometer regression on return | `BK-DEMO-RETURN` / `MO-024` | Either |
|
||||
| 2 | Possible duplicate customer | `CUS-0012` / `CUS-0178` / `DQ-DEMO-DUPLICATE` | OM |
|
||||
| 3 | Overlapping bookings | `MO-016` / `BK-DEMO-OVERLAP-A/B` / `DQ-DEMO-OVERLAP` | OM |
|
||||
| 4 | Failed automation, retried | outbox event `...020` / `BK-H-0020` | OM |
|
||||
| 5 | Grounded procedure question | (no fixed record; suggested questions) | Either |
|
||||
|
||||
`GET /api/v1/demo/manifest`'s `scenarios` array derives `ready`/`blocked_reason` from the
|
||||
live underlying records, never hardcoded — confirmed via `backend/tests/
|
||||
test_demo_manifest.py` (`test_demo_manifest_scenarios_ready_after_fresh_reset`) and
|
||||
live-checked after every reset throughout this work.
|
||||
|
||||
## Seed strategy and date-anchoring
|
||||
|
||||
`seed/generate_seed.py --anchor 2026-08-01 --seed 20260801` produces deterministic CSVs
|
||||
with absolute timestamps authored against a fixed anchor. `backend/app/seed_loader.py`
|
||||
shifts every seeded datetime by `(real today − authored anchor)` on every seed/reset, so
|
||||
"today"/"near-future"/"currently overlapping" scenarios stay true to the actual reset
|
||||
moment instead of decaying. This fixed a real, confirmed bug (`BK-DEMO-RETURN` was found
|
||||
sitting 2 days in the past before this fix). Full detail: `docs/demo-release/demo-data.md`.
|
||||
|
||||
## Reset strategy
|
||||
|
||||
`POST /api/v1/demo/reset` (Operations Manager only, gated by `DEMO_ALLOW_RESET`) clears
|
||||
MobilityOps's own tables, reseeds with a fresh date anchor, re-runs the data-quality scan,
|
||||
and runs a server-side scenario-integrity check (`scenario_integrity_report()`) recorded
|
||||
in both the response and the `demo_reset` audit event. Reachable from the sidebar, the
|
||||
Demo Guide, and the About page. Never touches shared n8n/RAGcore/MCP data, other
|
||||
containers, or volumes.
|
||||
|
||||
## Real vs. synthetic vs. not-connected
|
||||
|
||||
See `docs/demo-release/demo-concept.md` for the full breakdown. In short: auth/roles,
|
||||
vehicle/booking management, return preview/commit, the 5 data-quality rules and their
|
||||
resolutions, the audit trail, n8n orchestration, Docker deployment, and the automated
|
||||
test suite are all really implemented. The organisation, all people, vehicles, bookings,
|
||||
procedures, and the 5 named scenarios are synthetic. RAGcore and the ITWorx MCP Hub are
|
||||
not live-connected (honestly labelled "Demomodus"/"Niet gekoppeld" everywhere, never a
|
||||
fabricated success).
|
||||
|
||||
## Test results
|
||||
|
||||
### Backend (clean checkout, isolated stack)
|
||||
- `pytest`: **127 passed**
|
||||
- `ruff check .`: clean
|
||||
- `mypy app`: clean (48 source files)
|
||||
|
||||
### Frontend (clean checkout, isolated stack)
|
||||
- `npm ci`: clean (pre-existing esbuild-moderate/react-router-RSC-high advisories,
|
||||
unchanged from before this work — not introduced by it)
|
||||
- `tsc -b`: clean
|
||||
- `npm run build`: clean
|
||||
- Full Playwright suite: **56 passed** (against the isolated clean-checkout stack)
|
||||
|
||||
### Guided-demo test
|
||||
`frontend/e2e/guided-demo-full.spec.ts` — one comprehensive test walking a fresh
|
||||
Operations Manager session through all 8 Demo Guide steps performing the real action at
|
||||
each step (processes the actual odometer-anomaly return, resolves the resulting
|
||||
data-quality issue, merges the duplicate customer, asks a suggested knowledge question,
|
||||
checks automation + audit, reviews the About page), then resets the demo data again to
|
||||
restore the environment. **Passed**, confirmed stable across repeated runs both locally
|
||||
and against the live Unraid deployment.
|
||||
|
||||
### Clean-checkout drill
|
||||
Fresh `git clone` of this branch/commit into an isolated scratch directory, `.env` from
|
||||
`.env.example`, isolated Compose project name (`mobilityops-cleandrill`) and remapped
|
||||
host ports (`compose.override.yaml` with `!override` merge tags — no shared state with
|
||||
any other stack), `docker compose up --build -d` from empty volumes → migrations ran
|
||||
automatically (`e7b08389f47f (head)`) → seeded → full backend gate (127 passed, ruff/
|
||||
mypy clean) → `npm ci`/`tsc -b`/`vite build` clean → full Playwright suite (56 passed)
|
||||
→ reseeded and confirmed all 5 scenarios `ready: true` via the manifest → torn down
|
||||
(`docker compose down -v` on the isolated project only; the working dev stack was never
|
||||
touched).
|
||||
|
||||
### Server deployment
|
||||
Deployed incrementally after every batch (10 deploy cycles across this work); final
|
||||
state: both `api` and `web` rebuilt and healthy at the final commit, `db` untouched
|
||||
across all of them (no destructive migrations on this branch). Migrations at
|
||||
`e7b08389f47f (head)` throughout. `.deploy/source-revision` on the server matches the
|
||||
final commit exactly.
|
||||
|
||||
### Container health
|
||||
`docker compose ps` on the server: `api`, `db`, `web` all `healthy`, no restart loops.
|
||||
|
||||
### Browser console / network
|
||||
No unexpected console errors on login, dashboard, scenarios, About, or with the Demo
|
||||
Guide open (verified via `demo-accessibility.spec.ts`; the one benign 401 from the app's
|
||||
own session-probe on first load is expected and explicitly accounted for, not silenced
|
||||
blindly). No unresolved server errors in `docker logs` for `api`/`web` at the time of
|
||||
this evidence capture.
|
||||
|
||||
### Responsive / accessibility
|
||||
- Demo Guide renders as a correctly-anchored bottom sheet at 390px with no horizontal
|
||||
overflow (`demo-accessibility.spec.ts`).
|
||||
- **Real bug found and fixed**: the Demo Guide's fixed desktop side panel overlapped
|
||||
main content with no reflow, making the return form's "Review return" button
|
||||
unclickable while the guide was open at ordinary desktop widths — this surfaced while
|
||||
writing the full guided-demo test. Fixed via a `guide-open` layout class that reserves
|
||||
space for the panel; regression-tested.
|
||||
- Demo badge and Demo Guide triggers are keyboard-focusable and operable (Enter to open,
|
||||
explicit close controls).
|
||||
- Existing responsive-overflow checks (390/768/1280/1440px) remain green throughout.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- RAGcore and the ITWorx MCP Hub are not live-connected in this environment (by design
|
||||
— see scope). The knowledge assistant uses a local, English-only demo knowledge base;
|
||||
a Dutch question against it returns "insufficient evidence" (verified empirically), so
|
||||
suggested questions and the Demo Guide's step 6 instructions deliberately stay in
|
||||
English rather than silently breaking the demo's centerpiece grounded-answer feature.
|
||||
- Existing operational screens (Dashboard, Vehicles, Bookings, Data Quality workbench,
|
||||
Audit, Automation internals) remain in English; only new demo-productization surfaces
|
||||
(login, Demo Guide, scenario overview, About page, demo badge, plain-language
|
||||
integration labels) are in Dutch — a deliberate, documented scope decision, not an
|
||||
oversight (`docs/demo-release/current-demo-gap-audit.md`, gap #11).
|
||||
- Scenario S3 ("missing inspection before next booking", `MO-031`) is seeded and visible
|
||||
in the attention queue but isn't one of the 5 scenarios surfaced on `/scenarios`,
|
||||
matching the brief's request for exactly 5.
|
||||
|
||||
## 5-minute and 10-minute demo flows
|
||||
|
||||
See `docs/demo-release/demo-runbook.md` for the exact click-through scripts.
|
||||
|
||||
## Redeploy commands and rollback procedure
|
||||
|
||||
See `docs/demo-release/demo-runbook.md` — `git archive` → `scp` → extract → rebuild
|
||||
`api`/`web` → confirm migrations → reseed. Rollback: extract an earlier
|
||||
`.deploy/source-<short-sha>.tar.gz` and update `.deploy/source-revision` to match.
|
||||
|
||||
## Evidence screenshots
|
||||
|
||||
All captured live against `http://192.168.10.150:1236` (`artifacts/demo-release/screenshots/`):
|
||||
|
||||
1. `01-demo-entry-desktop.png` / `02-demo-entry-mobile.png` — demo entry, both sizes
|
||||
2. `03-dashboard-with-scenarios.png` — dashboard with the scenario teaser panel
|
||||
3. `04-demo-guide.png` — the Demo Guide panel open
|
||||
4. `05-return-preview.png` / `06-return-result.png` — the return flow
|
||||
5. `07-data-quality-resolution.png` — a data-quality issue with its plain-language explainer
|
||||
6. `08-duplicate-customer-merge.png` — the duplicate-customer comparison/merge UI
|
||||
7. `09-knowledge-assistant.png` — a grounded answer with cited sources
|
||||
8. `10-integration-status.png` — plain-language integration status on Automation
|
||||
9. `11-automation-retry-before.png` / `11-automation-retry-after.png` — a workflow retry
|
||||
10. `12-audit-trail.png` / `13-audit-related-events.png` — audit trail + correlation drill-down
|
||||
11. `14-about-demo.png` — the About page
|
||||
12. `15-demo-badge-popover.png` — the permanent synthetic-demo badge popover
|
||||
13. `16-reset-confirm.png` — the reset confirmation flow
|
||||
|
||||
No secrets appear in any screenshot or in this document.
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -30,7 +30,9 @@ _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
return datetime.fromisoformat(settings.demo_today).date()
|
||||
# Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor
|
||||
# shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting.
|
||||
return datetime.now(UTC).date()
|
||||
|
||||
|
||||
@router.get("", response_model=DashboardOut)
|
||||
@@ -59,12 +61,11 @@ def get_dashboard(
|
||||
entity = customers_by_id.get(issue.entity_id)
|
||||
link_type = "customer"
|
||||
link_ref = entity.public_ref if entity else ""
|
||||
title = f"{issue.rule_type.replace('_', ' ').title()} — {link_ref}"
|
||||
attention_items.append(
|
||||
AttentionItem(
|
||||
kind="quality_issue",
|
||||
severity=issue.severity,
|
||||
title=title,
|
||||
rule_type=issue.rule_type,
|
||||
detail=issue.evidence_json.get("summary", ""),
|
||||
link_type=link_type,
|
||||
link_ref=link_ref,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -11,14 +11,23 @@ from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import SessionPayload, create_session_token, read_session_token
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, DemoLoginRequest
|
||||
from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
|
||||
|
||||
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.get("/manifest", response_model=DemoManifestOut)
|
||||
def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
|
||||
# Deliberately unauthenticated: the demo-entry screen and the permanent demo badge
|
||||
# both need this before any session exists. Nothing here is sensitive — it's the same
|
||||
# honest "what is this demo" summary a logged-in user would see.
|
||||
return build_demo_manifest(db)
|
||||
|
||||
|
||||
@router.post("/login", response_model=CurrentUser)
|
||||
def demo_login(
|
||||
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
|
||||
@@ -92,15 +101,31 @@ def demo_reset(
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> dict:
|
||||
if not settings.demo_allow_reset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Demo reset is disabled on this deployment.",
|
||||
)
|
||||
result = reset_and_seed(db)
|
||||
integrity = scenario_integrity_report(db)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="demo_reset",
|
||||
entity_type="system",
|
||||
metadata={"counts": result.counts},
|
||||
metadata={
|
||||
"counts": result.counts,
|
||||
"anchor_date": result.anchor_date.isoformat(),
|
||||
"scenario_integrity": integrity,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
return {"status": "reset", "counts": result.counts}
|
||||
return {
|
||||
"status": "reset",
|
||||
"counts": result.counts,
|
||||
"anchor_date": result.anchor_date.isoformat(),
|
||||
"seeded_at": result.seeded_at.isoformat(),
|
||||
"scenario_integrity": integrity,
|
||||
}
|
||||
|
||||
@@ -1,75 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.schemas import (
|
||||
CurrentUser,
|
||||
IntegrationStatusOut,
|
||||
McpHubIntegrationStatus,
|
||||
N8nIntegrationStatus,
|
||||
)
|
||||
from app.services.integration_status import derive_n8n_status
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
counts: dict[str, int] = dict(
|
||||
db.execute(
|
||||
select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status)
|
||||
).all() # type: ignore[arg-type]
|
||||
)
|
||||
pending = counts.get("pending", 0)
|
||||
delivering = counts.get("delivering", 0)
|
||||
failed = counts.get("failed", 0)
|
||||
succeeded = counts.get("succeeded", 0)
|
||||
|
||||
latest_success_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
|
||||
)
|
||||
latest_failure_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed")
|
||||
)
|
||||
|
||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||
if not settings.n8n_dispatch_enabled:
|
||||
state = "disabled"
|
||||
elif failed > 0 and succeeded == 0:
|
||||
state = "unavailable"
|
||||
elif failed > 0:
|
||||
state = "degraded"
|
||||
elif succeeded > 0 or pending > 0 or delivering > 0:
|
||||
state = "operational"
|
||||
else:
|
||||
state = "no_evidence"
|
||||
|
||||
return N8nIntegrationStatus(
|
||||
configured=bool(settings.n8n_webhook_url),
|
||||
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||
state=state,
|
||||
pending=pending,
|
||||
delivering=delivering,
|
||||
failed=failed,
|
||||
succeeded=succeeded,
|
||||
latest_success_at=latest_success_at,
|
||||
latest_failure_at=latest_failure_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=IntegrationStatusOut)
|
||||
def integration_status(
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> IntegrationStatusOut:
|
||||
return IntegrationStatusOut(
|
||||
n8n=_n8n_status(db),
|
||||
n8n=derive_n8n_status(db),
|
||||
mcp_hub=McpHubIntegrationStatus(
|
||||
registration_enabled=settings.mcp_hub_registration_enabled,
|
||||
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -13,9 +14,12 @@ from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledg
|
||||
|
||||
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
|
||||
|
||||
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
|
||||
|
||||
|
||||
class AskQuestionRequest(BaseModel):
|
||||
question: str = Field(min_length=3, max_length=1000)
|
||||
language: SupportedLanguage = "en-GB"
|
||||
|
||||
|
||||
@router.post("/questions", response_model=GroundedAnswer)
|
||||
@@ -26,7 +30,7 @@ def ask_question(
|
||||
) -> GroundedAnswer:
|
||||
correlation_id = str(uuid.uuid4())
|
||||
provider = get_knowledge_provider()
|
||||
answer = provider.ask(body.question, correlation_id)
|
||||
answer = provider.ask(body.question, correlation_id, body.language)
|
||||
|
||||
record_audit_event(
|
||||
db,
|
||||
@@ -40,6 +44,7 @@ def ask_question(
|
||||
"provider": answer.provider,
|
||||
"source_ids": [s.document_id for s in answer.sources],
|
||||
"question_length": len(body.question),
|
||||
"language": body.language,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
@@ -47,5 +52,8 @@ def ask_question(
|
||||
|
||||
|
||||
@router.get("/status", response_model=KnowledgeHealth)
|
||||
def knowledge_status(_user: CurrentUser = Depends(get_current_user)) -> KnowledgeHealth:
|
||||
return get_knowledge_provider().health()
|
||||
def knowledge_status(
|
||||
language: SupportedLanguage = "en-GB",
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> KnowledgeHealth:
|
||||
return get_knowledge_provider().health(language)
|
||||
|
||||
@@ -32,7 +32,9 @@ class Settings(BaseSettings):
|
||||
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||
mcp_hub_registration_enabled: bool = False
|
||||
cors_allow_origins: str = "http://localhost:1228"
|
||||
demo_today: str = "2026-08-01"
|
||||
demo_organization_name: str = "Northstar Mobility"
|
||||
demo_timezone: str = "Europe/Brussels"
|
||||
demo_allow_reset: bool = True
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -197,6 +197,37 @@ class IntegrationStatusOut(BaseModel):
|
||||
mcp_hub: McpHubIntegrationStatus
|
||||
|
||||
|
||||
class DemoScenarioOut(BaseModel):
|
||||
id: str
|
||||
estimated_minutes: int
|
||||
required_roles: list[Role]
|
||||
start_path: str
|
||||
ready: bool
|
||||
blocked_reason_code: str | None = None
|
||||
blocked_reason_params: dict[str, str] = {}
|
||||
|
||||
|
||||
class DemoIntegrationSummaryOut(BaseModel):
|
||||
key: Literal["n8n", "ragcore", "mcp_hub"]
|
||||
status_code: str
|
||||
detail_code: str
|
||||
detail_params: dict[str, str | int] = {}
|
||||
|
||||
|
||||
class DemoManifestOut(BaseModel):
|
||||
demo_mode: bool
|
||||
organization_name: str
|
||||
timezone: str
|
||||
synthetic_data: bool
|
||||
allow_reset: bool
|
||||
last_reset_at: datetime | None
|
||||
anchor_date: str | None
|
||||
guide_available: bool
|
||||
required_roles: list[Role]
|
||||
scenarios: list[DemoScenarioOut]
|
||||
integrations: list[DemoIntegrationSummaryOut]
|
||||
|
||||
|
||||
class VehicleDetailOut(VehicleOut):
|
||||
bookings: list[BookingSummaryOut] = Field(default_factory=list)
|
||||
inspections: list[InspectionOut] = Field(default_factory=list)
|
||||
@@ -217,7 +248,7 @@ class DashboardMetrics(BaseModel):
|
||||
class AttentionItem(BaseModel):
|
||||
kind: Literal["quality_issue", "vehicle"]
|
||||
severity: str
|
||||
title: str
|
||||
rule_type: str
|
||||
detail: str
|
||||
link_type: Literal["vehicle", "booking", "customer"]
|
||||
link_ref: str
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import csv
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import delete, insert, update
|
||||
@@ -20,6 +20,7 @@ from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
@@ -36,6 +37,17 @@ DEMO_USERS = [
|
||||
},
|
||||
]
|
||||
|
||||
# seed/generate_seed.py authored the committed CSVs relative to this fixed date
|
||||
# (`--anchor 2026-08-01`, matching Settings.demo_today). Every reset shifts every
|
||||
# seeded date by (today - SEED_AUTHORED_ANCHOR) so "today" / "near-future" / "overlaps
|
||||
# right now" scenarios stay true to the actual reset moment instead of decaying as real
|
||||
# time passes between resets -- a fixed anchor with no shift goes stale within days.
|
||||
SEED_AUTHORED_ANCHOR = date(2026, 8, 1)
|
||||
|
||||
|
||||
def _seed_anchor_shift(today: date) -> timedelta:
|
||||
return today - SEED_AUTHORED_ANCHOR
|
||||
|
||||
|
||||
def _parse_dt(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
@@ -53,6 +65,8 @@ def _parse_optional_int(value: str) -> int | None:
|
||||
@dataclass
|
||||
class SeedResult:
|
||||
counts: dict[str, int]
|
||||
anchor_date: date
|
||||
seeded_at: datetime
|
||||
|
||||
|
||||
def _seed_dir() -> Path:
|
||||
@@ -83,6 +97,8 @@ def clear_all(db: Session) -> None:
|
||||
|
||||
def load_seed(db: Session) -> SeedResult:
|
||||
counts: dict[str, int] = {}
|
||||
today = datetime.now(UTC).date()
|
||||
shift = _seed_anchor_shift(today)
|
||||
|
||||
user_rows = [
|
||||
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
|
||||
@@ -154,8 +170,8 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"public_ref": row["public_ref"],
|
||||
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
||||
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||
"starts_at": _parse_dt(row["starts_at"]),
|
||||
"ends_at": _parse_dt(row["ends_at"]),
|
||||
"starts_at": _parse_dt(row["starts_at"]) + shift,
|
||||
"ends_at": _parse_dt(row["ends_at"]) + shift,
|
||||
"status": row["status"],
|
||||
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
|
||||
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
||||
@@ -179,7 +195,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"damage_reported": _parse_bool(row["damage_reported"]),
|
||||
"technical_warning": _parse_bool(row["technical_warning"]),
|
||||
"odometer_km": int(row["odometer_km"]),
|
||||
"completed_at": _parse_dt(row["completed_at"]),
|
||||
"completed_at": _parse_dt(row["completed_at"]) + shift,
|
||||
"completed_by": None,
|
||||
}
|
||||
)
|
||||
@@ -193,7 +209,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"id": uuid.uuid4(),
|
||||
"public_ref": row["public_ref"],
|
||||
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||
"occurred_at": _parse_dt(row["occurred_at"]),
|
||||
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
|
||||
"odometer_km": int(row["odometer_km"]),
|
||||
"category": row["category"],
|
||||
"summary": row["summary"],
|
||||
@@ -266,7 +282,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
},
|
||||
"aggregate_ref": row["aggregate_ref"],
|
||||
},
|
||||
"occurred_at": _parse_dt(row["occurred_at"]),
|
||||
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
|
||||
"delivery_status": row["status"],
|
||||
"attempts": int(row["attempts"]),
|
||||
"next_attempt_at": None,
|
||||
@@ -277,7 +293,21 @@ def load_seed(db: Session) -> SeedResult:
|
||||
db.execute(insert(OutboxEvent), outbox_rows)
|
||||
counts["workflow_runs"] = len(outbox_rows)
|
||||
|
||||
return SeedResult(counts=counts)
|
||||
seeded_at = datetime.now(UTC)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="system",
|
||||
actor_label="seed loader",
|
||||
action="demo_data_seeded",
|
||||
entity_type="system",
|
||||
metadata={
|
||||
"anchor_date": today.isoformat(),
|
||||
"seed_authored_anchor": SEED_AUTHORED_ANCHOR.isoformat(),
|
||||
"counts": counts,
|
||||
},
|
||||
)
|
||||
|
||||
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
|
||||
|
||||
|
||||
def reset_and_seed(db: Session) -> SeedResult:
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
|
||||
from app.services.integration_status import derive_n8n_status
|
||||
from app.services.knowledge import get_knowledge_provider
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020"
|
||||
|
||||
|
||||
def _last_reset(db: Session) -> tuple[datetime | None, str | None]:
|
||||
marker = db.scalar(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.action == "demo_data_seeded")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
)
|
||||
if marker is None:
|
||||
return None, None
|
||||
metadata = marker.metadata_json or {}
|
||||
return marker.occurred_at, metadata.get("anchor_date")
|
||||
|
||||
|
||||
def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN"))
|
||||
duplicate_issue = db.scalar(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE")
|
||||
)
|
||||
overlap_issue = db.scalar(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP")
|
||||
)
|
||||
failed_run = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID)
|
||||
)
|
||||
knowledge_health = get_knowledge_provider().health()
|
||||
|
||||
# Human copy (title, problem statement, "demonstrates" summary) lives entirely in the
|
||||
# frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI
|
||||
# languages. This service only emits stable identifiers and message codes -- never
|
||||
# display prose -- per the message_code + params architecture used across the app.
|
||||
return_ready = bool(
|
||||
booking and booking.status == "active" and booking.end_odometer_km is None
|
||||
)
|
||||
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
|
||||
overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
|
||||
automation_ready = bool(failed_run and failed_run.delivery_status == "failed")
|
||||
|
||||
return [
|
||||
DemoScenarioOut(
|
||||
id="return-anomaly",
|
||||
estimated_minutes=3,
|
||||
required_roles=["rental_employee", "operations_manager"],
|
||||
start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings",
|
||||
ready=return_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if return_ready
|
||||
else "bookingNotFound" if booking is None else "bookingAlreadyProcessed"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="duplicate-customer",
|
||||
estimated_minutes=3,
|
||||
required_roles=["operations_manager"],
|
||||
start_path=(
|
||||
f"/data-quality/{duplicate_issue.public_ref}"
|
||||
if duplicate_issue
|
||||
else "/data-quality"
|
||||
),
|
||||
ready=duplicate_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if duplicate_ready
|
||||
else "duplicateIssueNotFound" if duplicate_issue is None else "issueAlreadyResolved"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="booking-overlap",
|
||||
estimated_minutes=2,
|
||||
required_roles=["operations_manager"],
|
||||
start_path=(
|
||||
f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality"
|
||||
),
|
||||
ready=overlap_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if overlap_ready
|
||||
else "overlapIssueNotFound" if overlap_issue is None else "issueAlreadyResolved"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="automation-retry",
|
||||
estimated_minutes=2,
|
||||
required_roles=["operations_manager"],
|
||||
start_path="/automation",
|
||||
ready=automation_ready,
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if automation_ready
|
||||
else "failedEventNotFound" if failed_run is None else "eventAlreadyRecovered"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
id="knowledge-question",
|
||||
estimated_minutes=2,
|
||||
required_roles=["rental_employee", "operations_manager"],
|
||||
start_path="/knowledge",
|
||||
ready=knowledge_health.available,
|
||||
blocked_reason_code=None if knowledge_health.available else "knowledgeUnavailable",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
||||
n8n = derive_n8n_status(db)
|
||||
knowledge_health = get_knowledge_provider().health()
|
||||
|
||||
return [
|
||||
DemoIntegrationSummaryOut(
|
||||
key="n8n",
|
||||
status_code=n8n.state,
|
||||
detail_code="n8nDetail",
|
||||
detail_params={
|
||||
"succeeded": n8n.succeeded,
|
||||
"failed": n8n.failed,
|
||||
"pending": n8n.pending,
|
||||
},
|
||||
),
|
||||
DemoIntegrationSummaryOut(
|
||||
key="ragcore",
|
||||
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
|
||||
detail_code="ragcoreDetail",
|
||||
detail_params={
|
||||
"count": knowledge_health.document_count,
|
||||
"collection": knowledge_health.collection,
|
||||
},
|
||||
),
|
||||
DemoIntegrationSummaryOut(
|
||||
key="mcp_hub",
|
||||
status_code="operational" if settings.mcp_hub_registration_enabled else "notConnected",
|
||||
detail_code=(
|
||||
"mcpDetailEnabled"
|
||||
if settings.mcp_hub_registration_enabled
|
||||
else "mcpDetailNotConnected"
|
||||
),
|
||||
detail_params={},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def scenario_integrity_report(db: Session) -> dict:
|
||||
"""Server-side scenario-integrity check run after every reset (section 15): confirms
|
||||
each of the 5 named scenarios is actually present and ready, rather than trusting the
|
||||
seed loader silently. Reuses the same readiness derivation the manifest/scenario
|
||||
overview already use, so this can never drift from what a visitor actually sees."""
|
||||
scenarios = _scenarios(db)
|
||||
not_ready = [
|
||||
{"id": s.id, "reason_code": s.blocked_reason_code}
|
||||
for s in scenarios
|
||||
if not s.ready
|
||||
]
|
||||
return {"all_ready": len(not_ready) == 0, "not_ready": not_ready}
|
||||
|
||||
|
||||
def build_demo_manifest(db: Session) -> DemoManifestOut:
|
||||
last_reset_at, anchor_date = _last_reset(db)
|
||||
return DemoManifestOut(
|
||||
demo_mode=settings.mobilityops_demo_mode,
|
||||
organization_name=settings.demo_organization_name,
|
||||
timezone=settings.demo_timezone,
|
||||
synthetic_data=True,
|
||||
allow_reset=settings.demo_allow_reset,
|
||||
last_reset_at=last_reset_at,
|
||||
anchor_date=anchor_date,
|
||||
guide_available=True,
|
||||
required_roles=["operations_manager", "rental_employee"],
|
||||
scenarios=_scenarios(db),
|
||||
integrations=_integrations(db),
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.schemas import N8nIntegrationStatus
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
counts: dict[str, int] = dict(
|
||||
db.execute(
|
||||
select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status)
|
||||
).all() # type: ignore[arg-type]
|
||||
)
|
||||
pending = counts.get("pending", 0)
|
||||
delivering = counts.get("delivering", 0)
|
||||
failed = counts.get("failed", 0)
|
||||
succeeded = counts.get("succeeded", 0)
|
||||
|
||||
latest_success_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
|
||||
)
|
||||
latest_failure_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed")
|
||||
)
|
||||
|
||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||
if not settings.n8n_dispatch_enabled:
|
||||
state = "disabled"
|
||||
elif failed > 0 and succeeded == 0:
|
||||
state = "unavailable"
|
||||
elif failed > 0:
|
||||
state = "degraded"
|
||||
elif succeeded > 0 or pending > 0 or delivering > 0:
|
||||
state = "operational"
|
||||
else:
|
||||
state = "no_evidence"
|
||||
|
||||
return N8nIntegrationStatus(
|
||||
configured=bool(settings.n8n_webhook_url),
|
||||
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||
state=state,
|
||||
pending=pending,
|
||||
delivering=delivering,
|
||||
failed=failed,
|
||||
succeeded=succeeded,
|
||||
latest_success_at=latest_success_at,
|
||||
latest_failure_at=latest_failure_at,
|
||||
)
|
||||
@@ -39,9 +39,11 @@ class KnowledgeHealth(BaseModel):
|
||||
class KnowledgeProvider(Protocol):
|
||||
name: str
|
||||
|
||||
def health(self) -> KnowledgeHealth: ...
|
||||
def health(self, language: str = "en-GB") -> KnowledgeHealth: ...
|
||||
|
||||
def ask(self, question: str, correlation_id: str) -> GroundedAnswer: ...
|
||||
def ask(
|
||||
self, question: str, correlation_id: str, language: str = "en-GB"
|
||||
) -> GroundedAnswer: ...
|
||||
|
||||
|
||||
@lru_cache
|
||||
|
||||
@@ -8,12 +8,31 @@ from pathlib import Path
|
||||
from app.core.config import get_settings
|
||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
||||
|
||||
STOPWORDS = {
|
||||
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
||||
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then",
|
||||
"do", "does", "did", "must", "may", "can", "could", "should", "would",
|
||||
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how",
|
||||
"with", "without", "this", "that", "these", "those", "not", "no",
|
||||
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||
DEFAULT_LANGUAGE = "en-GB"
|
||||
|
||||
STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
|
||||
"en-GB": {
|
||||
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
||||
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then",
|
||||
"do", "does", "did", "must", "may", "can", "could", "should", "would",
|
||||
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how",
|
||||
"with", "without", "this", "that", "these", "those", "not", "no",
|
||||
},
|
||||
"nl-BE": {
|
||||
"een", "de", "het", "is", "zijn", "was", "waren", "worden", "wordt",
|
||||
"van", "in", "op", "voor", "en", "of", "maar", "als", "dan",
|
||||
"moet", "mag", "kan", "kunnen", "zou", "zouden",
|
||||
"ik", "jij", "u", "we", "wij", "zij", "mijn", "jouw", "wat", "wanneer", "hoe",
|
||||
"met", "zonder", "dit", "dat", "deze", "die", "niet", "geen",
|
||||
},
|
||||
"fr-BE": {
|
||||
"un", "une", "le", "la", "les", "des", "est", "sont", "était", "être",
|
||||
"de", "du", "en", "sur", "pour", "et", "ou", "mais", "si", "alors",
|
||||
"doit", "peut", "peuvent", "pourrait", "devrait",
|
||||
"je", "tu", "vous", "il", "elle", "nous", "ils", "mon", "votre", "quoi", "quand", "comment",
|
||||
"avec", "sans", "ce", "cette", "ces", "cela", "pas", "non",
|
||||
},
|
||||
}
|
||||
|
||||
_WORD_RE = re.compile(r"[a-z0-9]+")
|
||||
@@ -28,9 +47,10 @@ def _stem(word: str) -> str:
|
||||
return word
|
||||
|
||||
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
def _tokenize(text: str, language: str) -> set[str]:
|
||||
stopwords = STOPWORDS_BY_LANGUAGE.get(language, STOPWORDS_BY_LANGUAGE[DEFAULT_LANGUAGE])
|
||||
words = _WORD_RE.findall(text.lower())
|
||||
return {_stem(w) for w in words if w not in STOPWORDS and len(w) > 2}
|
||||
return {_stem(w) for w in words if w not in stopwords and len(w) > 2}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -86,7 +106,7 @@ def _split_sections(body: str) -> list[tuple[str, str]]:
|
||||
return sections
|
||||
|
||||
|
||||
def _load_sections(procedures_dir: Path) -> list[ScoredSection]:
|
||||
def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
|
||||
sections: list[ScoredSection] = []
|
||||
for path in sorted(procedures_dir.glob("*.md")):
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
@@ -96,7 +116,7 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]:
|
||||
document_id=meta.get("document_id", path.stem),
|
||||
title=title,
|
||||
version=meta.get("version", "1.0"),
|
||||
title_tokens=_tokenize(title),
|
||||
title_tokens=_tokenize(title, language),
|
||||
)
|
||||
for heading, text in _split_sections(body):
|
||||
sections.append(
|
||||
@@ -104,19 +124,49 @@ def _load_sections(procedures_dir: Path) -> list[ScoredSection]:
|
||||
document=doc,
|
||||
heading=heading,
|
||||
text=text,
|
||||
heading_tokens=_tokenize(heading),
|
||||
body_tokens=_tokenize(text),
|
||||
heading_tokens=_tokenize(heading, language),
|
||||
body_tokens=_tokenize(text, language),
|
||||
)
|
||||
)
|
||||
return sections
|
||||
|
||||
|
||||
_NO_MATCH_TEXT = {
|
||||
"en-GB": "No matching procedure was found for this question.",
|
||||
"nl-BE": "Er werd geen passende procedure gevonden voor deze vraag.",
|
||||
"fr-BE": "Aucune procédure correspondante n'a été trouvée pour cette question.",
|
||||
}
|
||||
_LOW_CONFIDENCE_TEXT = {
|
||||
"en-GB": (
|
||||
"The available procedures do not clearly answer this question. "
|
||||
"The closest matches are included below for review."
|
||||
),
|
||||
"nl-BE": (
|
||||
"De beschikbare procedures beantwoorden deze vraag niet duidelijk. "
|
||||
"De dichtstbijzijnde overeenkomsten staan hieronder ter beoordeling."
|
||||
),
|
||||
"fr-BE": (
|
||||
"Les procédures disponibles ne répondent pas clairement à cette question. "
|
||||
"Les correspondances les plus proches sont indiquées ci-dessous pour examen."
|
||||
),
|
||||
}
|
||||
_LEAD_ANSWER_TEMPLATE = {
|
||||
"en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}',
|
||||
"nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}',
|
||||
"fr-BE": 'Selon « {title} » (v{version}), section « {heading} » : {excerpt}',
|
||||
}
|
||||
|
||||
|
||||
class DemoKnowledgeProvider:
|
||||
"""Deterministic extractive retrieval over the local procedure Markdown files.
|
||||
|
||||
Not a generative model: it scores sections with TF-IDF-weighted keyword overlap
|
||||
(downweighting terms common across the whole corpus, like "vehicle", in favor of
|
||||
distinctive ones, like "damage") and returns real excerpts, never invented text.
|
||||
|
||||
Each supported UI language has its own translated procedure corpus under
|
||||
knowledge/procedures/<language>/ -- retrieval searches only within the requested
|
||||
language's corpus so citations always link to a same-language document.
|
||||
"""
|
||||
|
||||
name = "demo"
|
||||
@@ -124,10 +174,18 @@ class DemoKnowledgeProvider:
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self._settings = settings
|
||||
self._procedures_dir = Path(settings.knowledge_dir)
|
||||
self._sections = _load_sections(self._procedures_dir)
|
||||
self._document_count = len({s.document.document_id for s in self._sections})
|
||||
self._idf = self._build_idf(self._sections)
|
||||
base_dir = Path(settings.knowledge_dir)
|
||||
self._sections_by_language: dict[str, list[ScoredSection]] = {}
|
||||
self._idf_by_language: dict[str, dict[str, float]] = {}
|
||||
self._document_count_by_language: dict[str, int] = {}
|
||||
for language in SUPPORTED_LANGUAGES:
|
||||
lang_dir = base_dir / language
|
||||
sections = _load_sections(lang_dir, language) if lang_dir.is_dir() else []
|
||||
self._sections_by_language[language] = sections
|
||||
self._idf_by_language[language] = self._build_idf(sections)
|
||||
self._document_count_by_language[language] = len(
|
||||
{s.document.document_id for s in sections}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_idf(sections: list[ScoredSection]) -> dict[str, float]:
|
||||
@@ -140,7 +198,13 @@ class DemoKnowledgeProvider:
|
||||
doc_freq[token] = doc_freq.get(token, 0) + 1
|
||||
return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()}
|
||||
|
||||
def health(self) -> KnowledgeHealth:
|
||||
def _normalize_language(self, language: str | None) -> str:
|
||||
if language in SUPPORTED_LANGUAGES:
|
||||
return language
|
||||
return DEFAULT_LANGUAGE
|
||||
|
||||
def health(self, language: str = DEFAULT_LANGUAGE) -> KnowledgeHealth:
|
||||
language = self._normalize_language(language)
|
||||
return KnowledgeHealth(
|
||||
provider=self.name,
|
||||
available=True,
|
||||
@@ -148,36 +212,40 @@ class DemoKnowledgeProvider:
|
||||
tenant=self._settings.ragcore_tenant,
|
||||
workspace=self._settings.ragcore_workspace,
|
||||
collection=self._settings.ragcore_collection,
|
||||
document_count=self._document_count,
|
||||
document_count=self._document_count_by_language[language],
|
||||
)
|
||||
|
||||
def _score(self, query_tokens: set[str], section: ScoredSection) -> float:
|
||||
def _score(
|
||||
self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float]
|
||||
) -> float:
|
||||
score = 0.0
|
||||
for token in query_tokens:
|
||||
idf = self._idf.get(token, 0.0)
|
||||
if idf == 0.0:
|
||||
token_idf = idf.get(token, 0.0)
|
||||
if token_idf == 0.0:
|
||||
continue
|
||||
if token in section.heading_tokens:
|
||||
score += 3 * idf
|
||||
score += 3 * token_idf
|
||||
elif token in section.document.title_tokens:
|
||||
score += 2 * idf
|
||||
score += 2 * token_idf
|
||||
elif token in section.body_tokens:
|
||||
score += idf
|
||||
score += token_idf
|
||||
return score
|
||||
|
||||
def ask(self, question: str, correlation_id: str) -> GroundedAnswer:
|
||||
query_tokens = _tokenize(question)
|
||||
scored = [
|
||||
(self._score(query_tokens, section), section)
|
||||
for section in self._sections
|
||||
]
|
||||
def ask(
|
||||
self, question: str, correlation_id: str, language: str = DEFAULT_LANGUAGE
|
||||
) -> GroundedAnswer:
|
||||
language = self._normalize_language(language)
|
||||
sections = self._sections_by_language[language]
|
||||
idf = self._idf_by_language[language]
|
||||
query_tokens = _tokenize(question, language)
|
||||
scored = [(self._score(query_tokens, section, idf), section) for section in sections]
|
||||
scored = [(score, section) for score, section in scored if score > 0]
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
top = scored[:3]
|
||||
|
||||
if not top:
|
||||
return GroundedAnswer(
|
||||
answer="No matching procedure was found for this question.",
|
||||
answer=_NO_MATCH_TEXT[language],
|
||||
evidence_state="insufficient",
|
||||
sources=[],
|
||||
provider=self.name,
|
||||
@@ -197,10 +265,7 @@ class DemoKnowledgeProvider:
|
||||
|
||||
if top[0][0] < 3:
|
||||
return GroundedAnswer(
|
||||
answer=(
|
||||
"The available procedures do not clearly answer this question. "
|
||||
"The closest matches are included below for review."
|
||||
),
|
||||
answer=_LOW_CONFIDENCE_TEXT[language],
|
||||
evidence_state="insufficient",
|
||||
sources=sources,
|
||||
provider=self.name,
|
||||
@@ -208,9 +273,11 @@ class DemoKnowledgeProvider:
|
||||
)
|
||||
|
||||
lead_section = top[0][1]
|
||||
answer = (
|
||||
f'Per "{lead_section.document.title}" (v{lead_section.document.version}), '
|
||||
f'section "{lead_section.heading}": {lead_section.text.splitlines()[0][:300]}'
|
||||
answer = _LEAD_ANSWER_TEMPLATE[language].format(
|
||||
title=lead_section.document.title,
|
||||
version=lead_section.document.version,
|
||||
heading=lead_section.heading,
|
||||
excerpt=lead_section.text.splitlines()[0][:300],
|
||||
)
|
||||
return GroundedAnswer(
|
||||
answer=answer,
|
||||
|
||||
@@ -32,7 +32,7 @@ class RAGcoreKnowledgeProvider:
|
||||
timeout=self._settings.ragcore_http_timeout_seconds,
|
||||
)
|
||||
|
||||
def health(self) -> KnowledgeHealth:
|
||||
def health(self, language: str = "en-GB") -> KnowledgeHealth:
|
||||
try:
|
||||
with self._client() as client:
|
||||
response = client.get("/health")
|
||||
@@ -52,7 +52,7 @@ class RAGcoreKnowledgeProvider:
|
||||
document_count=0,
|
||||
)
|
||||
|
||||
def ask(self, question: str, correlation_id: str) -> GroundedAnswer:
|
||||
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
|
||||
try:
|
||||
with self._client() as client:
|
||||
response = client.post(
|
||||
@@ -63,6 +63,7 @@ class RAGcoreKnowledgeProvider:
|
||||
"collection": self._settings.ragcore_collection,
|
||||
"question": question,
|
||||
"correlation_id": correlation_id,
|
||||
"language": language,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -17,7 +17,25 @@ def test_rental_employee_cannot_reset_demo(employee_client):
|
||||
def test_operations_manager_can_reset_demo(ops_client):
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["counts"]["vehicles"] == 50
|
||||
body = response.json()
|
||||
assert body["counts"]["vehicles"] == 50
|
||||
assert body["anchor_date"]
|
||||
assert body["seeded_at"]
|
||||
assert body["scenario_integrity"]["all_ready"] is True
|
||||
assert body["scenario_integrity"]["not_ready"] == []
|
||||
|
||||
|
||||
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
|
||||
import app.api.routers.demo as demo_router
|
||||
|
||||
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", False)
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
assert response.status_code == 403
|
||||
|
||||
# Restore real demo data: this test intentionally disabled reset, so a following test
|
||||
# module must not inherit a database left mid-mutation by an earlier test.
|
||||
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", True)
|
||||
assert ops_client.post("/api/v1/demo/reset").status_code == 200
|
||||
|
||||
|
||||
def test_session_endpoint_requires_authentication(client):
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from app.core.db import SessionLocal
|
||||
from app.seed_loader import reset_and_seed
|
||||
|
||||
|
||||
def test_demo_manifest_is_public(client):
|
||||
# No login call at all -- the demo-entry screen and badge need this before any
|
||||
# session exists.
|
||||
response = client.get("/api/v1/demo/manifest")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_demo_manifest_shape(client):
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
assert body["organization_name"] == "Northstar Mobility"
|
||||
assert body["demo_mode"] is True
|
||||
assert body["synthetic_data"] is True
|
||||
assert body["allow_reset"] is True
|
||||
assert body["timezone"] == "Europe/Brussels"
|
||||
assert body["guide_available"] is True
|
||||
assert set(body["required_roles"]) == {"operations_manager", "rental_employee"}
|
||||
assert body["last_reset_at"] is not None
|
||||
assert body["anchor_date"] is not None
|
||||
|
||||
scenario_ids = {s["id"] for s in body["scenarios"]}
|
||||
assert scenario_ids == {
|
||||
"return-anomaly",
|
||||
"duplicate-customer",
|
||||
"booking-overlap",
|
||||
"automation-retry",
|
||||
"knowledge-question",
|
||||
}
|
||||
integration_keys = {i["key"] for i in body["integrations"]}
|
||||
assert integration_keys == {"n8n", "ragcore", "mcp_hub"}
|
||||
|
||||
|
||||
def test_demo_manifest_scenarios_ready_after_fresh_reset(client):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
scenarios = {s["id"]: s for s in body["scenarios"]}
|
||||
for scenario_id, scenario in scenarios.items():
|
||||
assert scenario["ready"] is True, f"{scenario_id} should be ready right after a reset"
|
||||
assert scenario["blocked_reason_code"] is None
|
||||
assert scenario["start_path"]
|
||||
|
||||
|
||||
def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client):
|
||||
body = client.get("/api/v1/demo/manifest").json()
|
||||
ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore")
|
||||
assert ragcore["status_code"] == "demoMode"
|
||||
@@ -32,7 +32,52 @@ def test_demo_provider_health_reports_document_count():
|
||||
health = provider.health()
|
||||
assert health.provider == "demo"
|
||||
assert health.available is True
|
||||
assert health.document_count == 10
|
||||
assert health.document_count == 11
|
||||
|
||||
|
||||
def test_demo_provider_health_reports_document_count_per_language():
|
||||
provider = DemoKnowledgeProvider()
|
||||
for language in ("nl-BE", "en-GB", "fr-BE"):
|
||||
assert provider.health(language).document_count == 11
|
||||
|
||||
|
||||
def test_demo_provider_grounds_damage_question_in_dutch():
|
||||
provider = DemoKnowledgeProvider()
|
||||
answer = provider.ask(
|
||||
"Wat moet ik doen als een voertuig terugkomt met schade?",
|
||||
"test-correlation-nl",
|
||||
"nl-BE",
|
||||
)
|
||||
assert answer.evidence_state == "grounded"
|
||||
document_ids = {s.document_id for s in answer.sources}
|
||||
assert "damage-procedure" in document_ids
|
||||
|
||||
|
||||
def test_demo_provider_grounds_damage_question_in_french():
|
||||
provider = DemoKnowledgeProvider()
|
||||
answer = provider.ask(
|
||||
"Que dois-je faire quand un véhicule revient avec des dommages ?",
|
||||
"test-correlation-fr",
|
||||
"fr-BE",
|
||||
)
|
||||
assert answer.evidence_state == "grounded"
|
||||
document_ids = {s.document_id for s in answer.sources}
|
||||
assert "damage-procedure" in document_ids
|
||||
|
||||
|
||||
def test_demo_provider_insufficient_evidence_message_is_localized():
|
||||
provider = DemoKnowledgeProvider()
|
||||
nl_answer = provider.ask(
|
||||
"Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE"
|
||||
)
|
||||
fr_answer = provider.ask(
|
||||
"Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE"
|
||||
)
|
||||
assert nl_answer.evidence_state == "insufficient"
|
||||
assert fr_answer.evidence_state == "insufficient"
|
||||
assert nl_answer.answer != fr_answer.answer
|
||||
assert "France" not in nl_answer.answer
|
||||
assert "France" not in fr_answer.answer
|
||||
|
||||
|
||||
def test_ask_question_endpoint_grounded(ops_client):
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed
|
||||
|
||||
|
||||
def test_seed_counts_match_deterministic_dataset():
|
||||
@@ -52,3 +55,117 @@ def test_seed_demo_scenarios_present():
|
||||
assert failed_run is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _by_ref(db, model, ref):
|
||||
return db.scalar(select(model).where(model.public_ref == ref))
|
||||
|
||||
|
||||
def test_seed_scenario_s1_odometer_regression_return():
|
||||
"""S1: BK-DEMO-RETURN on MO-024 is an active booking ready for a return with a
|
||||
below-canonical odometer reading, using the vehicle's own current odometer."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
booking = _by_ref(db, Booking, "BK-DEMO-RETURN")
|
||||
vehicle = _by_ref(db, Vehicle, "MO-024")
|
||||
assert booking is not None and vehicle is not None
|
||||
assert booking.vehicle_id == vehicle.id
|
||||
assert booking.status == "active"
|
||||
assert booking.end_odometer_km is None
|
||||
# A demo return reading must sit below the vehicle's canonical odometer to
|
||||
# reproduce the odometer-regression anomaly deterministically.
|
||||
assert vehicle.odometer_km > 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_s2_duplicate_customer_pair():
|
||||
"""S2: CUS-0012/CUS-0178 form a possible-duplicate pair with a matching open issue."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
primary = _by_ref(db, Customer, "CUS-0012")
|
||||
duplicate = _by_ref(db, Customer, "CUS-0178")
|
||||
assert primary is not None and duplicate is not None
|
||||
assert primary.email == duplicate.email
|
||||
assert duplicate.merged_into_customer_id is None
|
||||
|
||||
issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-DUPLICATE")
|
||||
assert issue is not None
|
||||
assert issue.rule_type == "possible_duplicate_customer"
|
||||
assert issue.status == "open"
|
||||
related = issue.evidence_json.get("related_refs", [])
|
||||
assert "CUS-0012" in related or "CUS-0178" in related
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_s4_booking_overlap():
|
||||
"""S4: MO-016 carries two overlapping reservations plus a matching open issue."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
vehicle = _by_ref(db, Vehicle, "MO-016")
|
||||
booking_a = _by_ref(db, Booking, "BK-DEMO-OVERLAP-A")
|
||||
booking_b = _by_ref(db, Booking, "BK-DEMO-OVERLAP-B")
|
||||
assert vehicle is not None and booking_a is not None and booking_b is not None
|
||||
assert booking_a.vehicle_id == vehicle.id
|
||||
assert booking_b.vehicle_id == vehicle.id
|
||||
assert booking_a.starts_at < booking_b.ends_at
|
||||
assert booking_b.starts_at < booking_a.ends_at
|
||||
|
||||
issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-OVERLAP")
|
||||
assert issue is not None
|
||||
assert issue.rule_type == "booking_overlap"
|
||||
assert issue.status == "open"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_s5_failed_workflow_run():
|
||||
"""S5: one seeded outbox event is durably 'failed' (terminal, retryable), not merely
|
||||
pending, so the background dispatcher never silently auto-heals it away."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
failed = db.scalar(
|
||||
select(OutboxEvent).where(
|
||||
OutboxEvent.event_id == "00000000-0000-4000-8000-000000000020"
|
||||
)
|
||||
)
|
||||
assert failed is not None
|
||||
assert failed.delivery_status == "failed"
|
||||
assert failed.attempts >= 1
|
||||
assert failed.last_error
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_dates_are_anchored_to_reset_moment():
|
||||
"""Every reset shifts seeded dates by (real today - authored anchor), so scenario
|
||||
bookings stay 'today'/'near-future' relative to whenever the reset actually ran,
|
||||
instead of decaying back to the fixed 2026-08-01 authoring date."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = reset_and_seed(db)
|
||||
today = datetime.now(UTC).date()
|
||||
assert result.anchor_date == today
|
||||
|
||||
shift = today - SEED_AUTHORED_ANCHOR
|
||||
booking = _by_ref(db, Booking, "BK-DEMO-RETURN")
|
||||
assert booking is not None
|
||||
# Authored ends_at was 2026-08-01T09:00Z; after shifting it must land on the
|
||||
# real reset date, not the frozen authoring date (unless shift is exactly zero).
|
||||
assert booking.ends_at.date() == today or shift.days == 0
|
||||
|
||||
marker = db.scalar(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.action == "demo_data_seeded")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
)
|
||||
assert marker is not None
|
||||
assert marker.metadata_json["anchor_date"] == today.isoformat()
|
||||
assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -24,7 +24,6 @@ services:
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops}
|
||||
TZ: ${TZ:-Europe/Brussels}
|
||||
APP_SECRET: ${APP_SECRET:-replace-in-production}
|
||||
DEMO_TODAY: ${DEMO_TODAY:-2026-08-01}
|
||||
CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228}
|
||||
KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo}
|
||||
RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000}
|
||||
@@ -35,6 +34,9 @@ services:
|
||||
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
|
||||
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
|
||||
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
|
||||
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
|
||||
DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels}
|
||||
DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true}
|
||||
ports:
|
||||
- "8128:8000"
|
||||
depends_on:
|
||||
|
||||
@@ -11,10 +11,17 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu
|
||||
### System and demo
|
||||
|
||||
- `GET /health`
|
||||
- `GET /api/v1/demo/manifest` — unauthenticated; demo org name/description, synthetic-data
|
||||
flag, reset allowance and timestamp, guide availability, the 5 named scenarios (with
|
||||
live readiness derived from actual records, not hardcoded), and plain-language
|
||||
integration summaries. Single source of truth for the demo-entry screen, the permanent
|
||||
demo badge, the scenario overview and the About page — avoids duplicating this logic
|
||||
per surface.
|
||||
- `POST /api/v1/demo/login`
|
||||
- `GET /api/v1/demo/session` — confirms the current session; `Cache-Control: no-store`
|
||||
- `POST /api/v1/demo/logout` — safe to call without a session
|
||||
- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own session
|
||||
- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own
|
||||
session; returns 403 if `DEMO_ALLOW_RESET=false`
|
||||
|
||||
### Dashboard
|
||||
|
||||
|
||||
@@ -42,6 +42,19 @@ One seeded outbox/workflow record is failed with a safe simulated connection err
|
||||
|
||||
Question: “What must I do when a vehicle returns with damage?” Expected: answer cites damage handling and return inspection procedures.
|
||||
|
||||
## Date anchoring
|
||||
|
||||
The committed CSVs store absolute ISO timestamps authored around a fixed anchor date
|
||||
(`SEED_AUTHORED_ANCHOR = 2026-08-01` in `backend/app/seed_loader.py`, matching the
|
||||
`--anchor` used to generate them). Every seed/reset shifts every seeded booking,
|
||||
inspection, maintenance and outbox timestamp by `today − SEED_AUTHORED_ANCHOR`, so
|
||||
"today"/"near-future"/"currently overlapping" scenarios stay true to the real moment the
|
||||
environment was (re)seeded instead of decaying as real time passes between resets. Public
|
||||
refs and entity relationships are untouched by the shift — only datetime columns move.
|
||||
`load_seed()` returns the resolved `anchor_date`/`seeded_at`, and records a
|
||||
`demo_data_seeded` audit event carrying both the resolved anchor and the original
|
||||
authoring anchor, so the shift applied on any given reset stays traceable.
|
||||
|
||||
## Demo reset
|
||||
|
||||
Reset must:
|
||||
@@ -49,6 +62,7 @@ Reset must:
|
||||
- require Operations Manager;
|
||||
- rebuild the deterministic dataset;
|
||||
- re-establish scenario references;
|
||||
- re-anchor scenario dates to the real reset moment (see above);
|
||||
- clear non-seed audit/workflow state;
|
||||
- complete safely and visibly;
|
||||
- be covered by a test.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# PoC runbook
|
||||
|
||||
For the demo-specific 5-minute/10-minute walkthroughs, reset behaviour, Unraid
|
||||
redeploy/rollback steps and troubleshooting, see `docs/demo-release/demo-runbook.md`.
|
||||
This document covers general environment bootstrap and n8n setup.
|
||||
|
||||
## Bootstrap (clean checkout)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# Current demo-readiness gap audit
|
||||
|
||||
Performed 2026-08-03 against `feat/mobilityops-functional-completion` @
|
||||
`e0c7ed60112510687627d20a957af91c8b9db7f8` (verified: correct branch, working tree
|
||||
clean, matches the deployed Unraid revision). Method: read `CLAUDE.md`/`AGENTS.md`
|
||||
(identical, no demo-specific guidance yet), `README.md`, `PROJECT_STATE.md`, the docs
|
||||
list in the brief, `artifacts/final-acceptance/summary.md`,
|
||||
`artifacts/functional-completion/final-summary.md`, `seed/generate_seed.py`,
|
||||
`seed/*.csv`, `backend/app/seed_loader.py`, `backend/app/core/config.py`,
|
||||
`backend/app/api/routers/dashboard.py`; live-browsed the deployed app at
|
||||
`http://192.168.10.150:1236` (login, dashboard, a data-quality issue, a booking detail,
|
||||
knowledge, automation).
|
||||
|
||||
## What already works well (do not rebuild)
|
||||
|
||||
- The premium Control Rail UI, role-based nav, server-backed sessions, return preview/
|
||||
commit, all five data-quality resolution flows, audit before/after, role-aware search,
|
||||
truthful integration status, demo reset, stale-lease recovery and the two n8n
|
||||
workflows are all implemented, tested and live-verified per
|
||||
`artifacts/functional-completion/final-summary.md` — this pass does not touch that
|
||||
business logic.
|
||||
- `docs/13-seed-and-demo-scenarios.md` already names exactly the scenarios this brief
|
||||
wants surfaced: **S1** odometer-regression return (`BK-DEMO-RETURN`/`MO-024`), **S2**
|
||||
duplicate customer (`CUS-0012`/`CUS-0178`, issue `DQ-DEMO-DUPLICATE`), **S4** booking
|
||||
overlap (`MO-016`, issue `DQ-DEMO-OVERLAP`, bookings `BK-DEMO-OVERLAP-A/B`), **S5**
|
||||
failed workflow (seeded failed outbox event, retryable from Automation), **S6**
|
||||
grounded knowledge question. These map directly to the brief's 5 requested scenarios
|
||||
— no new scenarios need to be invented, only surfaced with guidance. (S3, "missing
|
||||
inspection before next booking" on `MO-031`, exists too but isn't one of the 5
|
||||
requested; leave it as-is, it still shows up in the attention queue.)
|
||||
- `seed/generate_seed.py` already produces believable Flemish/Dutch data: first/last
|
||||
name pools (Sofie, Lotte, Bram, Peeters, Janssens, Vermeulen, ...), real Kempen-region
|
||||
towns (Geel, Turnhout, Herentals, Mol, Westerlo, Tessenderlo-Ham), `.test` email
|
||||
addresses, and believable RV/camper makes (Adria, Dethleffs, Hymer, Bürstner, ...).
|
||||
Confirmed live: `DQ-DEMO-DUPLICATE` shows customer "Bram Peeters" (Geel) with email
|
||||
`bram.peeters.12@example.test`. **The underlying data quality is already
|
||||
demo-grade — the gap is structural/experiential, not data flavour.**
|
||||
|
||||
## Confirmed gaps
|
||||
|
||||
### 1. No fictional organisation identity anywhere
|
||||
Login/demo-entry screen says "MobilityOps · Control Centre" and generic marketing copy
|
||||
("Every hand-off. One clear view.") but never names a demonstration organisation. A
|
||||
stranger cannot tell *whose* fleet this is. **Confirmed via full-repo search:**
|
||||
"Northstar Mobility" is never rendered in the UI, but it is **already the project's
|
||||
locked fictitious tenant name** — `PROJECT_STATE.md`'s "Locked decisions" section
|
||||
states verbatim *"Fictitious tenant: Northstar Mobility Demo"*, and
|
||||
`ragcore_tenant` defaults to `"northstar-mobility-demo"` in `backend/app/core/config.py`,
|
||||
`compose.yaml`, `.env.example`, `knowledge/manifest.json` and `contracts/mcp-tools.json`.
|
||||
So using "Northstar Mobility" as the visible demo brand (as this brief suggests) is not
|
||||
a new invention — it surfaces a decision the project already made and only ever used
|
||||
internally as a RAGcore-tenant slug. No renaming/config changes needed, just make it
|
||||
visible.
|
||||
|
||||
### 2. No guided path — a visitor has no idea where to start
|
||||
The dashboard shows real attention-queue items (duplicate customer, booking overlap,
|
||||
vehicle status conflict, missing field) and today's movements (`BK-DEMO-RETURN`
|
||||
11:00), but nothing tells a first-time visitor *which* of these is worth clicking, in
|
||||
what order, or why. There is no "start guided demo" affordance, no scenario overview,
|
||||
no step-by-step walkthrough. Everything technically works; nothing narrates.
|
||||
|
||||
### 3. Live date anchoring is broken — confirmed, reproducible
|
||||
`seed/bookings.csv` stores **absolute fixed ISO timestamps** authored around anchor
|
||||
date 2026-08-01 (`generate_seed.py --anchor 2026-08-01`). `backend/app/core/config.py`'s
|
||||
`demo_today` setting (`"2026-08-01"`) is used *only* by
|
||||
`dashboard.py::_today()` to filter "today's movements" against that frozen date — nothing
|
||||
re-anchors the underlying booking/inspection/maintenance/outbox dates themselves.
|
||||
**Live-confirmed right now** (today is 2026-08-03): `BK-DEMO-RETURN` shows status
|
||||
`active` with `ends_at = 01/08/2026 09:00` — already two days in the past, for a
|
||||
booking that's supposed to look "in progress, due for return soon." `BK-DEMO-NEXT`
|
||||
(meant to read as "near-future") starts 2026-08-02, also already past. Every day this
|
||||
environment isn't reset, this gets worse, and after any `seed --reset` it snaps right
|
||||
back to the same frozen 2026-08-01-relative positions regardless of the real reset
|
||||
moment. This is exactly the failure mode brief section 7 describes and must be fixed at
|
||||
the source (`seed_loader.py`), not papered over in the UI.
|
||||
|
||||
### 4. Knowledge assistant names "RAGcore" directly, not honestly "demo mode"
|
||||
`/knowledge` page copy: "Answers are shown only when **RAGcore** returns sufficient
|
||||
cited evidence" and a "RAGcore" label on the sources block — while the actual active
|
||||
provider is `KNOWLEDGE_PROVIDER=demo` (confirmed: badge below correctly shows
|
||||
`demo · Available · 10 procedures indexed`, which contradicts the body copy one line
|
||||
below it). This is the exact misleading-integration-status problem brief section 11
|
||||
warns against — the small badge is honest, the prose isn't. No suggested questions are
|
||||
offered.
|
||||
|
||||
### 5. Integration status labels are technical, not demo-plain-language
|
||||
Automation page shows raw states like `not_configured`/`degraded`/`operational` (English,
|
||||
developer register) rather than the plain-language labels section 12 asks for
|
||||
(Operationeel / Demomodus / Niet gekoppeld / Voorbereid / Verwerking mislukt / Opnieuw
|
||||
proberen mogelijk).
|
||||
|
||||
### 6. Data-quality resolution panels are functionally complete but not narrated
|
||||
Confirmed live on `DQ-DEMO-DUPLICATE`: the compare/merge UI works well, but nothing
|
||||
explains *why this matters operationally* (duplicate billing/contact risk) or what
|
||||
happens next in plain terms before the user acts. Same pattern across the other 4
|
||||
rule-type panels (already reviewed in this session's earlier batch). No "demo scenario"
|
||||
filter exists to surface the curated issues first.
|
||||
|
||||
### 7. No "About this demo" page
|
||||
No page anywhere explains what's real, what's synthetic, what's live vs. demo-mode vs.
|
||||
not-connected, or the architecture/testing approach in plain terms. A visitor exploring
|
||||
alone has no way to self-answer "is this real?"
|
||||
|
||||
### 8. No permanent, explained "synthetic demo" indicator
|
||||
The existing `demo-banner` ("Synthetic demo data · no real customer or vehicle
|
||||
information") is a full-width static bar repeated on every page — present, but static
|
||||
text only, no link to more explanation, no reset-timestamp/anchor info, and (per section
|
||||
4) shouldn't be a dominant warning bar; it should be a subtle, explained badge.
|
||||
|
||||
### 9. Demo reset exists but is not discoverable as a demo feature
|
||||
`POST /api/v1/demo/reset` and its sidebar UI trigger (this session's earlier batch)
|
||||
work correctly and are Operations-Manager-gated, but reset isn't connected to any demo
|
||||
narrative (no "prepare demo again" framing, not reachable from an About page or guide,
|
||||
no post-reset scenario-integrity confirmation beyond the raw counts).
|
||||
|
||||
### 10. No demo manifest / no single source of "what's on right now"
|
||||
Nothing exposes demo mode, scenario list, integration status and guidance availability
|
||||
as one small structured payload the frontend can render consistently (badge tooltip,
|
||||
guide, scenario overview, About page all currently would have to duplicate this
|
||||
knowledge if built independently).
|
||||
|
||||
### 11. Everything is in English; the brief asks for consistent Dutch demo text
|
||||
All existing UI copy is English. The brief explicitly asks for consistent Dutch product
|
||||
text for the demo-facing additions. Recorded decision (no existing doc answers this,
|
||||
and translating the entire already-shipped, already-tested Control Rail UI is out of
|
||||
proportion to "add guidance," would touch dozens of files and contradicts "this is not
|
||||
a redesign"): **new demo-productization surfaces are written in Dutch** (demo entry
|
||||
copy, Demo Guide, scenario overview, About-this-demo page, the demo badge/tooltip,
|
||||
plain-language integration-status labels). **Existing operational screens (Dashboard,
|
||||
Vehicles, Bookings, Data Quality workbench, Audit, Automation internals) stay in
|
||||
English** — they are already shipped, tested, and section 16 explicitly forbids
|
||||
rebuilding working screens without functional reason. This mirrors how a real bilingual
|
||||
guided demo works: narration in one language, software in whatever it already ships in.
|
||||
|
||||
## Scope reminder (not gaps — explicitly out of scope per the brief)
|
||||
|
||||
- No RAGcore or MCP Hub live implementation.
|
||||
- No new business logic in the 5 resolution flows — only explanatory framing around
|
||||
the existing ones.
|
||||
- No full redesign, new component library, new color palette, or full retranslation.
|
||||
|
||||
## Plan (implementation batches, matches task list)
|
||||
|
||||
1. Fix date anchoring in `seed_loader.py` (shift all seeded dates by `today − anchor`
|
||||
at every reset); switch `dashboard.py::_today()` to real wall-clock date; add a
|
||||
`demo_data_seeded` audit marker recording the anchor/reset moment.
|
||||
2. `GET /api/v1/demo/manifest` (org name, demo mode, scenarios, integration status,
|
||||
reset timestamp) + Dutch demo entry screen + permanent demo badge with popover.
|
||||
3. Demo Guide (collapsible panel/bottom sheet, 8 steps) + scenario overview (5 cards)
|
||||
on the dashboard.
|
||||
4. Layer plain-language Dutch explanation onto the return flow, the 5 DQ panels and the
|
||||
knowledge assistant (fix the RAGcore-naming bug), without touching their logic.
|
||||
5. Plain-language integration status labels, richer audit narration for guide-linked
|
||||
events, "Over deze demo" page, reset wired into guide/About/OM menu with a
|
||||
post-reset scenario-integrity check.
|
||||
6. Full guided-demo Playwright test + targeted tests per section 19.
|
||||
7. Clean-checkout demo drill, docs, Unraid deploy, final evidence with screenshots.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Demo concept
|
||||
|
||||
## The fictional problem
|
||||
|
||||
**Northstar Mobility** is a fictional Belgian camper/van rental company with roughly 50
|
||||
vehicles, one main location, a rental team, an operations manager, and a small workshop.
|
||||
Northstar Mobility does not exist — this name and every customer, vehicle, booking and
|
||||
procedure in the demo are synthetic. Before MobilityOps, Northstar Mobility's bookings,
|
||||
returns, customer records and maintenance history lived in spreadsheets and verbal
|
||||
hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked
|
||||
vehicles) only surfaced late, after they had already caused friction. MobilityOps shows
|
||||
how one connected system detects these problems early and lets an Operations Manager
|
||||
resolve them under audit, with automation handling the routine follow-up.
|
||||
|
||||
## Target audience
|
||||
|
||||
Anyone evaluating how MobilityOps approaches operational data-quality and hand-off
|
||||
problems for a small rental fleet: Operations Managers, Rental Employees, and reviewers
|
||||
assessing the approach. No account setup or backend knowledge is required to start —
|
||||
the login screen explains the fictional context and offers a guided path.
|
||||
|
||||
## Demo scope
|
||||
|
||||
This is a focused proof of concept, not a full ERP. In scope: vehicle and booking
|
||||
management, return processing with server-side evaluation, five data-quality detection
|
||||
rules each with one bounded resolution flow, a full audit trail, and orchestration via a
|
||||
real n8n instance. Explicitly out of scope: accounting, payments, public reservations, a
|
||||
generic CRM, inventory, HR, a second RAG stack, or autonomous write actions from any
|
||||
external tool.
|
||||
|
||||
## What's really implemented
|
||||
|
||||
All of the following is functional code, not a mockup or hardcoded screen:
|
||||
|
||||
- Role-based authentication and server-verified sessions (Operations Manager, Rental
|
||||
Employee), enforced both server-side and in the frontend's navigation/route guards.
|
||||
- Vehicle and booking management, with return preview/commit sharing one authoritative
|
||||
evaluation function so they can never drift.
|
||||
- Five data-quality rules (possible duplicate customer, missing required field, odometer
|
||||
regression, booking overlap, vehicle status conflict), each with exactly one bounded,
|
||||
audited resolution endpoint.
|
||||
- A full audit trail with before/after state, actor, correlation IDs, and a
|
||||
"view related events" link reusing that correlation.
|
||||
- Reliable outbox-based delivery to a real n8n instance, with bounded retries and
|
||||
stale-delivery recovery.
|
||||
- Docker Compose deployment and an automated test suite (backend pytest, Ruff, mypy,
|
||||
and a full Playwright end-to-end suite covering the demo experience itself).
|
||||
- A demo manifest (`GET /api/v1/demo/manifest`) as the single source of truth for the
|
||||
demo's identity, synthetic-data status, reset state, scenario readiness, and honest
|
||||
per-integration labels — the frontend never hardcodes what mode it's in.
|
||||
|
||||
## What's synthetic
|
||||
|
||||
The organisation, all customers, vehicles, bookings, maintenance history, the knowledge
|
||||
base's procedures, and the five prepared demo scenarios are entirely fictional. No data
|
||||
refers to a real person, vehicle, or company. Every seeded email uses the `.test`
|
||||
reserved domain. See [`demo-data.md`](demo-data.md) for exactly how this data is
|
||||
generated and kept fresh across resets.
|
||||
|
||||
## What's not yet live-connected
|
||||
|
||||
- **RAGcore**: not connected. The knowledge assistant uses a local, deterministic
|
||||
keyword-matching "demo knowledge base" built from five procedure documents, honestly
|
||||
labelled as such everywhere in the UI (never presented as RAGcore). A
|
||||
`RAGcoreKnowledgeProvider` HTTP adapter exists and is unit-tested, ready to take over
|
||||
the same interface once a real RAGcore backend is available — swapping providers is a
|
||||
configuration change (`KNOWLEDGE_PROVIDER`), not a UI change.
|
||||
- **ITWorx MCP Hub**: not connected. Registration is disabled by default
|
||||
(`MCP_HUB_REGISTRATION_ENABLED=false`) and the UI always shows "Not connected" —
|
||||
never a fabricated successful registration.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [`demo-scenarios.md`](demo-scenarios.md) — the five guided scenarios and their fixed records.
|
||||
- [`demo-data.md`](demo-data.md) — seed strategy and date-anchoring.
|
||||
- [`demo-guide.md`](demo-guide.md) — the in-app 8-step guided tour.
|
||||
- [`demo-runbook.md`](demo-runbook.md) — how to run, reset, and redeploy the demo.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Demo data strategy
|
||||
|
||||
## Three separate concepts
|
||||
|
||||
It's important to keep these distinct — they solve different problems:
|
||||
|
||||
1. **Deterministic records** — every seeded entity has a stable public reference
|
||||
(`BK-DEMO-RETURN`, `DQ-DEMO-DUPLICATE`, `MO-024`, `CUS-0012`, ...). These references
|
||||
never change between seed generations or resets; they are what the five demo
|
||||
scenarios, the Demo Guide, and `docs/13-seed-and-demo-scenarios.md` all link against.
|
||||
2. **Date anchoring** — the mechanism that keeps "today"/"near-future"/"currently
|
||||
overlapping" scenarios true to whenever the environment was actually last reset,
|
||||
described below.
|
||||
3. **The reset date** — the real wall-clock moment a reset actually happened. This
|
||||
changes every time someone resets; it's the input to date anchoring, not a fixed
|
||||
record.
|
||||
|
||||
## Generating the seed dataset
|
||||
|
||||
```bash
|
||||
python seed/generate_seed.py --anchor 2026-08-01 --seed 20260801
|
||||
```
|
||||
|
||||
This produces the committed CSVs in `seed/*.csv` with **absolute ISO timestamps**
|
||||
authored relative to a fixed anchor date (`2026-08-01`). Target scale: 50 vehicles, 180
|
||||
customers (including three duplicate pairs), ~245 historical/current/future bookings,
|
||||
realistic inspections and maintenance history, and the fixed quality/workflow scenarios
|
||||
described in [`demo-scenarios.md`](demo-scenarios.md). Names, towns and vehicle makes are
|
||||
drawn from believable Flemish/Kempen-region pools; all emails use the `.test` domain.
|
||||
|
||||
## Date anchoring
|
||||
|
||||
`backend/app/seed_loader.py` defines:
|
||||
|
||||
```python
|
||||
SEED_AUTHORED_ANCHOR = date(2026, 8, 1) # matches generate_seed.py's --anchor
|
||||
```
|
||||
|
||||
Every seed/reset computes `shift = today - SEED_AUTHORED_ANCHOR` and applies it to every
|
||||
seeded booking, inspection, maintenance and outbox timestamp before insertion. Public
|
||||
references and entity relationships are never touched by the shift — only datetime
|
||||
columns move. This means:
|
||||
|
||||
- `BK-DEMO-RETURN` always ends "today" (or very close to it) relative to whenever you
|
||||
actually reset, not relative to the frozen 2026-08-01 authoring date.
|
||||
- `BK-DEMO-NEXT` and the overlap-scenario bookings always read as "near future".
|
||||
- The shift is recomputed fresh on every reset, so scenarios never decay as real time
|
||||
passes between resets — this was a real, confirmed bug before this fix (see
|
||||
`docs/demo-release/current-demo-gap-audit.md`, gap #3): the environment would drift
|
||||
further out of sync with every day it wasn't reset, and a reset didn't fix it because
|
||||
nothing re-anchored the underlying stored dates.
|
||||
|
||||
`load_seed()` returns the resolved `anchor_date` (real today) and `seeded_at` timestamp,
|
||||
and records a `demo_data_seeded` audit event carrying both the resolved anchor and the
|
||||
original authoring anchor, so the shift applied on any given reset stays traceable via
|
||||
the audit trail.
|
||||
|
||||
`dashboard.py::_today()` uses real wall-clock UTC date (not a frozen setting) to filter
|
||||
"today's movements", consistent with the shifted data.
|
||||
|
||||
## Reset
|
||||
|
||||
`POST /api/v1/demo/reset` (Operations Manager only, and only if `DEMO_ALLOW_RESET=true`)
|
||||
clears all MobilityOps tables, reloads the seed with a fresh date shift, re-runs the
|
||||
data-quality scan, and runs a server-side **scenario-integrity check**
|
||||
(`scenario_integrity_report()` in `backend/app/services/demo_manifest.py`) confirming all
|
||||
five named scenarios are actually present and ready — recorded in both the response body
|
||||
and the `demo_reset` audit event's metadata. Reset only ever affects MobilityOps's own
|
||||
tables; it never touches shared n8n, RAGcore, or MCP data, other containers, or volumes.
|
||||
|
||||
## Seed-validation tests
|
||||
|
||||
`backend/tests/test_seed.py` proves, after every reset:
|
||||
|
||||
- S1 (`BK-DEMO-RETURN`/`MO-024`) is active with no end odometer recorded yet.
|
||||
- S2 (`CUS-0012`/`CUS-0178`/`DQ-DEMO-DUPLICATE`) is open with matching evidence.
|
||||
- S4 (`MO-016`/`BK-DEMO-OVERLAP-A`/`-B`/`DQ-DEMO-OVERLAP`) genuinely overlaps in time.
|
||||
- S5 (the seeded failed outbox event) is durably `failed` immediately after reset, not
|
||||
silently auto-healed by the background dispatcher (which only claims `pending` rows).
|
||||
- The date-anchoring shift and the `demo_data_seeded` audit marker are both correct.
|
||||
|
||||
`backend/tests/test_demo_manifest.py` additionally proves that all five manifest
|
||||
scenarios report `ready: true` with no `blocked_reason` right after a fresh reset.
|
||||
@@ -0,0 +1,71 @@
|
||||
# The in-app Demo Guide
|
||||
|
||||
## What it is (and isn't)
|
||||
|
||||
The Demo Guide is a compact, reusable side panel (a bottom sheet on mobile) that walks an
|
||||
Operations Manager through eight fixed steps covering the demo's core functionality. It is
|
||||
**not** a generic tutorial engine and **not** a source of business logic — every action it
|
||||
prompts is a real action against the real API; the guide only narrates, links, and tracks
|
||||
progress. It duplicates no business logic: routes for the two scenario-backed steps
|
||||
(return, duplicate-merge) are resolved from the live `GET /api/v1/demo/manifest` response
|
||||
rather than hardcoded, so they can never point at a stale or missing record.
|
||||
|
||||
## Where it lives
|
||||
|
||||
- `frontend/src/data/demoGuideSteps.ts` — the eight step definitions (title, what you'll
|
||||
see, why it matters, the exact start action, the expected outcome, and a `route()`
|
||||
function).
|
||||
- `frontend/src/context/DemoGuideContext.tsx` — open/close state and step progress,
|
||||
persisted to `sessionStorage` only (browser-local, never touches auth or business
|
||||
state; a "Demo opnieuw voorbereiden" restart or a new browser session starts fresh).
|
||||
- `frontend/src/components/DemoGuide.tsx` — the panel itself and its topbar trigger
|
||||
(`DemoGuideTrigger`, Operations-Manager-only, since all eight steps require that role).
|
||||
|
||||
## The eight steps
|
||||
|
||||
1. **Understand the operational state** — the dashboard's readiness and attention queue.
|
||||
2. **Open the booking needing attention** — `BK-DEMO-RETURN`, resolved from the manifest.
|
||||
3. **Process the return with an odometer anomaly** — same booking; the return form is
|
||||
pre-filled with the suspicious reading (see [`demo-scenarios.md`](demo-scenarios.md)
|
||||
scenario 1).
|
||||
4. **Handle the created data-quality issue** — the fresh odometer-regression issue that
|
||||
step 3 just created.
|
||||
5. **Review and merge the possible duplicate customer** — `DQ-DEMO-DUPLICATE`.
|
||||
6. **Ask the procedure assistant a question** — one of the suggested questions.
|
||||
7. **Check automation and the audit trail**.
|
||||
8. **Review what's real, simulated, or not yet connected** — the About page.
|
||||
|
||||
## How progression works
|
||||
|
||||
- **"Ga naar deze stap"** navigates to the step's resolved route without marking it done.
|
||||
- **"Volgende"** marks the current step complete and advances the index (used for steps
|
||||
with no dedicated in-page continuation, like the knowledge-assistant step).
|
||||
- Several real actions (a successful return, a resolved data-quality issue) show their
|
||||
own **"Ga verder met de demo"** button that both completes the current step and
|
||||
navigates straight to the next one — this is how the guide chains through steps 3→4→5
|
||||
without a detour back through the panel's own controls.
|
||||
- The step list on the panel lets you jump directly to any step.
|
||||
- **"Demo opnieuw voorbereiden"** calls the real reset endpoint, resets the guide's own
|
||||
progress, and returns to the login screen — mirroring the existing sidebar reset
|
||||
control, not a separate implementation.
|
||||
|
||||
## A known, deliberate limitation: suggested questions stay in English
|
||||
|
||||
The demo knowledge base's five procedure documents (`knowledge/procedures/*.md`) are
|
||||
written in English. Verified empirically while building step 6: an equivalent Dutch
|
||||
question returned `insufficient` evidence against the demo provider, while the English
|
||||
original returned `grounded`. Rather than mistranslate the demo's centerpiece "grounded
|
||||
answer" feature into something that silently answers incorrectly, the suggested questions
|
||||
on `/knowledge` and this guide's step 6 instructions stay in English, with the guide
|
||||
explicitly explaining why in Dutch. Retranslating the procedure documents themselves was
|
||||
judged out of scope for a demo-productization pass (see the language-split note in
|
||||
`docs/demo-release/current-demo-gap-audit.md`, gap #11).
|
||||
|
||||
## Layout note: the panel reserves space, it doesn't overlap content
|
||||
|
||||
On desktop the panel is a fixed 400px-wide right-side overlay. `Layout.tsx` adds a
|
||||
`guide-open` class to the main workspace while the guide is open, which reserves
|
||||
`padding-right` so page content reflows aside instead of sitting underneath the panel —
|
||||
this was a real bug found and fixed while building the full guided-demo Playwright test
|
||||
(see `PROJECT_STATE.md`, Batch 6): without it, the return form's "Review return" button
|
||||
was unclickable while the guide was open at ordinary desktop widths.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Demo runbook
|
||||
|
||||
## Starting the demo (any environment)
|
||||
|
||||
Open the deployed URL (Unraid review: `http://192.168.10.150:1236`; local:
|
||||
`http://localhost:1228`). The login screen names the fictional organisation, states that
|
||||
all data is synthetic and all workflows are really implemented, and offers:
|
||||
|
||||
- **Start begeleide demo** — logs in as Operations Manager and opens the Demo Guide at
|
||||
step 1.
|
||||
- **Verken als Operations Manager** / **Verken als Rental Employee** — free exploration,
|
||||
no guide.
|
||||
|
||||
No password is shown or required for either path.
|
||||
|
||||
## Five-minute demo (guided)
|
||||
|
||||
1. Click **Start begeleide demo**.
|
||||
2. Follow steps 1–3: dashboard → the booking needing attention → confirm the pre-filled
|
||||
odometer-anomaly return.
|
||||
3. Step 4: resolve the data-quality issue the return just created (any decision is fine
|
||||
for a quick pass — "Retain canonical" is the fastest).
|
||||
4. Step 6: ask a suggested knowledge question and show the cited source.
|
||||
5. Step 7: show the automation/audit trail link-through.
|
||||
6. Close with step 8, the About page's honest real/synthetic/not-connected breakdown.
|
||||
|
||||
## Ten-minute demo (guided + one extra scenario)
|
||||
|
||||
Do the five-minute path above, then from `/scenarios`:
|
||||
|
||||
- Run **scenario 2** (duplicate customer merge) if not already done via the guide's own
|
||||
step 5.
|
||||
- Run **scenario 3** (booking overlap) — `/data-quality`, resolve `DQ-DEMO-OVERLAP`.
|
||||
- Run **scenario 4** (failed automation retry) — `/automation`, filter to failed, retry.
|
||||
|
||||
All five scenarios can be run in any order and are independent of each other.
|
||||
|
||||
## Resetting the environment
|
||||
|
||||
Any Operations Manager can reset from: the sidebar ("Reset demo data"), the Demo Guide
|
||||
panel ("Demo opnieuw voorbereiden"), or the About page (points to the sidebar control).
|
||||
Reset requires confirmation, rebuilds the deterministic dataset with a fresh date anchor,
|
||||
runs a server-side scenario-integrity check, and signs the acting session out (the server
|
||||
invalidates the session as part of reset). It only ever touches MobilityOps's own tables
|
||||
— never shared n8n, RAGcore, or MCP data, other containers, or volumes. It can be
|
||||
disabled entirely via `DEMO_ALLOW_RESET=false` if an environment must not be rebuildable.
|
||||
|
||||
## Redeploying to Unraid
|
||||
|
||||
```bash
|
||||
# From a clean local checkout on the target branch/commit:
|
||||
git archive --format=tar.gz -o /tmp/mobilityops-source.tar.gz HEAD
|
||||
scp /tmp/mobilityops-source.tar.gz unraid:/mnt/user/appdata/mobilityops/.deploy/source-<short-sha>.tar.gz
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||
&& tar -xzf .deploy/source-<short-sha>.tar.gz \
|
||||
&& echo <full-sha> > .deploy/source-revision"
|
||||
|
||||
# Rebuild only what changed (api and/or web); db is never rebuilt:
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web"
|
||||
|
||||
# Confirm migrations and reseed:
|
||||
ssh unraid "cd /mnt/user/appdata/mobilityops \
|
||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m alembic current \
|
||||
&& docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api python -m app.cli seed --reset"
|
||||
```
|
||||
|
||||
Extraction preserves the server's existing `.env` and named volumes (Postgres data, n8n
|
||||
data) — the tarball never contains `.env` since it's gitignored. Never edit source
|
||||
directly on the server; never deploy uncommitted changes.
|
||||
|
||||
## Rollback
|
||||
|
||||
`.deploy/source-revision` on the server records exactly which commit is live. Prior
|
||||
source tarballs remain in `.deploy/` for rollback: extract an earlier
|
||||
`source-<short-sha>.tar.gz`, update `source-revision` to match, and re-run the rebuild
|
||||
step above. Database rollback is out of scope for this demo (migrations are additive;
|
||||
there has been no destructive migration on this branch).
|
||||
|
||||
## Server safety (Unraid)
|
||||
|
||||
Only touch the `mobilityops` Compose project's own `api`/`web` services (and `db` only
|
||||
via migrations, never manually). Never stop other containers, run `docker system prune`,
|
||||
delete unrecognised images/networks/volumes, delete the MobilityOps database, overwrite
|
||||
the server `.env`, print secrets, start a second permanent n8n instance, or activate
|
||||
guessed RAGcore/MCP URLs. PostgreSQL is never exposed externally.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **A scenario shows "Niet beschikbaar" on `/scenarios`**: it has already been resolved
|
||||
since the last reset (expected once you've worked through it) — reset to restore it.
|
||||
- **Knowledge question returns "insufficient evidence"**: check the question is in
|
||||
English and close to one of the suggested phrasings — the demo knowledge base is
|
||||
English-only (see `demo-guide.md`).
|
||||
- **n8n shows a failed delivery that isn't the seeded demo one**: check `/automation`'s
|
||||
filter and retry — bounded retries mean it should self-heal within
|
||||
`n8n_max_attempts` attempts, or can be retried manually by an Operations Manager.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Demo scenarios
|
||||
|
||||
Five fixed scenarios are surfaced at `/scenarios` and via `GET /api/v1/demo/manifest`'s
|
||||
`scenarios` array, which derives each scenario's `ready`/`blocked_reason` from the actual
|
||||
underlying records — never hardcoded. All five are re-findable, using the same public
|
||||
references, after every demo reset (see [`demo-data.md`](demo-data.md)).
|
||||
|
||||
## 1 — Odometer regression on return
|
||||
|
||||
- **Fixed records**: booking `BK-DEMO-RETURN` on vehicle `MO-024`.
|
||||
- **Role**: Rental Employee or Operations Manager.
|
||||
- **Problem demonstrated**: a return is submitted with an odometer reading lower than the
|
||||
vehicle's canonical reading — usually a data-entry mistake or a return logged against
|
||||
the wrong vehicle.
|
||||
- **Flow**: open the booking. The return form is pre-filled with a suspicious reading
|
||||
below `MO-024`'s canonical odometer (the demo never asks a visitor to invent one), with
|
||||
a callout explaining why. Review the server-evaluated preview, then confirm. The
|
||||
canonical odometer is never silently lowered; a data-quality issue is opened
|
||||
automatically and the outcome is queued for delivery to n8n.
|
||||
- **Continue**: the result panel links to the vehicle, automation status, and audit
|
||||
trail, plus (inside the guided tour) a "Ga verder met de demo" button.
|
||||
|
||||
## 2 — Possible duplicate customer
|
||||
|
||||
- **Fixed records**: customers `CUS-0012` and `CUS-0178` (same email/phone), issue
|
||||
`DQ-DEMO-DUPLICATE`.
|
||||
- **Role**: Operations Manager (merge is a bounded, audited action).
|
||||
- **Problem demonstrated**: two customer profiles that are very likely the same person,
|
||||
registered twice — a risk for split booking history and duplicate contact.
|
||||
- **Flow**: open the issue, compare both profiles field by field, choose which survives,
|
||||
and merge. The losing profile becomes a tombstone linked to the survivor; its bookings
|
||||
are rewired. The result is recorded in the audit trail.
|
||||
|
||||
## 3 — Overlapping bookings
|
||||
|
||||
- **Fixed records**: vehicle `MO-016`, bookings `BK-DEMO-OVERLAP-A`/`BK-DEMO-OVERLAP-B`,
|
||||
issue `DQ-DEMO-OVERLAP`.
|
||||
- **Role**: Operations Manager.
|
||||
- **Problem demonstrated**: the same vehicle committed to two overlapping reservations —
|
||||
a legacy-import-style conflict that a normal booking command would reject outright.
|
||||
- **Flow**: open the issue, choose which of the two bookings to block; the other keeps
|
||||
its current status.
|
||||
|
||||
## 4 — Failed automation, retried
|
||||
|
||||
- **Fixed record**: a seeded outbox event (`00000000-0000-4000-8000-000000000020`,
|
||||
`BK-H-0020`) that is durably `failed` immediately after every reset — a safe, simulated
|
||||
connection error, not a deliberately destabilised n8n configuration.
|
||||
- **Role**: Operations Manager.
|
||||
- **Problem demonstrated**: bounded retries and visible failure/recovery state for
|
||||
workflow delivery, rather than a silent drop.
|
||||
- **Flow**: open Automation, filter to failed deliveries, retry the event; it moves out
|
||||
of the failed filter once delivered.
|
||||
|
||||
## 5 — Grounded procedure question
|
||||
|
||||
- **Role**: Rental Employee or Operations Manager.
|
||||
- **Problem demonstrated**: an operational question gets an answer with a citation from
|
||||
the demo knowledge base — or an honest "insufficient evidence" if nothing indexed
|
||||
answers it — never an invented answer.
|
||||
- **Flow**: open Knowledge, click one of the suggested questions (English, matching the
|
||||
indexed procedure content — see the note in [`demo-guide.md`](demo-guide.md) about why
|
||||
suggested questions stay in English even though the rest of the demo narrates in
|
||||
Dutch), and inspect the cited source and evidence state.
|
||||
|
||||
## A sixth scenario exists but isn't one of the five requested
|
||||
|
||||
`docs/13-seed-and-demo-scenarios.md`'s **S3** ("missing inspection before next booking",
|
||||
vehicle `MO-031`) is also seeded and shows up in the attention queue; it isn't surfaced
|
||||
on `/scenarios` because the brief names five specific scenarios, but it remains available
|
||||
for anyone exploring the attention queue directly.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Final product polish — audit
|
||||
|
||||
Performed 2026-08-03 against `feat/mobilityops-functional-completion`.
|
||||
|
||||
## Commit state
|
||||
|
||||
- **Local/branch HEAD**: `4a268c73515dc4f1d56c1aa2f231714654bffbb8`
|
||||
- **Deployed on Unraid** (`/mnt/user/appdata/mobilityops/.deploy/source-revision`):
|
||||
`4a268c73515dc4f1d56c1aa2f231714654bffbb8` — **matches exactly**, no drift.
|
||||
- **Repository's primary branch is `master`, not `main`** — no branch named `main` exists
|
||||
(`git branch -a` / `git remote show` confirm only `master`, `design/mobilityops-premium-ui`,
|
||||
and `feat/mobilityops-functional-completion`). `origin/master` = local `master` =
|
||||
`e0c7ed60112510687627d20a957af91c8b9db7f8`, unchanged since the functional-completion
|
||||
baseline — this is the baseline to compare against before any future merge.
|
||||
- **Evidence-file mismatch found and corrected**: `artifacts/demo-release/final-summary.md`
|
||||
recorded final commit `294a8176d19...` (one commit behind actual HEAD, because its own
|
||||
"record the hash" follow-up commit necessarily couldn't self-reference). Corrected to
|
||||
the verified, unambiguous `4a268c73515dc4f1d56c1aa2f231714654bffbb8`, cross-checked
|
||||
against both `git rev-parse HEAD` and the server's `source-revision`.
|
||||
- Containers on Unraid: `api`/`db`/`web` all healthy; migrations at `e7b08389f47f (head)`.
|
||||
|
||||
## Remaining "MobilityOps" mentions (user-facing)
|
||||
|
||||
- `frontend/index.html:7` — `<title>MobilityOps</title>`; `:6` meta description.
|
||||
- `frontend/src/components/Layout.tsx:178` — sidebar brand lockup `<strong>MobilityOps</strong>`.
|
||||
- `frontend/src/components/Layout.tsx:232` — global search `aria-label`/visually-hidden
|
||||
label "Search MobilityOps".
|
||||
- `frontend/src/components/Layout.tsx:299` — footer `<span>MobilityOps PoC</span>` (also
|
||||
the "PoC" mention to remove).
|
||||
- `frontend/src/pages/Login.tsx:27` — the manifest-fallback org description string.
|
||||
- `frontend/src/pages/AboutDemo.tsx:32,63,71` — page heading and two body paragraphs.
|
||||
- `frontend/src/pages/Knowledge.tsx:108` — empty-state copy.
|
||||
- `frontend/src/pages/BookingDetail.tsx:80` — scenario callout copy.
|
||||
- `frontend/src/data/demoGuideSteps.ts:91` — step 8's expected-outcome text.
|
||||
- `backend/app/services/demo_manifest.py:24` — `ORGANIZATION_DESCRIPTION` (the string
|
||||
`Login.tsx:27` falls back to when the manifest hasn't loaded yet — both need updating
|
||||
together to avoid a flash of stale branding).
|
||||
- `backend/app/main.py:36` — FastAPI `title="MobilityOps API"` (visible in the OpenAPI/
|
||||
Swagger UI a reviewer might open; low-risk to rename since it's a display string, not a
|
||||
route or contract field).
|
||||
- **`knowledge/procedures/*.md` — user-facing, appears directly in cited answer text**:
|
||||
`02-vehicle-return.md`, `03-damage-handling.md` (also says "PoC"), `05-cleaning-
|
||||
checklist.md`, `07-customer-documents.md` (also says "PoC"), `09-booking-conflicts.md`
|
||||
all say "MobilityOps" in prose that gets quoted verbatim as a knowledge-assistant
|
||||
answer excerpt — these must be rebranded too, not just the app chrome.
|
||||
- e2e tests asserting on the string "MobilityOps" that must be updated alongside the
|
||||
rename (not gaps, just dependencies): `demo-accessibility.spec.ts`, `demo-entry.spec.ts`,
|
||||
`guided-demo-full.spec.ts` (About-page heading), `ui-redesign.spec.ts` (4× "Search
|
||||
MobilityOps" combobox name).
|
||||
|
||||
**Kept as technical identifiers** (per the brief, not renamed): Git repo `Jens/MobilityOps`,
|
||||
local repo folder name, Compose project `mobilityops`, deployment dir
|
||||
`/mnt/user/appdata/mobilityops`, Postgres db/user `mobilityops`, `knowledge/manifest.json`'s
|
||||
`"workspace": "mobilityops"` (a RAGcore-workspace config value), frontend package name
|
||||
`mobilityops-web`, and the internal API route prefix conventions.
|
||||
|
||||
## Language problems (no i18n exists at all)
|
||||
|
||||
- `frontend/package.json` has no i18n library (`i18next`/`react-i18next`/etc. absent).
|
||||
- There is no language switcher anywhere and no persisted language preference.
|
||||
- The existing UI mixes English (Dashboard, Vehicles, Bookings, Data Quality workbench,
|
||||
Audit, Automation internals — all shipped in Task-B's demo-productization pass) and
|
||||
Dutch (Login, Demo Guide, Scenarios, About, demo badge — added in the same pass) by a
|
||||
documented, deliberate scope decision at the time. That decision must now be superseded:
|
||||
the brief requires one true default (nl-BE) with working en-GB/fr-BE switching across
|
||||
the **entire** application, so the English-language existing screens are now in scope
|
||||
for translation, not just new surfaces.
|
||||
- The demo knowledge base (`knowledge/procedures/*.md`) is English-only; a Dutch or French
|
||||
question against it returns `insufficient` evidence (verified empirically in the prior
|
||||
work) — this is a real gap for the trilingual guided demo requirement.
|
||||
|
||||
## Remaining technical user-facing language
|
||||
|
||||
- `frontend/src/pages/Automation.tsx:183` — `{r.event_type}` rendered raw (e.g.
|
||||
`vehicle.returned.v1`) in the primary ledger table, no human label.
|
||||
- `frontend/src/pages/Automation.tsx:91` — "delivered through the outbox" in primary copy.
|
||||
- `frontend/src/pages/Audit.tsx:129` — `{e.correlation_id.slice(0, 8)}` shown as the
|
||||
visible `<summary>` trigger text for technical detail, not a meaningful reference.
|
||||
- `frontend/src/pages/DataQualityIssueDetail.tsx:341,366` — "canonical odometer" used
|
||||
directly in primary decision copy, not translated to an operational phrase.
|
||||
- No central technical→human terminology mapping exists anywhere in the frontend.
|
||||
|
||||
## Demo Guide panel problems
|
||||
|
||||
- `frontend/src/components/DemoGuide.tsx` + the `@media (min-width: 701px)` rule in
|
||||
`styles.css` implement exactly **one** behaviour for every desktop/tablet width ≥701px:
|
||||
a fixed 400px right-side panel with `.app-workspace.guide-open { padding-right:
|
||||
min(400px, 92vw) }` reserving space. There is no distinction between "extra-wide
|
||||
desktop, dock + reflow with a guaranteed minimum content width" and "standard desktop/
|
||||
tablet, floating non-modal sheet that auto-collapses to a progress chip" as the brief
|
||||
now requires — today the panel never collapses to a chip at all; it only fully opens
|
||||
or fully closes.
|
||||
- "Ga naar deze stap" (`DemoGuide.tsx`'s `goToStepRoute()`) only calls `navigate(...)`; it
|
||||
does not scroll to or focus the relevant target element, and does not auto-collapse the
|
||||
panel afterward.
|
||||
- No semantic target anchors exist on pages for the guide to scroll/focus toward.
|
||||
- Reduced-motion is not explicitly handled for the panel's open/close or any future
|
||||
collapse/expand transition.
|
||||
|
||||
## Data Quality Workbench contrast and hierarchy
|
||||
|
||||
- Confirmed by direct inspection of `DataQualityIssueDetail.tsx`: only the odometer-
|
||||
regression decision (lines 349,358) uses the higher-contrast `.check-card` treatment
|
||||
(`background: var(--surface-subtle); border: 1px solid var(--line); padding: 10px 12px`).
|
||||
Every other decision point — `DuplicateCustomerPanel`'s survivor choice (134,143) and
|
||||
per-field merge choice (172,187), and `BookingOverlapPanel`'s block-choice (459) — uses
|
||||
the bare `.checkbox-label` class: a small inline radio with no card background, no
|
||||
border, no selected-state treatment, and no visible risk/consequence copy next to the
|
||||
option. This is the inconsistency the brief describes as "kleine losse radioknoppen in
|
||||
een zwak omlijnde rij."
|
||||
- Resolve/defer/reject already have some visual differentiation (`button-primary` for the
|
||||
main resolution action inside each panel; a separate "Defer or reject" section below),
|
||||
but defer and reject are rendered as two identically-weighted plain `<button>` elements
|
||||
with no secondary/tertiary visual distinction from each other.
|
||||
- No sticky/always-visible action bar exists within longer resolution panels.
|
||||
|
||||
## Automation/audit density
|
||||
|
||||
- `frontend/src/pages/Automation.tsx`'s delivery ledger renders every one of the ~20
|
||||
seeded `succeeded` events as an individual flat table row with only a status-text
|
||||
filter (`pending`/`delivering`/`succeeded`/`failed`) — no grouping/summarization of
|
||||
repeated successes, and event references are raw 8-character UUID slices with no
|
||||
meaningful short display reference scheme.
|
||||
- `frontend/src/pages/Audit.tsx` shows one row per raw audit event; my prior "View related
|
||||
events" filter (added in the demo-productization pass) lets a user filter down to a
|
||||
correlation group, but does not present that group as one collapsed operational summary
|
||||
by default — a visitor still sees N separate technical rows even after filtering.
|
||||
- Before/after is already shown as a computed diff string (`describeChanges()`), not raw
|
||||
JSON, which partially satisfies 8C already — but the diff is a flat semicolon-joined
|
||||
string, not the labelled multi-line "Status: A → B" presentation the brief shows.
|
||||
|
||||
## Rows that are not fully clickable
|
||||
|
||||
- **Attention Queue** (`Dashboard.tsx:126-146`, `.attention-list li`): only the `<Link>`
|
||||
wrapping the item's title text is interactive; the severity badge, detail text, ref,
|
||||
and chevron are inert. Confirmed by direct markup inspection.
|
||||
- **Today's movements** (`Dashboard.tsx:153-161`, `.movement-timeline li`): only the
|
||||
`<Link>` wrapping the booking reference is interactive; each row unambiguously points
|
||||
to one booking, so this qualifies for the same fully-clickable-row treatment.
|
||||
- **Recent activity** (`Dashboard.tsx:220-224`, `.recent-list li`): has **no** link at all
|
||||
today, and there is no existing single-record detail page for an individual automation
|
||||
event to point to — out of scope for "make it clickable" per the brief's own
|
||||
"alleen wanneer de volledige rij ondubbelzinnig naar één bestemming verwijst" carve-out;
|
||||
left as-is unless a natural destination is introduced elsewhere in this pass.
|
||||
- Scenario cards (`Scenarios.tsx`) already use a single full-card "Start scenario" link
|
||||
per card with no competing interactive elements inside — already compliant, no change
|
||||
needed.
|
||||
|
||||
## Plan (implementation order)
|
||||
|
||||
1. Rebrand to Fleet Ops (frontend strings, backend description strings, knowledge
|
||||
procedure prose, index.html, e2e assertions) — batch, tested, committed.
|
||||
2. i18n architecture: add `i18next`/`react-i18next`, language switcher, persistence,
|
||||
`html lang`, `Intl` formatting, namespaces, fail-fast missing-key check.
|
||||
3. Translate all existing screens + new demo surfaces into nl-BE/en-GB/fr-BE.
|
||||
4. Backend message-code fields for data-quality reasons/recommended actions/return
|
||||
reasons/integration statuses/audit actions/errors; frontend localizes.
|
||||
5. Multilingual knowledge base (5 procedures × 3 locales) + locale-aware provider lookup.
|
||||
6. Adaptive Demo Guide: docked-rail / floating-sheet+chip / mobile-bottom-sheet behaviour,
|
||||
scroll-to-target + focus + highlight, reduced motion.
|
||||
7. Data Quality Workbench: choice cards everywhere, sticky action bar, de-emphasized
|
||||
defer/reject, contrast fixes.
|
||||
8. Central terminology mapping layer (nl/en/fr) used in Automation/Audit/Data-Quality.
|
||||
9. Automation ledger grouping/filtering + retry UX narration.
|
||||
10. Audit trail human action labels + correlation-grouped summary + labelled diffs.
|
||||
11. Fully clickable Attention Queue + Today's movements rows, with tests.
|
||||
12. Responsive pass across the 7 required breakpoints × 3 languages.
|
||||
13. Full test suite additions (i18n, branding, guide, quality, queue, automation/audit,
|
||||
trilingual guided demo).
|
||||
14. Clean-checkout drill.
|
||||
15. Deploy feature branch to Unraid for final validation.
|
||||
16. Safe merge to `master` (the repository's actual main branch) + redeploy + final
|
||||
evidence at `artifacts/fleet-ops-release/final-summary.md`.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// One-off tooling to capture demo-productization evidence screenshots for
|
||||
// artifacts/demo-release/final-summary.md. Not part of the regular test suite
|
||||
// (prefixed with `_` and excluded from the configured test run via testIgnore).
|
||||
|
||||
const OUT = "../artifacts/demo-release/screenshots";
|
||||
|
||||
test("capture demo-release evidence screenshots", async ({ page, request }) => {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/01-demo-entry-desktop.png`, fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` });
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/04-demo-guide.png` });
|
||||
await page.getByRole("button", { name: "Sluiten" }).click();
|
||||
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Review return" }).click();
|
||||
await expect(page.getByText(/Expected fleet state/)).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/05-return-preview.png`, fullPage: true });
|
||||
await page.getByRole("button", { name: "Confirm return" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/06-return-result.png`, fullPage: true });
|
||||
|
||||
await page.goto("/data-quality");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
await page.locator(".data-table tbody tr").first().locator("a").click();
|
||||
await expect(page.getByText("What's wrong")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/07-data-quality-resolution.png`, fullPage: true });
|
||||
await page.getByRole("radio", { name: /Retain canonical/ }).check();
|
||||
await page.getByRole("button", { name: "Resolve issue" }).click();
|
||||
await expect(page.getByText(/Issue .* resolved/)).toBeVisible();
|
||||
|
||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
||||
await expect(page.getByRole("heading", { name: "Compare and merge" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/08-duplicate-customer-merge.png`, fullPage: true });
|
||||
|
||||
await page.goto("/knowledge");
|
||||
await page.getByRole("button", { name: "What must I do when a vehicle returns with damage?" }).click();
|
||||
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/09-knowledge-assistant.png`, fullPage: true });
|
||||
|
||||
await page.goto("/automation");
|
||||
await expect(page.locator(".integration-cards")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/10-integration-status.png`, fullPage: true });
|
||||
const failedRetry = page.locator(".data-table tbody tr", { hasText: "failed" }).first();
|
||||
if (await failedRetry.count()) {
|
||||
await page.screenshot({ path: `${OUT}/11-automation-retry-before.png`, fullPage: true });
|
||||
await failedRetry.getByRole("button", { name: "Retry" }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/11-automation-retry-after.png`, fullPage: true });
|
||||
}
|
||||
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/12-audit-trail.png`, fullPage: true });
|
||||
await page.locator(".data-table tbody tr").first().getByRole("button", { name: "View related events" }).click();
|
||||
await page.screenshot({ path: `${OUT}/13-audit-related-events.png`, fullPage: true });
|
||||
|
||||
await page.goto("/about");
|
||||
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/14-about-demo.png`, fullPage: true });
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await page.getByRole("button", { name: /Synthetische demo/ }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/15-demo-badge-popover.png` });
|
||||
|
||||
// Final reset, captured, to leave the environment fully restored.
|
||||
await page.keyboard.press("Escape");
|
||||
const resetButton = page.getByRole("button", { name: "Reset demo data" });
|
||||
if (await resetButton.count()) {
|
||||
await resetButton.click();
|
||||
await expect(page.getByRole("button", { name: "Yes, reset" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/16-reset-confirm.png` });
|
||||
await page.getByRole("button", { name: "Yes, reset" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
}
|
||||
});
|
||||
@@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => {
|
||||
await page.goto("/login");
|
||||
await page.screenshot({ path: `${OUT}/1-login.png` });
|
||||
|
||||
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
// Verifies the "stretched link" pattern used across the Attention Queue, Today's movements,
|
||||
// Vehicles, Bookings and Data Quality tables: the whole row is one activation target, not
|
||||
// just its title/reference text, while any secondary in-row link stays independently usable.
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
test("attention queue row opens its record when clicking empty row space, not just the title", async ({ page }) => {
|
||||
const row = page.locator(".attention-list li.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const box = await row.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
// Click near the far right edge of the row -- empty space, not the title text or badge.
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
|
||||
});
|
||||
|
||||
test("attention queue row is keyboard reachable and opens on Enter", async ({ page }) => {
|
||||
const row = page.locator(".attention-list li.row-clickable").first();
|
||||
const link = row.locator(".row-link");
|
||||
await link.focus();
|
||||
await expect(link).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
|
||||
});
|
||||
|
||||
test("today's movements row opens the correct booking on click", async ({ page }) => {
|
||||
const row = page.locator(".movement-timeline li.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const box = await row.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(/\/bookings\/BK-/);
|
||||
});
|
||||
|
||||
test("vehicles table row opens the vehicle detail from empty row space", async ({ page }) => {
|
||||
await page.goto("/vehicles");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
|
||||
const box = await row.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(new RegExp(`/vehicles/${ref}$`));
|
||||
});
|
||||
|
||||
test("bookings table row opens the booking, and the secondary vehicle link stays independently clickable", async ({ page }) => {
|
||||
await page.goto("/bookings");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const vehicleLink = row.locator(".cell-link");
|
||||
const vehicleRef = (await vehicleLink.innerText()).trim();
|
||||
|
||||
// Clicking the secondary vehicle-ref link navigates to the vehicle, not the booking --
|
||||
// it must not be swallowed by the row-spanning overlay link sitting behind it.
|
||||
await vehicleLink.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/vehicles/${vehicleRef}$`));
|
||||
|
||||
await page.goto("/bookings");
|
||||
const rowAgain = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
const bookingRef = (await rowAgain.locator("th").first().innerText()).split("\n")[0].trim();
|
||||
const box = await rowAgain.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(new RegExp(`/bookings/${bookingRef}$`));
|
||||
});
|
||||
|
||||
test("data quality table row opens the issue detail from empty row space", async ({ page }) => {
|
||||
await page.goto("/data-quality");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
|
||||
const box = await row.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
|
||||
});
|
||||
|
||||
test("attention queue row opens the correct record on a mobile viewport tap", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/dashboard");
|
||||
const row = page.locator(".attention-list li.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const box = await row.boundingBox();
|
||||
// A real touch context needs `hasTouch`, which this shared spec file doesn't opt into;
|
||||
// a mouse click at the same mobile viewport size still exercises the same CSS layout
|
||||
// and click-target logic, since the app has no touch-specific event handling.
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
|
||||
});
|
||||
|
||||
test("data quality table row has a pointer cursor and a visible focus ring covering the whole row", async ({ page }) => {
|
||||
await page.goto("/data-quality");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toHaveCSS("cursor", "pointer");
|
||||
await row.locator(".row-link").focus();
|
||||
await expect(row.locator(".row-link")).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("demo guide is usable as a mobile bottom sheet", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
|
||||
const panel = page.getByRole("dialog", { name: "Gegidste demo" });
|
||||
await expect(panel).toBeVisible();
|
||||
const box = await panel.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
// A bottom sheet: anchored to the bottom of the viewport, not a full-height side panel.
|
||||
expect(box!.height).toBeLessThan(800);
|
||||
expect(box!.x).toBeLessThanOrEqual(1);
|
||||
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await expect(page.getByRole("heading", { name: "2. Open een boeking" })).toBeVisible();
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
});
|
||||
|
||||
test("demo guide does not cover the return form's action buttons on desktop", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
|
||||
const reviewButton = page.getByRole("button", { name: "Retour nakijken" });
|
||||
await expect(reviewButton).toBeVisible();
|
||||
await reviewButton.click({ timeout: 5000 });
|
||||
await expect(page.getByText(/Verwachte wagenparkstatus/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
|
||||
await guideTrigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
// The guide panel itself doesn't bind Escape (it's a persistent panel, not a transient
|
||||
// popover), so close it explicitly the way a keyboard user would: activate its own
|
||||
// close control.
|
||||
await page.getByRole("button", { name: "Sluiten" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
|
||||
const badgeTrigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||
await badgeTrigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("key demo pages load without console errors", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() !== "error") return;
|
||||
// The app deliberately probes GET /demo/session on every load to confirm whether a
|
||||
// session cookie is still valid (see AuthContext.tsx); a logged-out visitor's very
|
||||
// first load always logs one benign 401 for this, which the app already handles via
|
||||
// .catch() -- it is not an application error.
|
||||
if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return;
|
||||
errors.push(msg.text());
|
||||
});
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/scenarios");
|
||||
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
||||
await page.goto("/about");
|
||||
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
|
||||
expect(errors, `Unexpected console errors: ${errors.join("\n")}`).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("demo entry screen names the fictional org and never shows a password", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Verken als Operations Manager" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Verken als Rental Employee" })).toBeVisible();
|
||||
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("start guided demo logs in as Operations Manager and opens the guide at step 1", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
// The ?guide=start marker is a one-shot signal the dashboard strips immediately after
|
||||
// consuming it, so assert on its effect (the guide panel opens at step 1) rather than
|
||||
// the transient URL, which can already be gone by the time this assertion runs.
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByText("Amelie De Ridder")).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||
await expect(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
|
||||
await expect(page.getByText(/Laatste reset:/)).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: /Over deze demo/ }).click();
|
||||
await expect(page).toHaveURL(/\/about$/);
|
||||
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
|
||||
await expect(page.getByText("Northstar Mobility").first()).toBeVisible();
|
||||
await expect(page.getByText("Demomodus").first()).toBeVisible();
|
||||
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("badge popover closes on Escape and outside click", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toBeHidden();
|
||||
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.mouse.click(10, 10);
|
||||
await expect(page.getByRole("dialog")).toBeHidden();
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/scenarios");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
||||
const cards = page.locator(".scenario-card");
|
||||
await expect(cards).toHaveCount(5);
|
||||
for (const label of ["Klaar voor demo"]) {
|
||||
await expect(page.getByText(label).first()).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText("Niet beschikbaar")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("starting a scenario navigates to its fixed record", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/scenarios");
|
||||
|
||||
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
|
||||
await duplicateCard.getByRole("link", { name: "Start scenario" }).click();
|
||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
|
||||
});
|
||||
|
||||
// The default Playwright viewport (1280x720) falls in the "standard desktop/tablet" tier
|
||||
// (see useViewportTier.ts): the guide is a floating, non-modal panel that auto-collapses
|
||||
// to a persistent progress chip the moment the visitor acts on "Ga naar deze stap".
|
||||
test("demo guide: navigating steps, jumping to a step collapses to a chip, and the chip reopens it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await expect(page.getByRole("heading", { name: "2. Open een boeking die aandacht nodig heeft" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
|
||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/knowledge$/);
|
||||
// Panel auto-collapses to a persistent chip -- it must never sit over the knowledge
|
||||
// page's primary "Ask" action after navigation.
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
const chip = page.getByRole("button", { name: /Demo-gids · stap 6 van 8/ });
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
await chip.click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the collapsed chip has its own close control, independent of reopening it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
const chip = page.getByRole("button", { name: /Demo-gids · stap/ });
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Sluiten" }).click();
|
||||
await expect(chip).toBeHidden();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("demo guide progress persists across navigation and the trigger shows it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await page.getByRole("button", { name: "Demo-gids inklappen" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /^Demo-gids/ }).first()).toContainText("2/8");
|
||||
|
||||
await page.goto("/vehicles");
|
||||
await page.getByRole("button", { name: /^Demo-gids/ }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("demo guide is not shown to a rental employee", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("restarting the demo from the guide resets data and returns to login", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("wide desktop viewport docks the guide as a rail that never collapses to a chip", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
const panel = page.locator(".demo-guide-panel.is-wide");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.locator(".demo-guide-chip")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
const panel = page.locator(".demo-guide-panel.is-mobile");
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveClass(/sheet-half/);
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
|
||||
await page.locator(".demo-guide-sheet-handle").click();
|
||||
await expect(panel).toHaveClass(/sheet-full/);
|
||||
await page.locator(".demo-guide-sheet-handle").click();
|
||||
await expect(panel).toHaveClass(/sheet-collapsed/);
|
||||
});
|
||||
|
||||
test("Escape collapses the standard-tier panel, then closes it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeHidden();
|
||||
});
|
||||
|
||||
test("going to a step scrolls, focuses and highlights the on-page target", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/knowledge$/);
|
||||
|
||||
const target = page.locator("#ask-heading");
|
||||
await expect(target).toBeFocused();
|
||||
await expect(target).toHaveClass(/demo-guide-highlight/);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
|
||||
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
|
||||
const odometerInput = page.getByLabel("Eindkilometerstand (km)");
|
||||
await expect(odometerInput).not.toHaveValue("");
|
||||
const prefilled = Number(await odometerInput.inputValue());
|
||||
expect(prefilled).toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole("button", { name: "Retour nakijken" }).click();
|
||||
await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Audit trail bekijken" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
||||
|
||||
await expect(page.getByText("Wat is er mis")).toBeVisible();
|
||||
await expect(page.getByText("Waarom dit belangrijk is")).toBeVisible();
|
||||
await expect(page.getByText(/waarschijnlijk dezelfde persoon/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality list can filter to demo scenarios only", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/data-quality");
|
||||
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const allRows = await page.locator(".data-table tbody tr").count();
|
||||
await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).check();
|
||||
const filteredRows = await page.locator(".data-table tbody tr").count();
|
||||
expect(filteredRows).toBeGreaterThan(0);
|
||||
expect(filteredRows).toBeLessThanOrEqual(allRows);
|
||||
const refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
||||
for (const ref of refs) {
|
||||
expect(ref.startsWith("DQ-DEMO-")).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/knowledge");
|
||||
|
||||
// The status badge and retrieval-flow diagram must name the actual active provider
|
||||
// honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is
|
||||
// allowed to mention RAGcore by name when explaining it isn't live yet.
|
||||
await expect(page.locator(".knowledge-status strong")).toHaveText("de demokennisbank");
|
||||
await expect(page.locator(".retrieval-flow")).toContainText("de demokennisbank");
|
||||
await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore");
|
||||
|
||||
await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
|
||||
});
|
||||
@@ -18,13 +18,13 @@ test("five-minute demo script end to end", async ({ page, request }) => {
|
||||
|
||||
await test.step("1. login as Operations Manager", async () => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByText(/Synthetic proof of concept/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
|
||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
await test.step("2. verify dashboard metrics are loaded", async () => {
|
||||
await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
|
||||
const metricValues = page.locator(".metric-cell dd");
|
||||
await expect(metricValues.first()).toBeVisible();
|
||||
const values = await metricValues.allTextContents();
|
||||
@@ -35,62 +35,61 @@ test("five-minute demo script end to end", async ({ page, request }) => {
|
||||
await test.step("3. open active demo booking", async () => {
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
|
||||
await expect(page.getByText("active", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("actief", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("4. register an odometer-regression return (S1)", async () => {
|
||||
const vehicleOdometerText = await page
|
||||
.locator(".detail-grid div", { hasText: "Start odometer" })
|
||||
.locator(".detail-grid div", { hasText: "Startkilometerstand" })
|
||||
.locator("dd")
|
||||
.textContent();
|
||||
const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10);
|
||||
const lowReading = Math.max(0, startOdometer - 500);
|
||||
|
||||
await page.getByLabel("End odometer (km)").fill(String(lowReading));
|
||||
await page.getByLabel("Fuel level (%)").fill("55");
|
||||
await page.getByRole("button", { name: "Review return" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Review return impact" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Confirm return" }).click();
|
||||
await page.getByLabel("Eindkilometerstand (km)").fill(String(lowReading));
|
||||
await page.getByLabel("Brandstofniveau (%)").fill("55");
|
||||
await page.getByRole("button", { name: "Retour nakijken" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Retourimpact nakijken" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("5. verify quality issue and queued automation event", async () => {
|
||||
await expect(page.getByText(/DQ-RET-|None created/)).toBeVisible();
|
||||
await expect(page.getByText(/Queued for delivery \(/)).toBeVisible();
|
||||
await expect(page.getByText(/DQ-RET-|Geen aangemaakt/)).toBeVisible();
|
||||
await expect(page.getByText(/Klaargezet voor verwerking \(/)).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("6. resolve the duplicate customer scenario (S2)", async () => {
|
||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
||||
await expect(page.getByRole("heading", { name: "Compare and merge" })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Merge into CUS-0012/ }).click();
|
||||
await page.getByRole("button", { name: "Yes, merge" }).click();
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Vergelijken en samenvoegen" })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Samenvoegen met CUS-0012/ }).click();
|
||||
await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
|
||||
await expect(page.locator(".badge.status-resolved")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("7. ask the damage question and inspect citations (S6)", async () => {
|
||||
await page.goto("/knowledge");
|
||||
await page
|
||||
.getByPlaceholder(/What must I do when a vehicle returns with damage/)
|
||||
.fill("What must I do when a vehicle returns with damage?");
|
||||
await page.getByRole("button", { name: "Ask" }).click();
|
||||
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
|
||||
await expect(page.getByText("Damage handling procedure").first()).toBeVisible();
|
||||
await expect(page.getByText("Vehicle return procedure").first()).toBeVisible();
|
||||
.getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/)
|
||||
.fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
|
||||
await page.getByRole("button", { name: "Vraag stellen" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
|
||||
await expect(page.getByText("Procedure schadeafhandeling").first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("8. inspect audit entries", async () => {
|
||||
await page.goto("/audit");
|
||||
await page.getByLabel("Action").fill("return_registered");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
await expect(page.getByText("return registered").first()).toBeVisible();
|
||||
await page.getByLabel("Actie").fill("return_registered");
|
||||
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
|
||||
await expect(page.getByText("Voertuigretour geregistreerd").first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("9. verify responsive navigation at mobile width", async () => {
|
||||
await page.setViewportSize({ width: 360, height: 800 });
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByText(/Synthetic demo data/).first()).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Overview" }).first()).toBeVisible();
|
||||
await expect(page.getByText(/Synthetische demo/).first()).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Overzicht" }).first()).toBeVisible();
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("full guided demo walkthrough, start to finish, restoring the environment after", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
// Wide desktop viewport: the guide docks as a rail and never auto-collapses to a chip
|
||||
// (see useViewportTier.ts), so this walkthrough can keep interacting with the panel
|
||||
// directly across every step -- the standard-tier auto-collapse behaviour itself is
|
||||
// covered separately in demo-guide.spec.ts.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
|
||||
await test.step("start the guided demo from the login screen", async () => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("step 1: understand the operational state", async () => {
|
||||
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 2: open the booking needing attention", async () => {
|
||||
await expect(page.getByRole("heading", { name: "2. Open een boeking" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/bookings\/BK-DEMO-RETURN$/);
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 3: process the return with the pre-filled odometer anomaly", async () => {
|
||||
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
|
||||
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retour nakijken" }).click();
|
||||
await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 4: handle the newly created data-quality issue", async () => {
|
||||
await expect(page).toHaveURL(/\/data-quality$/);
|
||||
await expect(page.getByRole("heading", { name: "4. Bekijk en behandel" })).toBeVisible();
|
||||
const firstIssueLink = page.locator(".data-table tbody tr").first().locator("a");
|
||||
await firstIssueLink.click();
|
||||
await expect(page.getByText("Wat is er mis")).toBeVisible();
|
||||
// The newest issue is the odometer regression this return just created.
|
||||
await page.getByRole("radio", { name: /Laatst bevestigde stand behouden/ }).check();
|
||||
await page.getByRole("button", { name: "Probleem oplossen" }).click();
|
||||
await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 5: review and merge the possible duplicate customer", async () => {
|
||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
|
||||
await expect(page.getByRole("heading", { name: "5. Beoordeel en behandel" })).toBeVisible();
|
||||
await page.getByRole("button", { name: /^Samenvoegen met/ }).click();
|
||||
await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
|
||||
await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 6: ask the procedure assistant a question", async () => {
|
||||
await expect(page).toHaveURL(/\/knowledge$/);
|
||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 7: check automation and the audit trail", async () => {
|
||||
await expect(page.getByRole("heading", { name: "7. Controleer automatisering" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/automation$/);
|
||||
// Plain-language status only -- never the raw backend state string (e.g. "degraded").
|
||||
await expect(page.locator(".integration-cards")).not.toContainText("degraded");
|
||||
await expect(page.locator(".integration-cards")).not.toContainText("no_evidence");
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 8: review what's real, simulated, or not yet connected", async () => {
|
||||
await expect(page.getByRole("heading", { name: "8. Bekijk wat echt is" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/about$/);
|
||||
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
|
||||
await expect(page.getByText("Demomodus", { exact: false }).first()).toBeVisible();
|
||||
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("restore the environment", async () => {
|
||||
await resetDemoData(request);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Pure Node-context checks (no browser needed): every locale must define exactly the
|
||||
// same set of translation keys. A missing key would otherwise silently fall back to
|
||||
// showing the raw key string in production -- this test makes that impossible to ship.
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
|
||||
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"];
|
||||
|
||||
function collectKeyPaths(value: unknown, prefix = ""): string[] {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return [prefix];
|
||||
}
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
|
||||
collectKeyPaths(nested, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
|
||||
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
const namespaces = fs
|
||||
.readdirSync(path.join(LOCALES_DIR, "nl-BE"))
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.replace(/\.json$/, ""));
|
||||
|
||||
test("every locale defines the same translation keys as nl-BE, for every namespace", () => {
|
||||
expect(namespaces.length).toBeGreaterThan(0);
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
const referenceKeys = collectKeyPaths(loadNamespace("nl-BE", namespace)).sort();
|
||||
|
||||
for (const language of LANGUAGES) {
|
||||
if (language === "nl-BE") continue;
|
||||
const keys = collectKeyPaths(loadNamespace(language, namespace)).sort();
|
||||
const missing = referenceKeys.filter((k) => !keys.includes(k));
|
||||
const extra = keys.filter((k) => !referenceKeys.includes(k));
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`${language}/${namespace}.json is missing keys present in nl-BE: ${missing.join(", ")}`,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
extra,
|
||||
`${language}/${namespace}.json has extra keys not present in nl-BE: ${extra.join(", ")}`,
|
||||
).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("no locale file contains an empty string value", () => {
|
||||
for (const language of LANGUAGES) {
|
||||
for (const namespace of namespaces) {
|
||||
const data = loadNamespace(language, namespace);
|
||||
const keys = collectKeyPaths(data);
|
||||
for (const keyPath of keys) {
|
||||
const value = keyPath.split(".").reduce<unknown>((acc, part) => {
|
||||
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
|
||||
return undefined;
|
||||
}, data);
|
||||
if (typeof value === "string") {
|
||||
expect(value.trim().length, `${language}/${namespace}.json:${keyPath} is empty`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -7,9 +7,13 @@ async function resetDemoData(request: APIRequestContext) {
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// This file's assertions were authored against the English UI copy; nl-BE is now the
|
||||
// app's default for a fresh session, so force English explicitly rather than rewriting
|
||||
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
@@ -52,7 +56,7 @@ test("vehicles page: free-text search actually filters the rendered rows", async
|
||||
const totalRows = await page.locator(".data-table tbody tr").count();
|
||||
expect(totalRows).toBeGreaterThan(1);
|
||||
|
||||
const searchBox = page.getByRole("form", { name: "Filter vehicles" }).getByLabel("Search");
|
||||
const searchBox = page.getByRole("form", { name: "Vehicle fleet" }).getByLabel("Search");
|
||||
await searchBox.fill("MO-001");
|
||||
await expect(async () => {
|
||||
const rows = await page.locator(".data-table tbody tr").count();
|
||||
@@ -140,7 +144,7 @@ test("data quality page: status and rule-type filters work", async ({ page }) =>
|
||||
);
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents();
|
||||
expect(rules.every((r) => r.includes("possible duplicate customer"))).toBeTruthy();
|
||||
expect(rules.every((r) => r.includes("Possible duplicate customer"))).toBeTruthy();
|
||||
|
||||
await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption("");
|
||||
await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved");
|
||||
@@ -159,7 +163,7 @@ test("data quality issue detail: defer and reject buttons work", async ({ page,
|
||||
await firstLink.click();
|
||||
await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Defer" }).click();
|
||||
await expect(page.getByText("deferred", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".badge.status-deferred")).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => {
|
||||
@@ -173,7 +177,7 @@ test("data quality: providing missing fields resolves a vehicle issue", async ({
|
||||
await page.getByLabel("Location").fill("Depot");
|
||||
await page.getByRole("button", { name: "Save and re-check" }).click();
|
||||
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => {
|
||||
@@ -184,7 +188,7 @@ test("data quality: resolving a booking overlap blocks one booking", async ({ pa
|
||||
await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check();
|
||||
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
|
||||
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
|
||||
expect((await booking.json()).status).toBe("blocked");
|
||||
});
|
||||
@@ -220,7 +224,7 @@ test("data quality: retaining canonical resolves an odometer regression issue",
|
||||
await page.getByRole("radio", { name: /Retain canonical/ }).check();
|
||||
await page.getByRole("button", { name: "Resolve issue" }).click();
|
||||
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => {
|
||||
@@ -255,11 +259,11 @@ test("automation page: status filter and retry button work", async ({ page, requ
|
||||
|
||||
test("audit page: action filter works", async ({ page }) => {
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
await expect(page.locator(".audit-group-list")).toBeVisible();
|
||||
await page.getByLabel("Action").fill("demo_login");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const actions = await page.locator(".data-table tbody tr td:nth-child(3)").allTextContents();
|
||||
expect(actions.every((a) => a.includes("demo login"))).toBeTruthy();
|
||||
await expect(page.locator(".audit-group").first()).toBeVisible();
|
||||
const headings = await page.locator(".audit-group-heading strong").allTextContents();
|
||||
expect(headings.every((a) => a === "Logged in")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("audit page: shows human-readable before/after and a safe entity link", async ({
|
||||
@@ -284,14 +288,14 @@ test("audit page: shows human-readable before/after and a safe entity link", asy
|
||||
|
||||
await page.goto("/audit");
|
||||
await page.getByLabel("Action").fill("return_registered");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const firstGroup = page.locator(".audit-group").first();
|
||||
await expect(firstGroup).toBeVisible();
|
||||
|
||||
const changeCell = page.locator(".data-table tbody tr").first().locator("td").nth(4);
|
||||
await expect(changeCell).toContainText("status");
|
||||
await expect(changeCell).toContainText("returned");
|
||||
const changeDiff = firstGroup.locator(".change-diff");
|
||||
await expect(changeDiff).toContainText(/status/i);
|
||||
await expect(changeDiff).toContainText("returned");
|
||||
|
||||
const entityCell = page.locator(".data-table tbody tr").first().locator("td").nth(3);
|
||||
await expect(entityCell.locator("a")).toHaveAttribute("href", /\/bookings\/BK-/);
|
||||
await expect(firstGroup.locator(".audit-group-meta a")).toHaveAttribute("href", /\/bookings\/BK-/);
|
||||
});
|
||||
|
||||
test("knowledge page: form submits and clears input", async ({ page }) => {
|
||||
@@ -340,7 +344,7 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
await page.getByRole("button", { name: "Open as Rental Employee" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
// Manager-only nav items are not shown at all, not merely disabled.
|
||||
@@ -352,16 +356,16 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
|
||||
// message as a defense-in-depth measure, not just a hidden button.
|
||||
await page.goto("/automation");
|
||||
await expect(
|
||||
page.getByText("Automation delivery status is visible to Operations Managers only."),
|
||||
page.getByText("Automation is visible to Operations Managers only.").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto("/data-quality");
|
||||
await expect(
|
||||
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only."),
|
||||
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only.").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto("/audit");
|
||||
await expect(page.getByText("Audit history is visible to Operations Managers only.")).toBeVisible();
|
||||
await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
|
||||
});
|
||||
@@ -374,7 +378,7 @@ test("operations manager can reset demo data and is returned to login", async ({
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
// The reset must not have affected the ability to log back in against fresh data.
|
||||
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
@@ -392,7 +396,7 @@ test("rental employee direct API access to manager-only endpoints is rejected",
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
await page.getByRole("button", { name: "Open as Rental Employee" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
// page.request shares the browser context's cookies, and (via the web container's
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
// Cross-checks the responsive shell across the exact breakpoint matrix from the polish
|
||||
// brief (1440x1000, 1280x800, 1024x768, 768x1024, 430x932, 390x844, 360x800) in all three
|
||||
// supported languages -- both that nothing overflows horizontally and that the localized
|
||||
// heading text still renders (rather than being silently truncated away).
|
||||
const BREAKPOINTS: { width: number; height: number }[] = [
|
||||
{ width: 1440, height: 1000 },
|
||||
{ width: 1280, height: 800 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 430, height: 932 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 360, height: 800 },
|
||||
];
|
||||
|
||||
const LANGUAGES: { code: string; attentionTitle: string }[] = [
|
||||
{ code: "nl-BE", attentionTitle: "Aandachtspunten" },
|
||||
{ code: "en-GB", attentionTitle: "Attention queue" },
|
||||
{ code: "fr-BE", attentionTitle: "File d'attention" },
|
||||
];
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await resetDemoData(request);
|
||||
});
|
||||
|
||||
for (const language of LANGUAGES) {
|
||||
test.describe(`${language.code}`, () => {
|
||||
for (const bp of BREAKPOINTS) {
|
||||
test(`no horizontal overflow at ${bp.width}x${bp.height}`, async ({ page }) => {
|
||||
await page.addInitScript(
|
||||
(lang) => localStorage.setItem("fleetops.language", lang),
|
||||
language.code,
|
||||
);
|
||||
await page.setViewportSize({ width: bp.width, height: bp.height });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: /begeleide demo|guided demo|démo guidée/i }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
|
||||
await expect(page.getByRole("heading", { name: language.attentionTitle })).toBeVisible();
|
||||
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
client: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(dimensions.scroll, `${language.code} @ ${bp.width}x${bp.height}`).toBeLessThanOrEqual(
|
||||
dimensions.client + 1,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5,10 +5,14 @@ async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
// This file's assertions were authored against the English UI copy; nl-BE is now the
|
||||
// app's default for a fresh session, so force English explicitly rather than rewriting
|
||||
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
@@ -22,7 +26,7 @@ test("control-centre shell exposes landmarks, persisted readiness and active nav
|
||||
|
||||
test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => {
|
||||
await page.keyboard.press("Control+k");
|
||||
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await expect(search).toBeFocused();
|
||||
await search.fill("MO-024");
|
||||
const result = page.getByRole("option", { name: /MO-024/ });
|
||||
@@ -33,7 +37,7 @@ test("global search supports its keyboard shortcut and finds a vehicle by refere
|
||||
});
|
||||
|
||||
test("global search supports arrow-key navigation and Enter to select", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await search.fill("fleet");
|
||||
await expect(page.getByRole("option", { name: /Fleet/ })).toBeVisible();
|
||||
await search.press("ArrowDown");
|
||||
@@ -42,7 +46,7 @@ test("global search supports arrow-key navigation and Enter to select", async ({
|
||||
});
|
||||
|
||||
test("global search shows a no-results state and closes on Escape", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await search.fill("zzz-nothing-matches-zzz");
|
||||
await expect(page.getByText(/No matches for/)).toBeVisible();
|
||||
await search.press("Escape");
|
||||
@@ -50,7 +54,7 @@ test("global search shows a no-results state and closes on Escape", async ({ pag
|
||||
});
|
||||
|
||||
test("global search finds a booking and a data-quality issue by reference", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await search.fill("BK-DEMO-RETURN");
|
||||
const bookingResult = page.getByRole("option", { name: /BK-DEMO-RETURN/ });
|
||||
await expect(bookingResult).toBeVisible();
|
||||
@@ -83,7 +87,7 @@ test("return review separates capture from irreversible commit", async ({ page }
|
||||
// The review step is server-evaluated (not client-guessed), so exactly one non-mutating
|
||||
// preview call is expected before any commit.
|
||||
expect(previewRequests).toBe(1);
|
||||
await expect(page.getByText("Queue n8n delivery after the local commit")).toBeVisible();
|
||||
await expect(page.getByText("Queue automation after the local commit")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Edit details" }).click();
|
||||
await expect(page.getByLabel("End odometer (km)")).toHaveValue("60000");
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="MobilityOps synthetic-data operations proof of concept" />
|
||||
<title>MobilityOps</title>
|
||||
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
|
||||
<title>Fleet Ops</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
"name": "mobilityops-web",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"i18next": "^26.3.6",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-router-dom": "7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -255,6 +257,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -1463,6 +1474,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://locize.com"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -1661,6 +1709,33 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
|
||||
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
@@ -1794,7 +1869,7 @@
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -1835,6 +1910,15 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^26.3.6",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-router-dom": "7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { DemoManifestProvider } from "./context/DemoManifestContext";
|
||||
import { DemoGuideProvider } from "./context/DemoGuideContext";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
import { Login } from "./pages/Login";
|
||||
@@ -13,33 +15,41 @@ import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
|
||||
import { Automation } from "./pages/Automation";
|
||||
import { Knowledge } from "./pages/Knowledge";
|
||||
import { Audit } from "./pages/Audit";
|
||||
import { AboutDemo } from "./pages/AboutDemo";
|
||||
import { Scenarios } from "./pages/Scenarios";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/vehicles" element={<Vehicles />} />
|
||||
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
|
||||
<Route path="/bookings" element={<Bookings />} />
|
||||
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
|
||||
<Route path="/data-quality" element={<DataQuality />} />
|
||||
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
|
||||
<Route path="/automation" element={<Automation />} />
|
||||
<Route path="/knowledge" element={<Knowledge />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
<DemoManifestProvider>
|
||||
<AuthProvider>
|
||||
<DemoGuideProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/vehicles" element={<Vehicles />} />
|
||||
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
|
||||
<Route path="/bookings" element={<Bookings />} />
|
||||
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
|
||||
<Route path="/data-quality" element={<DataQuality />} />
|
||||
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
|
||||
<Route path="/automation" element={<Automation />} />
|
||||
<Route path="/knowledge" element={<Knowledge />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
<Route path="/about" element={<AboutDemo />} />
|
||||
<Route path="/scenarios" element={<Scenarios />} />
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</DemoGuideProvider>
|
||||
</AuthProvider>
|
||||
</DemoManifestProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export interface DashboardMetrics {
|
||||
export interface AttentionItem {
|
||||
kind: string;
|
||||
severity: "low" | "medium" | "high";
|
||||
title: string;
|
||||
rule_type: string;
|
||||
detail: string;
|
||||
link_type: "vehicle" | "booking" | "customer";
|
||||
link_ref: string;
|
||||
@@ -252,6 +252,37 @@ export interface IntegrationStatus {
|
||||
mcp_hub: McpHubIntegrationStatus;
|
||||
}
|
||||
|
||||
export interface DemoScenario {
|
||||
id: string;
|
||||
estimated_minutes: number;
|
||||
required_roles: Role[];
|
||||
start_path: string;
|
||||
ready: boolean;
|
||||
blocked_reason_code: string | null;
|
||||
blocked_reason_params: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DemoIntegrationSummary {
|
||||
key: "n8n" | "ragcore" | "mcp_hub";
|
||||
status_code: string;
|
||||
detail_code: string;
|
||||
detail_params: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface DemoManifest {
|
||||
demo_mode: boolean;
|
||||
organization_name: string;
|
||||
timezone: string;
|
||||
synthetic_data: boolean;
|
||||
allow_reset: boolean;
|
||||
last_reset_at: string | null;
|
||||
anchor_date: string | null;
|
||||
guide_available: boolean;
|
||||
required_roles: Role[];
|
||||
scenarios: DemoScenario[];
|
||||
integrations: DemoIntegrationSummary[];
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
actor_type: string;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
|
||||
const label = severity === "high" ? "High" : severity === "medium" ? "Medium" : "Low";
|
||||
return <span className={`badge severity-${severity}`}>{label} severity</span>;
|
||||
const { t } = useTranslation("quality");
|
||||
return <span className={`badge severity-${severity}`}>{t(`severities.${severity}`)}</span>;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return <span className={`badge status-${status}`}>{status.replace(/_/g, " ")}</span>;
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
return <span className={`badge status-${status}`}>{label ?? status.replace(/_/g, " ")}</span>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { Icon } from "./Icons";
|
||||
|
||||
export function DemoBadge() {
|
||||
const { t } = useTranslation("demo");
|
||||
const { formatDateTime } = useLocaleFormat();
|
||||
const { manifest } = useDemoManifest();
|
||||
const [open, setOpen] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (boxRef.current && !boxRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="demo-badge" ref={boxRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="demo-badge-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Icon name="shield" />
|
||||
<span>{t("badge.trigger")}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="demo-badge-popover" role="dialog" aria-label={t("badge.dialogLabel")}>
|
||||
<button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label={t("badge.close")}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
<p>
|
||||
{manifest ? (
|
||||
<Trans i18nKey="badge.orgIntro" t={t} values={{ orgName: manifest.organization_name }} components={{ strong: <strong /> }} />
|
||||
) : (
|
||||
t("badge.orgIntroFallback")
|
||||
)}
|
||||
</p>
|
||||
<p>{t("badge.realWorkflows")}</p>
|
||||
{manifest && (
|
||||
<p className="demo-badge-reset">
|
||||
<Trans
|
||||
i18nKey="badge.lastReset"
|
||||
t={t}
|
||||
values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("badge.unknown") }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<Link to="/about" onClick={() => setOpen(false)}>
|
||||
{t("badge.aboutLink")} <Icon name="chevron" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useViewportTier } from "../hooks/useViewportTier";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { Icon } from "./Icons";
|
||||
|
||||
export function DemoGuideTrigger() {
|
||||
const { t } = useTranslation("demo");
|
||||
const { user } = useAuth();
|
||||
const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide();
|
||||
|
||||
if (user?.role !== "operations_manager") return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="demo-guide-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
onClick={toggleGuide}
|
||||
>
|
||||
<Icon name="spark" />
|
||||
<span>{t("guide.trigger")}</span>
|
||||
<span className="demo-guide-progress-pill">{completed.size}/{totalSteps}</span>
|
||||
<span className="visually-hidden">, {t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function highlightTarget(selector: string | undefined) {
|
||||
if (!selector) return;
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const previousTabIndex = el.getAttribute("tabindex");
|
||||
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
|
||||
el.focus({ preventScroll: true });
|
||||
el.classList.add("demo-guide-highlight");
|
||||
window.setTimeout(() => {
|
||||
el.classList.remove("demo-guide-highlight");
|
||||
if (previousTabIndex === null) el.removeAttribute("tabindex");
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
export function DemoGuide() {
|
||||
const { t } = useTranslation("demo");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { logout } = useAuth();
|
||||
const { manifest, refresh } = useDemoManifest();
|
||||
const tier = useViewportTier();
|
||||
const {
|
||||
open,
|
||||
closeGuide,
|
||||
currentIndex,
|
||||
completed,
|
||||
totalSteps,
|
||||
goToStep,
|
||||
completeAndAdvance,
|
||||
restart,
|
||||
collapsedToChip,
|
||||
setCollapsedToChip,
|
||||
} = useDemoGuide();
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
|
||||
const pendingTarget = useRef<string | null>(null);
|
||||
|
||||
const step = DEMO_GUIDE_STEPS[currentIndex];
|
||||
const isLastStep = currentIndex === totalSteps - 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
if (tier === "standard" && !collapsedToChip) {
|
||||
setCollapsedToChip(true);
|
||||
} else if (tier === "mobile" && mobileSheetState !== "collapsed") {
|
||||
setMobileSheetState("collapsed");
|
||||
} else {
|
||||
closeGuide();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
return () => document.removeEventListener("keydown", handleKeydown);
|
||||
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingTarget.current) return;
|
||||
const target = pendingTarget.current;
|
||||
pendingTarget.current = null;
|
||||
const raf = requestAnimationFrame(() => highlightTarget(target));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [location.pathname]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function goToStepRoute() {
|
||||
pendingTarget.current = step.target ?? null;
|
||||
navigate(step.route(manifest));
|
||||
if (tier === "standard") setCollapsedToChip(true);
|
||||
if (tier === "mobile") setMobileSheetState("collapsed");
|
||||
}
|
||||
|
||||
async function handleRestartDemo() {
|
||||
setResetError(null);
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.post("/api/v1/demo/reset");
|
||||
restart();
|
||||
refresh();
|
||||
closeGuide();
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (tier === "standard" && collapsedToChip) {
|
||||
return (
|
||||
<div className="demo-guide-chip">
|
||||
<button
|
||||
type="button"
|
||||
className="demo-guide-chip-expand"
|
||||
onClick={() => setCollapsedToChip(false)}
|
||||
aria-label={`${t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}, ${t("guide.expand")}`}
|
||||
>
|
||||
<Icon name="spark" />
|
||||
{t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}
|
||||
<Icon name="chevron" />
|
||||
</button>
|
||||
<button type="button" className="demo-guide-chip-close" onClick={closeGuide} aria-label={t("guide.close")}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panelClassName = [
|
||||
"demo-guide-panel",
|
||||
tier === "wide" ? "is-wide" : "",
|
||||
tier === "mobile" ? `is-mobile sheet-${mobileSheetState}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<aside className={panelClassName} role="dialog" aria-label={t("guide.dialogLabel")}>
|
||||
{tier === "mobile" && (
|
||||
<button
|
||||
type="button"
|
||||
className="demo-guide-sheet-handle"
|
||||
onClick={() =>
|
||||
setMobileSheetState((s) => (s === "collapsed" ? "half" : s === "half" ? "full" : "collapsed"))
|
||||
}
|
||||
aria-label={
|
||||
mobileSheetState === "full"
|
||||
? t("guide.collapse")
|
||||
: t("guide.expand")
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<header className="demo-guide-header">
|
||||
<div>
|
||||
<p className="demo-guide-kicker">{t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</p>
|
||||
<h2>{t(`guide.steps.${step.id}.title`)}</h2>
|
||||
</div>
|
||||
{tier !== "mobile" && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeGuide}
|
||||
aria-label={tier === "standard" ? t("guide.collapse") : t("guide.close")}
|
||||
>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{mobileSheetState !== "collapsed" && (
|
||||
<>
|
||||
<div className="demo-guide-progress-bar" aria-hidden="true">
|
||||
{DEMO_GUIDE_STEPS.map((s, index) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className={
|
||||
index === currentIndex ? "is-current" : completed.has(s.id) ? "is-done" : ""
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(mobileSheetState !== "half" || tier !== "mobile") && (
|
||||
<div className="demo-guide-body">
|
||||
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
|
||||
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
|
||||
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
|
||||
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tier !== "mobile" && (
|
||||
<nav className="demo-guide-steps" aria-label={t("guide.allStepsLabel")}>
|
||||
{DEMO_GUIDE_STEPS.map((s, index) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={index === currentIndex ? "is-current" : ""}
|
||||
onClick={() => goToStep(index)}
|
||||
>
|
||||
{completed.has(s.id) && <Icon name="check" />}
|
||||
{t(`guide.steps.${s.id}.title`)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||
|
||||
<footer className="demo-guide-footer">
|
||||
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
||||
{t("guide.goToStep")}
|
||||
</button>
|
||||
<button type="button" className="button button-primary" onClick={completeAndAdvance} disabled={isLastStep}>
|
||||
{t("guide.next")}
|
||||
</button>
|
||||
{tier !== "mobile" && (
|
||||
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
|
||||
{resetting ? t("guide.restarting") : t("guide.restart")}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
persistLanguage,
|
||||
SUPPORTED_LANGUAGES,
|
||||
type SupportedLanguage,
|
||||
} from "../i18n/config";
|
||||
|
||||
export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const current = (i18n.language as SupportedLanguage) || "nl-BE";
|
||||
|
||||
function handleChange(next: SupportedLanguage) {
|
||||
void i18n.changeLanguage(next);
|
||||
persistLanguage(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={`language-switcher ${compact ? "language-switcher-compact" : ""}`}>
|
||||
<span className="visually-hidden">{t("language.label")}</span>
|
||||
<select
|
||||
value={current}
|
||||
onChange={(event) => handleChange(event.target.value as SupportedLanguage)}
|
||||
aria-label={t("language.label")}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{t(`language.${lang}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import type { Role, SearchResultItem } from "../api/types";
|
||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||
import { DemoBadge } from "./DemoBadge";
|
||||
import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
|
||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||
vehicle: "fleet",
|
||||
@@ -14,34 +20,36 @@ const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
roles?: Role[];
|
||||
}
|
||||
|
||||
const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
|
||||
const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
|
||||
{
|
||||
label: "Operate",
|
||||
labelKey: "groups.operate",
|
||||
items: [
|
||||
{ to: "/dashboard", label: "Overview", shortLabel: "Overview", icon: "activity" },
|
||||
{ to: "/vehicles", label: "Fleet", shortLabel: "Fleet", icon: "fleet" },
|
||||
{ to: "/bookings", label: "Bookings", shortLabel: "Bookings", icon: "bookings" },
|
||||
{ to: "/data-quality", label: "Data quality", shortLabel: "Quality", icon: "quality", roles: ["operations_manager"] },
|
||||
{ to: "/dashboard", labelKey: "items.overview", icon: "activity" },
|
||||
{ to: "/vehicles", labelKey: "items.fleet", icon: "fleet" },
|
||||
{ to: "/bookings", labelKey: "items.bookings", icon: "bookings" },
|
||||
{ to: "/data-quality", labelKey: "items.quality", icon: "quality", roles: ["operations_manager"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Assure",
|
||||
labelKey: "groups.assure",
|
||||
items: [
|
||||
{ to: "/knowledge", label: "Knowledge", shortLabel: "Knowledge", icon: "knowledge" },
|
||||
{ to: "/automation", label: "Integrations", shortLabel: "Systems", icon: "integrations", roles: ["operations_manager"] },
|
||||
{ to: "/audit", label: "Audit trail", shortLabel: "Audit", icon: "audit", roles: ["operations_manager"] },
|
||||
{ to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" },
|
||||
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
|
||||
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const { t } = useTranslation(["navigation", "common", "auth"]);
|
||||
const { user, logout } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -129,7 +137,7 @@ export function Layout() {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(err instanceof ApiError ? err.message : "Could not reset demo data.");
|
||||
setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
|
||||
setResetConfirming(false);
|
||||
} finally {
|
||||
setResetting(false);
|
||||
@@ -164,23 +172,26 @@ export function Layout() {
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">Skip to main content</a>
|
||||
<a className="skip-link" href="#main-content">{t("skipToContent")}</a>
|
||||
|
||||
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
|
||||
<div className="brand-lockup">
|
||||
<BrandMark className="brand-mark" />
|
||||
<div><strong>MobilityOps</strong><span>Control centre</span></div>
|
||||
<div><strong>{t("common:appName")}</strong><span>{t("common:brandTagline")}</span></div>
|
||||
</div>
|
||||
<nav aria-label="Primary navigation">
|
||||
<div className="sidebar-language">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<nav aria-label={t("primaryNavLabel")}>
|
||||
{navGroups.map((group) => (
|
||||
<div className="nav-group" key={group.label}>
|
||||
<p>{group.label}</p>
|
||||
<div className="nav-group" key={group.labelKey}>
|
||||
<p>{t(group.labelKey)}</p>
|
||||
<ul>
|
||||
{group.items.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink to={item.to} onClick={() => setMobileOpen(false)}>
|
||||
<Icon name={item.icon} />
|
||||
<span>{item.label}</span>
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
@@ -190,23 +201,23 @@ export function Layout() {
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<span className="environment-dot" />
|
||||
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
|
||||
<div><strong>{t("sidebarEnvironment")}</strong><span>{t("sidebarEnvironmentDetail")}</span></div>
|
||||
</div>
|
||||
{user?.role === "operations_manager" && (
|
||||
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
||||
<div className="sidebar-reset">
|
||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||
{!resetConfirming ? (
|
||||
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
||||
Reset demo data
|
||||
{t("resetDemoData")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="confirm-bar" role="alertdialog" aria-label="Confirm demo reset">
|
||||
<p>All synthetic changes will be discarded and deterministic demo data restored. You will be signed out.</p>
|
||||
<div className="confirm-bar" role="alertdialog" aria-label={t("resetConfirmTitle")}>
|
||||
<p>{t("resetConfirmBody")}</p>
|
||||
<button type="button" onClick={handleDemoReset} disabled={resetting}>
|
||||
{resetting ? "Resetting…" : "Yes, reset"}
|
||||
{resetting ? t("resetting") : t("resetConfirmYes")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setResetConfirming(false)} disabled={resetting}>
|
||||
Cancel
|
||||
{t("resetCancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -214,16 +225,16 @@ export function Layout() {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{mobileOpen && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileOpen(false)} />}
|
||||
{mobileOpen && <button className="nav-scrim" aria-label={t("closeNavigation")} onClick={() => setMobileOpen(false)} />}
|
||||
|
||||
<div className="app-workspace">
|
||||
<div className={`app-workspace ${guideOpen ? "guide-open" : ""} ${guideOpen && guideCollapsed ? "guide-collapsed" : ""}`}>
|
||||
<header className="topbar">
|
||||
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
|
||||
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label={t("openNavigation")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<div className="global-search" role="search" ref={searchBox}>
|
||||
<Icon name="search" />
|
||||
<label className="visually-hidden" htmlFor="global-search-input">Search MobilityOps</label>
|
||||
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel")}</label>
|
||||
<input
|
||||
id="global-search-input"
|
||||
ref={searchInput}
|
||||
@@ -234,7 +245,7 @@ export function Layout() {
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeIndex >= 0 ? `search-result-${activeIndex}` : undefined}
|
||||
value={searchQuery}
|
||||
placeholder="Search fleet, booking or section…"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value);
|
||||
@@ -242,13 +253,13 @@ export function Layout() {
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<kbd>Ctrl K</kbd>
|
||||
<kbd>{t("searchShortcutHint")}</kbd>
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="search-results" id="global-search-results" role="listbox">
|
||||
{searchLoading && <p className="search-status">Searching…</p>}
|
||||
{!searchLoading && searchError && <p className="search-status">Search is unavailable right now.</p>}
|
||||
{searchLoading && <p className="search-status">{t("searchSearching")}</p>}
|
||||
{!searchLoading && searchError && <p className="search-status">{t("searchUnavailable")}</p>}
|
||||
{!searchLoading && !searchError && searchResults.length === 0 && (
|
||||
<p className="search-status">No matches for "{searchQuery.trim()}".</p>
|
||||
<p className="search-status">{t("searchNoResults", { query: searchQuery.trim() })}</p>
|
||||
)}
|
||||
{!searchLoading &&
|
||||
!searchError &&
|
||||
@@ -274,36 +285,40 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-meta">
|
||||
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
|
||||
<LanguageSwitcher compact />
|
||||
<DemoGuideTrigger />
|
||||
<DemoBadge />
|
||||
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
|
||||
{user && (
|
||||
<div className="operator">
|
||||
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
|
||||
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? "Operations manager" : "Rental employee"}</small></span>
|
||||
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
|
||||
</div>
|
||||
)}
|
||||
<button className="icon-button" type="button" onClick={handleLogout} aria-label="Switch role" title="Switch demo role">
|
||||
<button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
|
||||
<Icon name="logout" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p className="demo-banner"><Icon name="shield" /> Synthetic demo data · no real customer or vehicle information</p>
|
||||
<main id="main-content" tabIndex={-1}><Outlet /></main>
|
||||
<footer className="app-footer"><span>MobilityOps PoC</span><span>Europe/Brussels · Synthetic demo data</span></footer>
|
||||
<footer className="app-footer"><span>{t("common:footer.productLine")}</span><span>{t("common:footer.locale")}</span></footer>
|
||||
</div>
|
||||
|
||||
<nav className="mobile-nav" aria-label="Mobile navigation">
|
||||
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
|
||||
{mobileItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to}>
|
||||
<Icon name={item.icon} />
|
||||
<span>{item.shortLabel}</span>
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<button type="button" onClick={() => setMobileOpen(true)}>
|
||||
<Icon name="menu" />
|
||||
<span>More</span>
|
||||
<span>{t("more")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<DemoGuide />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Icon, type IconName } from "./Icons";
|
||||
|
||||
export function PageHeader({
|
||||
@@ -28,15 +29,17 @@ export function SectionHeading({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
headingId,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
headingId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<h2 id={headingId}>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
@@ -44,20 +47,22 @@ export function SectionHeading({
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ label = "Loading workspace…" }: { label?: string }) {
|
||||
export function LoadingState({ label }: { label?: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="state-panel" role="status">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<p>{label}</p>
|
||||
<p>{label ?? t("states.loadingDefault")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ message }: { message: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<Icon name="alert" />
|
||||
<div><strong>We couldn’t load this workspace.</strong><p>{message}</p></div>
|
||||
<div><strong>{t("states.errorTitle")}</strong><p>{message}</p></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page">
|
||||
<LoadingState label="Verifying session…" />
|
||||
<LoadingState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { Icon } from "./Icons";
|
||||
import { StatusBadge } from "./Badge";
|
||||
|
||||
@@ -13,16 +18,27 @@ function newIdempotencyKey(): string {
|
||||
}
|
||||
|
||||
export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) {
|
||||
const { t } = useTranslation("returns");
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
|
||||
const canSeeQualityIssue = user?.role === "operations_manager";
|
||||
|
||||
function continueDemo() {
|
||||
completeAndAdvance();
|
||||
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
|
||||
navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel return-result success-panel" aria-labelledby="return-result-heading" aria-live="polite">
|
||||
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">Committed locally</p><h2 id="return-result-heading">Return registered</h2></div></div>
|
||||
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">{t("result.committedLocally")}</p><h2 id="return-result-heading">{t("result.heading")}</h2></div></div>
|
||||
<dl className="detail-grid">
|
||||
<div><dt>Inspection</dt><dd>{result.inspection_ref}</dd></div>
|
||||
<div><dt>Resulting vehicle status</dt><dd><StatusBadge status={result.resulting_vehicle_status} /></dd></div>
|
||||
<div><dt>{t("result.inspection")}</dt><dd>{result.inspection_ref}</dd></div>
|
||||
<div><dt>{t("result.resultingStatus")}</dt><dd><StatusBadge status={result.resulting_vehicle_status} label={t(`fleet:statuses.${result.resulting_vehicle_status}`, { defaultValue: result.resulting_vehicle_status })} /></dd></div>
|
||||
<div>
|
||||
<dt>Quality issue</dt>
|
||||
<dt>{t("result.qualityIssue")}</dt>
|
||||
<dd>
|
||||
{result.quality_issue_ref ? (
|
||||
canSeeQualityIssue ? (
|
||||
@@ -31,36 +47,39 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
|
||||
result.quality_issue_ref
|
||||
)
|
||||
) : (
|
||||
"None created"
|
||||
t("result.noneCreated")
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Automation event</dt>
|
||||
<dd>
|
||||
Queued for delivery ({result.workflow_event_id.slice(0, 8)}) — local commit succeeded;
|
||||
n8n delivery is asynchronous and not yet confirmed.
|
||||
</dd>
|
||||
<dt>{t("result.automationEvent")}</dt>
|
||||
<dd>{t("result.queuedForDelivery", { ref: result.workflow_event_id.slice(0, 8) })}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Next booking risk</dt>
|
||||
<dt>{t("result.nextBookingRisk")}</dt>
|
||||
<dd>
|
||||
{result.next_booking_risk
|
||||
? `${result.next_booking_risk.booking_ref} ${result.next_booking_risk.at_risk ? "— may be affected" : "— low risk"}`
|
||||
: "No upcoming booking for this vehicle"}
|
||||
? t("result.nextBookingRiskValue", {
|
||||
ref: result.next_booking_risk.booking_ref,
|
||||
status: result.next_booking_risk.at_risk ? t("result.atRisk") : t("result.lowRisk"),
|
||||
})
|
||||
: t("result.noUpcomingBooking")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{result.odometer_regression && (
|
||||
<p className="error" role="alert">
|
||||
The submitted odometer reading was below the vehicle's canonical odometer. It was
|
||||
recorded as-is; the canonical odometer was not changed, and a data-quality issue was
|
||||
opened for review.
|
||||
</p>
|
||||
<p className="error" role="alert">{t("result.odometerRegressionNotice")}</p>
|
||||
)}
|
||||
<p>
|
||||
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>View vehicle {result.vehicle_ref}<Icon name="chevron" /></Link>
|
||||
</p>
|
||||
<div className="result-links">
|
||||
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>{t("result.viewVehicle", { ref: result.vehicle_ref })}<Icon name="chevron" /></Link>
|
||||
<Link className="button button-secondary" to="/automation">{t("result.viewAutomation")}<Icon name="chevron" /></Link>
|
||||
<Link className="button button-secondary" to="/audit">{t("result.viewAudit")}<Icon name="chevron" /></Link>
|
||||
{guideOpen && (
|
||||
<button type="button" className="button button-primary" onClick={continueDemo}>
|
||||
{t("result.continueDemo")} <Icon name="chevron" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -68,11 +87,15 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
|
||||
export function ReturnForm({
|
||||
bookingRef,
|
||||
onRegistered,
|
||||
suggestedOdometerKm,
|
||||
}: {
|
||||
bookingRef: string;
|
||||
onRegistered: (result: RegisterReturnResult) => void;
|
||||
suggestedOdometerKm?: number;
|
||||
}) {
|
||||
const [odometer, setOdometer] = useState("");
|
||||
const { t } = useTranslation(["returns", "errors"]);
|
||||
const { formatNumber, formatDateTime } = useLocaleFormat();
|
||||
const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : "");
|
||||
const [fuel, setFuel] = useState("50");
|
||||
const [cleanlinessOk, setCleanlinessOk] = useState(true);
|
||||
const [damageReported, setDamageReported] = useState(false);
|
||||
@@ -109,9 +132,7 @@ export function ReturnForm({
|
||||
setPreview(evaluated);
|
||||
setStep("review");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : "Could not evaluate this return. Please try again.",
|
||||
);
|
||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
@@ -127,11 +148,7 @@ export function ReturnForm({
|
||||
);
|
||||
onRegistered(registered);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("Could not register the return. Please try again.");
|
||||
}
|
||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -139,13 +156,13 @@ export function ReturnForm({
|
||||
|
||||
return (
|
||||
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
|
||||
<div className="return-progress" aria-label="Return registration progress"><span className="is-complete"><i>1</i> Capture</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> Review</span><b /><span><i>3</i> Result</span></div>
|
||||
<div className="section-heading"><div><p className="page-eyebrow">Booking {bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? "Register vehicle return" : "Review return impact"}</h2><p>{step === "capture" ? "Record the hand-back condition. The next step evaluates the exact operational consequences before anything is committed." : "This is the server's authoritative evaluation of what committing will do — confirm before it updates fleet state and queues automation."}</p></div></div>
|
||||
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
|
||||
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
|
||||
{step === "capture" ? <div className="return-capture">
|
||||
<div className="form-grid"><label>
|
||||
End odometer (km)
|
||||
{t("capture.endOdometer")}
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
@@ -156,7 +173,7 @@ export function ReturnForm({
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Fuel level (%)
|
||||
{t("capture.fuelLevel")}
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
@@ -167,13 +184,13 @@ export function ReturnForm({
|
||||
/>
|
||||
</label></div>
|
||||
|
||||
<fieldset className="condition-fieldset"><legend>Vehicle condition</legend><label className="checkbox-label check-card">
|
||||
<fieldset className="condition-fieldset"><legend>{t("capture.conditionLegend")}</legend><label className="checkbox-label check-card">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cleanlinessOk}
|
||||
onChange={(e) => setCleanlinessOk(e.target.checked)}
|
||||
/>
|
||||
Cleanliness acceptable
|
||||
{t("capture.cleanlinessOk")}
|
||||
</label>
|
||||
|
||||
<label className="checkbox-label check-card">
|
||||
@@ -182,7 +199,7 @@ export function ReturnForm({
|
||||
checked={damageReported}
|
||||
onChange={(e) => setDamageReported(e.target.checked)}
|
||||
/>
|
||||
Damage reported
|
||||
{t("capture.damageReported")}
|
||||
</label>
|
||||
|
||||
<label className="checkbox-label check-card">
|
||||
@@ -191,48 +208,50 @@ export function ReturnForm({
|
||||
checked={technicalWarning}
|
||||
onChange={(e) => setTechnicalWarning(e.target.checked)}
|
||||
/>
|
||||
Technical warning
|
||||
{t("capture.technicalWarning")}
|
||||
</label></fieldset>
|
||||
|
||||
<label>
|
||||
Notes
|
||||
{t("capture.notes")}
|
||||
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} rows={3} />
|
||||
</label></div> : preview && <div className="return-review" aria-live="polite">
|
||||
<dl className="review-facts"><div><dt>Odometer</dt><dd>{Number(odometer).toLocaleString("en-GB")} km</dd></div><div><dt>Fuel</dt><dd>{fuel}%</dd></div><div><dt>Cleanliness</dt><dd>{cleanlinessOk ? "Accepted" : "Follow-up needed"}</dd></div><div><dt>Damage</dt><dd>{damageReported ? "Reported" : "None reported"}</dd></div><div><dt>Technical warning</dt><dd>{technicalWarning ? "Reported" : "None reported"}</dd></div></dl>
|
||||
<dl className="review-facts"><div><dt>{t("review.odometer")}</dt><dd>{formatNumber(Number(odometer))} km</dd></div><div><dt>{t("review.fuel")}</dt><dd>{fuel}%</dd></div><div><dt>{t("review.cleanliness")}</dt><dd>{cleanlinessOk ? t("review.cleanlinessAccepted") : t("review.cleanlinessFollowUp")}</dd></div><div><dt>{t("review.damage")}</dt><dd>{damageReported ? t("review.damageReported") : t("review.damageNone")}</dd></div><div><dt>{t("review.technicalWarning")}</dt><dd>{technicalWarning ? t("review.technicalWarningReported") : t("review.technicalWarningNone")}</dd></div></dl>
|
||||
<div className={`impact-preview ${preview.attention_reasons.length > 0 ? "impact-warning" : "impact-ready"}`}>
|
||||
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
|
||||
<div>
|
||||
<strong>Expected fleet state: <StatusBadge status={preview.resulting_vehicle_status} /></strong>
|
||||
<strong>{t("review.expectedState")} <StatusBadge status={preview.resulting_vehicle_status} label={t(`fleet:statuses.${preview.resulting_vehicle_status}`, { defaultValue: preview.resulting_vehicle_status })} /></strong>
|
||||
<p>{preview.status_reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
{preview.odometer_regression && (
|
||||
<p className="error" role="alert">
|
||||
Submitted odometer ({preview.submitted_odometer_km.toLocaleString("en-GB")} km) is below
|
||||
the canonical reading ({preview.canonical_odometer_km.toLocaleString("en-GB")} km). The
|
||||
canonical odometer will not change, and a data-quality issue will be opened.
|
||||
{t("review.odometerRegressionWarning", {
|
||||
submitted: formatNumber(preview.submitted_odometer_km),
|
||||
canonical: formatNumber(preview.canonical_odometer_km),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{preview.next_booking_risk && (
|
||||
<p className={preview.next_booking_risk.at_risk ? "error" : undefined} role={preview.next_booking_risk.at_risk ? "alert" : undefined}>
|
||||
Next booking {preview.next_booking_risk.booking_ref} starts{" "}
|
||||
{new Date(preview.next_booking_risk.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
|
||||
{preview.next_booking_risk.at_risk ? " — may be affected by this return." : " — low risk."}
|
||||
{t(preview.next_booking_risk.at_risk ? "review.nextBookingRisk" : "review.nextBookingLowRisk", {
|
||||
ref: preview.next_booking_risk.booking_ref,
|
||||
when: formatDateTime(preview.next_booking_risk.starts_at),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<ul className="commit-list"><li><Icon name="check" /> Create a return inspection</li><li><Icon name="check" /> Update the booking and vehicle atomically</li><li><Icon name="check" /> Queue n8n delivery after the local commit</li></ul>
|
||||
<ul className="commit-list"><li><Icon name="check" /> {t("review.commitList.inspection")}</li><li><Icon name="check" /> {t("review.commitList.updateAtomic")}</li><li><Icon name="check" /> {t("review.commitList.queueAutomation")}</li></ul>
|
||||
</div>}
|
||||
|
||||
<div className="form-actions">
|
||||
{step === "review" && <button className="button button-secondary" type="button" onClick={() => setStep("capture")} disabled={submitting}><Icon name="arrow-left" /> Edit details</button>}
|
||||
{step === "review" && <button className="button button-secondary" type="button" onClick={() => setStep("capture")} disabled={submitting}><Icon name="arrow-left" /> {t("review.editDetails")}</button>}
|
||||
<button className="button button-primary" type="submit" disabled={submitting || previewing}>
|
||||
{step === "capture"
|
||||
? previewing
|
||||
? "Evaluating…"
|
||||
: "Review return"
|
||||
? t("capture.evaluating")
|
||||
: t("capture.reviewReturn")
|
||||
: submitting
|
||||
? "Registering…"
|
||||
: "Confirm return"}{" "}
|
||||
? t("review.registering")
|
||||
: t("review.confirmReturn")}{" "}
|
||||
{step === "capture" && !previewing && <Icon name="chevron" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
|
||||
const STORAGE_KEY = "mobilityops.demo-guide-progress";
|
||||
|
||||
interface StoredProgress {
|
||||
currentIndex: number;
|
||||
completed: string[];
|
||||
}
|
||||
|
||||
function readProgress(): StoredProgress {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { currentIndex: 0, completed: [] };
|
||||
const parsed = JSON.parse(raw) as StoredProgress;
|
||||
return { currentIndex: parsed.currentIndex ?? 0, completed: parsed.completed ?? [] };
|
||||
} catch {
|
||||
return { currentIndex: 0, completed: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function writeProgress(progress: StoredProgress) {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
|
||||
}
|
||||
|
||||
interface DemoGuideState {
|
||||
open: boolean;
|
||||
currentIndex: number;
|
||||
completed: Set<string>;
|
||||
totalSteps: number;
|
||||
collapsedToChip: boolean;
|
||||
setCollapsedToChip: (value: boolean) => void;
|
||||
openGuide: () => void;
|
||||
closeGuide: () => void;
|
||||
toggleGuide: () => void;
|
||||
goToStep: (index: number) => void;
|
||||
completeAndAdvance: () => void;
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
const DemoGuideContext = createContext<DemoGuideState | undefined>(undefined);
|
||||
|
||||
export function DemoGuideProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [completed, setCompleted] = useState<Set<string>>(new Set());
|
||||
const [collapsedToChip, setCollapsedToChip] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readProgress();
|
||||
setCurrentIndex(stored.currentIndex);
|
||||
setCompleted(new Set(stored.completed));
|
||||
}, []);
|
||||
|
||||
const persist = useCallback((index: number, done: Set<string>) => {
|
||||
writeProgress({ currentIndex: index, completed: Array.from(done) });
|
||||
}, []);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(index: number) => {
|
||||
const clamped = Math.max(0, Math.min(index, DEMO_GUIDE_STEPS.length - 1));
|
||||
setCurrentIndex(clamped);
|
||||
persist(clamped, completed);
|
||||
},
|
||||
[completed, persist],
|
||||
);
|
||||
|
||||
const completeAndAdvance = useCallback(() => {
|
||||
setCompleted((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(DEMO_GUIDE_STEPS[currentIndex].id);
|
||||
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
|
||||
persist(nextIndex, next);
|
||||
setCurrentIndex(nextIndex);
|
||||
return next;
|
||||
});
|
||||
}, [currentIndex, persist]);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
setCurrentIndex(0);
|
||||
setCompleted(new Set());
|
||||
writeProgress({ currentIndex: 0, completed: [] });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DemoGuideContext.Provider
|
||||
value={{
|
||||
open,
|
||||
currentIndex,
|
||||
completed,
|
||||
totalSteps: DEMO_GUIDE_STEPS.length,
|
||||
collapsedToChip,
|
||||
setCollapsedToChip,
|
||||
openGuide: () => {
|
||||
setCollapsedToChip(false);
|
||||
setOpen(true);
|
||||
},
|
||||
closeGuide: () => setOpen(false),
|
||||
toggleGuide: () => {
|
||||
setCollapsedToChip(false);
|
||||
setOpen((v) => !v);
|
||||
},
|
||||
goToStep,
|
||||
completeAndAdvance,
|
||||
restart,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DemoGuideContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDemoGuide(): DemoGuideState {
|
||||
const ctx = useContext(DemoGuideContext);
|
||||
if (!ctx) throw new Error("useDemoGuide must be used within DemoGuideProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { DemoManifest } from "../api/types";
|
||||
|
||||
interface DemoManifestState {
|
||||
manifest: DemoManifest | null;
|
||||
loading: boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const DemoManifestContext = createContext<DemoManifestState | undefined>(undefined);
|
||||
|
||||
export function DemoManifestProvider({ children }: { children: ReactNode }) {
|
||||
const [manifest, setManifest] = useState<DemoManifest | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
// Public endpoint by design: the demo-entry screen needs this before any session
|
||||
// exists, so it is never gated behind auth.
|
||||
api
|
||||
.get<DemoManifest>("/api/v1/demo/manifest")
|
||||
.then((result) => {
|
||||
if (!cancelled) setManifest(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setManifest(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [version]);
|
||||
|
||||
return (
|
||||
<DemoManifestContext.Provider
|
||||
value={{ manifest, loading, refresh: () => setVersion((v) => v + 1) }}
|
||||
>
|
||||
{children}
|
||||
</DemoManifestContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDemoManifest(): DemoManifestState {
|
||||
const ctx = useContext(DemoManifestContext);
|
||||
if (!ctx) throw new Error("useDemoManifest must be used within DemoManifestProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { DemoManifest } from "../api/types";
|
||||
|
||||
export interface DemoGuideStep {
|
||||
id: string;
|
||||
/** Resolves the route to link to, using live manifest data where a scenario supplies
|
||||
* a concrete record (booking/issue ref) instead of hardcoding one that could drift. */
|
||||
route: (manifest: DemoManifest | null) => string;
|
||||
/** CSS selector for the on-page element this step is about. After navigating, the guide
|
||||
* scrolls it into view, moves keyboard focus to it and applies a brief highlight -- this
|
||||
* only works because the target already carries a stable id/class in the page itself, so
|
||||
* a missing target (selector not found post-navigation) is a silent no-op, never an error. */
|
||||
target?: string;
|
||||
}
|
||||
|
||||
export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [
|
||||
{ id: "understand-state", route: () => "/dashboard", target: "#attention-heading" },
|
||||
{
|
||||
id: "open-booking",
|
||||
route: (manifest) =>
|
||||
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings",
|
||||
target: ".record-surface",
|
||||
},
|
||||
{
|
||||
id: "process-return",
|
||||
route: (manifest) =>
|
||||
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings",
|
||||
target: "#return-form-heading",
|
||||
},
|
||||
{ id: "handle-quality-issue", route: () => "/data-quality", target: ".table-shell" },
|
||||
{
|
||||
id: "merge-duplicate",
|
||||
route: (manifest) =>
|
||||
manifest?.scenarios.find((s) => s.id === "duplicate-customer")?.start_path ?? "/data-quality",
|
||||
target: "#compare-heading",
|
||||
},
|
||||
{ id: "ask-knowledge", route: () => "/knowledge", target: "#ask-heading" },
|
||||
{ id: "check-automation-audit", route: () => "/automation", target: ".integration-cards" },
|
||||
{ id: "review-real-vs-simulated", route: () => "/about", target: ".about-cta" },
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { IntegrationStatus } from "../api/types";
|
||||
|
||||
// Plain-language status per section 12 of the demo brief: a visitor shouldn't have to
|
||||
// decode raw backend state strings to tell whether an integration is actually working.
|
||||
// `statusClass` maps onto an existing `.status-*` CSS modifier so the badge keeps
|
||||
// correct colour-coding; `labelKey` resolves to a localized label via the
|
||||
// `integrations:statusLabels.*` namespace (see i18n/locales/*/integrations.json).
|
||||
export const N8N_STATE_META: Record<IntegrationStatus["n8n"]["state"], { statusClass: string; labelKey: string }> = {
|
||||
disabled: { statusClass: "not_configured", labelKey: "notConnected" },
|
||||
unavailable: { statusClass: "unavailable", labelKey: "deliveryFailed" },
|
||||
degraded: { statusClass: "needs_attention", labelKey: "retryAvailable" },
|
||||
operational: { statusClass: "available", labelKey: "operational" },
|
||||
no_evidence: { statusClass: "no_events", labelKey: "prepared" },
|
||||
};
|
||||
|
||||
export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { statusClass: string; labelKey: string }> = {
|
||||
not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
|
||||
configured: { statusClass: "no_events", labelKey: "prepared" },
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type ViewportTier = "wide" | "standard" | "mobile";
|
||||
|
||||
// Breakpoints reuse the app's existing, already-measured layout thresholds (see
|
||||
// styles.css): 700px is where the sidebar collapses into the mobile bottom nav, and
|
||||
// 1440px is the largest width in the project's own responsive test matrix -- chosen as
|
||||
// the "extra-wide desktop" floor rather than an arbitrary new number.
|
||||
const MOBILE_QUERY = "(max-width: 700px)";
|
||||
const WIDE_QUERY = "(min-width: 1440px)";
|
||||
|
||||
function computeTier(): ViewportTier {
|
||||
if (typeof window === "undefined") return "standard";
|
||||
if (window.matchMedia(MOBILE_QUERY).matches) return "mobile";
|
||||
if (window.matchMedia(WIDE_QUERY).matches) return "wide";
|
||||
return "standard";
|
||||
}
|
||||
|
||||
export function useViewportTier(): ViewportTier {
|
||||
const [tier, setTier] = useState<ViewportTier>(computeTier);
|
||||
|
||||
useEffect(() => {
|
||||
const mobile = window.matchMedia(MOBILE_QUERY);
|
||||
const wide = window.matchMedia(WIDE_QUERY);
|
||||
const update = () => setTier(computeTier());
|
||||
mobile.addEventListener("change", update);
|
||||
wide.addEventListener("change", update);
|
||||
update();
|
||||
return () => {
|
||||
mobile.removeEventListener("change", update);
|
||||
wide.removeEventListener("change", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return tier;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import commonNl from "./locales/nl-BE/common.json";
|
||||
import authNl from "./locales/nl-BE/auth.json";
|
||||
import navigationNl from "./locales/nl-BE/navigation.json";
|
||||
import dashboardNl from "./locales/nl-BE/dashboard.json";
|
||||
import fleetNl from "./locales/nl-BE/fleet.json";
|
||||
import bookingsNl from "./locales/nl-BE/bookings.json";
|
||||
import returnsNl from "./locales/nl-BE/returns.json";
|
||||
import qualityNl from "./locales/nl-BE/quality.json";
|
||||
import knowledgeNl from "./locales/nl-BE/knowledge.json";
|
||||
import integrationsNl from "./locales/nl-BE/integrations.json";
|
||||
import auditNl from "./locales/nl-BE/audit.json";
|
||||
import demoNl from "./locales/nl-BE/demo.json";
|
||||
import errorsNl from "./locales/nl-BE/errors.json";
|
||||
import accessibilityNl from "./locales/nl-BE/accessibility.json";
|
||||
|
||||
import commonEn from "./locales/en-GB/common.json";
|
||||
import authEn from "./locales/en-GB/auth.json";
|
||||
import navigationEn from "./locales/en-GB/navigation.json";
|
||||
import dashboardEn from "./locales/en-GB/dashboard.json";
|
||||
import fleetEn from "./locales/en-GB/fleet.json";
|
||||
import bookingsEn from "./locales/en-GB/bookings.json";
|
||||
import returnsEn from "./locales/en-GB/returns.json";
|
||||
import qualityEn from "./locales/en-GB/quality.json";
|
||||
import knowledgeEn from "./locales/en-GB/knowledge.json";
|
||||
import integrationsEn from "./locales/en-GB/integrations.json";
|
||||
import auditEn from "./locales/en-GB/audit.json";
|
||||
import demoEn from "./locales/en-GB/demo.json";
|
||||
import errorsEn from "./locales/en-GB/errors.json";
|
||||
import accessibilityEn from "./locales/en-GB/accessibility.json";
|
||||
|
||||
import commonFr from "./locales/fr-BE/common.json";
|
||||
import authFr from "./locales/fr-BE/auth.json";
|
||||
import navigationFr from "./locales/fr-BE/navigation.json";
|
||||
import dashboardFr from "./locales/fr-BE/dashboard.json";
|
||||
import fleetFr from "./locales/fr-BE/fleet.json";
|
||||
import bookingsFr from "./locales/fr-BE/bookings.json";
|
||||
import returnsFr from "./locales/fr-BE/returns.json";
|
||||
import qualityFr from "./locales/fr-BE/quality.json";
|
||||
import knowledgeFr from "./locales/fr-BE/knowledge.json";
|
||||
import integrationsFr from "./locales/fr-BE/integrations.json";
|
||||
import auditFr from "./locales/fr-BE/audit.json";
|
||||
import demoFr from "./locales/fr-BE/demo.json";
|
||||
import errorsFr from "./locales/fr-BE/errors.json";
|
||||
import accessibilityFr from "./locales/fr-BE/accessibility.json";
|
||||
|
||||
export const SUPPORTED_LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
|
||||
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
export const DEFAULT_LANGUAGE: SupportedLanguage = "nl-BE";
|
||||
export const LANGUAGE_STORAGE_KEY = "fleetops.language";
|
||||
|
||||
export const NAMESPACES = [
|
||||
"common",
|
||||
"auth",
|
||||
"navigation",
|
||||
"dashboard",
|
||||
"fleet",
|
||||
"bookings",
|
||||
"returns",
|
||||
"quality",
|
||||
"knowledge",
|
||||
"integrations",
|
||||
"audit",
|
||||
"demo",
|
||||
"errors",
|
||||
"accessibility",
|
||||
] as const;
|
||||
|
||||
function readStoredLanguage(): SupportedLanguage {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||
if (stored && (SUPPORTED_LANGUAGES as readonly string[]).includes(stored)) {
|
||||
return stored as SupportedLanguage;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (e.g. private-mode edge cases) -- fall back to default.
|
||||
}
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
export function persistLanguage(language: SupportedLanguage): void {
|
||||
try {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||
} catch {
|
||||
// Best effort only; the in-memory i18next language still changes for this session.
|
||||
}
|
||||
}
|
||||
|
||||
void i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: readStoredLanguage(),
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
supportedLngs: SUPPORTED_LANGUAGES,
|
||||
ns: NAMESPACES,
|
||||
defaultNS: "common",
|
||||
// A missing key must never render silently as empty/blank text -- surface it loudly
|
||||
// (the key itself) so it's caught immediately in development and by the
|
||||
// translation-coverage test, rather than shipping a blank UI label.
|
||||
returnEmptyString: false,
|
||||
interpolation: { escapeValue: false },
|
||||
resources: {
|
||||
"nl-BE": {
|
||||
common: commonNl,
|
||||
auth: authNl,
|
||||
navigation: navigationNl,
|
||||
dashboard: dashboardNl,
|
||||
fleet: fleetNl,
|
||||
bookings: bookingsNl,
|
||||
returns: returnsNl,
|
||||
quality: qualityNl,
|
||||
knowledge: knowledgeNl,
|
||||
integrations: integrationsNl,
|
||||
audit: auditNl,
|
||||
demo: demoNl,
|
||||
errors: errorsNl,
|
||||
accessibility: accessibilityNl,
|
||||
},
|
||||
"en-GB": {
|
||||
common: commonEn,
|
||||
auth: authEn,
|
||||
navigation: navigationEn,
|
||||
dashboard: dashboardEn,
|
||||
fleet: fleetEn,
|
||||
bookings: bookingsEn,
|
||||
returns: returnsEn,
|
||||
quality: qualityEn,
|
||||
knowledge: knowledgeEn,
|
||||
integrations: integrationsEn,
|
||||
audit: auditEn,
|
||||
demo: demoEn,
|
||||
errors: errorsEn,
|
||||
accessibility: accessibilityEn,
|
||||
},
|
||||
"fr-BE": {
|
||||
common: commonFr,
|
||||
auth: authFr,
|
||||
navigation: navigationFr,
|
||||
dashboard: dashboardFr,
|
||||
fleet: fleetFr,
|
||||
bookings: bookingsFr,
|
||||
returns: returnsFr,
|
||||
quality: qualityFr,
|
||||
knowledge: knowledgeFr,
|
||||
integrations: integrationsFr,
|
||||
audit: auditFr,
|
||||
demo: demoFr,
|
||||
errors: errorsFr,
|
||||
accessibility: accessibilityFr,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
document.documentElement.lang = i18n.language;
|
||||
i18n.on("languageChanged", (lng) => {
|
||||
document.documentElement.lang = lng;
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const TIME_ZONE = "Europe/Brussels";
|
||||
|
||||
export function useLocaleFormat() {
|
||||
const { i18n } = useTranslation();
|
||||
const locale = i18n.language || "nl-BE";
|
||||
|
||||
return {
|
||||
locale,
|
||||
formatDate(value: string | Date): string {
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: TIME_ZONE }).format(
|
||||
new Date(value),
|
||||
);
|
||||
},
|
||||
formatShortDate(value: string | Date): string {
|
||||
return new Intl.DateTimeFormat(locale, { day: "2-digit", month: "short", timeZone: TIME_ZONE }).format(
|
||||
new Date(value),
|
||||
);
|
||||
},
|
||||
formatDateTime(value: string | Date): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: TIME_ZONE,
|
||||
}).format(new Date(value));
|
||||
},
|
||||
formatTime(value: string | Date): string {
|
||||
return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone: TIME_ZONE }).format(
|
||||
new Date(value),
|
||||
);
|
||||
},
|
||||
formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat(locale).format(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"openRow": "Open: {{label}}",
|
||||
"reducedMotion": "Reduced motion active",
|
||||
"closeDialog": "Close dialog",
|
||||
"expandSection": "Expand section",
|
||||
"collapseSection": "Collapse section"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"eyebrow": "Assure / Immutable history",
|
||||
"title": "Audit trail",
|
||||
"description": "Trace important state changes, actors and correlation references.",
|
||||
"actionFilterLabel": "Action",
|
||||
"actionFilterPlaceholder": "e.g. demo_login",
|
||||
"managerOnly": "The audit trail is visible to Operations Managers only.",
|
||||
"managerOnlyDetail": "Audit history is visible to Operations Managers only.",
|
||||
"loading": "Loading audit trail…",
|
||||
"unavailable": "Audit trail is unavailable right now.",
|
||||
"empty": "No audit events found",
|
||||
"emptyDetail": "Adjust the action filter.",
|
||||
"relatedFilterActive": "Showing only events linked to this action ({{count}} related events).",
|
||||
"clearFilter": "Clear this filter",
|
||||
"count": "{{count}} events",
|
||||
"storedRendered": "UTC stored · Brussels rendered",
|
||||
"columns": {
|
||||
"when": "When",
|
||||
"actor": "Actor",
|
||||
"action": "Action",
|
||||
"entity": "Entity",
|
||||
"change": "Change",
|
||||
"followUp": "Follow-up",
|
||||
"details": "Details"
|
||||
},
|
||||
"viewRelatedEvents": "View related events",
|
||||
"noChangeDetail": "No recorded change detail.",
|
||||
"noFieldChange": "No field-level change detected.",
|
||||
"reference": "Reference",
|
||||
"relatedEventsCount": "{{count}} linked event",
|
||||
"relatedEventsCount_other": "{{count}} linked events",
|
||||
"showTechnicalEvents": "Show {{count}} technical event",
|
||||
"showTechnicalEvents_other": "Show {{count}} technical events",
|
||||
"hideTechnicalEvents": "Hide technical events",
|
||||
"technicalDetails": "Technical details",
|
||||
"fullReference": "Full reference",
|
||||
"correlationId": "Correlation ID",
|
||||
"diff": {
|
||||
"setTo": "{{field}}: set to {{value}}",
|
||||
"was": "{{field}}: was {{value}}",
|
||||
"changed": "{{field}}: {{before}} → {{after}}"
|
||||
},
|
||||
"actions": {
|
||||
"demo_login": "Logged in",
|
||||
"demo_logout": "Logged out",
|
||||
"demo_reset": "Demo data reset",
|
||||
"demo_data_seeded": "Demo data seeded",
|
||||
"return_registered": "Vehicle return registered",
|
||||
"vehicle_status_changed": "Vehicle status changed",
|
||||
"data_quality_issue_resolved": "Data-quality issue resolved",
|
||||
"data_quality_issue_deferred": "Data-quality issue deferred",
|
||||
"data_quality_issue_rejected": "Data-quality issue rejected",
|
||||
"data_quality_fields_provided": "Missing fields provided",
|
||||
"data_quality_odometer_corrected": "Odometer corrected",
|
||||
"data_quality_odometer_retained": "Canonical odometer retained",
|
||||
"data_quality_status_applied": "Recommended status applied",
|
||||
"data_quality_booking_blocked": "Booking blocked",
|
||||
"data_quality_scan_run": "Quality scan run",
|
||||
"customer_merged": "Customers merged",
|
||||
"workflow_retry": "Automation retried",
|
||||
"knowledge_question_asked": "Knowledge question asked",
|
||||
"mcp_tool_request": "MCP tool called"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"brandTagline": "Control Centre",
|
||||
"orgLine": "Demo organisation: {{orgName}} (fictional)",
|
||||
"headline1": "Every hand-off.",
|
||||
"headline2": "One clear view.",
|
||||
"defaultDescription": "Fleet Ops brings vehicle, booking and operational data together, supports rental processes, detects data-quality problems and automates controlled follow-up steps.",
|
||||
"footnote": "Synthetic demo · no real customer or vehicle data · resettable at any time",
|
||||
"accessEyebrow": "Demo access",
|
||||
"accessHeading": "Choose how to start",
|
||||
"accessIntro": "No password needed. Each role opens a scoped synthetic environment — all workflows and controls are really implemented.",
|
||||
"startGuidedDemo": "Start guided demo",
|
||||
"exploreAsOperationsManager": "Explore as Operations Manager",
|
||||
"exploreAsOperationsManagerDetail": "Full overview, quality resolution and retries",
|
||||
"exploreAsRentalEmployee": "Explore as Rental Employee",
|
||||
"exploreAsRentalEmployeeDetail": "Bookings, returns, fleet and procedures",
|
||||
"safeByDesignTitle": "Safe by design",
|
||||
"safeByDesignDetail": "Every action is logged and is resettable in this demo.",
|
||||
"loginFailed": "The demo session could not be started. The API may be unreachable.",
|
||||
"roleOperationsManager": "Operations manager",
|
||||
"roleRentalEmployee": "Rental employee",
|
||||
"switchRole": "Switch role",
|
||||
"logout": "Log out"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"list": {
|
||||
"eyebrow": "Operations / Schedule",
|
||||
"title": "Bookings",
|
||||
"description": "Review active rental windows and upcoming vehicle commitments.",
|
||||
"searchLabel": "Search",
|
||||
"searchPlaceholder": "Booking, customer or vehicle",
|
||||
"statusLabel": "Status",
|
||||
"statusAll": "All statuses",
|
||||
"loading": "Loading booking ledger…",
|
||||
"empty": "No bookings found",
|
||||
"emptyDetail": "Adjust the booking status filter.",
|
||||
"noMatch": "No matching bookings",
|
||||
"noMatchDetail": "Try a broader search term.",
|
||||
"unavailable": "Booking list is unavailable right now.",
|
||||
"count": "{{count}} bookings",
|
||||
"pageOf": "Page {{page}} of {{total}}",
|
||||
"columns": {
|
||||
"reference": "Reference",
|
||||
"customer": "Customer",
|
||||
"vehicle": "Vehicle",
|
||||
"window": "Window",
|
||||
"status": "Status"
|
||||
},
|
||||
"paginationLabel": "Booking pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"rangeOf": "{{from}}–{{to}} of {{total}}"
|
||||
},
|
||||
"statuses": {
|
||||
"reserved": "reserved",
|
||||
"active": "active",
|
||||
"returned": "returned",
|
||||
"cancelled": "cancelled",
|
||||
"blocked": "blocked"
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Booking ledger",
|
||||
"eyebrow": "Bookings / Rental record",
|
||||
"notFound": "This booking could not be found.",
|
||||
"loading": "Loading booking record…",
|
||||
"customer": "Customer",
|
||||
"vehicle": "Vehicle",
|
||||
"starts": "Starts",
|
||||
"ends": "Ends",
|
||||
"startOdometer": "Start odometer",
|
||||
"endOdometer": "End odometer",
|
||||
"requirementsComplete": "Requirements complete",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"appName": "Fleet Ops",
|
||||
"orgName": "Northstar Mobility",
|
||||
"brandTagline": "Control Centre",
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"retry": "Retry",
|
||||
"reset": "Reset",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"viewDetails": "View details",
|
||||
"technicalDetails": "Technical details"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading…",
|
||||
"saving": "Saving…",
|
||||
"error": "Something went wrong",
|
||||
"success": "Success",
|
||||
"noResults": "No results"
|
||||
},
|
||||
"states": {
|
||||
"loadingDefault": "Loading workspace…",
|
||||
"errorTitle": "We couldn't load this workspace."
|
||||
},
|
||||
"timezone": "Europe/Brussels",
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"nl-BE": "Nederlands",
|
||||
"en-GB": "English",
|
||||
"fr-BE": "Français"
|
||||
},
|
||||
"footer": {
|
||||
"productLine": "Fleet Ops Demo",
|
||||
"locale": "Europe/Brussels · Synthetic demo data"
|
||||
},
|
||||
"demo": {
|
||||
"syntheticBadge": "Synthetic demo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"eyebrow": "Operations / Live overview",
|
||||
"title": "Good morning. Here's the fleet.",
|
||||
"description": "Readiness, exceptions and hand-offs across today's operation.",
|
||||
"viewFleet": "View fleet",
|
||||
"demoStart": {
|
||||
"title": "Try a demonstration scenario",
|
||||
"readyCount": "{{ready}} of {{total}} scenarios ready for demo.",
|
||||
"readyCountFallback": "Five focused scenarios.",
|
||||
"startGuide": "Start demo guide",
|
||||
"resumeGuide": "Continue demo guide ({{current}}/{{total}})",
|
||||
"viewScenarios": "View scenarios"
|
||||
},
|
||||
"readiness": {
|
||||
"title": "Fleet readiness",
|
||||
"description": "Live from persisted vehicle state",
|
||||
"openFleet": "Open fleet",
|
||||
"available": "Available",
|
||||
"rented": "Rented",
|
||||
"cleaning": "Cleaning",
|
||||
"maintenance": "Maintenance",
|
||||
"blocked": "Blocked"
|
||||
},
|
||||
"attention": {
|
||||
"title": "Attention queue",
|
||||
"description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions",
|
||||
"reviewQueue": "Review queue",
|
||||
"filterPlaceholder": "Filter issues…",
|
||||
"filterAriaLabel": "Search attention queue",
|
||||
"severityAll": "All severity",
|
||||
"severityHigh": "Critical",
|
||||
"severityMedium": "Warning",
|
||||
"severityLow": "Info",
|
||||
"severityAriaLabel": "Severity",
|
||||
"empty": "No issues match this filter.",
|
||||
"openRecord": "Open {{title}}"
|
||||
},
|
||||
"movements": {
|
||||
"title": "Today's movements",
|
||||
"description": "Departures and returns in Europe/Brussels",
|
||||
"allBookings": "All bookings",
|
||||
"empty": "No movements scheduled today.",
|
||||
"departure": "departure",
|
||||
"return": "return",
|
||||
"openBooking": "Open booking {{ref}}"
|
||||
},
|
||||
"integrationPulse": {
|
||||
"title": "Integration pulse",
|
||||
"description": "Current evidence from connected services",
|
||||
"systemDetail": "System detail",
|
||||
"n8nTitle": "n8n delivery",
|
||||
"n8nSummary": "{{succeeded}} succeeded · {{failed}} failed",
|
||||
"n8nLatest": "Latest event {{ref}}",
|
||||
"n8nNoEvidence": "No workflow evidence recorded",
|
||||
"knowledgeTitle": "Knowledge assistant",
|
||||
"knowledgeSummary": "{{count}} procedures indexed",
|
||||
"knowledgeUnavailable": "Health check unavailable",
|
||||
"mcpTitle": "MCP Hub",
|
||||
"mcpEnabled": "Registration enabled",
|
||||
"mcpNotConnected": "Not yet connected"
|
||||
},
|
||||
"recent": {
|
||||
"title": "Recent activity",
|
||||
"description": "Latest audited workflow changes",
|
||||
"empty": "No automation activity recorded."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"badge": {
|
||||
"trigger": "Synthetic demo",
|
||||
"dialogLabel": "About this demo environment",
|
||||
"close": "Close",
|
||||
"orgIntro": "<strong>{{orgName}}</strong> is a fictional organisation. All names, vehicles and bookings are synthetic.",
|
||||
"orgIntroFallback": "All names, vehicles and bookings in this environment are synthetic.",
|
||||
"realWorkflows": "The workflows, controls and automation are really implemented — only the data is invented.",
|
||||
"lastReset": "Last reset: <strong>{{when}}</strong> · this environment is resettable at any time.",
|
||||
"aboutLink": "About this demo",
|
||||
"unknown": "unknown"
|
||||
},
|
||||
"guide": {
|
||||
"dialogLabel": "Guided demo",
|
||||
"trigger": "Demo guide",
|
||||
"kicker": "Guided demo · step {{current}} of {{total}}",
|
||||
"whatYouWillSee": "What you'll see",
|
||||
"whyItMatters": "Why it matters",
|
||||
"startAction": "Get started",
|
||||
"expectedOutcome": "Expected outcome",
|
||||
"goToStep": "Go to this step",
|
||||
"next": "Next",
|
||||
"close": "Close",
|
||||
"restart": "Prepare demo again",
|
||||
"restarting": "Restarting…",
|
||||
"restartFailed": "Could not restart the demo.",
|
||||
"allStepsLabel": "All steps",
|
||||
"progressChip": "Demo guide · step {{current}} of {{total}}",
|
||||
"expand": "Expand demo guide",
|
||||
"collapse": "Collapse demo guide",
|
||||
"steps": {
|
||||
"understand-state": {
|
||||
"title": "1. Understand the operational state",
|
||||
"whatYouWillSee": "The dashboard shows fleet readiness, open attention points and today's movements.",
|
||||
"whyItMatters": "An Operations Manager starts every day with this overview to decide where to step in.",
|
||||
"startAction": "Open the dashboard and review the attention queue and today's timeline.",
|
||||
"expectedOutcome": "You see which bookings, vehicles or data-quality issues need attention."
|
||||
},
|
||||
"open-booking": {
|
||||
"title": "2. Open the booking needing attention",
|
||||
"whatYouWillSee": "Booking BK-DEMO-RETURN, active and due for return today.",
|
||||
"whyItMatters": "Returns are the moment incorrect odometer readings or damage first become visible.",
|
||||
"startAction": "Open the booking from the dashboard or the booking list.",
|
||||
"expectedOutcome": "You see the booking details and the button to process the return."
|
||||
},
|
||||
"process-return": {
|
||||
"title": "3. Process a return with an odometer anomaly",
|
||||
"whatYouWillSee": "A return form pre-filled with a suspicious odometer reading below the last known reading.",
|
||||
"whyItMatters": "A falling odometer signals a data-entry mistake or a mixed-up vehicle — this must be caught before release.",
|
||||
"startAction": "Submit the return with the suggested value and review the server preview before confirming.",
|
||||
"expectedOutcome": "The return is processed, the vehicle gets an appropriate status, and a data-quality issue is created automatically."
|
||||
},
|
||||
"handle-quality-issue": {
|
||||
"title": "4. Handle the created data-quality issue",
|
||||
"whatYouWillSee": "A new 'odometer regression' issue at the top of the work queue.",
|
||||
"whyItMatters": "Every detected issue has one bounded resolution step — nothing is silently auto-corrected.",
|
||||
"startAction": "Open the data-quality overview and choose the newest issue.",
|
||||
"expectedOutcome": "You see the recommended action, choose a resolution, and the issue resolves with an audit trail."
|
||||
},
|
||||
"merge-duplicate": {
|
||||
"title": "5. Review and handle a possible duplicate customer",
|
||||
"whatYouWillSee": "Two customer profiles with the same email and phone number, compared side by side.",
|
||||
"whyItMatters": "Duplicate customers split booking history and confuse communication.",
|
||||
"startAction": "Compare both profiles and choose which one survives.",
|
||||
"expectedOutcome": "The profiles are merged, bookings are rewired, and the losing profile becomes a tombstone."
|
||||
},
|
||||
"ask-knowledge": {
|
||||
"title": "6. Ask the procedure assistant a question",
|
||||
"whatYouWillSee": "An answer with citation from the scoped demo knowledge base.",
|
||||
"whyItMatters": "Staff need a quick, grounded answer about procedures, without guessing.",
|
||||
"startAction": "Click one of the suggested questions, in the current interface language.",
|
||||
"expectedOutcome": "You see the answer, the procedure used, and the source text — or an honest 'insufficient information' if none exists."
|
||||
},
|
||||
"check-automation-audit": {
|
||||
"title": "7. Check automation and the audit trail",
|
||||
"whatYouWillSee": "The delivery status of your return's automation job, and its related audit events.",
|
||||
"whyItMatters": "Every important action must be traceable: who did what, when, and what followed.",
|
||||
"startAction": "Open Integrations to see delivery status, and Audit trail for the full record.",
|
||||
"expectedOutcome": "You see a successful (or recoverable) delivery and a readable audit trail of your actions."
|
||||
},
|
||||
"review-real-vs-simulated": {
|
||||
"title": "8. Review what's real, simulated, or not yet connected",
|
||||
"whatYouWillSee": "An overview of what's functionally implemented in this demo, what's synthetic, and which integrations aren't live yet.",
|
||||
"whyItMatters": "A demo is only convincing if visitors can verify for themselves what really works and what's still ahead.",
|
||||
"startAction": "Read the 'About this demo' page.",
|
||||
"expectedOutcome": "You can explain yourself what Fleet Ops is and isn't, with no verbal explanation needed."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scenarios": {
|
||||
"eyebrow": "Demonstration scenarios",
|
||||
"title": "Try a demonstration scenario",
|
||||
"description": "Five focused scenarios that always use the same fixed bookings, customers and vehicles — always re-findable after a reset.",
|
||||
"loading": "Loading scenarios…",
|
||||
"ready": "Ready for demo",
|
||||
"notReady": "Not available",
|
||||
"duration": "Duration",
|
||||
"durationValue": "± {{minutes}} min",
|
||||
"role": "Role",
|
||||
"demonstrates": "Demonstrates:",
|
||||
"requiresRole": "Requires role: {{roles}}.",
|
||||
"startScenario": "Start scenario",
|
||||
"roleOr": "{{a}} or {{b}}",
|
||||
"roles": {
|
||||
"operations_manager": "Operations Manager",
|
||||
"rental_employee": "Rental Employee"
|
||||
},
|
||||
"items": {
|
||||
"return-anomaly": {
|
||||
"title": "Return with an odometer anomaly",
|
||||
"problem": "A vehicle comes back with an odometer reading lower than the last recorded reading — a sign of a data-entry mistake or a mixed-up vehicle.",
|
||||
"demonstrates": "Return processing, automatic data-quality detection, and the audit trail it produces."
|
||||
},
|
||||
"duplicate-customer": {
|
||||
"title": "Merge a possible duplicate customer",
|
||||
"problem": "Two customer profiles share the same email address and phone number — likely the same person, registered twice.",
|
||||
"demonstrates": "Merging customers while preserving booking history and the audit trail."
|
||||
},
|
||||
"booking-overlap": {
|
||||
"title": "Resolve overlapping bookings",
|
||||
"problem": "One vehicle is double-booked for overlapping periods — a scheduling error that must be resolved before departure.",
|
||||
"demonstrates": "Detection and controlled resolution of scheduling conflicts."
|
||||
},
|
||||
"automation-retry": {
|
||||
"title": "Retry a failed automation job",
|
||||
"problem": "One earlier event could not be delivered to the automation job due to a simulated connection error.",
|
||||
"demonstrates": "Reliable delivery with bounded retries and visible failure status."
|
||||
},
|
||||
"knowledge-question": {
|
||||
"title": "Ask a procedure question",
|
||||
"problem": "An employee isn't sure which procedure applies to a specific operational situation.",
|
||||
"demonstrates": "Source-grounded answers from a scoped demo knowledge base."
|
||||
}
|
||||
},
|
||||
"blockedReasons": {
|
||||
"bookingNotFound": "Demo booking BK-DEMO-RETURN not found. {{resetHint}}",
|
||||
"bookingAlreadyProcessed": "This booking has already been processed since the last reset. {{resetHint}}",
|
||||
"duplicateIssueNotFound": "Demo issue DQ-DEMO-DUPLICATE not found. {{resetHint}}",
|
||||
"issueAlreadyResolved": "This issue has already been resolved since the last reset. {{resetHint}}",
|
||||
"overlapIssueNotFound": "Demo issue DQ-DEMO-OVERLAP not found. {{resetHint}}",
|
||||
"failedEventNotFound": "The simulated failed event was not found. {{resetHint}}",
|
||||
"eventAlreadyRecovered": "This event has already been recovered since the last reset. {{resetHint}}",
|
||||
"knowledgeUnavailable": "The demo knowledge base is currently unavailable.",
|
||||
"resetHint": "Reset the demo data to make this scenario available again."
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"eyebrow": "About this demo",
|
||||
"title": "What Fleet Ops is and isn't",
|
||||
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
|
||||
"loading": "Loading demo information…",
|
||||
"ctaTitle": "Prefer to jump right in?",
|
||||
"ctaBody": "The guided demo walks through all eight steps above in practice.",
|
||||
"ctaButton": "Start guided demo",
|
||||
"problemTitle": "The fictional problem",
|
||||
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. Fleet Ops shows how one connected system flags these problems early and lets them be resolved under control.",
|
||||
"scopeTitle": "Who it's for and its scope",
|
||||
"scopeBody": "This demo is for anyone who wants to see how Fleet Ops tackles operational problems at a small rental company: Operations Managers and Rental Employees, and anyone evaluating the approach. The scope is deliberately focused on one connected proof of concept — no accounting, no payments, no public reservations, no full CRM or ERP.",
|
||||
"realTitle": "What really works",
|
||||
"realBody": "Everything below is functional code, not just a mockup: role-based access and sessions, vehicle and booking management, return processing with server-side validation, five data-quality rules each with its own resolution step, a full audit trail, automated delivery to n8n with bounded retries, Docker-based deployment, and an automated test suite (backend and Playwright end-to-end).",
|
||||
"syntheticTitle": "What's synthetic",
|
||||
"syntheticBody": "The organisation, all customers, vehicles, bookings, maintenance history, procedures in the knowledge base, and the pre-set-up scenarios are entirely invented. No data refers to a real person, vehicle or company; email addresses only use the {{testDomain}} test domain.",
|
||||
"architectureTitle": "Architecture in brief",
|
||||
"architectureBody": "A React/TypeScript frontend talks to a FastAPI backend (PostgreSQL via SQLAlchemy/Alembic migrations); important business rules live in the backend, not in n8n or in prompts. Returns and other events are committed locally first and only then delivered asynchronously to n8n through an automation job, so a temporary automation outage never blocks an operational action.",
|
||||
"securityTitle": "Security and access",
|
||||
"securityBody": "Access runs through signed, HTTP-only session cookies per role; each role is bound to a set of allowed routes, both enforced server-side and reflected in navigation. Important state changes are always checked and logged — never silently auto-corrected.",
|
||||
"testingTitle": "How this is tested",
|
||||
"testingBody": "An automated backend test suite covers business rules and API contracts; a full Playwright end-to-end suite covers the user flows, including this demo experience itself in three languages. Every change is also validated against a clean checkout (empty database, rebuilt from seed data) before deployment.",
|
||||
"integrationsTitle": "Integrations — honestly labelled",
|
||||
"integrationsDescription": "What's operational, what's demo mode, and what's not yet connected.",
|
||||
"resetTitle": "Restoring the demo environment",
|
||||
"resetBodyManager": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. Use <strong>Reset demo data</strong> in the sidebar to start over.",
|
||||
"resetBodyEmployee": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. An Operations Manager can reset the demo environment via the sidebar.",
|
||||
"limitationsTitle": "Limitations",
|
||||
"limitationsBody": "This is a focused proof of concept, not a full ERP. RAGcore and the ITWorx MCP Hub are not yet live-connected; the knowledge assistant uses a local, scoped demo knowledge base instead of a live RAGcore environment.",
|
||||
"unknown": "unknown"
|
||||
},
|
||||
"integrationSummary": {
|
||||
"titles": {
|
||||
"n8n": "Automation (n8n)",
|
||||
"ragcore": "Knowledge assistant (RAGcore)",
|
||||
"mcp_hub": "ITWorx MCP Hub"
|
||||
},
|
||||
"n8nDetail": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending.",
|
||||
"ragcoreDetail": "{{count}} procedures indexed in {{collection}}.",
|
||||
"mcpDetailEnabled": "Prepared for future, controlled tool calls from the Hub.",
|
||||
"mcpDetailNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"generic": "Something went wrong. Please try again.",
|
||||
"workspaceLoadFailed": "We couldn't load this workspace.",
|
||||
"unauthorized": "Your session has expired. Please log in again.",
|
||||
"forbidden": "You don't have access to this section.",
|
||||
"notFound": "This record could not be found.",
|
||||
"networkUnavailable": "The connection to the server is currently unavailable."
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"list": {
|
||||
"eyebrow": "Fleet / Registry",
|
||||
"title": "Vehicle fleet",
|
||||
"description": "Live operational state, location and service readiness.",
|
||||
"searchLabel": "Search",
|
||||
"searchPlaceholder": "Reference, make or location",
|
||||
"statusLabel": "Status",
|
||||
"statusAll": "All statuses",
|
||||
"attentionOnly": "Attention only",
|
||||
"loading": "Loading fleet registry…",
|
||||
"empty": "No vehicles found",
|
||||
"emptyDetail": "Adjust the current fleet filters.",
|
||||
"noMatch": "No matching vehicles",
|
||||
"noMatchDetail": "Try a broader search term.",
|
||||
"unavailable": "Vehicle list is unavailable right now.",
|
||||
"count": "{{count}} vehicles",
|
||||
"persisted": "Persisted fleet data",
|
||||
"columns": {
|
||||
"reference": "Reference",
|
||||
"makeModel": "Make / model",
|
||||
"location": "Location",
|
||||
"status": "Status",
|
||||
"odometer": "Odometer (km)",
|
||||
"attention": "Attention"
|
||||
},
|
||||
"needsAttention": "Needs attention"
|
||||
},
|
||||
"statuses": {
|
||||
"available": "available",
|
||||
"rented": "rented",
|
||||
"cleaning": "cleaning",
|
||||
"maintenance": "maintenance",
|
||||
"blocked": "blocked"
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Fleet registry",
|
||||
"eyebrow": "Fleet / Vehicle record",
|
||||
"notFound": "This vehicle could not be found.",
|
||||
"loading": "Loading vehicle record…",
|
||||
"needsAttention": "Needs attention",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"bookings": "Bookings",
|
||||
"inspections": "Inspections",
|
||||
"maintenance": "Maintenance",
|
||||
"quality": "Quality"
|
||||
},
|
||||
"tabsAriaLabel": "Vehicle sections",
|
||||
"overview": {
|
||||
"registration": "Registration",
|
||||
"modelYear": "Model year",
|
||||
"location": "Location",
|
||||
"odometer": "Odometer",
|
||||
"nextService": "Next service",
|
||||
"active": "Active"
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"noBookings": "No bookings recorded.",
|
||||
"noInspections": "No inspections recorded.",
|
||||
"noMaintenance": "No maintenance records.",
|
||||
"noQualityIssues": "No quality issues recorded.",
|
||||
"fuel": "Fuel {{percent}}%",
|
||||
"damage": "Damage",
|
||||
"technicalWarning": "Technical warning"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"eyebrow": "Assure / Integrations",
|
||||
"title": "Integration control",
|
||||
"description": "Monitor delivery health, graceful degradation and retryable workflow events.",
|
||||
"cards": {
|
||||
"orchestrationKicker": "Orchestration",
|
||||
"n8nTitle": "n8n delivery",
|
||||
"n8nSummary": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending · {{delivering}} delivering",
|
||||
"n8nFallback": "Return events are committed locally first and then delivered through the automation job.",
|
||||
"knowledgeKicker": "Knowledge",
|
||||
"knowledgeTitle": "Knowledge assistant",
|
||||
"knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.",
|
||||
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.",
|
||||
"knowledgeUnavailable": "Health evidence is currently unavailable.",
|
||||
"gatewayKicker": "Tool gateway",
|
||||
"mcpTitle": "MCP Hub",
|
||||
"mcpEnabled": "Registration is enabled for this deployment.",
|
||||
"mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub."
|
||||
},
|
||||
"statusLabels": {
|
||||
"notConnected": "Not connected",
|
||||
"deliveryFailed": "Delivery failed",
|
||||
"retryAvailable": "Retry available",
|
||||
"operational": "Operational",
|
||||
"prepared": "Prepared",
|
||||
"demoMode": "Demo mode",
|
||||
"unavailable": "Unavailable"
|
||||
},
|
||||
"ledger": {
|
||||
"title": "Automation jobs",
|
||||
"description": "Persisted automation attempts with the latest failure evidence.",
|
||||
"filterLabel": "View",
|
||||
"filterNeedsAttention": "Needs attention",
|
||||
"filterRecent": "Recent",
|
||||
"filterSucceeded": "Succeeded",
|
||||
"filterAll": "All",
|
||||
"statusFilterLabel": "Status",
|
||||
"statusAll": "All statuses",
|
||||
"statusPending": "Pending",
|
||||
"statusDelivering": "Delivering",
|
||||
"statusSucceeded": "Succeeded",
|
||||
"statusFailed": "Failed",
|
||||
"unavailable": "Automation jobs are unavailable right now.",
|
||||
"managerOnly": "Automation is visible to Operations Managers only.",
|
||||
"loading": "Loading automation jobs…",
|
||||
"empty": "No automation jobs match this filter.",
|
||||
"count": "{{count}} workflow events",
|
||||
"boundedRetries": "Bounded retries",
|
||||
"groupedSucceeded": "{{count}} succeeded vehicle-return jobs",
|
||||
"showIndividually": "Show individual jobs",
|
||||
"hideIndividually": "Hide individual jobs",
|
||||
"columns": {
|
||||
"event": "Job",
|
||||
"type": "Type",
|
||||
"booking": "Booking",
|
||||
"status": "Status",
|
||||
"attempts": "Attempts",
|
||||
"lastError": "Last error",
|
||||
"when": "When",
|
||||
"action": "Action"
|
||||
},
|
||||
"retry": "Retry",
|
||||
"retrying": "Retrying…",
|
||||
"retryFailed": "Could not retry this delivery.",
|
||||
"noAction": "—",
|
||||
"eventTypes": {
|
||||
"vehicle.returned.v1": "Vehicle return processed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"eyebrow": "Assure / Grounded knowledge",
|
||||
"title": "Procedure knowledge",
|
||||
"description": "Ask operational questions. Answers are shown only when {{provider}} returns sufficient cited evidence.",
|
||||
"providerDemo": "the demo knowledge base",
|
||||
"providerRagcore": "RAGcore",
|
||||
"statusAvailable": "Available",
|
||||
"statusUnavailable": "Unavailable",
|
||||
"proceduresIndexed": "{{count}} procedures indexed",
|
||||
"providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.",
|
||||
"askHeading": "Ask a procedure question",
|
||||
"askSubheading": "Retrieval → evidence check → grounded answer",
|
||||
"questionLabel": "Question",
|
||||
"questionPlaceholder": "e.g. What must I do when a vehicle returns with damage?",
|
||||
"ask": "Ask",
|
||||
"asking": "Asking…",
|
||||
"askFailed": "Could not reach the knowledge service.",
|
||||
"suggestedLabel": "Try one:",
|
||||
"suggestedQuestions": [
|
||||
"What must I do when a vehicle returns with damage?",
|
||||
"How do I register a vehicle return?",
|
||||
"When may a vehicle be made available again?",
|
||||
"Who reviews an unusual odometer reading?",
|
||||
"Which checks are required before checkout?"
|
||||
],
|
||||
"emptyTitle": "Evidence before answers",
|
||||
"emptyDescription": "Ask about returns, damage, inspections or another indexed procedure. Fleet Ops will not invent an answer when evidence is missing.",
|
||||
"retrievalFlow": {
|
||||
"question": "Question",
|
||||
"sources": "Sources",
|
||||
"answer": "Answer"
|
||||
},
|
||||
"questionLabelExchange": "Question",
|
||||
"evidenceStates": {
|
||||
"grounded": "Grounded in cited procedures",
|
||||
"insufficient": "Insufficient evidence",
|
||||
"unavailable": "Knowledge service unavailable"
|
||||
},
|
||||
"unavailableBody": "The knowledge service is currently unreachable. Operational features are unaffected — try again later.",
|
||||
"sourceVersion": "v{{version}}"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"skipToContent": "Skip to main content",
|
||||
"groups": {
|
||||
"operate": "Operate",
|
||||
"assure": "Assure"
|
||||
},
|
||||
"items": {
|
||||
"overview": "Overview",
|
||||
"fleet": "Fleet",
|
||||
"bookings": "Bookings",
|
||||
"quality": "Data quality",
|
||||
"knowledge": "Knowledge",
|
||||
"integrations": "Integrations",
|
||||
"audit": "Audit trail"
|
||||
},
|
||||
"primaryNavLabel": "Primary navigation",
|
||||
"mobileNavLabel": "Mobile navigation",
|
||||
"more": "More",
|
||||
"openNavigation": "Open navigation",
|
||||
"closeNavigation": "Close navigation",
|
||||
"sidebarEnvironment": "Demo environment",
|
||||
"sidebarEnvironmentDetail": "Synthetic data only",
|
||||
"resetDemoData": "Reset demo data",
|
||||
"resetConfirmTitle": "Confirm demo reset",
|
||||
"resetConfirmBody": "All synthetic changes will be discarded and deterministic demo data restored. You will be signed out.",
|
||||
"resetting": "Resetting…",
|
||||
"resetConfirmYes": "Yes, reset",
|
||||
"resetCancel": "Cancel",
|
||||
"resetFailed": "Could not reset demo data.",
|
||||
"searchLabel": "Search Fleet Ops",
|
||||
"searchPlaceholder": "Search fleet, booking or section…",
|
||||
"searchShortcutHint": "Ctrl K",
|
||||
"searchSearching": "Searching…",
|
||||
"searchUnavailable": "Search is unavailable right now.",
|
||||
"searchNoResults": "No matches for \"{{query}}\".",
|
||||
"switchRole": "Switch role",
|
||||
"switchRoleTitle": "Switch demo role",
|
||||
"languageSwitcherLabel": "Change language"
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
{
|
||||
"list": {
|
||||
"eyebrow": "Assure / Workbench",
|
||||
"title": "Data quality",
|
||||
"description": "Resolve evidence-backed exceptions before they disrupt operations.",
|
||||
"runScan": "Run quality scan",
|
||||
"confirmScanTitle": "Confirm quality scan",
|
||||
"confirmScanBody": "Run the deterministic scan across all five rule types now?",
|
||||
"scanning": "Scanning…",
|
||||
"confirmScanYes": "Yes, run scan",
|
||||
"cancel": "Cancel",
|
||||
"scanComplete": "Scan complete: {{summary}}",
|
||||
"scanNoNew": "no new issues found (existing open issues are not recreated).",
|
||||
"scanFailed": "Could not run the quality scan.",
|
||||
"statusLabel": "Status",
|
||||
"statusAll": "All statuses",
|
||||
"statusOpen": "Open",
|
||||
"statusDeferred": "Deferred",
|
||||
"statusResolved": "Resolved",
|
||||
"statusRejected": "Rejected",
|
||||
"ruleTypeLabel": "Rule type",
|
||||
"ruleTypeAll": "All rule types",
|
||||
"demoScenariosOnly": "Demo scenarios only",
|
||||
"loading": "Loading quality workbench…",
|
||||
"queueClear": "Queue is clear",
|
||||
"noIssuesMatch": "No issues match the current filters.",
|
||||
"noDemoIssuesMatch": "No demo-scenario issues match",
|
||||
"noDemoIssuesMatchDetail": "Uncheck 'Demo scenarios only' to see the full queue.",
|
||||
"unavailable": "Data-quality issues are unavailable right now.",
|
||||
"count": "{{count}} issues",
|
||||
"evidenceBacked": "Evidence-backed detection",
|
||||
"columns": {
|
||||
"reference": "Reference",
|
||||
"rule": "Rule",
|
||||
"entity": "Entity",
|
||||
"severity": "Severity",
|
||||
"status": "Status"
|
||||
}
|
||||
},
|
||||
"ruleTypes": {
|
||||
"possible_duplicate_customer": "Possible duplicate customer",
|
||||
"missing_required_field": "Missing required field",
|
||||
"odometer_regression": "Odometer regression",
|
||||
"booking_overlap": "Booking overlap",
|
||||
"vehicle_status_conflict": "Vehicle status conflict"
|
||||
},
|
||||
"severities": {
|
||||
"high": "Critical",
|
||||
"medium": "Warning",
|
||||
"low": "Info"
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Quality workbench",
|
||||
"eyebrow": "Quality / {{rule}}",
|
||||
"title": "Review persisted evidence and record an audited resolution.",
|
||||
"notFound": "This issue could not be found.",
|
||||
"loading": "Loading issue evidence…",
|
||||
"managerOnly": "The quality workbench is visible to Operations Managers only.",
|
||||
"managerOnlyDetail": "Data-quality evidence and resolutions are visible to Operations Managers only.",
|
||||
"summary": {
|
||||
"rule": "Rule",
|
||||
"entity": "Entity",
|
||||
"evidenceSummary": "Evidence summary"
|
||||
},
|
||||
"resolved": {
|
||||
"title": "Issue {{ref}} resolved",
|
||||
"body": "The change has been applied and is recorded in the audit trail.",
|
||||
"viewAudit": "View audit trail",
|
||||
"viewVehicle": "View vehicle",
|
||||
"continueDemo": "Continue the demo"
|
||||
},
|
||||
"deferOrReject": {
|
||||
"heading": "Defer or reject",
|
||||
"description": "Defer to review later, or reject if this is not a real issue.",
|
||||
"defer": "Defer",
|
||||
"reject": "Reject",
|
||||
"deferFailed": "Could not defer this issue.",
|
||||
"rejectFailed": "Could not reject this issue."
|
||||
},
|
||||
"explainer": {
|
||||
"whatIsWrong": "What's wrong",
|
||||
"whyItMatters": "Why it matters",
|
||||
"possible_duplicate_customer": {
|
||||
"whatIsWrong": "Two customer profiles share identifying details (email, phone or a very similar name) strongly enough that they are likely the same person, registered twice.",
|
||||
"whyItMatters": "Duplicate customers split booking history across two records, risk duplicate billing, and confuse support conversations."
|
||||
},
|
||||
"missing_required_field": {
|
||||
"whatIsWrong": "This record is missing information that's required for normal operation (for example, a customer with neither an email nor a phone number on file).",
|
||||
"whyItMatters": "Without this data, the business can't reach the customer, or can't reliably identify the vehicle for compliance and hand-off checks."
|
||||
},
|
||||
"odometer_regression": {
|
||||
"whatIsWrong": "A submitted odometer reading is lower than the vehicle's last known (canonical) reading.",
|
||||
"whyItMatters": "A falling odometer usually means a data-entry mistake or that readings were recorded against the wrong vehicle. Letting it through silently would corrupt maintenance scheduling and resale mileage history."
|
||||
},
|
||||
"booking_overlap": {
|
||||
"whatIsWrong": "The same vehicle is committed to two bookings whose date ranges overlap.",
|
||||
"whyItMatters": "Only one of these bookings can actually be honoured. Left unresolved, a customer would arrive to find their vehicle already out with someone else."
|
||||
},
|
||||
"vehicle_status_conflict": {
|
||||
"whatIsWrong": "This vehicle's stored operational status doesn't match what its own booking and inspection history implies it should be.",
|
||||
"whyItMatters": "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet."
|
||||
}
|
||||
},
|
||||
"duplicateCustomer": {
|
||||
"heading": "Compare and merge",
|
||||
"description": "Choose the canonical customer and review each conflicting field.",
|
||||
"keepAsSurvivor": "Keep as survivor",
|
||||
"differs": "Differs",
|
||||
"match": "Match",
|
||||
"mergePreview": "{{loser}} will become a tombstone linked to {{survivor}}; its bookings will be rewired.",
|
||||
"mergeInto": "Merge into {{ref}}",
|
||||
"confirmMergeTitle": "Confirm merge",
|
||||
"confirmMergeBody": "Merge {{loser}} into {{survivor}}? This cannot be undone.",
|
||||
"merging": "Merging…",
|
||||
"confirmMergeYes": "Yes, merge",
|
||||
"mergeFailed": "Could not merge these customers.",
|
||||
"bothMissing": "Both customers in this comparison could not be loaded.",
|
||||
"fieldColumn": "Field",
|
||||
"fields": {
|
||||
"first_name": "First name",
|
||||
"last_name": "Last name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"postal_code": "Postal code",
|
||||
"city": "City"
|
||||
}
|
||||
},
|
||||
"missingField": {
|
||||
"heading": "Provide the missing fields",
|
||||
"description": "Complete the record for {{ref}}. The issue resolves automatically once nothing required is missing.",
|
||||
"atLeastOne": "At least one of email or phone is required.",
|
||||
"saveAndRecheck": "Save and re-check",
|
||||
"saving": "Saving…",
|
||||
"saveFailed": "Could not save these fields.",
|
||||
"fields": {
|
||||
"first_name": "First name",
|
||||
"last_name": "Last name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"registration_number": "Registration number",
|
||||
"make": "Make",
|
||||
"model": "Model",
|
||||
"location": "Location"
|
||||
}
|
||||
},
|
||||
"odometerRegression": {
|
||||
"heading": "Resolve the odometer regression",
|
||||
"description": "The canonical odometer is never lowered automatically -- choose how to reconcile it.",
|
||||
"canonicalOdometer": "Canonical odometer",
|
||||
"decisionLegend": "Decision",
|
||||
"retainCanonical": "Retain canonical reading",
|
||||
"retainCanonicalDetail": "Treat the submitted reading as erroneous; nothing changes on the vehicle record.",
|
||||
"correctReading": "Correct the reading",
|
||||
"correctReadingDetail": "Update both the booking and the canonical odometer with the correct value.",
|
||||
"noBookingAttached": "No related booking is attached to this issue, so only 'retain canonical' is available.",
|
||||
"booking": "Booking",
|
||||
"correctedOdometer": "Corrected odometer (km)",
|
||||
"note": "Note",
|
||||
"resolving": "Resolving…",
|
||||
"resolveIssue": "Resolve issue",
|
||||
"resolveFailed": "Could not resolve this issue."
|
||||
},
|
||||
"bookingOverlap": {
|
||||
"heading": "Resolve the booking overlap",
|
||||
"description": "Block one of the two overlapping commitments. The other keeps its current status.",
|
||||
"columns": {
|
||||
"booking": "Booking",
|
||||
"window": "Window",
|
||||
"status": "Status",
|
||||
"blockThis": "Block this one"
|
||||
},
|
||||
"blockLabel": "Block {{ref}}",
|
||||
"note": "Note",
|
||||
"resolving": "Resolving…",
|
||||
"blockButton": "Block {{ref}}",
|
||||
"blockButtonFallback": "Block booking",
|
||||
"resolveFailed": "Could not resolve this overlap."
|
||||
},
|
||||
"vehicleStatusConflict": {
|
||||
"heading": "Resolve the status conflict",
|
||||
"description": "One authoritative rule recommends a corrected operational status for this vehicle.",
|
||||
"currentStatus": "Current status",
|
||||
"calculateAndApply": "Calculate and apply recommended status",
|
||||
"confirmTitle": "Confirm status change",
|
||||
"confirmBody": "Apply the authoritative recommended status for this vehicle?",
|
||||
"applying": "Applying…",
|
||||
"confirmYes": "Yes, apply",
|
||||
"applied": "Applied {{status}} — {{reason}}",
|
||||
"applyFailed": "Could not apply a recommended status."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"progress": {
|
||||
"capture": "Capture",
|
||||
"review": "Review",
|
||||
"result": "Result",
|
||||
"ariaLabel": "Return registration progress"
|
||||
},
|
||||
"capture": {
|
||||
"heading": "Register vehicle return",
|
||||
"description": "Record the hand-back condition. The next step evaluates the exact operational consequences before anything is committed.",
|
||||
"endOdometer": "End odometer (km)",
|
||||
"fuelLevel": "Fuel level (%)",
|
||||
"conditionLegend": "Vehicle condition",
|
||||
"cleanlinessOk": "Cleanliness acceptable",
|
||||
"damageReported": "Damage reported",
|
||||
"technicalWarning": "Technical warning",
|
||||
"notes": "Notes",
|
||||
"evaluating": "Evaluating…",
|
||||
"reviewReturn": "Review return"
|
||||
},
|
||||
"review": {
|
||||
"heading": "Review return impact",
|
||||
"description": "This is the server's authoritative evaluation of what committing will do — confirm before it updates fleet state and queues automation.",
|
||||
"odometer": "Odometer",
|
||||
"fuel": "Fuel",
|
||||
"cleanliness": "Cleanliness",
|
||||
"cleanlinessAccepted": "Accepted",
|
||||
"cleanlinessFollowUp": "Follow-up needed",
|
||||
"damage": "Damage",
|
||||
"damageReported": "Reported",
|
||||
"damageNone": "None reported",
|
||||
"technicalWarning": "Technical warning",
|
||||
"technicalWarningReported": "Reported",
|
||||
"technicalWarningNone": "None reported",
|
||||
"expectedState": "Expected fleet state:",
|
||||
"odometerRegressionWarning": "Submitted odometer ({{submitted}} km) is below the canonical reading ({{canonical}} km). The canonical odometer will not change, and a data-quality issue will be opened.",
|
||||
"nextBookingRisk": "Next booking {{ref}} starts {{when}} — may be affected by this return.",
|
||||
"nextBookingLowRisk": "Next booking {{ref}} starts {{when}} — low risk.",
|
||||
"commitList": {
|
||||
"inspection": "Create a return inspection",
|
||||
"updateAtomic": "Update the booking and vehicle atomically",
|
||||
"queueAutomation": "Queue automation after the local commit"
|
||||
},
|
||||
"editDetails": "Edit details",
|
||||
"registering": "Registering…",
|
||||
"confirmReturn": "Confirm return"
|
||||
},
|
||||
"result": {
|
||||
"committedLocally": "Committed locally",
|
||||
"heading": "Return registered",
|
||||
"inspection": "Inspection",
|
||||
"resultingStatus": "Resulting vehicle status",
|
||||
"qualityIssue": "Quality issue",
|
||||
"noneCreated": "None created",
|
||||
"automationEvent": "Automation job",
|
||||
"queuedForDelivery": "Queued for delivery ({{ref}}) — local commit succeeded; automation delivery is asynchronous and not yet confirmed.",
|
||||
"nextBookingRisk": "Next booking risk",
|
||||
"nextBookingRiskValue": "{{ref}} {{status}}",
|
||||
"atRisk": "— may be affected",
|
||||
"lowRisk": "— low risk",
|
||||
"noUpcomingBooking": "No upcoming booking for this vehicle",
|
||||
"odometerRegressionNotice": "The submitted odometer reading was below the vehicle's canonical odometer. It was recorded as-is; the canonical odometer was not changed, and a data-quality issue was opened for review.",
|
||||
"viewVehicle": "View vehicle {{ref}}",
|
||||
"viewAutomation": "View automation status",
|
||||
"viewAudit": "View audit trail",
|
||||
"continueDemo": "Continue the demo"
|
||||
},
|
||||
"scenario": {
|
||||
"title": "Demo scenario: odometer anomaly",
|
||||
"body": "This vehicle currently reads {{odometer}} km. The form below is pre-filled with a return reading below that — a sign of a data-entry mistake or a mixed-up vehicle. Confirm the return to see how Fleet Ops detects and handles this.",
|
||||
"preparing": "Preparing scenario…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"openRow": "Ouvrir : {{label}}",
|
||||
"reducedMotion": "Mouvement réduit actif",
|
||||
"closeDialog": "Fermer la boîte de dialogue",
|
||||
"expandSection": "Déplier la section",
|
||||
"collapseSection": "Replier la section"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"eyebrow": "Assurance / Historique immuable",
|
||||
"title": "Piste d'audit",
|
||||
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
|
||||
"actionFilterLabel": "Action",
|
||||
"actionFilterPlaceholder": "p. ex. demo_login",
|
||||
"managerOnly": "La piste d'audit est visible uniquement pour les Operations Managers.",
|
||||
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Operations Managers.",
|
||||
"loading": "Chargement de la piste d'audit…",
|
||||
"unavailable": "La piste d'audit est actuellement indisponible.",
|
||||
"empty": "Aucun événement d'audit trouvé",
|
||||
"emptyDetail": "Ajustez le filtre d'action.",
|
||||
"relatedFilterActive": "Affichage uniquement des événements liés à cette action ({{count}} événements liés).",
|
||||
"clearFilter": "Effacer ce filtre",
|
||||
"count": "{{count}} événements",
|
||||
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
|
||||
"columns": {
|
||||
"when": "Quand",
|
||||
"actor": "Acteur",
|
||||
"action": "Action",
|
||||
"entity": "Entité",
|
||||
"change": "Changement",
|
||||
"followUp": "Suite",
|
||||
"details": "Détails"
|
||||
},
|
||||
"viewRelatedEvents": "Voir les événements liés",
|
||||
"noChangeDetail": "Aucun détail de changement enregistré.",
|
||||
"noFieldChange": "Aucun changement au niveau des champs détecté.",
|
||||
"reference": "Référence",
|
||||
"relatedEventsCount": "{{count}} événement lié",
|
||||
"relatedEventsCount_other": "{{count}} événements liés",
|
||||
"showTechnicalEvents": "Afficher {{count}} événement technique",
|
||||
"showTechnicalEvents_other": "Afficher {{count}} événements techniques",
|
||||
"hideTechnicalEvents": "Masquer les événements techniques",
|
||||
"technicalDetails": "Détails techniques",
|
||||
"fullReference": "Référence complète",
|
||||
"correlationId": "ID de corrélation",
|
||||
"diff": {
|
||||
"setTo": "{{field}} : défini à {{value}}",
|
||||
"was": "{{field}} : était {{value}}",
|
||||
"changed": "{{field}} : {{before}} → {{after}}"
|
||||
},
|
||||
"actions": {
|
||||
"demo_login": "Connecté",
|
||||
"demo_logout": "Déconnecté",
|
||||
"demo_reset": "Données de démo réinitialisées",
|
||||
"demo_data_seeded": "Données de démo chargées",
|
||||
"return_registered": "Retour de véhicule enregistré",
|
||||
"vehicle_status_changed": "Statut du véhicule modifié",
|
||||
"data_quality_issue_resolved": "Problème de qualité résolu",
|
||||
"data_quality_issue_deferred": "Problème de qualité reporté",
|
||||
"data_quality_issue_rejected": "Problème de qualité rejeté",
|
||||
"data_quality_fields_provided": "Champs manquants complétés",
|
||||
"data_quality_odometer_corrected": "Kilométrage corrigé",
|
||||
"data_quality_odometer_retained": "Kilométrage de référence conservé",
|
||||
"data_quality_status_applied": "Statut recommandé appliqué",
|
||||
"data_quality_booking_blocked": "Réservation bloquée",
|
||||
"data_quality_scan_run": "Contrôle qualité exécuté",
|
||||
"customer_merged": "Clients fusionnés",
|
||||
"workflow_retry": "Automatisation retentée",
|
||||
"knowledge_question_asked": "Question de connaissance posée",
|
||||
"mcp_tool_request": "Outil MCP appelé"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"brandTagline": "Centre de contrôle",
|
||||
"orgLine": "Organisation de démo : {{orgName}} (fictive)",
|
||||
"headline1": "Chaque transfert.",
|
||||
"headline2": "Une vue claire.",
|
||||
"defaultDescription": "Fleet Ops rassemble les données de véhicules, de réservations et d'exploitation, soutient les processus de location, détecte les problèmes de qualité des données et automatise les suites contrôlées.",
|
||||
"footnote": "Démo synthétique · aucune donnée client ou véhicule réelle · réinitialisable à tout moment",
|
||||
"accessEyebrow": "Accès démo",
|
||||
"accessHeading": "Choisissez comment démarrer",
|
||||
"accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.",
|
||||
"startGuidedDemo": "Démarrer la démo guidée",
|
||||
"exploreAsOperationsManager": "Explorer en tant qu'Operations Manager",
|
||||
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
|
||||
"exploreAsRentalEmployee": "Explorer en tant que Rental Employee",
|
||||
"exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures",
|
||||
"safeByDesignTitle": "Conçu pour la sécurité",
|
||||
"safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.",
|
||||
"loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.",
|
||||
"roleOperationsManager": "Operations manager",
|
||||
"roleRentalEmployee": "Rental employee",
|
||||
"switchRole": "Changer de rôle",
|
||||
"logout": "Déconnexion"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"list": {
|
||||
"eyebrow": "Exploitation / Planning",
|
||||
"title": "Réservations",
|
||||
"description": "Consultez les périodes de location actives et les engagements de véhicules à venir.",
|
||||
"searchLabel": "Rechercher",
|
||||
"searchPlaceholder": "Réservation, client ou véhicule",
|
||||
"statusLabel": "Statut",
|
||||
"statusAll": "Tous les statuts",
|
||||
"loading": "Chargement du registre des réservations…",
|
||||
"empty": "Aucune réservation trouvée",
|
||||
"emptyDetail": "Ajustez le filtre de statut des réservations.",
|
||||
"noMatch": "Aucune réservation correspondante",
|
||||
"noMatchDetail": "Essayez un terme de recherche plus large.",
|
||||
"unavailable": "La liste des réservations est actuellement indisponible.",
|
||||
"count": "{{count}} réservations",
|
||||
"pageOf": "Page {{page}} sur {{total}}",
|
||||
"columns": {
|
||||
"reference": "Référence",
|
||||
"customer": "Client",
|
||||
"vehicle": "Véhicule",
|
||||
"window": "Période",
|
||||
"status": "Statut"
|
||||
},
|
||||
"paginationLabel": "Pages de réservations",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"rangeOf": "{{from}}–{{to}} sur {{total}}"
|
||||
},
|
||||
"statuses": {
|
||||
"reserved": "réservé",
|
||||
"active": "actif",
|
||||
"returned": "retourné",
|
||||
"cancelled": "annulé",
|
||||
"blocked": "bloqué"
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Registre des réservations",
|
||||
"eyebrow": "Réservations / Fiche de location",
|
||||
"notFound": "Cette réservation est introuvable.",
|
||||
"loading": "Chargement de la fiche de réservation…",
|
||||
"customer": "Client",
|
||||
"vehicle": "Véhicule",
|
||||
"starts": "Début",
|
||||
"ends": "Fin",
|
||||
"startOdometer": "Kilométrage de départ",
|
||||
"endOdometer": "Kilométrage de retour",
|
||||
"requirementsComplete": "Exigences complètes",
|
||||
"yes": "Oui",
|
||||
"no": "Non"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"appName": "Fleet Ops",
|
||||
"orgName": "Northstar Mobility",
|
||||
"brandTagline": "Centre de contrôle",
|
||||
"actions": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"close": "Fermer",
|
||||
"back": "Retour",
|
||||
"next": "Suivant",
|
||||
"retry": "Réessayer",
|
||||
"reset": "Réinitialiser",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"viewDetails": "Voir les détails",
|
||||
"technicalDetails": "Détails techniques"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement…",
|
||||
"saving": "Enregistrement…",
|
||||
"error": "Une erreur est survenue",
|
||||
"success": "Réussi",
|
||||
"noResults": "Aucun résultat"
|
||||
},
|
||||
"states": {
|
||||
"loadingDefault": "Chargement de l'espace de travail…",
|
||||
"errorTitle": "Impossible de charger cet espace de travail."
|
||||
},
|
||||
"timezone": "Europe/Bruxelles",
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"nl-BE": "Nederlands",
|
||||
"en-GB": "English",
|
||||
"fr-BE": "Français"
|
||||
},
|
||||
"footer": {
|
||||
"productLine": "Fleet Ops Demo",
|
||||
"locale": "Europe/Bruxelles · Données de démonstration synthétiques"
|
||||
},
|
||||
"demo": {
|
||||
"syntheticBadge": "Démo synthétique"
|
||||
}
|
||||
}
|
||||