merge: finalize Fleet Ops localization
This commit is contained in:
+93
-7
@@ -29,9 +29,15 @@ M7 — complete. All milestones (M0–M7) done, plus a full post-M7 final-accept
|
|||||||
|
|
||||||
## Locked decisions
|
## 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.
|
- 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`.
|
- Core stack and boundaries are defined in `CLAUDE.md` and `docs/03-architecture.md`.
|
||||||
- RAGcore and ITWorx MCP Hub are external central services.
|
- RAGcore and ITWorx MCP Hub are external central services.
|
||||||
- n8n receives post-commit events through an outbox dispatcher.
|
- 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)
|
and master, responsive/accessibility results, known limitations, rollback procedure)
|
||||||
plus 10 screenshots in `artifacts/fleet-ops-release/screenshots/`.
|
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
|
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
|
(`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
|
- **Status-recommendation flow redesigned** per the brief: the old single opaque
|
||||||
"calculate and apply recommended status" action is replaced by a single shared, pure
|
"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
|
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
|
errors, no errors in `api`/`web` container logs, both containers healthy, final
|
||||||
reset done, `scenario_integrity.all_ready: true`.
|
reset done, `scenario_integrity.all_ready: true`.
|
||||||
- **Not yet done**: the final merge to master with
|
- Final evidence: `artifacts/fleet-ops-correction/final-summary.md`. Merged to master
|
||||||
`artifacts/fleet-ops-correction/final-summary.md` evidence. Do not claim PASS on
|
via `de0bdea`, followed by evidence commit `f780557` on master. See the "Fleet Ops
|
||||||
this correction until that's done.
|
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
|
- Commits so far on this branch: `6deb955` (status flow + brand constant + message
|
||||||
codes), `e6539d1` (knowledge fixes), `ac4b163` (Playwright spec updates for the new
|
codes), `e6539d1` (knowledge fixes), `ac4b163` (Playwright spec updates for the new
|
||||||
flow), `1fdd2b3` (new E2E coverage + 2 bug fixes), `1e40775` (accessibility test),
|
flow), `1fdd2b3` (new E2E coverage + 2 bug fixes), `1e40775` (accessibility test),
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ knowledge base, and this documentation. "MobilityOps" remains the technical
|
|||||||
identifier only — the repository name, local directory, package/module names, Docker
|
identifier only — the repository name, local directory, package/module names, Docker
|
||||||
Compose project, deployment directory, and database names. The UI is fully trilingual
|
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
|
(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
|
The web application uses the premium responsive **Control Rail** interface: a compact
|
||||||
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
|
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
|
||||||
@@ -123,7 +126,7 @@ All defaults are configurable via `.env` (see `.env.example`).
|
|||||||
```bash
|
```bash
|
||||||
make test # backend: pytest (151 tests)
|
make test # backend: pytest (151 tests)
|
||||||
make lint # backend: ruff + mypy (strict, zero errors)
|
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`).
|
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
||||||
|
COPY public ./public
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN npm ci && npm run build
|
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.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` });
|
||||||
await page.setViewportSize({ width: 1280, height: 900 });
|
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).toHaveURL(/\/dashboard$/);
|
||||||
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
|
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
|
||||||
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
|
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => {
|
|||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.screenshot({ path: `${OUT}/1-login.png` });
|
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 expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
|
||||||
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
|
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
|
||||||
await page.goto("/login");
|
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 expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
|
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));
|
page.on("pageerror", (err) => errors.push(err.message));
|
||||||
|
|
||||||
await page.goto("/login");
|
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 expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/scenarios");
|
await page.goto("/scenarios");
|
||||||
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
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.emulateMedia({ reducedMotion: "reduce" });
|
||||||
await page.goto("/login");
|
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 expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||||
|
|
||||||
|
|||||||
@@ -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(/Northstar Mobility/)).toBeVisible();
|
||||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Start begeleide 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 Operationsmanager" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Verken als Rental Employee" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
|
||||||
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
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 }) => {
|
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
|
||||||
await page.goto("/login");
|
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 expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
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 }) => {
|
test("badge popover closes on Escape and outside click", async ({ page }) => {
|
||||||
await page.goto("/login");
|
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 expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" });
|
|||||||
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
|
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/login");
|
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 expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/scenarios");
|
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 }) => {
|
test("starting a scenario navigates to its fixed record", async ({ page }) => {
|
||||||
await page.goto("/login");
|
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 expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/scenarios");
|
await page.goto("/scenarios");
|
||||||
|
|
||||||
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
|
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$/);
|
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 }) => {
|
test("demo guide is not shown to a rental employee", async ({ page }) => {
|
||||||
await page.goto("/login");
|
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).toHaveURL(/\/dashboard$/);
|
||||||
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" });
|
|||||||
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
|
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/login");
|
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 expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
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 page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).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 }) => {
|
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
|
||||||
await page.goto("/login");
|
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 expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
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 }) => {
|
test("data quality list can filter to demo scenarios only", async ({ page }) => {
|
||||||
await page.goto("/login");
|
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 expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/data-quality");
|
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 }) => {
|
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
|
||||||
await page.goto("/login");
|
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 expect(page).toHaveURL(/\/dashboard$/);
|
||||||
await page.goto("/knowledge");
|
await page.goto("/knowledge");
|
||||||
|
|
||||||
|
|||||||
@@ -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 test.step("1. login as Operations Manager", async () => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
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$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -19,9 +19,9 @@ async function resetDemoData(request: APIRequestContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EXPLORE_OPS_MANAGER: Record<string, string> = {
|
const EXPLORE_OPS_MANAGER: Record<string, string> = {
|
||||||
"nl-BE": "Verken als Operations Manager",
|
"nl-BE": "Verken als Operationsmanager",
|
||||||
"en-GB": "Explore as Operations Manager",
|
"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> = {
|
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 }) => {
|
test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force
|
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 ---
|
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
|
||||||
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
|
// 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"
|
// 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
|
// 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.
|
// hide an unrelated real mistranslation under the same key in a different namespace.
|
||||||
const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
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
|
"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.nl-BE", // language-picker options show each language's own endonym
|
||||||
"common.language.fr-BE",
|
"common.language.fr-BE",
|
||||||
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
|
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
|
||||||
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
|
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
|
||||||
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
|
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
|
||||||
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
|
"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.about.limitationsTitle", // "Limitations" -- identical spelling in French
|
||||||
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
|
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
|
||||||
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
|
"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.questionLabel", // "Question" -- identical spelling in French
|
||||||
"knowledge.retrievalFlow.question",
|
"knowledge.retrievalFlow.question",
|
||||||
"knowledge.questionLabelExchange",
|
"knowledge.questionLabelExchange",
|
||||||
"navigation.items.audit", // "Audit trail" kept as an established cross-language term
|
|
||||||
"returns.result.inspection", // "Inspection" -- identical spelling in French
|
"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) ---
|
// --- Hardcoded JSX text (section 11D) ---
|
||||||
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
|
// 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
|
// `{...}` expression) containing two or more real words are almost always user-facing
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
|
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Fleet Ops</title>
|
<title>Fleet Ops</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { ApiError } from "./apiError";
|
||||||
|
|
||||||
|
export { ApiError } from "./apiError";
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
|
|
||||||
type UnauthorizedListener = () => void;
|
type UnauthorizedListener = () => void;
|
||||||
@@ -8,19 +12,6 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
|||||||
return () => unauthorizedListeners.delete(listener);
|
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> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useAuth } from "../context/AuthContext";
|
||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
import { useViewportTier } from "../hooks/useViewportTier";
|
import { useViewportTier } from "../hooks/useViewportTier";
|
||||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||||
import { Icon } from "./Icons";
|
import { Icon } from "./Icons";
|
||||||
|
import { ApiErrorNotice } from "./PageChrome";
|
||||||
import { PRODUCT_NAME } from "../product";
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
|
||||||
export function DemoGuideTrigger() {
|
export function DemoGuideTrigger() {
|
||||||
@@ -68,7 +70,7 @@ export function DemoGuide() {
|
|||||||
setCollapsedToChip,
|
setCollapsedToChip,
|
||||||
} = useDemoGuide();
|
} = useDemoGuide();
|
||||||
const [resetting, setResetting] = useState(false);
|
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 [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
|
||||||
const pendingTarget = useRef<string | null>(null);
|
const pendingTarget = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -119,7 +121,7 @@ export function DemoGuide() {
|
|||||||
await logout();
|
await logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
|
setResetError(describeApiError(t, err, "guide.restartFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setResetting(false);
|
setResetting(false);
|
||||||
}
|
}
|
||||||
@@ -227,7 +229,7 @@ export function DemoGuide() {
|
|||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
<ApiErrorNotice error={resetError} />
|
||||||
|
|
||||||
<footer className="demo-guide-footer">
|
<footer className="demo-guide-footer">
|
||||||
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useAuth } from "../context/AuthContext";
|
||||||
import type { Role, SearchResultItem } from "../api/types";
|
import type { Role, SearchResultItem } from "../api/types";
|
||||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||||
@@ -11,6 +12,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
|
|||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
import { PRODUCT_NAME } from "../product";
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
import { ApiErrorNotice } from "./PageChrome";
|
||||||
|
|
||||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||||
vehicle: "fleet",
|
vehicle: "fleet",
|
||||||
@@ -86,7 +88,7 @@ export function Layout() {
|
|||||||
const [activeIndex, setActiveIndex] = useState(-1);
|
const [activeIndex, setActiveIndex] = useState(-1);
|
||||||
const [resetConfirming, setResetConfirming] = useState(false);
|
const [resetConfirming, setResetConfirming] = useState(false);
|
||||||
const [resetting, setResetting] = 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 searchInput = useRef<HTMLInputElement>(null);
|
||||||
const searchBox = useRef<HTMLDivElement>(null);
|
const searchBox = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -163,7 +165,7 @@ export function Layout() {
|
|||||||
await logout();
|
await logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
|
setResetError(describeApiError(t, err, "resetFailed"));
|
||||||
setResetConfirming(false);
|
setResetConfirming(false);
|
||||||
} finally {
|
} finally {
|
||||||
setResetting(false);
|
setResetting(false);
|
||||||
@@ -231,7 +233,7 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
||||||
<div className="sidebar-reset">
|
<div className="sidebar-reset">
|
||||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
<ApiErrorNotice error={resetError} />
|
||||||
{!resetConfirming ? (
|
{!resetConfirming ? (
|
||||||
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
||||||
{t("resetDemoData")}
|
{t("resetDemoData")}
|
||||||
|
|||||||
@@ -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({
|
export function EmptyState({
|
||||||
icon = "check",
|
icon = "check",
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
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 type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
@@ -10,6 +11,7 @@ import { useLocaleFormat } from "../i18n/format";
|
|||||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||||
import { Icon } from "./Icons";
|
import { Icon } from "./Icons";
|
||||||
import { StatusBadge } from "./Badge";
|
import { StatusBadge } from "./Badge";
|
||||||
|
import { ApiErrorNotice } from "./PageChrome";
|
||||||
|
|
||||||
function newIdempotencyKey(): string {
|
function newIdempotencyKey(): string {
|
||||||
return typeof crypto.randomUUID === "function"
|
return typeof crypto.randomUUID === "function"
|
||||||
@@ -103,7 +105,7 @@ export function ReturnForm({
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [previewing, setPreviewing] = 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 [idempotencyKey] = useState(newIdempotencyKey);
|
||||||
const [step, setStep] = useState<"capture" | "review">("capture");
|
const [step, setStep] = useState<"capture" | "review">("capture");
|
||||||
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
|
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
|
||||||
@@ -132,7 +134,7 @@ export function ReturnForm({
|
|||||||
setPreview(evaluated);
|
setPreview(evaluated);
|
||||||
setStep("review");
|
setStep("review");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
setError(describeApiError(t, err));
|
||||||
} finally {
|
} finally {
|
||||||
setPreviewing(false);
|
setPreviewing(false);
|
||||||
}
|
}
|
||||||
@@ -148,7 +150,7 @@ export function ReturnForm({
|
|||||||
);
|
);
|
||||||
onRegistered(registered);
|
onRegistered(registered);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("errors:generic"));
|
setError(describeApiError(t, err));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -158,7 +160,7 @@ export function ReturnForm({
|
|||||||
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
|
<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="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>
|
<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">
|
{step === "capture" ? <div className="return-capture">
|
||||||
<div className="form-grid"><label>
|
<div className="form-grid"><label>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
{
|
{
|
||||||
"eyebrow": "Operations / Live overview",
|
"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.",
|
"description": "Readiness, exceptions and hand-offs across today's operation.",
|
||||||
"viewFleet": "View fleet",
|
"viewFleet": "View fleet",
|
||||||
"demoStart": {
|
"demoStart": {
|
||||||
|
|||||||
@@ -1,8 +1,180 @@
|
|||||||
{
|
{
|
||||||
"generic": "Something went wrong. Please try again.",
|
"generic": {
|
||||||
"workspaceLoadFailed": "We couldn't load this workspace.",
|
"title": "Something went wrong",
|
||||||
"unauthorized": "Your session has expired. Please log in again.",
|
"explanation": "This action could not be completed. Please try again."
|
||||||
"forbidden": "You don't have access to this section.",
|
},
|
||||||
"notFound": "This record could not be found.",
|
"codes": {
|
||||||
"networkUnavailable": "The connection to the server is currently unavailable."
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
|
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
|
||||||
"actionFilterLabel": "Action",
|
"actionFilterLabel": "Action",
|
||||||
"actionFilterPlaceholder": "p. ex. demo_login",
|
"actionFilterPlaceholder": "p. ex. demo_login",
|
||||||
"managerOnly": "La piste 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 Operations Managers.",
|
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.",
|
||||||
"loading": "Chargement de la piste d'audit…",
|
"loading": "Chargement de la piste d'audit…",
|
||||||
"unavailable": "La piste d'audit est actuellement indisponible.",
|
"unavailable": "La piste d'audit est actuellement indisponible.",
|
||||||
"empty": "Aucun événement d'audit trouvé",
|
"empty": "Aucun événement d'audit trouvé",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
|
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
|
||||||
"columns": {
|
"columns": {
|
||||||
"when": "Quand",
|
"when": "Quand",
|
||||||
"actor": "Acteur",
|
"actor": "Auteur",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
"entity": "Entité",
|
"entity": "Entité",
|
||||||
"change": "Changement",
|
"change": "Changement",
|
||||||
|
|||||||
@@ -9,15 +9,15 @@
|
|||||||
"accessHeading": "Choisissez comment démarrer",
|
"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.",
|
"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",
|
"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",
|
"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",
|
"exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures",
|
||||||
"safeByDesignTitle": "Conçu pour la sécurité",
|
"safeByDesignTitle": "Conçu pour la sécurité",
|
||||||
"safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.",
|
"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.",
|
"loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.",
|
||||||
"roleOperationsManager": "Operations manager",
|
"roleOperationsManager": "Responsable des opérations",
|
||||||
"roleRentalEmployee": "Rental employee",
|
"roleRentalEmployee": "Collaborateur de location",
|
||||||
"switchRole": "Changer de rôle",
|
"switchRole": "Changer de rôle",
|
||||||
"logout": "Déconnexion"
|
"logout": "Déconnexion"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
{
|
{
|
||||||
"eyebrow": "Exploitation / Aperçu en direct",
|
"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.",
|
"description": "Disponibilité, exceptions et transferts de l'activité en cours.",
|
||||||
"viewFleet": "Voir la flotte",
|
"viewFleet": "Voir la flotte",
|
||||||
"demoStart": {
|
"demoStart": {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"understand-state": {
|
"understand-state": {
|
||||||
"title": "1. Comprendre l'état opérationnel",
|
"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.",
|
"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.",
|
"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."
|
"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",
|
"startScenario": "Démarrer le scénario",
|
||||||
"roleOr": "{{a}} ou {{b}}",
|
"roleOr": "{{a}} ou {{b}}",
|
||||||
"roles": {
|
"roles": {
|
||||||
"operations_manager": "Operations Manager",
|
"operations_manager": "Responsable des opérations",
|
||||||
"rental_employee": "Rental Employee"
|
"rental_employee": "Collaborateur de location"
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"return-anomaly": {
|
"return-anomaly": {
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
"problemTitle": "Le problème fictif",
|
"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.",
|
"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",
|
"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",
|
"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).",
|
"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",
|
"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é.",
|
"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",
|
"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.",
|
"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",
|
"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.",
|
"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"
|
"unknown": "inconnu"
|
||||||
|
|||||||
@@ -1,8 +1,180 @@
|
|||||||
{
|
{
|
||||||
"generic": "Une erreur est survenue. Veuillez réessayer.",
|
"generic": {
|
||||||
"workspaceLoadFailed": "Nous n'avons pas pu charger cet espace de travail.",
|
"title": "Une erreur s'est produite",
|
||||||
"unauthorized": "Votre session a expiré. Veuillez vous reconnecter.",
|
"explanation": "Cette action n'a pas pu être terminée. Veuillez réessayer."
|
||||||
"forbidden": "Vous n'avez pas accès à cette section.",
|
},
|
||||||
"notFound": "Cette fiche est introuvable.",
|
"codes": {
|
||||||
"networkUnavailable": "La connexion au serveur est actuellement indisponible."
|
"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",
|
"statusSucceeded": "Réussi",
|
||||||
"statusFailed": "Échoué",
|
"statusFailed": "Échoué",
|
||||||
"unavailable": "Les tâches d'automatisation sont actuellement indisponibles.",
|
"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…",
|
"loading": "Chargement des tâches d'automatisation…",
|
||||||
"empty": "Aucune tâche d'automatisation ne correspond à ce filtre.",
|
"empty": "Aucune tâche d'automatisation ne correspond à ce filtre.",
|
||||||
"count": "{{count}} événements de workflow",
|
"count": "{{count}} événements de workflow",
|
||||||
|
|||||||
@@ -55,8 +55,8 @@
|
|||||||
"title": "Examinez les preuves enregistrées et consignez une résolution auditée.",
|
"title": "Examinez les preuves enregistrées et consignez une résolution auditée.",
|
||||||
"notFound": "Ce problème est introuvable.",
|
"notFound": "Ce problème est introuvable.",
|
||||||
"loading": "Chargement des preuves du problème…",
|
"loading": "Chargement des preuves du problème…",
|
||||||
"managerOnly": "L'atelier qualité est visible 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 Operations Managers.",
|
"managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Responsables des opérations.",
|
||||||
"summary": {
|
"summary": {
|
||||||
"rule": "Règle",
|
"rule": "Règle",
|
||||||
"entity": "Entité",
|
"entity": "Entité",
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
"reviewAgain": "Revoir la recommandation",
|
"reviewAgain": "Revoir la recommandation",
|
||||||
"manualReview": {
|
"manualReview": {
|
||||||
"heading": "Évaluation manuelle requise",
|
"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."
|
"hint": "Utilisez « Reporter » ou « Rejeter » ci-dessous, ou examinez manuellement le véhicule et les réservations concernées."
|
||||||
},
|
},
|
||||||
"noConflict": {
|
"noConflict": {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"eyebrow": "Bewaken / Onveranderlijke geschiedenis",
|
"eyebrow": "Bewaken / Onveranderlijke geschiedenis",
|
||||||
"title": "Audit trail",
|
"title": "Auditgeschiedenis",
|
||||||
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
|
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
|
||||||
"actionFilterLabel": "Actie",
|
"actionFilterLabel": "Actie",
|
||||||
"actionFilterPlaceholder": "bv. demo-login",
|
"actionFilterPlaceholder": "bv. demo-login",
|
||||||
"managerOnly": "De audit trail is enkel zichtbaar voor Operations Managers.",
|
"managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
|
||||||
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operations Managers.",
|
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
|
||||||
"loading": "Audit trail laden…",
|
"loading": "Auditgeschiedenis laden…",
|
||||||
"unavailable": "Audit trail is momenteel niet beschikbaar.",
|
"unavailable": "Auditgeschiedenis is momenteel niet beschikbaar.",
|
||||||
"empty": "Geen auditgebeurtenissen gevonden",
|
"empty": "Geen auditgebeurtenissen gevonden",
|
||||||
"emptyDetail": "Pas het actiefilter aan.",
|
"emptyDetail": "Pas het actiefilter aan.",
|
||||||
"relatedFilterActive": "Enkel gebeurtenissen gekoppeld aan deze actie ({{count}} gerelateerde gebeurtenissen).",
|
"relatedFilterActive": "Enkel gebeurtenissen gekoppeld aan deze actie ({{count}} gerelateerde gebeurtenissen).",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"storedRendered": "UTC opgeslagen · Brussel weergegeven",
|
"storedRendered": "UTC opgeslagen · Brussel weergegeven",
|
||||||
"columns": {
|
"columns": {
|
||||||
"when": "Wanneer",
|
"when": "Wanneer",
|
||||||
"actor": "Actor",
|
"actor": "Uitvoerder",
|
||||||
"action": "Actie",
|
"action": "Actie",
|
||||||
"entity": "Entiteit",
|
"entity": "Entiteit",
|
||||||
"change": "Wijziging",
|
"change": "Wijziging",
|
||||||
|
|||||||
@@ -9,15 +9,15 @@
|
|||||||
"accessHeading": "Kies hoe je wil starten",
|
"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.",
|
"accessIntro": "Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.",
|
||||||
"startGuidedDemo": "Start begeleide demo",
|
"startGuidedDemo": "Start begeleide demo",
|
||||||
"exploreAsOperationsManager": "Verken als Operations Manager",
|
"exploreAsOperationsManager": "Verken als Operationsmanager",
|
||||||
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
|
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
|
||||||
"exploreAsRentalEmployee": "Verken als Rental Employee",
|
"exploreAsRentalEmployee": "Verken als Verhuurmedewerker",
|
||||||
"exploreAsRentalEmployeeDetail": "Boekingen, retours, wagenpark en procedures",
|
"exploreAsRentalEmployeeDetail": "Boekingen, retours, wagenpark en procedures",
|
||||||
"safeByDesignTitle": "Veilig ontworpen",
|
"safeByDesignTitle": "Veilig ontworpen",
|
||||||
"safeByDesignDetail": "Elke actie wordt gelogd en is in deze demo herstelbaar.",
|
"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.",
|
"loginFailed": "De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.",
|
||||||
"roleOperationsManager": "Operationsmanager",
|
"roleOperationsManager": "Operationsmanager",
|
||||||
"roleRentalEmployee": "Rental employee",
|
"roleRentalEmployee": "Verhuurmedewerker",
|
||||||
"switchRole": "Wissel van rol",
|
"switchRole": "Wissel van rol",
|
||||||
"logout": "Uitloggen"
|
"logout": "Uitloggen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
{
|
{
|
||||||
"eyebrow": "Uitvoeren / Live overzicht",
|
"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.",
|
"description": "Beschikbaarheid, uitzonderingen en overdrachten doorheen de huidige werking.",
|
||||||
"viewFleet": "Wagenpark bekijken",
|
"viewFleet": "Wagenpark bekijken",
|
||||||
"demoStart": {
|
"demoStart": {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"understand-state": {
|
"understand-state": {
|
||||||
"title": "1. Begrijp de operationele status",
|
"title": "1. Begrijp de operationele status",
|
||||||
"whatYouWillSee": "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.",
|
"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.",
|
"startAction": "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.",
|
||||||
"expectedOutcome": "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen."
|
"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."
|
"expectedOutcome": "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is."
|
||||||
},
|
},
|
||||||
"check-automation-audit": {
|
"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.",
|
"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.",
|
"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."
|
"expectedOutcome": "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties."
|
||||||
},
|
},
|
||||||
"review-real-vs-simulated": {
|
"review-real-vs-simulated": {
|
||||||
@@ -99,22 +99,22 @@
|
|||||||
"role": "Rol",
|
"role": "Rol",
|
||||||
"demonstrates": "Toont aan:",
|
"demonstrates": "Toont aan:",
|
||||||
"requiresRole": "Vereist rol: {{roles}}.",
|
"requiresRole": "Vereist rol: {{roles}}.",
|
||||||
"startScenario": "Start scenario",
|
"startScenario": "Scenario starten",
|
||||||
"roleOr": "{{a}} of {{b}}",
|
"roleOr": "{{a}} of {{b}}",
|
||||||
"roles": {
|
"roles": {
|
||||||
"operations_manager": "Operations Manager",
|
"operations_manager": "Operationsmanager",
|
||||||
"rental_employee": "Rental Employee"
|
"rental_employee": "Verhuurmedewerker"
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"return-anomaly": {
|
"return-anomaly": {
|
||||||
"title": "Retour met afwijkende kilometerstand",
|
"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.",
|
"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": {
|
"duplicate-customer": {
|
||||||
"title": "Mogelijke dubbele klant samenvoegen",
|
"title": "Mogelijke dubbele klant samenvoegen",
|
||||||
"problem": "Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — waarschijnlijk dezelfde persoon, twee keer geregistreerd.",
|
"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": {
|
"booking-overlap": {
|
||||||
"title": "Overlappende boekingen herstellen",
|
"title": "Overlappende boekingen herstellen",
|
||||||
@@ -155,9 +155,9 @@
|
|||||||
"problemTitle": "Het fictieve probleem",
|
"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.",
|
"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",
|
"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",
|
"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",
|
"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}}.",
|
"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",
|
"architectureTitle": "Architectuur in het kort",
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
"integrationsDescription": "Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is.",
|
"integrationsDescription": "Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is.",
|
||||||
"resetTitle": "Demo-omgeving herstellen",
|
"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.",
|
"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",
|
"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.",
|
"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"
|
"unknown": "onbekend"
|
||||||
|
|||||||
@@ -1,8 +1,180 @@
|
|||||||
{
|
{
|
||||||
"generic": "Er is een fout opgetreden. Probeer opnieuw.",
|
"generic": {
|
||||||
"workspaceLoadFailed": "We konden deze werkruimte niet laden.",
|
"title": "Er is iets misgegaan",
|
||||||
"unauthorized": "Je sessie is verlopen. Log opnieuw in.",
|
"explanation": "Deze actie kon niet voltooid worden. Probeer opnieuw."
|
||||||
"forbidden": "Je hebt geen toegang tot dit onderdeel.",
|
},
|
||||||
"notFound": "Dit record kon niet gevonden worden.",
|
"codes": {
|
||||||
"networkUnavailable": "De verbinding met de server is momenteel niet beschikbaar."
|
"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.",
|
"description": "Vastgelegde automatiseringspogingen met de recentste foutevidentie.",
|
||||||
"filterLabel": "Weergave",
|
"filterLabel": "Weergave",
|
||||||
"filterNeedsAttention": "Vraagt aandacht",
|
"filterNeedsAttention": "Vraagt aandacht",
|
||||||
"filterRecent": "Recent",
|
"filterRecent": "Recentste",
|
||||||
"filterSucceeded": "Geslaagd",
|
"filterSucceeded": "Geslaagd",
|
||||||
"filterAll": "Alle",
|
"filterAll": "Alle",
|
||||||
"statusFilterLabel": "Status",
|
"statusFilterLabel": "Status",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"statusSucceeded": "Geslaagd",
|
"statusSucceeded": "Geslaagd",
|
||||||
"statusFailed": "Mislukt",
|
"statusFailed": "Mislukt",
|
||||||
"unavailable": "Automatiseringsopdrachten zijn momenteel niet beschikbaar.",
|
"unavailable": "Automatiseringsopdrachten zijn momenteel niet beschikbaar.",
|
||||||
"managerOnly": "Automatisering is enkel zichtbaar voor Operations Managers.",
|
"managerOnly": "Automatisering is enkel zichtbaar voor Operationsmanagers.",
|
||||||
"loading": "Automatiseringsopdrachten laden…",
|
"loading": "Automatiseringsopdrachten laden…",
|
||||||
"empty": "Geen automatiseringsopdrachten voor dit filter.",
|
"empty": "Geen automatiseringsopdrachten voor dit filter.",
|
||||||
"count": "{{count}} workflowgebeurtenissen",
|
"count": "{{count}} workflowgebeurtenissen",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"quality": "Datakwaliteit",
|
"quality": "Datakwaliteit",
|
||||||
"knowledge": "Kennis",
|
"knowledge": "Kennis",
|
||||||
"integrations": "Integraties",
|
"integrations": "Integraties",
|
||||||
"audit": "Audit trail"
|
"audit": "Auditgeschiedenis"
|
||||||
},
|
},
|
||||||
"primaryNavLabel": "Hoofdnavigatie",
|
"primaryNavLabel": "Hoofdnavigatie",
|
||||||
"mobileNavLabel": "Mobiele navigatie",
|
"mobileNavLabel": "Mobiele navigatie",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"scanFailed": "Kwaliteitscontrole kon niet uitgevoerd worden.",
|
"scanFailed": "Kwaliteitscontrole kon niet uitgevoerd worden.",
|
||||||
"statusLabel": "Status",
|
"statusLabel": "Status",
|
||||||
"statusAll": "Alle statussen",
|
"statusAll": "Alle statussen",
|
||||||
"statusOpen": "Open",
|
"statusOpen": "Openstaand",
|
||||||
"statusDeferred": "Uitgesteld",
|
"statusDeferred": "Uitgesteld",
|
||||||
"statusResolved": "Opgelost",
|
"statusResolved": "Opgelost",
|
||||||
"statusRejected": "Verworpen",
|
"statusRejected": "Verworpen",
|
||||||
@@ -55,8 +55,8 @@
|
|||||||
"title": "Bekijk vastgelegde evidentie en registreer een geauditeerde oplossing.",
|
"title": "Bekijk vastgelegde evidentie en registreer een geauditeerde oplossing.",
|
||||||
"notFound": "Dit probleem kon niet gevonden worden.",
|
"notFound": "Dit probleem kon niet gevonden worden.",
|
||||||
"loading": "Probleemevidentie laden…",
|
"loading": "Probleemevidentie laden…",
|
||||||
"managerOnly": "De kwaliteitswerkbank is enkel zichtbaar voor Operations Managers.",
|
"managerOnly": "De kwaliteitswerkbank is enkel zichtbaar voor Operationsmanagers.",
|
||||||
"managerOnlyDetail": "Datakwaliteitsevidentie en -oplossingen zijn enkel zichtbaar voor Operations Managers.",
|
"managerOnlyDetail": "Datakwaliteitsevidentie en -oplossingen zijn enkel zichtbaar voor Operationsmanagers.",
|
||||||
"summary": {
|
"summary": {
|
||||||
"rule": "Regel",
|
"rule": "Regel",
|
||||||
"entity": "Entiteit",
|
"entity": "Entiteit",
|
||||||
@@ -64,8 +64,8 @@
|
|||||||
},
|
},
|
||||||
"resolved": {
|
"resolved": {
|
||||||
"title": "Probleem {{ref}} opgelost",
|
"title": "Probleem {{ref}} opgelost",
|
||||||
"body": "De wijziging is doorgevoerd en vastgelegd in de audit trail.",
|
"body": "De wijziging is doorgevoerd en vastgelegd in de auditgeschiedenis.",
|
||||||
"viewAudit": "Audit trail bekijken",
|
"viewAudit": "Auditgeschiedenis bekijken",
|
||||||
"viewVehicle": "Voertuig bekijken",
|
"viewVehicle": "Voertuig bekijken",
|
||||||
"continueDemo": "Ga verder met de demo"
|
"continueDemo": "Ga verder met de demo"
|
||||||
},
|
},
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
"consequence": {
|
"consequence": {
|
||||||
"statusWillChange": "De voertuigstatus wordt gewijzigd naar {{status}}.",
|
"statusWillChange": "De voertuigstatus wordt gewijzigd naar {{status}}.",
|
||||||
"issueWillBeRechecked": "Dit kwaliteitsprobleem wordt opnieuw gecontroleerd.",
|
"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."
|
"bookingsNotDeleted": "De boekingen zelf worden niet verwijderd."
|
||||||
},
|
},
|
||||||
"changeStatusTo": "Status wijzigen naar {{status}}",
|
"changeStatusTo": "Status wijzigen naar {{status}}",
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
"reviewAgain": "Aanbeveling opnieuw bekijken",
|
"reviewAgain": "Aanbeveling opnieuw bekijken",
|
||||||
"manualReview": {
|
"manualReview": {
|
||||||
"heading": "Handmatige beoordeling vereist",
|
"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."
|
"hint": "Gebruik 'Uitstellen' of 'Verwerpen' hieronder, of onderzoek het voertuig en de betrokken boekingen manueel."
|
||||||
},
|
},
|
||||||
"noConflict": {
|
"noConflict": {
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
"viewVehicle": "Voertuig {{ref}} bekijken",
|
||||||
"viewAutomation": "Automatiseringsstatus bekijken",
|
"viewAutomation": "Automatiseringsstatus bekijken",
|
||||||
"viewAudit": "Audit trail bekijken",
|
"viewAudit": "Auditgeschiedenis bekijken",
|
||||||
"continueDemo": "Ga verder met de demo"
|
"continueDemo": "Ga verder met de demo"
|
||||||
},
|
},
|
||||||
"scenario": {
|
"scenario": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 type { AutomationRun, IntegrationStatus, KnowledgeHealth } from "../api/types";
|
||||||
import { StatusBadge } from "../components/Badge";
|
import { StatusBadge } from "../components/Badge";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useLocaleFormat } from "../i18n/format";
|
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";
|
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
|
||||||
|
|
||||||
type ViewFilter = "attention" | "recent" | "succeeded" | "all";
|
type ViewFilter = "attention" | "recent" | "succeeded" | "all";
|
||||||
@@ -25,7 +26,7 @@ export function Automation() {
|
|||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [view, setView] = useState<ViewFilter>("attention");
|
const [view, setView] = useState<ViewFilter>("attention");
|
||||||
const [expandSucceeded, setExpandSucceeded] = useState(false);
|
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 [retrying, setRetrying] = useState<string | null>(null);
|
||||||
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
||||||
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
|
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
|
||||||
@@ -70,7 +71,7 @@ export function Automation() {
|
|||||||
load();
|
load();
|
||||||
loadIntegrationStatus();
|
loadIntegrationStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRetryError(err instanceof ApiError ? err.message : t("ledger.retryFailed"));
|
setRetryError(describeApiError(t, err, "ledger.retryFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setRetrying(null);
|
setRetrying(null);
|
||||||
}
|
}
|
||||||
@@ -242,7 +243,7 @@ export function Automation() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{error && <ErrorState message={error} />}
|
{error && <ErrorState message={error} />}
|
||||||
{retryError && <p className="error" role="alert">{retryError}</p>}
|
<ApiErrorNotice error={retryError} />
|
||||||
{!error && !runs && <LoadingState label={t("ledger.loading")} />}
|
{!error && !runs && <LoadingState label={t("ledger.loading")} />}
|
||||||
{visibleRuns && visibleRuns.length === 0 && <p>{t("ledger.empty")}</p>}
|
{visibleRuns && visibleRuns.length === 0 && <p>{t("ledger.empty")}</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
import { useLocaleFormat } from "../i18n/format";
|
import { useLocaleFormat } from "../i18n/format";
|
||||||
|
import { useGreetingPeriod } from "../i18n/useGreetingPeriod";
|
||||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||||
import { Icon } from "../components/Icons";
|
import { Icon } from "../components/Icons";
|
||||||
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||||
@@ -31,6 +32,7 @@ function attentionItemTitle(
|
|||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { t } = useTranslation(["dashboard", "common", "integrations"]);
|
const { t } = useTranslation(["dashboard", "common", "integrations"]);
|
||||||
const { formatTime, formatShortDate } = useLocaleFormat();
|
const { formatTime, formatShortDate } = useLocaleFormat();
|
||||||
|
const greetingPeriod = useGreetingPeriod();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { manifest } = useDemoManifest();
|
const { manifest } = useDemoManifest();
|
||||||
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
|
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
|
||||||
@@ -78,10 +80,11 @@ export function Dashboard() {
|
|||||||
|
|
||||||
const latestRun = data.recent_automation[0];
|
const latestRun = data.recent_automation[0];
|
||||||
const readyCount = manifest?.scenarios.filter((s) => s.ready).length ?? 0;
|
const readyCount = manifest?.scenarios.filter((s) => s.ready).length ?? 0;
|
||||||
|
const greetingTitle = `${t(`greeting.${greetingPeriod}`)}. ${t(`greetingBody.${greetingPeriod}`)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page dashboard-page">
|
<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")}>
|
<section className="demo-start-panel" aria-label={t("demoStart.title")}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
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 type { DataQualityIssue, ScanResult } from "../api/types";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
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 = [
|
const RULE_TYPES = [
|
||||||
"possible_duplicate_customer",
|
"possible_duplicate_customer",
|
||||||
@@ -23,7 +24,7 @@ export function DataQuality() {
|
|||||||
const [status, setStatus] = useState("open");
|
const [status, setStatus] = useState("open");
|
||||||
const [ruleType, setRuleType] = useState("");
|
const [ruleType, setRuleType] = useState("");
|
||||||
const [scanning, setScanning] = useState(false);
|
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 [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||||
const [confirmingScan, setConfirmingScan] = useState(false);
|
const [confirmingScan, setConfirmingScan] = useState(false);
|
||||||
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
|
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
|
||||||
@@ -54,7 +55,7 @@ export function DataQuality() {
|
|||||||
setConfirmingScan(false);
|
setConfirmingScan(false);
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setScanError(err instanceof ApiError ? err.message : t("list.scanFailed"));
|
setScanError(describeApiError(t, err, "list.scanFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setScanning(false);
|
setScanning(false);
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,7 @@ export function DataQuality() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{scanError && <p className="error" role="alert">{scanError}</p>}
|
<ApiErrorNotice error={scanError} />
|
||||||
{scanResult && (
|
{scanResult && (
|
||||||
<p className="quiet-empty" role="status">
|
<p className="quiet-empty" role="status">
|
||||||
{t("list.scanComplete", {
|
{t("list.scanComplete", {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react";
|
|||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, ApiError } from "../api/client";
|
import { api, ApiError } from "../api/client";
|
||||||
|
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||||
import type {
|
import type {
|
||||||
ApplyRecommendedStatusResult,
|
ApplyRecommendedStatusResult,
|
||||||
DataQualityIssueDetail as IssueDetail,
|
DataQualityIssueDetail as IssueDetail,
|
||||||
@@ -15,7 +16,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
|
|||||||
import { useLocaleFormat } from "../i18n/format";
|
import { useLocaleFormat } from "../i18n/format";
|
||||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||||
import { Icon } from "../components/Icons";
|
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"];
|
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 { user } = useAuth();
|
||||||
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
|
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
|
||||||
const [fieldChoices, setFieldChoices] = useState<Record<string, "a" | "b">>({});
|
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 [submitting, setSubmitting] = useState(false);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
@@ -155,7 +156,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
|||||||
});
|
});
|
||||||
onResolved();
|
onResolved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("detail.duplicateCustomer.mergeFailed"));
|
setError(describeApiError(t, err, "detail.duplicateCustomer.mergeFailed"));
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -171,7 +172,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
|||||||
return (
|
return (
|
||||||
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
|
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
|
||||||
<SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} />
|
<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">
|
<fieldset className="choice-fieldset">
|
||||||
<legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
|
<legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
|
||||||
@@ -210,7 +211,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
|
|||||||
const differ = valueA !== valueB;
|
const differ = valueA !== valueB;
|
||||||
return (
|
return (
|
||||||
<tr key={field}>
|
<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}>
|
<td data-label={a.public_ref}>
|
||||||
{differ ? (
|
{differ ? (
|
||||||
<label className="checkbox-label">
|
<label className="checkbox-label">
|
||||||
@@ -285,7 +286,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
|
|||||||
}
|
}
|
||||||
return initial;
|
return initial;
|
||||||
});
|
});
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
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 });
|
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields });
|
||||||
onResolved();
|
onResolved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("detail.missingField.saveFailed"));
|
setError(describeApiError(t, err, "detail.missingField.saveFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -312,7 +313,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
|
|||||||
title={t("detail.missingField.heading")}
|
title={t("detail.missingField.heading")}
|
||||||
description={t("detail.missingField.description", { ref: snapshot?.public_ref ?? issue.entity_ref })}
|
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">
|
<div className="form-grid">
|
||||||
{fieldKeys.map((field) => (
|
{fieldKeys.map((field) => (
|
||||||
<label key={field}>
|
<label key={field}>
|
||||||
@@ -345,7 +346,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
|
|||||||
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
|
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
|
||||||
const [correctedValue, setCorrectedValue] = useState("");
|
const [correctedValue, setCorrectedValue] = useState("");
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
@@ -362,7 +363,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
|
|||||||
});
|
});
|
||||||
onResolved();
|
onResolved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("detail.odometerRegression.resolveFailed"));
|
setError(describeApiError(t, err, "detail.odometerRegression.resolveFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -375,7 +376,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
|
|||||||
title={t("detail.odometerRegression.heading")}
|
title={t("detail.odometerRegression.heading")}
|
||||||
description={t("detail.odometerRegression.description")}
|
description={t("detail.odometerRegression.description")}
|
||||||
/>
|
/>
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
<ApiErrorNotice error={error} />
|
||||||
<dl className="detail-grid">
|
<dl className="detail-grid">
|
||||||
<div><dt>{t("detail.odometerRegression.canonicalOdometer")}</dt><dd>{formatNumber(Number(issue.entity_snapshot?.odometer_km ?? 0))} km</dd></div>
|
<div><dt>{t("detail.odometerRegression.canonicalOdometer")}</dt><dd>{formatNumber(Number(issue.entity_snapshot?.odometer_km ?? 0))} km</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -453,7 +454,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
|
|||||||
const bookings = issue.related_snapshots.filter((s) => s.entity_type === "booking");
|
const bookings = issue.related_snapshots.filter((s) => s.entity_type === "booking");
|
||||||
const [bookingRef, setBookingRef] = useState(bookings[0]?.public_ref ?? "");
|
const [bookingRef, setBookingRef] = useState(bookings[0]?.public_ref ?? "");
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
@@ -467,7 +468,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
|
|||||||
});
|
});
|
||||||
onResolved();
|
onResolved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("detail.bookingOverlap.resolveFailed"));
|
setError(describeApiError(t, err, "detail.bookingOverlap.resolveFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -480,7 +481,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
|
|||||||
title={t("detail.bookingOverlap.heading")}
|
title={t("detail.bookingOverlap.heading")}
|
||||||
description={t("detail.bookingOverlap.description")}
|
description={t("detail.bookingOverlap.description")}
|
||||||
/>
|
/>
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
<ApiErrorNotice error={error} />
|
||||||
<fieldset className="choice-fieldset choice-fieldset-grid">
|
<fieldset className="choice-fieldset choice-fieldset-grid">
|
||||||
<legend className="visually-hidden">{t("detail.bookingOverlap.columns.blockThis")}</legend>
|
<legend className="visually-hidden">{t("detail.bookingOverlap.columns.blockThis")}</legend>
|
||||||
{bookings.map((b) => (
|
{bookings.map((b) => (
|
||||||
@@ -570,7 +571,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
|||||||
const [recommendation, setRecommendation] = useState<StatusRecommendation | null>(null);
|
const [recommendation, setRecommendation] = useState<StatusRecommendation | null>(null);
|
||||||
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
|
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
|
||||||
const [stale, setStale] = 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 [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -596,7 +597,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
|||||||
);
|
);
|
||||||
setRecommendation(preview);
|
setRecommendation(preview);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.recommendationFailed"));
|
setError(describeApiError(t, err, "detail.vehicleStatusConflict.recommendationFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRecommendation(false);
|
setLoadingRecommendation(false);
|
||||||
}
|
}
|
||||||
@@ -614,12 +615,10 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
|||||||
setResult(applied);
|
setResult(applied);
|
||||||
onResolved();
|
onResolved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setError(describeApiError(t, err, "detail.vehicleStatusConflict.applyFailed"));
|
||||||
if (err instanceof ApiError && err.code === "RECOMMENDATION_STALE") {
|
if (err instanceof ApiError && err.code === "RECOMMENDATION_STALE") {
|
||||||
setError(t("detail.vehicleStatusConflict.staleRecommendation"));
|
|
||||||
setRecommendation(null);
|
setRecommendation(null);
|
||||||
setStale(true);
|
setStale(true);
|
||||||
} else {
|
|
||||||
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -645,7 +644,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
|||||||
title={t("detail.vehicleStatusConflict.heading")}
|
title={t("detail.vehicleStatusConflict.heading")}
|
||||||
description={t("detail.vehicleStatusConflict.description")}
|
description={t("detail.vehicleStatusConflict.description")}
|
||||||
/>
|
/>
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
<ApiErrorNotice error={error} />
|
||||||
<dl className="detail-grid">
|
<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>
|
<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>
|
</dl>
|
||||||
@@ -739,7 +738,7 @@ export function DataQualityIssueDetail() {
|
|||||||
const { publicRef } = useParams<{ publicRef: string }>();
|
const { publicRef } = useParams<{ publicRef: string }>();
|
||||||
const [issue, setIssue] = useState<IssueDetail | null>(null);
|
const [issue, setIssue] = useState<IssueDetail | null>(null);
|
||||||
const [error, setError] = useState<string | 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 [justResolved, setJustResolved] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
@@ -776,7 +775,7 @@ export function DataQualityIssueDetail() {
|
|||||||
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
|
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} 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} />
|
<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" && (
|
{justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && (
|
||||||
<section className="panel success-panel" aria-live="polite">
|
<section className="panel success-panel" aria-live="polite">
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 type { GroundedAnswer, KnowledgeHealth } from "../api/types";
|
||||||
import { Icon } from "../components/Icons";
|
import { Icon } from "../components/Icons";
|
||||||
import { PageHeader } from "../components/PageChrome";
|
import { ApiErrorNotice, PageHeader } from "../components/PageChrome";
|
||||||
import { PRODUCT_NAME } from "../product";
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
|
||||||
interface Exchange {
|
interface Exchange {
|
||||||
@@ -16,7 +17,7 @@ export function Knowledge() {
|
|||||||
const [status, setStatus] = useState<KnowledgeHealth | null>(null);
|
const [status, setStatus] = useState<KnowledgeHealth | null>(null);
|
||||||
const [question, setQuestion] = useState("");
|
const [question, setQuestion] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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 [exchanges, setExchanges] = useState<Exchange[]>([]);
|
||||||
|
|
||||||
const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE";
|
const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE";
|
||||||
@@ -39,7 +40,7 @@ export function Knowledge() {
|
|||||||
setExchanges((prev) => [{ question: questionText, answer }, ...prev]);
|
setExchanges((prev) => [{ question: questionText, answer }, ...prev]);
|
||||||
setQuestion("");
|
setQuestion("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("askFailed"));
|
setError(describeApiError(t, err, "askFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -107,7 +108,7 @@ export function Knowledge() {
|
|||||||
{!submitting && <Icon name="chevron" />}
|
{!submitting && <Icon name="chevron" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
<ApiErrorNotice error={error} />
|
||||||
<div className="knowledge-suggestions">
|
<div className="knowledge-suggestions">
|
||||||
<span>{t("suggestedLabel")}</span>
|
<span>{t("suggestedLabel")}</span>
|
||||||
{suggestedQuestions.map((q) => (
|
{suggestedQuestions.map((q) => (
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ a:hover { color: var(--teal); }
|
|||||||
.timezone svg { width: 15px; }
|
.timezone svg { width: 15px; }
|
||||||
.operator { display: flex; align-items: center; gap: 9px; padding-left: 17px; border-left: 1px solid var(--line); }
|
.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; }
|
.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 > span:last-child { display: grid; gap: 1px; min-width: 0; }
|
||||||
.operator strong { font-size: .74rem; }
|
.operator strong { font-size: .74rem; overflow-wrap: anywhere; }
|
||||||
.operator small { color: var(--muted); font-size: .64rem; }
|
.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 { 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:hover { background: var(--surface-subtle); border-color: var(--line); }
|
||||||
.icon-button svg { width: 18px; height: 18px; }
|
.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); }
|
.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; }
|
.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-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); }
|
.login-story { position: relative; min-height: 100vh; display: flex; flex-direction: column; padding: 34px 7vw 30px; overflow: hidden; color: white; background: var(--petrol); }
|
||||||
|
|||||||
Reference in New Issue
Block a user