merge: finalize Fleet Ops localization

This commit is contained in:
NuklearRabbit
2026-08-04 03:46:20 +02:00
51 changed files with 1596 additions and 174 deletions
+93 -7
View File
@@ -29,9 +29,15 @@ M7 — complete. All milestones (M0M7) done, plus a full post-M7 final-accept
## Locked decisions
- Product name: MobilityOps.
- Product name: Fleet Ops (the only visible product name in the UI/copy, never translated;
see `frontend/src/product.ts`). "MobilityOps" is the internal repo name, Compose project
name and deployment directory only — never shown to a user. See the "Final product
polish: Fleet Ops rebrand" and "Fleet Ops final localization" entries below.
- Fictitious tenant: Northstar Mobility Demo.
- PoC only; all operational and knowledge data are synthetic.
- Synthetic demo data only; all operational and knowledge data are synthetic. The product
itself is not described as a "PoC" in user-facing copy (see the localization entry
below) — this document and other internal/engineering docs may still use "PoC" to
describe the engineering scope, per `CLAUDE.md`.
- Core stack and boundaries are defined in `CLAUDE.md` and `docs/03-architecture.md`.
- RAGcore and ITWorx MCP Hub are external central services.
- n8n receives post-commit events through an outbox dispatcher.
@@ -873,11 +879,15 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
and master, responsive/accessibility results, known limitations, rollback procedure)
plus 10 screenshots in `artifacts/fleet-ops-release/screenshots/`.
## Fleet Ops correction: safe status-recommendation flow, MO-016, message codes (2026-08-03) — IN PROGRESS on fix/fleet-ops-i18n-status-flow
## Fleet Ops correction: safe status-recommendation flow, MO-016, message codes (2026-08-03) — MERGED TO MASTER
Branch `fix/fleet-ops-i18n-status-flow`, created from master's post-release head
(`18344bc`). Audit and rationale in `docs/fleet-ops-correction/` (gap audit, i18n
inventory, vehicle-status decision table). Not yet merged to master.
inventory, vehicle-status decision table). Merged to master via `de0bdea` ("merge:
complete Fleet Ops localization and status resolution"), with final evidence commit
`f780557` ("docs(release): final Fleet Ops correction evidence and screenshots") —
`f780557` is `origin/master`'s current head as of the start of the correction round
below.
- **Status-recommendation flow redesigned** per the brief: the old single opaque
"calculate and apply recommended status" action is replaced by a single shared, pure
@@ -979,9 +989,85 @@ inventory, vehicle-status decision table). Not yet merged to master.
the live server (`MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236`), no console
errors, no errors in `api`/`web` container logs, both containers healthy, final
reset done, `scenario_integrity.all_ready: true`.
- **Not yet done**: the final merge to master with
`artifacts/fleet-ops-correction/final-summary.md` evidence. Do not claim PASS on
this correction until that's done.
- Final evidence: `artifacts/fleet-ops-correction/final-summary.md`. Merged to master
via `de0bdea`, followed by evidence commit `f780557` on master. See the "Fleet Ops
final localization" entry below for the next (small correction) round on top of this.
## Fleet Ops final localization: remaining NL/FR gaps, API-error localization, greeting (2026-08-04) — IN PROGRESS on fix/fleet-ops-final-i18n-ux
Branch `fix/fleet-ops-final-i18n-ux`, created from master's post-correction head
(`f780557`) — the brief asked for `fix/fleet-ops-final-localization`, but the
already-checked-out branch name is used instead since it was verified freshly and
cleanly branched from current `origin/master` with a clean working tree; see
`docs/fleet-ops-final-localization/audit.md` for the naming note. Scope: a small,
targeted correction round only — explicitly not touching status-flow business logic,
the status evaluator, Data Quality resolution rules, return rules, RAGcore/MCP Hub, or
product scope.
- **Audit-driven gap sweep**: `docs/fleet-ops-final-localization/audit.md` documents
every remaining untranslated/incorrect string, raw-backend-error call site,
over-permissive allowlist entry, the static-greeting bug, and doc staleness found by
a dedicated Explore pass before any file was touched.
- **Remaining NL/FR translation gaps fixed**: role names actually translated (not just
labelled as translated) — `auth.json`/`demo.json` role keys, `audit.title` →
"Auditgeschiedenis"/"Piste d'audit", `columns.actor` → "Uitvoerder", `list.statusOpen`
→ "Openstaand", `ledger.filterRecent` → "Recentste", `scenarios.startScenario` →
"Scenario starten". Also found and fixed (via the new embedded-substring test below)
8 previously-missed mid-sentence "Audit trail" leaks across `demo.json`,
`quality.json`, `returns.json` that the old whole-string-identity test structurally
could not catch.
- **Central API-error localization**: new `frontend/src/api/errorMessages.ts`
(`describeApiError`) replaces the `err instanceof ApiError ? err.message : ...`
anti-pattern (which showed raw English for the common case) at all 13 call sites
across 7 files. Raw backend text is now only ever shown under a "Technical
details"/"Détails techniques" disclosure (new `ApiErrorNotice` component in
`PageChrome.tsx`); the primary message is always a localized title + explanation +
optional next step, keyed on the 32 known `AppError` codes, then known HTTP statuses
(401/403/404/409/422/500), then a fully generic fallback. `ApiError` was split out of
`client.ts` into a standalone `api/apiError.ts` (no `import.meta.env` dependency) so
`errorMessages.ts` is independently testable outside a Vite/browser context.
- **i18n allowlist tightened**: removed 7 now-stale `IDENTICAL_VALUE_ALLOWLIST` entries
in `i18n-coverage.spec.ts` (`audit.title`, `auth.roleOperationsManager`,
`auth.roleRentalEmployee`, `demo.scenarios.startScenario`,
`demo.scenarios.roles.operations_manager`, `demo.scenarios.roles.rental_employee`,
`navigation.items.audit`) now that they're genuinely translated. Added 2 new tests:
one closing the embedded-English/Dutch-substring blind spot (mid-sentence phrase
leaks the whole-string check misses), one asserting no locale file contains
"MobilityOps" or the word "PoC".
- **`describeApiError` test coverage**: new `frontend/e2e/error-messages.spec.ts` (10
tests) — every known code/HTTP status has non-empty copy in all 3 locales, a known
code never surfaces raw backend text as the primary message (only via `.technical`),
unknown-code and unknown-status fallback chains behave correctly, and a drift guard
that greps the actual backend `AppError("CODE", ...)` call sites and fails if
`KNOWN_CODES` and the backend's real codes ever diverge (currently exactly in sync,
32 codes).
- **Time-dependent Europe/Brussels dashboard greeting**: new
`frontend/src/i18n/greeting.ts` (`getGreetingPeriod`, DST-safe via
`Intl.DateTimeFormat({ timeZone: "Europe/Brussels", hourCycle: "h23" })`, clock
injectable) + `useGreetingPeriod.ts` hook (30s poll for period rollover while the app
stays open, no reload). Replaces the previously-always-"Goedemorgen" static
`dashboard.json` title with 4 periods × 3 languages for both the greeting word and a
varying accompanying sentence (never "Goedenacht"). Tests: `greeting.spec.ts` (pure
boundary/DST unit tests) + `greeting-live.spec.ts` (6 real-browser tests via
Playwright's `page.clock` — all 8 required boundary times in all 3 languages, live
rollover without reload, language-switch behaviour, the "never Goedenacht" guard).
- **Found and fixed one real CSS regression along the way**: correctly translating
`roleOperationsManager` to the single unbreakable Dutch compound word
"Operationsmanager" (vs. the old two-word "Operations Manager", which could wrap)
pushed the topbar's `.operator` block past 1024px width, caught by the existing
`responsive-i18n.spec.ts` overflow test. Fixed with `overflow-wrap: anywhere` on
`.operator strong`/`small` and `min-width: 0` on their flex-item wrapper, not by
reverting the correct translation.
- Gates green so far: backend `pytest` 151 passed, `ruff check .` clean, `mypy app`
clean (49 files, unchanged — no backend Python touched this round); frontend `tsc`
clean, production build clean, full local Playwright suite **138 passed** (rebuilt
and restarted the local `web` container from source before this run).
- **Not yet done**: clean-checkout drill, commit/push, Unraid deployment of this fix
branch with live 3-language validation, the master merge (with the mandatory
`git fetch origin` / unexpected-change check first), and
`artifacts/fleet-ops-final-localization/final-summary.md`. Do not claim PASS on this
correction round until all of those are done and `git rev-parse HEAD` exactly matches
`/mnt/user/appdata/mobilityops/.deploy/source-revision`.
- Commits so far on this branch: `6deb955` (status flow + brand constant + message
codes), `e6539d1` (knowledge fixes), `ac4b163` (Playwright spec updates for the new
flow), `1fdd2b3` (new E2E coverage + 2 bug fixes), `1e40775` (accessibility test),
+5 -2
View File
@@ -9,7 +9,10 @@ knowledge base, and this documentation. "MobilityOps" remains the technical
identifier only — the repository name, local directory, package/module names, Docker
Compose project, deployment directory, and database names. The UI is fully trilingual
(nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization
architecture, the vehicle-status decision table, and the correction evidence.
architecture, the vehicle-status decision table, and the correction evidence, and
`docs/fleet-ops-final-localization/` for the follow-up correction round (remaining
NL/FR translation gaps, centralized API-error localization, the time-dependent
Europe/Brussels dashboard greeting).
The web application uses the premium responsive **Control Rail** interface: a compact
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
@@ -123,7 +126,7 @@ All defaults are configurable via `.env` (see `.env.example`).
```bash
make test # backend: pytest (151 tests)
make lint # backend: ruff + mypy (strict, zero errors)
make e2e # frontend: Playwright end-to-end (113 tests, live stack required)
make e2e # frontend: Playwright end-to-end (138 tests, live stack required)
```
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
+130
View File
@@ -0,0 +1,130 @@
# Fleet Ops final localization — gap audit
Branch: `fix/fleet-ops-final-i18n-ux` (created from `master` @ `f7805579f7c73bd3085d73a725fa985b4a4892ed`,
working tree clean at audit time). Deployed revision on Unraid at audit time: `de0bdea84fea01b4501deb7099107bc753c2e6d7`
(the merge commit; `f780557` is an evidence-only commit not separately deployed). Both containers healthy.
## 1. Remaining untranslated/incorrect text
### nl-BE
| File | Key | Current | Fix |
|---|---|---|---|
| `audit.json` | `title` | "Audit trail" | "Auditgeschiedenis" |
| `audit.json` | `columns.actor` | "Actor" | "Uitvoerder" |
| `navigation.json` | `items.audit` | "Audit trail" | "Auditgeschiedenis" |
| `auth.json` | `exploreAsOperationsManager` | "Verken als Operations Manager" | "Verken als Operationsmanager" |
| `auth.json` | `exploreAsRentalEmployee` | "Verken als Rental Employee" | "Verken als Verhuurmedewerker" |
| `auth.json` | `roleOperationsManager` | "Operations manager" | "Operationsmanager" |
| `auth.json` | `roleRentalEmployee` | "Rental employee" | "Verhuurmedewerker" |
| `demo.json` | `scenarios.roles.operations_manager` | "Operations Manager" | "Operationsmanager" |
| `demo.json` | `scenarios.roles.rental_employee` | "Rental Employee" | "Verhuurmedewerker" |
| `demo.json` | `scenarios.startScenario` | "Start scenario" | "Scenario starten" |
| `integrations.json` | `ledger.filterRecent` | "Recent" | "Recentste" |
| `quality.json` | `list.statusOpen` | "Open" | "Openstaand" |
| `audit.json`, `quality.json`, `integrations.json`, `demo.json` | 8 `managerOnly`/`whyItMatters`/`scopeBody`/etc. keys (16 nl+fr occurrences) | embedded "Operations Manager(s)" mid-sentence | "Operationsmanager(s)" |
`columns.details` ("Details") judged fine as-is: short data-table column header, genuine NL/EN cognate,
siblings are single-word labels too.
### fr-BE
Same key set as nl-BE (role labels + embedded mentions), fr-BE `columns.actor` is already correctly
"Acteur" (no fix needed). French role translations: "Responsable des opérations" /
"Collaborateur de location", per the correction brief.
### Hardcoded JSX (bypasses i18n entirely)
`frontend/src/pages/DataQualityIssueDetail.tsx` line ~213: `data-label="Field"` — literal English,
never localized. Fix: reuse the already-existing, already-translated
`detail.duplicateCustomer.fieldColumn` key (same table's `<thead>` seven lines above already uses it
correctly) — zero locale-file changes needed, pure JSX fix.
No other hardcoded `data-label`/`aria-label`/`title`/`placeholder` found across `frontend/src/**/*.tsx`.
## 2. Raw backend errors shown directly
13 call sites across 7 files (`Automation.tsx`, `ReturnForm.tsx` ×2, `DataQuality.tsx`, `DemoGuide.tsx`,
`Layout.tsx`, `DataQualityIssueDetail.tsx` ×7, `Knowledge.tsx`) all follow:
`err instanceof ApiError ? err.message : t("some:fallback")` — i.e. the **common** case (a real,
structured `ApiError` from the backend) shows raw, un-localized English `error.message` verbatim; the
translated fallback only fires for network-level failures where no `ApiError` could even be
constructed. One site (`DataQualityIssueDetail.tsx` apply-status handler) already special-cases
`err.code === "RECOMMENDATION_STALE"` inline — this needs migrating into the new central system rather
than staying a one-off.
Backend `AppError`/`HTTPException` codes found (32 semantic `AppError` codes + generic HTTP-status
fallback codes "401"/"403"/"404"/"422" for plain `HTTPException`s, verified via
`app/main.py`'s `error_body()` envelope — both AppError and HTTPException responses share the same
`{"error": {"code", "message", "correlation_id"}}` shape):
`BOOKING_NOT_ACTIVE, BOOKING_NOT_FOUND, CONFLICT_STILL_PRESENT, CORRECTED_VALUE_REQUIRED,
CORRECTION_BELOW_CANONICAL, CUSTOMER_NOT_FOUND, EMPTY_VALUE, ENTITY_NOT_FOUND, EVENT_NOT_FOUND,
IDEMPOTENCY_KEY_REUSED, INVALID_BOOKING_REFERENCE, INVALID_BOOKING_STATE, INVALID_EVENT_ID,
INVALID_FIELD, INVALID_FIELD_OVERRIDE, INVALID_IDEMPOTENCY_KEY, INVALID_SURVIVOR, ISSUE_NOT_FOUND,
ISSUE_NOT_OPEN, MANUAL_REVIEW_REQUIRED, NOT_AN_ODOMETER_ISSUE, NOT_AN_OVERLAP_ISSUE,
NOT_A_DUPLICATE_ISSUE, NOT_A_MISSING_FIELD_ISSUE, NOT_A_STATUS_CONFLICT_ISSUE, NOT_RETRYABLE,
NO_CONFLICT_DETECTED, NO_FIELDS_PROVIDED, OVERLAP_STILL_PRESENT, RECOMMENDATION_STALE,
UNAUTHORIZED_SERVICE, UNSUPPORTED_ENTITY, VEHICLE_NOT_FOUND`.
Only one code (`INVALID_SURVIVOR`) carries structured `details` params; the rest embed specifics only
in the raw English `message` string — so localized messages will be generic per-code (title +
explanation + optional next step), not parameterized with extracted specifics, with the raw string
preserved verbatim under "Technical details".
`errors.json` namespace already exists (all 3 locales) with 6 generic keys (`generic`,
`workspaceLoadFailed`, `unauthorized`, `forbidden`, `notFound`, `networkUnavailable`) but is not wired
to `ApiError.code` at all — only used as the non-`ApiError` fallback string.
## 3. i18n allowlist over-permissiveness
`frontend/e2e/i18n-coverage.spec.ts`'s `IDENTICAL_VALUE_ALLOWLIST` currently contains 4 entries that
must be removed once role labels are translated: `auth.roleOperationsManager`,
`auth.roleRentalEmployee`, `demo.scenarios.roles.operations_manager`,
`demo.scenarios.roles.rental_employee` (comment: "deliberately-untranslated role title" — no longer
true once fixed). `audit.title` and `navigation.items.audit` ("Audit trail" kept as compliance term)
also need removing once translated to "Auditgeschiedenis".
Remaining ~19 allowlist entries are genuine cognates/proper nouns/templates (verified by the audit
agent against a broad Dutch-word grep of fr-BE — zero Dutch leakage found) and should stay.
Also noted: the coverage test's identical-value check only catches **whole-string** identity to en-GB,
not **mid-sentence embedded English** (the 16 "Operations Manager(s)" occurrences above) — this is a
real blind spot the new tests (section 8 of the correction brief) need to close with a targeted,
explicit check for known English substrings appearing in nl-BE/fr-BE prose.
## 4. Dashboard greeting
No time-of-day logic exists anywhere in the codebase — `dashboard.json`'s `title` key is a **static**
string ("Good morning. Here's the fleet." / "Goedemorgen. Hier is je wagenpark." / "Bonjour. Voici
votre flotte.") shown unconditionally at all times of day, despite implying dynamism. Needs: a central,
testable, clock-injectable greeting function keyed on `Europe/Brussels` wall-clock hour, 4 periods per
the brief, updating on language change and on period rollover while the app stays open.
## 5. Documentation staleness
- `PROJECT_STATE.md` "Locked decisions" block: `"Product name: MobilityOps."` and `"PoC only..."`
predates the Fleet Ops rebrand, contradicts the later (correct) sections of the same file.
- `PROJECT_STATE.md`'s final section header still reads `"...IN PROGRESS on
fix/fleet-ops-i18n-status-flow"` and states `"Not yet merged to master"` / `"Do not claim PASS..."` —
**false**: the merge (`de0bdea`) and final evidence commit (`f780557`) both already exist in git
history, neither is mentioned in the file, and the branch name has moved on to
`fix/fleet-ops-final-i18n-ux`.
- A separate, older `"## Demo productization (in progress, same branch
feat/mobilityops-functional-completion)"` section header was also never marked complete.
- README.md is accurate and current — no fix needed there beyond a version-count refresh after this
round's test additions.
- No false "RAGcore live" / "MCP Hub connected" claims found anywhere — this part is already honest.
## Plan
1. Fix the ~10 nl-BE + ~10 fr-BE locale-file translations above (role labels, "Audit trail", "Actor",
"Start scenario", "Recent", "Open", embedded mid-sentence mentions).
2. Fix the one hardcoded `data-label="Field"` JSX bug.
3. Build a central `describeApiError(t, err)` helper + shared rendering component, wire all 13 call
sites through it, with a code→message map covering all 32 backend codes + generic HTTP fallbacks,
raw text demoted to "Technical details".
4. Tighten the allowlist (remove the 6 now-stale entries) and add a targeted embedded-English-substring
test, a `describeApiError` coverage test, and greeting boundary tests.
5. Build the time-of-day greeting function + wire into `Dashboard.tsx`, with matching locale copy for
4 periods × 3 languages + a localized description line replacing the current static one.
6. Fix `PROJECT_STATE.md` staleness (append a new dated entry, do not rewrite prior entries).
7. Full local validation → clean-checkout drill → deploy fix branch → live validation in 3 languages →
merge to master → redeploy → final evidence.
+1
View File
@@ -1,6 +1,7 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
COPY public ./public
COPY src ./src
RUN npm ci && npm run build
@@ -18,7 +18,7 @@ test("capture demo-release evidence screenshots", async ({ page, request }) => {
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 page.getByRole("button", { name: "Verken als Operationsmanager" }).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 });
+1 -1
View File
@@ -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: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
+3 -3
View File
@@ -38,7 +38,7 @@ test("demo guide does not cover the return form's action buttons on desktop", as
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 page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
@@ -74,7 +74,7 @@ test("key demo pages load without console errors", async ({ page }) => {
page.on("pageerror", (err) => errors.push(err.message));
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/scenarios");
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
@@ -96,7 +96,7 @@ test("status-recommendation panel is fully keyboard operable, respects reduced m
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality/DQ-DEMO-STATUS");
+4 -4
View File
@@ -7,8 +7,8 @@ test("demo entry screen names the fictional org and never shows a password", asy
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.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
await expect(page.locator('input[type="password"]')).toHaveCount(0);
});
@@ -26,7 +26,7 @@ test("start guided demo logs in as Operations Manager and opens the guide at ste
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 page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
@@ -45,7 +45,7 @@ test("permanent demo badge shows a popover with last reset info and a working Ab
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 page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
+4 -4
View File
@@ -12,7 +12,7 @@ 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 page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/scenarios");
@@ -27,12 +27,12 @@ test("scenario overview lists all 5 scenarios, ready right after a reset", async
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 page.getByRole("button", { name: "Verken als Operationsmanager" }).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 duplicateCard.getByRole("link", { name: "Scenario starten" }).click();
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
});
@@ -92,7 +92,7 @@ test("demo guide progress persists across navigation and the trigger shows it",
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 page.getByRole("button", { name: "Verken als Verhuurmedewerker" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
});
+5 -5
View File
@@ -12,7 +12,7 @@ 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 page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/bookings/BK-DEMO-RETURN");
@@ -27,12 +27,12 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a
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();
await expect(page.getByRole("link", { name: "Auditgeschiedenis 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 page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
@@ -43,7 +43,7 @@ test("data quality issue detail explains what's wrong and why it matters", async
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 page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality");
@@ -61,7 +61,7 @@ test("data quality list can filter to demo scenarios only", async ({ page }) =>
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 page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/knowledge");
+1 -1
View File
@@ -19,7 +19,7 @@ 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(/Synthetische demo/)).toBeVisible();
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
+205
View File
@@ -0,0 +1,205 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ApiError } from "../src/api/apiError";
import { describeApiError, KNOWN_CODES } from "../src/api/errorMessages";
// Pure Node-context checks for the central API-error-localization function (section 7 /
// 11 of the Fleet Ops final localization brief). No browser needed: describeApiError()
// only depends on a `t` function and a caught error, so it's tested here against the
// real locale JSON with a minimal i18next-shaped `t` stub -- proving the known-code and
// known-HTTP-status paths never leak raw backend English as the primary message, and
// that the raw text is always still available via `.technical` for "Technical details".
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"] as const;
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"));
}
// Mirrors the (namespace, options.defaultValue) contract react-i18next's `t` exposes,
// resolving "namespace:dotted.path" against the real locale files for the given language.
function makeT(language: string): (key: string, options?: Record<string, unknown>) => string {
return (key: string, options?: Record<string, unknown>) => {
const [ns, ...rest] = key.includes(":") ? key.split(":") : ["errors", key];
const dottedPath = key.includes(":") ? rest.join(":") : rest.join("");
const data = loadNamespace(language, ns);
const value = dottedPath.split(".").reduce<unknown>((acc, part) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
return undefined;
}, data);
if (typeof value === "string") return value;
if (options && "defaultValue" in options) return String(options.defaultValue);
return key;
};
}
const KNOWN_HTTP_STATUSES = ["401", "403", "404", "409", "422", "500"];
test("every known AppError code has a non-empty title+explanation in all 3 locales", () => {
for (const language of LANGUAGES) {
const codes = loadNamespace(language, "errors").codes as Record<string, { title?: string; explanation?: string }>;
for (const code of KNOWN_CODES) {
expect(codes[code], `${language}/errors.json is missing codes.${code}`).toBeTruthy();
expect(codes[code]?.title?.trim().length ?? 0, `${language}/errors.json:codes.${code}.title is empty`).toBeGreaterThan(0);
expect(
codes[code]?.explanation?.trim().length ?? 0,
`${language}/errors.json:codes.${code}.explanation is empty`,
).toBeGreaterThan(0);
}
}
});
test("every known HTTP status fallback has a non-empty title+explanation in all 3 locales", () => {
for (const language of LANGUAGES) {
const http = loadNamespace(language, "errors").http as Record<string, { title?: string; explanation?: string }>;
for (const status of KNOWN_HTTP_STATUSES) {
expect(http[status], `${language}/errors.json is missing http.${status}`).toBeTruthy();
expect(http[status]?.title?.trim().length ?? 0, `${language}/errors.json:http.${status}.title is empty`).toBeGreaterThan(0);
expect(
http[status]?.explanation?.trim().length ?? 0,
`${language}/errors.json:http.${status}.explanation is empty`,
).toBeGreaterThan(0);
}
}
});
test("a known AppError code resolves to its localized codes.* entry, never the raw backend message", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "IntegrityError: duplicate key value violates unique constraint";
const err = new ApiError(409, "VEHICLE_NOT_FOUND", raw, "corr-1");
const info = describeApiError(t, err);
const expected = loadNamespace(language, "errors").codes as Record<string, { title: string; explanation: string; nextStep?: string }>;
expect(info.title).toBe(expected.VEHICLE_NOT_FOUND.title);
expect(info.explanation).toBe(expected.VEHICLE_NOT_FOUND.explanation);
expect(info.title).not.toBe(raw);
expect(info.explanation).not.toBe(raw);
// The raw backend text must still be reachable, just demoted to `.technical`.
expect(info.technical).toBe(raw);
}
});
test("a code with nextStep populates it; a code without nextStep leaves it undefined", () => {
const t = makeT("nl-BE");
const withNextStep = describeApiError(t, new ApiError(422, "EMPTY_VALUE", "raw", "c1"));
expect(withNextStep.nextStep).toBeTruthy();
const withoutNextStep = describeApiError(t, new ApiError(404, "CUSTOMER_NOT_FOUND", "raw", "c2"));
expect(withoutNextStep.nextStep).toBeUndefined();
});
test("an unrecognized AppError code falls back to the matching known HTTP status, not raw text", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "Some brand-new backend code nobody localized yet";
const err = new ApiError(404, "SOME_FUTURE_CODE_NOT_YET_LOCALIZED", raw, "corr-2");
const info = describeApiError(t, err);
const expected404 = (loadNamespace(language, "errors").http as Record<string, { title: string; explanation: string }>)["404"];
expect(info.title).toBe(expected404.title);
expect(info.explanation).toBe(expected404.explanation);
expect(info.title).not.toBe(raw);
expect(info.technical).toBe(raw);
}
});
test("an unrecognized code and an unrecognized HTTP status fall back to the fully generic message", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "418 I'm a teapot (never mapped)";
const err = new ApiError(418, "418", raw, "corr-3");
const info = describeApiError(t, err);
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
expect(info.title).toBe(generic.title);
expect(info.explanation).toBe(generic.explanation);
expect(info.technical).toBe(raw);
}
});
test("a stringified HTTP status used as the AppError code (plain HTTPException path) resolves via the http map", () => {
// Mirrors app/main.py's plain-HTTPException handler, which sets code = str(status_code)
// (e.g. "401") rather than a semantic AppError code -- see backend/app/main.py.
const t = makeT("fr-BE");
const err = new ApiError(401, "401", "Not authenticated", "corr-4");
const info = describeApiError(t, err);
const expected401 = (loadNamespace("fr-BE", "errors").http as Record<string, { title: string }>)["401"];
expect(info.title).toBe(expected401.title);
expect(info.title).not.toBe("Not authenticated");
});
test("a non-ApiError (e.g. network failure before any response) uses the fallback key, never a raw JS error message as the primary text", () => {
const t = makeT("nl-BE");
const networkFailure = new TypeError("Failed to fetch");
// Real call sites (e.g. Automation.tsx) invoke t() with their own default namespace
// already scoped via useTranslation("integrations"); this stub's default namespace is
// "errors", so the fallback key is qualified explicitly here to match.
const info = describeApiError(t, networkFailure, "integrations:ledger.retryFailed");
const expectedFallback = loadNamespace("nl-BE", "integrations").ledger as Record<string, string>;
expect(info.explanation).toBe(expectedFallback.retryFailed);
expect(info.explanation).not.toBe("Failed to fetch");
expect(info.technical).toBe("Failed to fetch");
});
// --- Backend/frontend AppError code drift guard ---
// KNOWN_CODES is a hand-maintained mirror of every `raise AppError("CODE", ...)` in the
// backend (see app/core/errors.py::AppError and every raise site). If the backend adds a
// new code and nobody updates KNOWN_CODES, it silently falls back to the generic-but-
// still-localized HTTP/generic message rather than raw English -- not a broken build, but
// a missed opportunity for a more specific message. This test surfaces that drift instead
// of letting it go unnoticed indefinitely.
const BACKEND_APP_DIR = path.resolve(__dirname, "../../backend/app");
function collectPyFiles(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return collectPyFiles(full);
return entry.name.endsWith(".py") ? [full] : [];
});
}
function collectBackendAppErrorCodes(): Set<string> {
const codes = new Set<string>();
for (const file of collectPyFiles(BACKEND_APP_DIR)) {
const source = fs.readFileSync(file, "utf-8");
const pattern = /AppError\(\s*"([A-Z_]+)"/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
codes.add(match[1]);
}
}
return codes;
}
test("frontend KNOWN_CODES exactly matches every AppError code actually raised by the backend", () => {
const backendCodes = collectBackendAppErrorCodes();
const frontendCodes = KNOWN_CODES;
const missingFromFrontend = [...backendCodes].filter((c) => !frontendCodes.has(c)).sort();
const staleInFrontend = [...frontendCodes].filter((c) => !backendCodes.has(c)).sort();
expect(
missingFromFrontend,
`Backend raises AppError code(s) with no localized entry in errorMessages.ts KNOWN_CODES ` +
`(they'll fall back to a generic/HTTP-status message): ${missingFromFrontend.join(", ")}`,
).toEqual([]);
expect(
staleInFrontend,
`errorMessages.ts KNOWN_CODES lists code(s) the backend never raises -- likely renamed or ` +
`removed on the backend side: ${staleInFrontend.join(", ")}`,
).toEqual([]);
});
test("a non-ApiError with no fallbackKey uses the fully generic explanation", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const info = describeApiError(t, new TypeError("Failed to fetch"));
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
expect(info.title).toBe(generic.title);
expect(info.explanation).toBe(generic.explanation);
}
});
+12 -2
View File
@@ -19,9 +19,9 @@ async function resetDemoData(request: APIRequestContext) {
}
const EXPLORE_OPS_MANAGER: Record<string, string> = {
"nl-BE": "Verken als Operations Manager",
"nl-BE": "Verken als Operationsmanager",
"en-GB": "Explore as Operations Manager",
"fr-BE": "Explorer en tant qu'Operations Manager",
"fr-BE": "Explorer en tant que Responsable des opérations",
};
const REVIEW_RECOMMENDATION: Record<string, string> = {
@@ -74,6 +74,16 @@ test.describe("branding", () => {
}
});
test("the Fleet Ops favicon is linked and resolves (not the browser's blank-tab default)", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/login");
const href = await page.locator('link[rel="icon"]').getAttribute("href");
expect(href).toBe("/favicon.svg");
const response = await page.request.get(href as string);
expect(response.ok()).toBeTruthy();
expect(response.headers()["content-type"]).toContain("svg");
});
test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => {
await resetDemoData(request);
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force
+107
View File
@@ -0,0 +1,107 @@
import { expect, type Page, test } from "@playwright/test";
// Browser-context evidence for the time-dependent Europe/Brussels dashboard greeting
// (section 9 / 11 / 16 of the Fleet Ops final localization brief): the real rendered app,
// in all 3 languages, at every required boundary instant, using Playwright's clock API to
// control the browser's Date without waiting on real wall-clock time. Also proves the
// greeting updates live (no reload) when the period rolls over while the app stays open.
// 2026-01-15 is CET (UTC+1): Brussels hour = UTC hour + 1.
function cet(hour: number, minute = 0, second = 0): Date {
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute, second));
}
const BOUNDARY_CASES: Array<{ time: Date; label: string; period: string }> = [
{ time: cet(4, 59), label: "04:59", period: "night" },
{ time: cet(5, 0), label: "05:00", period: "morning" },
{ time: cet(11, 59), label: "11:59", period: "morning" },
{ time: cet(12, 0), label: "12:00", period: "afternoon" },
{ time: cet(17, 59), label: "17:59", period: "afternoon" },
{ time: cet(18, 0), label: "18:00", period: "evening" },
{ time: cet(22, 59), label: "22:59", period: "evening" },
{ time: cet(23, 0), label: "23:00", period: "night" },
];
const EXPECTED_TITLE: Record<string, Record<string, string>> = {
"nl-BE": {
morning: "Goedemorgen. Hier is de status van je wagenpark voor vandaag.",
afternoon: "Goedemiddag. Hier is het actuele overzicht van je wagenpark.",
evening: "Goedenavond. Hier is het overzicht van je wagenpark voor vanavond.",
night: "Welkom terug. Hier is het laatste overzicht van je wagenpark.",
},
"en-GB": {
morning: "Good morning. Here's today's fleet status.",
afternoon: "Good afternoon. Here's the current overview of your fleet.",
evening: "Good evening. Here's this evening's fleet overview.",
night: "Welcome back. Here's the latest overview of your fleet.",
},
"fr-BE": {
morning: "Bonjour. Voici l'état de votre flotte pour aujourd'hui.",
afternoon: "Bonjour. Voici l'aperçu actuel de votre flotte.",
evening: "Bonsoir. Voici l'aperçu de votre flotte pour ce soir.",
night: "Bon retour. Voici le dernier aperçu de votre flotte.",
},
};
async function loginAsOperationsManager(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
async function switchLanguage(page: Page, language: "nl-BE" | "en-GB" | "fr-BE") {
// At the default desktop viewport, only the topbar's compact switcher is visible --
// the sidebar's full switcher is `display: none` until the <960px breakpoint.
await page.locator(".language-switcher-compact select").selectOption(language);
}
const dashboardHeading = (page: Page) => page.locator(".page-header h1");
for (const language of ["nl-BE", "en-GB", "fr-BE"] as const) {
test(`dashboard greeting matches every required boundary time in ${language}`, async ({ page }) => {
await page.clock.install({ time: BOUNDARY_CASES[0].time });
await loginAsOperationsManager(page);
if (language !== "nl-BE") {
await switchLanguage(page, language);
}
for (const { time, label, period } of BOUNDARY_CASES) {
await page.clock.setFixedTime(time);
await page.reload();
await expect(dashboardHeading(page), `${language} @ ${label} Brussels time (expected period: ${period})`).toHaveText(
EXPECTED_TITLE[language][period],
);
}
});
}
test("dashboard greeting updates live across a period rollover without a page reload", async ({ page }) => {
// Start at 11:59:31 Brussels -- 29s before the 12:00 boundary.
await page.clock.install({ time: cet(11, 59, 31) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
// Advance 30s of fake time (crossing 12:00) so the hook's 30s poll interval fires and
// recomputes the period -- no page.reload() call anywhere in this test.
await page.clock.fastForward(30_000);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].afternoon);
});
test("dashboard greeting updates immediately on language switch without changing the time period", async ({ page }) => {
await page.clock.install({ time: cet(9, 0) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
await switchLanguage(page, "en-GB");
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["en-GB"].morning);
await switchLanguage(page, "fr-BE");
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["fr-BE"].morning);
});
test("dashboard never shows 'Goedenacht' as a greeting at any hour", async ({ page }) => {
await page.clock.install({ time: cet(2, 0) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).not.toContainText("Goedenacht");
await expect(dashboardHeading(page)).toContainText("Welkom terug");
});
+68
View File
@@ -0,0 +1,68 @@
import { expect, test } from "@playwright/test";
import { getBrusselsHour, getGreetingPeriod } from "../src/i18n/greeting";
// Pure Node-context boundary tests for the central, clock-injectable greeting function
// (section 9 of the Fleet Ops final localization brief). Every case below constructs an
// explicit UTC instant that corresponds to a specific Europe/Brussels wall-clock time --
// this is what "clock injection" buys: no real time needs to pass, and DST is exercised
// by picking instants either side of the CET/CEST transition.
// 2026-01-15 is CET (UTC+1): 04:59 Brussels = 03:59 UTC.
function cet(hour: number, minute = 0): Date {
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute));
}
// 2026-07-15 is CEST (UTC+2): 04:59 Brussels = 02:59 UTC.
function cest(hour: number, minute = 0): Date {
return new Date(Date.UTC(2026, 6, 15, hour - 2, minute));
}
test("period boundaries are correct in winter time (CET, UTC+1)", () => {
expect(getGreetingPeriod(cet(4, 59))).toBe("night");
expect(getGreetingPeriod(cet(5, 0))).toBe("morning");
expect(getGreetingPeriod(cet(11, 59))).toBe("morning");
expect(getGreetingPeriod(cet(12, 0))).toBe("afternoon");
expect(getGreetingPeriod(cet(17, 59))).toBe("afternoon");
expect(getGreetingPeriod(cet(18, 0))).toBe("evening");
expect(getGreetingPeriod(cet(22, 59))).toBe("evening");
expect(getGreetingPeriod(cet(23, 0))).toBe("night");
});
test("period boundaries are correct in summer time (CEST, UTC+2)", () => {
expect(getGreetingPeriod(cest(4, 59))).toBe("night");
expect(getGreetingPeriod(cest(5, 0))).toBe("morning");
expect(getGreetingPeriod(cest(11, 59))).toBe("morning");
expect(getGreetingPeriod(cest(12, 0))).toBe("afternoon");
expect(getGreetingPeriod(cest(17, 59))).toBe("afternoon");
expect(getGreetingPeriod(cest(18, 0))).toBe("evening");
expect(getGreetingPeriod(cest(22, 59))).toBe("evening");
expect(getGreetingPeriod(cest(23, 0))).toBe("night");
});
test("DST transition (2026-03-29, clocks spring forward 02:00 -> 03:00 CEST): the Brussels hour never regresses or skips a period incorrectly", () => {
// 00:30 UTC = 01:30 CET, still "night" (before the 05:00 boundary regardless).
const beforeTransition = new Date(Date.UTC(2026, 2, 29, 0, 30));
expect(getBrusselsHour(beforeTransition)).toBe(1);
expect(getGreetingPeriod(beforeTransition)).toBe("night");
// 09:00 UTC on transition day = 11:00 CEST (already sprung forward) -- still morning.
const afterTransition = new Date(Date.UTC(2026, 2, 29, 9, 0));
expect(getBrusselsHour(afterTransition)).toBe(11);
expect(getGreetingPeriod(afterTransition)).toBe("morning");
// Autumn transition, 2026-10-25: fall-back happens at 01:00 UTC (03:00 CEST -> 02:00
// CET), so 00:30 UTC is still CEST -> 02:30 Brussels.
const beforeFallBack = new Date(Date.UTC(2026, 9, 25, 0, 30));
expect(getBrusselsHour(beforeFallBack)).toBe(2);
expect(getGreetingPeriod(beforeFallBack)).toBe("night");
// 09:00 UTC on fall-back day = 10:00 CET (already fallen back) -- still morning.
const afterFallBack = new Date(Date.UTC(2026, 9, 25, 9, 0));
expect(getBrusselsHour(afterFallBack)).toBe(10);
expect(getGreetingPeriod(afterFallBack)).toBe("morning");
});
test("default argument uses the real current time when no clock is injected", () => {
const period = getGreetingPeriod();
expect(["morning", "afternoon", "evening", "night"]).toContain(period);
});
+73 -7
View File
@@ -97,6 +97,24 @@ test("no locale file defines an 'appName' key or the literal brand string", () =
}
});
test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => {
for (const language of LANGUAGES) {
for (const namespace of namespaces) {
const raw = JSON.stringify(loadNamespace(language, namespace));
expect(
raw.includes("MobilityOps"),
`${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` +
`always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`,
).toBe(false);
expect(
/\bPoC\b/.test(raw),
`${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` +
`in user-facing copy`,
).toBe(false);
}
}
});
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
// the literal English string under the right key and still pass). For every "real prose"
@@ -110,19 +128,13 @@ test("no locale file defines an 'appName' key or the literal brand string", () =
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
// hide an unrelated real mistranslation under the same key in a different namespace.
const IDENTICAL_VALUE_ALLOWLIST = new Set([
"audit.title", // "Audit trail" kept as an established cross-language compliance term
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
"auth.roleOperationsManager", // deliberately-untranslated role title (see demo.json roles)
"auth.roleRentalEmployee",
"demo.scenarios.roles.operations_manager", // same convention, scenario-overview role labels
"demo.scenarios.roles.rental_employee",
"common.language.nl-BE", // language-picker options show each language's own endonym
"common.language.fr-BE",
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
"demo.scenarios.startScenario", // "start"/"scenario" are naturalised loanwords in Dutch
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
@@ -131,7 +143,6 @@ const IDENTICAL_VALUE_ALLOWLIST = new Set([
"knowledge.questionLabel", // "Question" -- identical spelling in French
"knowledge.retrievalFlow.question",
"knowledge.questionLabelExchange",
"navigation.items.audit", // "Audit trail" kept as an established cross-language term
"returns.result.inspection", // "Inspection" -- identical spelling in French
]);
@@ -185,6 +196,61 @@ test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or ea
}
});
// --- Embedded English/Dutch fragments inside otherwise-translated prose ---
// The whole-string identity check above only catches a value that is IDENTICAL to
// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final
// localization pass: a sentence gets 95% translated but a role/status noun phrase is
// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden
// goedgekeurd." This scan flags known English fragments appearing literally inside any
// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste
// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common
// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary).
const FORBIDDEN_ENGLISH_FRAGMENTS = [
"Operations Manager",
"Operations Managers",
"Rental Employee",
"Rental Employees",
"Audit trail",
"Start scenario",
];
const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [
"Operationsmanager",
"Verhuurmedewerker",
"Auditgeschiedenis",
"Scenario starten",
];
function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> {
if (typeof value === "string") return [{ path: prefix, value }];
if (value === null || typeof value !== "object") return [];
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key),
);
}
test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => {
const findings: string[] = [];
for (const namespace of namespaces) {
const nl = loadNamespace("nl-BE", namespace);
const fr = loadNamespace("fr-BE", namespace);
for (const { path: keyPath, value } of collectStringLeaves(nl)) {
for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) {
if (value.includes(fragment)) {
findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`);
}
}
}
for (const { path: keyPath, value } of collectStringLeaves(fr)) {
for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) {
if (value.includes(fragment)) {
findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`);
}
}
}
}
expect(findings, findings.join("\n")).toEqual([]);
});
// --- Hardcoded JSX text (section 11D) ---
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
// `{...}` expression) containing two or more real words are almost always user-facing
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Fleet Ops</title>
</head>
<body>
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0f172a" />
<path d="M5 23V8l7.1 8L19 8h8v15h-5V14l-9.9 11L10 22.6V23H5Z" fill="white" />
<path d="M3 15.5h26" stroke="#2dd4bf" stroke-width="2.5" />
</svg>

After

Width:  |  Height:  |  Size: 266 B

+12
View File
@@ -0,0 +1,12 @@
export class ApiError extends Error {
status: number;
code: string;
correlationId: string;
constructor(status: number, code: string, message: string, correlationId: string) {
super(message);
this.status = status;
this.code = code;
this.correlationId = correlationId;
}
}
+4 -13
View File
@@ -1,3 +1,7 @@
import { ApiError } from "./apiError";
export { ApiError } from "./apiError";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
type UnauthorizedListener = () => void;
@@ -8,19 +12,6 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
return () => unauthorizedListeners.delete(listener);
}
export class ApiError extends Error {
status: number;
code: string;
correlationId: string;
constructor(status: number, code: string, message: string, correlationId: string) {
super(message);
this.status = status;
this.code = code;
this.correlationId = correlationId;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
+100
View File
@@ -0,0 +1,100 @@
import { ApiError } from "./apiError";
export interface ApiErrorInfo {
title: string;
explanation: string;
nextStep?: string;
technical: string;
}
type TFn = (key: string, options?: Record<string, unknown>) => string;
// Backend AppError codes this frontend knows how to present with a localized title,
// explanation and (where useful) a next step -- see
// backend/app/core/errors.py::AppError and every `raise AppError("CODE", ...)` site.
// Anything not in this list still gets a sensible HTTP-status-based fallback below, so
// a newly-introduced backend code never regresses to raw English -- it just falls back
// to a generic-but-localized message until this list is extended.
export const KNOWN_CODES = new Set([
"VEHICLE_NOT_FOUND",
"BOOKING_NOT_FOUND",
"CUSTOMER_NOT_FOUND",
"ENTITY_NOT_FOUND",
"EVENT_NOT_FOUND",
"ISSUE_NOT_FOUND",
"ISSUE_NOT_OPEN",
"BOOKING_NOT_ACTIVE",
"INVALID_BOOKING_STATE",
"NOT_RETRYABLE",
"CONFLICT_STILL_PRESENT",
"OVERLAP_STILL_PRESENT",
"EMPTY_VALUE",
"NO_FIELDS_PROVIDED",
"INVALID_FIELD",
"INVALID_FIELD_OVERRIDE",
"INVALID_SURVIVOR",
"INVALID_BOOKING_REFERENCE",
"INVALID_EVENT_ID",
"INVALID_IDEMPOTENCY_KEY",
"IDEMPOTENCY_KEY_REUSED",
"CORRECTED_VALUE_REQUIRED",
"CORRECTION_BELOW_CANONICAL",
"NOT_A_DUPLICATE_ISSUE",
"NOT_A_MISSING_FIELD_ISSUE",
"NOT_AN_ODOMETER_ISSUE",
"NOT_AN_OVERLAP_ISSUE",
"NOT_A_STATUS_CONFLICT_ISSUE",
"UNSUPPORTED_ENTITY",
"MANUAL_REVIEW_REQUIRED",
"NO_CONFLICT_DETECTED",
"RECOMMENDATION_STALE",
"UNAUTHORIZED_SERVICE",
]);
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
/**
* Turns a caught error into a localized {title, explanation, nextStep?, technical}
* for display. The raw backend/network text is only ever exposed as `technical`
* (shown under "Technical details" by ApiErrorNotice) -- never as the primary message.
*
* `fallbackKey` is an existing, already-localized `t()` key used as the explanation
* when the error isn't an ApiError at all (e.g. the fetch failed before a response
* existed) and errors:generic doesn't fit the specific action being attempted.
*/
export function describeApiError(t: TFn, err: unknown, fallbackKey?: string): ApiErrorInfo {
if (!(err instanceof ApiError)) {
return {
title: t("errors:generic.title"),
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
technical: err instanceof Error ? err.message : String(err),
};
}
if (KNOWN_CODES.has(err.code)) {
const nextStep = t(`errors:codes.${err.code}.nextStep`, { defaultValue: "" });
return {
title: t(`errors:codes.${err.code}.title`),
explanation: t(`errors:codes.${err.code}.explanation`),
nextStep: nextStep || undefined,
technical: err.message,
};
}
const httpKey = KNOWN_HTTP_STATUSES.has(err.code) ? err.code : String(err.status);
if (KNOWN_HTTP_STATUSES.has(httpKey)) {
const nextStep = t(`errors:http.${httpKey}.nextStep`, { defaultValue: "" });
return {
title: t(`errors:http.${httpKey}.title`),
explanation: t(`errors:http.${httpKey}.explanation`),
nextStep: nextStep || undefined,
technical: err.message,
};
}
return {
title: t("errors:generic.title"),
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
technical: err.message,
};
}
+6 -4
View File
@@ -1,13 +1,15 @@
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 { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
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";
import { ApiErrorNotice } from "./PageChrome";
import { PRODUCT_NAME } from "../product";
export function DemoGuideTrigger() {
@@ -68,7 +70,7 @@ export function DemoGuide() {
setCollapsedToChip,
} = useDemoGuide();
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null);
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
const pendingTarget = useRef<string | null>(null);
@@ -119,7 +121,7 @@ export function DemoGuide() {
await logout();
navigate("/login");
} catch (err) {
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
setResetError(describeApiError(t, err, "guide.restartFailed"));
} finally {
setResetting(false);
}
@@ -227,7 +229,7 @@ export function DemoGuide() {
</nav>
)}
{resetError && <p className="error" role="alert">{resetError}</p>}
<ApiErrorNotice error={resetError} />
<footer className="demo-guide-footer">
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
+6 -4
View File
@@ -1,7 +1,8 @@
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 { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { useAuth } from "../context/AuthContext";
import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons";
@@ -11,6 +12,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { PRODUCT_NAME } from "../product";
import { ApiErrorNotice } from "./PageChrome";
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
vehicle: "fleet",
@@ -86,7 +88,7 @@ export function Layout() {
const [activeIndex, setActiveIndex] = useState(-1);
const [resetConfirming, setResetConfirming] = useState(false);
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null);
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const searchInput = useRef<HTMLInputElement>(null);
const searchBox = useRef<HTMLDivElement>(null);
@@ -163,7 +165,7 @@ export function Layout() {
await logout();
navigate("/login");
} catch (err) {
setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
setResetError(describeApiError(t, err, "resetFailed"));
setResetConfirming(false);
} finally {
setResetting(false);
@@ -231,7 +233,7 @@ export function Layout() {
</div>
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset">
{resetError && <p className="error" role="alert">{resetError}</p>}
<ApiErrorNotice error={resetError} />
{!resetConfirming ? (
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
{t("resetDemoData")}
+20
View File
@@ -67,6 +67,26 @@ export function ErrorState({ message }: { message: string }) {
);
}
// Shared rendering for describeApiError()'s output: a localized title + explanation +
// optional next step, with the raw backend/network text demoted to a "Technical
// details" disclosure -- never shown as the primary message. See
// frontend/src/api/errorMessages.ts and docs/fleet-ops-final-localization/audit.md.
export function ApiErrorNotice({ error }: { error: import("../api/errorMessages").ApiErrorInfo | null }) {
const { t } = useTranslation("common");
if (!error) return null;
return (
<div className="error api-error-notice" role="alert">
<strong>{error.title}</strong>
<p>{error.explanation}</p>
{error.nextStep && <p className="api-error-next-step">{error.nextStep}</p>}
<details className="evidence-disclosure">
<summary>{t("actions.technicalDetails")}</summary>
<pre className="evidence-block">{error.technical}</pre>
</details>
</div>
);
}
export function EmptyState({
icon = "check",
title,
+7 -5
View File
@@ -1,7 +1,8 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
@@ -10,6 +11,7 @@ import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons";
import { StatusBadge } from "./Badge";
import { ApiErrorNotice } from "./PageChrome";
function newIdempotencyKey(): string {
return typeof crypto.randomUUID === "function"
@@ -103,7 +105,7 @@ export function ReturnForm({
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [idempotencyKey] = useState(newIdempotencyKey);
const [step, setStep] = useState<"capture" | "review">("capture");
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
@@ -132,7 +134,7 @@ export function ReturnForm({
setPreview(evaluated);
setStep("review");
} catch (err) {
setError(err instanceof ApiError ? err.message : t("errors:generic"));
setError(describeApiError(t, err));
} finally {
setPreviewing(false);
}
@@ -148,7 +150,7 @@ export function ReturnForm({
);
onRegistered(registered);
} catch (err) {
setError(err instanceof ApiError ? err.message : t("errors:generic"));
setError(describeApiError(t, err));
} finally {
setSubmitting(false);
}
@@ -158,7 +160,7 @@ export function ReturnForm({
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
<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>}
<ApiErrorNotice error={error} />
{step === "capture" ? <div className="return-capture">
<div className="form-grid"><label>
+28
View File
@@ -0,0 +1,28 @@
export type GreetingPeriod = "morning" | "afternoon" | "evening" | "night";
const BRUSSELS_TIME_ZONE = "Europe/Brussels";
// DST-safe: Intl.DateTimeFormat resolves the correct Europe/Brussels wall-clock hour for
// any instant, automatically accounting for the CET/CEST transition -- no manual UTC
// offset math (which would silently break twice a year) is needed. `hourCycle: "h23"`
// pins the output to a plain 0-23 range (some engines otherwise render midnight as "24").
export function getBrusselsHour(date: Date): number {
const formatter = new Intl.DateTimeFormat("en-GB", {
timeZone: BRUSSELS_TIME_ZONE,
hour: "numeric",
hourCycle: "h23",
});
return Number(formatter.format(date));
}
// Boundaries per the Fleet Ops final localization brief (section 9):
// 05:00-11:59 morning, 12:00-17:59 afternoon, 18:00-22:59 evening, 23:00-04:59 night.
// `date` defaults to `new Date()` but accepts any Date so callers (and tests) can inject
// a fixed clock instead of depending on the real wall clock.
export function getGreetingPeriod(date: Date = new Date()): GreetingPeriod {
const hour = getBrusselsHour(date);
if (hour >= 5 && hour < 12) return "morning";
if (hour >= 12 && hour < 18) return "afternoon";
if (hour >= 18 && hour < 23) return "evening";
return "night";
}
+12 -1
View File
@@ -1,6 +1,17 @@
{
"eyebrow": "Operations / Live overview",
"title": "Good morning. Here's the fleet.",
"greeting": {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
"night": "Welcome back"
},
"greetingBody": {
"morning": "Here's today's fleet status.",
"afternoon": "Here's the current overview of your fleet.",
"evening": "Here's this evening's fleet overview.",
"night": "Here's the latest overview of your fleet."
},
"description": "Readiness, exceptions and hand-offs across today's operation.",
"viewFleet": "View fleet",
"demoStart": {
+178 -6
View File
@@ -1,8 +1,180 @@
{
"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."
"generic": {
"title": "Something went wrong",
"explanation": "This action could not be completed. Please try again."
},
"codes": {
"VEHICLE_NOT_FOUND": {
"title": "Vehicle not found",
"explanation": "This vehicle could not be found. It may have been removed or the reference may be incorrect."
},
"BOOKING_NOT_FOUND": {
"title": "Booking not found",
"explanation": "This booking could not be found. It may have been removed or the reference may be incorrect."
},
"CUSTOMER_NOT_FOUND": {
"title": "Customer not found",
"explanation": "This customer record could not be found."
},
"ENTITY_NOT_FOUND": {
"title": "Record not found",
"explanation": "The underlying record for this action could not be found."
},
"EVENT_NOT_FOUND": {
"title": "Workflow event not found",
"explanation": "This automation event could not be found."
},
"ISSUE_NOT_FOUND": {
"title": "Data-quality issue not found",
"explanation": "This data-quality issue could not be found."
},
"ISSUE_NOT_OPEN": {
"title": "Issue is no longer open",
"explanation": "This issue has already been resolved, deferred or rejected.",
"nextStep": "Refresh the page to see its current state."
},
"BOOKING_NOT_ACTIVE": {
"title": "Booking is not active",
"explanation": "Only a reserved or active booking can be used for this action."
},
"INVALID_BOOKING_STATE": {
"title": "Booking is in the wrong state",
"explanation": "This booking's current state does not allow this action."
},
"NOT_RETRYABLE": {
"title": "This event cannot be retried",
"explanation": "Only a failed delivery can be retried."
},
"CONFLICT_STILL_PRESENT": {
"title": "Conflict was not resolved",
"explanation": "Applying this change did not resolve the underlying conflict.",
"nextStep": "Review the recommendation again before retrying."
},
"OVERLAP_STILL_PRESENT": {
"title": "Overlap was not resolved",
"explanation": "Blocking this booking did not remove the overlap; another commitment remains."
},
"EMPTY_VALUE": {
"title": "A required field is blank",
"explanation": "This field cannot be left blank.",
"nextStep": "Enter a value and try again."
},
"NO_FIELDS_PROVIDED": {
"title": "No changes provided",
"explanation": "At least one field must be filled in to continue."
},
"INVALID_FIELD": {
"title": "Field not allowed here",
"explanation": "One of the fields provided is not permitted for this action."
},
"INVALID_FIELD_OVERRIDE": {
"title": "Field cannot be merged",
"explanation": "One of the selected fields cannot be used in this merge."
},
"INVALID_SURVIVOR": {
"title": "Invalid selection",
"explanation": "The record you selected to keep must be one of the two records being compared."
},
"INVALID_BOOKING_REFERENCE": {
"title": "Booking reference not valid here",
"explanation": "The selected booking is not one of the bookings related to this issue."
},
"INVALID_EVENT_ID": {
"title": "Invalid event reference",
"explanation": "This automation event reference is not valid."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "Request could not be repeated safely",
"explanation": "This request's tracking key is not valid.",
"nextStep": "Reload the page and try again."
},
"IDEMPOTENCY_KEY_REUSED": {
"title": "This action was already submitted",
"explanation": "An identical request was already processed with a different outcome.",
"nextStep": "Reload the page to see the current state before retrying."
},
"CORRECTED_VALUE_REQUIRED": {
"title": "A corrected value is required",
"explanation": "Choose \"correct the reading\" requires entering the corrected value."
},
"CORRECTION_BELOW_CANONICAL": {
"title": "Correction is below the confirmed reading",
"explanation": "A corrected odometer reading can never be lower than the last confirmed reading."
},
"NOT_A_DUPLICATE_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to possible-duplicate-customer issues."
},
"NOT_A_MISSING_FIELD_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to missing-required-field issues."
},
"NOT_AN_ODOMETER_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to odometer-regression issues."
},
"NOT_AN_OVERLAP_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to booking-overlap issues."
},
"NOT_A_STATUS_CONFLICT_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to vehicle-status-conflict issues."
},
"UNSUPPORTED_ENTITY": {
"title": "Not supported for this record type",
"explanation": "This action is not available for this kind of record."
},
"MANUAL_REVIEW_REQUIRED": {
"title": "Manual review required",
"explanation": "The facts for this vehicle contradict each other, so no automatic change is safe.",
"nextStep": "Defer or reject this issue, or investigate manually."
},
"NO_CONFLICT_DETECTED": {
"title": "Nothing to apply",
"explanation": "The current state no longer conflicts, so there is nothing left to apply."
},
"RECOMMENDATION_STALE": {
"title": "The situation has changed",
"explanation": "The underlying facts changed since this recommendation was shown.",
"nextStep": "Review the recommendation again before applying it."
},
"UNAUTHORIZED_SERVICE": {
"title": "Service authorisation failed",
"explanation": "This automated request could not be authorised."
}
},
"http": {
"401": {
"title": "Session expired",
"explanation": "Your session has expired.",
"nextStep": "Please log in again."
},
"403": {
"title": "Permission denied",
"explanation": "You don't have permission to perform this action."
},
"404": {
"title": "Not found",
"explanation": "This record could not be found."
},
"409": {
"title": "This action is no longer possible",
"explanation": "The underlying state has changed since this page was loaded.",
"nextStep": "Refresh the page and try again."
},
"422": {
"title": "Validation failed",
"explanation": "The information provided is not valid."
},
"500": {
"title": "Server error",
"explanation": "Something went wrong on our side."
},
"network": {
"title": "Connection unavailable",
"explanation": "The connection to the server is currently unavailable.",
"nextStep": "Check your connection and try again."
}
}
}
+3 -3
View File
@@ -4,8 +4,8 @@
"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.",
"managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.",
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.",
"loading": "Chargement de la piste d'audit…",
"unavailable": "La piste d'audit est actuellement indisponible.",
"empty": "Aucun événement d'audit trouvé",
@@ -16,7 +16,7 @@
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
"columns": {
"when": "Quand",
"actor": "Acteur",
"actor": "Auteur",
"action": "Action",
"entity": "Entité",
"change": "Changement",
+4 -4
View File
@@ -9,15 +9,15 @@
"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",
"exploreAsOperationsManager": "Explorer en tant que Responsable des opérations",
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
"exploreAsRentalEmployee": "Explorer en tant que Rental Employee",
"exploreAsRentalEmployee": "Explorer en tant que Collaborateur de location",
"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",
"roleOperationsManager": "Responsable des opérations",
"roleRentalEmployee": "Collaborateur de location",
"switchRole": "Changer de rôle",
"logout": "Déconnexion"
}
+12 -1
View File
@@ -1,6 +1,17 @@
{
"eyebrow": "Exploitation / Aperçu en direct",
"title": "Bonjour. Voici votre flotte.",
"greeting": {
"morning": "Bonjour",
"afternoon": "Bonjour",
"evening": "Bonsoir",
"night": "Bon retour"
},
"greetingBody": {
"morning": "Voici l'état de votre flotte pour aujourd'hui.",
"afternoon": "Voici l'aperçu actuel de votre flotte.",
"evening": "Voici l'aperçu de votre flotte pour ce soir.",
"night": "Voici le dernier aperçu de votre flotte."
},
"description": "Disponibilité, exceptions et transferts de l'activité en cours.",
"viewFleet": "Voir la flotte",
"demoStart": {
+5 -5
View File
@@ -32,7 +32,7 @@
"understand-state": {
"title": "1. Comprendre l'état opérationnel",
"whatYouWillSee": "Le tableau de bord affiche la disponibilité de la flotte, les points d'attention ouverts et les mouvements du jour.",
"whyItMatters": "Un Operations Manager commence chaque journée par cet aperçu pour décider où intervenir.",
"whyItMatters": "Un Responsable des opérations commence chaque journée par cet aperçu pour décider où intervenir.",
"startAction": "Ouvrez le tableau de bord et consultez la file d'attention et la chronologie du jour.",
"expectedOutcome": "Vous voyez quelles réservations, véhicules ou problèmes de qualité nécessitent de l'attention."
},
@@ -102,8 +102,8 @@
"startScenario": "Démarrer le scénario",
"roleOr": "{{a}} ou {{b}}",
"roles": {
"operations_manager": "Operations Manager",
"rental_employee": "Rental Employee"
"operations_manager": "Responsable des opérations",
"rental_employee": "Collaborateur de location"
},
"items": {
"return-anomaly": {
@@ -155,7 +155,7 @@
"problemTitle": "Le problème fictif",
"problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. {{productName}} montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.",
"scopeTitle": "Pour qui et avec quelle portée",
"scopeBody": "Cette démo s'adresse à quiconque veut voir comment {{productName}} traite les problèmes opérationnels d'un petit loueur : Operations Managers et Rental Employees, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.",
"scopeBody": "Cette démo s'adresse à quiconque veut voir comment {{productName}} traite les problèmes opérationnels d'un petit loueur : Responsables des opérations et Collaborateurs de location, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.",
"realTitle": "Ce qui fonctionne réellement",
"realBody": "Tout ce qui suit est du code fonctionnel, pas seulement une maquette : accès et sessions basés sur les rôles, gestion des véhicules et réservations, traitement des retours avec validation côté serveur, cinq règles de qualité des données avec chacune sa propre étape de résolution, une piste d'audit complète, une livraison automatisée vers n8n avec nouvelles tentatives limitées, un déploiement basé sur Docker, et une suite de tests automatisés (backend et Playwright de bout en bout).",
"syntheticTitle": "Ce qui est synthétique",
@@ -170,7 +170,7 @@
"integrationsDescription": "Ce qui est opérationnel, ce qui est en mode démo, et ce qui n'est pas encore connecté.",
"resetTitle": "Restaurer l'environnement de démo",
"resetBodyManager": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Utilisez <strong>Réinitialiser les données de démo</strong> dans la barre latérale pour recommencer.",
"resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Un Operations Manager peut réinitialiser l'environnement de démo via la barre latérale.",
"resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Un Responsable des opérations peut réinitialiser l'environnement de démo via la barre latérale.",
"limitationsTitle": "Limitations",
"limitationsBody": "Ceci est une preuve de concept ciblée, pas un ERP complet. RAGcore et l'ITWorx MCP Hub ne sont pas encore connectés en direct ; l'assistant de connaissances utilise une base de connaissances de démo locale et délimitée au lieu d'un environnement RAGcore en direct.",
"unknown": "inconnu"
+178 -6
View File
@@ -1,8 +1,180 @@
{
"generic": "Une erreur est survenue. Veuillez réessayer.",
"workspaceLoadFailed": "Nous n'avons pas pu charger cet espace de travail.",
"unauthorized": "Votre session a expiré. Veuillez vous reconnecter.",
"forbidden": "Vous n'avez pas accès à cette section.",
"notFound": "Cette fiche est introuvable.",
"networkUnavailable": "La connexion au serveur est actuellement indisponible."
"generic": {
"title": "Une erreur s'est produite",
"explanation": "Cette action n'a pas pu être terminée. Veuillez réessayer."
},
"codes": {
"VEHICLE_NOT_FOUND": {
"title": "Véhicule introuvable",
"explanation": "Ce véhicule est introuvable. Il a peut-être été supprimé, ou la référence est incorrecte."
},
"BOOKING_NOT_FOUND": {
"title": "Réservation introuvable",
"explanation": "Cette réservation est introuvable. Elle a peut-être été supprimée, ou la référence est incorrecte."
},
"CUSTOMER_NOT_FOUND": {
"title": "Client introuvable",
"explanation": "Cette fiche client est introuvable."
},
"ENTITY_NOT_FOUND": {
"title": "Fiche introuvable",
"explanation": "La fiche sous-jacente à cette action est introuvable."
},
"EVENT_NOT_FOUND": {
"title": "Tâche d'automatisation introuvable",
"explanation": "Cet événement d'automatisation est introuvable."
},
"ISSUE_NOT_FOUND": {
"title": "Problème de qualité des données introuvable",
"explanation": "Ce problème de qualité des données est introuvable."
},
"ISSUE_NOT_OPEN": {
"title": "Le problème n'est plus ouvert",
"explanation": "Ce problème a déjà été résolu, reporté ou rejeté.",
"nextStep": "Actualisez la page pour voir son état actuel."
},
"BOOKING_NOT_ACTIVE": {
"title": "La réservation n'est pas active",
"explanation": "Seule une réservation réservée ou active peut être utilisée pour cette action."
},
"INVALID_BOOKING_STATE": {
"title": "La réservation est dans le mauvais état",
"explanation": "L'état actuel de cette réservation ne permet pas cette action."
},
"NOT_RETRYABLE": {
"title": "Cette tâche ne peut pas être relancée",
"explanation": "Seule une livraison échouée peut être relancée."
},
"CONFLICT_STILL_PRESENT": {
"title": "Le conflit n'a pas été résolu",
"explanation": "L'application de ce changement n'a pas résolu le conflit sous-jacent.",
"nextStep": "Consultez à nouveau la recommandation avant de réessayer."
},
"OVERLAP_STILL_PRESENT": {
"title": "Le chevauchement n'a pas été résolu",
"explanation": "Le blocage de cette réservation n'a pas supprimé le chevauchement ; un autre engagement subsiste."
},
"EMPTY_VALUE": {
"title": "Un champ requis est vide",
"explanation": "Ce champ ne peut pas rester vide.",
"nextStep": "Saisissez une valeur et réessayez."
},
"NO_FIELDS_PROVIDED": {
"title": "Aucune modification fournie",
"explanation": "Au moins un champ doit être complété pour continuer."
},
"INVALID_FIELD": {
"title": "Champ non autorisé ici",
"explanation": "L'un des champs fournis n'est pas autorisé pour cette action."
},
"INVALID_FIELD_OVERRIDE": {
"title": "Ce champ ne peut pas être fusionné",
"explanation": "L'un des champs sélectionnés ne peut pas être utilisé dans cette fusion."
},
"INVALID_SURVIVOR": {
"title": "Sélection non valide",
"explanation": "La fiche que vous souhaitez conserver doit être l'une des deux fiches comparées."
},
"INVALID_BOOKING_REFERENCE": {
"title": "Référence de réservation non valide ici",
"explanation": "La réservation sélectionnée ne fait pas partie des réservations liées à ce problème."
},
"INVALID_EVENT_ID": {
"title": "Référence d'événement non valide",
"explanation": "Cette référence d'événement d'automatisation n'est pas valide."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "La demande n'a pas pu être répétée en toute sécurité",
"explanation": "La clé de suivi de cette demande n'est pas valide.",
"nextStep": "Rechargez la page et réessayez."
},
"IDEMPOTENCY_KEY_REUSED": {
"title": "Cette action a déjà été soumise",
"explanation": "Une demande identique a déjà été traitée, avec un résultat différent.",
"nextStep": "Rechargez la page pour voir l'état actuel avant de réessayer."
},
"CORRECTED_VALUE_REQUIRED": {
"title": "Une valeur corrigée est requise",
"explanation": "Le choix « corriger le relevé » nécessite de saisir la valeur corrigée."
},
"CORRECTION_BELOW_CANONICAL": {
"title": "La correction est inférieure au relevé confirmé",
"explanation": "Un kilométrage corrigé ne peut jamais être inférieur au dernier relevé confirmé."
},
"NOT_A_DUPLICATE_ISSUE": {
"title": "Type de problème incorrect",
"explanation": "Cette action s'applique uniquement aux problèmes de client potentiellement en double."
},
"NOT_A_MISSING_FIELD_ISSUE": {
"title": "Type de problème incorrect",
"explanation": "Cette action s'applique uniquement aux problèmes de champ obligatoire manquant."
},
"NOT_AN_ODOMETER_ISSUE": {
"title": "Type de problème incorrect",
"explanation": "Cette action s'applique uniquement aux problèmes d'anomalie de kilométrage."
},
"NOT_AN_OVERLAP_ISSUE": {
"title": "Type de problème incorrect",
"explanation": "Cette action s'applique uniquement aux problèmes de chevauchement de réservations."
},
"NOT_A_STATUS_CONFLICT_ISSUE": {
"title": "Type de problème incorrect",
"explanation": "Cette action s'applique uniquement aux problèmes de conflit de statut du véhicule."
},
"UNSUPPORTED_ENTITY": {
"title": "Non pris en charge pour ce type de fiche",
"explanation": "Cette action n'est pas disponible pour ce type de fiche."
},
"MANUAL_REVIEW_REQUIRED": {
"title": "Évaluation manuelle requise",
"explanation": "Les faits concernant ce véhicule se contredisent, ce qui rend tout changement automatique non sûr.",
"nextStep": "Reportez ou rejetez ce problème, ou examinez-le manuellement."
},
"NO_CONFLICT_DETECTED": {
"title": "Rien à appliquer",
"explanation": "Le statut actuel correspond déjà aux faits, il n'y a donc plus rien à appliquer."
},
"RECOMMENDATION_STALE": {
"title": "La situation a changé entre-temps",
"explanation": "Les faits sous-jacents ont changé depuis l'affichage de cette recommandation.",
"nextStep": "Consultez à nouveau la recommandation avant de l'appliquer."
},
"UNAUTHORIZED_SERVICE": {
"title": "Échec de l'autorisation du service",
"explanation": "Cette demande automatisée n'a pas pu être autorisée."
}
},
"http": {
"401": {
"title": "Session expirée",
"explanation": "Votre session a expiré.",
"nextStep": "Veuillez vous reconnecter."
},
"403": {
"title": "Accès refusé",
"explanation": "Vous n'avez pas la permission d'effectuer cette action."
},
"404": {
"title": "Introuvable",
"explanation": "Cette fiche est introuvable."
},
"409": {
"title": "Cette action n'est plus possible",
"explanation": "L'état sous-jacent a changé depuis le chargement de cette page.",
"nextStep": "Actualisez la page et réessayez."
},
"422": {
"title": "Échec de la validation",
"explanation": "Les informations fournies ne sont pas valides."
},
"500": {
"title": "Erreur serveur",
"explanation": "Un problème est survenu de notre côté."
},
"network": {
"title": "Connexion indisponible",
"explanation": "La connexion au serveur est actuellement indisponible.",
"nextStep": "Vérifiez votre connexion et réessayez."
}
}
}
@@ -41,7 +41,7 @@
"statusSucceeded": "Réussi",
"statusFailed": "Échoué",
"unavailable": "Les tâches d'automatisation sont actuellement indisponibles.",
"managerOnly": "L'automatisation est visible uniquement pour les Operations Managers.",
"managerOnly": "L'automatisation est visible uniquement pour les Responsables des opérations.",
"loading": "Chargement des tâches d'automatisation…",
"empty": "Aucune tâche d'automatisation ne correspond à ce filtre.",
"count": "{{count}} événements de workflow",
+3 -3
View File
@@ -55,8 +55,8 @@
"title": "Examinez les preuves enregistrées et consignez une résolution auditée.",
"notFound": "Ce problème est introuvable.",
"loading": "Chargement des preuves du problème…",
"managerOnly": "L'atelier qualité est visible uniquement pour les Operations Managers.",
"managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Operations Managers.",
"managerOnly": "L'atelier qualité est visible uniquement pour les Responsables des opérations.",
"managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Responsables des opérations.",
"summary": {
"rule": "Règle",
"entity": "Entité",
@@ -212,7 +212,7 @@
"reviewAgain": "Revoir la recommandation",
"manualReview": {
"heading": "Évaluation manuelle requise",
"body": "Les faits concernant ce véhicule se contredisent. Un Operations Manager doit évaluer la situation en personne plutôt que d'appliquer un changement de statut automatique.",
"body": "Les faits concernant ce véhicule se contredisent. Un Responsable des opérations doit évaluer la situation en personne plutôt que d'appliquer un changement de statut automatique.",
"hint": "Utilisez « Reporter » ou « Rejeter » ci-dessous, ou examinez manuellement le véhicule et les réservations concernées."
},
"noConflict": {
+6 -6
View File
@@ -1,13 +1,13 @@
{
"eyebrow": "Bewaken / Onveranderlijke geschiedenis",
"title": "Audit trail",
"title": "Auditgeschiedenis",
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
"actionFilterLabel": "Actie",
"actionFilterPlaceholder": "bv. demo-login",
"managerOnly": "De audit trail is enkel zichtbaar voor Operations Managers.",
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operations Managers.",
"loading": "Audit trail laden…",
"unavailable": "Audit trail is momenteel niet beschikbaar.",
"managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
"loading": "Auditgeschiedenis laden…",
"unavailable": "Auditgeschiedenis is momenteel niet beschikbaar.",
"empty": "Geen auditgebeurtenissen gevonden",
"emptyDetail": "Pas het actiefilter aan.",
"relatedFilterActive": "Enkel gebeurtenissen gekoppeld aan deze actie ({{count}} gerelateerde gebeurtenissen).",
@@ -16,7 +16,7 @@
"storedRendered": "UTC opgeslagen · Brussel weergegeven",
"columns": {
"when": "Wanneer",
"actor": "Actor",
"actor": "Uitvoerder",
"action": "Actie",
"entity": "Entiteit",
"change": "Wijziging",
+4 -4
View File
@@ -9,15 +9,15 @@
"accessHeading": "Kies hoe je wil starten",
"accessIntro": "Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.",
"startGuidedDemo": "Start begeleide demo",
"exploreAsOperationsManager": "Verken als Operations Manager",
"exploreAsOperationsManager": "Verken als Operationsmanager",
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
"exploreAsRentalEmployee": "Verken als Rental Employee",
"exploreAsRentalEmployee": "Verken als Verhuurmedewerker",
"exploreAsRentalEmployeeDetail": "Boekingen, retours, wagenpark en procedures",
"safeByDesignTitle": "Veilig ontworpen",
"safeByDesignDetail": "Elke actie wordt gelogd en is in deze demo herstelbaar.",
"loginFailed": "De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.",
"roleOperationsManager": "Operations manager",
"roleRentalEmployee": "Rental employee",
"roleOperationsManager": "Operationsmanager",
"roleRentalEmployee": "Verhuurmedewerker",
"switchRole": "Wissel van rol",
"logout": "Uitloggen"
}
+12 -1
View File
@@ -1,6 +1,17 @@
{
"eyebrow": "Uitvoeren / Live overzicht",
"title": "Goedemorgen. Hier is je wagenpark.",
"greeting": {
"morning": "Goedemorgen",
"afternoon": "Goedemiddag",
"evening": "Goedenavond",
"night": "Welkom terug"
},
"greetingBody": {
"morning": "Hier is de status van je wagenpark voor vandaag.",
"afternoon": "Hier is het actuele overzicht van je wagenpark.",
"evening": "Hier is het overzicht van je wagenpark voor vanavond.",
"night": "Hier is het laatste overzicht van je wagenpark."
},
"description": "Beschikbaarheid, uitzonderingen en overdrachten doorheen de huidige werking.",
"viewFleet": "Wagenpark bekijken",
"demoStart": {
+11 -11
View File
@@ -32,7 +32,7 @@
"understand-state": {
"title": "1. Begrijp de operationele status",
"whatYouWillSee": "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.",
"whyItMatters": "Een Operations Manager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.",
"whyItMatters": "Een Operationsmanager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.",
"startAction": "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.",
"expectedOutcome": "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen."
},
@@ -72,10 +72,10 @@
"expectedOutcome": "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is."
},
"check-automation-audit": {
"title": "7. Controleer automatisering en audit trail",
"title": "7. Controleer automatisering en auditgeschiedenis",
"whatYouWillSee": "De status van de n8n-aflevering voor je retour, en de bijhorende audit-gebeurtenissen.",
"whyItMatters": "Elke belangrijke actie moet naspeurbaar zijn: wie deed wat, wanneer, en wat was het gevolg.",
"startAction": "Open Integraties om de afleverstatus te zien, en Audit trail voor het volledige spoor.",
"startAction": "Open Integraties om de afleverstatus te zien, en Auditgeschiedenis voor het volledige spoor.",
"expectedOutcome": "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties."
},
"review-real-vs-simulated": {
@@ -99,22 +99,22 @@
"role": "Rol",
"demonstrates": "Toont aan:",
"requiresRole": "Vereist rol: {{roles}}.",
"startScenario": "Start scenario",
"startScenario": "Scenario starten",
"roleOr": "{{a}} of {{b}}",
"roles": {
"operations_manager": "Operations Manager",
"rental_employee": "Rental Employee"
"operations_manager": "Operationsmanager",
"rental_employee": "Verhuurmedewerker"
},
"items": {
"return-anomaly": {
"title": "Retour met afwijkende kilometerstand",
"problem": "Een voertuig komt terug met een kilometerstand die lager ligt dan de laatst geregistreerde stand — een teken van een foutieve invoer of een verwisseld voertuig.",
"demonstrates": "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de audit trail die daaruit ontstaat."
"demonstrates": "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de auditgeschiedenis die daaruit ontstaat."
},
"duplicate-customer": {
"title": "Mogelijke dubbele klant samenvoegen",
"problem": "Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — waarschijnlijk dezelfde persoon, twee keer geregistreerd.",
"demonstrates": "Samenvoegen van klanten met behoud van boekingsgeschiedenis en audit trail."
"demonstrates": "Samenvoegen van klanten met behoud van boekingsgeschiedenis en auditgeschiedenis."
},
"booking-overlap": {
"title": "Overlappende boekingen herstellen",
@@ -155,9 +155,9 @@
"problemTitle": "Het fictieve probleem",
"problemBody": "{{orgName}} verhuurt zo'n 50 campers en bestelwagens vanuit één hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. {{productName}} toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.",
"scopeTitle": "Voor wie en met welke scope",
"scopeBody": "Deze demo is bedoeld voor wie wil zien hoe {{productName}} operationele problemen bij een kleine verhuurder aanpakt: Operations Managers en Rental Employees, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.",
"scopeBody": "Deze demo is bedoeld voor wie wil zien hoe {{productName}} operationele problemen bij een kleine verhuurder aanpakt: Operationsmanagers en Verhuurmedewerkers, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.",
"realTitle": "Wat écht werkt",
"realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).",
"realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige auditgeschiedenis, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).",
"syntheticTitle": "Wat synthetisch is",
"syntheticBody": "De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis, procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of bedrijf; e-mailadressen gebruiken uitsluitend het testdomein {{testDomain}}.",
"architectureTitle": "Architectuur in het kort",
@@ -170,7 +170,7 @@
"integrationsDescription": "Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is.",
"resetTitle": "Demo-omgeving herstellen",
"resetBodyManager": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Gebruik <strong>Demogegevens herstellen</strong> in de zijbalk om opnieuw te beginnen.",
"resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Een Operations Manager kan de demo-omgeving herstellen via de zijbalk.",
"resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Een Operationsmanager kan de demo-omgeving herstellen via de zijbalk.",
"limitationsTitle": "Beperkingen",
"limitationsBody": "Dit is een gerichte proof of concept, geen volledig ERP. RAGcore en de ITWorx MCP Hub zijn nog niet live gekoppeld; de kennisassistent gebruikt een lokale, afgebakende demokennisbank in plaats van een live RAGcore-omgeving.",
"unknown": "onbekend"
+178 -6
View File
@@ -1,8 +1,180 @@
{
"generic": "Er is een fout opgetreden. Probeer opnieuw.",
"workspaceLoadFailed": "We konden deze werkruimte niet laden.",
"unauthorized": "Je sessie is verlopen. Log opnieuw in.",
"forbidden": "Je hebt geen toegang tot dit onderdeel.",
"notFound": "Dit record kon niet gevonden worden.",
"networkUnavailable": "De verbinding met de server is momenteel niet beschikbaar."
"generic": {
"title": "Er is iets misgegaan",
"explanation": "Deze actie kon niet voltooid worden. Probeer opnieuw."
},
"codes": {
"VEHICLE_NOT_FOUND": {
"title": "Voertuig niet gevonden",
"explanation": "Dit voertuig kon niet gevonden worden. Het is mogelijk verwijderd, of de referentie klopt niet."
},
"BOOKING_NOT_FOUND": {
"title": "Boeking niet gevonden",
"explanation": "Deze boeking kon niet gevonden worden. Ze is mogelijk verwijderd, of de referentie klopt niet."
},
"CUSTOMER_NOT_FOUND": {
"title": "Klant niet gevonden",
"explanation": "Dit klantrecord kon niet gevonden worden."
},
"ENTITY_NOT_FOUND": {
"title": "Record niet gevonden",
"explanation": "Het onderliggende record voor deze actie kon niet gevonden worden."
},
"EVENT_NOT_FOUND": {
"title": "Automatiseringsopdracht niet gevonden",
"explanation": "Deze automatiseringsgebeurtenis kon niet gevonden worden."
},
"ISSUE_NOT_FOUND": {
"title": "Datakwaliteitsprobleem niet gevonden",
"explanation": "Dit datakwaliteitsprobleem kon niet gevonden worden."
},
"ISSUE_NOT_OPEN": {
"title": "Probleem is niet meer openstaand",
"explanation": "Dit probleem is al opgelost, uitgesteld of verworpen.",
"nextStep": "Vernieuw de pagina om de huidige status te zien."
},
"BOOKING_NOT_ACTIVE": {
"title": "Boeking is niet actief",
"explanation": "Enkel een gereserveerde of actieve boeking kan voor deze actie gebruikt worden."
},
"INVALID_BOOKING_STATE": {
"title": "Boeking heeft de verkeerde status",
"explanation": "De huidige status van deze boeking laat deze actie niet toe."
},
"NOT_RETRYABLE": {
"title": "Deze opdracht kan niet opnieuw geprobeerd worden",
"explanation": "Enkel een mislukte aflevering kan opnieuw geprobeerd worden."
},
"CONFLICT_STILL_PRESENT": {
"title": "Conflict is niet opgelost",
"explanation": "Het toepassen van deze wijziging heeft het onderliggende conflict niet opgelost.",
"nextStep": "Bekijk de aanbeveling opnieuw voordat je het nogmaals probeert."
},
"OVERLAP_STILL_PRESENT": {
"title": "Overlap is niet opgelost",
"explanation": "Het blokkeren van deze boeking heeft de overlap niet weggenomen; er blijft een andere verbintenis bestaan."
},
"EMPTY_VALUE": {
"title": "Een verplicht veld is leeg",
"explanation": "Dit veld mag niet leeg blijven.",
"nextStep": "Vul een waarde in en probeer opnieuw."
},
"NO_FIELDS_PROVIDED": {
"title": "Geen wijzigingen opgegeven",
"explanation": "Er moet minstens één veld ingevuld worden om verder te gaan."
},
"INVALID_FIELD": {
"title": "Veld hier niet toegelaten",
"explanation": "Eén van de opgegeven velden is niet toegelaten voor deze actie."
},
"INVALID_FIELD_OVERRIDE": {
"title": "Veld kan niet samengevoegd worden",
"explanation": "Eén van de gekozen velden kan niet gebruikt worden in deze samenvoeging."
},
"INVALID_SURVIVOR": {
"title": "Ongeldige keuze",
"explanation": "Het record dat je wil behouden moet één van de twee vergeleken records zijn."
},
"INVALID_BOOKING_REFERENCE": {
"title": "Boekingreferentie hier niet geldig",
"explanation": "De gekozen boeking hoort niet bij de boekingen die aan dit probleem gekoppeld zijn."
},
"INVALID_EVENT_ID": {
"title": "Ongeldige opdrachtreferentie",
"explanation": "Deze referentie naar een automatiseringsopdracht is niet geldig."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "Aanvraag kon niet veilig herhaald worden",
"explanation": "De trackingsleutel van deze aanvraag is niet geldig.",
"nextStep": "Herlaad de pagina en probeer opnieuw."
},
"IDEMPOTENCY_KEY_REUSED": {
"title": "Deze actie werd al ingediend",
"explanation": "Een identieke aanvraag werd al verwerkt, met een ander resultaat.",
"nextStep": "Herlaad de pagina om de huidige status te zien voordat je het nogmaals probeert."
},
"CORRECTED_VALUE_REQUIRED": {
"title": "Een gecorrigeerde waarde is vereist",
"explanation": "Voor de keuze \"stand corrigeren\" moet je de gecorrigeerde waarde invullen."
},
"CORRECTION_BELOW_CANONICAL": {
"title": "Correctie ligt onder de bevestigde stand",
"explanation": "Een gecorrigeerde kilometerstand mag nooit lager zijn dan de laatst bevestigde stand."
},
"NOT_A_DUPLICATE_ISSUE": {
"title": "Verkeerd probleemtype",
"explanation": "Deze actie geldt enkel voor problemen van het type mogelijke dubbele klant."
},
"NOT_A_MISSING_FIELD_ISSUE": {
"title": "Verkeerd probleemtype",
"explanation": "Deze actie geldt enkel voor problemen met een ontbrekend verplicht veld."
},
"NOT_AN_ODOMETER_ISSUE": {
"title": "Verkeerd probleemtype",
"explanation": "Deze actie geldt enkel voor problemen met een afwijkende kilometerstand."
},
"NOT_AN_OVERLAP_ISSUE": {
"title": "Verkeerd probleemtype",
"explanation": "Deze actie geldt enkel voor problemen met een overlappende boeking."
},
"NOT_A_STATUS_CONFLICT_ISSUE": {
"title": "Verkeerd probleemtype",
"explanation": "Deze actie geldt enkel voor problemen met een statusconflict van het voertuig."
},
"UNSUPPORTED_ENTITY": {
"title": "Niet ondersteund voor dit recordtype",
"explanation": "Deze actie is niet beschikbaar voor dit soort record."
},
"MANUAL_REVIEW_REQUIRED": {
"title": "Handmatige beoordeling vereist",
"explanation": "De feiten voor dit voertuig spreken elkaar tegen, waardoor geen automatische wijziging veilig is.",
"nextStep": "Stel dit probleem uit of verwerp het, of onderzoek het handmatig."
},
"NO_CONFLICT_DETECTED": {
"title": "Niets om toe te passen",
"explanation": "De huidige status komt al overeen met de feiten, dus er is niets meer om toe te passen."
},
"RECOMMENDATION_STALE": {
"title": "De situatie is intussen gewijzigd",
"explanation": "De onderliggende feiten zijn gewijzigd sinds deze aanbeveling getoond werd.",
"nextStep": "Bekijk de aanbeveling opnieuw voordat je ze toepast."
},
"UNAUTHORIZED_SERVICE": {
"title": "Dienstautorisatie mislukt",
"explanation": "Deze geautomatiseerde aanvraag kon niet geautoriseerd worden."
}
},
"http": {
"401": {
"title": "Sessie verlopen",
"explanation": "Je sessie is verlopen.",
"nextStep": "Log opnieuw in."
},
"403": {
"title": "Geen toegang",
"explanation": "Je hebt geen toestemming om deze actie uit te voeren."
},
"404": {
"title": "Niet gevonden",
"explanation": "Dit record kon niet gevonden worden."
},
"409": {
"title": "Deze actie is niet meer mogelijk",
"explanation": "De onderliggende status is gewijzigd sinds deze pagina geladen werd.",
"nextStep": "Vernieuw de pagina en probeer opnieuw."
},
"422": {
"title": "Validatie mislukt",
"explanation": "De opgegeven informatie is niet geldig."
},
"500": {
"title": "Serverfout",
"explanation": "Er is iets misgegaan aan onze kant."
},
"network": {
"title": "Verbinding niet beschikbaar",
"explanation": "De verbinding met de server is momenteel niet beschikbaar.",
"nextStep": "Controleer je verbinding en probeer opnieuw."
}
}
}
@@ -31,7 +31,7 @@
"description": "Vastgelegde automatiseringspogingen met de recentste foutevidentie.",
"filterLabel": "Weergave",
"filterNeedsAttention": "Vraagt aandacht",
"filterRecent": "Recent",
"filterRecent": "Recentste",
"filterSucceeded": "Geslaagd",
"filterAll": "Alle",
"statusFilterLabel": "Status",
@@ -41,7 +41,7 @@
"statusSucceeded": "Geslaagd",
"statusFailed": "Mislukt",
"unavailable": "Automatiseringsopdrachten zijn momenteel niet beschikbaar.",
"managerOnly": "Automatisering is enkel zichtbaar voor Operations Managers.",
"managerOnly": "Automatisering is enkel zichtbaar voor Operationsmanagers.",
"loading": "Automatiseringsopdrachten laden…",
"empty": "Geen automatiseringsopdrachten voor dit filter.",
"count": "{{count}} workflowgebeurtenissen",
@@ -11,7 +11,7 @@
"quality": "Datakwaliteit",
"knowledge": "Kennis",
"integrations": "Integraties",
"audit": "Audit trail"
"audit": "Auditgeschiedenis"
},
"primaryNavLabel": "Hoofdnavigatie",
"mobileNavLabel": "Mobiele navigatie",
+7 -7
View File
@@ -14,7 +14,7 @@
"scanFailed": "Kwaliteitscontrole kon niet uitgevoerd worden.",
"statusLabel": "Status",
"statusAll": "Alle statussen",
"statusOpen": "Open",
"statusOpen": "Openstaand",
"statusDeferred": "Uitgesteld",
"statusResolved": "Opgelost",
"statusRejected": "Verworpen",
@@ -55,8 +55,8 @@
"title": "Bekijk vastgelegde evidentie en registreer een geauditeerde oplossing.",
"notFound": "Dit probleem kon niet gevonden worden.",
"loading": "Probleemevidentie laden…",
"managerOnly": "De kwaliteitswerkbank is enkel zichtbaar voor Operations Managers.",
"managerOnlyDetail": "Datakwaliteitsevidentie en -oplossingen zijn enkel zichtbaar voor Operations Managers.",
"managerOnly": "De kwaliteitswerkbank is enkel zichtbaar voor Operationsmanagers.",
"managerOnlyDetail": "Datakwaliteitsevidentie en -oplossingen zijn enkel zichtbaar voor Operationsmanagers.",
"summary": {
"rule": "Regel",
"entity": "Entiteit",
@@ -64,8 +64,8 @@
},
"resolved": {
"title": "Probleem {{ref}} opgelost",
"body": "De wijziging is doorgevoerd en vastgelegd in de audit trail.",
"viewAudit": "Audit trail bekijken",
"body": "De wijziging is doorgevoerd en vastgelegd in de auditgeschiedenis.",
"viewAudit": "Auditgeschiedenis bekijken",
"viewVehicle": "Voertuig bekijken",
"continueDemo": "Ga verder met de demo"
},
@@ -201,7 +201,7 @@
"consequence": {
"statusWillChange": "De voertuigstatus wordt gewijzigd naar {{status}}.",
"issueWillBeRechecked": "Dit kwaliteitsprobleem wordt opnieuw gecontroleerd.",
"changeWillBeAudited": "De wijziging wordt vastgelegd in de audit trail.",
"changeWillBeAudited": "De wijziging wordt vastgelegd in de auditgeschiedenis.",
"bookingsNotDeleted": "De boekingen zelf worden niet verwijderd."
},
"changeStatusTo": "Status wijzigen naar {{status}}",
@@ -212,7 +212,7 @@
"reviewAgain": "Aanbeveling opnieuw bekijken",
"manualReview": {
"heading": "Handmatige beoordeling vereist",
"body": "De feiten voor dit voertuig spreken elkaar tegen. Een Operations Manager moet dit persoonlijk beoordelen in plaats van een automatische statuswijziging toe te passen.",
"body": "De feiten voor dit voertuig spreken elkaar tegen. Een Operationsmanager moet dit persoonlijk beoordelen in plaats van een automatische statuswijziging toe te passen.",
"hint": "Gebruik 'Uitstellen' of 'Verwerpen' hieronder, of onderzoek het voertuig en de betrokken boekingen manueel."
},
"noConflict": {
+1 -1
View File
@@ -69,7 +69,7 @@
"odometerRegressionNotice": "De ingevoerde kilometerstand lag onder de laatst bevestigde stand van het voertuig. Ze werd zo geregistreerd; de laatst bevestigde kilometerstand is niet gewijzigd en er is een datakwaliteitsprobleem geopend ter controle.",
"viewVehicle": "Voertuig {{ref}} bekijken",
"viewAutomation": "Automatiseringsstatus bekijken",
"viewAudit": "Audit trail bekijken",
"viewAudit": "Auditgeschiedenis bekijken",
"continueDemo": "Ga verder met de demo"
},
"scenario": {
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
import { getGreetingPeriod, type GreetingPeriod } from "./greeting";
// Checked frequently enough that a period rollover (e.g. 11:59 -> 12:00) is picked up
// within the Dashboard while the app stays open, without a page reload -- see section 9
// of the Fleet Ops final localization brief.
const CHECK_INTERVAL_MS = 30_000;
export function useGreetingPeriod(): GreetingPeriod {
const [period, setPeriod] = useState<GreetingPeriod>(() => getGreetingPeriod());
useEffect(() => {
const id = setInterval(() => {
setPeriod((current) => {
const next = getGreetingPeriod();
return next === current ? current : next;
});
}, CHECK_INTERVAL_MS);
return () => clearInterval(id);
}, []);
return period;
}
+6 -5
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { AutomationRun, IntegrationStatus, KnowledgeHealth } from "../api/types";
import { StatusBadge } from "../components/Badge";
import { useAuth } from "../context/AuthContext";
import { useLocaleFormat } from "../i18n/format";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { ApiErrorNotice, ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
type ViewFilter = "attention" | "recent" | "succeeded" | "all";
@@ -25,7 +26,7 @@ export function Automation() {
const [status, setStatus] = useState("");
const [view, setView] = useState<ViewFilter>("attention");
const [expandSucceeded, setExpandSucceeded] = useState(false);
const [retryError, setRetryError] = useState<string | null>(null);
const [retryError, setRetryError] = useState<ApiErrorInfo | null>(null);
const [retrying, setRetrying] = useState<string | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
@@ -70,7 +71,7 @@ export function Automation() {
load();
loadIntegrationStatus();
} catch (err) {
setRetryError(err instanceof ApiError ? err.message : t("ledger.retryFailed"));
setRetryError(describeApiError(t, err, "ledger.retryFailed"));
} finally {
setRetrying(null);
}
@@ -242,7 +243,7 @@ export function Automation() {
</form>
{error && <ErrorState message={error} />}
{retryError && <p className="error" role="alert">{retryError}</p>}
<ApiErrorNotice error={retryError} />
{!error && !runs && <LoadingState label={t("ledger.loading")} />}
{visibleRuns && visibleRuns.length === 0 && <p>{t("ledger.empty")}</p>}
+4 -1
View File
@@ -7,6 +7,7 @@ import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { useGreetingPeriod } from "../i18n/useGreetingPeriod";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
@@ -31,6 +32,7 @@ function attentionItemTitle(
export function Dashboard() {
const { t } = useTranslation(["dashboard", "common", "integrations"]);
const { formatTime, formatShortDate } = useLocaleFormat();
const greetingPeriod = useGreetingPeriod();
const { user } = useAuth();
const { manifest } = useDemoManifest();
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
@@ -78,10 +80,11 @@ export function Dashboard() {
const latestRun = data.recent_automation[0];
const readyCount = manifest?.scenarios.filter((s) => s.ready).length ?? 0;
const greetingTitle = `${t(`greeting.${greetingPeriod}`)}. ${t(`greetingBody.${greetingPeriod}`)}`;
return (
<div className="page dashboard-page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
<PageHeader eyebrow={t("eyebrow")} title={greetingTitle} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
<section className="demo-start-panel" aria-label={t("demoStart.title")}>
<div>
+6 -5
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { DataQualityIssue, ScanResult } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
const RULE_TYPES = [
"possible_duplicate_customer",
@@ -23,7 +24,7 @@ export function DataQuality() {
const [status, setStatus] = useState("open");
const [ruleType, setRuleType] = useState("");
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
@@ -54,7 +55,7 @@ export function DataQuality() {
setConfirmingScan(false);
load();
} catch (err) {
setScanError(err instanceof ApiError ? err.message : t("list.scanFailed"));
setScanError(describeApiError(t, err, "list.scanFailed"));
} finally {
setScanning(false);
}
@@ -101,7 +102,7 @@ export function DataQuality() {
}
/>
{scanError && <p className="error" role="alert">{scanError}</p>}
<ApiErrorNotice error={scanError} />
{scanResult && (
<p className="quiet-empty" role="status">
{t("list.scanComplete", {
+22 -23
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type {
ApplyRecommendedStatusResult,
DataQualityIssueDetail as IssueDetail,
@@ -15,7 +16,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { ApiErrorNotice, ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
@@ -124,7 +125,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const { user } = useAuth();
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
const [fieldChoices, setFieldChoices] = useState<Record<string, "a" | "b">>({});
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [confirming, setConfirming] = useState(false);
@@ -155,7 +156,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.duplicateCustomer.mergeFailed"));
setError(describeApiError(t, err, "detail.duplicateCustomer.mergeFailed"));
setConfirming(false);
} finally {
setSubmitting(false);
@@ -171,7 +172,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
return (
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
<SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} />
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<fieldset className="choice-fieldset">
<legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
@@ -210,7 +211,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const differ = valueA !== valueB;
return (
<tr key={field}>
<th scope="row" data-label="Field">{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? <span className="difference-mark">{t("detail.duplicateCustomer.differs")}</span> : <span className="match-mark">{t("detail.duplicateCustomer.match")}</span>}</th>
<th scope="row" data-label={t("detail.duplicateCustomer.fieldColumn")}>{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? <span className="difference-mark">{t("detail.duplicateCustomer.differs")}</span> : <span className="match-mark">{t("detail.duplicateCustomer.match")}</span>}</th>
<td data-label={a.public_ref}>
{differ ? (
<label className="checkbox-label">
@@ -285,7 +286,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
}
return initial;
});
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
@@ -299,7 +300,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields });
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.missingField.saveFailed"));
setError(describeApiError(t, err, "detail.missingField.saveFailed"));
} finally {
setSubmitting(false);
}
@@ -312,7 +313,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
title={t("detail.missingField.heading")}
description={t("detail.missingField.description", { ref: snapshot?.public_ref ?? issue.entity_ref })}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<div className="form-grid">
{fieldKeys.map((field) => (
<label key={field}>
@@ -345,7 +346,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
const [correctedValue, setCorrectedValue] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
@@ -362,7 +363,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.odometerRegression.resolveFailed"));
setError(describeApiError(t, err, "detail.odometerRegression.resolveFailed"));
} finally {
setSubmitting(false);
}
@@ -375,7 +376,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
title={t("detail.odometerRegression.heading")}
description={t("detail.odometerRegression.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<dl className="detail-grid">
<div><dt>{t("detail.odometerRegression.canonicalOdometer")}</dt><dd>{formatNumber(Number(issue.entity_snapshot?.odometer_km ?? 0))} km</dd></div>
</dl>
@@ -453,7 +454,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
const bookings = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const [bookingRef, setBookingRef] = useState(bookings[0]?.public_ref ?? "");
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
@@ -467,7 +468,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.bookingOverlap.resolveFailed"));
setError(describeApiError(t, err, "detail.bookingOverlap.resolveFailed"));
} finally {
setSubmitting(false);
}
@@ -480,7 +481,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
title={t("detail.bookingOverlap.heading")}
description={t("detail.bookingOverlap.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<fieldset className="choice-fieldset choice-fieldset-grid">
<legend className="visually-hidden">{t("detail.bookingOverlap.columns.blockThis")}</legend>
{bookings.map((b) => (
@@ -570,7 +571,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
const [recommendation, setRecommendation] = useState<StatusRecommendation | null>(null);
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
const [stale, setStale] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -596,7 +597,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
);
setRecommendation(preview);
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.recommendationFailed"));
setError(describeApiError(t, err, "detail.vehicleStatusConflict.recommendationFailed"));
} finally {
setLoadingRecommendation(false);
}
@@ -614,12 +615,10 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
setResult(applied);
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.vehicleStatusConflict.applyFailed"));
if (err instanceof ApiError && err.code === "RECOMMENDATION_STALE") {
setError(t("detail.vehicleStatusConflict.staleRecommendation"));
setRecommendation(null);
setStale(true);
} else {
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
}
} finally {
setSubmitting(false);
@@ -645,7 +644,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
title={t("detail.vehicleStatusConflict.heading")}
description={t("detail.vehicleStatusConflict.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<dl className="detail-grid">
<div><dt>{t("detail.vehicleStatusConflict.currentStatus")}</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} label={t(`fleet:statuses.${issue.entity_snapshot?.operational_status}`, { defaultValue: String(issue.entity_snapshot?.operational_status ?? "") })} /></dd></div>
</dl>
@@ -739,7 +738,7 @@ export function DataQualityIssueDetail() {
const { publicRef } = useParams<{ publicRef: string }>();
const [issue, setIssue] = useState<IssueDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [justResolved, setJustResolved] = useState(false);
const load = useCallback(() => {
@@ -776,7 +775,7 @@ export function DataQualityIssueDetail() {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
load();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : t(`detail.deferOrReject.${action}Failed`));
setActionError(describeApiError(t, err, `detail.deferOrReject.${action}Failed`));
}
}
@@ -808,7 +807,7 @@ export function DataQualityIssueDetail() {
<RuleExplainer ruleType={issue.rule_type} />
{actionError && <p className="error" role="alert">{actionError}</p>}
<ApiErrorNotice error={actionError} />
{justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && (
<section className="panel success-panel" aria-live="polite">
+6 -5
View File
@@ -1,9 +1,10 @@
import { useEffect, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { GroundedAnswer, KnowledgeHealth } from "../api/types";
import { Icon } from "../components/Icons";
import { PageHeader } from "../components/PageChrome";
import { ApiErrorNotice, PageHeader } from "../components/PageChrome";
import { PRODUCT_NAME } from "../product";
interface Exchange {
@@ -16,7 +17,7 @@ export function Knowledge() {
const [status, setStatus] = useState<KnowledgeHealth | null>(null);
const [question, setQuestion] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [exchanges, setExchanges] = useState<Exchange[]>([]);
const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE";
@@ -39,7 +40,7 @@ export function Knowledge() {
setExchanges((prev) => [{ question: questionText, answer }, ...prev]);
setQuestion("");
} catch (err) {
setError(err instanceof ApiError ? err.message : t("askFailed"));
setError(describeApiError(t, err, "askFailed"));
} finally {
setSubmitting(false);
}
@@ -107,7 +108,7 @@ export function Knowledge() {
{!submitting && <Icon name="chevron" />}
</button>
</div>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<div className="knowledge-suggestions">
<span>{t("suggestedLabel")}</span>
{suggestedQuestions.map((q) => (
+9 -3
View File
@@ -96,9 +96,9 @@ a:hover { color: var(--teal); }
.timezone svg { width: 15px; }
.operator { display: flex; align-items: center; gap: 9px; padding-left: 17px; border-left: 1px solid var(--line); }
.avatar { width: 32px; height: 32px; display: grid; place-items: center; color: white; background: var(--petrol); border-radius: 50%; font-size: .67rem; font-weight: 700; }
.operator > span:last-child { display: grid; gap: 1px; }
.operator strong { font-size: .74rem; }
.operator small { color: var(--muted); font-size: .64rem; }
.operator > span:last-child { display: grid; gap: 1px; min-width: 0; }
.operator strong { font-size: .74rem; overflow-wrap: anywhere; }
.operator small { color: var(--muted); font-size: .64rem; overflow-wrap: anywhere; }
.icon-button { width: 40px; height: 40px; display: grid; place-items: center; padding: 0; color: var(--ink-soft); background: transparent; border: 1px solid transparent; border-radius: var(--radius); cursor: pointer; }
.icon-button:hover { background: var(--surface-subtle); border-color: var(--line); }
.icon-button svg { width: 18px; height: 18px; }
@@ -347,6 +347,12 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
.error { color: #9f2929; font-size: .74rem; font-weight: 600; }
.api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }
.api-error-notice strong { display: block; font-size: .78rem; }
.api-error-notice p { margin: 4px 0 0; font-weight: 400; }
.api-error-notice .api-error-next-step { color: #7a2323; font-style: italic; }
.api-error-notice .evidence-disclosure { margin-top: 8px; }
.api-error-notice .evidence-disclosure summary { font-weight: 700; cursor: pointer; }
.login-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(420px, 1.05fr) minmax(430px, .95fr); background: white; }
.login-story { position: relative; min-height: 100vh; display: flex; flex-direction: column; padding: 34px 7vw 30px; overflow: hidden; color: white; background: var(--petrol); }