Publish curated MobilityOps source snapshot
This commit is contained in:
commit
7adc072f5e
317 files changed
+48851
No files matched your search
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
playwright-report
|
||||
test-results
|
||||
e2e
|
||||
tsconfig.tsbuildinfo
|
||||
.env*
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
# Vite inlines VITE_* variables at build time; leave empty (default) so the SPA talks to
|
||||
# the same origin and nginx proxies /api to the backend.
|
||||
ARG VITE_API_BASE_URL=""
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
|
||||
# The pinned upstream image can lag Alpine security rebuilds. Apply the current
|
||||
# fixes from its pinned Alpine release before shipping the runtime image.
|
||||
RUN apk upgrade --no-cache
|
||||
ARG VCS_REF=development
|
||||
ARG BUILD_DATE=unknown
|
||||
LABEL org.opencontainers.image.title="Fleet Ops Web" \
|
||||
org.opencontainers.image.revision="$VCS_REF" \
|
||||
org.opencontainers.image.created="$BUILD_DATE" \
|
||||
org.opencontainers.image.source="https://fleetops.example.test"
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("public health and HTTPS-facing shell are available", async ({ page, request, baseURL }) => {
|
||||
const readiness = await request.get("/health/ready");
|
||||
expect(readiness.ok()).toBeTruthy();
|
||||
expect(await readiness.json()).toMatchObject({ status: "ready", database: "up" });
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page).toHaveTitle(/Fleet Ops/);
|
||||
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
|
||||
if (baseURL?.startsWith("https://")) {
|
||||
expect(page.url()).toMatch(/^https:\/\//);
|
||||
}
|
||||
});
|
||||
|
||||
test("non-destructive operator canary covers routes and grounded knowledge", async ({ page }) => {
|
||||
const pageErrors: string[] = [];
|
||||
const serverErrors: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 500) serverErrors.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
if (pageErrors.some((message) => message.toLowerCase().includes("dynamically imported module"))) {
|
||||
// A deploy can replace the SPA between loading index.html and its lazy
|
||||
// dashboard chunk. One clean reload must recover; a persistent chunk or
|
||||
// server error is collected again and still fails the canary below.
|
||||
pageErrors.length = 0;
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.locator("main")).toBeVisible();
|
||||
}
|
||||
|
||||
for (const [path, heading] of [
|
||||
["/vehicles", "Wagenpark"],
|
||||
["/bookings", "Boekingen"],
|
||||
["/data-quality", "Datakwaliteit"],
|
||||
["/automation", "Integratiebeheer"],
|
||||
["/audit", "Auditgeschiedenis"],
|
||||
] as const) {
|
||||
await page.goto(path);
|
||||
await expect(page.getByRole("heading", { name: heading }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
await page.goto("/knowledge");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Vraag" })
|
||||
.fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
|
||||
await page.getByRole("button", { name: "Vraag stellen" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
expect(serverErrors).toEqual([]);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// One-off tooling to capture demo-productization evidence screenshots for
|
||||
// artifacts/demo-release/final-summary.md. Not part of the regular test suite
|
||||
// (prefixed with `_` and excluded from the configured test run via testIgnore).
|
||||
|
||||
const OUT = "../artifacts/demo-release/screenshots";
|
||||
|
||||
test("capture demo-release evidence screenshots", async ({ page, request }) => {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/01-demo-entry-desktop.png`, fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` });
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/04-demo-guide.png` });
|
||||
await page.getByRole("button", { name: "Sluiten" }).click();
|
||||
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Review return" }).click();
|
||||
await expect(page.getByText(/Expected fleet state/)).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/05-return-preview.png`, fullPage: true });
|
||||
await page.getByRole("button", { name: "Confirm return" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/06-return-result.png`, fullPage: true });
|
||||
|
||||
await page.goto("/data-quality");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
await page.locator(".data-table tbody tr").first().locator("a").click();
|
||||
await expect(page.getByText("What's wrong")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/07-data-quality-resolution.png`, fullPage: true });
|
||||
await page.getByRole("radio", { name: /Retain canonical/ }).check();
|
||||
await page.getByRole("button", { name: "Resolve issue" }).click();
|
||||
await expect(page.getByText(/Issue .* resolved/)).toBeVisible();
|
||||
|
||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
||||
await expect(page.getByRole("heading", { name: "Compare and merge" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/08-duplicate-customer-merge.png`, fullPage: true });
|
||||
|
||||
await page.goto("/knowledge");
|
||||
await page.getByRole("button", { name: "What must I do when a vehicle returns with damage?" }).click();
|
||||
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/09-knowledge-assistant.png`, fullPage: true });
|
||||
|
||||
await page.goto("/automation");
|
||||
await expect(page.locator(".integration-cards")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/10-integration-status.png`, fullPage: true });
|
||||
const failedRetry = page.locator(".data-table tbody tr", { hasText: "failed" }).first();
|
||||
if (await failedRetry.count()) {
|
||||
await page.screenshot({ path: `${OUT}/11-automation-retry-before.png`, fullPage: true });
|
||||
await failedRetry.getByRole("button", { name: "Retry" }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/11-automation-retry-after.png`, fullPage: true });
|
||||
}
|
||||
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/12-audit-trail.png`, fullPage: true });
|
||||
await page.locator(".data-table tbody tr").first().getByRole("button", { name: "View related events" }).click();
|
||||
await page.screenshot({ path: `${OUT}/13-audit-related-events.png`, fullPage: true });
|
||||
|
||||
await page.goto("/about");
|
||||
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/14-about-demo.png`, fullPage: true });
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await page.getByRole("button", { name: /Synthetische demo/ }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/15-demo-badge-popover.png` });
|
||||
|
||||
// Final reset, captured, to leave the environment fully restored.
|
||||
await page.keyboard.press("Escape");
|
||||
const resetButton = page.getByRole("button", { name: "Reset demo data" });
|
||||
if (await resetButton.count()) {
|
||||
await resetButton.click();
|
||||
await expect(page.getByRole("button", { name: "Yes, reset" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/16-reset-confirm.png` });
|
||||
await page.getByRole("button", { name: "Yes, reset" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const OUT = "../artifacts/evidence/screenshots";
|
||||
|
||||
test("capture current recruiter-facing evidence", async ({ page, request }) => {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("heading", { name: "Elke overdracht. Eén helder overzicht." })).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/1-login.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click();
|
||||
await expect(page).toHaveURL(/\/highlights$/);
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/2-highlights.png`, fullPage: true });
|
||||
|
||||
await page.goto("/about");
|
||||
await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/3-engineering-story.png`, fullPage: true });
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/4-dashboard.png`, fullPage: true });
|
||||
|
||||
await page.goto("/knowledge");
|
||||
await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/5-knowledge-evidence.png`, fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/highlights");
|
||||
await expect(page.locator(".highlight-card").first()).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/6-highlights-mobile.png` });
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// One-off tooling to capture evidence screenshots for artifacts/evidence/final-summary.md.
|
||||
// Not part of the regular test suite (prefixed with `_` and excluded from CI runs).
|
||||
|
||||
const OUT = "../artifacts/evidence/screenshots";
|
||||
|
||||
test("capture the seven main pages", async ({ page, request }) => {
|
||||
await request.post("http://localhost:8128/api/v1/demo/login", {
|
||||
data: { role: "operations_manager" },
|
||||
});
|
||||
await request.post("http://localhost:8128/api/v1/demo/reset");
|
||||
|
||||
await page.goto("/login");
|
||||
await page.screenshot({ path: `${OUT}/1-login.png` });
|
||||
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
|
||||
|
||||
await page.goto("/vehicles");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/3-vehicles.png`, fullPage: true });
|
||||
|
||||
await page.goto("/bookings");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/4-bookings.png`, fullPage: true });
|
||||
|
||||
await page.goto("/data-quality");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/5-data-quality.png`, fullPage: true });
|
||||
|
||||
await page.goto("/knowledge");
|
||||
await page
|
||||
.getByPlaceholder(/What must I do when a vehicle returns with damage/)
|
||||
.fill("What must I do when a vehicle returns with damage?");
|
||||
await page.getByRole("button", { name: "Ask" }).click();
|
||||
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/6-knowledge.png`, fullPage: true });
|
||||
|
||||
await page.goto("/automation");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/7-automation.png`, fullPage: true });
|
||||
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/8-audit.png`, fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 360, height: 800 });
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
|
||||
await page.screenshot({ path: `${OUT}/9-mobile-dashboard.png` });
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.use({ bypassCSP: true, reducedMotion: "reduce" });
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const axeSource = readFileSync(require.resolve("axe-core/axe.min.js"), "utf8");
|
||||
|
||||
type AxeViolation = {
|
||||
id: string;
|
||||
impact: string | null;
|
||||
help: string;
|
||||
nodes: Array<{ target: string[] }>;
|
||||
};
|
||||
|
||||
async function seriousViolations(page: import("@playwright/test").Page): Promise<AxeViolation[]> {
|
||||
await page.addStyleTag({
|
||||
content: "*,*::before,*::after{animation:none!important;transition:none!important}",
|
||||
});
|
||||
await page.addScriptTag({ content: axeSource });
|
||||
return page.evaluate(async () => {
|
||||
const axe = (window as Window & {
|
||||
axe: {
|
||||
run: (
|
||||
context: Document,
|
||||
options: object,
|
||||
) => Promise<{ violations: AxeViolation[] }>;
|
||||
};
|
||||
}).axe;
|
||||
const result = await axe.run(document, {
|
||||
runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] },
|
||||
});
|
||||
return result.violations.filter(
|
||||
(violation) => violation.impact === "critical" || violation.impact === "serious",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("principal routes have no serious automated accessibility violations", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
expect(await seriousViolations(page)).toEqual([]);
|
||||
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
for (const route of ["/dashboard", "/data-quality", "/knowledge", "/automation", "/audit"] as const) {
|
||||
await page.goto(route);
|
||||
const violations = await seriousViolations(page);
|
||||
expect(violations, `${route}: ${JSON.stringify(violations)}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
// Verifies the "stretched link" pattern used across the Attention Queue, Today's movements,
|
||||
// Vehicles, Bookings and Data Quality tables: the whole row is one activation target, not
|
||||
// just its title/reference text, while any secondary in-row link stays independently usable.
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
test("attention queue row opens its record when clicking empty row space, not just the title", async ({ page }) => {
|
||||
const row = page.locator(".attention-list li.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const box = await row.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
// Click near the far right edge of the row -- empty space, not the title text or badge.
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
|
||||
});
|
||||
|
||||
test("attention queue row is keyboard reachable and opens on Enter", async ({ page }) => {
|
||||
const row = page.locator(".attention-list li.row-clickable").first();
|
||||
const link = row.locator(".row-link");
|
||||
await link.focus();
|
||||
await expect(link).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
|
||||
});
|
||||
|
||||
test("today's movements row opens the correct booking on click", async ({ page }) => {
|
||||
const row = page.locator(".movement-timeline li.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const box = await row.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(/\/bookings\/BK-/);
|
||||
});
|
||||
|
||||
test("vehicles table row opens the vehicle detail from empty row space", async ({ page }) => {
|
||||
await page.goto("/vehicles");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
|
||||
const box = await row.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(new RegExp(`/vehicles/${ref}$`));
|
||||
});
|
||||
|
||||
test("bookings table row opens the booking, and the secondary vehicle link stays independently clickable", async ({ page }) => {
|
||||
await page.goto("/bookings");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const vehicleLink = row.locator(".cell-link");
|
||||
const vehicleRef = (await vehicleLink.innerText()).trim();
|
||||
|
||||
// Clicking the secondary vehicle-ref link navigates to the vehicle, not the booking --
|
||||
// it must not be swallowed by the row-spanning overlay link sitting behind it.
|
||||
await vehicleLink.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/vehicles/${vehicleRef}$`));
|
||||
|
||||
await page.goto("/bookings");
|
||||
const rowAgain = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
const bookingRef = (await rowAgain.locator("th").first().innerText()).split("\n")[0].trim();
|
||||
const box = await rowAgain.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(new RegExp(`/bookings/${bookingRef}$`));
|
||||
});
|
||||
|
||||
test("data quality table row opens the issue detail from empty row space", async ({ page }) => {
|
||||
await page.goto("/data-quality");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
|
||||
const box = await row.boundingBox();
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
|
||||
});
|
||||
|
||||
test("attention queue row opens the correct record on a mobile viewport tap", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/dashboard");
|
||||
const row = page.locator(".attention-list li.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const link = row.locator(".row-link");
|
||||
const [rowBox, linkBox] = await Promise.all([row.boundingBox(), link.boundingBox()]);
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(linkBox).not.toBeNull();
|
||||
// The stretched link itself must cover the whole mobile row. Locator.click handles
|
||||
// scrolling and fixed mobile chrome before clicking the link's empty right-hand area.
|
||||
expect(Math.abs(linkBox!.width - rowBox!.width)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(linkBox!.height - rowBox!.height)).toBeLessThanOrEqual(1);
|
||||
// A real touch context needs `hasTouch`, which this shared spec file doesn't opt into;
|
||||
// a mouse click at the same mobile viewport size still exercises the same CSS layout
|
||||
// and click-target logic, since the app has no touch-specific event handling.
|
||||
await link.click({ position: { x: linkBox!.width - 10, y: linkBox!.height / 2 } });
|
||||
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
|
||||
});
|
||||
|
||||
test("data quality table row has a pointer cursor and a visible focus ring covering the whole row", async ({ page }) => {
|
||||
await page.goto("/data-quality");
|
||||
const row = page.locator(".data-table tbody tr.row-clickable").first();
|
||||
await expect(row).toHaveCSS("cursor", "pointer");
|
||||
await row.locator(".row-link").focus();
|
||||
await expect(row.locator(".row-link")).toBeFocused();
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import type { AutomationRun } from "../src/api/types";
|
||||
|
||||
const apiErrorBody = JSON.stringify({
|
||||
error: { code: "TEST_UNAVAILABLE", message: "Injected test failure", correlation_id: "test-correlation" },
|
||||
});
|
||||
|
||||
async function loginAsManager(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
}
|
||||
|
||||
async function failFirstManifestRequest(page: Page) {
|
||||
let requestCount = 0;
|
||||
await page.route("**/api/v1/demo/manifest", async (route) => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
test("session validation does not wait for a stalled system-status request", async ({ page }) => {
|
||||
let releaseStatus = () => undefined;
|
||||
const statusGate = new Promise<void>((resolve) => {
|
||||
releaseStatus = resolve;
|
||||
});
|
||||
let sessionRequested = false;
|
||||
|
||||
await page.route("**/api/v1/system/status", async (route) => {
|
||||
await statusGate;
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody });
|
||||
});
|
||||
await page.route("**/api/v1/auth/session", async (route) => {
|
||||
sessionRequested = true;
|
||||
await route.fulfill({ status: 401, contentType: "application/json", body: apiErrorBody });
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeEnabled({
|
||||
timeout: 2_000,
|
||||
});
|
||||
expect(sessionRequested).toBe(true);
|
||||
} finally {
|
||||
releaseStatus();
|
||||
}
|
||||
});
|
||||
|
||||
test("guided demo stays gated while its manifest loads, then exposes an explicit retry", async ({ page }) => {
|
||||
let releaseManifest = () => undefined;
|
||||
const manifestGate = new Promise<void>((resolve) => {
|
||||
releaseManifest = resolve;
|
||||
});
|
||||
let markManifestRequested = () => undefined;
|
||||
const manifestRequested = new Promise<void>((resolve) => {
|
||||
markManifestRequested = resolve;
|
||||
});
|
||||
let manifestAttempts = 0;
|
||||
|
||||
await page.route("**/api/v1/demo/manifest", async (route) => {
|
||||
manifestAttempts += 1;
|
||||
if (manifestAttempts === 1) {
|
||||
markManifestRequested();
|
||||
await manifestGate;
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto("/login");
|
||||
await manifestRequested;
|
||||
const guidedDemo = page.getByRole("button", { name: "Start begeleide demo" });
|
||||
await expect(guidedDemo).toBeDisabled();
|
||||
await expect(page.getByText("Begeleide demo voorbereiden…")).toBeVisible();
|
||||
|
||||
releaseManifest();
|
||||
await expect(page.getByText("De begeleide demo-informatie kon niet geladen worden.")).toBeVisible();
|
||||
await expect(guidedDemo).toBeDisabled();
|
||||
await page.getByRole("button", { name: "Demo-informatie opnieuw laden" }).click();
|
||||
|
||||
await expect(guidedDemo).toBeEnabled();
|
||||
expect(manifestAttempts).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
releaseManifest();
|
||||
}
|
||||
});
|
||||
|
||||
test("guided demo does not continue until the manifest reports it ready", async ({ page }) => {
|
||||
let returnNotReady = true;
|
||||
await page.route("**/api/v1/demo/manifest", async (route) => {
|
||||
if (!returnNotReady) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
const response = await route.fetch();
|
||||
const manifest = await response.json() as Record<string, unknown>;
|
||||
await route.fulfill({ response, json: { ...manifest, guide_available: false } });
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
const guidedDemo = page.getByRole("button", { name: "Start begeleide demo" });
|
||||
await expect(guidedDemo).toBeDisabled();
|
||||
await expect(page.getByText("De begeleide demo is momenteel niet klaar om te starten.")).toBeVisible();
|
||||
await guidedDemo.evaluate((button) => {
|
||||
button.removeAttribute("disabled");
|
||||
(button as HTMLButtonElement).click();
|
||||
});
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
returnNotReady = false;
|
||||
await page.getByRole("button", { name: "Demo-informatie opnieuw laden" }).click();
|
||||
await expect(guidedDemo).toBeEnabled();
|
||||
});
|
||||
|
||||
test("blocked sessionStorage cannot break login or logout", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperties(Storage.prototype, {
|
||||
setItem: {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new DOMException("Storage is blocked", "SecurityError");
|
||||
},
|
||||
},
|
||||
removeItem: {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new DOMException("Storage is blocked", "SecurityError");
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await loginAsManager(page);
|
||||
await expect(page.getByText("Amelie De Ridder").first()).toBeVisible();
|
||||
|
||||
await page.locator(".operator-menu > summary").click();
|
||||
await page.getByRole("button", { name: "Wissel van rol" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
test("scenario manifest failure is visible and retryable", async ({ page }) => {
|
||||
await failFirstManifestRequest(page);
|
||||
await loginAsManager(page);
|
||||
|
||||
await page.locator('a[href="/scenarios"]').first().click();
|
||||
await expect(page).toHaveURL(/\/scenarios$/);
|
||||
await expect(page.getByText("De demonstratiescenario's konden niet geladen worden.")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Opnieuw proberen" }).click();
|
||||
await expect(page.locator(".scenario-card")).toHaveCount(5);
|
||||
});
|
||||
|
||||
test("engineering manifest failure is visible and retryable", async ({ page }) => {
|
||||
await failFirstManifestRequest(page);
|
||||
await loginAsManager(page);
|
||||
|
||||
await page.locator('a[href="/about"]').first().click();
|
||||
await expect(page).toHaveURL(/\/about$/);
|
||||
await expect(page.getByText("De demo-informatie kon niet geladen worden.")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Opnieuw proberen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the latest automation filter wins when an older response completes last", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
let releaseOldResponse = () => undefined;
|
||||
const oldResponseGate = new Promise<void>((resolve) => {
|
||||
releaseOldResponse = resolve;
|
||||
});
|
||||
let markOldRequestStarted = () => undefined;
|
||||
const oldRequestStarted = new Promise<void>((resolve) => {
|
||||
markOldRequestStarted = resolve;
|
||||
});
|
||||
let markOldResponseAttempted = () => undefined;
|
||||
const oldResponseAttempted = new Promise<void>((resolve) => {
|
||||
markOldResponseAttempted = resolve;
|
||||
});
|
||||
|
||||
const run = (status: "failed" | "pending", aggregateRef: string): AutomationRun => ({
|
||||
event_id: status === "failed"
|
||||
? "11111111-1111-4111-8111-111111111111"
|
||||
: "22222222-2222-4222-8222-222222222222",
|
||||
event_type: "vehicle.returned.v1",
|
||||
aggregate_ref: aggregateRef,
|
||||
status,
|
||||
attempts: status === "failed" ? 2 : 0,
|
||||
last_error: null,
|
||||
last_error_code: null,
|
||||
is_demo_scenario: false,
|
||||
occurred_at: "2026-08-23T10:00:00Z",
|
||||
});
|
||||
|
||||
await page.route(/\/api\/v1\/workflows(?:\?.*)?$/, async (route) => {
|
||||
const requestedStatus = new URL(route.request().url()).searchParams.get("status");
|
||||
if (requestedStatus === "failed") {
|
||||
markOldRequestStarted();
|
||||
await oldResponseGate;
|
||||
try {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([run("failed", "BK-RACE-OLD")]),
|
||||
});
|
||||
} catch {
|
||||
// Chromium cancels the superseded fetch. The delayed route can therefore be
|
||||
// closed before Playwright gets to fulfil it; either outcome proves the same
|
||||
// contract as long as it cannot overwrite the newer response.
|
||||
} finally {
|
||||
markOldResponseAttempted();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (requestedStatus === "pending") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([run("pending", "BK-RACE-NEW")]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
try {
|
||||
await loginAsManager(page);
|
||||
await page.goto("/automation");
|
||||
await expect(page.getByRole("heading", { name: "Automatiseringsopdrachten" })).toBeVisible();
|
||||
|
||||
const statusFilter = page.getByRole("combobox", { name: "Status", exact: true });
|
||||
await statusFilter.selectOption("failed");
|
||||
await oldRequestStarted;
|
||||
await statusFilter.selectOption("pending");
|
||||
|
||||
await expect(page.getByText("BK-RACE-NEW", { exact: true })).toBeVisible();
|
||||
releaseOldResponse();
|
||||
await oldResponseAttempted;
|
||||
|
||||
await expect(page.getByText("BK-RACE-NEW", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("BK-RACE-OLD", { exact: true })).toHaveCount(0);
|
||||
} finally {
|
||||
releaseOldResponse();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("demo guide is usable as a mobile bottom sheet", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
|
||||
const panel = page.getByRole("dialog", { name: "Gegidste demo" });
|
||||
await expect(panel).toBeVisible();
|
||||
const box = await panel.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
// A bottom sheet: anchored to the bottom of the viewport, not a full-height side panel.
|
||||
expect(box!.height).toBeLessThan(800);
|
||||
expect(box!.x).toBeLessThanOrEqual(1);
|
||||
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await expect(page.getByRole("heading", { name: "2. Open een boeking" })).toBeVisible();
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
});
|
||||
|
||||
test("demo guide does not cover the return form's action buttons on desktop", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
|
||||
const reviewButton = page.getByRole("button", { name: "Retour nakijken" });
|
||||
await expect(reviewButton).toBeVisible();
|
||||
await reviewButton.click({ timeout: 5000 });
|
||||
await expect(page.getByText(/Verwachte wagenparkstatus/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
|
||||
await guideTrigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
// The guide panel itself doesn't bind Escape (it's a persistent panel, not a transient
|
||||
// popover), so close it explicitly the way a keyboard user would: activate its own
|
||||
// close control.
|
||||
await page.getByRole("button", { name: "Sluiten" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
|
||||
const badgeTrigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||
await badgeTrigger.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("key demo pages load without console errors", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() !== "error") return;
|
||||
// The app deliberately probes GET /demo/session on every load to confirm whether a
|
||||
// session cookie is still valid (see AuthContext.tsx); a logged-out visitor's very
|
||||
// first load always logs one benign 401 for this, which the app already handles via
|
||||
// .catch() -- it is not an application error.
|
||||
if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return;
|
||||
errors.push(msg.text());
|
||||
});
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/scenarios");
|
||||
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
||||
await page.goto("/about");
|
||||
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
|
||||
expect(errors, `Unexpected console errors: ${errors.join("\n")}`).toEqual([]);
|
||||
});
|
||||
|
||||
test("mobile quality workbench keeps filters compact and bulk actions reachable", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/data-quality?status=open");
|
||||
|
||||
const filterButton = page.getByRole("button", { name: /Filters/ });
|
||||
await expect(filterButton).toBeVisible();
|
||||
await expect(page.locator("#quality-filters")).toBeHidden();
|
||||
await filterButton.click();
|
||||
await expect(page.locator("#quality-filters")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Zichtbare problemen selecteren" }).click();
|
||||
await expect(page.getByRole("region", { name: "Geselecteerde werkvoorraad bijwerken" })).toBeVisible();
|
||||
const checked = await page.locator(".quality-table tbody input[type=checkbox]:checked").count();
|
||||
expect(checked).toBeGreaterThan(1);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
|
||||
await page.evaluate(() => document.documentElement.clientWidth + 1),
|
||||
);
|
||||
});
|
||||
|
||||
test("status-recommendation panel is fully keyboard operable, respects reduced motion, and never signals status by colour alone", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
|
||||
const reviewButton = page.getByRole("button", { name: "Aanbeveling bekijken" });
|
||||
await reviewButton.focus();
|
||||
await expect(reviewButton).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const confirmButton = page.getByRole("button", { name: /^Status wijzigen naar/ });
|
||||
await expect(confirmButton).toBeVisible();
|
||||
|
||||
// Status is never conveyed by colour alone: the badge always carries its own text.
|
||||
const badge = page.locator(".status-decision .badge").first();
|
||||
await expect(badge).not.toHaveText("");
|
||||
|
||||
// The confirm action itself is a real, focusable, keyboard-activatable button (the
|
||||
// previous "Aanbeveling bekijken" button is unmounted once the decision panel
|
||||
// replaces it, so focus is verified directly rather than via a Tab chain from it).
|
||||
await confirmButton.focus();
|
||||
await expect(confirmButton).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("demo entry screen names the fictional org and never shows a password", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
||||
await expect(page.getByText(/Synthetische demo · geen echte klant-/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
|
||||
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("start guided demo logs in as Operations Manager and opens the guide at step 1", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
// The ?guide=start marker is a one-shot signal the dashboard strips immediately after
|
||||
// consuming it, so assert on its effect (the guide panel opens at step 1) rather than
|
||||
// the transient URL, which can already be gone by the time this assertion runs.
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByText("Amelie De Ridder")).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||
await expect(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
|
||||
await expect(page.getByText(/Laatste reset:/)).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: /Over deze demo/ }).click();
|
||||
await expect(page).toHaveURL(/\/about$/);
|
||||
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
|
||||
await expect(page.getByText("Northstar Mobility").first()).toBeVisible();
|
||||
await expect(page.getByText("Demomodus").first()).toBeVisible();
|
||||
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("badge popover closes on Escape and outside click", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toBeHidden();
|
||||
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.mouse.click(10, 10);
|
||||
await expect(page.getByRole("dialog")).toBeHidden();
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/scenarios");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
||||
const cards = page.locator(".scenario-card");
|
||||
await expect(cards).toHaveCount(5);
|
||||
for (const label of ["Klaar voor demo"]) {
|
||||
await expect(page.getByText(label).first()).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText("Niet beschikbaar")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("starting a scenario navigates to its fixed record", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/scenarios");
|
||||
|
||||
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
|
||||
await duplicateCard.getByRole("link", { name: "Scenario starten" }).click();
|
||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
|
||||
});
|
||||
|
||||
// The default Playwright viewport (1280x720) falls in the "standard desktop/tablet" tier
|
||||
// (see useViewportTier.ts): the guide is a floating, non-modal panel that auto-collapses
|
||||
// to a persistent progress chip the moment the visitor acts on "Ga naar deze stap".
|
||||
test("demo guide: navigating steps, jumping to a step collapses to a chip, and the chip reopens it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await expect(page.getByRole("heading", { name: "2. Open een boeking die aandacht nodig heeft" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
|
||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/knowledge$/);
|
||||
// Panel auto-collapses to a persistent chip -- it must never sit over the knowledge
|
||||
// page's primary "Ask" action after navigation.
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
const chip = page.getByRole("button", { name: /Demo-gids · stap 6 van 8/ });
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
await chip.click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the collapsed chip has its own close control, independent of reopening it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
const chip = page.getByRole("button", { name: /Demo-gids · stap/ });
|
||||
await expect(chip).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Sluiten" }).click();
|
||||
await expect(chip).toBeHidden();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
await expect(page.locator(".demo-guide-trigger")).toBeFocused();
|
||||
});
|
||||
|
||||
test("demo guide progress persists across navigation and the trigger shows it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await page.getByRole("button", { name: "Demo-gids inklappen" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /^Demo-gids/ }).first()).toContainText("2/8");
|
||||
|
||||
await page.goto("/vehicles");
|
||||
await page.getByRole("button", { name: /^Demo-gids/ }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("demo guide is not shown to a rental employee", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Verhuurmedewerker" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("restarting the demo from the guide resets data and returns to login", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("sidebar reset preserves guide progress on failure and clears it only after success", async ({ page }) => {
|
||||
let resetShouldFail = true;
|
||||
await page.route("**/api/v1/demo/reset", async (route) => {
|
||||
if (resetShouldFail) {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
error: { code: "TEST_UNAVAILABLE", message: "Injected reset failure", correlation_id: "reset-test" },
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "reset" }) });
|
||||
});
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await page.locator(".demo-guide-trigger").click();
|
||||
await expect(page.locator(".demo-guide-trigger")).toContainText("1/8");
|
||||
|
||||
await page.getByRole("button", { name: "Demogegevens herstellen" }).click();
|
||||
await page.getByRole("button", { name: "Ja, herstellen" }).click();
|
||||
await expect(page.getByText("Demogegevens konden niet hersteld worden.")).toBeVisible();
|
||||
await expect(page.locator(".demo-guide-trigger")).toContainText("1/8");
|
||||
|
||||
resetShouldFail = false;
|
||||
await page.getByRole("button", { name: "Demogegevens herstellen" }).click();
|
||||
await page.getByRole("button", { name: "Ja, herstellen" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page.locator(".demo-guide-trigger")).toContainText("0/8");
|
||||
});
|
||||
|
||||
test("wide desktop viewport docks the guide as a rail that never collapses to a chip", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
const panel = page.locator(".demo-guide-panel.is-wide");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.locator(".demo-guide-chip")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
const panel = page.locator(".demo-guide-panel.is-mobile");
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toHaveClass(/sheet-half/);
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
|
||||
await page.locator(".demo-guide-sheet-handle").click();
|
||||
await expect(panel).toHaveClass(/sheet-full/);
|
||||
await page.locator(".demo-guide-sheet-handle").click();
|
||||
await expect(panel).toHaveClass(/sheet-collapsed/);
|
||||
});
|
||||
|
||||
test("Escape collapses the standard-tier panel, then closes it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeHidden();
|
||||
await expect(page.locator(".demo-guide-trigger")).toBeFocused();
|
||||
});
|
||||
|
||||
test("closing the guide cancels a delayed target lookup and restores trigger focus", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
let releaseKnowledgeChunk = () => undefined;
|
||||
const knowledgeChunkGate = new Promise<void>((resolve) => {
|
||||
releaseKnowledgeChunk = resolve;
|
||||
});
|
||||
let markKnowledgeChunkRequested = () => undefined;
|
||||
const knowledgeChunkRequested = new Promise<void>((resolve) => {
|
||||
markKnowledgeChunkRequested = resolve;
|
||||
});
|
||||
|
||||
await page.route(
|
||||
/\/(?:assets\/Knowledge-[^/?]+\.js|src\/pages\/Knowledge\.tsx)(?:\?.*)?$/,
|
||||
async (route) => {
|
||||
markKnowledgeChunkRequested();
|
||||
await knowledgeChunkGate;
|
||||
await route.continue();
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await knowledgeChunkRequested;
|
||||
|
||||
await page
|
||||
.locator(".demo-guide-panel.is-wide")
|
||||
.getByRole("button", { name: "Sluiten" })
|
||||
.click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
await expect(page.locator(".demo-guide-trigger")).toBeFocused();
|
||||
|
||||
releaseKnowledgeChunk();
|
||||
const target = page.locator("#ask-heading");
|
||||
await expect(target).toBeVisible();
|
||||
await page.waitForTimeout(150);
|
||||
await expect(target).not.toBeFocused();
|
||||
await expect(target).not.toHaveClass(/demo-guide-highlight/);
|
||||
} finally {
|
||||
releaseKnowledgeChunk();
|
||||
}
|
||||
});
|
||||
|
||||
test("going to a step scrolls, focuses and highlights the on-page target", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/knowledge$/);
|
||||
|
||||
const target = page.locator("#ask-heading");
|
||||
await expect(target).toBeFocused();
|
||||
await expect(target).toHaveClass(/demo-guide-highlight/);
|
||||
});
|
||||
|
||||
test("going to a guide target on the already-active route still focuses and highlights it", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: /8\. Bekijk wat echt is/ }).click();
|
||||
await page.goto("/about");
|
||||
await page.getByRole("button", { name: /^Demo-gids/ }).first().click();
|
||||
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
|
||||
const target = page.locator(".engineering-hero");
|
||||
await expect(target).toBeFocused();
|
||||
await expect(target).toHaveClass(/demo-guide-highlight/);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
|
||||
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
|
||||
const odometerInput = page.getByLabel("Eindkilometerstand (km)");
|
||||
await expect(odometerInput).not.toHaveValue("");
|
||||
const prefilled = Number(await odometerInput.inputValue());
|
||||
expect(prefilled).toBeGreaterThan(0);
|
||||
|
||||
await page.getByRole("button", { name: "Retour nakijken" }).click();
|
||||
await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Volledige verwerking volgen" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
||||
|
||||
await expect(page.getByText("Wat is er mis")).toBeVisible();
|
||||
await expect(page.getByText("Waarom dit belangrijk is")).toBeVisible();
|
||||
await expect(page.getByText(/waarschijnlijk dezelfde persoon/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality list can filter to demo scenarios only", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/data-quality");
|
||||
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const allRows = await page.locator(".data-table tbody tr").count();
|
||||
// The URL-driven filter update can replace the controlled checkbox immediately after
|
||||
// its click. Assert against the newly rendered control instead of asking `check()` to
|
||||
// verify the detached pre-navigation node.
|
||||
await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).click();
|
||||
await expect(page).toHaveURL(/demo=true/);
|
||||
await expect(page.getByRole("checkbox", { name: "Enkel demoscenario's" })).toBeChecked();
|
||||
const filteredRows = await page.locator(".data-table tbody tr").count();
|
||||
expect(filteredRows).toBeGreaterThan(0);
|
||||
expect(filteredRows).toBeLessThanOrEqual(allRows);
|
||||
const refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
||||
for (const ref of refs) {
|
||||
expect(ref.startsWith("DQ-DEMO-")).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/knowledge");
|
||||
|
||||
// The status badge must name the actual active provider once its check settles.
|
||||
await expect(page.locator(".knowledge-status strong")).toHaveText(/RAGcore|de demokennisbank/);
|
||||
|
||||
await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
|
||||
const sources = page.locator(".exchange").first().locator(".source-card");
|
||||
expect(await sources.count()).toBeLessThanOrEqual(3);
|
||||
const titles = await sources.locator(".source-title").allTextContents();
|
||||
expect(new Set(titles.map((title) => title.trim().toLocaleLowerCase())).size).toBe(titles.length);
|
||||
await page.getByRole("button", { name: "Ja" }).first().click();
|
||||
await expect(page.getByText("Feedback opgeslagen")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import type {
|
||||
AuditEvent,
|
||||
AutomationRun,
|
||||
DataQualityIssueDetail,
|
||||
RegisterReturnResult,
|
||||
} from "../src/api/types";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", {
|
||||
data: { role: "operations_manager" },
|
||||
});
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("five-minute demo script end to end", async ({ page, request }) => {
|
||||
const proof: { returned?: RegisterReturnResult } = {};
|
||||
|
||||
// Reset via a throwaway API session so the UI test starts from the deterministic seed
|
||||
// regardless of what earlier test runs mutated (S1 return, S2 merge, etc.).
|
||||
await resetDemoData(request);
|
||||
|
||||
await test.step("1. login as Operations Manager", async () => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByText(/Synthetische demo · geen echte klant-/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
await test.step("2. verify dashboard metrics are loaded", async () => {
|
||||
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
|
||||
const metricValues = page.locator(".metric-cell dd");
|
||||
await expect(metricValues.first()).toBeVisible();
|
||||
const values = await metricValues.allTextContents();
|
||||
expect(values.length).toBeGreaterThan(0);
|
||||
expect(values.some((v) => Number(v) > 0)).toBeTruthy();
|
||||
});
|
||||
|
||||
await test.step("3. open active demo booking", async () => {
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
|
||||
await expect(page.getByText("actief", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("4. register an odometer-regression return (S1)", async () => {
|
||||
const vehicleOdometerText = await page
|
||||
.locator(".detail-grid div", { hasText: "Startkilometerstand" })
|
||||
.locator("dd")
|
||||
.textContent();
|
||||
const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10);
|
||||
const lowReading = Math.max(0, startOdometer - 500);
|
||||
|
||||
await page.getByLabel("Eindkilometerstand (km)").fill(String(lowReading));
|
||||
await page.getByLabel("Brandstofniveau (%)").fill("55");
|
||||
await page.getByRole("button", { name: "Retour nakijken" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Retourimpact nakijken" })).toBeVisible();
|
||||
|
||||
const returnResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
response.url().endsWith("/api/v1/bookings/BK-DEMO-RETURN/return"),
|
||||
);
|
||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||
const returnResponse = await returnResponsePromise;
|
||||
expect(returnResponse.status()).toBe(201);
|
||||
proof.returned = (await returnResponse.json()) as RegisterReturnResult;
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("5. verify the persisted quality issue, outbox event and audit trace", async () => {
|
||||
const returned = proof.returned;
|
||||
if (!returned) throw new Error("The return API response was not captured");
|
||||
|
||||
expect(returned.odometer_regression).toBe(true);
|
||||
expect(returned.quality_issue_ref).toMatch(/^DQ-RET-/);
|
||||
const qualityIssueRef = returned.quality_issue_ref;
|
||||
if (!qualityIssueRef) throw new Error("The regression return did not create a quality issue");
|
||||
|
||||
await expect(page.getByRole("link", { name: qualityIssueRef })).toBeVisible();
|
||||
await expect(page.getByText(/Klaargezet voor verwerking \(/)).toBeVisible();
|
||||
|
||||
// Use the page-bound request context: the throwaway reset session above is
|
||||
// deliberately invalidated by reset, while this context shares the manager login
|
||||
// cookie established in step 1.
|
||||
const issueResponse = await page.request.get(`/api/v1/data-quality/issues/${qualityIssueRef}`);
|
||||
expect(issueResponse.ok()).toBeTruthy();
|
||||
const issue = (await issueResponse.json()) as DataQualityIssueDetail;
|
||||
expect(issue.public_ref).toBe(qualityIssueRef);
|
||||
expect(issue.rule_type).toBe("odometer_regression");
|
||||
expect(issue.status).toBe("open");
|
||||
expect(issue.entity_ref).toBe(returned.vehicle_ref);
|
||||
|
||||
const workflowsResponse = await page.request.get("/api/v1/workflows");
|
||||
expect(workflowsResponse.ok()).toBeTruthy();
|
||||
const workflows = (await workflowsResponse.json()) as AutomationRun[];
|
||||
const workflow = workflows.find((run) => run.event_id === returned.workflow_event_id);
|
||||
expect(workflow).toBeDefined();
|
||||
expect(workflow).toMatchObject({
|
||||
event_type: "vehicle.returned.v1",
|
||||
aggregate_ref: returned.booking_ref,
|
||||
});
|
||||
|
||||
const auditResponse = await page.request.get(
|
||||
`/api/v1/audit?correlation_id=${encodeURIComponent(returned.correlation_id)}`,
|
||||
);
|
||||
expect(auditResponse.ok()).toBeTruthy();
|
||||
const trace = (await auditResponse.json()) as AuditEvent[];
|
||||
expect(trace.map((event) => event.action)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"data_quality_issue_created",
|
||||
"return_registered",
|
||||
"vehicle_status_changed",
|
||||
]),
|
||||
);
|
||||
expect(trace.every((event) => event.correlation_id === returned.correlation_id)).toBe(true);
|
||||
});
|
||||
|
||||
await test.step("6. resolve the duplicate customer scenario (S2)", async () => {
|
||||
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
|
||||
await expect(page.getByRole("heading", { name: "Vergelijken en samenvoegen" })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Samenvoegen met CUS-0012/ }).click();
|
||||
await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
|
||||
await expect(page.locator(".badge.status-resolved")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("7. ask the damage question and inspect citations (S6)", async () => {
|
||||
await page.goto("/knowledge");
|
||||
await page
|
||||
.getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/)
|
||||
.fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
|
||||
await page.getByRole("button", { name: "Vraag stellen" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
|
||||
const damageSource = page.locator(".source-card", { hasText: /zichtbare of gemelde schade/i }).first();
|
||||
await expect(damageSource).toBeVisible();
|
||||
await expect(damageSource).toContainText(/zichtbare of gemelde schade/i);
|
||||
});
|
||||
|
||||
await test.step("8. inspect audit entries", async () => {
|
||||
await page.goto("/audit");
|
||||
await page.getByLabel("Actie").fill("return_registered");
|
||||
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
|
||||
await expect(page.getByText("Voertuigretour geregistreerd").first()).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("9. verify responsive navigation at mobile width", async () => {
|
||||
await page.setViewportSize({ width: 360, height: 800 });
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByRole("button", { name: /Synthetische demo/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Overzicht" }).first()).toBeVisible();
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
// Targeted end-to-end coverage for the Fleet Ops correction brief (docs/fleet-ops-
|
||||
// correction/): branding, the redesigned status-recommendation flow (preview/apply/
|
||||
// manual-review/stale-token), MO-016 order independence, trilingual knowledge
|
||||
// grounding, and localized audit/automation content. See also i18n-coverage.spec.ts
|
||||
// (key parity, brand invariant, translation-quality) and responsive-i18n.spec.ts
|
||||
// (breakpoint matrix) for the complementary static-content checks.
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
// /api/v1/demo/reset deletes the session cookie (it recreates the users table), so any
|
||||
// further authenticated call through this same request context needs a fresh login.
|
||||
const relogin = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(relogin.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
const EXPLORE_OPS_MANAGER: Record<string, string> = {
|
||||
"nl-BE": "Verken als Operationsmanager",
|
||||
"en-GB": "Explore as Operations Manager",
|
||||
"fr-BE": "Explorer en tant que Responsable des opérations",
|
||||
};
|
||||
|
||||
const REVIEW_RECOMMENDATION: Record<string, string> = {
|
||||
"nl-BE": "Aanbeveling bekijken",
|
||||
"en-GB": "Review recommendation",
|
||||
"fr-BE": "Voir la recommandation",
|
||||
};
|
||||
|
||||
const CHANGE_STATUS_PREFIX: Record<string, RegExp> = {
|
||||
"nl-BE": /^Status wijzigen naar/,
|
||||
"en-GB": /^Change status to/,
|
||||
"fr-BE": /^Changer le statut vers/,
|
||||
};
|
||||
|
||||
const MANUAL_REVIEW_HEADING: Record<string, string> = {
|
||||
"nl-BE": "Handmatige beoordeling vereist",
|
||||
"en-GB": "Manual review required",
|
||||
"fr-BE": "Évaluation manuelle requise",
|
||||
};
|
||||
|
||||
async function loginAsOpsManager(page: Page, lang: string) {
|
||||
await page.addInitScript((l) => localStorage.setItem("fleetops.language", l), lang);
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: EXPLORE_OPS_MANAGER[lang] }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test.describe("branding", () => {
|
||||
for (const lang of ["nl-BE", "en-GB", "fr-BE"]) {
|
||||
test(`Fleet Ops is the visible brand and no MobilityOps/PoC leaks through (${lang})`, async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await loginAsOpsManager(page, lang);
|
||||
await expect(page.locator(".brand-mark").first()).toBeVisible();
|
||||
await expect(page.getByText("Fleet Ops", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.locator(".app-footer")).toContainText("Fleet Ops");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", lang);
|
||||
|
||||
for (const path of ["/dashboard", "/vehicles", "/data-quality", "/audit", "/automation", "/knowledge"]) {
|
||||
await page.goto(path);
|
||||
const text = await page.locator("body").innerText();
|
||||
expect(text, `${path} (${lang})`).not.toContain("MobilityOps");
|
||||
expect(text, `${path} (${lang})`).not.toMatch(/\bPoC\b/);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("the Fleet Ops favicon is linked and resolves (not the browser's blank-tab default)", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/login");
|
||||
const href = await page.locator('link[rel="icon"]').getAttribute("href");
|
||||
expect(href).toBe("/favicon.svg");
|
||||
const response = await page.request.get(href as string);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.headers()["content-type"]).toContain("svg");
|
||||
});
|
||||
|
||||
test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force
|
||||
// nl-BE on every reload, defeating exactly the persistence behaviour under test.
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: EXPLORE_OPS_MANAGER["nl-BE"] }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByRole("heading", { name: "Aandachtspunten" })).toBeVisible();
|
||||
|
||||
await page.getByRole("combobox", { name: "Taal" }).selectOption("fr-BE");
|
||||
await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible();
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE");
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible();
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE");
|
||||
});
|
||||
|
||||
test.describe("status-recommendation flow", () => {
|
||||
test("preview does not mutate anything, apply names the exact target status", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await loginAsOpsManager(page, "nl-BE");
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
await expect(page.getByText("DQ-DEMO-STATUS", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
||||
await expect(page.getByText("Aanbevolen status")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Waarom" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Gevolg" })).toBeVisible();
|
||||
|
||||
// Previewing must not have resolved the issue -- still open, using the page's own
|
||||
// authenticated session (page.request shares cookies with the browser context).
|
||||
const issue = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS");
|
||||
expect((await issue.json()).status).toBe("open");
|
||||
|
||||
const confirmButton = page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] });
|
||||
await expect(confirmButton).toHaveText(/Geblokkeerd/);
|
||||
await confirmButton.click();
|
||||
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
|
||||
|
||||
const resolved = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS");
|
||||
expect((await resolved.json()).status).toBe("resolved");
|
||||
});
|
||||
|
||||
test("manual review state offers no generic apply button for a genuine fact contradiction", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
const issues = await (
|
||||
await request.get("/api/v1/data-quality/issues", {
|
||||
params: { rule_type: "vehicle_status_conflict", status: "open" },
|
||||
})
|
||||
).json();
|
||||
const conflicted = issues.find((i: { entity_ref: string }) => i.entity_ref === "MO-024");
|
||||
expect(conflicted, "expected MO-024's vehicle_status_conflict issue to exist after reset").toBeTruthy();
|
||||
|
||||
await loginAsOpsManager(page, "en-GB");
|
||||
await page.goto(`/data-quality/${conflicted.public_ref}`);
|
||||
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: MANUAL_REVIEW_HEADING["en-GB"] })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /^Change status to/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a stale recommendation is rejected and the user must review again before applying", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await loginAsOpsManager(page, "en-GB");
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click();
|
||||
await expect(page.getByRole("button", { name: /^Change status to/ })).toBeVisible();
|
||||
|
||||
// Simulate the underlying facts changing after the preview was shown (the same
|
||||
// session resolves the booking overlap in the meantime) -- the previously-fetched
|
||||
// recommendation token must no longer be accepted.
|
||||
await page.evaluate(async () => {
|
||||
await fetch("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ booking_ref: "BK-DEMO-OVERLAP-B" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: /^Change status to/ }).click();
|
||||
await expect(page.getByText(/situation has changed/i)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("MO-016 status conflict is order-independent", () => {
|
||||
// Order independence does NOT mean "the same final vehicle status regardless of
|
||||
// order" -- resolving the booking overlap first genuinely removes the conflict, so
|
||||
// there is correctly nothing left to apply afterwards. What must hold in either
|
||||
// order: the recommendation always reflects the real, current facts (never a stale
|
||||
// "was some other issue open" proxy), and nothing unsafe is ever applied (never
|
||||
// "rented").
|
||||
|
||||
test("resolving the booking overlap first correctly leaves nothing to apply", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await loginAsOpsManager(page, "nl-BE");
|
||||
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
||||
await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check();
|
||||
await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click();
|
||||
await expect(page.getByText("Opgelost").first()).toBeVisible();
|
||||
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
||||
await expect(page.getByRole("heading", { name: "Geen wijziging nodig" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] })).toHaveCount(0);
|
||||
|
||||
const vehicle = await request.get("/api/v1/vehicles/MO-016");
|
||||
expect((await vehicle.json()).operational_status).toBe("available");
|
||||
});
|
||||
|
||||
test("resolving the status conflict first safely blocks the vehicle, unaffected by the later overlap fix", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await loginAsOpsManager(page, "nl-BE");
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
||||
await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click();
|
||||
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
|
||||
|
||||
const vehicleMid = await request.get("/api/v1/vehicles/MO-016");
|
||||
const statusAfterApply = (await vehicleMid.json()).operational_status;
|
||||
expect(statusAfterApply).not.toBe("rented");
|
||||
|
||||
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
||||
await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check();
|
||||
await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click();
|
||||
await expect(page.getByText("Opgelost").first()).toBeVisible();
|
||||
|
||||
// Resolving the now-redundant overlap afterwards must not itself change the
|
||||
// vehicle's status as a side effect.
|
||||
const vehicleFinal = await request.get("/api/v1/vehicles/MO-016");
|
||||
const statusFinal = (await vehicleFinal.json()).operational_status;
|
||||
expect(statusFinal).toBe(statusAfterApply);
|
||||
expect(statusFinal).not.toBe("rented");
|
||||
|
||||
await resetDemoData(request);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("knowledge base is grounded in the operator's own language", () => {
|
||||
const cases: { lang: string; question: string; sourceHint: RegExp; sourceTitle: RegExp }[] = [
|
||||
{
|
||||
lang: "nl-BE",
|
||||
question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
|
||||
sourceHint: /zichtbare of gemelde schade/i,
|
||||
sourceTitle: /Procedure schadeafhandeling|Schade bij voertuigretour/i,
|
||||
},
|
||||
{
|
||||
lang: "en-GB",
|
||||
question: "What should I do when a vehicle returns with damage?",
|
||||
sourceHint: /visible or reported damage/i,
|
||||
sourceTitle: /Damage handling procedure|Vehicle return damage/i,
|
||||
},
|
||||
{
|
||||
lang: "fr-BE",
|
||||
question: "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
|
||||
sourceHint: /dommages visibles ou signalés/i,
|
||||
sourceTitle: /Procédure de gestion des dommages|Dommages au retour du véhicule/i,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { lang, question, sourceHint, sourceTitle } of cases) {
|
||||
test(`grounded ${lang} answer cites a ${lang} source about damage`, async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await loginAsOpsManager(page, lang);
|
||||
await page.goto("/knowledge");
|
||||
await page.locator("#knowledge-question").fill(question);
|
||||
await page.getByRole("button", { name: /^(Vraag stellen|Ask|Demander)$/ }).click();
|
||||
const localizedSource = page.locator(".source-card", { hasText: sourceHint }).first();
|
||||
await expect(localizedSource).toBeVisible({ timeout: 15_000 });
|
||||
await expect(localizedSource).toContainText(sourceTitle);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("audit trail shows localized action and field labels with raw codes only in technical details", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await loginAsOpsManager(page, "nl-BE");
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
||||
await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click();
|
||||
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
|
||||
|
||||
await page.goto("/audit");
|
||||
|
||||
// Applying resolves both the vehicle status and the issue in one correlated action --
|
||||
// expand the group's "technical events" toggle so the other audit row's diff renders
|
||||
// too, regardless of which one the grouping picked as primary.
|
||||
const toggle = page.getByRole("button", { name: /technische gebeurtenis/ }).first();
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
await expect(page.getByRole("button", { name: "Technische gebeurtenissen verbergen" })).toBeVisible();
|
||||
|
||||
await expect(page.getByText("Aanbevolen status toegepast").first()).toBeVisible();
|
||||
const diffs = page.locator(".change-diff");
|
||||
await expect(diffs.first()).toBeVisible();
|
||||
const combinedDiffText = (await diffs.allInnerTexts()).join(" ");
|
||||
expect(combinedDiffText).toContain("Operationele status");
|
||||
expect(combinedDiffText).not.toContain("operational_status");
|
||||
});
|
||||
|
||||
test("automation shows a localized error explanation with the raw error only under technical details", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await loginAsOpsManager(page, "nl-BE");
|
||||
await page.goto("/automation");
|
||||
|
||||
const localizedSummary = /Gesimuleerde time-out richting de workflowdienst/;
|
||||
await expect(page.getByText(localizedSummary).first()).toBeVisible();
|
||||
await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible();
|
||||
// Scope to the failed job's own row -- the workflow-evidence table above it also has
|
||||
// "Technische details" toggles (one per workflow), so an unscoped .first() can open
|
||||
// the wrong one.
|
||||
const failedJobRow = page.locator("tr", { has: page.getByText(localizedSummary) });
|
||||
await failedJobRow.getByText("Technische details").click();
|
||||
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("route matrix (section 11F)", () => {
|
||||
// Opens every main route in all 3 languages: no console errors, correct html[lang],
|
||||
// and a real, non-empty page heading (proving the route actually rendered content
|
||||
// instead of silently falling back to a raw i18next key or a blank screen). Key
|
||||
// parity across locale files is already proven structurally by i18n-coverage.spec.ts
|
||||
// (every key that exists in nl-BE also exists, non-empty, in en-GB/fr-BE), so this
|
||||
// matrix focuses on what only a live render can catch.
|
||||
const routes = [
|
||||
"/dashboard",
|
||||
"/vehicles",
|
||||
"/vehicles/MO-001",
|
||||
"/bookings",
|
||||
"/bookings/BK-DEMO-RETURN",
|
||||
"/data-quality",
|
||||
"/data-quality/DQ-DEMO-STATUS",
|
||||
"/automation",
|
||||
"/knowledge",
|
||||
"/audit",
|
||||
"/scenarios",
|
||||
"/about",
|
||||
];
|
||||
|
||||
for (const lang of ["nl-BE", "en-GB", "fr-BE"]) {
|
||||
test(`every main route renders correctly with no console errors (${lang})`, async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
|
||||
const errors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() !== "error") return;
|
||||
if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return;
|
||||
errors.push(msg.text());
|
||||
});
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
|
||||
await loginAsOpsManager(page, lang);
|
||||
|
||||
for (const route of routes) {
|
||||
await page.goto(route);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", lang);
|
||||
const heading = page.getByRole("heading", { level: 1 });
|
||||
await expect(heading, `${route} (${lang})`).toBeVisible();
|
||||
const headingText = (await heading.first().textContent())?.trim() ?? "";
|
||||
expect(headingText, `${route} (${lang}) heading text`).not.toBe("");
|
||||
// A raw, unresolved i18next key looks like "namespace:some.key.path" -- real
|
||||
// page headings never contain a colon followed by a dotted identifier.
|
||||
expect(headingText, `${route} (${lang}) heading looks like a raw i18n key`).not.toMatch(
|
||||
/^[a-zA-Z]+:[\w.]+$/,
|
||||
);
|
||||
}
|
||||
|
||||
expect(errors, `Console errors across the route matrix (${lang}):\n${errors.join("\n")}`).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("data-quality evidence summary is localized, not raw English (section 6)", () => {
|
||||
// The primary evidence line at the top of every issue's detail page must render the
|
||||
// structured `evidence.signals` in the operator's language; the legacy English
|
||||
// `evidence.summary` string is a technical fallback only, visible solely inside
|
||||
// "Technical details". Live-caught: this line was unconditionally showing raw
|
||||
// English ("vehicle marked available while reserved bookings conflict") in every
|
||||
// language until fixed.
|
||||
const cases: { lang: string; expectedText: RegExp }[] = [
|
||||
{ lang: "nl-BE", expectedText: /overlappende reserveringen/i },
|
||||
{ lang: "en-GB", expectedText: /overlapping bookings/i },
|
||||
{ lang: "fr-BE", expectedText: /chevauchent|chevauchement/i },
|
||||
];
|
||||
|
||||
for (const { lang, expectedText } of cases) {
|
||||
test(`vehicle_status_conflict evidence is localized (${lang})`, async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await loginAsOpsManager(page, lang);
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
|
||||
const summarySection = page.locator(".record-surface-evidence");
|
||||
await expect(summarySection).toBeVisible();
|
||||
await expect(summarySection).toContainText(expectedText);
|
||||
await expect(summarySection).not.toContainText("vehicle marked available while reserved bookings conflict");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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,83 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { getBrusselsHour, getGreetingPeriod } from "../src/i18n/greeting";
|
||||
import { brusselsLocalToIso, toBrusselsDate, tryBrusselsLocalToIso } from "../src/i18n/brusselsDateTime";
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
test("Brussels wall-clock conversion is DST-aware and rejects impossible local times", () => {
|
||||
expect(brusselsLocalToIso("2026-01-15T12:00")).toBe("2026-01-15T11:00:00.000Z");
|
||||
expect(brusselsLocalToIso("2026-07-15T12:00")).toBe("2026-07-15T10:00:00.000Z");
|
||||
expect(tryBrusselsLocalToIso("2026-03-29T02:30")).toBeNull();
|
||||
expect(tryBrusselsLocalToIso("2026-02-30T12:00")).toBeNull();
|
||||
// The repeated autumn hour resolves deterministically to the post-transition CET instant.
|
||||
expect(brusselsLocalToIso("2026-10-25T02:30")).toBe("2026-10-25T01:30:00.000Z");
|
||||
});
|
||||
|
||||
test("Brussels calendar dates do not depend on the browser or runner timezone", () => {
|
||||
expect(toBrusselsDate(new Date("2026-01-01T23:30:00.000Z"))).toBe("2026-01-02");
|
||||
expect(toBrusselsDate(new Date("2026-07-01T22:30:00.000Z"))).toBe("2026-07-02");
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("full guided demo walkthrough, start to finish, restoring the environment after", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
// Wide desktop viewport: the guide docks as a rail and never auto-collapses to a chip
|
||||
// (see useViewportTier.ts), so this walkthrough can keep interacting with the panel
|
||||
// directly across every step -- the standard-tier auto-collapse behaviour itself is
|
||||
// covered separately in demo-guide.spec.ts.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
|
||||
await test.step("start the guided demo from the login screen", async () => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("step 1: understand the operational state", async () => {
|
||||
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 2: open the booking needing attention", async () => {
|
||||
await expect(page.getByRole("heading", { name: "2. Open een boeking" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/bookings\/BK-DEMO-RETURN$/);
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 3: process the return with the pre-filled odometer anomaly", async () => {
|
||||
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
|
||||
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retour nakijken" }).click();
|
||||
await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 4: handle the newly created data-quality issue", async () => {
|
||||
await expect(page).toHaveURL(/\/data-quality$/);
|
||||
await expect(page.getByRole("heading", { name: "4. Bekijk en behandel" })).toBeVisible();
|
||||
await page.getByLabel("Regeltype").selectOption("odometer_regression");
|
||||
const odometerIssue = page.getByRole("row", {
|
||||
name: /Afwijkende kilometerstand.*MO-024/,
|
||||
});
|
||||
await odometerIssue.locator("a").click();
|
||||
await expect(page.getByText("Wat is er mis")).toBeVisible();
|
||||
// SLA ordering deliberately outranks recency, so select the issue by its rule.
|
||||
await page.getByRole("radio", { name: /Laatst bevestigde stand behouden/ }).check();
|
||||
await page.getByRole("button", { name: "Probleem oplossen" }).click();
|
||||
await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 5: review and merge the possible duplicate customer", async () => {
|
||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
|
||||
await expect(page.getByRole("heading", { name: "5. Beoordeel en behandel" })).toBeVisible();
|
||||
await page.getByRole("button", { name: /^Samenvoegen met/ }).click();
|
||||
await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
|
||||
await expect(page.getByText(/Probleem .* opgelost/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 6: ask the procedure assistant a question", async () => {
|
||||
await expect(page).toHaveURL(/\/knowledge$/);
|
||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
await test.step("step 7: check automation and the audit trail", async () => {
|
||||
await expect(page.getByRole("heading", { name: "7. Controleer automatisering" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/automation$/);
|
||||
// Plain-language status only -- never the raw backend state string (e.g. "degraded").
|
||||
await expect(page.locator(".integration-cards")).not.toContainText("degraded");
|
||||
await expect(page.locator(".integration-cards")).not.toContainText("no_evidence");
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Gegidste demo" })
|
||||
.getByRole("button", { name: "Volgende", exact: true })
|
||||
.click();
|
||||
});
|
||||
|
||||
await test.step("step 8: review what's real, simulated, or not yet connected", async () => {
|
||||
await expect(page.getByRole("heading", { name: "8. Bekijk wat echt is" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/about$/);
|
||||
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
|
||||
await expect(page.getByText("Demomodus", { exact: false }).first()).toBeVisible();
|
||||
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "Demo afronden" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
const guideTrigger = page.locator(".demo-guide-trigger");
|
||||
await expect(guideTrigger).toContainText("8/8");
|
||||
await expect(guideTrigger).toBeFocused();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("button", { name: /^Demo-gids/ }).first()).toContainText("8/8");
|
||||
});
|
||||
|
||||
await test.step("restore the environment", async () => {
|
||||
await resetDemoData(request);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Pure Node-context checks (no browser needed): every locale must define exactly the
|
||||
// same set of translation keys. A missing key would otherwise silently fall back to
|
||||
// showing the raw key string in production -- this test makes that impossible to ship.
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
|
||||
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"];
|
||||
|
||||
function collectKeyPaths(value: unknown, prefix = ""): string[] {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return [prefix];
|
||||
}
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
|
||||
collectKeyPaths(nested, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
|
||||
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
const namespaces = fs
|
||||
.readdirSync(path.join(LOCALES_DIR, "nl-BE"))
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.replace(/\.json$/, ""));
|
||||
|
||||
test("every locale defines the same translation keys as nl-BE, for every namespace", () => {
|
||||
expect(namespaces.length).toBeGreaterThan(0);
|
||||
|
||||
for (const namespace of namespaces) {
|
||||
const referenceKeys = collectKeyPaths(loadNamespace("nl-BE", namespace)).sort();
|
||||
|
||||
for (const language of LANGUAGES) {
|
||||
if (language === "nl-BE") continue;
|
||||
const keys = collectKeyPaths(loadNamespace(language, namespace)).sort();
|
||||
const missing = referenceKeys.filter((k) => !keys.includes(k));
|
||||
const extra = keys.filter((k) => !referenceKeys.includes(k));
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`${language}/${namespace}.json is missing keys present in nl-BE: ${missing.join(", ")}`,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
extra,
|
||||
`${language}/${namespace}.json has extra keys not present in nl-BE: ${extra.join(", ")}`,
|
||||
).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("no locale file contains an empty string value", () => {
|
||||
for (const language of LANGUAGES) {
|
||||
for (const namespace of namespaces) {
|
||||
const data = loadNamespace(language, namespace);
|
||||
const keys = collectKeyPaths(data);
|
||||
for (const keyPath of keys) {
|
||||
const value = keyPath.split(".").reduce<unknown>((acc, part) => {
|
||||
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
|
||||
return undefined;
|
||||
}, data);
|
||||
if (typeof value === "string") {
|
||||
expect(value.trim().length, `${language}/${namespace}.json:${keyPath} is empty`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Brand-invariant: "Fleet Ops" is a fixed constant, never a translation value ---
|
||||
// (see frontend/src/product.ts and docs/fleet-ops-correction/current-gap-audit.md §1).
|
||||
// A regression here means someone re-introduced a per-locale brand key/value instead of
|
||||
// interpolating {{productName}} from the shared constant.
|
||||
|
||||
test("no locale file defines an 'appName' key or the literal brand string", () => {
|
||||
for (const language of LANGUAGES) {
|
||||
for (const namespace of namespaces) {
|
||||
const data = loadNamespace(language, namespace);
|
||||
const raw = JSON.stringify(data);
|
||||
expect(
|
||||
raw.includes("Fleet Ops"),
|
||||
`${language}/${namespace}.json contains the literal brand string "Fleet Ops" -- ` +
|
||||
`use {{productName}} interpolation instead so the brand can never drift per locale`,
|
||||
).toBe(false);
|
||||
const keys = collectKeyPaths(data);
|
||||
expect(
|
||||
keys.some((k) => k === "appName" || k.endsWith(".appName")),
|
||||
`${language}/${namespace}.json defines an "appName" key -- the brand name must come ` +
|
||||
`from the PRODUCT_NAME constant, never a translatable key`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => {
|
||||
for (const language of LANGUAGES) {
|
||||
for (const namespace of namespaces) {
|
||||
const raw = JSON.stringify(loadNamespace(language, namespace));
|
||||
expect(
|
||||
raw.includes("MobilityOps"),
|
||||
`${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` +
|
||||
`always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`,
|
||||
).toBe(false);
|
||||
expect(
|
||||
/\bPoC\b/.test(raw),
|
||||
`${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` +
|
||||
`in user-facing copy`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
|
||||
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
|
||||
// the literal English string under the right key and still pass). For every "real prose"
|
||||
// string (>=8 chars, not on the allowlist below), assert nl-BE and fr-BE differ from
|
||||
// en-GB, and that fr-BE differs from nl-BE -- catching both "still English" and
|
||||
// "Dutch text copy-pasted into French" in one pass.
|
||||
|
||||
// Exact (namespace, key-path) pairs that are legitimately identical across two or more
|
||||
// locales: real proper nouns/brand names, deliberately-untranslated role titles, and
|
||||
// genuine cross-language cognates (identical spelling in Dutch/French/English). This is
|
||||
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
|
||||
// hide an unrelated real mistranslation under the same key in a different namespace.
|
||||
const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
||||
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
|
||||
"common.language.nl-BE", // language-picker options show each language's own endonym
|
||||
"common.language.fr-BE",
|
||||
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
|
||||
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
|
||||
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
|
||||
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
|
||||
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
|
||||
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
|
||||
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
|
||||
"fleet.detail.tabs.inspections", // "Inspections" -- identical spelling in French
|
||||
"integrations.cards.orchestrationKicker", // "Orchestration" -- identical in French
|
||||
"knowledge.questionLabel", // "Question" -- identical spelling in French
|
||||
"knowledge.retrievalFlow.question",
|
||||
"knowledge.questionLabelExchange",
|
||||
"returns.result.inspection", // "Inspection" -- identical spelling in French
|
||||
"integrations.workflows.columns.name", // "Workflow" -- used as-is in Dutch and French
|
||||
]);
|
||||
|
||||
function isTranslatableProse(value: unknown): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
if (value.trim().length < 8) return false;
|
||||
// Strip interpolation placeholders and non-letter characters; if nothing substantial
|
||||
// remains (pure numbers/punctuation/units), it's not "prose" that needs translating.
|
||||
const stripped = value
|
||||
.replace(/\{\{[^}]+\}\}/g, " ")
|
||||
.replace(/[^a-zA-Zà-öø-ÿÀ-ÖØ-ß]/g, "");
|
||||
return stripped.trim().length >= 3;
|
||||
}
|
||||
|
||||
test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or each other", () => {
|
||||
for (const namespace of namespaces) {
|
||||
const en = loadNamespace("en-GB", namespace);
|
||||
const nl = loadNamespace("nl-BE", namespace);
|
||||
const fr = loadNamespace("fr-BE", namespace);
|
||||
const keys = collectKeyPaths(en);
|
||||
|
||||
for (const keyPath of keys) {
|
||||
if (IDENTICAL_VALUE_ALLOWLIST.has(`${namespace}.${keyPath}`)) continue;
|
||||
const at = (data: Record<string, unknown>) =>
|
||||
keyPath.split(".").reduce<unknown>((acc, part) => {
|
||||
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
|
||||
return undefined;
|
||||
}, data);
|
||||
|
||||
const enValue = at(en);
|
||||
if (!isTranslatableProse(enValue)) continue;
|
||||
const nlValue = at(nl);
|
||||
const frValue = at(fr);
|
||||
|
||||
expect(
|
||||
nlValue,
|
||||
`${namespace}.json:${keyPath} — nl-BE is identical to en-GB ("${enValue}"); ` +
|
||||
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
|
||||
).not.toBe(enValue);
|
||||
expect(
|
||||
frValue,
|
||||
`${namespace}.json:${keyPath} — fr-BE is identical to en-GB ("${enValue}"); ` +
|
||||
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
|
||||
).not.toBe(enValue);
|
||||
expect(
|
||||
frValue,
|
||||
`${namespace}.json:${keyPath} — fr-BE is identical to nl-BE ("${nlValue}"); ` +
|
||||
`looks like Dutch text was copy-pasted into the French locale`,
|
||||
).not.toBe(nlValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Embedded English/Dutch fragments inside otherwise-translated prose ---
|
||||
// The whole-string identity check above only catches a value that is IDENTICAL to
|
||||
// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final
|
||||
// localization pass: a sentence gets 95% translated but a role/status noun phrase is
|
||||
// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden
|
||||
// goedgekeurd." This scan flags known English fragments appearing literally inside any
|
||||
// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste
|
||||
// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common
|
||||
// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary).
|
||||
const FORBIDDEN_ENGLISH_FRAGMENTS = [
|
||||
"Operations Manager",
|
||||
"Operations Managers",
|
||||
"Rental Employee",
|
||||
"Rental Employees",
|
||||
"Audit trail",
|
||||
"Start scenario",
|
||||
];
|
||||
const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [
|
||||
"Operationsmanager",
|
||||
"Verhuurmedewerker",
|
||||
"Auditgeschiedenis",
|
||||
"Scenario starten",
|
||||
];
|
||||
|
||||
function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> {
|
||||
if (typeof value === "string") return [{ path: prefix, value }];
|
||||
if (value === null || typeof value !== "object") return [];
|
||||
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
|
||||
collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key),
|
||||
);
|
||||
}
|
||||
|
||||
test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => {
|
||||
const findings: string[] = [];
|
||||
for (const namespace of namespaces) {
|
||||
const nl = loadNamespace("nl-BE", namespace);
|
||||
const fr = loadNamespace("fr-BE", namespace);
|
||||
for (const { path: keyPath, value } of collectStringLeaves(nl)) {
|
||||
for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) {
|
||||
if (value.includes(fragment)) {
|
||||
findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { path: keyPath, value } of collectStringLeaves(fr)) {
|
||||
for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) {
|
||||
if (value.includes(fragment)) {
|
||||
findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(findings, findings.join("\n")).toEqual([]);
|
||||
});
|
||||
|
||||
// --- Hardcoded JSX text (section 11D) ---
|
||||
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
|
||||
// `{...}` expression) containing two or more real words are almost always user-facing
|
||||
// prose that should go through t(...). This is not a full parser, so a short, explicit
|
||||
// allowlist covers technical tokens/proper nouns that are correctly never translated
|
||||
// (MobilityOps.md is checked for absence elsewhere; this list is for things that ARE
|
||||
// expected to appear literally in JSX).
|
||||
const SRC_DIR = path.resolve(__dirname, "../src");
|
||||
const SCAN_DIRS = ["pages", "components"];
|
||||
|
||||
const ALLOWED_LITERAL_TEXT = new Set([
|
||||
"Fleet Ops", // the non-localizable brand name (frontend/src/product.ts)
|
||||
"Northstar Mobility", // fictional demo org, a proper noun
|
||||
"ITWorx MCP Hub", // proper noun
|
||||
]);
|
||||
|
||||
function collectTsxFiles(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 collectTsxFiles(full);
|
||||
return entry.name.endsWith(".tsx") ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function findHardcodedJsxText(filePath: string): string[] {
|
||||
const source = fs.readFileSync(filePath, "utf-8");
|
||||
const findings: string[] = [];
|
||||
// Matches `<Tag ...>text</Tag>` where the closing tag name backreferences the
|
||||
// opening one -- this specifically excludes TypeScript generics like
|
||||
// `useState<string | null>(null)`, which have no matching `</string | null>` closer,
|
||||
// unlike a naive `>...<` scan would. Deliberately spans newlines (Prettier commonly
|
||||
// puts JSX text on its own line) and does not attempt to parse JSX properly -- it is
|
||||
// a fast, approximate net for the common mistake, not a compiler.
|
||||
const jsxTextPattern = /<([A-Za-z][\w.]*)(?:\s[^<>]*)?>([^<>{}]{3,200})<\/\1>/gs;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = jsxTextPattern.exec(source)) !== null) {
|
||||
const text = match[2].trim();
|
||||
if (!text) continue;
|
||||
if (ALLOWED_LITERAL_TEXT.has(text)) continue;
|
||||
// Needs at least two alphabetic words to count as "prose" -- filters out numbers,
|
||||
// single technical words, units (km, %), punctuation-only fragments, and JSX
|
||||
// whitespace artifacts.
|
||||
const words = text.match(/[A-Za-z]+/g) ?? [];
|
||||
if (words.length < 2) continue;
|
||||
// Skip anything that is itself an i18next interpolation artifact leaking through
|
||||
// (shouldn't happen, but never flag `{{...}}`-shaped remnants) or looks like a URL
|
||||
// or path.
|
||||
if (/^https?:\/\//.test(text) || text.includes("/") || text.includes("{{")) continue;
|
||||
findings.push(`${path.relative(SRC_DIR, filePath)}: "${text}"`);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
test("no hardcoded user-facing JSX text outside the approved technical-token allowlist", () => {
|
||||
const allFindings: string[] = [];
|
||||
for (const dir of SCAN_DIRS) {
|
||||
const files = collectTsxFiles(path.join(SRC_DIR, dir));
|
||||
for (const file of files) {
|
||||
allFindings.push(...findHardcodedJsxText(file));
|
||||
}
|
||||
}
|
||||
expect(
|
||||
allFindings,
|
||||
`Found ${allFindings.length} likely hardcoded JSX string(s) bypassing t(...). ` +
|
||||
`Either route it through the translation system, or add the exact literal to ` +
|
||||
`ALLOWED_LITERAL_TEXT in this test if it's a genuine proper noun/technical token:\n` +
|
||||
allFindings.join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,468 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
async function switchRole(page: Page) {
|
||||
await page.locator(".operator-menu > summary").click();
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// This file's assertions were authored against the English UI copy; nl-BE is now the
|
||||
// app's default for a fresh session, so force English explicitly rather than rewriting
|
||||
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
// Keep reset's Set-Cookie deletion in the standalone request fixture. Sharing the
|
||||
// browser cookie jar here can race that deletion against the UI login below.
|
||||
await resetDemoData(request);
|
||||
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
test("all manager navigation items navigate correctly", async ({ page }) => {
|
||||
const items: [string, RegExp][] = [
|
||||
["Overview", /\/dashboard$/],
|
||||
["Fleet", /\/vehicles$/],
|
||||
["Bookings", /\/bookings$/],
|
||||
["Data quality", /\/data-quality$/],
|
||||
["Knowledge", /\/knowledge$/],
|
||||
["Integrations", /\/automation$/],
|
||||
["Audit trail", /\/audit$/],
|
||||
["Users", /\/users$/],
|
||||
["Privacy", /\/privacy$/],
|
||||
];
|
||||
const primaryNavigation = page.getByRole("navigation", { name: "Primary navigation" });
|
||||
for (const [label, urlPattern] of items) {
|
||||
await primaryNavigation.getByRole("link", { name: label, exact: true }).click();
|
||||
await expect(page).toHaveURL(urlPattern);
|
||||
}
|
||||
});
|
||||
|
||||
test("vehicles page: status filter and attention-only checkbox both work", async ({ page }) => {
|
||||
await page.goto("/vehicles");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
|
||||
await page.getByLabel("Status").selectOption("maintenance");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const statuses = await page.locator(".data-table tbody tr td:nth-child(4)").allTextContents();
|
||||
expect(statuses.every((s) => s.includes("maintenance"))).toBeTruthy();
|
||||
|
||||
await page.getByLabel("Status").selectOption("");
|
||||
await expect(page).not.toHaveURL(/status=maintenance/);
|
||||
await expect(page.getByLabel("Attention only")).not.toBeChecked();
|
||||
// Updating the URL replaces this controlled input. Click it, then assert against the
|
||||
// newly-rendered control rather than asking Playwright to verify the detached node.
|
||||
await page.getByLabel("Attention only").click();
|
||||
await expect(page).toHaveURL(/attention_only=true/);
|
||||
await expect(page.getByLabel("Attention only")).toBeChecked();
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const rows = page.locator(".data-table tbody tr");
|
||||
await expect(rows.locator(".attention-flag")).toHaveCount(await rows.count());
|
||||
const attentionReasons = await rows.locator(".attention-flag").allTextContents();
|
||||
expect(attentionReasons.every((reason) => reason.trim().length > 0)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("vehicles page: free-text search actually filters the rendered rows", async ({ page }) => {
|
||||
await page.goto("/vehicles");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
const totalRows = await page.locator(".data-table tbody tr").count();
|
||||
expect(totalRows).toBeGreaterThan(1);
|
||||
|
||||
const searchBox = page.getByRole("form", { name: "Vehicle fleet" }).getByLabel("Search");
|
||||
await searchBox.fill("MO-001");
|
||||
await expect(async () => {
|
||||
const rows = await page.locator(".data-table tbody tr").count();
|
||||
expect(rows).toBe(1);
|
||||
}).toPass({ timeout: 5000 });
|
||||
const refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
||||
expect(refs).toEqual(["MO-001"]);
|
||||
|
||||
await searchBox.fill("");
|
||||
await expect(async () => {
|
||||
const rows = await page.locator(".data-table tbody tr").count();
|
||||
expect(rows).toBe(totalRows);
|
||||
}).toPass({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("vehicle detail: all tabs render distinct content", async ({ page }) => {
|
||||
await page.goto("/vehicles/MO-016");
|
||||
await expect(page.getByRole("heading", { name: /MO-016/ })).toBeVisible();
|
||||
for (const tab of ["Overview", "Bookings", "Inspections", "Maintenance", "Quality"]) {
|
||||
await page.getByRole("tab", { name: tab }).click();
|
||||
await expect(page.getByRole("tab", { name: tab })).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
});
|
||||
|
||||
test("bookings page: status filter works", async ({ page }) => {
|
||||
await page.goto("/bookings");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
await page.getByLabel("Status").selectOption("returned");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const statuses = await page.locator(".data-table tbody tr td:nth-child(5)").allTextContents();
|
||||
expect(statuses.every((s) => s.includes("returned"))).toBeTruthy();
|
||||
});
|
||||
|
||||
test("bookings page: pagination renders at most 25 rows and page 2 differs from page 1", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/bookings");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
|
||||
const page1Count = await page.locator(".data-table tbody tr").count();
|
||||
expect(page1Count).toBeLessThanOrEqual(25);
|
||||
const page1Refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
||||
|
||||
const nextButton = page.getByRole("button", { name: "Next" });
|
||||
await expect(nextButton).toBeEnabled();
|
||||
await nextButton.click();
|
||||
|
||||
await expect(async () => {
|
||||
const page2Refs = await page.locator(".data-table tbody tr th a").allTextContents();
|
||||
expect(page2Refs.length).toBeGreaterThan(0);
|
||||
expect(page2Refs).not.toEqual(page1Refs);
|
||||
}).toPass({ timeout: 5000 });
|
||||
|
||||
const page2Count = await page.locator(".data-table tbody tr").count();
|
||||
expect(page2Count).toBeLessThanOrEqual(25);
|
||||
|
||||
const prevButton = page.getByRole("button", { name: "Previous" });
|
||||
await expect(prevButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("return preview correctly reports blocked (not maintenance) for damage reported", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
await page.getByLabel("End odometer (km)").fill("55000");
|
||||
await page.getByLabel("Fuel level (%)").fill("40");
|
||||
await page.getByRole("checkbox", { name: "Damage reported" }).check();
|
||||
await page.getByRole("button", { name: "Review return" }).click();
|
||||
|
||||
// The preview is the server's authoritative evaluation: damage always routes to
|
||||
// "blocked", never "maintenance" -- this used to be guessed client-side and wrong.
|
||||
// The localized reason is the primary text; the raw code sits behind "Technical
|
||||
// details" so it isn't visible until expanded.
|
||||
await expect(page.getByText("Damage was reported on this return.")).toBeVisible();
|
||||
const statusRegion = page.locator(".impact-preview");
|
||||
await expect(statusRegion.getByText("blocked", { exact: true })).toBeVisible();
|
||||
await page.getByText("Technical details").click();
|
||||
await expect(page.getByText("Damage was reported on return.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality page: status and rule-type filters work", async ({ page }) => {
|
||||
await page.goto("/data-quality");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
|
||||
await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption(
|
||||
"possible_duplicate_customer",
|
||||
);
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents();
|
||||
expect(rules.every((r) => r.includes("Possible duplicate customer"))).toBeTruthy();
|
||||
|
||||
await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption("");
|
||||
await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality issue detail: defer and reject buttons work", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality?status=open&rule_type=missing_required_field");
|
||||
await expect(page.getByRole("combobox", { name: "Rule type", exact: true })).toHaveValue(
|
||||
"missing_required_field",
|
||||
);
|
||||
const firstLink = page.locator(".data-table tbody tr").first().locator("a");
|
||||
await expect(firstLink).toBeVisible();
|
||||
const ref = (await firstLink.textContent())?.trim() ?? "";
|
||||
expect(ref).not.toBe("");
|
||||
await firstLink.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
|
||||
await expect(page.locator(".page-header .page-eyebrow")).toHaveText(ref);
|
||||
await page.getByRole("button", { name: "Defer" }).click();
|
||||
await expect(page.locator(".badge.status-deferred")).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-ATTENTION");
|
||||
await expect(page.locator(".page-header .page-eyebrow")).toHaveText("DQ-DEMO-ATTENTION");
|
||||
|
||||
await page.getByLabel("Registration number").fill("TST-777");
|
||||
await page.getByLabel("Make").fill("TestMake");
|
||||
await page.getByLabel("Model").fill("TestModel");
|
||||
await page.getByLabel("Location").fill("Depot");
|
||||
await page.getByRole("button", { name: "Save and re-check" }).click();
|
||||
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
||||
await expect(page.locator(".page-header .page-eyebrow")).toHaveText("DQ-DEMO-OVERLAP");
|
||||
|
||||
await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check();
|
||||
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
|
||||
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
|
||||
return (await booking.json()).status;
|
||||
})
|
||||
.toBe("blocked");
|
||||
});
|
||||
|
||||
test("data quality: applying the recommended status resolves a vehicle conflict", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
await expect(page.getByText("DQ-DEMO-STATUS", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Review recommendation" }).click();
|
||||
await page.getByRole("button", { name: /^Change status to/ }).click();
|
||||
|
||||
await expect(page.getByText("Applied", { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: retaining canonical resolves an odometer regression issue", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
const issues = await (
|
||||
await page.request.get("/api/v1/data-quality/issues", {
|
||||
params: { rule_type: "odometer_regression", status: "open" },
|
||||
})
|
||||
).json();
|
||||
const target = issues[0];
|
||||
|
||||
await page.goto(`/data-quality/${target.public_ref}`);
|
||||
await expect(page.getByText(target.public_ref, { exact: true })).toBeVisible();
|
||||
await page.getByRole("radio", { name: /Retain canonical/ }).check();
|
||||
await page.getByRole("button", { name: "Resolve issue" }).click();
|
||||
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality");
|
||||
await page.getByRole("button", { name: "Run quality scan" }).click();
|
||||
await page.getByRole("button", { name: "Yes, run scan" }).click();
|
||||
|
||||
await expect(page.getByText(/Scan complete/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("automation page: status filter and retry button work", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/automation");
|
||||
const jobsTable = page.getByRole("table", { name: "Automation jobs" });
|
||||
await expect(jobsTable).toBeVisible();
|
||||
|
||||
await page.getByLabel("Status").selectOption("failed");
|
||||
await expect(jobsTable.locator("tbody tr").first()).toBeVisible();
|
||||
const failedRowCountBefore = await jobsTable.locator("tbody tr").count();
|
||||
const retryButton = page.getByRole("button", { name: "Retry" }).first();
|
||||
await expect(retryButton).toBeVisible();
|
||||
await retryButton.click();
|
||||
|
||||
// Retrying a failed delivery moves it out of the "failed" status, so — while still
|
||||
// filtered to "failed" — the row correctly disappears from this view rather than
|
||||
// showing "pending" in place. Confirm the filtered list shrank by one.
|
||||
await expect(async () => {
|
||||
const count = await jobsTable.locator("tbody tr").count();
|
||||
expect(count).toBe(failedRowCountBefore - 1);
|
||||
}).toPass({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("audit page: action filter works", async ({ page }) => {
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".audit-group-list")).toBeVisible();
|
||||
await page.getByLabel("Action").fill("demo_login");
|
||||
await expect(page.locator(".audit-group").first()).toBeVisible();
|
||||
const headings = await page.locator(".audit-group-heading strong").allTextContents();
|
||||
expect(headings.every((a) => a === "Logged in")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("audit page: shows human-readable before/after and a safe entity link", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
// demo/reset deletes the acting session's own cookie, so submit the return through
|
||||
// page.request instead -- it shares the browser context's still-valid OM session from
|
||||
// beforeEach rather than the now-logged-out standalone `request` fixture.
|
||||
const submitted = await page.request.post("/api/v1/bookings/BK-DEMO-RETURN/return", {
|
||||
data: {
|
||||
end_odometer_km: 60000,
|
||||
fuel_level_percent: 55,
|
||||
cleanliness_ok: true,
|
||||
damage_reported: false,
|
||||
technical_warning: false,
|
||||
},
|
||||
headers: { "Idempotency-Key": "e2e-audit-before-after-check" },
|
||||
});
|
||||
expect(submitted.ok()).toBeTruthy();
|
||||
|
||||
await page.goto("/audit");
|
||||
await page.getByLabel("Action").fill("return_registered");
|
||||
const firstGroup = page.locator(".audit-group").first();
|
||||
await expect(firstGroup).toBeVisible();
|
||||
|
||||
const changeDiff = firstGroup.locator(".change-diff");
|
||||
await expect(changeDiff).toContainText(/status/i);
|
||||
await expect(changeDiff).toContainText("returned");
|
||||
|
||||
await expect(firstGroup.locator(".audit-group-meta a")).toHaveAttribute("href", /\/bookings\/BK-/);
|
||||
});
|
||||
|
||||
test("knowledge page: form submits and clears input", async ({ page }) => {
|
||||
await page.goto("/knowledge");
|
||||
const input = page.getByPlaceholder(/What must I do when a vehicle returns with damage/);
|
||||
await input.fill("What must I do when a vehicle returns with damage?");
|
||||
await page.getByRole("button", { name: "Ask" }).click();
|
||||
await expect(page.getByText("Grounded in cited procedures")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
test("switch role button logs out and returns to login", async ({ page }) => {
|
||||
await switchRole(page);
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
test("session survives a page refresh and restores the correct role", async ({ page }) => {
|
||||
await page.goto("/vehicles");
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/vehicles$/);
|
||||
await expect(page.getByText("Operations manager")).toBeVisible();
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
});
|
||||
|
||||
test("logout invalidates the server session so a refresh returns to login", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await switchRole(page);
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
// Directly re-requesting a protected route after logout must not restore access from a
|
||||
// stale client cache; the server-side cookie is gone.
|
||||
await page.goto("/dashboard");
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
test("direct navigation to a protected route without a session redirects to login", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.clearCookies();
|
||||
await page.goto("/vehicles");
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
test("rental employee role has a restricted nav and cannot reach manager-only pages", async ({
|
||||
page,
|
||||
}) => {
|
||||
await switchRole(page);
|
||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
// Manager-only nav items are not shown at all, not merely disabled.
|
||||
await expect(page.getByRole("link", { name: "Data quality" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Integrations" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Audit trail" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Privacy" })).toHaveCount(0);
|
||||
|
||||
// Direct URL navigation is still blocked server-side and shows the same restricted
|
||||
// message as a defense-in-depth measure, not just a hidden button.
|
||||
await page.goto("/automation");
|
||||
await expect(
|
||||
page.getByText("Automation is visible to Operations Managers only.").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto("/data-quality");
|
||||
await expect(
|
||||
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only.").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto("/audit");
|
||||
await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible();
|
||||
|
||||
await page.goto("/privacy");
|
||||
await expect(page.getByText("These governance functions are available to Operations Managers only.")).toBeVisible();
|
||||
|
||||
await page.goto("/users");
|
||||
await expect(page.getByText("User administration is available to Operations Managers only.").first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Add user" })).toHaveCount(0);
|
||||
|
||||
await page.goto("/vehicles/MO-024");
|
||||
await expect(page.getByRole("tab", { name: "Quality" })).toHaveCount(0);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("rental employee return result exposes operational follow-up but no manager-only evidence links", async ({
|
||||
page,
|
||||
}) => {
|
||||
await switchRole(page);
|
||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
|
||||
await page.getByLabel("End odometer (km)").fill("54000");
|
||||
await page.getByLabel("Fuel level (%)").fill("75");
|
||||
await page.getByRole("button", { name: "Review return" }).click();
|
||||
await page.getByRole("button", { name: "Confirm return" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("link", { name: /View vehicle MO-024/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "View automation status" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Trace the complete operation" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("operations manager can reset demo data and is returned to login", async ({ page }) => {
|
||||
await expect(page.getByRole("button", { name: "Reset demo data" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Reset demo data" }).click();
|
||||
await page.getByRole("button", { name: "Yes, reset" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
// The reset must not have affected the ability to log back in against fresh data.
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
test("automation page shows the aggregate n8n integration status, not just the latest event", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/automation");
|
||||
await expect(page.locator(".integration-cards")).toContainText("succeeded");
|
||||
await expect(page.locator(".integration-cards")).toContainText("failed");
|
||||
});
|
||||
|
||||
test("rental employee direct API access to manager-only endpoints is rejected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await switchRole(page);
|
||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
// page.request shares the browser context's cookies, and (via the web container's
|
||||
// nginx /api/ proxy) works identically against localhost and the deployed server --
|
||||
// the backend API itself is never exposed directly on either.
|
||||
for (const path of ["/api/v1/data-quality/issues", "/api/v1/audit", "/api/v1/workflows"]) {
|
||||
const response = await page.request.get(path);
|
||||
expect(response.status(), path).toBe(403);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
async function reset(request: APIRequestContext) {
|
||||
expect((await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } })).ok()).toBeTruthy();
|
||||
expect((await request.post("/api/v1/demo/reset")).ok()).toBeTruthy();
|
||||
expect((await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } })).ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("booking creation explains when the end does not follow the start", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto("/bookings/new");
|
||||
const start = page.getByLabel("Start");
|
||||
const end = page.getByLabel("Einde");
|
||||
const startValue = await start.inputValue();
|
||||
|
||||
await end.fill(startValue);
|
||||
|
||||
await expect(page.getByRole("alert")).toHaveText("Het einde moet na de start van de boeking vallen.");
|
||||
await expect(end).toHaveAttribute("aria-invalid", "true");
|
||||
await expect(page.getByRole("button", { name: "Boeking aanmaken" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("operator can create and cancel a booking through the UI", async ({ page, request }) => {
|
||||
await reset(request);
|
||||
await login(page);
|
||||
await page.goto("/bookings/new");
|
||||
await page.getByLabel("Klant zoeken").fill("CUS-0001");
|
||||
const customer = page.locator(".booking-create-form select").nth(0);
|
||||
await expect.poll(() => customer.locator("option").count()).toBeGreaterThan(1);
|
||||
await customer.selectOption({ index: 1 });
|
||||
const vehicle = page.locator(".booking-create-form select").nth(1);
|
||||
await expect.poll(() => vehicle.locator("option").count()).toBeGreaterThan(1);
|
||||
await vehicle.selectOption({ index: 1 });
|
||||
await page.getByRole("button", { name: "Boeking aanmaken" }).click();
|
||||
await expect(page).toHaveURL(/\/bookings\/BK-/);
|
||||
await expect(page.getByText("gereserveerd", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Vereisten controleren" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Voertuig uitchecken" })).toHaveCount(0);
|
||||
await page.getByLabel("Controlebewijs").fill("Rijbewijs en huurvoorwaarden gecontroleerd");
|
||||
await page.getByRole("button", { name: "Vereisten bevestigen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Voertuig uitchecken" })).toBeVisible();
|
||||
await page.getByLabel("Reden", { exact: true }).fill("Klant annuleert de geplande rit");
|
||||
await page.getByRole("button", { name: "Annulering bevestigen" }).click();
|
||||
await expect(page.getByText("geannuleerd", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("manager can create, edit and deactivate an operational user", async ({ page, request }) => {
|
||||
await reset(request);
|
||||
await login(page);
|
||||
await page.goto("/users");
|
||||
await page.getByLabel("Naam").fill("E2E Planner");
|
||||
await page.getByLabel("E-mail").fill("e2e.planner@example.test");
|
||||
await page.getByLabel("Tijdelijk wachtwoord").fill("secure-e2e-password");
|
||||
await page.getByRole("button", { name: "Gebruiker toevoegen" }).click();
|
||||
let row = page.getByRole("row", { name: /E2E Planner/ });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole("button", { name: "Bewerken" }).click();
|
||||
const editor = page.locator(".user-edit");
|
||||
await editor.getByLabel("Naam").fill("E2E Senior Planner");
|
||||
await editor.getByLabel("Rol").selectOption("operations_manager");
|
||||
await editor.getByLabel("Nieuw wachtwoord").fill("updated-e2e-password");
|
||||
await editor.getByRole("button", { name: "Wijzigingen opslaan" }).click();
|
||||
row = page.getByRole("row", { name: /E2E Senior Planner/ });
|
||||
await expect(row.getByText("Operationeel beheerder")).toBeVisible();
|
||||
await row.getByRole("button", { name: "Deactiveren" }).click();
|
||||
await expect(row.getByText("inactief", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("manager can assign data-quality work from the queue", async ({ page, request }) => {
|
||||
await reset(request);
|
||||
await login(page);
|
||||
await page.goto("/data-quality?status=open");
|
||||
const firstRow = page.locator(".data-table tbody tr").first();
|
||||
const issueRef = (await firstRow.locator(".row-link").textContent())?.trim() ?? "";
|
||||
expect(issueRef).not.toBe("");
|
||||
await firstRow.getByRole("checkbox").check();
|
||||
const assignee = page.getByLabel("Toewijzen aan");
|
||||
await assignee.selectOption({ index: 1 });
|
||||
const selectedLabel = await assignee.locator("option:checked").textContent();
|
||||
await page.getByRole("button", { name: "Werkvoorraad bijwerken" }).click();
|
||||
const updatedRow = page.locator(".data-table tbody tr").filter({
|
||||
has: page.getByRole("link", { name: issueRef, exact: true }),
|
||||
});
|
||||
await expect(updatedRow).toContainText(selectedLabel ?? "");
|
||||
});
|
||||
|
||||
test("manager can record maintenance and release a safe vehicle", async ({ page, request }) => {
|
||||
await reset(request);
|
||||
const vehicles = await (await request.get("/api/v1/vehicles?status=available")).json();
|
||||
let selected = vehicles[0];
|
||||
for (const candidate of vehicles) {
|
||||
const detail = await (await request.get(`/api/v1/vehicles/${candidate.public_ref}`)).json();
|
||||
if (!detail.attention && !detail.bookings.some((booking: { status: string }) => booking.status === "active")) {
|
||||
selected = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await login(page);
|
||||
await page.goto(`/vehicles/${selected.public_ref}`);
|
||||
await page.getByRole("tab", { name: "Onderhoud" }).click();
|
||||
await page.getByRole("button", { name: "Onderhoud registreren" }).click();
|
||||
await page.getByLabel("Uitgevoerd werk").fill("Periodiek onderhoud en veiligheidscontrole voltooid");
|
||||
await page.getByRole("button", { name: "Onderhoud opslaan" }).click();
|
||||
await expect(page.getByText("onderhoud", { exact: true }).first()).toBeVisible();
|
||||
await page.getByLabel("Reden voor vrijgave").fill("Veiligheidscontrole afgerond zonder open punten");
|
||||
await page.getByRole("button", { name: "Voertuig vrijgeven" }).click();
|
||||
await expect(page.getByText("beschikbaar", { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
async function resetAndLogin(request: APIRequestContext, page: Page) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
}
|
||||
|
||||
test("privacy centre reports policy and produces an audited CSV export", async ({ page, request }) => {
|
||||
await resetAndLogin(request, page);
|
||||
await page.goto("/privacy");
|
||||
await expect(page.getByRole("heading", { name: "Privacybeheer" })).toBeVisible();
|
||||
await expect(page.getByText("30 dagen", { exact: true })).toBeVisible();
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("link", { name: "Audit CSV downloaden" }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe("mobilityops-audit.csv");
|
||||
});
|
||||
|
||||
test("privacy centre refuses anonymisation of a customer with an active booking", async ({ page, request }) => {
|
||||
await resetAndLogin(request, page);
|
||||
await page.goto("/privacy");
|
||||
await page.getByLabel("Klantreferentie").first().fill("CUS-0042");
|
||||
await page.getByLabel("Gemotiveerde reden").fill("Gevalideerd verzoek van de betrokkene");
|
||||
await page.getByLabel(/Typ CUS-0042 ter bevestiging/).fill("CUS-0042");
|
||||
await page.getByRole("button", { name: "Definitief anonimiseren" }).click();
|
||||
await expect(page.getByText(/kon niet worden uitgevoerd/)).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
async function loginAsOperationsManager(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
}
|
||||
|
||||
test("90-second recruiter entry exposes three verifiable engineering highlights", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/highlights$/);
|
||||
await expect(page.getByRole("heading", { name: "De sterkste keuzes in 90 seconden" })).toBeVisible();
|
||||
await expect(page.locator(".highlight-card")).toHaveCount(3);
|
||||
await expect(page.getByText("Transactie · idempotency · outbox · degradatie")).toBeVisible();
|
||||
await expect(page.getByText("RAGcore · citations · veilige degradatie")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Engineering Story exposes architecture, reliability and honest integration evidence", async ({ page }) => {
|
||||
await loginAsOperationsManager(page);
|
||||
await page.goto("/about");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible();
|
||||
await expect(page.getByText("Commit eerst, orkestreer daarna")).toBeVisible();
|
||||
await expect(page.getByText("AI moet bewijs tonen")).toBeVisible();
|
||||
await expect(page.locator(".architecture-step", { hasText: "PostgreSQL + audittrail" }).getByRole("button")).toBeVisible();
|
||||
const outboxStep = page.locator(".architecture-step", { hasText: "Betrouwbare outbox" }).getByRole("button");
|
||||
await outboxStep.click();
|
||||
await expect(outboxStep).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.locator("#architecture-active-detail")).toContainText("Post-commit aflevering");
|
||||
const architectureTypeMinimums = [
|
||||
[".architecture-zones > span", 12],
|
||||
[".architecture-step button > small", 13],
|
||||
[".architecture-zone-tag", 12],
|
||||
[".architecture-commit-note", 12],
|
||||
[".architecture-interaction-hint", 13],
|
||||
[".architecture-active-copy p", 13],
|
||||
[".architecture-active-evidence dt", 12],
|
||||
[".architecture-active-evidence dd", 13],
|
||||
] as const;
|
||||
for (const [selector, minimumPixels] of architectureTypeMinimums) {
|
||||
const fontSize = await page.locator(selector).first().evaluate((element) =>
|
||||
Number.parseFloat(window.getComputedStyle(element).fontSize));
|
||||
expect(fontSize, `${selector} font-size`).toBeGreaterThanOrEqual(minimumPixels);
|
||||
}
|
||||
await expect(page.getByText(/nog niet live gekoppeld/)).toHaveCount(0);
|
||||
await expect(page.getByText(/unknown procedures/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dashboard load failure offers an in-place retry and recovers", async ({ page }) => {
|
||||
await loginAsOperationsManager(page);
|
||||
let dashboardAttempts = 0;
|
||||
let allowDashboardRecovery = false;
|
||||
await page.route("**/api/v1/dashboard", async (route) => {
|
||||
dashboardAttempts += 1;
|
||||
if (!allowDashboardRecovery) {
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" });
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
const retryButton = page.getByRole("button", { name: "Opnieuw proberen" });
|
||||
await expect(retryButton).toBeVisible();
|
||||
allowDashboardRecovery = true;
|
||||
await retryButton.click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
|
||||
expect(dashboardAttempts).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("dashboard header stacks a full-width touch target on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await loginAsOperationsManager(page);
|
||||
|
||||
const header = page.locator(".page-header");
|
||||
const action = page.locator(".page-actions .button");
|
||||
const [headerBox, actionBox] = await Promise.all([header.boundingBox(), action.boundingBox()]);
|
||||
expect(headerBox).not.toBeNull();
|
||||
expect(actionBox).not.toBeNull();
|
||||
expect(actionBox!.width).toBeGreaterThanOrEqual(headerBox!.width - 1);
|
||||
expect(Math.round(actionBox!.height)).toBeGreaterThanOrEqual(44);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
|
||||
await page.evaluate(() => document.documentElement.clientWidth + 1),
|
||||
);
|
||||
});
|
||||
|
||||
test("mobile recruiter surfaces remain readable without horizontal overflow", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click();
|
||||
await expect(page).toHaveURL(/\/highlights$/);
|
||||
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
await expect(page.locator(".highlight-card").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("remaining attention items use a compact, legible queue action", async ({ page, request }) => {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
await loginAsOperationsManager(page);
|
||||
|
||||
const queueAction = page.locator(".queue-more a");
|
||||
await expect(queueAction).toContainText("Nog 2 aandachtspunten");
|
||||
await expect(queueAction).toContainText("Open de volledige werklijst");
|
||||
const actionBox = await queueAction.boundingBox();
|
||||
const arrowBox = await queueAction.locator(".queue-more-arrow svg").boundingBox();
|
||||
expect(actionBox?.height).toBeLessThanOrEqual(70);
|
||||
expect(arrowBox?.width).toBeLessThanOrEqual(16);
|
||||
});
|
||||
|
||||
test("Knowledge Hub renders independently verified RAGcore index evidence", async ({ page }) => {
|
||||
await loginAsOperationsManager(page);
|
||||
await page.route("**/api/v1/knowledge/status**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
provider: "ragcore",
|
||||
available: true,
|
||||
detail: "11/11 managed sources verified",
|
||||
tenant: "northstar-mobility-demo",
|
||||
workspace: "mobilityops",
|
||||
collection: "internal-procedures",
|
||||
document_count: 11,
|
||||
source_document_count: 11,
|
||||
reported_synced_document_count: 33,
|
||||
reported_failed_document_count: 0,
|
||||
last_sync_at: "2026-08-10T16:00:00Z",
|
||||
statistics_state: "verified",
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.goto("/knowledge");
|
||||
|
||||
await expect(page.getByText("11 procedures geïndexeerd")).toBeVisible();
|
||||
await expect(page.getByText("exact gevonden en actief gepubliceerd")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
// Cross-checks the responsive shell across the exact breakpoint matrix from the polish
|
||||
// brief (1440x1000, 1280x800, 1024x768, 768x1024, 430x932, 390x844, 360x800) in all three
|
||||
// supported languages -- both that nothing overflows horizontally and that the localized
|
||||
// heading text still renders (rather than being silently truncated away).
|
||||
const BREAKPOINTS: { width: number; height: number }[] = [
|
||||
{ width: 1440, height: 1000 },
|
||||
{ width: 1280, height: 800 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 430, height: 932 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 360, height: 800 },
|
||||
];
|
||||
|
||||
const LANGUAGES: { code: string; attentionTitle: string }[] = [
|
||||
{ code: "nl-BE", attentionTitle: "Aandachtspunten" },
|
||||
{ code: "en-GB", attentionTitle: "Attention queue" },
|
||||
{ code: "fr-BE", attentionTitle: "File d'attention" },
|
||||
];
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await resetDemoData(request);
|
||||
});
|
||||
|
||||
for (const language of LANGUAGES) {
|
||||
test.describe(`${language.code}`, () => {
|
||||
for (const bp of BREAKPOINTS) {
|
||||
test(`no horizontal overflow at ${bp.width}x${bp.height}`, async ({ page }) => {
|
||||
await page.addInitScript(
|
||||
(lang) => localStorage.setItem("fleetops.language", lang),
|
||||
language.code,
|
||||
);
|
||||
await page.setViewportSize({ width: bp.width, height: bp.height });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: /begeleide demo|guided demo|démo guidée/i }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
|
||||
await expect(page.getByRole("heading", { name: language.attentionTitle })).toBeVisible();
|
||||
|
||||
const dimensions = await page.evaluate(() => ({
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
client: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(dimensions.scroll, `${language.code} @ ${bp.width}x${bp.height}`).toBeLessThanOrEqual(
|
||||
dimensions.client + 1,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
async function reset(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
}
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ request }) => reset(request));
|
||||
|
||||
test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => {
|
||||
const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25");
|
||||
expect(vehicles.ok()).toBeTruthy();
|
||||
const vehiclePage = await vehicles.json();
|
||||
expect(vehiclePage.items).toHaveLength(25);
|
||||
expect(vehiclePage.total).toBeGreaterThanOrEqual(25);
|
||||
|
||||
const audit = await request.get("/api/v1/audit?page=1&page_size=25");
|
||||
expect(audit.ok()).toBeTruthy();
|
||||
const auditPage = await audit.json();
|
||||
expect(auditPage.items.length).toBeLessThanOrEqual(25);
|
||||
|
||||
await login(page);
|
||||
await page.goto("/vehicles?status=maintenance&page=1");
|
||||
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
|
||||
await expect(page).toHaveURL(/status=maintenance/);
|
||||
});
|
||||
|
||||
test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await login(page);
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
|
||||
await expect(page.locator(".page-actions .badge")).toBeVisible();
|
||||
await page.goto("/vehicles");
|
||||
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }));
|
||||
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
// This file's assertions were authored against the English UI copy; nl-BE is now the
|
||||
// app's default for a fresh session, so force English explicitly rather than rewriting
|
||||
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
test("control-centre shell exposes landmarks, persisted readiness and active navigation", async ({ page }) => {
|
||||
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible();
|
||||
await expect(page.locator(".metric-cell dd").first()).not.toHaveText("");
|
||||
await expect(page.getByRole("link", { name: "Overview" }).first()).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await expect(async () => {
|
||||
await page.keyboard.press("Control+k");
|
||||
await expect(search).toBeFocused();
|
||||
}).toPass({ timeout: 5_000 });
|
||||
await search.fill("MO-024");
|
||||
const result = page.getByRole("option", { name: /MO-024/ });
|
||||
await expect(result).toBeVisible();
|
||||
await result.click();
|
||||
await expect(page).toHaveURL(/\/vehicles\/MO-024$/);
|
||||
await expect(page.getByRole("heading", { name: "MO-024" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("global search supports arrow-key navigation and Enter to select", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await search.fill("fleet");
|
||||
await expect(page.getByRole("option", { name: /Fleet/ })).toBeVisible();
|
||||
await search.press("ArrowDown");
|
||||
await search.press("Enter");
|
||||
await expect(page).toHaveURL(/\/vehicles$/);
|
||||
});
|
||||
|
||||
test("global search shows a no-results state and closes on Escape", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await search.fill("zzz-nothing-matches-zzz");
|
||||
await expect(page.getByText(/No matches for/)).toBeVisible();
|
||||
await search.press("Escape");
|
||||
await expect(page.getByRole("listbox")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("global search finds a booking and a data-quality issue by reference", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await search.fill("BK-DEMO-RETURN");
|
||||
const bookingResult = page.getByRole("option", { name: /BK-DEMO-RETURN/ });
|
||||
await expect(bookingResult).toBeVisible();
|
||||
await bookingResult.click();
|
||||
await expect(page).toHaveURL(/\/bookings\/BK-DEMO-RETURN$/);
|
||||
|
||||
await search.fill("DQ-DEMO-OVERLAP");
|
||||
const issueResult = page.getByRole("option", { name: /DQ-DEMO-OVERLAP/ });
|
||||
await expect(issueResult).toBeVisible();
|
||||
await issueResult.click();
|
||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-OVERLAP$/);
|
||||
});
|
||||
|
||||
test("return review separates capture from irreversible commit", async ({ page }) => {
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
await page.getByLabel("End odometer (km)").fill("60000");
|
||||
await page.getByLabel("Fuel level (%)").fill("65");
|
||||
|
||||
let commitRequests = 0;
|
||||
let previewRequests = 0;
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return;
|
||||
if (request.url().endsWith("/return-preview")) previewRequests += 1;
|
||||
else if (request.url().endsWith("/return")) commitRequests += 1;
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Review return" }).click();
|
||||
await expect(page.getByText(/Expected fleet state/)).toBeVisible();
|
||||
expect(commitRequests).toBe(0);
|
||||
// The review step is server-evaluated (not client-guessed), so exactly one non-mutating
|
||||
// preview call is expected before any commit.
|
||||
expect(previewRequests).toBe(1);
|
||||
await expect(page.getByText("Queue automation after the local commit")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Edit details" }).click();
|
||||
await expect(page.getByLabel("End odometer (km)")).toHaveValue("60000");
|
||||
});
|
||||
|
||||
for (const width of [390, 768, 1280, 1440]) {
|
||||
test(`responsive shell has no horizontal overflow at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: width < 700 ? 844 : 900 });
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByRole("heading", { name: "Attention queue" })).toBeVisible();
|
||||
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }));
|
||||
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1);
|
||||
if (width < 960) await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("public entry and engineering highlights retain their visual hierarchy", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.locator(".login-access")).toHaveScreenshot("login-access.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
|
||||
await page.goto("/highlights");
|
||||
await expect(page.locator("main")).toHaveScreenshot("highlights-main.png", {
|
||||
animations: "disabled",
|
||||
maxDiffPixelRatio: 0.02,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||
import globals from "globals";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist/", "playwright-report/", "test-results/", "node_modules/"] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
jsxA11y.flatConfigs.recommended,
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
languageOptions: { globals: { ...globals.browser } },
|
||||
rules: {
|
||||
// Hook dependency mistakes are real bugs in this codebase's data-loading effects.
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
// The React Compiler-oriented rules in react-hooks v7 flag the classic
|
||||
// "reset state, then fetch in an effect" data-loading pattern this React 18 app
|
||||
// uses deliberately; they are not bugs here.
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"react-hooks/purity": "off",
|
||||
"react-hooks/refs": "off",
|
||||
// Choice cards wrap the input and put the visible text two spans deep.
|
||||
"jsx-a11y/label-has-associated-control": ["error", { depth: 3 }],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["e2e/**/*.ts", "*.config.ts"],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Fleet Ops is a production-minded vehicle operations demo with reliable workflows, explainable data quality and source-grounded AI." />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Fleet Ops — connected vehicle operations" />
|
||||
<meta property="og:description" content="Explore reliable vehicle returns, controlled data-quality resolution and source-grounded operational knowledge." />
|
||||
<meta property="og:image" content="/og-fleet-ops.svg" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Fleet Ops — connected vehicle operations" />
|
||||
<meta name="twitter:description" content="A working synthetic-data platform for reliable mobility operations." />
|
||||
<meta name="twitter:image" content="/og-fleet-ops.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Fleet Ops — connected vehicle operations</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
limit_req_zone $binary_remote_addr zone=demo_login:10m rate=600r/m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
server_tokens off;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml font/woff2;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
|
||||
location = /api/v1/demo/login {
|
||||
limit_req zone=demo_login burst=100 nodelay;
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /api/v1/demo/reset {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://api:8000/health;
|
||||
}
|
||||
|
||||
location / {
|
||||
expires -1;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# Vite emits content-hashed filenames like "Bookings-BXgkh2jX.js" (hyphen + 8 chars,
|
||||
# base64url alphabet, one dot); anything under /assets/ matching that is immutable.
|
||||
location ~* "^/assets/.+-[A-Za-z0-9_-]{8,}\.(css|js|woff2|png|svg)$" {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
Generated
+4916
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "mobilityops-web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"budget": "node ../scripts/check-frontend-budget.mjs",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"lint": "tsc -b --noEmit && eslint .",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "26.3.6",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.11",
|
||||
"react-router-dom": "7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.5",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "6.0.5",
|
||||
"axe-core": "4.13.0",
|
||||
"eslint": "9.39.5",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "17.11.0",
|
||||
"typescript": "5.6.3",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite": "8.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
// _capture-screenshots.spec.ts is evidence-generation tooling, not a regression test —
|
||||
// it mutates demo data via a reset and is run explicitly, not as part of the suite.
|
||||
testIgnore: process.env.CAPTURE_EVIDENCE === "1" ? undefined : "**/_*.spec.ts",
|
||||
timeout: 30_000,
|
||||
// CI exercises the browser while image scans and container services share the
|
||||
// same runner. Keep assertions strict, but allow successful API-backed route
|
||||
// transitions enough time to render under that bounded load.
|
||||
expect: { timeout: 15_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
snapshotPathTemplate: "{testDir}/__screenshots__/{arg}{ext}",
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
|
||||
use: {
|
||||
baseURL: process.env.MOBILITYOPS_PUBLIC_URL ?? "http://localhost:1228",
|
||||
// Optional: point at a pre-installed Chromium (CI images, sandboxes) instead of the
|
||||
// Playwright-managed download.
|
||||
...(process.env.PW_CHROMIUM_PATH ? { launchOptions: { executablePath: process.env.PW_CHROMIUM_PATH } } : {}),
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e-live",
|
||||
timeout: 30_000,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-live-report" }]],
|
||||
use: {
|
||||
baseURL: process.env.MOBILITYOPS_PUBLIC_URL ?? "http://localhost:1228",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#0f172a" />
|
||||
<path d="M16 25.5c-4.4-5.1-6-8.2-6-11a6 6 0 1 1 12 0c0 2.8-1.6 5.9-6 11Z" fill="white" />
|
||||
<circle cx="16" cy="14.3" r="2.1" fill="#2dd4bf" />
|
||||
<rect x="3" y="20.6" width="26" height="2.4" rx="1.2" fill="#2dd4bf" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Fleet Ops</title>
|
||||
<desc id="desc">Connected vehicle operations with reliable workflows, explainable data quality and grounded knowledge.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0f172a"/><stop offset="1" stop-color="#17394d"/></linearGradient>
|
||||
<radialGradient id="glow"><stop stop-color="#2dd4bf" stop-opacity=".34"/><stop offset="1" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<circle cx="1000" cy="92" r="360" fill="url(#glow)"/>
|
||||
<g transform="translate(78 72)">
|
||||
<path d="M42 72C16 42 7 24 7 8a35 35 0 1 1 70 0c0 16-9 34-35 64Z" fill="#fff" transform="translate(0 6) scale(.72)"/>
|
||||
<circle cx="30" cy="28" r="6" fill="#2dd4bf"/>
|
||||
<rect x="0" y="58" width="62" height="6" rx="3" fill="#2dd4bf"/>
|
||||
<text x="84" y="51" fill="#fff" font-family="Inter,Segoe UI,sans-serif" font-size="36" font-weight="750">Fleet Ops</text>
|
||||
</g>
|
||||
<text x="78" y="265" fill="#5eead4" font-family="Inter,Segoe UI,sans-serif" font-size="24" font-weight="750" letter-spacing="3">CONNECTED VEHICLE OPERATIONS</text>
|
||||
<text x="78" y="345" fill="#fff" font-family="Inter,Segoe UI,sans-serif" font-size="60" font-weight="760">Every hand-off.</text>
|
||||
<text x="78" y="413" fill="#fff" font-family="Inter,Segoe UI,sans-serif" font-size="60" font-weight="760">One clear view.</text>
|
||||
<g transform="translate(78 500)" font-family="Inter,Segoe UI,sans-serif" font-size="22" font-weight="650">
|
||||
<rect width="272" height="54" rx="27" fill="#fff" fill-opacity=".09" stroke="#fff" stroke-opacity=".16"/><text x="27" y="35" fill="#dbeafe">Reliable workflows</text>
|
||||
<rect x="292" width="288" height="54" rx="27" fill="#fff" fill-opacity=".09" stroke="#fff" stroke-opacity=".16"/><text x="319" y="35" fill="#dbeafe">Explainable quality</text>
|
||||
<rect x="600" width="290" height="54" rx="27" fill="#fff" fill-opacity=".09" stroke="#fff" stroke-opacity=".16"/><text x="627" y="35" fill="#dbeafe">Grounded knowledge</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,68 @@
|
||||
import { lazy, Suspense, type ReactNode } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { DemoManifestProvider } from "./context/DemoManifestContext";
|
||||
import { DemoGuideProvider } from "./context/DemoGuideContext";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
const Login = lazy(() => import("./pages/Login").then((module) => ({ default: module.Login })));
|
||||
const Dashboard = lazy(() => import("./pages/Dashboard").then((module) => ({ default: module.Dashboard })));
|
||||
const Vehicles = lazy(() => import("./pages/Vehicles").then((module) => ({ default: module.Vehicles })));
|
||||
const VehicleDetail = lazy(() => import("./pages/VehicleDetail").then((module) => ({ default: module.VehicleDetail })));
|
||||
const Bookings = lazy(() => import("./pages/Bookings").then((module) => ({ default: module.Bookings })));
|
||||
const BookingDetail = lazy(() => import("./pages/BookingDetail").then((module) => ({ default: module.BookingDetail })));
|
||||
const BookingCreate = lazy(() => import("./pages/BookingCreate").then((module) => ({ default: module.BookingCreate })));
|
||||
const DataQuality = lazy(() => import("./pages/DataQuality").then((module) => ({ default: module.DataQuality })));
|
||||
const DataQualityIssueDetail = lazy(() => import("./pages/DataQualityIssueDetail").then((module) => ({ default: module.DataQualityIssueDetail })));
|
||||
const Automation = lazy(() => import("./pages/Automation").then((module) => ({ default: module.Automation })));
|
||||
const Knowledge = lazy(() => import("./pages/Knowledge").then((module) => ({ default: module.Knowledge })));
|
||||
const Audit = lazy(() => import("./pages/Audit").then((module) => ({ default: module.Audit })));
|
||||
const AboutDemo = lazy(() => import("./pages/AboutDemo").then((module) => ({ default: module.AboutDemo })));
|
||||
const Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios })));
|
||||
const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users })));
|
||||
const Privacy = lazy(() => import("./pages/Privacy").then((module) => ({ default: module.Privacy })));
|
||||
const Highlights = lazy(() => import("./pages/Highlights").then((module) => ({ default: module.Highlights })));
|
||||
|
||||
function deferredPage(element: ReactNode) {
|
||||
return <Suspense fallback={<div className="route-loading" role="status" aria-label="Loading"><div className="route-skeleton"><span /><span /><span /></div></div>}>{element}</Suspense>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<DemoManifestProvider>
|
||||
<AuthProvider>
|
||||
<DemoGuideProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={deferredPage(<Login />)} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/dashboard" element={deferredPage(<Dashboard />)} />
|
||||
<Route path="/vehicles" element={deferredPage(<Vehicles />)} />
|
||||
<Route path="/vehicles/:publicRef" element={deferredPage(<VehicleDetail />)} />
|
||||
<Route path="/bookings" element={deferredPage(<Bookings />)} />
|
||||
<Route path="/bookings/new" element={deferredPage(<BookingCreate />)} />
|
||||
<Route path="/bookings/:publicRef" element={deferredPage(<BookingDetail />)} />
|
||||
<Route path="/data-quality" element={deferredPage(<DataQuality />)} />
|
||||
<Route path="/data-quality/:publicRef" element={deferredPage(<DataQualityIssueDetail />)} />
|
||||
<Route path="/automation" element={deferredPage(<Automation />)} />
|
||||
<Route path="/knowledge" element={deferredPage(<Knowledge />)} />
|
||||
<Route path="/audit" element={deferredPage(<Audit />)} />
|
||||
<Route path="/users" element={deferredPage(<Users />)} />
|
||||
<Route path="/privacy" element={deferredPage(<Privacy />)} />
|
||||
<Route path="/about" element={deferredPage(<AboutDemo />)} />
|
||||
<Route path="/scenarios" element={deferredPage(<Scenarios />)} />
|
||||
<Route path="/highlights" element={deferredPage(<Highlights />)} />
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</DemoGuideProvider>
|
||||
</AuthProvider>
|
||||
</DemoManifestProvider>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ApiError } from "./apiError";
|
||||
|
||||
export { ApiError } from "./apiError";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
|
||||
type UnauthorizedListener = () => void;
|
||||
const unauthorizedListeners = new Set<UnauthorizedListener>();
|
||||
|
||||
export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
||||
unauthorizedListeners.add(listener);
|
||||
return () => unauthorizedListeners.delete(listener);
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** `AbortSignal.any` with a fallback for browsers that predate it (Safari < 17.4, etc.). */
|
||||
function combineSignals(caller: AbortSignal, timeout: AbortSignal): AbortSignal {
|
||||
if (typeof AbortSignal.any === "function") return AbortSignal.any([caller, timeout]);
|
||||
const combined = new AbortController();
|
||||
const forward = (source: AbortSignal) => () => combined.abort(source.reason);
|
||||
if (caller.aborted) combined.abort(caller.reason);
|
||||
else caller.addEventListener("abort", forward(caller), { once: true });
|
||||
if (timeout.aborted) combined.abort(timeout.reason);
|
||||
else timeout.addEventListener("abort", forward(timeout), { once: true });
|
||||
return combined.signal;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const timeoutController = new AbortController();
|
||||
const timeout = window.setTimeout(() => timeoutController.abort("timeout"), DEFAULT_TIMEOUT_MS);
|
||||
const signal = init?.signal
|
||||
? combineSignals(init.signal, timeoutController.signal)
|
||||
: timeoutController.signal;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
signal,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (timeoutController.signal.aborted && !init?.signal?.aborted) {
|
||||
throw new ApiError(408, "REQUEST_TIMEOUT", "The request took too long.", "");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let body: { error?: { code: string; message: string; correlation_id: string } } | undefined;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = undefined;
|
||||
}
|
||||
const error = body?.error;
|
||||
if (response.status === 401) {
|
||||
unauthorizedListeners.forEach((listener) => listener());
|
||||
}
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
error?.code ?? String(response.status),
|
||||
error?.message ?? response.statusText,
|
||||
error?.correlation_id ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, init?: Pick<RequestInit, "signal">) => request<T>(path, init),
|
||||
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
request<T>(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
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",
|
||||
"CUSTOMER_ALREADY_MERGED",
|
||||
"ENTITY_NOT_FOUND",
|
||||
"EVENT_NOT_FOUND",
|
||||
"ISSUE_NOT_FOUND",
|
||||
"ISSUE_NOT_OPEN",
|
||||
"ISSUE_CHANGED",
|
||||
"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_EVENT_CORRELATION",
|
||||
"CALLBACK_EVENT_MISMATCH",
|
||||
"CALLBACK_CORRELATION_MISMATCH",
|
||||
"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",
|
||||
"UNKNOWN_WORKFLOW",
|
||||
]);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
export type Role = "operations_manager" | "rental_employee";
|
||||
|
||||
export interface CurrentUser {
|
||||
public_ref: string;
|
||||
display_name: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
service: string;
|
||||
environment: string;
|
||||
demo_mode: boolean;
|
||||
knowledge_provider: string;
|
||||
oidc_enabled: boolean;
|
||||
oidc_provider_name: string | null;
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
public_ref: string;
|
||||
make: string;
|
||||
model: string;
|
||||
model_year: number;
|
||||
registration_number: string;
|
||||
location: string;
|
||||
operational_status: string;
|
||||
odometer_km: number;
|
||||
next_service_km: number;
|
||||
active: boolean;
|
||||
attention: boolean;
|
||||
attention_reason: "blocked_status" | "service_due" | "data_quality" | null;
|
||||
service_remaining_km: number;
|
||||
next_booking_ref: string | null;
|
||||
next_booking_at: string | null;
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface BookingSummary {
|
||||
public_ref: string;
|
||||
customer_ref: string;
|
||||
vehicle_ref: string;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Booking extends BookingSummary {
|
||||
start_odometer_km: number | null;
|
||||
end_odometer_km: number | null;
|
||||
requirements_complete: boolean;
|
||||
customer_name: string;
|
||||
}
|
||||
|
||||
export interface CustomerOption {
|
||||
public_ref: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface AvailableVehicle {
|
||||
public_ref: string;
|
||||
make: string;
|
||||
model: string;
|
||||
registration_number: string;
|
||||
location: string;
|
||||
operational_status: string;
|
||||
}
|
||||
|
||||
export interface CheckoutBookingResult {
|
||||
booking_ref: string;
|
||||
vehicle_ref: string;
|
||||
inspection_ref: string;
|
||||
booking_status: string;
|
||||
resulting_vehicle_status: string;
|
||||
activated: boolean;
|
||||
attention_reasons: string[];
|
||||
}
|
||||
|
||||
export interface UserRecord {
|
||||
public_ref: string;
|
||||
email: string | null;
|
||||
display_name: string;
|
||||
role: Role;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface PrivacyRetention {
|
||||
minimum_booking_retention_days: number;
|
||||
audit_retention_days: number;
|
||||
customers_total: number;
|
||||
customers_anonymized: number;
|
||||
customers_eligible: number;
|
||||
}
|
||||
|
||||
export interface CustomerAnonymizeResult {
|
||||
public_ref: string;
|
||||
anonymized_at: string;
|
||||
status: "anonymized" | "already_anonymized";
|
||||
}
|
||||
|
||||
export interface Inspection {
|
||||
public_ref: string;
|
||||
booking_ref: string;
|
||||
type: string;
|
||||
fuel_level_percent: number;
|
||||
cleanliness_ok: boolean;
|
||||
damage_reported: boolean;
|
||||
technical_warning: boolean;
|
||||
odometer_km: number;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceRecord {
|
||||
public_ref: string;
|
||||
occurred_at: string;
|
||||
odometer_km: number;
|
||||
category: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface DataQualityIssue {
|
||||
public_ref: string;
|
||||
rule_type: string;
|
||||
entity_type: string;
|
||||
entity_ref: string;
|
||||
severity: "low" | "medium" | "high";
|
||||
status: "open" | "deferred" | "resolved" | "rejected";
|
||||
evidence: Record<string, unknown>;
|
||||
detected_at: string;
|
||||
due_at: string | null;
|
||||
assigned_to_ref: string | null;
|
||||
assigned_to_name: string | null;
|
||||
overdue: boolean;
|
||||
resolved_at: string | null;
|
||||
}
|
||||
|
||||
export interface VehicleDetail extends Vehicle {
|
||||
bookings: BookingSummary[];
|
||||
inspections: Inspection[];
|
||||
maintenance: MaintenanceRecord[];
|
||||
quality_issues: DataQualityIssue[];
|
||||
}
|
||||
|
||||
export interface DashboardMetrics {
|
||||
available: number;
|
||||
rented: number;
|
||||
cleaning: number;
|
||||
maintenance: number;
|
||||
blocked: number;
|
||||
open_quality_issues: number;
|
||||
pending_or_failed_workflows: number;
|
||||
}
|
||||
|
||||
export interface EvidenceSignal {
|
||||
code: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AttentionItem {
|
||||
kind: string;
|
||||
severity: "low" | "medium" | "high";
|
||||
rule_type: string;
|
||||
evidence_signals: EvidenceSignal[];
|
||||
link_type: "vehicle" | "booking" | "customer";
|
||||
link_ref: string;
|
||||
issue_ref: string | null;
|
||||
}
|
||||
|
||||
export interface TodayItem {
|
||||
kind: "departure" | "return";
|
||||
booking_ref: string;
|
||||
vehicle_ref: string;
|
||||
scheduled_at: string;
|
||||
}
|
||||
|
||||
export interface AutomationRun {
|
||||
event_id: string;
|
||||
event_type: string;
|
||||
aggregate_ref: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
last_error: string | null;
|
||||
last_error_code: string | null;
|
||||
/** True for the failure the demo seed plants on purpose (see the backend's
|
||||
* DEMO_SCENARIO_ERROR_CODE). Drives the "prepared scenario" labelling instead of
|
||||
* showing it as an unexplained production error. */
|
||||
is_demo_scenario: boolean;
|
||||
occurred_at: string;
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
metrics: DashboardMetrics;
|
||||
attention_items: AttentionItem[];
|
||||
today: TodayItem[];
|
||||
recent_automation: AutomationRun[];
|
||||
}
|
||||
|
||||
export interface RegisterReturnRequest {
|
||||
end_odometer_km: number;
|
||||
fuel_level_percent: number;
|
||||
cleanliness_ok: boolean;
|
||||
damage_reported: boolean;
|
||||
technical_warning: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface NextBookingRisk {
|
||||
booking_ref: string;
|
||||
starts_at: string;
|
||||
at_risk: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterReturnResult {
|
||||
booking_ref: string;
|
||||
vehicle_ref: string;
|
||||
inspection_ref: string;
|
||||
resulting_vehicle_status: string;
|
||||
odometer_regression: boolean;
|
||||
quality_issue_ref: string | null;
|
||||
workflow_event_id: string;
|
||||
correlation_id: string;
|
||||
next_booking_risk: NextBookingRisk | null;
|
||||
}
|
||||
|
||||
export interface ReturnPreviewResult {
|
||||
booking_ref: string;
|
||||
vehicle_ref: string;
|
||||
canonical_odometer_km: number;
|
||||
submitted_odometer_km: number;
|
||||
odometer_regression: boolean;
|
||||
resulting_odometer_km: number;
|
||||
resulting_vehicle_status: string;
|
||||
status_reason: string;
|
||||
status_reason_code: string;
|
||||
status_reason_params: Record<string, string | number>;
|
||||
would_create_quality_issue: boolean;
|
||||
attention_reasons: string[];
|
||||
next_booking_risk: NextBookingRisk | null;
|
||||
}
|
||||
|
||||
export interface EntitySnapshot {
|
||||
entity_type: "customer" | "vehicle" | "booking" | "inspection" | "maintenance";
|
||||
public_ref: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DataQualityIssueDetail extends DataQualityIssue {
|
||||
entity_snapshot: EntitySnapshot | null;
|
||||
related_snapshots: EntitySnapshot[];
|
||||
}
|
||||
|
||||
export interface ApplyRecommendedStatusResult {
|
||||
issue: DataQualityIssue;
|
||||
applied_status: string;
|
||||
reason_code: string;
|
||||
}
|
||||
|
||||
export interface VehicleStatusFacts {
|
||||
active_booking_refs: string[];
|
||||
overlapping_booking_pairs: string[][];
|
||||
service_threshold_reached: boolean;
|
||||
odometer_km: number;
|
||||
next_service_km: number;
|
||||
open_booking_overlap_issue_ref: string | null;
|
||||
}
|
||||
|
||||
export interface StatusRecommendation {
|
||||
current_status: string;
|
||||
recommended_status: string | null;
|
||||
recommendation_code: string;
|
||||
safe_to_apply: boolean;
|
||||
manual_review_required: boolean;
|
||||
facts: VehicleStatusFacts;
|
||||
blocking_reasons: string[];
|
||||
recommendation_token: string;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
created: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
type: "vehicle" | "booking" | "data_quality_issue" | "section";
|
||||
label: string;
|
||||
detail_code: string;
|
||||
detail_params: Record<string, string>;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
query: string;
|
||||
results: SearchResultItem[];
|
||||
}
|
||||
|
||||
export interface MergeCustomersRequest {
|
||||
survivor_ref: string;
|
||||
field_overrides?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MergeCustomersResult {
|
||||
issue_ref: string;
|
||||
survivor_ref: string;
|
||||
loser_ref: string;
|
||||
rewired_bookings: number;
|
||||
}
|
||||
|
||||
export interface SourceCard {
|
||||
document_id: string;
|
||||
title: string;
|
||||
version: string;
|
||||
section: string;
|
||||
excerpt: string;
|
||||
}
|
||||
|
||||
export interface GroundedAnswer {
|
||||
answer: string;
|
||||
evidence_state: "grounded" | "insufficient" | "unavailable";
|
||||
sources: SourceCard[];
|
||||
provider: string;
|
||||
correlation_id: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeHealth {
|
||||
provider: string;
|
||||
available: boolean;
|
||||
detail: string;
|
||||
tenant: string;
|
||||
workspace: string;
|
||||
collection: string;
|
||||
document_count: number | null;
|
||||
source_document_count: number;
|
||||
reported_synced_document_count: number | null;
|
||||
reported_failed_document_count: number | null;
|
||||
last_sync_at: string | null;
|
||||
statistics_state: "verified" | "sync_reported" | "not_reported";
|
||||
}
|
||||
|
||||
export interface N8nWorkflowEvidence {
|
||||
name: string;
|
||||
built: boolean;
|
||||
last_seen_at: string | null;
|
||||
state: "no_evidence" | "healthy" | "stale" | "failed";
|
||||
last_status: "succeeded" | "failed" | null;
|
||||
last_execution_id: string | null;
|
||||
}
|
||||
|
||||
export interface N8nErrorHandlerStatus {
|
||||
total_failures_registered: number;
|
||||
latest_failure_at: string | null;
|
||||
latest_failure_workflow: string | null;
|
||||
}
|
||||
|
||||
export interface N8nIntegrationStatus {
|
||||
configured: boolean;
|
||||
dispatch_enabled: boolean;
|
||||
state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence";
|
||||
pending: number;
|
||||
delivering: number;
|
||||
/** Every failed delivery, prepared and real together. */
|
||||
failed: number;
|
||||
/** Failures that were not planted by the demo seed -- the only ones that affect state. */
|
||||
unexpected_failed: number;
|
||||
/** Prepared demo failures. Visible and counted, but never a health signal. */
|
||||
demo_scenario_failed: number;
|
||||
succeeded: number;
|
||||
latest_success_at: string | null;
|
||||
/** Most recent real failure; a prepared one never sets this. */
|
||||
latest_failure_at: string | null;
|
||||
latest_demo_scenario_at: string | null;
|
||||
expected_workflow_count: number;
|
||||
known_workflow_count: number;
|
||||
workflows: N8nWorkflowEvidence[];
|
||||
error_handler: N8nErrorHandlerStatus;
|
||||
}
|
||||
|
||||
export interface McpHubIntegrationStatus {
|
||||
registration_enabled: boolean;
|
||||
state: "not_configured" | "no_evidence" | "operational";
|
||||
total_calls: number;
|
||||
last_tool: string | null;
|
||||
last_client: string | null;
|
||||
last_called_at: string | null;
|
||||
hub_reachable: boolean | null;
|
||||
}
|
||||
|
||||
export interface IntegrationStatus {
|
||||
n8n: N8nIntegrationStatus;
|
||||
mcp_hub: McpHubIntegrationStatus;
|
||||
}
|
||||
|
||||
export interface DemoScenario {
|
||||
id: string;
|
||||
estimated_minutes: number;
|
||||
required_roles: Role[];
|
||||
start_path: string;
|
||||
ready: boolean;
|
||||
blocked_reason_code: string | null;
|
||||
blocked_reason_params: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DemoIntegrationSummary {
|
||||
key: "n8n" | "ragcore" | "mcp_hub";
|
||||
status_code: string;
|
||||
detail_code: string;
|
||||
detail_params: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface DemoManifest {
|
||||
demo_mode: boolean;
|
||||
organization_name: string;
|
||||
timezone: string;
|
||||
synthetic_data: boolean;
|
||||
allow_reset: boolean;
|
||||
last_reset_at: string | null;
|
||||
anchor_date: string | null;
|
||||
guide_available: boolean;
|
||||
required_roles: Role[];
|
||||
scenarios: DemoScenario[];
|
||||
integrations: DemoIntegrationSummary[];
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
actor_type: string;
|
||||
actor_label: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
entity_ref: string | null;
|
||||
entity_link: string | null;
|
||||
correlation_id: string;
|
||||
occurred_at: string;
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Icon, type IconName } from "./Icons";
|
||||
import "../styles-architecture-flow.css";
|
||||
|
||||
type ArchitectureStepId = "Frontend" | "Api" | "Database" | "Outbox" | "External";
|
||||
type ArchitectureZone = "intent" | "transaction" | "edge";
|
||||
|
||||
const ARCHITECTURE_STEPS: Array<{
|
||||
id: ArchitectureStepId;
|
||||
icon: IconName;
|
||||
zone: ArchitectureZone;
|
||||
}> = [
|
||||
{ id: "Frontend", icon: "user", zone: "intent" },
|
||||
{ id: "Api", icon: "shield", zone: "transaction" },
|
||||
{ id: "Database", icon: "audit", zone: "transaction" },
|
||||
{ id: "Outbox", icon: "activity", zone: "transaction" },
|
||||
{ id: "External", icon: "integrations", zone: "edge" },
|
||||
];
|
||||
|
||||
const ZONE_TRANSLATION_KEYS: Record<ArchitectureZone, string> = {
|
||||
intent: "architectureZoneIntent",
|
||||
transaction: "architectureZoneTransaction",
|
||||
edge: "architectureZoneEdge",
|
||||
};
|
||||
|
||||
export function ArchitectureFlow() {
|
||||
const { t } = useTranslation("demo");
|
||||
const [activeStepId, setActiveStepId] = useState<ArchitectureStepId>("Database");
|
||||
const activeIndex = ARCHITECTURE_STEPS.findIndex((step) => step.id === activeStepId);
|
||||
const activeStep = ARCHITECTURE_STEPS[activeIndex];
|
||||
|
||||
return (
|
||||
<div className="architecture-explorer">
|
||||
<div className="architecture-zones" aria-label={t("about.architectureZonesLabel")}>
|
||||
<span className="zone-intent"><Icon name="user" /> {t("about.architectureZoneIntent")}</span>
|
||||
<span className="zone-transaction"><Icon name="shield" /> {t("about.architectureZoneTransaction")}</span>
|
||||
<span className="zone-edge"><Icon name="integrations" /> {t("about.architectureZoneEdge")}</span>
|
||||
</div>
|
||||
|
||||
<ol className="architecture-flow" aria-label={t("about.architectureFlowLabel")}>
|
||||
{ARCHITECTURE_STEPS.map((step, index) => {
|
||||
const isActive = step.id === activeStepId;
|
||||
const isCommitBoundary = step.id === "Outbox";
|
||||
return (
|
||||
<li
|
||||
className={`architecture-step architecture-step-${step.zone}${isActive ? " is-active" : ""}`}
|
||||
key={step.id}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isActive}
|
||||
aria-controls="architecture-active-detail"
|
||||
onClick={() => setActiveStepId(step.id)}
|
||||
>
|
||||
<span className="architecture-step-top">
|
||||
<span className="architecture-step-number">0{index + 1}</span>
|
||||
<span className="architecture-step-icon"><Icon name={step.icon} /></span>
|
||||
</span>
|
||||
<strong>{t(`about.architecture${step.id}`)}</strong>
|
||||
<small>{t(`about.architectureDetails.${step.id}.summary`)}</small>
|
||||
<span className="architecture-step-meta">
|
||||
<span className="architecture-zone-tag">{t(`about.${ZONE_TRANSLATION_KEYS[step.zone]}`)}</span>
|
||||
{isCommitBoundary ? <span className="architecture-commit-note">→ {t("about.architectureCommitBoundary")}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
{index < ARCHITECTURE_STEPS.length - 1 ? (
|
||||
<span className={`architecture-connector${isCommitBoundary ? " is-commit-boundary" : ""}`} aria-hidden="true">
|
||||
<span className="architecture-signal" />
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<p className="architecture-interaction-hint">{t("about.architectureInteractionHint")}</p>
|
||||
<section
|
||||
className={`architecture-active-detail architecture-active-detail-${activeStep.zone}`}
|
||||
id="architecture-active-detail"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="architecture-active-copy">
|
||||
<span>{t("about.architectureSelectedStep", { current: activeIndex + 1, total: ARCHITECTURE_STEPS.length })}</span>
|
||||
<h3>{t(`about.architecture${activeStep.id}`)}</h3>
|
||||
<p>{t(`about.architectureDetails.${activeStep.id}.body`)}</p>
|
||||
</div>
|
||||
<dl className="architecture-active-evidence">
|
||||
<div>
|
||||
<dt>{t("about.architectureBoundaryLabel")}</dt>
|
||||
<dd>{t(`about.${ZONE_TRANSLATION_KEYS[activeStep.zone]}`)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("about.architectureEvidenceLabel")}</dt>
|
||||
<dd>
|
||||
<span>{t(`about.architectureDetails.${activeStep.id}.proofOne`)}</span>
|
||||
<span>{t(`about.architectureDetails.${activeStep.id}.proofTwo`)}</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
|
||||
const { t } = useTranslation("quality");
|
||||
return <span className={`badge severity-${severity}`}>{t(`severities.${severity}`)}</span>;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
return <span className={`badge status-${status}`}>{label ?? status.replace(/_/g, " ")}</span>;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import type { CheckoutBookingResult, VehicleDetail } from "../api/types";
|
||||
import { ApiErrorNotice, LoadingState, SectionHeading } from "./PageChrome";
|
||||
|
||||
export function CheckoutForm({ bookingRef, vehicleRef, onRecorded }: { bookingRef: string; vehicleRef: string; onRecorded: (result: CheckoutBookingResult) => void }) {
|
||||
const { t } = useTranslation(["bookings", "errors"]);
|
||||
const [odometer, setOdometer] = useState<number | null>(null);
|
||||
const [fuel, setFuel] = useState(100);
|
||||
const [clean, setClean] = useState(true);
|
||||
const [damage, setDamage] = useState(false);
|
||||
const [warning, setWarning] = useState(false);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<VehicleDetail>(`/api/v1/vehicles/${vehicleRef}`)
|
||||
.then((vehicle) => setOdometer(vehicle.odometer_km))
|
||||
.catch((err) => setError(describeApiError(t, err)));
|
||||
}, [t, vehicleRef]);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (odometer === null) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.post<CheckoutBookingResult>(`/api/v1/bookings/${bookingRef}/checkout`, {
|
||||
start_odometer_km: odometer,
|
||||
fuel_level_percent: fuel,
|
||||
cleanliness_ok: clean,
|
||||
damage_reported: damage,
|
||||
technical_warning: warning,
|
||||
notes: notes || null,
|
||||
});
|
||||
onRecorded(result);
|
||||
} catch (err) {
|
||||
setError(describeApiError(t, err, "bookings:checkout.failed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (odometer === null && !error) return <LoadingState label={t("checkout.preparing")} />;
|
||||
return <form className="return-form record-surface" onSubmit={submit}>
|
||||
<SectionHeading title={t("checkout.title")} description={t("checkout.description")} />
|
||||
<div className="return-capture">
|
||||
<ApiErrorNotice error={error} />
|
||||
<div className="form-grid">
|
||||
<label>{t("checkout.odometer")}<input type="number" min={0} required value={odometer ?? ""} onChange={(event) => setOdometer(Number(event.target.value))} /></label>
|
||||
<label>{t("checkout.fuel")}<input type="number" min={0} max={100} required value={fuel} onChange={(event) => setFuel(Number(event.target.value))} /></label>
|
||||
</div>
|
||||
<fieldset className="condition-fieldset"><legend>{t("checkout.condition")}</legend>
|
||||
<label className="check-card"><input type="checkbox" checked={clean} onChange={(event) => setClean(event.target.checked)} /> {t("checkout.clean")}</label>
|
||||
<label className="check-card"><input type="checkbox" checked={damage} onChange={(event) => setDamage(event.target.checked)} /> {t("checkout.damage")}</label>
|
||||
<label className="check-card"><input type="checkbox" checked={warning} onChange={(event) => setWarning(event.target.checked)} /> {t("checkout.warning")}</label>
|
||||
</fieldset>
|
||||
<label>{t("checkout.notes")}<textarea maxLength={2000} value={notes} onChange={(event) => setNotes(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className="form-actions"><button type="submit" className="button button-primary" disabled={saving || odometer === null}>{saving ? t("checkout.saving") : t("checkout.submit")}</button></div>
|
||||
</form>;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { Icon } from "./Icons";
|
||||
|
||||
export function DemoBadge() {
|
||||
const { t } = useTranslation("demo");
|
||||
const { formatDateTime } = useLocaleFormat();
|
||||
const { manifest } = useDemoManifest();
|
||||
const [open, setOpen] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (boxRef.current && !boxRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="demo-badge" ref={boxRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="demo-badge-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Icon name="shield" />
|
||||
<span>{t("badge.trigger")}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="demo-badge-popover" role="dialog" aria-label={t("badge.dialogLabel")}>
|
||||
<button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label={t("badge.close")}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
<p>
|
||||
{manifest ? (
|
||||
<Trans i18nKey="badge.orgIntro" t={t} values={{ orgName: manifest.organization_name }} components={{ strong: <strong /> }} />
|
||||
) : (
|
||||
t("badge.orgIntroFallback")
|
||||
)}
|
||||
</p>
|
||||
<p>{t("badge.realWorkflows")}</p>
|
||||
{manifest && (
|
||||
<p className="demo-badge-reset">
|
||||
<Trans
|
||||
i18nKey="badge.lastReset"
|
||||
t={t}
|
||||
values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("badge.unknown") }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<Link to="/about" onClick={() => setOpen(false)}>
|
||||
{t("badge.aboutLink")} <Icon name="chevron" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useViewportTier } from "../hooks/useViewportTier";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { Icon } from "./Icons";
|
||||
import { ApiErrorNotice } from "./PageChrome";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
|
||||
const GUIDE_TRIGGER_ID = "demo-guide-trigger";
|
||||
|
||||
export function DemoGuideTrigger() {
|
||||
const { t } = useTranslation("demo");
|
||||
const { user } = useAuth();
|
||||
const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide();
|
||||
|
||||
if (user?.role !== "operations_manager") return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
id={GUIDE_TRIGGER_ID}
|
||||
type="button"
|
||||
className="demo-guide-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
onClick={toggleGuide}
|
||||
>
|
||||
<Icon name="spark" />
|
||||
<span>{t("guide.trigger")}</span>
|
||||
<span className="demo-guide-progress-pill">{completed.size}/{totalSteps}</span>
|
||||
<span className="visually-hidden">, {t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function highlightTarget(selector: string): (() => void) | null {
|
||||
const el = document.querySelector<HTMLElement>(selector);
|
||||
if (!el) return null;
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const previousTabIndex = el.getAttribute("tabindex");
|
||||
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
|
||||
el.focus({ preventScroll: true });
|
||||
el.classList.add("demo-guide-highlight");
|
||||
const timeout = window.setTimeout(() => {
|
||||
el.classList.remove("demo-guide-highlight");
|
||||
if (previousTabIndex === null) el.removeAttribute("tabindex");
|
||||
}, 2200);
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
el.classList.remove("demo-guide-highlight");
|
||||
if (previousTabIndex === null) el.removeAttribute("tabindex");
|
||||
};
|
||||
}
|
||||
|
||||
export function DemoGuide() {
|
||||
const { t } = useTranslation("demo");
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuth();
|
||||
const { manifest, refresh } = useDemoManifest();
|
||||
const tier = useViewportTier();
|
||||
const {
|
||||
open,
|
||||
closeGuide,
|
||||
currentIndex,
|
||||
completed,
|
||||
totalSteps,
|
||||
goToStep,
|
||||
completeAndAdvance,
|
||||
restart,
|
||||
collapsedToChip,
|
||||
setCollapsedToChip,
|
||||
} = useDemoGuide();
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
|
||||
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
|
||||
const pendingTarget = useRef<string | null>(null);
|
||||
const [targetRequest, setTargetRequest] = useState(0);
|
||||
|
||||
const step = DEMO_GUIDE_STEPS[currentIndex];
|
||||
const isLastStep = currentIndex === totalSteps - 1;
|
||||
|
||||
const closeAndRestoreFocus = useCallback(() => {
|
||||
pendingTarget.current = null;
|
||||
closeGuide();
|
||||
window.requestAnimationFrame(() => {
|
||||
document.getElementById(GUIDE_TRIGGER_ID)?.focus();
|
||||
});
|
||||
}, [closeGuide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
if (tier === "standard" && !collapsedToChip) {
|
||||
setCollapsedToChip(true);
|
||||
} else if (tier === "mobile" && mobileSheetState !== "collapsed") {
|
||||
setMobileSheetState("collapsed");
|
||||
} else {
|
||||
closeAndRestoreFocus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
return () => document.removeEventListener("keydown", handleKeydown);
|
||||
}, [open, tier, collapsedToChip, mobileSheetState, closeAndRestoreFocus, setCollapsedToChip]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) pendingTarget.current = null;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !pendingTarget.current) return;
|
||||
const target = pendingTarget.current;
|
||||
let attempts = 0;
|
||||
let timer = 0;
|
||||
let cancelled = false;
|
||||
let removeHighlight: (() => void) | null = null;
|
||||
const findAndHighlight = () => {
|
||||
if (cancelled) return;
|
||||
removeHighlight = highlightTarget(target);
|
||||
if (removeHighlight) {
|
||||
pendingTarget.current = null;
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts < 40) timer = window.setTimeout(findAndHighlight, 50);
|
||||
else pendingTarget.current = null;
|
||||
};
|
||||
timer = window.setTimeout(findAndHighlight, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
removeHighlight?.();
|
||||
};
|
||||
}, [open, targetRequest]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function goToStepRoute() {
|
||||
pendingTarget.current = step.target ?? null;
|
||||
navigate(step.route(manifest));
|
||||
// A navigation to the already-active route does not necessarily change pathname.
|
||||
// This request counter guarantees the bounded target lookup still runs.
|
||||
setTargetRequest((request) => request + 1);
|
||||
if (tier === "standard") setCollapsedToChip(true);
|
||||
if (tier === "mobile") setMobileSheetState("collapsed");
|
||||
}
|
||||
|
||||
function handlePrimaryAction() {
|
||||
completeAndAdvance();
|
||||
if (isLastStep) closeAndRestoreFocus();
|
||||
}
|
||||
|
||||
async function handleRestartDemo() {
|
||||
setResetError(null);
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.post("/api/v1/demo/reset");
|
||||
restart();
|
||||
refresh();
|
||||
closeAndRestoreFocus();
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(describeApiError(t, err, "guide.restartFailed"));
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (tier === "standard" && collapsedToChip) {
|
||||
return (
|
||||
<div className="demo-guide-chip">
|
||||
<button
|
||||
type="button"
|
||||
className="demo-guide-chip-expand"
|
||||
onClick={() => setCollapsedToChip(false)}
|
||||
aria-label={`${t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}, ${t("guide.expand")}`}
|
||||
>
|
||||
<Icon name="spark" />
|
||||
{t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}
|
||||
<Icon name="chevron" />
|
||||
</button>
|
||||
<button type="button" className="demo-guide-chip-close" onClick={closeAndRestoreFocus} aria-label={t("guide.close")}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const panelClassName = [
|
||||
"demo-guide-panel",
|
||||
tier === "wide" ? "is-wide" : "",
|
||||
tier === "mobile" ? `is-mobile sheet-${mobileSheetState}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<aside className={panelClassName} role="dialog" aria-label={t("guide.dialogLabel")}>
|
||||
{tier === "mobile" && (
|
||||
<button
|
||||
type="button"
|
||||
className="demo-guide-sheet-handle"
|
||||
onClick={() =>
|
||||
setMobileSheetState((s) => (s === "collapsed" ? "half" : s === "half" ? "full" : "collapsed"))
|
||||
}
|
||||
aria-label={
|
||||
mobileSheetState === "full"
|
||||
? t("guide.collapse")
|
||||
: t("guide.expand")
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<header className="demo-guide-header">
|
||||
<div>
|
||||
<p className="demo-guide-kicker">{t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</p>
|
||||
<h2>{t(`guide.steps.${step.id}.title`)}</h2>
|
||||
</div>
|
||||
{tier !== "mobile" && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeAndRestoreFocus}
|
||||
aria-label={tier === "standard" ? t("guide.collapse") : t("guide.close")}
|
||||
>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{mobileSheetState !== "collapsed" && (
|
||||
<>
|
||||
<div className="demo-guide-progress-bar" aria-hidden="true">
|
||||
{DEMO_GUIDE_STEPS.map((s, index) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className={
|
||||
index === currentIndex ? "is-current" : completed.has(s.id) ? "is-done" : ""
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(mobileSheetState !== "half" || tier !== "mobile") && (
|
||||
<div className="demo-guide-body">
|
||||
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
|
||||
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
|
||||
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
|
||||
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`, { productName: PRODUCT_NAME })}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tier !== "mobile" && (
|
||||
<nav className="demo-guide-steps" aria-label={t("guide.allStepsLabel")}>
|
||||
{DEMO_GUIDE_STEPS.map((s, index) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={index === currentIndex ? "is-current" : ""}
|
||||
onClick={() => goToStep(index)}
|
||||
>
|
||||
{completed.has(s.id) && <Icon name="check" />}
|
||||
{t(`guide.steps.${s.id}.title`)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<ApiErrorNotice error={resetError} />
|
||||
|
||||
<footer className="demo-guide-footer">
|
||||
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
||||
{t("guide.goToStep")}
|
||||
</button>
|
||||
<button type="button" className="button button-primary" onClick={handlePrimaryAction}>
|
||||
{t(isLastStep ? "guide.finish" : "guide.next")}
|
||||
</button>
|
||||
{tier !== "mobile" && (
|
||||
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
|
||||
{resetting ? t("guide.restarting") : t("guide.restart")}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export type IconName =
|
||||
| "activity"
|
||||
| "alert"
|
||||
| "arrow-left"
|
||||
| "audit"
|
||||
| "bookings"
|
||||
| "check"
|
||||
| "chevron"
|
||||
| "clock"
|
||||
| "fleet"
|
||||
| "integrations"
|
||||
| "knowledge"
|
||||
| "logout"
|
||||
| "menu"
|
||||
| "quality"
|
||||
| "search"
|
||||
| "shield"
|
||||
| "spark"
|
||||
| "user"
|
||||
| "x";
|
||||
|
||||
const paths: Record<IconName, JSX.Element> = {
|
||||
activity: <><path d="M3 12h4l2.5-6 5 12 2.5-6h4" /></>,
|
||||
alert: <><path d="M10.3 3.4 2.6 17a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 3.4a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></>,
|
||||
"arrow-left": <><path d="m15 18-6-6 6-6" /></>,
|
||||
audit: <><path d="M9 4h6" /><path d="M9 2h6v4H9z" /><path d="M7 4H5a2 2 0 0 0-2 2v14h18V6a2 2 0 0 0-2-2h-2" /><path d="m8 13 2 2 5-5" /></>,
|
||||
bookings: <><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M16 3v4M8 3v4M3 10h18" /></>,
|
||||
check: <><path d="m5 12 4 4L19 6" /></>,
|
||||
chevron: <><path d="m9 18 6-6-6-6" /></>,
|
||||
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
|
||||
fleet: <><path d="m5 16-1 3h2" /><path d="m19 16 1 3h-2" /><path d="M4 16h16l-1.4-6.2A2.3 2.3 0 0 0 16.4 8H7.6a2.3 2.3 0 0 0-2.2 1.8L4 16Z" /><path d="M6 13h12" /><circle cx="7" cy="16" r="1" /><circle cx="17" cy="16" r="1" /></>,
|
||||
integrations: <><circle cx="12" cy="12" r="3" /><circle cx="5" cy="6" r="2" /><circle cx="19" cy="6" r="2" /><circle cx="12" cy="20" r="2" /><path d="m7 7.5 2.7 2.3m4.6 0L17 7.5M12 15v3" /></>,
|
||||
knowledge: <><path d="M4 5.5A3.5 3.5 0 0 1 7.5 2H11v18H7.5A3.5 3.5 0 0 0 4 23V5.5Z" /><path d="M20 5.5A3.5 3.5 0 0 0 16.5 2H13v18h3.5A3.5 3.5 0 0 1 20 23V5.5Z" /></>,
|
||||
logout: <><path d="M10 17l5-5-5-5M15 12H3" /><path d="M14 4h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5" /></>,
|
||||
menu: <><path d="M4 7h16M4 12h16M4 17h16" /></>,
|
||||
quality: <><path d="m12 3 2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7L12 3Z" /></>,
|
||||
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>,
|
||||
shield: <><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" /><path d="m9 12 2 2 4-4" /></>,
|
||||
spark: <><path d="m12 3 1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z" /><path d="m19 15 .8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" /></>,
|
||||
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>,
|
||||
x: <><path d="m6 6 12 12M18 6 6 18" /></>,
|
||||
};
|
||||
|
||||
export function Icon({ name, ...props }: { name: IconName } & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
focusable="false"
|
||||
{...props}
|
||||
>
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrandMark({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} aria-hidden="true" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M16 25.5c-4.4-5.1-6-8.2-6-11a6 6 0 1 1 12 0c0 2.8-1.6 5.9-6 11Z" fill="currentColor" />
|
||||
<circle cx="16" cy="14.3" r="2.1" fill="#2dd4bf" />
|
||||
<rect x="3" y="20.6" width="26" height="2.4" rx="1.2" fill="#2dd4bf" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
persistLanguage,
|
||||
SUPPORTED_LANGUAGES,
|
||||
type SupportedLanguage,
|
||||
} from "../i18n/config";
|
||||
|
||||
export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const current = (i18n.language as SupportedLanguage) || "nl-BE";
|
||||
|
||||
function handleChange(next: SupportedLanguage) {
|
||||
void i18n.changeLanguage(next);
|
||||
persistLanguage(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={`language-switcher ${compact ? "language-switcher-compact" : ""}`}>
|
||||
<span className="visually-hidden">{t("language.label")}</span>
|
||||
<select
|
||||
value={current}
|
||||
onChange={(event) => handleChange(event.target.value as SupportedLanguage)}
|
||||
aria-label={t("language.label")}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<option key={lang} value={lang}>
|
||||
{t(`language.${lang}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import type { Role, SearchResultItem } from "../api/types";
|
||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||
import { DemoBadge } from "./DemoBadge";
|
||||
import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
|
||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
import { ApiErrorNotice } from "./PageChrome";
|
||||
|
||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||
vehicle: "fleet",
|
||||
booking: "bookings",
|
||||
data_quality_issue: "quality",
|
||||
section: "chevron",
|
||||
};
|
||||
|
||||
// The backend only ever sends a stable code + raw data params (never English prose) --
|
||||
// see app/api/routers/search.py. Localizing here means the label/detail always follow
|
||||
// the operator's selected locale, in every namespace search results can point to.
|
||||
function searchResultLabel(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
|
||||
if (item.type === "section") return t(`navigation:items.${item.label}`, { defaultValue: item.label });
|
||||
return item.label;
|
||||
}
|
||||
|
||||
function searchResultDetail(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
|
||||
switch (item.type) {
|
||||
case "section":
|
||||
return t(`searchSections.${item.detail_code}`, { defaultValue: item.detail_code });
|
||||
case "vehicle":
|
||||
return t("searchVehicleSummary", { ...item.detail_params });
|
||||
case "booking":
|
||||
return t(`bookings:statuses.${item.detail_code}`, { defaultValue: item.detail_code });
|
||||
case "data_quality_issue":
|
||||
return t(`quality:ruleTypes.${item.detail_code}`, {
|
||||
defaultValue: item.detail_code.replace(/_/g, " "),
|
||||
});
|
||||
default:
|
||||
return item.detail_code;
|
||||
}
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
roles?: Role[];
|
||||
}
|
||||
|
||||
const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
|
||||
{
|
||||
labelKey: "groups.operate",
|
||||
items: [
|
||||
{ to: "/dashboard", labelKey: "items.overview", icon: "activity" },
|
||||
{ to: "/vehicles", labelKey: "items.fleet", icon: "fleet" },
|
||||
{ to: "/bookings", labelKey: "items.bookings", icon: "bookings" },
|
||||
{ to: "/data-quality", labelKey: "items.quality", icon: "quality", roles: ["operations_manager"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
labelKey: "groups.assure",
|
||||
items: [
|
||||
{ to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" },
|
||||
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
|
||||
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
|
||||
{ to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] },
|
||||
{ to: "/privacy", labelKey: "items.privacy", icon: "shield", roles: ["operations_manager"] },
|
||||
{ to: "/about", labelKey: "items.engineering", icon: "spark" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const { t } = useTranslation(["navigation", "common", "auth"]);
|
||||
const { user, logout, demoMode } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { open: guideOpen, collapsedToChip: guideCollapsed, restart: restartGuide } = useDemoGuide();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [resetConfirming, setResetConfirming] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
|
||||
const searchInput = useRef<HTMLInputElement>(null);
|
||||
const searchBox = useRef<HTMLDivElement>(null);
|
||||
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
NAV_GROUPS.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => !item.roles || (user && item.roles.includes(user.role))),
|
||||
})).filter((group) => group.items.length > 0),
|
||||
[user],
|
||||
);
|
||||
const mobileItems = useMemo(() => navGroups.flatMap((group) => group.items).slice(0, 5), [navGroups]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
function focusGlobalSearch(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
searchInput.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the shortcut before a nested control can consume it, and register before
|
||||
// paint so Ctrl/Cmd+K is available as soon as the visible shell is interactive.
|
||||
document.addEventListener("keydown", focusGlobalSearch, true);
|
||||
return () => document.removeEventListener("keydown", focusGlobalSearch, true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setSearchResults([]);
|
||||
setSearchLoading(false);
|
||||
setSearchError(false);
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
setSearchLoading(true);
|
||||
setSearchError(false);
|
||||
// Abort the in-flight request when the query changes so a slow response for "a"
|
||||
// can never overwrite the results for "ab" (or flip the loading state early).
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => {
|
||||
api
|
||||
.get<{ query: string; results: SearchResultItem[] }>(
|
||||
`/api/v1/search?q=${encodeURIComponent(query)}`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((response) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setSearchResults(response.results);
|
||||
setActiveIndex(-1);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setSearchError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setSearchLoading(false);
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
controller.abort();
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (searchBox.current && !searchBox.current.contains(event.target as Node)) {
|
||||
setSearchOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
async function handleDemoReset() {
|
||||
setResetError(null);
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.post("/api/v1/demo/reset");
|
||||
// Reset guide progress only after the server has confirmed that the data reset
|
||||
// committed. A failed reset must leave the visitor's current walkthrough intact.
|
||||
restartGuide();
|
||||
// The server invalidates the acting session as part of reset; drop local state the
|
||||
// same way an explicit logout would and return to the login screen.
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(describeApiError(t, err, "resetFailed"));
|
||||
setResetConfirming(false);
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectResult(item: SearchResultItem) {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
navigate(item.link);
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Escape") {
|
||||
setSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!searchOpen || searchResults.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % searchResults.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => (i <= 0 ? searchResults.length - 1 : i - 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const target = searchResults[activeIndex] ?? searchResults[0];
|
||||
if (target) selectResult(target);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<a className="skip-link" href="#main-content">{t("skipToContent")}</a>
|
||||
|
||||
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
|
||||
<div className="brand-lockup">
|
||||
<BrandMark className="brand-mark" />
|
||||
<div><strong>{PRODUCT_NAME}</strong><span>{t("common:brandTagline")}</span></div>
|
||||
</div>
|
||||
<div className="sidebar-language">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<nav aria-label={t("primaryNavLabel")}>
|
||||
{navGroups.map((group) => (
|
||||
<div className="nav-group" key={group.labelKey}>
|
||||
<p>{t(group.labelKey)}</p>
|
||||
<ul>
|
||||
{group.items.map((item) => (
|
||||
<li key={item.to}>
|
||||
<NavLink to={item.to} onClick={() => setMobileOpen(false)}>
|
||||
<Icon name={item.icon} />
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<span className="environment-dot" />
|
||||
<div><strong>{t("sidebarEnvironment")}</strong><span>{t("sidebarEnvironmentDetail")}</span></div>
|
||||
</div>
|
||||
{demoMode && user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
||||
<div className="sidebar-reset">
|
||||
<ApiErrorNotice error={resetError} />
|
||||
{!resetConfirming ? (
|
||||
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
||||
{t("resetDemoData")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="confirm-bar" role="alertdialog" aria-label={t("resetConfirmTitle")}>
|
||||
<p>{t("resetConfirmBody")}</p>
|
||||
<button type="button" onClick={handleDemoReset} disabled={resetting}>
|
||||
{resetting ? t("resetting") : t("resetConfirmYes")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setResetConfirming(false)} disabled={resetting}>
|
||||
{t("resetCancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{mobileOpen && <button className="nav-scrim" aria-label={t("closeNavigation")} onClick={() => setMobileOpen(false)} />}
|
||||
|
||||
<div className={`app-workspace ${guideOpen ? "guide-open" : ""} ${guideOpen && guideCollapsed ? "guide-collapsed" : ""}`}>
|
||||
<header className="topbar">
|
||||
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label={t("openNavigation")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<div className="global-search" role="search" ref={searchBox}>
|
||||
<Icon name="search" />
|
||||
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel", { productName: PRODUCT_NAME })}</label>
|
||||
<input
|
||||
id="global-search-input"
|
||||
ref={searchInput}
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="global-search-results"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeIndex >= 0 ? `search-result-${activeIndex}` : undefined}
|
||||
value={searchQuery}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value);
|
||||
setSearchOpen(true);
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<kbd>{t("searchShortcutHint")}</kbd>
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="search-results" id="global-search-results" role="listbox">
|
||||
{searchLoading && <p className="search-status">{t("searchSearching")}</p>}
|
||||
{!searchLoading && searchError && <p className="search-status">{t("searchUnavailable")}</p>}
|
||||
{!searchLoading && !searchError && searchResults.length === 0 && (
|
||||
<p className="search-status">{t("searchNoResults", { query: searchQuery.trim() })}</p>
|
||||
)}
|
||||
{!searchLoading &&
|
||||
!searchError &&
|
||||
searchResults.map((item, index) => (
|
||||
<button
|
||||
key={`${item.type}-${item.link}`}
|
||||
id={`search-result-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
type="button"
|
||||
className={`search-result ${index === activeIndex ? "is-active" : ""}`}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => selectResult(item)}
|
||||
>
|
||||
<Icon name={SEARCH_ICON[item.type]} />
|
||||
<span className="search-result-copy">
|
||||
<strong>{searchResultLabel(t, item)}</strong>
|
||||
<small>{searchResultDetail(t, item)}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-meta">
|
||||
{demoMode && <DemoGuideTrigger />}
|
||||
{demoMode && <DemoBadge />}
|
||||
{user && <LanguageSwitcher compact />}
|
||||
{user && (
|
||||
<details className="operator-menu">
|
||||
<summary className="operator">
|
||||
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
|
||||
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
|
||||
</summary>
|
||||
<div className="operator-popover">
|
||||
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
|
||||
<button className="operator-logout" type="button" onClick={handleLogout}>
|
||||
<Icon name="logout" /> {t("switchRole")}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content" tabIndex={-1}><Outlet /></main>
|
||||
<footer className="app-footer"><span>{t("common:footer.productLine", { productName: PRODUCT_NAME })}</span><span>{t("common:footer.locale")}</span></footer>
|
||||
</div>
|
||||
|
||||
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
|
||||
{mobileItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to}>
|
||||
<Icon name={item.icon} />
|
||||
<span>{t(item.labelKey)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<button type="button" onClick={() => setMobileOpen(true)}>
|
||||
<Icon name="menu" />
|
||||
<span>{t("more")}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{demoMode && <DemoGuide />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Icon, type IconName } from "./Icons";
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
{description && <p className="page-description">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="page-actions">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
headingId,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
headingId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 id={headingId}>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ label }: { label?: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="state-panel" role="status">
|
||||
<span className="spinner" aria-hidden="true" />
|
||||
<p>{label ?? t("states.loadingDefault")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
message,
|
||||
onRetry,
|
||||
retryLabel,
|
||||
}: {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="state-panel state-error" role="alert">
|
||||
<Icon name="alert" />
|
||||
<div>
|
||||
<strong>{t("states.errorTitle")}</strong>
|
||||
<p>{message}</p>
|
||||
{onRetry ? <button type="button" className="button button-secondary state-retry" onClick={onRetry}>{retryLabel ?? t("actions.retry")}</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared rendering for describeApiError()'s output: a localized title + explanation +
|
||||
// optional next step, with the raw backend/network text demoted to a "Technical
|
||||
// details" disclosure -- never shown as the primary message. See
|
||||
// frontend/src/api/errorMessages.ts and docs/fleet-ops-final-localization/audit.md.
|
||||
export function ApiErrorNotice({ error }: { error: import("../api/errorMessages").ApiErrorInfo | null }) {
|
||||
const { t } = useTranslation("common");
|
||||
if (!error) return null;
|
||||
return (
|
||||
<div className="error api-error-notice" role="alert">
|
||||
<strong>{error.title}</strong>
|
||||
<p>{error.explanation}</p>
|
||||
{error.nextStep && <p className="api-error-next-step">{error.nextStep}</p>}
|
||||
<details className="evidence-disclosure">
|
||||
<summary>{t("actions.technicalDetails")}</summary>
|
||||
<pre className="evidence-block">{error.technical}</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon = "check",
|
||||
title,
|
||||
detail,
|
||||
}: {
|
||||
icon?: IconName;
|
||||
title: string;
|
||||
detail: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="state-panel state-empty">
|
||||
<Icon name={icon} />
|
||||
<div><strong>{title}</strong><p>{detail}</p></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IntegrationMark({ kind }: { kind: "n8n" | "rag" | "mcp" }) {
|
||||
const labels = { n8n: "n8n", rag: "RA", mcp: "MC" };
|
||||
return <span className={`integration-mark integration-${kind}`} aria-hidden="true">{labels[kind]}</span>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
if (totalPages <= 1) return null;
|
||||
return (
|
||||
<nav className="pagination" aria-label={t("pagination.label")}>
|
||||
<button type="button" onClick={() => onPageChange(page - 1)} disabled={page <= 1}>
|
||||
{t("pagination.previous")}
|
||||
</button>
|
||||
<span aria-current="page">{t("pagination.pageOf", { page, total: totalPages })}</span>
|
||||
<button type="button" onClick={() => onPageChange(page + 1)} disabled={page >= totalPages}>
|
||||
{t("pagination.next")}
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { LoadingState } from "./PageChrome";
|
||||
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page">
|
||||
<LoadingState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { Icon } from "./Icons";
|
||||
import { StatusBadge } from "./Badge";
|
||||
import { ApiErrorNotice } from "./PageChrome";
|
||||
|
||||
function newIdempotencyKey(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `key-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) {
|
||||
const { t } = useTranslation("returns");
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
|
||||
const canSeeManagerEvidence = user?.role === "operations_manager";
|
||||
|
||||
function continueDemo() {
|
||||
completeAndAdvance();
|
||||
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
|
||||
navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel return-result success-panel" aria-labelledby="return-result-heading" aria-live="polite">
|
||||
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">{t("result.committedLocally")}</p><h2 id="return-result-heading">{t("result.heading")}</h2></div></div>
|
||||
<dl className="detail-grid">
|
||||
<div><dt>{t("result.inspection")}</dt><dd>{result.inspection_ref}</dd></div>
|
||||
<div><dt>{t("result.resultingStatus")}</dt><dd><StatusBadge status={result.resulting_vehicle_status} label={t(`fleet:statuses.${result.resulting_vehicle_status}`, { defaultValue: result.resulting_vehicle_status })} /></dd></div>
|
||||
<div>
|
||||
<dt>{t("result.qualityIssue")}</dt>
|
||||
<dd>
|
||||
{result.quality_issue_ref ? (
|
||||
canSeeManagerEvidence ? (
|
||||
<Link to={`/data-quality/${result.quality_issue_ref}`}>{result.quality_issue_ref}</Link>
|
||||
) : (
|
||||
result.quality_issue_ref
|
||||
)
|
||||
) : (
|
||||
t("result.noneCreated")
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("result.automationEvent")}</dt>
|
||||
<dd>{t("result.queuedForDelivery", { ref: result.workflow_event_id.slice(0, 8) })}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("result.nextBookingRisk")}</dt>
|
||||
<dd>
|
||||
{result.next_booking_risk
|
||||
? t("result.nextBookingRiskValue", {
|
||||
ref: result.next_booking_risk.booking_ref,
|
||||
status: result.next_booking_risk.at_risk ? t("result.atRisk") : t("result.lowRisk"),
|
||||
})
|
||||
: t("result.noUpcomingBooking")}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{result.odometer_regression && (
|
||||
<p className="error" role="alert">{t("result.odometerRegressionNotice")}</p>
|
||||
)}
|
||||
<div className="result-links">
|
||||
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>{t("result.viewVehicle", { ref: result.vehicle_ref })}<Icon name="chevron" /></Link>
|
||||
{canSeeManagerEvidence && <Link className="button button-secondary" to="/automation">{t("result.viewAutomation")}<Icon name="chevron" /></Link>}
|
||||
{canSeeManagerEvidence && <Link className="button button-secondary" to={`/audit?correlation_id=${result.correlation_id}`}>{t("result.viewTrace")}<Icon name="chevron" /></Link>}
|
||||
{guideOpen && (
|
||||
<button type="button" className="button button-primary" onClick={continueDemo}>
|
||||
{t("result.continueDemo")} <Icon name="chevron" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReturnForm({
|
||||
bookingRef,
|
||||
onRegistered,
|
||||
suggestedOdometerKm,
|
||||
}: {
|
||||
bookingRef: string;
|
||||
onRegistered: (result: RegisterReturnResult) => void;
|
||||
suggestedOdometerKm?: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["returns", "errors"]);
|
||||
const { formatNumber, formatDateTime } = useLocaleFormat();
|
||||
const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : "");
|
||||
const [fuel, setFuel] = useState("50");
|
||||
const [cleanlinessOk, setCleanlinessOk] = useState(true);
|
||||
const [damageReported, setDamageReported] = useState(false);
|
||||
const [technicalWarning, setTechnicalWarning] = useState(false);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
const [idempotencyKey] = useState(newIdempotencyKey);
|
||||
const [step, setStep] = useState<"capture" | "review">("capture");
|
||||
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
|
||||
|
||||
function currentBody(): RegisterReturnRequest {
|
||||
return {
|
||||
end_odometer_km: Number(odometer),
|
||||
fuel_level_percent: Number(fuel),
|
||||
cleanliness_ok: cleanlinessOk,
|
||||
damage_reported: damageReported,
|
||||
technical_warning: technicalWarning,
|
||||
notes: notes || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (step === "capture") {
|
||||
setError(null);
|
||||
setPreviewing(true);
|
||||
try {
|
||||
const evaluated = await api.post<ReturnPreviewResult>(
|
||||
`/api/v1/bookings/${bookingRef}/return-preview`,
|
||||
currentBody(),
|
||||
);
|
||||
setPreview(evaluated);
|
||||
setStep("review");
|
||||
} catch (err) {
|
||||
setError(describeApiError(t, err));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const registered = await api.post<RegisterReturnResult>(
|
||||
`/api/v1/bookings/${bookingRef}/return`,
|
||||
currentBody(),
|
||||
{ "Idempotency-Key": idempotencyKey },
|
||||
);
|
||||
onRegistered(registered);
|
||||
} catch (err) {
|
||||
setError(describeApiError(t, err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
|
||||
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
|
||||
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
|
||||
<ApiErrorNotice error={error} />
|
||||
|
||||
{step === "capture" ? <div className="return-capture">
|
||||
<div className="form-grid"><label>
|
||||
{t("capture.endOdometer")}
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={0}
|
||||
value={odometer}
|
||||
onChange={(e) => setOdometer(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t("capture.fuelLevel")}
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={0}
|
||||
max={100}
|
||||
value={fuel}
|
||||
onChange={(e) => setFuel(e.target.value)}
|
||||
/>
|
||||
</label></div>
|
||||
|
||||
<fieldset className="condition-fieldset"><legend>{t("capture.conditionLegend")}</legend><label className="checkbox-label check-card">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cleanlinessOk}
|
||||
onChange={(e) => setCleanlinessOk(e.target.checked)}
|
||||
/>
|
||||
{t("capture.cleanlinessOk")}
|
||||
</label>
|
||||
|
||||
<label className="checkbox-label check-card">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={damageReported}
|
||||
onChange={(e) => setDamageReported(e.target.checked)}
|
||||
/>
|
||||
{t("capture.damageReported")}
|
||||
</label>
|
||||
|
||||
<label className="checkbox-label check-card">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={technicalWarning}
|
||||
onChange={(e) => setTechnicalWarning(e.target.checked)}
|
||||
/>
|
||||
{t("capture.technicalWarning")}
|
||||
</label></fieldset>
|
||||
|
||||
<label>
|
||||
{t("capture.notes")}
|
||||
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} rows={3} />
|
||||
</label></div> : preview && <div className="return-review" aria-live="polite">
|
||||
<dl className="review-facts"><div><dt>{t("review.odometer")}</dt><dd>{formatNumber(Number(odometer))} km</dd></div><div><dt>{t("review.fuel")}</dt><dd>{fuel}%</dd></div><div><dt>{t("review.cleanliness")}</dt><dd>{cleanlinessOk ? t("review.cleanlinessAccepted") : t("review.cleanlinessFollowUp")}</dd></div><div><dt>{t("review.damage")}</dt><dd>{damageReported ? t("review.damageReported") : t("review.damageNone")}</dd></div><div><dt>{t("review.technicalWarning")}</dt><dd>{technicalWarning ? t("review.technicalWarningReported") : t("review.technicalWarningNone")}</dd></div></dl>
|
||||
<div className={`impact-preview ${preview.attention_reasons.length > 0 ? "impact-warning" : "impact-ready"}`}>
|
||||
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
|
||||
<div>
|
||||
<strong>{t("review.expectedState")} <StatusBadge status={preview.resulting_vehicle_status} label={t(`fleet:statuses.${preview.resulting_vehicle_status}`, { defaultValue: preview.resulting_vehicle_status })} /></strong>
|
||||
<p>{t(`review.reasonCodes.${preview.status_reason_code}`, {
|
||||
defaultValue: preview.status_reason,
|
||||
threshold_km:
|
||||
typeof preview.status_reason_params.threshold_km === "number"
|
||||
? formatNumber(preview.status_reason_params.threshold_km)
|
||||
: undefined,
|
||||
})}</p>
|
||||
<details className="evidence-disclosure">
|
||||
<summary>{t("common:actions.technicalDetails")}</summary>
|
||||
<pre className="evidence-block">{preview.status_reason}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{preview.odometer_regression && (
|
||||
<p className="error" role="alert">
|
||||
{t("review.odometerRegressionWarning", {
|
||||
submitted: formatNumber(preview.submitted_odometer_km),
|
||||
canonical: formatNumber(preview.canonical_odometer_km),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{preview.next_booking_risk && (
|
||||
<p className={preview.next_booking_risk.at_risk ? "error" : undefined} role={preview.next_booking_risk.at_risk ? "alert" : undefined}>
|
||||
{t(preview.next_booking_risk.at_risk ? "review.nextBookingRisk" : "review.nextBookingLowRisk", {
|
||||
ref: preview.next_booking_risk.booking_ref,
|
||||
when: formatDateTime(preview.next_booking_risk.starts_at),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<ul className="commit-list"><li><Icon name="check" /> {t("review.commitList.inspection")}</li><li><Icon name="check" /> {t("review.commitList.updateAtomic")}</li><li><Icon name="check" /> {t("review.commitList.queueAutomation")}</li></ul>
|
||||
</div>}
|
||||
|
||||
<div className="form-actions">
|
||||
{step === "review" && <button className="button button-secondary" type="button" onClick={() => setStep("capture")} disabled={submitting}><Icon name="arrow-left" /> {t("review.editDetails")}</button>}
|
||||
<button className="button button-primary" type="submit" disabled={submitting || previewing}>
|
||||
{step === "capture"
|
||||
? previewing
|
||||
? t("capture.evaluating")
|
||||
: t("capture.reviewReturn")
|
||||
: submitting
|
||||
? t("review.registering")
|
||||
: t("review.confirmReturn")}{" "}
|
||||
{step === "capture" && !previewing && <Icon name="chevron" />}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import type { MaintenanceRecord, VehicleDetail } from "../api/types";
|
||||
import { ApiErrorNotice } from "./PageChrome";
|
||||
import { brusselsLocalToIso, toBrusselsDate } from "../i18n/brusselsDateTime";
|
||||
|
||||
export function VehicleMaintenanceActions({ vehicle, onSaved }: { vehicle: VehicleDetail; onSaved: () => void }) {
|
||||
const { t } = useTranslation(["fleet", "errors"]);
|
||||
const [showRecord, setShowRecord] = useState(false);
|
||||
const [occurredAt, setOccurredAt] = useState(() => toBrusselsDate(new Date()));
|
||||
const [odometer, setOdometer] = useState(vehicle.odometer_km);
|
||||
const [category, setCategory] = useState("periodic_service");
|
||||
const [summary, setSummary] = useState("");
|
||||
const [nextService, setNextService] = useState(vehicle.next_service_km);
|
||||
const [releaseReason, setReleaseReason] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
|
||||
async function recordMaintenance(event: FormEvent) {
|
||||
event.preventDefault(); setSaving(true); setError(null);
|
||||
try {
|
||||
await api.post<MaintenanceRecord>(`/api/v1/vehicles/${vehicle.public_ref}/maintenance`, {
|
||||
occurred_at: brusselsLocalToIso(`${occurredAt}T12:00`), odometer_km: odometer,
|
||||
category, summary, next_service_km: nextService, mark_maintenance: true,
|
||||
});
|
||||
setShowRecord(false); setSummary(""); onSaved();
|
||||
} catch (err) { setError(describeApiError(t, err, "fleet:maintenance.failed")); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
async function release(event: FormEvent) {
|
||||
event.preventDefault(); setSaving(true); setError(null);
|
||||
try {
|
||||
await api.post(`/api/v1/vehicles/${vehicle.public_ref}/release`, { reason: releaseReason });
|
||||
setReleaseReason(""); onSaved();
|
||||
} catch (err) { setError(describeApiError(t, err, "fleet:maintenance.releaseFailed")); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return <section className="record-surface maintenance-actions">
|
||||
<div className="section-heading"><div><h2>{t("maintenance.actionsTitle")}</h2><p>{t("maintenance.actionsDescription")}</p></div>{!showRecord && <button type="button" className="button button-secondary" onClick={() => setShowRecord(true)}>{t("maintenance.add")}</button>}</div>
|
||||
<ApiErrorNotice error={error} />
|
||||
{showRecord && <form onSubmit={recordMaintenance}>
|
||||
<div className="form-grid">
|
||||
<label>{t("maintenance.date")}<input type="date" required value={occurredAt} onChange={(event) => setOccurredAt(event.target.value)} /></label>
|
||||
<label>{t("maintenance.odometer")}<input type="number" min={vehicle.odometer_km} required value={odometer} onChange={(event) => setOdometer(Number(event.target.value))} /></label>
|
||||
<label>{t("maintenance.category")}<select value={category} onChange={(event) => setCategory(event.target.value)}>{["periodic_service", "repair", "inspection", "tyres", "other"].map((value) => <option value={value} key={value}>{t(`detail.maintenanceCategories.${value}`, { defaultValue: value })}</option>)}</select></label>
|
||||
<label>{t("maintenance.nextService")}<input type="number" min={odometer} required value={nextService} onChange={(event) => setNextService(Number(event.target.value))} /></label>
|
||||
</div>
|
||||
<label>{t("maintenance.summary")}<textarea required minLength={3} maxLength={2000} value={summary} onChange={(event) => setSummary(event.target.value)} /></label>
|
||||
<div className="form-actions"><button type="button" className="button button-secondary" onClick={() => setShowRecord(false)}>{t("maintenance.cancel")}</button><button className="button button-primary" disabled={saving}>{saving ? t("maintenance.saving") : t("maintenance.save")}</button></div>
|
||||
</form>}
|
||||
{["cleaning", "maintenance", "blocked"].includes(vehicle.operational_status) && <form className="release-form" onSubmit={release}><label>{t("maintenance.releaseReason")}<input required minLength={3} maxLength={500} value={releaseReason} onChange={(event) => setReleaseReason(event.target.value)} placeholder={t("maintenance.releasePlaceholder")} /></label><button className="button button-primary" disabled={saving || releaseReason.trim().length < 3}>{t("maintenance.release")}</button></form>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { api, ApiError, onUnauthorized } from "../api/client";
|
||||
import type { CurrentUser, Role, SystemStatus } from "../api/types";
|
||||
|
||||
interface AuthState {
|
||||
user: CurrentUser | null;
|
||||
loading: boolean;
|
||||
demoMode: boolean;
|
||||
oidcEnabled: boolean;
|
||||
oidcProviderName: string | null;
|
||||
loginAs: (role: Role) => Promise<void>;
|
||||
loginWithPassword: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = "mobilityops.demo-user";
|
||||
|
||||
function readCachedUser(): CurrentUser | null {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return stored ? (JSON.parse(stored) as CurrentUser) : null;
|
||||
} catch {
|
||||
// A corrupted or blocked sessionStorage must never prevent the app from booting;
|
||||
// the session endpoint remains the source of truth.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheUser(user: CurrentUser | null) {
|
||||
try {
|
||||
if (user) {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Session storage is only a paint optimisation. Browser privacy settings or a full
|
||||
// storage quota must never turn a successful server-side login/logout into a client
|
||||
// failure; the HttpOnly session cookie remains authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// The cached value only avoids a login-screen flash while the server round trip below is
|
||||
// in flight; it is never trusted on its own. `loading` stays true until the server has
|
||||
// confirmed (or rejected) the session cookie.
|
||||
const [user, setUser] = useState<CurrentUser | null>(() => readCachedUser());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [demoMode, setDemoMode] = useState(true);
|
||||
const [oidcEnabled, setOidcEnabled] = useState(false);
|
||||
const [oidcProviderName, setOidcProviderName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// System capabilities and session identity are independent reads. In particular, a
|
||||
// slow public status endpoint must not hold an already valid session (or the login
|
||||
// controls) behind its timeout.
|
||||
void api.get<SystemStatus>("/api/v1/system/status", { signal: controller.signal })
|
||||
.then((system) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setDemoMode(system.demo_mode);
|
||||
setOidcEnabled(system.oidc_enabled);
|
||||
setOidcProviderName(system.oidc_provider_name);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setDemoMode(true);
|
||||
});
|
||||
|
||||
void api
|
||||
.get<CurrentUser>("/api/v1/auth/session", { signal: controller.signal })
|
||||
.then((confirmed) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setUser(confirmed);
|
||||
cacheUser(confirmed);
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setUser(null);
|
||||
cacheUser(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
onUnauthorized(() => {
|
||||
setUser(null);
|
||||
cacheUser(null);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const loginAs = useCallback(async (role: Role) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const loggedIn = await api.post<CurrentUser>("/api/v1/demo/login", { role });
|
||||
setUser(loggedIn);
|
||||
cacheUser(loggedIn);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loginWithPassword = useCallback(async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const loggedIn = await api.post<CurrentUser>("/api/v1/auth/login", { email, password });
|
||||
setUser(loggedIn);
|
||||
cacheUser(loggedIn);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
// A logout response expires an HttpOnly cookie. Chromium can expose a very small
|
||||
// race between resolving fetch() and applying that Set-Cookie header to an
|
||||
// immediate top-level navigation. Confirm the server now rejects the session
|
||||
// before allowing the router to continue; retrying logout is idempotent.
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await api.post("/api/v1/auth/logout");
|
||||
try {
|
||||
await api.get<CurrentUser>("/api/v1/auth/session");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best effort: the cookie is cleared server-side when it works, and the client has
|
||||
// still drops its own state when the request itself is unavailable.
|
||||
} finally {
|
||||
// Keep RequireAuth from redirecting to /login while the server-side invalidation
|
||||
// is still in flight. Otherwise a very fast navigation can race ahead of logout.
|
||||
setUser(null);
|
||||
cacheUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, demoMode, oidcEnabled, oidcProviderName, loginAs, loginWithPassword, logout }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
|
||||
const STORAGE_KEY = "mobilityops.demo-guide-progress";
|
||||
|
||||
interface StoredProgress {
|
||||
currentIndex: number;
|
||||
completed: string[];
|
||||
}
|
||||
|
||||
function readProgress(): StoredProgress {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { currentIndex: 0, completed: [] };
|
||||
const parsed = JSON.parse(raw) as Partial<StoredProgress>;
|
||||
const validStepIds = new Set(DEMO_GUIDE_STEPS.map((step) => step.id));
|
||||
const currentIndex = Number.isInteger(parsed.currentIndex)
|
||||
? Math.max(0, Math.min(parsed.currentIndex as number, DEMO_GUIDE_STEPS.length - 1))
|
||||
: 0;
|
||||
const completed = Array.isArray(parsed.completed)
|
||||
? parsed.completed.filter(
|
||||
(stepId): stepId is string => typeof stepId === "string" && validStepIds.has(stepId),
|
||||
)
|
||||
: [];
|
||||
return { currentIndex, completed: Array.from(new Set(completed)) };
|
||||
} catch {
|
||||
return { currentIndex: 0, completed: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function writeProgress(progress: StoredProgress) {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
|
||||
} catch {
|
||||
// Progress is a convenience. Blocked or full storage must never break the guide.
|
||||
}
|
||||
}
|
||||
|
||||
interface DemoGuideState {
|
||||
open: boolean;
|
||||
currentIndex: number;
|
||||
completed: Set<string>;
|
||||
totalSteps: number;
|
||||
collapsedToChip: boolean;
|
||||
setCollapsedToChip: (value: boolean) => void;
|
||||
openGuide: () => void;
|
||||
closeGuide: () => void;
|
||||
toggleGuide: () => void;
|
||||
goToStep: (index: number) => void;
|
||||
completeAndAdvance: () => void;
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
const DemoGuideContext = createContext<DemoGuideState | undefined>(undefined);
|
||||
|
||||
export function DemoGuideProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [completed, setCompleted] = useState<Set<string>>(new Set());
|
||||
const [collapsedToChip, setCollapsedToChip] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readProgress();
|
||||
setCurrentIndex(stored.currentIndex);
|
||||
setCompleted(new Set(stored.completed));
|
||||
}, []);
|
||||
|
||||
const persist = useCallback((index: number, done: Set<string>) => {
|
||||
writeProgress({ currentIndex: index, completed: Array.from(done) });
|
||||
}, []);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(index: number) => {
|
||||
const clamped = Math.max(0, Math.min(index, DEMO_GUIDE_STEPS.length - 1));
|
||||
setCurrentIndex(clamped);
|
||||
persist(clamped, completed);
|
||||
},
|
||||
[completed, persist],
|
||||
);
|
||||
|
||||
const completeAndAdvance = useCallback(() => {
|
||||
setCompleted((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(DEMO_GUIDE_STEPS[currentIndex].id);
|
||||
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
|
||||
persist(nextIndex, next);
|
||||
setCurrentIndex(nextIndex);
|
||||
return next;
|
||||
});
|
||||
}, [currentIndex, persist]);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
setCurrentIndex(0);
|
||||
setCompleted(new Set());
|
||||
writeProgress({ currentIndex: 0, completed: [] });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DemoGuideContext.Provider
|
||||
value={{
|
||||
open,
|
||||
currentIndex,
|
||||
completed,
|
||||
totalSteps: DEMO_GUIDE_STEPS.length,
|
||||
collapsedToChip,
|
||||
setCollapsedToChip,
|
||||
openGuide: () => {
|
||||
setCollapsedToChip(false);
|
||||
setOpen(true);
|
||||
},
|
||||
closeGuide: () => setOpen(false),
|
||||
toggleGuide: () => {
|
||||
setCollapsedToChip(false);
|
||||
setOpen((v) => !v);
|
||||
},
|
||||
goToStep,
|
||||
completeAndAdvance,
|
||||
restart,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DemoGuideContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDemoGuide(): DemoGuideState {
|
||||
const ctx = useContext(DemoGuideContext);
|
||||
if (!ctx) throw new Error("useDemoGuide must be used within DemoGuideProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { DemoManifest } from "../api/types";
|
||||
|
||||
interface DemoManifestState {
|
||||
manifest: DemoManifest | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const DemoManifestContext = createContext<DemoManifestState | undefined>(undefined);
|
||||
|
||||
export function DemoManifestProvider({ children }: { children: ReactNode }) {
|
||||
const [manifest, setManifest] = useState<DemoManifest | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
// Public endpoint by design: the demo-entry screen needs this before any session
|
||||
// exists, so it is never gated behind auth.
|
||||
api
|
||||
.get<DemoManifest>("/api/v1/demo/manifest", { signal: controller.signal })
|
||||
.then((result) => {
|
||||
if (!controller.signal.aborted) setManifest(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setManifest(null);
|
||||
setError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [version]);
|
||||
|
||||
return (
|
||||
<DemoManifestContext.Provider
|
||||
value={{ manifest, loading, error, refresh: () => setVersion((v) => v + 1) }}
|
||||
>
|
||||
{children}
|
||||
</DemoManifestContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDemoManifest(): DemoManifestState {
|
||||
const ctx = useContext(DemoManifestContext);
|
||||
if (!ctx) throw new Error("useDemoManifest must be used within DemoManifestProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type ViewportTier = "wide" | "standard" | "mobile";
|
||||
|
||||
// Breakpoints reuse the app's existing, already-measured layout thresholds (see
|
||||
// styles.css): 700px is where the sidebar collapses into the mobile bottom nav, and
|
||||
// 1440px is the largest width in the project's own responsive test matrix -- chosen as
|
||||
// the "extra-wide desktop" floor rather than an arbitrary new number.
|
||||
const MOBILE_QUERY = "(max-width: 700px)";
|
||||
const WIDE_QUERY = "(min-width: 1440px)";
|
||||
|
||||
function computeTier(): ViewportTier {
|
||||
if (typeof window === "undefined") return "standard";
|
||||
if (window.matchMedia(MOBILE_QUERY).matches) return "mobile";
|
||||
if (window.matchMedia(WIDE_QUERY).matches) return "wide";
|
||||
return "standard";
|
||||
}
|
||||
|
||||
export function useViewportTier(): ViewportTier {
|
||||
const [tier, setTier] = useState<ViewportTier>(computeTier);
|
||||
|
||||
useEffect(() => {
|
||||
const mobile = window.matchMedia(MOBILE_QUERY);
|
||||
const wide = window.matchMedia(WIDE_QUERY);
|
||||
const update = () => setTier(computeTier());
|
||||
mobile.addEventListener("change", update);
|
||||
wide.addEventListener("change", update);
|
||||
update();
|
||||
return () => {
|
||||
mobile.removeEventListener("change", update);
|
||||
wide.removeEventListener("change", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return tier;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const BRUSSELS_TIME_ZONE = "Europe/Brussels";
|
||||
|
||||
const partsFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: BRUSSELS_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
function partsAt(value: Date): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
partsFormatter.formatToParts(value)
|
||||
.filter((part) => part.type !== "literal")
|
||||
.map((part) => [part.type, part.value]),
|
||||
);
|
||||
}
|
||||
|
||||
export function toBrusselsDateTimeLocal(value: Date): string {
|
||||
const parts = partsAt(value);
|
||||
return `${toBrusselsDate(value)}T${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
|
||||
/** Render an instant as the Fleet Ops Europe/Brussels calendar date (YYYY-MM-DD). */
|
||||
export function toBrusselsDate(value: Date): string {
|
||||
const parts = partsAt(value);
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
export function brusselsDateTimeFromNow(hours: number): string {
|
||||
const value = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||
value.setUTCMinutes(0, 0, 0);
|
||||
return toBrusselsDateTimeLocal(value);
|
||||
}
|
||||
|
||||
/** Convert a Fleet Ops Europe/Brussels wall-clock value to an unambiguous UTC instant. */
|
||||
export function brusselsLocalToIso(value: string): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
|
||||
if (!match) throw new Error("Invalid Brussels date-time");
|
||||
const [, year, month, day, hour, minute] = match;
|
||||
const wallClockUtc = Date.UTC(+year, +month - 1, +day, +hour, +minute);
|
||||
let candidate = wallClockUtc;
|
||||
for (let iteration = 0; iteration < 3; iteration += 1) {
|
||||
const parts = partsAt(new Date(candidate));
|
||||
const represented = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute);
|
||||
candidate += wallClockUtc - represented;
|
||||
}
|
||||
const result = new Date(candidate);
|
||||
// Date.UTC normalises impossible calendar values and Brussels' spring transition
|
||||
// contains a wall-clock hour that does not exist. Never silently shift either one.
|
||||
if (toBrusselsDateTimeLocal(result) !== value) {
|
||||
throw new Error("Invalid or non-existent Brussels date-time");
|
||||
}
|
||||
return result.toISOString();
|
||||
}
|
||||
|
||||
export function tryBrusselsLocalToIso(value: string): string | null {
|
||||
try {
|
||||
return brusselsLocalToIso(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start of the given Brussels calendar day (YYYY-MM-DD) as a UTC ISO instant. */
|
||||
export function brusselsDayStartIso(day: string): string {
|
||||
return brusselsLocalToIso(`${day}T00:00`);
|
||||
}
|
||||
|
||||
/** Start of the day *after* the given Brussels calendar day, i.e. an exclusive upper bound. */
|
||||
export function brusselsNextDayStartIso(day: string): string {
|
||||
const [year, month, date] = day.split("-").map(Number);
|
||||
const next = new Date(Date.UTC(year, month - 1, date + 1));
|
||||
return brusselsLocalToIso(`${next.toISOString().slice(0, 10)}T00:00`);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import commonNl from "./locales/nl-BE/common.json";
|
||||
import authNl from "./locales/nl-BE/auth.json";
|
||||
import navigationNl from "./locales/nl-BE/navigation.json";
|
||||
import dashboardNl from "./locales/nl-BE/dashboard.json";
|
||||
import fleetNl from "./locales/nl-BE/fleet.json";
|
||||
import bookingsNl from "./locales/nl-BE/bookings.json";
|
||||
import returnsNl from "./locales/nl-BE/returns.json";
|
||||
import qualityNl from "./locales/nl-BE/quality.json";
|
||||
import knowledgeNl from "./locales/nl-BE/knowledge.json";
|
||||
import integrationsNl from "./locales/nl-BE/integrations.json";
|
||||
import auditNl from "./locales/nl-BE/audit.json";
|
||||
import demoNl from "./locales/nl-BE/demo.json";
|
||||
import errorsNl from "./locales/nl-BE/errors.json";
|
||||
import accessibilityNl from "./locales/nl-BE/accessibility.json";
|
||||
import operationsNl from "./locales/nl-BE/operations.json";
|
||||
import privacyNl from "./locales/nl-BE/privacy.json";
|
||||
|
||||
import commonEn from "./locales/en-GB/common.json";
|
||||
import authEn from "./locales/en-GB/auth.json";
|
||||
import navigationEn from "./locales/en-GB/navigation.json";
|
||||
import dashboardEn from "./locales/en-GB/dashboard.json";
|
||||
import fleetEn from "./locales/en-GB/fleet.json";
|
||||
import bookingsEn from "./locales/en-GB/bookings.json";
|
||||
import returnsEn from "./locales/en-GB/returns.json";
|
||||
import qualityEn from "./locales/en-GB/quality.json";
|
||||
import knowledgeEn from "./locales/en-GB/knowledge.json";
|
||||
import integrationsEn from "./locales/en-GB/integrations.json";
|
||||
import auditEn from "./locales/en-GB/audit.json";
|
||||
import demoEn from "./locales/en-GB/demo.json";
|
||||
import errorsEn from "./locales/en-GB/errors.json";
|
||||
import accessibilityEn from "./locales/en-GB/accessibility.json";
|
||||
import operationsEn from "./locales/en-GB/operations.json";
|
||||
import privacyEn from "./locales/en-GB/privacy.json";
|
||||
|
||||
import commonFr from "./locales/fr-BE/common.json";
|
||||
import authFr from "./locales/fr-BE/auth.json";
|
||||
import navigationFr from "./locales/fr-BE/navigation.json";
|
||||
import dashboardFr from "./locales/fr-BE/dashboard.json";
|
||||
import fleetFr from "./locales/fr-BE/fleet.json";
|
||||
import bookingsFr from "./locales/fr-BE/bookings.json";
|
||||
import returnsFr from "./locales/fr-BE/returns.json";
|
||||
import qualityFr from "./locales/fr-BE/quality.json";
|
||||
import knowledgeFr from "./locales/fr-BE/knowledge.json";
|
||||
import integrationsFr from "./locales/fr-BE/integrations.json";
|
||||
import auditFr from "./locales/fr-BE/audit.json";
|
||||
import demoFr from "./locales/fr-BE/demo.json";
|
||||
import errorsFr from "./locales/fr-BE/errors.json";
|
||||
import accessibilityFr from "./locales/fr-BE/accessibility.json";
|
||||
import operationsFr from "./locales/fr-BE/operations.json";
|
||||
import privacyFr from "./locales/fr-BE/privacy.json";
|
||||
|
||||
export const SUPPORTED_LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
|
||||
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
export const DEFAULT_LANGUAGE: SupportedLanguage = "nl-BE";
|
||||
export const LANGUAGE_STORAGE_KEY = "fleetops.language";
|
||||
|
||||
export const NAMESPACES = [
|
||||
"common",
|
||||
"auth",
|
||||
"navigation",
|
||||
"dashboard",
|
||||
"fleet",
|
||||
"bookings",
|
||||
"returns",
|
||||
"quality",
|
||||
"knowledge",
|
||||
"integrations",
|
||||
"audit",
|
||||
"demo",
|
||||
"errors",
|
||||
"accessibility",
|
||||
"operations",
|
||||
"privacy",
|
||||
] as const;
|
||||
|
||||
function readStoredLanguage(): SupportedLanguage {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
||||
if (stored && (SUPPORTED_LANGUAGES as readonly string[]).includes(stored)) {
|
||||
return stored as SupportedLanguage;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (e.g. private-mode edge cases) -- fall back to default.
|
||||
}
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
export function persistLanguage(language: SupportedLanguage): void {
|
||||
try {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
||||
} catch {
|
||||
// Best effort only; the in-memory i18next language still changes for this session.
|
||||
}
|
||||
}
|
||||
|
||||
void i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: readStoredLanguage(),
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
supportedLngs: SUPPORTED_LANGUAGES,
|
||||
ns: NAMESPACES,
|
||||
defaultNS: "common",
|
||||
// A missing key must never render silently as empty/blank text -- surface it loudly
|
||||
// (the key itself) so it's caught immediately in development and by the
|
||||
// translation-coverage test, rather than shipping a blank UI label.
|
||||
returnEmptyString: false,
|
||||
interpolation: { escapeValue: false },
|
||||
resources: {
|
||||
"nl-BE": {
|
||||
common: commonNl,
|
||||
auth: authNl,
|
||||
navigation: navigationNl,
|
||||
dashboard: dashboardNl,
|
||||
fleet: fleetNl,
|
||||
bookings: bookingsNl,
|
||||
returns: returnsNl,
|
||||
quality: qualityNl,
|
||||
knowledge: knowledgeNl,
|
||||
integrations: integrationsNl,
|
||||
audit: auditNl,
|
||||
demo: demoNl,
|
||||
errors: errorsNl,
|
||||
accessibility: accessibilityNl,
|
||||
operations: operationsNl,
|
||||
privacy: privacyNl,
|
||||
},
|
||||
"en-GB": {
|
||||
common: commonEn,
|
||||
auth: authEn,
|
||||
navigation: navigationEn,
|
||||
dashboard: dashboardEn,
|
||||
fleet: fleetEn,
|
||||
bookings: bookingsEn,
|
||||
returns: returnsEn,
|
||||
quality: qualityEn,
|
||||
knowledge: knowledgeEn,
|
||||
integrations: integrationsEn,
|
||||
audit: auditEn,
|
||||
demo: demoEn,
|
||||
errors: errorsEn,
|
||||
accessibility: accessibilityEn,
|
||||
operations: operationsEn,
|
||||
privacy: privacyEn,
|
||||
},
|
||||
"fr-BE": {
|
||||
common: commonFr,
|
||||
auth: authFr,
|
||||
navigation: navigationFr,
|
||||
dashboard: dashboardFr,
|
||||
fleet: fleetFr,
|
||||
bookings: bookingsFr,
|
||||
returns: returnsFr,
|
||||
quality: qualityFr,
|
||||
knowledge: knowledgeFr,
|
||||
integrations: integrationsFr,
|
||||
audit: auditFr,
|
||||
demo: demoFr,
|
||||
errors: errorsFr,
|
||||
accessibility: accessibilityFr,
|
||||
operations: operationsFr,
|
||||
privacy: privacyFr,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
document.documentElement.lang = i18n.language;
|
||||
i18n.on("languageChanged", (lng) => {
|
||||
document.documentElement.lang = lng;
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const TIME_ZONE = "Europe/Brussels";
|
||||
|
||||
export function useLocaleFormat() {
|
||||
const { i18n } = useTranslation();
|
||||
const locale = i18n.language || "nl-BE";
|
||||
|
||||
return {
|
||||
locale,
|
||||
formatDate(value: string | Date): string {
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: TIME_ZONE }).format(
|
||||
new Date(value),
|
||||
);
|
||||
},
|
||||
formatShortDate(value: string | Date): string {
|
||||
return new Intl.DateTimeFormat(locale, { day: "2-digit", month: "short", timeZone: TIME_ZONE }).format(
|
||||
new Date(value),
|
||||
);
|
||||
},
|
||||
formatDateTime(value: string | Date): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: TIME_ZONE,
|
||||
}).format(new Date(value));
|
||||
},
|
||||
formatTime(value: string | Date): string {
|
||||
return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone: TIME_ZONE }).format(
|
||||
new Date(value),
|
||||
);
|
||||
},
|
||||
formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat(locale).format(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"openRow": "Open: {{label}}",
|
||||
"reducedMotion": "Reduced motion active",
|
||||
"closeDialog": "Close dialog",
|
||||
"expandSection": "Expand section",
|
||||
"collapseSection": "Collapse section"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"eyebrow": "Assure / Immutable history",
|
||||
"title": "Audit trail",
|
||||
"description": "Trace important state changes, actors and correlation references.",
|
||||
"actionFilterLabel": "Action",
|
||||
"actionFilterPlaceholder": "e.g. demo_login",
|
||||
"actorFilterLabel": "Actor",
|
||||
"actorFilterPlaceholder": "Name or service",
|
||||
"entityFilterLabel": "Record",
|
||||
"entityFilterPlaceholder": "e.g. MO-016",
|
||||
"fromFilterLabel": "From",
|
||||
"toFilterLabel": "To",
|
||||
"managerOnly": "The audit trail is visible to Operations Managers only.",
|
||||
"managerOnlyDetail": "Audit history is visible to Operations Managers only.",
|
||||
"loading": "Loading audit trail…",
|
||||
"unavailable": "Audit trail is unavailable right now.",
|
||||
"empty": "No audit events found",
|
||||
"emptyDetail": "Adjust the action filter.",
|
||||
"relatedFilterActive": "Showing only events linked to this action ({{count}} related events).",
|
||||
"clearFilter": "Clear this filter",
|
||||
"count": "{{count}} events",
|
||||
"storedRendered": "UTC stored · Brussels rendered",
|
||||
"columns": {
|
||||
"when": "When",
|
||||
"actor": "Actor",
|
||||
"action": "Action",
|
||||
"entity": "Entity",
|
||||
"change": "Change",
|
||||
"followUp": "Follow-up",
|
||||
"details": "Details"
|
||||
},
|
||||
"viewRelatedEvents": "View related events",
|
||||
"noChangeDetail": "No recorded change detail.",
|
||||
"noFieldChange": "No field-level change detected.",
|
||||
"reference": "Reference",
|
||||
"relatedEventsCount": "{{count}} linked event",
|
||||
"relatedEventsCount_other": "{{count}} linked events",
|
||||
"showTechnicalEvents": "Show {{count}} technical event",
|
||||
"showTechnicalEvents_other": "Show {{count}} technical events",
|
||||
"hideTechnicalEvents": "Hide technical events",
|
||||
"technicalDetails": "Technical details",
|
||||
"fullReference": "Full reference",
|
||||
"correlationId": "Correlation ID",
|
||||
"diff": {
|
||||
"setTo": "{{field}}: set to {{value}}",
|
||||
"was": "{{field}}: was {{value}}",
|
||||
"changed": "{{field}}: {{before}} → {{after}}"
|
||||
},
|
||||
"actorTypes": {
|
||||
"user": "User",
|
||||
"system": "System",
|
||||
"service": "Service"
|
||||
},
|
||||
"fields": {
|
||||
"status": "Status",
|
||||
"operational_status": "Operational status",
|
||||
"registration_number": "Registration number",
|
||||
"make": "Make",
|
||||
"model": "Model",
|
||||
"location": "Location",
|
||||
"first_name": "First name",
|
||||
"last_name": "Last name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"postal_code": "Postal code",
|
||||
"city": "City",
|
||||
"booking_end_odometer_km": "Booking end odometer",
|
||||
"vehicle_odometer_km": "Vehicle odometer",
|
||||
"survivor": "Retained profile",
|
||||
"loser": "Merged profile"
|
||||
},
|
||||
"trace": {
|
||||
"eyebrow": "End-to-end trace",
|
||||
"title": "From return to automation",
|
||||
"description": "This view connects events sharing the same correlation ID into one verifiable operation.",
|
||||
"steps": {
|
||||
"return": { "title": "Return persisted", "complete": "Booking and inspection were processed atomically.", "pending": "Not processed yet." },
|
||||
"vehicle": { "title": "Fleet state derived", "complete": "The server persisted the resulting vehicle state.", "pending": "No state change found yet." },
|
||||
"outbox": { "title": "Automation queued", "complete": "The outbox job was created in the same transaction.", "pending": "No outbox job found." },
|
||||
"delivery": { "title": "External follow-up", "complete": "n8n confirmed the follow-up.", "pending": "Delivery is asynchronous and not yet confirmed in this audit trace." }
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"demo_login": "Logged in",
|
||||
"demo_logout": "Logged out",
|
||||
"demo_reset": "Demo data reset",
|
||||
"demo_data_seeded": "Demo data seeded",
|
||||
"return_registered": "Vehicle return registered",
|
||||
"vehicle_status_changed": "Vehicle status changed",
|
||||
"data_quality_issue_resolved": "Data-quality issue resolved",
|
||||
"data_quality_issue_deferred": "Data-quality issue deferred",
|
||||
"data_quality_issue_rejected": "Data-quality issue rejected",
|
||||
"data_quality_fields_provided": "Missing fields provided",
|
||||
"data_quality_odometer_corrected": "Odometer corrected",
|
||||
"data_quality_odometer_retained": "Canonical odometer retained",
|
||||
"data_quality_status_applied": "Recommended status applied",
|
||||
"data_quality_booking_blocked": "Booking blocked",
|
||||
"data_quality_scan_run": "Quality scan run",
|
||||
"customer_merged": "Customers merged",
|
||||
"workflow_retry": "Automation retried",
|
||||
"knowledge_question_asked": "Knowledge question asked",
|
||||
"mcp_tool_request": "MCP tool called"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"brandTagline": "Control Centre",
|
||||
"orgLine": "Demo organisation: {{orgName}} (fictional)",
|
||||
"headline1": "Every hand-off.",
|
||||
"headline2": "One clear view.",
|
||||
"defaultDescription": "{{productName}} brings vehicle, booking and operational data together, supports rental processes, detects data-quality problems and automates controlled follow-up steps.",
|
||||
"footnote": "Synthetic demo · no real customer or vehicle data · resettable at any time",
|
||||
"accessEyebrow": "Demo access",
|
||||
"accessHeading": "Choose how to start",
|
||||
"accessIntro": "No password needed. Each role opens a scoped synthetic environment — all workflows and controls are really implemented.",
|
||||
"startGuidedDemo": "Start guided demo",
|
||||
"guidedDemoChecking": "Preparing the guided demo…",
|
||||
"guidedDemoManifestUnavailable": "The guided-demo information could not be loaded.",
|
||||
"guidedDemoNotReady": "The guided demo is not ready to start right now.",
|
||||
"guidedDemoRetry": "Reload demo information",
|
||||
"startRecruiterTour": "See the highlights in 90 seconds",
|
||||
"exploreAsOperationsManager": "Explore as Operations Manager",
|
||||
"exploreAsOperationsManagerDetail": "Full overview, quality resolution and retries",
|
||||
"exploreAsRentalEmployee": "Explore as Rental Employee",
|
||||
"exploreAsRentalEmployeeDetail": "Bookings, returns, fleet and procedures",
|
||||
"safeByDesignTitle": "Safe by design",
|
||||
"safeByDesignDetail": "Every action is logged and is resettable in this demo.",
|
||||
"loginFailed": "The demo session could not be started. The API may be unreachable.",
|
||||
"roleOperationsManager": "Operations manager",
|
||||
"roleRentalEmployee": "Rental employee",
|
||||
"switchRole": "Switch role",
|
||||
"logout": "Log out",
|
||||
"emailLabel": "Email address",
|
||||
"passwordLabel": "Password",
|
||||
"signIn": "Sign in",
|
||||
"passwordLoginFailed": "Sign-in failed. Check your credentials.",
|
||||
"or": "or",
|
||||
"signInWithOrganization": "Sign in with {{provider}}"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"list": {
|
||||
"eyebrow": "Operations / Schedule",
|
||||
"title": "Bookings",
|
||||
"description": "Review active rental windows and upcoming vehicle commitments.",
|
||||
"create": "New booking",
|
||||
"searchLabel": "Search",
|
||||
"searchPlaceholder": "Booking, customer or vehicle",
|
||||
"statusLabel": "Status",
|
||||
"statusAll": "All statuses",
|
||||
"fromLabel": "From date",
|
||||
"toLabel": "Through date",
|
||||
"locationLabel": "Location",
|
||||
"locationPlaceholder": "Exact operating location",
|
||||
"sortLabel": "Sort",
|
||||
"sortOperational": "Operational priority",
|
||||
"sortAscending": "Start date ascending",
|
||||
"sortDescending": "Start date descending",
|
||||
"presetsLabel": "Quick filters",
|
||||
"todayPreset": "Today",
|
||||
"upcomingPreset": "Upcoming reservations",
|
||||
"clearFilters": "Clear filters",
|
||||
"loading": "Loading booking ledger…",
|
||||
"empty": "No bookings found",
|
||||
"emptyDetail": "Adjust the booking status filter.",
|
||||
"noMatch": "No matching bookings",
|
||||
"noMatchDetail": "Try a broader search term.",
|
||||
"unavailable": "Booking list is unavailable right now.",
|
||||
"count": "{{count}} bookings",
|
||||
"pageOf": "Page {{page}} of {{total}}",
|
||||
"columns": {
|
||||
"reference": "Reference",
|
||||
"customer": "Customer",
|
||||
"vehicle": "Vehicle",
|
||||
"window": "Window",
|
||||
"status": "Status"
|
||||
},
|
||||
"paginationLabel": "Booking pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"rangeOf": "{{from}}–{{to}} of {{total}}"
|
||||
},
|
||||
"create": {
|
||||
"backLink": "Booking ledger",
|
||||
"eyebrow": "Bookings / New reservation",
|
||||
"title": "Create booking",
|
||||
"description": "Select a customer and a vehicle that is available for the full rental window.",
|
||||
"startsAt": "Starts",
|
||||
"endsAt": "Ends",
|
||||
"customerSearch": "Search customer",
|
||||
"customerPlaceholder": "Name, email or customer reference",
|
||||
"customer": "Customer",
|
||||
"searchFirst": "Search for a customer first",
|
||||
"chooseCustomer": "Select a customer",
|
||||
"vehicle": "Available vehicle",
|
||||
"vehicleSearch": "Search vehicle",
|
||||
"vehiclePlaceholder": "Reference, make, model, registration or location",
|
||||
"locationUnknown": "location unknown",
|
||||
"loadingVehicles": "Checking availability…",
|
||||
"chooseVehicle": "Select a vehicle",
|
||||
"noVehicles": "No vehicle available for this window",
|
||||
"invalidLocalTime": "Choose a valid Europe/Brussels time. The skipped hour during the spring clock change does not exist.",
|
||||
"endAfterStart": "The booking end must be after its start.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Create booking",
|
||||
"saving": "Saving booking…",
|
||||
"failed": "The booking could not be created. Check the window and availability."
|
||||
},
|
||||
"statuses": {
|
||||
"reserved": "reserved",
|
||||
"active": "active",
|
||||
"returned": "returned",
|
||||
"cancelled": "cancelled",
|
||||
"blocked": "blocked"
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Check out vehicle",
|
||||
"description": "Record departure condition. Any exception automatically blocks the rental for follow-up.",
|
||||
"preparing": "Preparing departure inspection…",
|
||||
"odometer": "Start odometer",
|
||||
"fuel": "Fuel level (%)",
|
||||
"condition": "Departure condition",
|
||||
"clean": "Vehicle is clean",
|
||||
"damage": "Damage found",
|
||||
"warning": "Technical warning",
|
||||
"notes": "Notes",
|
||||
"submit": "Record inspection and start rental",
|
||||
"saving": "Recording checkout…",
|
||||
"failed": "The departure inspection could not be recorded.",
|
||||
"activatedTitle": "Rental started",
|
||||
"activatedDetail": "Departure inspection {{inspection}} was saved and the vehicle is now rented.",
|
||||
"blockedTitle": "Rental blocked",
|
||||
"blockedDetail": "Departure inspection {{inspection}} contains an exception. The vehicle was safely removed from service."
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Booking ledger",
|
||||
"eyebrow": "Bookings / Rental record",
|
||||
"notFound": "This booking could not be found.",
|
||||
"loading": "Loading booking record…",
|
||||
"customer": "Customer",
|
||||
"vehicle": "Vehicle",
|
||||
"starts": "Starts",
|
||||
"ends": "Ends",
|
||||
"startOdometer": "Start odometer",
|
||||
"endOdometer": "End odometer",
|
||||
"startOdometerPending": "Not yet recorded — trip hasn't started yet",
|
||||
"endOdometerPending": "Not yet recorded — not yet closed",
|
||||
"requirementsComplete": "Requirements complete",
|
||||
"requirementsAction": "Check requirements",
|
||||
"requirementsActionDetail": "The departure inspection remains locked until the driving licence and rental requirements are explicitly confirmed.",
|
||||
"requirementsConfirmation": "Check evidence",
|
||||
"requirementsConfirmationPlaceholder": "Record what was checked",
|
||||
"requirementsConfirm": "Confirm requirements",
|
||||
"requirementsSaving": "Confirming…",
|
||||
"requirementsFailed": "The requirements could not be confirmed.",
|
||||
"rescheduleAction": "Reschedule reservation",
|
||||
"rescheduleDetail": "Change the rental window. Availability is checked again before the change is saved.",
|
||||
"rescheduleReason": "Reason for change",
|
||||
"confirmReschedule": "Save new window",
|
||||
"rescheduling": "Saving window…",
|
||||
"rescheduleFailed": "The reservation could not be rescheduled.",
|
||||
"invalidScheduleWindow": "Choose valid Europe/Brussels times and make sure the end is after the start.",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"cancelAction": "Cancel booking",
|
||||
"cancelReason": "Reason",
|
||||
"cancelReasonPlaceholder": "Why is this booking being cancelled?",
|
||||
"confirmCancel": "Confirm cancellation",
|
||||
"cancelling": "Cancelling…",
|
||||
"cancelFailed": "The booking could not be cancelled."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"orgName": "Northstar Mobility",
|
||||
"brandTagline": "Control Centre",
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"retry": "Retry",
|
||||
"reset": "Reset",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"viewDetails": "View details",
|
||||
"technicalDetails": "Technical details"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading…",
|
||||
"saving": "Saving…",
|
||||
"error": "Something went wrong",
|
||||
"success": "Success",
|
||||
"noResults": "No results"
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Page navigation",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"pageOf": "Page {{page}} of {{total}}"
|
||||
},
|
||||
"states": {
|
||||
"loadingDefault": "Loading workspace…",
|
||||
"errorTitle": "We couldn't load this workspace."
|
||||
},
|
||||
"timezone": "Europe/Brussels",
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"nl-BE": "Nederlands",
|
||||
"en-GB": "English",
|
||||
"fr-BE": "Français"
|
||||
},
|
||||
"footer": {
|
||||
"productLine": "{{productName}} Demo",
|
||||
"locale": "Europe/Brussels · Synthetic demo data"
|
||||
},
|
||||
"demo": {
|
||||
"syntheticBadge": "Synthetic demo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"eyebrow": "Operations / Live overview",
|
||||
"greeting": {
|
||||
"morning": "Good morning",
|
||||
"afternoon": "Good afternoon",
|
||||
"evening": "Good evening",
|
||||
"night": "Welcome back"
|
||||
},
|
||||
"greetingBody": {
|
||||
"morning": "Here's today's fleet status.",
|
||||
"afternoon": "Here's the current overview of your fleet.",
|
||||
"evening": "Here's this evening's fleet overview.",
|
||||
"night": "Here's the latest overview of your fleet."
|
||||
},
|
||||
"description": "Readiness, exceptions and hand-offs across today's operation.",
|
||||
"viewFleet": "View fleet",
|
||||
"demoStart": {
|
||||
"title": "Try a demonstration scenario",
|
||||
"readyCount": "{{ready}} of {{total}} scenarios ready for demo.",
|
||||
"readyCountFallback": "Five focused scenarios.",
|
||||
"startGuide": "Start demo guide",
|
||||
"resumeGuide": "Continue demo guide ({{current}}/{{total}})",
|
||||
"viewScenarios": "View scenarios"
|
||||
},
|
||||
"readiness": {
|
||||
"title": "Fleet readiness",
|
||||
"description": "Live from persisted vehicle state",
|
||||
"openFleet": "Open fleet",
|
||||
"available": "Available",
|
||||
"rented": "Rented",
|
||||
"cleaning": "Cleaning",
|
||||
"maintenance": "Maintenance",
|
||||
"blocked": "Blocked"
|
||||
},
|
||||
"attention": {
|
||||
"title": "Attention queue",
|
||||
"description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions",
|
||||
"reviewQueue": "Review queue",
|
||||
"remaining_one": "One more attention item",
|
||||
"remaining_other": "{{count}} more attention items",
|
||||
"remainingHint": "Open the complete work queue",
|
||||
"filterPlaceholder": "Filter issues…",
|
||||
"filterAriaLabel": "Search attention queue",
|
||||
"severityAll": "All severity",
|
||||
"severityHigh": "Critical",
|
||||
"severityMedium": "Warning",
|
||||
"severityLow": "Info",
|
||||
"severityAriaLabel": "Severity",
|
||||
"tierNow": "Handle now",
|
||||
"tierToday": "Follow up today",
|
||||
"tierLater": "Review later",
|
||||
"empty": "No issues match this filter.",
|
||||
"openRecord": "Open {{title}}"
|
||||
},
|
||||
"movements": {
|
||||
"title": "Today's movements",
|
||||
"description": "Departures and returns in Europe/Brussels",
|
||||
"allBookings": "All bookings",
|
||||
"empty": "No movements scheduled today.",
|
||||
"departure": "departure",
|
||||
"return": "return",
|
||||
"openBooking": "Open booking {{ref}}"
|
||||
},
|
||||
"integrationPulse": {
|
||||
"title": "Integration pulse",
|
||||
"description": "Current evidence from connected services",
|
||||
"systemDetail": "System detail",
|
||||
"n8nTitle": "n8n delivery",
|
||||
"n8nSummary": "{{succeeded}} succeeded · {{failed}} failed",
|
||||
"n8nLatest": "Latest event {{ref}}",
|
||||
"n8nNoEvidence": "No workflow evidence recorded",
|
||||
"knowledgeTitle": "Knowledge assistant",
|
||||
"knowledgeSummary": "{{count}} procedures indexed",
|
||||
"knowledgeIndexUnknown": "{{count}} knowledge sources · index size not verifiable",
|
||||
"knowledgeUnavailable": "Health check unavailable",
|
||||
"mcpTitle": "MCP Hub",
|
||||
"mcpEnabled": "Registration enabled",
|
||||
"mcpNotConnected": "Not yet connected"
|
||||
},
|
||||
"recent": {
|
||||
"title": "Recent activity",
|
||||
"description": "Latest audited workflow changes",
|
||||
"empty": "No automation activity recorded."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"badge": {
|
||||
"trigger": "Synthetic demo",
|
||||
"dialogLabel": "About this demo environment",
|
||||
"close": "Close",
|
||||
"orgIntro": "<strong>{{orgName}}</strong> is a fictional organisation. All names, vehicles and bookings are synthetic.",
|
||||
"orgIntroFallback": "All names, vehicles and bookings in this environment are synthetic.",
|
||||
"realWorkflows": "The workflows, controls and automation are really implemented — only the data is invented.",
|
||||
"lastReset": "Last reset: <strong>{{when}}</strong> · this environment is resettable at any time.",
|
||||
"aboutLink": "About this demo",
|
||||
"unknown": "unknown"
|
||||
},
|
||||
"guide": {
|
||||
"dialogLabel": "Guided demo",
|
||||
"trigger": "Demo guide",
|
||||
"kicker": "Guided demo · step {{current}} of {{total}}",
|
||||
"whatYouWillSee": "What you'll see",
|
||||
"whyItMatters": "Why it matters",
|
||||
"startAction": "Get started",
|
||||
"expectedOutcome": "Expected outcome",
|
||||
"goToStep": "Go to this step",
|
||||
"next": "Next",
|
||||
"finish": "Finish demo",
|
||||
"close": "Close",
|
||||
"restart": "Prepare demo again",
|
||||
"restarting": "Restarting…",
|
||||
"restartFailed": "Could not restart the demo.",
|
||||
"allStepsLabel": "All steps",
|
||||
"progressChip": "Demo guide · step {{current}} of {{total}}",
|
||||
"expand": "Expand demo guide",
|
||||
"collapse": "Collapse demo guide",
|
||||
"steps": {
|
||||
"understand-state": {
|
||||
"title": "1. Understand the operational state",
|
||||
"whatYouWillSee": "The dashboard shows fleet readiness, open attention points and today's movements.",
|
||||
"whyItMatters": "An Operations Manager starts every day with this overview to decide where to step in.",
|
||||
"startAction": "Open the dashboard and review the attention queue and today's timeline.",
|
||||
"expectedOutcome": "You see which bookings, vehicles or data-quality issues need attention."
|
||||
},
|
||||
"open-booking": {
|
||||
"title": "2. Open the booking needing attention",
|
||||
"whatYouWillSee": "Booking BK-DEMO-RETURN, active and due for return today.",
|
||||
"whyItMatters": "Returns are the moment incorrect odometer readings or damage first become visible.",
|
||||
"startAction": "Open the booking from the dashboard or the booking list.",
|
||||
"expectedOutcome": "You see the booking details and the button to process the return."
|
||||
},
|
||||
"process-return": {
|
||||
"title": "3. Process a return with an odometer anomaly",
|
||||
"whatYouWillSee": "A return form pre-filled with a suspicious odometer reading below the last known reading.",
|
||||
"whyItMatters": "A falling odometer signals a data-entry mistake or a mixed-up vehicle — this must be caught before release.",
|
||||
"startAction": "Submit the return with the suggested value and review the server preview before confirming.",
|
||||
"expectedOutcome": "The return is processed, the vehicle gets an appropriate status, and a data-quality issue is created automatically."
|
||||
},
|
||||
"handle-quality-issue": {
|
||||
"title": "4. Handle the created data-quality issue",
|
||||
"whatYouWillSee": "A new 'odometer regression' issue at the top of the work queue.",
|
||||
"whyItMatters": "Every detected issue has one bounded resolution step — nothing is silently auto-corrected.",
|
||||
"startAction": "Open the data-quality overview and choose the newest issue.",
|
||||
"expectedOutcome": "You see the recommended action, choose a resolution, and the issue resolves with an audit trail."
|
||||
},
|
||||
"merge-duplicate": {
|
||||
"title": "5. Review and handle a possible duplicate customer",
|
||||
"whatYouWillSee": "Two customer profiles with the same email and phone number, compared side by side.",
|
||||
"whyItMatters": "Duplicate customers split booking history and confuse communication.",
|
||||
"startAction": "Compare both profiles and choose which one survives.",
|
||||
"expectedOutcome": "The profiles are merged, bookings are rewired, and the losing profile becomes a tombstone."
|
||||
},
|
||||
"ask-knowledge": {
|
||||
"title": "6. Ask the procedure assistant a question",
|
||||
"whatYouWillSee": "An answer with citation from the scoped demo knowledge base.",
|
||||
"whyItMatters": "Staff need a quick, grounded answer about procedures, without guessing.",
|
||||
"startAction": "Click one of the suggested questions, in the current interface language.",
|
||||
"expectedOutcome": "You see the answer, the procedure used, and the source text — or an honest 'insufficient information' if none exists."
|
||||
},
|
||||
"check-automation-audit": {
|
||||
"title": "7. Check automation and the audit trail",
|
||||
"whatYouWillSee": "The delivery status of your return's automation job, and its related audit events.",
|
||||
"whyItMatters": "Every important action must be traceable: who did what, when, and what followed.",
|
||||
"startAction": "Open Integrations to see delivery status, and Audit trail for the full record.",
|
||||
"expectedOutcome": "You see a successful (or recoverable) delivery and a readable audit trail of your actions."
|
||||
},
|
||||
"review-real-vs-simulated": {
|
||||
"title": "8. Review what's real, simulated, or not yet connected",
|
||||
"whatYouWillSee": "An overview of what's functionally implemented in this demo, what's synthetic, and which integrations aren't live yet.",
|
||||
"whyItMatters": "A demo is only convincing if visitors can verify for themselves what really works and what's still ahead.",
|
||||
"startAction": "Read the 'About this demo' page.",
|
||||
"expectedOutcome": "You can explain yourself what {{productName}} is and isn't, with no verbal explanation needed."
|
||||
}
|
||||
}
|
||||
},
|
||||
"highlights": {
|
||||
"eyebrow": "Recruiter highlights",
|
||||
"title": "The strongest choices in 90 seconds",
|
||||
"description": "Three short views show how {{productName}} combines operational reliability, controlled corrections and source-grounded AI.",
|
||||
"duration": "about 30 sec",
|
||||
"open": "View in the platform",
|
||||
"proofLabel": "Engineering proof",
|
||||
"return": { "title": "A return that cannot fail silently", "body": "See server-side impact validation, atomic persistence and an outbox that decouples external automation from the rental desk.", "proof": "Transaction · idempotency · outbox · degradation" },
|
||||
"quality": { "title": "Data quality with human control", "body": "Compare duplicate profiles, deliberately choose the surviving record and preserve booking and audit history.", "proof": "Explainable rules · preview · audit · rollback" },
|
||||
"knowledge": { "title": "AI that must show its sources", "body": "Ask a procedure question and inspect the answer, source excerpts and honest insufficient-evidence state.", "proof": "RAGcore · citations · safe degradation" },
|
||||
"storyTitle": "Want to see the choices behind the system?",
|
||||
"storyBody": "The Engineering page connects product flows to architecture, reliability and honest integration status.",
|
||||
"storyButton": "Open Engineering Story",
|
||||
"fullTourButton": "Start the complete demo"
|
||||
},
|
||||
"scenarios": {
|
||||
"eyebrow": "Demonstration scenarios",
|
||||
"title": "Try a demonstration scenario",
|
||||
"description": "Five focused scenarios that always use the same fixed bookings, customers and vehicles — always re-findable after a reset.",
|
||||
"loading": "Loading scenarios…",
|
||||
"unavailable": "The demo scenarios could not be loaded. Please try again.",
|
||||
"ready": "Ready for demo",
|
||||
"notReady": "Not available",
|
||||
"duration": "Duration",
|
||||
"durationValue": "± {{minutes}} min",
|
||||
"role": "Role",
|
||||
"demonstrates": "Demonstrates:",
|
||||
"requiresRole": "Requires role: {{roles}}.",
|
||||
"startScenario": "Start scenario",
|
||||
"roleOr": "{{a}} or {{b}}",
|
||||
"roles": {
|
||||
"operations_manager": "Operations Manager",
|
||||
"rental_employee": "Rental Employee"
|
||||
},
|
||||
"items": {
|
||||
"return-anomaly": {
|
||||
"title": "Return with an odometer anomaly",
|
||||
"problem": "A vehicle comes back with an odometer reading lower than the last recorded reading — a sign of a data-entry mistake or a mixed-up vehicle.",
|
||||
"demonstrates": "Return processing, automatic data-quality detection, and the audit trail it produces."
|
||||
},
|
||||
"duplicate-customer": {
|
||||
"title": "Merge a possible duplicate customer",
|
||||
"problem": "Two customer profiles share the same email address and phone number — likely the same person, registered twice.",
|
||||
"demonstrates": "Merging customers while preserving booking history and the audit trail."
|
||||
},
|
||||
"booking-overlap": {
|
||||
"title": "Resolve overlapping bookings",
|
||||
"problem": "One vehicle is double-booked for overlapping periods — a scheduling error that must be resolved before departure.",
|
||||
"demonstrates": "Detection and controlled resolution of scheduling conflicts."
|
||||
},
|
||||
"automation-retry": {
|
||||
"title": "Retry a failed automation job",
|
||||
"problem": "One earlier event could not be delivered to the automation job due to a simulated connection error.",
|
||||
"demonstrates": "Reliable delivery with bounded retries and visible failure status."
|
||||
},
|
||||
"knowledge-question": {
|
||||
"title": "Ask a procedure question",
|
||||
"problem": "An employee isn't sure which procedure applies to a specific operational situation.",
|
||||
"demonstrates": "Source-grounded answers from a scoped demo knowledge base."
|
||||
}
|
||||
},
|
||||
"blockedReasons": {
|
||||
"bookingNotFound": "Demo booking BK-DEMO-RETURN not found. {{resetHint}}",
|
||||
"bookingAlreadyProcessed": "This booking has already been processed since the last reset. {{resetHint}}",
|
||||
"duplicateIssueNotFound": "Demo issue DQ-DEMO-DUPLICATE not found. {{resetHint}}",
|
||||
"issueAlreadyResolved": "This issue has already been resolved since the last reset. {{resetHint}}",
|
||||
"overlapIssueNotFound": "Demo issue DQ-DEMO-OVERLAP not found. {{resetHint}}",
|
||||
"failedEventNotFound": "The simulated failed event was not found. {{resetHint}}",
|
||||
"eventAlreadyRecovered": "This event has already been recovered since the last reset. {{resetHint}}",
|
||||
"knowledgeUnavailable": "The demo knowledge base is currently unavailable.",
|
||||
"resetHint": "Reset the demo data to make this scenario available again."
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"eyebrow": "About this demo",
|
||||
"title": "What {{productName}} is and isn't",
|
||||
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
|
||||
"loading": "Loading demo information…",
|
||||
"unavailable": "The demo information could not be loaded. Please try again.",
|
||||
"ctaTitle": "Choose how much time you have",
|
||||
"ctaBody": "See the three strongest engineering moments in 90 seconds, or take the complete operational tour.",
|
||||
"ctaButton": "Start full demo",
|
||||
"quickTourButton": "See highlights · 90 sec",
|
||||
"scenarioButton": "Open scenario lab",
|
||||
"proofEyebrow": "Engineering evidence",
|
||||
"proofTitle": "Control, reliability and explainability",
|
||||
"proofDescription": "Every technical choice supports a concrete operational action.",
|
||||
"proofRulesTitle": "Rules stay with the domain",
|
||||
"proofRulesBody": "FastAPI governs returns, statuses and data-quality resolutions. n8n and prompts never make authoritative decisions.",
|
||||
"proofReliabilityTitle": "Commit first, orchestrate second",
|
||||
"proofReliabilityBody": "The operation commits atomically; an outbox then delivers to n8n with bounded retries. An outage never blocks the rental desk.",
|
||||
"proofAiTitle": "AI must show evidence",
|
||||
"proofAiBody": "RAGcore answers include sources. Without sufficient evidence the assistant does not invent an answer.",
|
||||
"architectureFlowTitle": "From user action to controlled follow-up",
|
||||
"architectureFlowDescription": "The system of record stays separate from external orchestration and AI.",
|
||||
"architectureFrontend": "Accessible React interface",
|
||||
"architectureApi": "FastAPI domain rules",
|
||||
"architectureDatabase": "PostgreSQL + audit trail",
|
||||
"architectureOutbox": "Reliable outbox",
|
||||
"architectureExternal": "External services: n8n, RAGcore and MCP Hub",
|
||||
"architectureZonesLabel": "Architecture responsibility boundaries",
|
||||
"architectureZoneIntent": "User intent",
|
||||
"architectureZoneTransaction": "Local transaction",
|
||||
"architectureZoneEdge": "Recoverable system edge",
|
||||
"architectureFlowLabel": "Interactive system flow from interface to external services",
|
||||
"architectureCommitBoundary": "After commit",
|
||||
"architectureInteractionHint": "Select a step to inspect its operational guarantee and control points.",
|
||||
"architectureSelectedStep": "Step {{current}} of {{total}}",
|
||||
"architectureBoundaryLabel": "Responsibility",
|
||||
"architectureEvidenceLabel": "Control points",
|
||||
"architectureDetails": {
|
||||
"Frontend": {
|
||||
"summary": "Makes role and intent explicit",
|
||||
"body": "Accessible forms collect validated input and show the impact before confirmation. The interface never decides a domain status on its own.",
|
||||
"proofOne": "Role-based route guard",
|
||||
"proofTwo": "Preview before confirmation"
|
||||
},
|
||||
"Api": {
|
||||
"summary": "Validates every operational rule",
|
||||
"body": "FastAPI enforces statuses, invariants and resolution rules at the API boundary, independently of what the browser submits.",
|
||||
"proofOne": "Pydantic validation",
|
||||
"proofTwo": "Server-side domain decision"
|
||||
},
|
||||
"Database": {
|
||||
"summary": "Commits data and audit atomically",
|
||||
"body": "PostgreSQL stores the operational change and its audit evidence in the same transaction, preventing a partial state change.",
|
||||
"proofOne": "UUID + public reference",
|
||||
"proofTwo": "UTC + unbroken audit trail"
|
||||
},
|
||||
"Outbox": {
|
||||
"summary": "Records follow-up work durably",
|
||||
"body": "The outbox record commits with the operation. A worker only delivers afterwards, idempotently and with bounded retries, to the orchestration layer.",
|
||||
"proofOne": "Post-commit delivery",
|
||||
"proofTwo": "Idempotency + bounded retries"
|
||||
},
|
||||
"External": {
|
||||
"summary": "Degrades without local data loss",
|
||||
"body": "n8n, RAGcore and MCP Hub have timeouts and visible health state. Failure remains recoverable and AI never answers without sufficient source evidence.",
|
||||
"proofOne": "Health state + timeouts",
|
||||
"proofTwo": "No answer without evidence"
|
||||
}
|
||||
},
|
||||
"verificationTitle": "Built to be verified",
|
||||
"verificationBody": "Domain rules, API contracts, degraded modes and the complete demo are tested automatically. The repository contains the exact acceptance commands and evidence bundle.",
|
||||
"problemTitle": "The fictional problem",
|
||||
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. {{productName}} shows how one connected system flags these problems early and lets them be resolved under control.",
|
||||
"scopeTitle": "Who it's for and its scope",
|
||||
"scopeBody": "This demo is for anyone who wants to see how {{productName}} tackles operational problems at a small rental company: Operations Managers and Rental Employees, and anyone evaluating the approach. The scope is deliberately focused on one connected proof of concept — no accounting, no payments, no public reservations, no full CRM or ERP.",
|
||||
"realTitle": "What really works",
|
||||
"realBody": "Everything below is functional code, not just a mockup: role-based access and sessions, vehicle and booking management, return processing with server-side validation, five data-quality rules each with its own resolution step, a full audit trail, automated delivery to n8n with bounded retries, Docker-based deployment, and an automated test suite (backend and Playwright end-to-end).",
|
||||
"syntheticTitle": "What's synthetic",
|
||||
"syntheticBody": "The organisation, all customers, vehicles, bookings, maintenance history, procedures in the knowledge base, and the pre-set-up scenarios are entirely invented. No data refers to a real person, vehicle or company; email addresses only use the {{testDomain}} test domain.",
|
||||
"architectureTitle": "Architecture in brief",
|
||||
"architectureBody": "A React/TypeScript frontend talks to a FastAPI backend (PostgreSQL via SQLAlchemy/Alembic migrations); important business rules live in the backend, not in n8n or in prompts. Returns and other events are committed locally first and only then delivered asynchronously to n8n through an automation job, so a temporary automation outage never blocks an operational action.",
|
||||
"securityTitle": "Security and access",
|
||||
"securityBody": "Access runs through signed, HTTP-only session cookies per role; each role is bound to a set of allowed routes, both enforced server-side and reflected in navigation. Important state changes are always checked and logged — never silently auto-corrected.",
|
||||
"testingTitle": "How this is tested",
|
||||
"testingBody": "An automated backend test suite covers business rules and API contracts; a full Playwright end-to-end suite covers the user flows, including this demo experience itself in three languages. Every change is also validated against a clean checkout (empty database, rebuilt from seed data) before deployment.",
|
||||
"integrationsTitle": "Integrations — honestly labelled",
|
||||
"integrationsDescription": "What's operational, what's demo mode, and what's not yet connected.",
|
||||
"resetTitle": "Restoring the demo environment",
|
||||
"resetBodyManager": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. Use <strong>Reset demo data</strong> in the sidebar to start over.",
|
||||
"resetBodyEmployee": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. An Operations Manager can reset the demo environment via the sidebar.",
|
||||
"limitationsTitle": "Limitations",
|
||||
"limitationsBody": "This is a focused operational platform, not a full ERP. Accounting, payments, public reservations and a generic CRM are deliberately out of scope. External integrations are assessed separately and honestly using recent evidence.",
|
||||
"unknown": "unknown"
|
||||
},
|
||||
"integrationSummary": {
|
||||
"titles": {
|
||||
"n8n": "Automation (n8n)",
|
||||
"ragcore": "Knowledge assistant (RAGcore)",
|
||||
"mcp_hub": "ITWorx MCP Hub"
|
||||
},
|
||||
"n8nDetail": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending.",
|
||||
"ragcoreDetail": "{{count}} procedures indexed in {{collection}}.",
|
||||
"ragcoreSourceDetail": "{{source_count}} managed source procedures available; RAGcore does not report a reliable index count.",
|
||||
"mcpDetailOperational": "Live controlled tool calls from the Hub are confirmed in the audit trail.",
|
||||
"mcpDetailPrepared": "Four read-only tools are contract-ready; no recent Hub call has been recorded."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"generic": {
|
||||
"title": "Something went wrong",
|
||||
"explanation": "This action could not be completed. Please try again."
|
||||
},
|
||||
"codes": {
|
||||
"VEHICLE_NOT_FOUND": {
|
||||
"title": "Vehicle not found",
|
||||
"explanation": "This vehicle could not be found. It may have been removed or the reference may be incorrect."
|
||||
},
|
||||
"BOOKING_NOT_FOUND": {
|
||||
"title": "Booking not found",
|
||||
"explanation": "This booking could not be found. It may have been removed or the reference may be incorrect."
|
||||
},
|
||||
"CUSTOMER_NOT_FOUND": {
|
||||
"title": "Customer not found",
|
||||
"explanation": "This customer record could not be found."
|
||||
},
|
||||
"CUSTOMER_ALREADY_MERGED": {
|
||||
"title": "Customer already merged",
|
||||
"explanation": "One of these customer records has already been merged into another record.",
|
||||
"nextStep": "Reload the issue; it may already be resolved."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"ISSUE_CHANGED": {
|
||||
"title": "Issue evidence has changed",
|
||||
"explanation": "New evidence was recorded while this correction was being prepared.",
|
||||
"nextStep": "Refresh the issue and review the current evidence before deciding again."
|
||||
},
|
||||
"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_EVENT_CORRELATION": {
|
||||
"title": "Invalid correlation reference",
|
||||
"explanation": "The automation callback does not contain a valid correlation reference."
|
||||
},
|
||||
"CALLBACK_EVENT_MISMATCH": {
|
||||
"title": "Callback event does not match",
|
||||
"explanation": "The automation callback refers to a different event than the event being updated."
|
||||
},
|
||||
"CALLBACK_CORRELATION_MISMATCH": {
|
||||
"title": "Callback trace does not match",
|
||||
"explanation": "The automation callback does not match the correlation reference stored for this event."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"UNKNOWN_WORKFLOW": {
|
||||
"title": "Unknown automation workflow",
|
||||
"explanation": "This workflow is not one of the four managed automation workflows."
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"list": {
|
||||
"eyebrow": "Fleet / Registry",
|
||||
"title": "Vehicle fleet",
|
||||
"description": "Live operational state, location and service readiness.",
|
||||
"searchLabel": "Search",
|
||||
"searchPlaceholder": "Reference, make or location",
|
||||
"statusLabel": "Status",
|
||||
"statusAll": "All statuses",
|
||||
"attentionOnly": "Attention only",
|
||||
"locationFilter": "Location",
|
||||
"locationPlaceholder": "Exact operating location",
|
||||
"serviceDue": "Service overdue",
|
||||
"serviceRemaining": "{{count}} km remaining",
|
||||
"attentionReasons": {
|
||||
"blocked_status": "Blocked status",
|
||||
"service_due": "Service overdue",
|
||||
"data_quality": "Open quality issue"
|
||||
},
|
||||
"loading": "Loading fleet registry…",
|
||||
"empty": "No vehicles found",
|
||||
"emptyDetail": "Adjust the current fleet filters.",
|
||||
"noMatch": "No matching vehicles",
|
||||
"noMatchDetail": "Try a broader search term.",
|
||||
"unavailable": "Vehicle list is unavailable right now.",
|
||||
"count": "{{count}} vehicles",
|
||||
"persisted": "Persisted fleet data",
|
||||
"columns": {
|
||||
"reference": "Reference",
|
||||
"makeModel": "Make / model",
|
||||
"location": "Location",
|
||||
"status": "Status",
|
||||
"odometer": "Odometer (km)",
|
||||
"service": "Service",
|
||||
"nextBooking": "Next booking",
|
||||
"attention": "Attention"
|
||||
},
|
||||
"needsAttention": "Needs attention"
|
||||
},
|
||||
"statuses": {
|
||||
"available": "available",
|
||||
"rented": "rented",
|
||||
"cleaning": "cleaning",
|
||||
"maintenance": "maintenance",
|
||||
"blocked": "blocked"
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Fleet registry",
|
||||
"eyebrow": "Fleet / Vehicle record",
|
||||
"notFound": "This vehicle could not be found.",
|
||||
"loading": "Loading vehicle record…",
|
||||
"needsAttention": "Needs attention",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"bookings": "Bookings",
|
||||
"inspections": "Inspections",
|
||||
"maintenance": "Maintenance",
|
||||
"quality": "Quality"
|
||||
},
|
||||
"tabsAriaLabel": "Vehicle sections",
|
||||
"overview": {
|
||||
"registration": "Registration",
|
||||
"modelYear": "Model year",
|
||||
"location": "Location",
|
||||
"odometer": "Odometer",
|
||||
"nextService": "Next service",
|
||||
"active": "Active"
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"noBookings": "No bookings recorded.",
|
||||
"noInspections": "No inspections recorded.",
|
||||
"noMaintenance": "No maintenance records.",
|
||||
"noQualityIssues": "No quality issues recorded.",
|
||||
"fuel": "Fuel {{percent}}%",
|
||||
"damage": "Damage",
|
||||
"technicalWarning": "Technical warning",
|
||||
"inspectionTypes": {
|
||||
"departure": "Departure inspection",
|
||||
"return": "Return inspection"
|
||||
},
|
||||
"maintenanceCategories": {
|
||||
"periodic_service": "Periodic service",
|
||||
"repair": "Repair",
|
||||
"inspection": "Technical inspection",
|
||||
"tyres": "Tyres",
|
||||
"other": "Other"
|
||||
}
|
||||
},
|
||||
"maintenance": { "actionsTitle": "Maintenance actions", "actionsDescription": "Record completed work or release a safe vehicle back into service.", "add": "Record maintenance", "date": "Date", "odometer": "Odometer", "category": "Category", "nextService": "Next service (km)", "summary": "Work completed", "cancel": "Cancel", "save": "Save maintenance", "saving": "Saving…", "failed": "Maintenance could not be saved.", "releaseReason": "Release reason", "releasePlaceholder": "Which check confirms the vehicle is safe for service?", "release": "Release vehicle", "releaseFailed": "The vehicle cannot be released yet." }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"eyebrow": "Assure / Integrations",
|
||||
"title": "Integration control",
|
||||
"description": "Monitor delivery health, graceful degradation and retryable workflow events.",
|
||||
"cards": {
|
||||
"checking": "Retrieving current integration status…",
|
||||
"orchestrationKicker": "Orchestration",
|
||||
"n8nTitle": "n8n delivery",
|
||||
"n8nSummary": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending · {{delivering}} delivering",
|
||||
"n8nFallback": "Return events are committed locally first and then delivered through the automation job.",
|
||||
"knowledgeKicker": "Knowledge",
|
||||
"knowledgeTitle": "Knowledge assistant",
|
||||
"knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.",
|
||||
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.",
|
||||
"knowledgeSummaryIndexUnknown": "RAGcore reachable · {{sourceCount}} source documents for this language · index size not verifiable for {{collection}}.",
|
||||
"knowledgeSummarySyncReported": "RAGcore reachable · {{sourceCount}} sources for this language · latest sync reported {{synced}} processed, {{failed}} failed at {{when}} · index size not verifiable for {{collection}}.",
|
||||
"knowledgeUnavailable": "Health evidence is currently unavailable.",
|
||||
"gatewayKicker": "Tool gateway",
|
||||
"mcpTitle": "MCP Hub",
|
||||
"mcpEnabled": "Registration is enabled for this deployment.",
|
||||
"mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub.",
|
||||
"mcpNoEvidence": "Registered, but no tool call has been recorded yet.",
|
||||
"mcpEvidence": "ITWorx MCP Hub last ran {{tool}} · {{count}} controlled calls in total",
|
||||
"mcpTechnicalIdentity": "Technical client identity",
|
||||
"mcpHubReachable": "Hub reachable",
|
||||
"mcpHubUnreachable": "Hub unreachable",
|
||||
"n8nDemoScenario": "Plus {{count}} prepared demo scenario — a simulated temporary failure, not an integration problem."
|
||||
},
|
||||
"statusLabels": {
|
||||
"checking": "Checking…",
|
||||
"notConnected": "Not connected",
|
||||
"deliveryFailed": "Delivery failed",
|
||||
"retryAvailable": "Retry available",
|
||||
"operational": "Operational",
|
||||
"prepared": "Prepared",
|
||||
"demoMode": "Demo mode",
|
||||
"unavailable": "Unavailable"
|
||||
},
|
||||
"workflows": {
|
||||
"title": "Automation workflows",
|
||||
"description": "{{known}} of {{expected}} canonical n8n workflows have live evidence of running.",
|
||||
"loadingEvidence": "Retrieving current workflow evidence…",
|
||||
"unavailable": "Workflow evidence is currently unavailable.",
|
||||
"notBuilt": "Not built yet",
|
||||
"noEvidence": "No evidence yet",
|
||||
"healthy": "Healthy",
|
||||
"stale": "Evidence is stale",
|
||||
"failed": "Latest execution failed",
|
||||
"executionId": "Execution",
|
||||
"columns": {
|
||||
"name": "Workflow",
|
||||
"status": "Status",
|
||||
"lastSeen": "Last evidence",
|
||||
"technicalId": "Details"
|
||||
},
|
||||
"names": {
|
||||
"vehicleReturn": "Vehicle return orchestration",
|
||||
"scheduledScan": "Scheduled data quality scan",
|
||||
"ragcoreSync": "Knowledge procedure sync",
|
||||
"errorHandler": "Workflow error handler"
|
||||
},
|
||||
"expectations": {
|
||||
"vehicleReturn": "Event-driven after a vehicle return is safely committed.",
|
||||
"scheduledScan": "Scheduled; normally runs every hour.",
|
||||
"ragcoreSync": "Scheduled; normally runs daily.",
|
||||
"errorHandler": "Runs only when a real workflow execution fails."
|
||||
},
|
||||
"errorHandler": {
|
||||
"summary": "{{count}} automation failure(s) registered — latest from {{workflow}} at {{when}}.",
|
||||
"summaryEmpty": "No automation failures have been registered."
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"title": "Automation jobs",
|
||||
"description": "Persisted automation attempts with the latest failure evidence.",
|
||||
"filterLabel": "View",
|
||||
"filterNeedsAttention": "Needs attention",
|
||||
"filterRecent": "Recent",
|
||||
"filterSucceeded": "Succeeded",
|
||||
"filterAll": "All",
|
||||
"statusFilterLabel": "Status",
|
||||
"statusAll": "All statuses",
|
||||
"statusPending": "Pending",
|
||||
"statusDelivering": "Delivering",
|
||||
"statusSucceeded": "Succeeded",
|
||||
"statusFailed": "Failed",
|
||||
"unavailable": "Automation jobs are unavailable right now.",
|
||||
"managerOnly": "Automation is visible to Operations Managers only.",
|
||||
"loading": "Loading automation jobs…",
|
||||
"empty": "No automation jobs match this filter.",
|
||||
"count": "{{count}} workflow events",
|
||||
"boundedRetries": "Bounded retries",
|
||||
"groupedSucceeded": "{{count}} succeeded vehicle-return jobs",
|
||||
"showIndividually": "Show individual jobs",
|
||||
"hideIndividually": "Hide individual jobs",
|
||||
"columns": {
|
||||
"event": "Job",
|
||||
"type": "Type",
|
||||
"booking": "Booking",
|
||||
"status": "Status",
|
||||
"attempts": "Attempts",
|
||||
"lastError": "Last error",
|
||||
"when": "When",
|
||||
"action": "Action"
|
||||
},
|
||||
"retry": "Retry",
|
||||
"retrying": "Retrying…",
|
||||
"retryFailed": "Could not retry this delivery.",
|
||||
"noAction": "—",
|
||||
"eventTypes": {
|
||||
"vehicle.returned.v1": "Vehicle return processed"
|
||||
},
|
||||
"errorCodes": {
|
||||
"connectionError": "The workflow service was temporarily unreachable. The return was safely saved and can be retried.",
|
||||
"malformedPayload": "The job contained incomplete data and could not be delivered. The underlying data remains safely stored.",
|
||||
"remoteReportedFailure": "The workflow service declined the delivery. The job can be retried.",
|
||||
"staleLeaseRecovered": "This job was recovered after an earlier delivery attempt stalled without a result.",
|
||||
"unknownError": "An unexpected error occurred while delivering this job.",
|
||||
"demoScenarioTimeout": "Simulated timeout towards the workflow service. This failure is part of the prepared demo scenario, not a real incident."
|
||||
},
|
||||
"demoScenarioBadge": "Prepared demo scenario",
|
||||
"demoScenarioExplanation": "Simulated temporary failure, planted by the demo data on purpose to show retry and audit. It does not affect the health of the automation.",
|
||||
"retryDemoScenario": "Retry demo scenario"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"eyebrow": "Assure / Grounded knowledge",
|
||||
"title": "Procedure knowledge",
|
||||
"description": "Ask operational questions. Answers are shown only when {{provider}} returns sufficient cited evidence.",
|
||||
"providerDemo": "the demo knowledge base",
|
||||
"providerRagcore": "RAGcore",
|
||||
"providerChecking": "the knowledge source",
|
||||
"providerUnavailable": "the knowledge service",
|
||||
"statusChecking": "Checking knowledge source…",
|
||||
"statusCheckingDetail": "Retrieving the current provider status.",
|
||||
"statusAvailable": "Available",
|
||||
"statusUnavailable": "Unavailable",
|
||||
"proceduresIndexed": "{{count}} procedures indexed",
|
||||
"proceduresIndexUnknown": "Index size is not available through this connection",
|
||||
"proceduresAvailable": "{{count}} managed source procedures available",
|
||||
"technicalDetails": "Technical provider details",
|
||||
"progress": { "retrieving": "Selecting sources", "checking": "Checking evidence", "composing": "Building answer" },
|
||||
"sourceTitles": {
|
||||
"damage-procedure": "Vehicle return damage",
|
||||
"vehicle-availability-procedure": "Restoring vehicle availability",
|
||||
"vehicle-return-procedure": "Registering a vehicle return",
|
||||
"odometer-anomaly-procedure": "Reviewing an odometer anomaly",
|
||||
"checkout-procedure": "Required checkout controls"
|
||||
},
|
||||
"statistics": {
|
||||
"scope": "Language scope: index and source counts apply to the current language; the n8n sync report covers all three languages.",
|
||||
"verifiedIndexed": "exactly matched and actively published in this language",
|
||||
"sourceDocuments": "source documents in this language",
|
||||
"reportedSynced": "sync reported by n8n",
|
||||
"reportedFailed": "sync failures reported",
|
||||
"lastSync": "Latest sync report: {{when}}. Index state is independently checked through each managed source's exact identity and active RAGcore version.",
|
||||
"noSyncReport": "No sync report has been received yet. Index evidence is checked separately through active RAGcore versions."
|
||||
},
|
||||
"providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.",
|
||||
"askHeading": "Ask a procedure question",
|
||||
"askSubheading": "Retrieval → evidence check → grounded answer",
|
||||
"questionLabel": "Question",
|
||||
"questionPlaceholder": "e.g. What must I do when a vehicle returns with damage?",
|
||||
"ask": "Ask",
|
||||
"asking": "Asking…",
|
||||
"askFailed": "Could not reach the knowledge service.",
|
||||
"feedbackFailed": "Could not save feedback.",
|
||||
"suggestedLabel": "Try one:",
|
||||
"suggestedQuestions": [
|
||||
"What must I do when a vehicle returns with damage?",
|
||||
"How do I register a vehicle return?",
|
||||
"When may a vehicle be made available again?",
|
||||
"Who reviews an unusual odometer reading?",
|
||||
"Which checks are required before checkout?"
|
||||
],
|
||||
"emptyTitle": "Evidence before answers",
|
||||
"emptyDescription": "Ask about returns, damage, inspections or another indexed procedure. {{productName}} will not invent an answer when evidence is missing.",
|
||||
"retrievalFlow": {
|
||||
"question": "Question",
|
||||
"sources": "Sources",
|
||||
"answer": "Answer"
|
||||
},
|
||||
"questionLabelExchange": "Question",
|
||||
"evidenceStates": {
|
||||
"grounded": "Grounded in cited procedures",
|
||||
"insufficient": "Insufficient evidence",
|
||||
"unavailable": "Knowledge service unavailable"
|
||||
},
|
||||
"unavailableBody": "The knowledge service is currently unreachable. Operational features are unaffected — try again later.",
|
||||
"sourceVersion": "v{{version}}",
|
||||
"answeredIn": "Answered in {{seconds}} s",
|
||||
"sourceTechnicalVersion": "Technical source version available in the audit log",
|
||||
"feedbackLabel": "Answer feedback",
|
||||
"feedbackPrompt": "Was this answer useful?",
|
||||
"feedbackThanks": "Feedback saved",
|
||||
"feedbackHelpful": "Yes",
|
||||
"feedbackNotHelpful": "No"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"skipToContent": "Skip to main content",
|
||||
"groups": {
|
||||
"operate": "Operate",
|
||||
"assure": "Assure"
|
||||
},
|
||||
"items": {
|
||||
"overview": "Overview",
|
||||
"fleet": "Fleet",
|
||||
"bookings": "Bookings",
|
||||
"quality": "Data quality",
|
||||
"knowledge": "Knowledge",
|
||||
"integrations": "Integrations",
|
||||
"audit": "Audit trail",
|
||||
"users": "Users",
|
||||
"privacy": "Privacy",
|
||||
"engineering": "Engineering"
|
||||
},
|
||||
"primaryNavLabel": "Primary navigation",
|
||||
"mobileNavLabel": "Mobile navigation",
|
||||
"more": "More",
|
||||
"openNavigation": "Open navigation",
|
||||
"closeNavigation": "Close navigation",
|
||||
"sidebarEnvironment": "Demo environment",
|
||||
"sidebarEnvironmentDetail": "Synthetic data only",
|
||||
"resetDemoData": "Reset demo data",
|
||||
"resetConfirmTitle": "Confirm demo reset",
|
||||
"resetConfirmBody": "All synthetic changes will be discarded and deterministic demo data restored. You will be signed out.",
|
||||
"resetting": "Resetting…",
|
||||
"resetConfirmYes": "Yes, reset",
|
||||
"resetCancel": "Cancel",
|
||||
"resetFailed": "Could not reset demo data.",
|
||||
"searchLabel": "Search {{productName}}",
|
||||
"searchPlaceholder": "Search fleet, booking or section…",
|
||||
"searchShortcutHint": "Ctrl K",
|
||||
"searchSearching": "Searching…",
|
||||
"searchUnavailable": "Search is unavailable right now.",
|
||||
"searchNoResults": "No matches for \"{{query}}\".",
|
||||
"searchVehicleSummary": "{{make}} {{model}} · {{location}}",
|
||||
"searchSections": {
|
||||
"overview": "Operations dashboard",
|
||||
"fleet": "Vehicle registry",
|
||||
"bookings": "Rental bookings",
|
||||
"quality": "Quality workbench",
|
||||
"knowledge": "Procedure assistant",
|
||||
"integrations": "Automation and integration status",
|
||||
"audit": "Audit history",
|
||||
"privacy": "Privacy management"
|
||||
},
|
||||
"switchRole": "Switch role",
|
||||
"switchRoleTitle": "Switch demo role",
|
||||
"languageSwitcherLabel": "Change language"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"users": { "eyebrow": "Administration / Access", "title": "Users", "description": "Manage operational access and roles. Every change is audited.", "managerOnly": "User administration is available to Operations Managers only.", "addTitle": "Add user", "name": "Name", "email": "Email", "role": "Role", "password": "Temporary password", "status": "Status", "action": "Action", "add": "Add user", "saving": "Saving…", "loading": "Loading users…", "active": "active", "inactive": "inactive", "activate": "Activate", "deactivate": "Deactivate", "edit": "Edit", "editTitle": "Edit user {{ref}}", "newPassword": "New password", "passwordUnchanged": "Leave empty to keep unchanged", "saveChanges": "Save changes", "cancel": "Cancel", "createFailed": "The user could not be created.", "updateFailed": "The user could not be updated." },
|
||||
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Rental employee" }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"eyebrow": "Assure / Governance",
|
||||
"title": "Privacy management",
|
||||
"description": "Export personal data, monitor retention and anonymise safely with a complete audit trail.",
|
||||
"managerOnly": "These governance functions are available to Operations Managers only.",
|
||||
"loading": "Loading privacy policy…",
|
||||
"error": "The privacy action could not be completed. Check the reference and retention period.",
|
||||
"policyTitle": "Retention policy",
|
||||
"totalCustomers": "Customers",
|
||||
"eligibleCustomers": "Eligible",
|
||||
"anonymizedCustomers": "Anonymised",
|
||||
"bookingRetention": "Minimum booking retention",
|
||||
"auditRetention": "Audit retention",
|
||||
"days_one": "{{count}} day",
|
||||
"days_other": "{{count}} days",
|
||||
"customerRef": "Customer reference",
|
||||
"exports": {"title":"Data exports","detail":"Download a customer file as JSON or a bounded audit export as CSV. Every export is logged.","customer":"Download customer file","audit":"Download audit CSV"},
|
||||
"anonymize": {"title":"Anonymise customer","detail":"Irreversible. Active bookings and bookings newer than {{days}} days block this action.","reason":"Reasoned justification","confirmation":"Type {{ref}} to confirm","submit":"Anonymise permanently","working":"Anonymising…","anonymized":"{{ref}} was anonymised.","already_anonymized":"{{ref}} was already anonymised."}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"list": {
|
||||
"eyebrow": "Assure / Workbench",
|
||||
"title": "Data quality",
|
||||
"description": "Resolve evidence-backed exceptions before they disrupt operations.",
|
||||
"runScan": "Run quality scan",
|
||||
"confirmScanTitle": "Confirm quality scan",
|
||||
"confirmScanBody": "Run the deterministic scan across all five rule types now?",
|
||||
"scanning": "Scanning…",
|
||||
"confirmScanYes": "Yes, run scan",
|
||||
"cancel": "Cancel",
|
||||
"scanComplete": "Scan complete: {{summary}}",
|
||||
"scanNoNew": "no new issues found (existing open issues are not recreated).",
|
||||
"scanFailed": "Could not run the quality scan.",
|
||||
"statusLabel": "Status",
|
||||
"statusAll": "All statuses",
|
||||
"statusOpen": "Open",
|
||||
"statusDeferred": "Deferred",
|
||||
"statusResolved": "Resolved",
|
||||
"statusRejected": "Rejected",
|
||||
"ruleTypeLabel": "Rule type",
|
||||
"ruleTypeAll": "All rule types",
|
||||
"severityLabel": "Severity",
|
||||
"severityAll": "All severity levels",
|
||||
"assigneeLabel": "Assigned to",
|
||||
"assigneeAll": "All team members",
|
||||
"unassigned": "Unassigned",
|
||||
"overdueOnly": "Overdue only",
|
||||
"bulkTitle": "Update selected work",
|
||||
"selected": "{{count}} selected",
|
||||
"assignTo": "Assign to",
|
||||
"dueAt": "Due date",
|
||||
"invalidDueAt": "Choose a valid Europe/Brussels date and time.",
|
||||
"bulkApply": "Update work queue",
|
||||
"bulkSaving": "Updating…",
|
||||
"bulkFailed": "The work queue could not be updated.",
|
||||
"clearSelection": "Clear selection",
|
||||
"selectIssue": "Select issue {{ref}}",
|
||||
"selectVisible": "Select visible issues",
|
||||
"deselectVisible": "Clear visible selection",
|
||||
"openIssue": "Open issue {{ref}}",
|
||||
"filtersToggle": "Filters · {{count}} active",
|
||||
"activeFilters": "Active filters",
|
||||
"demoScenariosOnly": "Demo scenarios only",
|
||||
"loading": "Loading quality workbench…",
|
||||
"queueClear": "Queue is clear",
|
||||
"noIssuesMatch": "No issues match the current filters.",
|
||||
"noDemoIssuesMatch": "No demo-scenario issues match",
|
||||
"noDemoIssuesMatchDetail": "Uncheck 'Demo scenarios only' to see the full queue.",
|
||||
"unavailable": "Data-quality issues are unavailable right now.",
|
||||
"count": "{{count}} issues",
|
||||
"evidenceBacked": "Evidence-backed detection",
|
||||
"columns": {
|
||||
"reference": "Reference",
|
||||
"select": "Select",
|
||||
"rule": "Rule",
|
||||
"entity": "Entity",
|
||||
"severity": "Severity",
|
||||
"status": "Status",
|
||||
"assignee": "Assignee",
|
||||
"due": "Due date"
|
||||
}
|
||||
},
|
||||
"ruleTypes": {
|
||||
"possible_duplicate_customer": "Possible duplicate customer",
|
||||
"missing_required_field": "Missing required field",
|
||||
"odometer_regression": "Odometer regression",
|
||||
"booking_overlap": "Booking overlap",
|
||||
"vehicle_status_conflict": "Vehicle status conflict"
|
||||
},
|
||||
"severities": {
|
||||
"high": "Critical",
|
||||
"medium": "Warning",
|
||||
"low": "Info"
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Quality workbench",
|
||||
"eyebrow": "Quality / {{rule}}",
|
||||
"title": "Review persisted evidence and record an audited resolution.",
|
||||
"notFound": "This issue could not be found.",
|
||||
"loading": "Loading issue evidence…",
|
||||
"managerOnly": "The quality workbench is visible to Operations Managers only.",
|
||||
"managerOnlyDetail": "Data-quality evidence and resolutions are visible to Operations Managers only.",
|
||||
"summary": {
|
||||
"rule": "Rule",
|
||||
"entity": "Entity",
|
||||
"evidenceSummary": "Evidence summary"
|
||||
},
|
||||
"resolved": {
|
||||
"title": "Issue {{ref}} resolved",
|
||||
"body": "The change has been applied and is recorded in the audit trail.",
|
||||
"viewAudit": "View audit trail",
|
||||
"viewVehicle": "View vehicle",
|
||||
"continueDemo": "Continue the demo"
|
||||
},
|
||||
"deferOrReject": {
|
||||
"heading": "Defer or reject",
|
||||
"description": "Defer to review later, or reject if this is not a real issue.",
|
||||
"defer": "Defer",
|
||||
"reject": "Reject",
|
||||
"deferFailed": "Could not defer this issue.",
|
||||
"rejectFailed": "Could not reject this issue."
|
||||
},
|
||||
"explainer": {
|
||||
"whatIsWrong": "What's wrong",
|
||||
"whyItMatters": "Why it matters",
|
||||
"possible_duplicate_customer": {
|
||||
"whatIsWrong": "Two customer profiles share identifying details (email, phone or a very similar name) strongly enough that they are likely the same person, registered twice.",
|
||||
"whyItMatters": "Duplicate customers split booking history across two records, risk duplicate billing, and confuse support conversations."
|
||||
},
|
||||
"missing_required_field": {
|
||||
"whatIsWrong": "This record is missing information that's required for normal operation (for example, a customer with neither an email nor a phone number on file).",
|
||||
"whyItMatters": "Without this data, the business can't reach the customer, or can't reliably identify the vehicle for compliance and hand-off checks."
|
||||
},
|
||||
"odometer_regression": {
|
||||
"whatIsWrong": "A submitted odometer reading is lower than the vehicle's last known (canonical) reading.",
|
||||
"whyItMatters": "A falling odometer usually means a data-entry mistake or that readings were recorded against the wrong vehicle. Letting it through silently would corrupt maintenance scheduling and resale mileage history."
|
||||
},
|
||||
"booking_overlap": {
|
||||
"whatIsWrong": "The same vehicle is committed to two bookings whose date ranges overlap.",
|
||||
"whyItMatters": "Only one of these bookings can actually be honoured. Left unresolved, a customer would arrive to find their vehicle already out with someone else."
|
||||
},
|
||||
"vehicle_status_conflict": {
|
||||
"whatIsWrong": "This vehicle's stored operational status doesn't match what its own booking and inspection history implies it should be.",
|
||||
"whyItMatters": "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet."
|
||||
}
|
||||
},
|
||||
"evidence": {
|
||||
"duplicate.exact_email": "Identical email address",
|
||||
"duplicate.exact_phone": "Identical phone number",
|
||||
"duplicate.same_postal_code": "Same postal code",
|
||||
"duplicateSimilarName": "Strongly similar name (score {{score}})",
|
||||
"missingField": "Missing field: {{field}}",
|
||||
"overlapReservedBookings": "Overlapping bookings: {{refs}}",
|
||||
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
|
||||
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing."
|
||||
},
|
||||
"duplicateCustomer": {
|
||||
"heading": "Compare and merge",
|
||||
"description": "Choose the canonical customer and review each conflicting field.",
|
||||
"keepAsSurvivor": "Keep as survivor",
|
||||
"differs": "Differs",
|
||||
"match": "Match",
|
||||
"summaryCounts": "{{matchCount}} matching fields · {{conflictCount}} conflicting fields",
|
||||
"showMatchingFields": "Also show matching fields",
|
||||
"hideMatchingFields": "Hide matching fields",
|
||||
"previewTitle": "Preview of the merged record ({{ref}})",
|
||||
"mergePreview": "{{loser}} will be safely archived and linked to {{survivor}}; its booking history moves with it.",
|
||||
"mergeInto": "Merge into {{ref}}",
|
||||
"confirmMergeTitle": "Confirm merge",
|
||||
"confirmMergeBody": "Merge {{loser}} into {{survivor}}? This cannot be undone.",
|
||||
"merging": "Merging…",
|
||||
"confirmMergeYes": "Yes, merge",
|
||||
"mergeFailed": "Could not merge these customers.",
|
||||
"bothMissing": "Both customers in this comparison could not be loaded.",
|
||||
"fieldColumn": "Field",
|
||||
"fields": {
|
||||
"first_name": "First name",
|
||||
"last_name": "Last name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"postal_code": "Postal code",
|
||||
"city": "City"
|
||||
}
|
||||
},
|
||||
"missingField": {
|
||||
"heading": "Provide the missing fields",
|
||||
"description": "Complete the record for {{ref}}. The issue resolves automatically once nothing required is missing.",
|
||||
"atLeastOne": "At least one of email or phone is required.",
|
||||
"saveAndRecheck": "Save and re-check",
|
||||
"saving": "Saving…",
|
||||
"saveFailed": "Could not save these fields.",
|
||||
"fields": {
|
||||
"first_name": "First name",
|
||||
"last_name": "Last name",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"registration_number": "Registration number",
|
||||
"make": "Make",
|
||||
"model": "Model",
|
||||
"location": "Location"
|
||||
}
|
||||
},
|
||||
"odometerRegression": {
|
||||
"heading": "Resolve the odometer regression",
|
||||
"description": "The canonical odometer is never lowered automatically -- choose how to reconcile it.",
|
||||
"canonicalOdometer": "Canonical odometer",
|
||||
"decisionLegend": "Decision",
|
||||
"retainCanonical": "Retain canonical reading",
|
||||
"retainCanonicalDetail": "Treat the submitted reading as erroneous; nothing changes on the vehicle record.",
|
||||
"correctReading": "Correct the reading",
|
||||
"correctReadingDetail": "Update both the booking and the canonical odometer with the correct value.",
|
||||
"noBookingAttached": "No related booking is attached to this issue, so only 'retain canonical' is available.",
|
||||
"booking": "Booking",
|
||||
"correctedOdometer": "Corrected odometer (km)",
|
||||
"note": "Note",
|
||||
"resolving": "Resolving…",
|
||||
"resolveIssue": "Resolve issue",
|
||||
"resolveFailed": "Could not resolve this issue."
|
||||
},
|
||||
"bookingOverlap": {
|
||||
"heading": "Resolve the booking overlap",
|
||||
"description": "Block one of the two overlapping commitments. The other keeps its current status.",
|
||||
"columns": {
|
||||
"booking": "Booking",
|
||||
"window": "Window",
|
||||
"status": "Status",
|
||||
"blockThis": "Block this one"
|
||||
},
|
||||
"blockLabel": "Block {{ref}}",
|
||||
"note": "Note",
|
||||
"resolving": "Resolving…",
|
||||
"blockButton": "Block {{ref}}",
|
||||
"blockButtonFallback": "Block booking",
|
||||
"resolveFailed": "Could not resolve this overlap."
|
||||
},
|
||||
"vehicleStatusConflict": {
|
||||
"heading": "Resolve the status conflict",
|
||||
"description": "One authoritative rule recommends a corrected operational status for this vehicle.",
|
||||
"currentStatus": "Current status",
|
||||
"reviewRecommendation": "Review recommendation",
|
||||
"loadingRecommendation": "Loading recommendation…",
|
||||
"recommendationFailed": "Could not load a recommendation.",
|
||||
"recommendedStatus": "Recommended status",
|
||||
"whyHeading": "Why",
|
||||
"evidenceHeading": "Facts",
|
||||
"consequenceHeading": "Consequence",
|
||||
"consequence": {
|
||||
"statusWillChange": "The vehicle status will change to {{status}}.",
|
||||
"issueWillBeRechecked": "This quality issue will be re-checked.",
|
||||
"changeWillBeAudited": "The change will be recorded in the audit trail.",
|
||||
"bookingsNotDeleted": "The bookings themselves are not deleted."
|
||||
},
|
||||
"changeStatusTo": "Change status to {{status}}",
|
||||
"applying": "Applying…",
|
||||
"applied": "Applied {{status}} — {{reason}}",
|
||||
"applyFailed": "Could not apply a recommended status.",
|
||||
"staleRecommendation": "The situation has changed since this recommendation was shown. Review it again before applying.",
|
||||
"reviewAgain": "Review recommendation again",
|
||||
"manualReview": {
|
||||
"heading": "Manual review required",
|
||||
"body": "The facts for this vehicle contradict each other. An Operations Manager needs to review this in person rather than apply an automatic status change.",
|
||||
"hint": "Use 'Defer' or 'Reject' below, or investigate the vehicle and the related bookings manually."
|
||||
},
|
||||
"noConflict": {
|
||||
"heading": "No change needed",
|
||||
"body": "This vehicle's current status already matches the facts."
|
||||
},
|
||||
"evidence": {
|
||||
"activeBookings": "Active booking(s): {{refs}}",
|
||||
"overlappingBookings": "Overlapping bookings: {{pairs}}",
|
||||
"serviceThresholdReached": "Odometer reading ({{odometer}} km) has reached the service threshold ({{threshold}} km).",
|
||||
"openOverlapIssue": "Open booking-overlap issue: {{ref}}"
|
||||
},
|
||||
"reasonCodes": {
|
||||
"vehicle.active_rental": "This vehicle has an active rental but is not recorded as rented. The status is corrected to reflect the actual situation.",
|
||||
"vehicle.service_threshold_reached": "This vehicle has reached its service threshold. It is set to maintenance so it is not deployed before servicing takes place.",
|
||||
"vehicle.booking_conflict": "This vehicle has two overlapping bookings. Blocking it prevents it from being considered available again before the booking conflict is resolved.",
|
||||
"vehicle.rental_ended": "The rental for this vehicle has ended and there is no active booking left. It is set back to available.",
|
||||
"vehicle.manual_review_required": "The facts for this vehicle contradict each other (for example, an active booking while the vehicle is in maintenance, or a service threshold reached alongside an active rental). This requires a human review rather than an automatic status change.",
|
||||
"vehicle.no_conflict": "This vehicle's current status already matches the facts. No change is needed."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"progress": {
|
||||
"capture": "Capture",
|
||||
"review": "Review",
|
||||
"result": "Result",
|
||||
"ariaLabel": "Return registration progress"
|
||||
},
|
||||
"capture": {
|
||||
"heading": "Register vehicle return",
|
||||
"description": "Record the hand-back condition. The next step evaluates the exact operational consequences before anything is committed.",
|
||||
"endOdometer": "End odometer (km)",
|
||||
"fuelLevel": "Fuel level (%)",
|
||||
"conditionLegend": "Vehicle condition",
|
||||
"cleanlinessOk": "Cleanliness acceptable",
|
||||
"damageReported": "Damage reported",
|
||||
"technicalWarning": "Technical warning",
|
||||
"notes": "Notes",
|
||||
"evaluating": "Evaluating…",
|
||||
"reviewReturn": "Review return"
|
||||
},
|
||||
"review": {
|
||||
"heading": "Review return impact",
|
||||
"description": "This is the server's authoritative evaluation of what committing will do — confirm before it updates fleet state and queues automation.",
|
||||
"odometer": "Odometer",
|
||||
"fuel": "Fuel",
|
||||
"cleanliness": "Cleanliness",
|
||||
"cleanlinessAccepted": "Accepted",
|
||||
"cleanlinessFollowUp": "Follow-up needed",
|
||||
"damage": "Damage",
|
||||
"damageReported": "Reported",
|
||||
"damageNone": "None reported",
|
||||
"technicalWarning": "Technical warning",
|
||||
"technicalWarningReported": "Reported",
|
||||
"technicalWarningNone": "None reported",
|
||||
"expectedState": "Expected fleet state:",
|
||||
"odometerRegressionWarning": "Submitted odometer ({{submitted}} km) is below the canonical reading ({{canonical}} km). The canonical odometer will not change, and a data-quality issue will be opened.",
|
||||
"nextBookingRisk": "Next booking {{ref}} starts {{when}} — may be affected by this return.",
|
||||
"nextBookingLowRisk": "Next booking {{ref}} starts {{when}} — low risk.",
|
||||
"reasonCodes": {
|
||||
"returnBlockedDamageAndTechnical": "Both damage and a technical warning were reported on this return.",
|
||||
"returnBlockedDamage": "Damage was reported on this return.",
|
||||
"returnBlockedTechnicalWarning": "A technical warning was reported on this return.",
|
||||
"returnServiceThresholdReached": "The odometer reading has reached the {{threshold_km}} km service threshold.",
|
||||
"returnRoutedToCleaning": "No damage, technical warning or service threshold reached; the vehicle goes to cleaning."
|
||||
},
|
||||
"commitList": {
|
||||
"inspection": "Create a return inspection",
|
||||
"updateAtomic": "Update the booking and vehicle atomically",
|
||||
"queueAutomation": "Queue automation after the local commit"
|
||||
},
|
||||
"editDetails": "Edit details",
|
||||
"registering": "Registering…",
|
||||
"confirmReturn": "Confirm return"
|
||||
},
|
||||
"result": {
|
||||
"committedLocally": "Committed locally",
|
||||
"heading": "Return registered",
|
||||
"inspection": "Inspection",
|
||||
"resultingStatus": "Resulting vehicle status",
|
||||
"qualityIssue": "Quality issue",
|
||||
"noneCreated": "None created",
|
||||
"automationEvent": "Automation job",
|
||||
"queuedForDelivery": "Queued for delivery ({{ref}}) — local commit succeeded; automation delivery is asynchronous and not yet confirmed.",
|
||||
"nextBookingRisk": "Next booking risk",
|
||||
"nextBookingRiskValue": "{{ref}} {{status}}",
|
||||
"atRisk": "— may be affected",
|
||||
"lowRisk": "— low risk",
|
||||
"noUpcomingBooking": "No upcoming booking for this vehicle",
|
||||
"odometerRegressionNotice": "The submitted odometer reading was below the vehicle's canonical odometer. It was recorded as-is; the canonical odometer was not changed, and a data-quality issue was opened for review.",
|
||||
"viewVehicle": "View vehicle {{ref}}",
|
||||
"viewAutomation": "View automation status",
|
||||
"viewAudit": "View audit trail",
|
||||
"viewTrace": "Trace the complete operation",
|
||||
"continueDemo": "Continue the demo"
|
||||
},
|
||||
"scenario": {
|
||||
"ariaLabel": "Demo scenario",
|
||||
"title": "Demo scenario: odometer anomaly",
|
||||
"body": "This vehicle currently reads {{odometer}} km. The form below is pre-filled with a return reading below that — a sign of a data-entry mistake or a mixed-up vehicle. Confirm the return to see how {{productName}} detects and handles this.",
|
||||
"preparing": "Preparing scenario…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"openRow": "Ouvrir : {{label}}",
|
||||
"reducedMotion": "Mouvement réduit actif",
|
||||
"closeDialog": "Fermer la boîte de dialogue",
|
||||
"expandSection": "Déplier la section",
|
||||
"collapseSection": "Replier la section"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"eyebrow": "Assurance / Historique immuable",
|
||||
"title": "Piste d'audit",
|
||||
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
|
||||
"actionFilterLabel": "Action",
|
||||
"actionFilterPlaceholder": "p. ex. demo_login",
|
||||
"actorFilterLabel": "Intervenant",
|
||||
"actorFilterPlaceholder": "Nom ou service",
|
||||
"entityFilterLabel": "Dossier",
|
||||
"entityFilterPlaceholder": "p. ex. MO-016",
|
||||
"fromFilterLabel": "À partir du",
|
||||
"toFilterLabel": "Jusqu’au",
|
||||
"managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.",
|
||||
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.",
|
||||
"loading": "Chargement de la piste d'audit…",
|
||||
"unavailable": "La piste d'audit est actuellement indisponible.",
|
||||
"empty": "Aucun événement d'audit trouvé",
|
||||
"emptyDetail": "Ajustez le filtre d'action.",
|
||||
"relatedFilterActive": "Affichage uniquement des événements liés à cette action ({{count}} événements liés).",
|
||||
"clearFilter": "Effacer ce filtre",
|
||||
"count": "{{count}} événements",
|
||||
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
|
||||
"columns": {
|
||||
"when": "Quand",
|
||||
"actor": "Auteur",
|
||||
"action": "Action",
|
||||
"entity": "Entité",
|
||||
"change": "Changement",
|
||||
"followUp": "Suite",
|
||||
"details": "Détails"
|
||||
},
|
||||
"viewRelatedEvents": "Voir les événements liés",
|
||||
"noChangeDetail": "Aucun détail de changement enregistré.",
|
||||
"noFieldChange": "Aucun changement au niveau des champs détecté.",
|
||||
"reference": "Référence",
|
||||
"relatedEventsCount": "{{count}} événement lié",
|
||||
"relatedEventsCount_other": "{{count}} événements liés",
|
||||
"showTechnicalEvents": "Afficher {{count}} événement technique",
|
||||
"showTechnicalEvents_other": "Afficher {{count}} événements techniques",
|
||||
"hideTechnicalEvents": "Masquer les événements techniques",
|
||||
"technicalDetails": "Détails techniques",
|
||||
"fullReference": "Référence complète",
|
||||
"correlationId": "ID de corrélation",
|
||||
"diff": {
|
||||
"setTo": "{{field}} : défini à {{value}}",
|
||||
"was": "{{field}} : était {{value}}",
|
||||
"changed": "{{field}} : {{before}} → {{after}}"
|
||||
},
|
||||
"actorTypes": {
|
||||
"user": "Utilisateur",
|
||||
"system": "Système",
|
||||
"service": "Service"
|
||||
},
|
||||
"fields": {
|
||||
"status": "Statut",
|
||||
"operational_status": "Statut opérationnel",
|
||||
"registration_number": "Plaque d'immatriculation",
|
||||
"make": "Marque",
|
||||
"model": "Modèle",
|
||||
"location": "Emplacement",
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom",
|
||||
"email": "E-mail",
|
||||
"phone": "Téléphone",
|
||||
"postal_code": "Code postal",
|
||||
"city": "Ville",
|
||||
"booking_end_odometer_km": "Kilométrage final de la réservation",
|
||||
"vehicle_odometer_km": "Kilométrage du véhicule",
|
||||
"survivor": "Profil conservé",
|
||||
"loser": "Profil fusionné"
|
||||
},
|
||||
"trace": {
|
||||
"eyebrow": "Trace de bout en bout",
|
||||
"title": "Du retour à l'automatisation",
|
||||
"description": "Cette vue relie les événements partageant le même identifiant de corrélation en une opération vérifiable.",
|
||||
"steps": {
|
||||
"return": { "title": "Retour enregistré", "complete": "La réservation et l'inspection ont été traitées atomiquement.", "pending": "Pas encore traité." },
|
||||
"vehicle": { "title": "État de flotte dérivé", "complete": "Le serveur a enregistré l'état résultant du véhicule.", "pending": "Aucun changement d'état trouvé." },
|
||||
"outbox": { "title": "Automatisation planifiée", "complete": "La tâche outbox a été créée dans la même transaction.", "pending": "Aucune tâche outbox trouvée." },
|
||||
"delivery": { "title": "Suivi externe", "complete": "n8n a confirmé le suivi.", "pending": "La livraison est asynchrone et n'est pas encore confirmée dans cette trace." }
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"demo_login": "Connecté",
|
||||
"demo_logout": "Déconnecté",
|
||||
"demo_reset": "Données de démo réinitialisées",
|
||||
"demo_data_seeded": "Données de démo chargées",
|
||||
"return_registered": "Retour de véhicule enregistré",
|
||||
"vehicle_status_changed": "Statut du véhicule modifié",
|
||||
"data_quality_issue_resolved": "Problème de qualité résolu",
|
||||
"data_quality_issue_deferred": "Problème de qualité reporté",
|
||||
"data_quality_issue_rejected": "Problème de qualité rejeté",
|
||||
"data_quality_fields_provided": "Champs manquants complétés",
|
||||
"data_quality_odometer_corrected": "Kilométrage corrigé",
|
||||
"data_quality_odometer_retained": "Kilométrage de référence conservé",
|
||||
"data_quality_status_applied": "Statut recommandé appliqué",
|
||||
"data_quality_booking_blocked": "Réservation bloquée",
|
||||
"data_quality_scan_run": "Contrôle qualité exécuté",
|
||||
"customer_merged": "Clients fusionnés",
|
||||
"workflow_retry": "Automatisation retentée",
|
||||
"knowledge_question_asked": "Question de connaissance posée",
|
||||
"mcp_tool_request": "Outil MCP appelé"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"brandTagline": "Centre de contrôle",
|
||||
"orgLine": "Organisation de démo : {{orgName}} (fictive)",
|
||||
"headline1": "Chaque transfert.",
|
||||
"headline2": "Une vue claire.",
|
||||
"defaultDescription": "{{productName}} rassemble les données de véhicules, de réservations et d'exploitation, soutient les processus de location, détecte les problèmes de qualité des données et automatise les suites contrôlées.",
|
||||
"footnote": "Démo synthétique · aucune donnée client ou véhicule réelle · réinitialisable à tout moment",
|
||||
"accessEyebrow": "Accès démo",
|
||||
"accessHeading": "Choisissez comment démarrer",
|
||||
"accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.",
|
||||
"startGuidedDemo": "Démarrer la démo guidée",
|
||||
"guidedDemoChecking": "Préparation de la démo guidée…",
|
||||
"guidedDemoManifestUnavailable": "Les informations de la démo guidée n’ont pas pu être chargées.",
|
||||
"guidedDemoNotReady": "La démo guidée n’est pas prête à démarrer pour le moment.",
|
||||
"guidedDemoRetry": "Recharger les informations de démo",
|
||||
"startRecruiterTour": "Voir les points forts en 90 secondes",
|
||||
"exploreAsOperationsManager": "Explorer en tant que Responsable des opérations",
|
||||
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
|
||||
"exploreAsRentalEmployee": "Explorer en tant que Collaborateur de location",
|
||||
"exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures",
|
||||
"safeByDesignTitle": "Conçu pour la sécurité",
|
||||
"safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.",
|
||||
"loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.",
|
||||
"roleOperationsManager": "Responsable des opérations",
|
||||
"roleRentalEmployee": "Collaborateur de location",
|
||||
"switchRole": "Changer de rôle",
|
||||
"logout": "Déconnexion",
|
||||
"emailLabel": "Adresse e-mail",
|
||||
"passwordLabel": "Mot de passe",
|
||||
"signIn": "Se connecter",
|
||||
"passwordLoginFailed": "Connexion impossible. Vérifiez vos identifiants.",
|
||||
"or": "ou",
|
||||
"signInWithOrganization": "Se connecter avec {{provider}}"
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"list": {
|
||||
"eyebrow": "Exploitation / Planning",
|
||||
"title": "Réservations",
|
||||
"description": "Consultez les périodes de location actives et les engagements de véhicules à venir.",
|
||||
"create": "Nouvelle réservation",
|
||||
"searchLabel": "Rechercher",
|
||||
"searchPlaceholder": "Réservation, client ou véhicule",
|
||||
"statusLabel": "Statut",
|
||||
"statusAll": "Tous les statuts",
|
||||
"fromLabel": "À partir du",
|
||||
"toLabel": "Jusqu’au",
|
||||
"locationLabel": "Site",
|
||||
"locationPlaceholder": "Site opérationnel exact",
|
||||
"sortLabel": "Tri",
|
||||
"sortOperational": "Priorité opérationnelle",
|
||||
"sortAscending": "Date de début croissante",
|
||||
"sortDescending": "Date de début décroissante",
|
||||
"presetsLabel": "Filtres rapides",
|
||||
"todayPreset": "Aujourd’hui",
|
||||
"upcomingPreset": "Réservations à venir",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"loading": "Chargement du registre des réservations…",
|
||||
"empty": "Aucune réservation trouvée",
|
||||
"emptyDetail": "Ajustez le filtre de statut des réservations.",
|
||||
"noMatch": "Aucune réservation correspondante",
|
||||
"noMatchDetail": "Essayez un terme de recherche plus large.",
|
||||
"unavailable": "La liste des réservations est actuellement indisponible.",
|
||||
"count": "{{count}} réservations",
|
||||
"pageOf": "Page {{page}} sur {{total}}",
|
||||
"columns": {
|
||||
"reference": "Référence",
|
||||
"customer": "Client",
|
||||
"vehicle": "Véhicule",
|
||||
"window": "Période",
|
||||
"status": "Statut"
|
||||
},
|
||||
"paginationLabel": "Pages de réservations",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"rangeOf": "{{from}}–{{to}} sur {{total}}"
|
||||
},
|
||||
"create": {
|
||||
"backLink": "Registre des réservations",
|
||||
"eyebrow": "Réservations / Nouvelle réservation",
|
||||
"title": "Créer une réservation",
|
||||
"description": "Sélectionnez un client et un véhicule disponible pendant toute la période de location.",
|
||||
"startsAt": "Début",
|
||||
"endsAt": "Fin",
|
||||
"customerSearch": "Rechercher un client",
|
||||
"customerPlaceholder": "Nom, e-mail ou référence client",
|
||||
"customer": "Client",
|
||||
"searchFirst": "Recherchez d'abord un client",
|
||||
"chooseCustomer": "Sélectionnez un client",
|
||||
"vehicle": "Véhicule disponible",
|
||||
"vehicleSearch": "Rechercher un véhicule",
|
||||
"vehiclePlaceholder": "Référence, marque, modèle, plaque ou site",
|
||||
"locationUnknown": "site inconnu",
|
||||
"loadingVehicles": "Vérification des disponibilités…",
|
||||
"chooseVehicle": "Sélectionnez un véhicule",
|
||||
"noVehicles": "Aucun véhicule disponible pour cette période",
|
||||
"invalidLocalTime": "Choisissez une heure Europe/Brussels valide. L’heure sautée lors du passage à l’heure d’été n’existe pas.",
|
||||
"endAfterStart": "La fin de la réservation doit être postérieure à son début.",
|
||||
"cancel": "Annuler",
|
||||
"save": "Créer la réservation",
|
||||
"saving": "Enregistrement…",
|
||||
"failed": "La réservation n'a pas pu être créée. Vérifiez la période et les disponibilités."
|
||||
},
|
||||
"statuses": {
|
||||
"reserved": "réservé",
|
||||
"active": "actif",
|
||||
"returned": "retourné",
|
||||
"cancelled": "annulé",
|
||||
"blocked": "bloqué"
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Remettre le véhicule",
|
||||
"description": "Enregistrez l’état de départ. Toute anomalie bloque automatiquement la location pour suivi.",
|
||||
"preparing": "Préparation de l’inspection de départ…",
|
||||
"odometer": "Kilométrage de départ",
|
||||
"fuel": "Niveau de carburant (%)",
|
||||
"condition": "État au départ",
|
||||
"clean": "Le véhicule est propre",
|
||||
"damage": "Dommage constaté",
|
||||
"warning": "Alerte technique",
|
||||
"notes": "Notes",
|
||||
"submit": "Enregistrer l’inspection et démarrer la location",
|
||||
"saving": "Enregistrement du départ…",
|
||||
"failed": "L’inspection de départ n’a pas pu être enregistrée.",
|
||||
"activatedTitle": "Location démarrée",
|
||||
"activatedDetail": "L’inspection {{inspection}} est enregistrée et le véhicule est loué.",
|
||||
"blockedTitle": "Location bloquée",
|
||||
"blockedDetail": "L’inspection {{inspection}} contient une anomalie. Le véhicule a été retiré du service en toute sécurité."
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Registre des réservations",
|
||||
"eyebrow": "Réservations / Fiche de location",
|
||||
"notFound": "Cette réservation est introuvable.",
|
||||
"loading": "Chargement de la fiche de réservation…",
|
||||
"customer": "Client",
|
||||
"vehicle": "Véhicule",
|
||||
"starts": "Début",
|
||||
"ends": "Fin",
|
||||
"startOdometer": "Kilométrage de départ",
|
||||
"endOdometer": "Kilométrage de retour",
|
||||
"startOdometerPending": "Pas encore enregistré — trajet pas encore commencé",
|
||||
"endOdometerPending": "Pas encore enregistré — pas encore clôturé",
|
||||
"requirementsComplete": "Exigences complètes",
|
||||
"requirementsAction": "Contrôler les exigences",
|
||||
"requirementsActionDetail": "L’inspection de départ reste verrouillée jusqu’à la confirmation explicite du permis et des exigences de location.",
|
||||
"requirementsConfirmation": "Preuve du contrôle",
|
||||
"requirementsConfirmationPlaceholder": "Notez ce qui a été contrôlé",
|
||||
"requirementsConfirm": "Confirmer les exigences",
|
||||
"requirementsSaving": "Confirmation…",
|
||||
"requirementsFailed": "Les exigences n’ont pas pu être confirmées.",
|
||||
"rescheduleAction": "Replanifier la réservation",
|
||||
"rescheduleDetail": "Modifiez la période de location. La disponibilité est revérifiée avant l’enregistrement.",
|
||||
"rescheduleReason": "Motif de la modification",
|
||||
"confirmReschedule": "Enregistrer la nouvelle période",
|
||||
"rescheduling": "Enregistrement…",
|
||||
"rescheduleFailed": "La réservation n’a pas pu être replanifiée.",
|
||||
"invalidScheduleWindow": "Choisissez des heures Europe/Brussels valides et assurez-vous que la fin suit le début.",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"cancelAction": "Annuler la réservation",
|
||||
"cancelReason": "Motif",
|
||||
"cancelReasonPlaceholder": "Pourquoi cette réservation est-elle annulée ?",
|
||||
"confirmCancel": "Confirmer l'annulation",
|
||||
"cancelling": "Annulation…",
|
||||
"cancelFailed": "La réservation n'a pas pu être annulée."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"orgName": "Northstar Mobility",
|
||||
"brandTagline": "Centre de contrôle",
|
||||
"actions": {
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"close": "Fermer",
|
||||
"back": "Retour",
|
||||
"next": "Suivant",
|
||||
"retry": "Réessayer",
|
||||
"reset": "Réinitialiser",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"viewDetails": "Voir les détails",
|
||||
"technicalDetails": "Détails techniques"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement…",
|
||||
"saving": "Enregistrement…",
|
||||
"error": "Une erreur est survenue",
|
||||
"success": "Réussi",
|
||||
"noResults": "Aucun résultat"
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Navigation entre les pages",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"pageOf": "Page {{page}} sur {{total}}"
|
||||
},
|
||||
"states": {
|
||||
"loadingDefault": "Chargement de l'espace de travail…",
|
||||
"errorTitle": "Impossible de charger cet espace de travail."
|
||||
},
|
||||
"timezone": "Europe/Bruxelles",
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"nl-BE": "Nederlands",
|
||||
"en-GB": "English",
|
||||
"fr-BE": "Français"
|
||||
},
|
||||
"footer": {
|
||||
"productLine": "{{productName}} Demo",
|
||||
"locale": "Europe/Bruxelles · Données de démonstration synthétiques"
|
||||
},
|
||||
"demo": {
|
||||
"syntheticBadge": "Démo synthétique"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"eyebrow": "Exploitation / Aperçu en direct",
|
||||
"greeting": {
|
||||
"morning": "Bonjour",
|
||||
"afternoon": "Bonjour",
|
||||
"evening": "Bonsoir",
|
||||
"night": "Bon retour"
|
||||
},
|
||||
"greetingBody": {
|
||||
"morning": "Voici l'état de votre flotte pour aujourd'hui.",
|
||||
"afternoon": "Voici l'aperçu actuel de votre flotte.",
|
||||
"evening": "Voici l'aperçu de votre flotte pour ce soir.",
|
||||
"night": "Voici le dernier aperçu de votre flotte."
|
||||
},
|
||||
"description": "Disponibilité, exceptions et transferts de l'activité en cours.",
|
||||
"viewFleet": "Voir la flotte",
|
||||
"demoStart": {
|
||||
"title": "Essayer un scénario de démonstration",
|
||||
"readyCount": "{{ready}} scénarios sur {{total}} prêts pour la démo.",
|
||||
"readyCountFallback": "Cinq scénarios ciblés.",
|
||||
"startGuide": "Démarrer le guide de démo",
|
||||
"resumeGuide": "Poursuivre le guide de démo ({{current}}/{{total}})",
|
||||
"viewScenarios": "Voir les scénarios"
|
||||
},
|
||||
"readiness": {
|
||||
"title": "Disponibilité de la flotte",
|
||||
"description": "En direct depuis l'état persistant des véhicules",
|
||||
"openFleet": "Ouvrir la flotte",
|
||||
"available": "Disponible",
|
||||
"rented": "Loué",
|
||||
"cleaning": "Nettoyage",
|
||||
"maintenance": "Entretien",
|
||||
"blocked": "Bloqué"
|
||||
},
|
||||
"attention": {
|
||||
"title": "File d'attention",
|
||||
"description": "{{openIssues}} problèmes de qualité ouverts · {{workflowExceptions}} exceptions de workflow",
|
||||
"reviewQueue": "Examiner la file",
|
||||
"remaining_one": "Encore un point d’attention",
|
||||
"remaining_other": "Encore {{count}} points d’attention",
|
||||
"remainingHint": "Ouvrir la file de travail complète",
|
||||
"filterPlaceholder": "Filtrer les problèmes…",
|
||||
"filterAriaLabel": "Rechercher dans la file d'attention",
|
||||
"severityAll": "Toute gravité",
|
||||
"severityHigh": "Critique",
|
||||
"severityMedium": "Avertissement",
|
||||
"severityLow": "Info",
|
||||
"severityAriaLabel": "Gravité",
|
||||
"tierNow": "À traiter maintenant",
|
||||
"tierToday": "À suivre aujourd'hui",
|
||||
"tierLater": "À contrôler plus tard",
|
||||
"empty": "Aucun problème ne correspond à ce filtre.",
|
||||
"openRecord": "Ouvrir {{title}}"
|
||||
},
|
||||
"movements": {
|
||||
"title": "Mouvements du jour",
|
||||
"description": "Départs et retours en Europe/Bruxelles",
|
||||
"allBookings": "Toutes les réservations",
|
||||
"empty": "Aucun mouvement prévu aujourd'hui.",
|
||||
"departure": "départ",
|
||||
"return": "retour",
|
||||
"openBooking": "Ouvrir la réservation {{ref}}"
|
||||
},
|
||||
"integrationPulse": {
|
||||
"title": "État des intégrations",
|
||||
"description": "Preuves actuelles des services connectés",
|
||||
"systemDetail": "Détail du système",
|
||||
"n8nTitle": "Livraison n8n",
|
||||
"n8nSummary": "{{succeeded}} réussies · {{failed}} échouées",
|
||||
"n8nLatest": "Dernier événement {{ref}}",
|
||||
"n8nNoEvidence": "Aucune preuve d'automatisation enregistrée",
|
||||
"knowledgeTitle": "Assistant de connaissances",
|
||||
"knowledgeSummary": "{{count}} procédures indexées",
|
||||
"knowledgeIndexUnknown": "{{count}} sources de connaissances · taille d’index non vérifiable",
|
||||
"knowledgeUnavailable": "Vérification de santé indisponible",
|
||||
"mcpTitle": "MCP Hub",
|
||||
"mcpEnabled": "Enregistrement activé",
|
||||
"mcpNotConnected": "Pas encore connecté"
|
||||
},
|
||||
"recent": {
|
||||
"title": "Activité récente",
|
||||
"description": "Derniers changements de workflow audités",
|
||||
"empty": "Aucune activité d'automatisation enregistrée."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"badge": {
|
||||
"trigger": "Démo synthétique",
|
||||
"dialogLabel": "À propos de cet environnement de démo",
|
||||
"close": "Fermer",
|
||||
"orgIntro": "<strong>{{orgName}}</strong> est une organisation fictive. Tous les noms, véhicules et réservations sont synthétiques.",
|
||||
"orgIntroFallback": "Tous les noms, véhicules et réservations de cet environnement sont synthétiques.",
|
||||
"realWorkflows": "Les workflows, contrôles et l'automatisation sont réellement implémentés — seules les données sont inventées.",
|
||||
"lastReset": "Dernière réinitialisation : <strong>{{when}}</strong> · cet environnement peut être réinitialisé à tout moment.",
|
||||
"aboutLink": "À propos de cette démo",
|
||||
"unknown": "inconnu"
|
||||
},
|
||||
"guide": {
|
||||
"dialogLabel": "Démo guidée",
|
||||
"trigger": "Guide de démo",
|
||||
"kicker": "Démo guidée · étape {{current}} sur {{total}}",
|
||||
"whatYouWillSee": "Ce que vous allez voir",
|
||||
"whyItMatters": "Pourquoi c'est important",
|
||||
"startAction": "Pour commencer",
|
||||
"expectedOutcome": "Résultat attendu",
|
||||
"goToStep": "Aller à cette étape",
|
||||
"next": "Suivant",
|
||||
"finish": "Terminer la démo",
|
||||
"close": "Fermer",
|
||||
"restart": "Préparer à nouveau la démo",
|
||||
"restarting": "Réinitialisation…",
|
||||
"restartFailed": "Impossible de réinitialiser la démo.",
|
||||
"allStepsLabel": "Toutes les étapes",
|
||||
"progressChip": "Guide de démo · étape {{current}} sur {{total}}",
|
||||
"expand": "Déplier le guide de démo",
|
||||
"collapse": "Replier le guide de démo",
|
||||
"steps": {
|
||||
"understand-state": {
|
||||
"title": "1. Comprendre l'état opérationnel",
|
||||
"whatYouWillSee": "Le tableau de bord affiche la disponibilité de la flotte, les points d'attention ouverts et les mouvements du jour.",
|
||||
"whyItMatters": "Un Responsable des opérations commence chaque journée par cet aperçu pour décider où intervenir.",
|
||||
"startAction": "Ouvrez le tableau de bord et consultez la file d'attention et la chronologie du jour.",
|
||||
"expectedOutcome": "Vous voyez quelles réservations, véhicules ou problèmes de qualité nécessitent de l'attention."
|
||||
},
|
||||
"open-booking": {
|
||||
"title": "2. Ouvrir la réservation nécessitant de l'attention",
|
||||
"whatYouWillSee": "La réservation BK-DEMO-RETURN, active et à retourner aujourd'hui.",
|
||||
"whyItMatters": "Les retours sont le moment où les kilométrages incorrects ou les dommages deviennent visibles pour la première fois.",
|
||||
"startAction": "Ouvrez la réservation depuis le tableau de bord ou la liste des réservations.",
|
||||
"expectedOutcome": "Vous voyez les détails de la réservation et le bouton pour traiter le retour."
|
||||
},
|
||||
"process-return": {
|
||||
"title": "3. Traiter un retour avec une anomalie de kilométrage",
|
||||
"whatYouWillSee": "Un formulaire de retour pré-rempli avec un relevé de kilométrage suspect, inférieur au dernier relevé connu.",
|
||||
"whyItMatters": "Un kilométrage en baisse signale une erreur de saisie ou un véhicule confondu — cela doit être détecté avant la libération.",
|
||||
"startAction": "Soumettez le retour avec la valeur suggérée et examinez l'aperçu du serveur avant de confirmer.",
|
||||
"expectedOutcome": "Le retour est traité, le véhicule reçoit un statut approprié, et un problème de qualité des données est créé automatiquement."
|
||||
},
|
||||
"handle-quality-issue": {
|
||||
"title": "4. Traiter le problème de qualité créé",
|
||||
"whatYouWillSee": "Un nouveau problème « anomalie de kilométrage » en haut de la file de travail.",
|
||||
"whyItMatters": "Chaque problème détecté a une étape de résolution unique et délimitée — rien n'est corrigé automatiquement en silence.",
|
||||
"startAction": "Ouvrez l'aperçu qualité des données et choisissez le problème le plus récent.",
|
||||
"expectedOutcome": "Vous voyez l'action recommandée, choisissez une résolution, et le problème se résout avec une piste d'audit."
|
||||
},
|
||||
"merge-duplicate": {
|
||||
"title": "5. Examiner et traiter un client potentiellement en double",
|
||||
"whatYouWillSee": "Deux profils clients avec le même e-mail et numéro de téléphone, comparés côte à côte.",
|
||||
"whyItMatters": "Les clients en double divisent l'historique de réservation et compliquent la communication.",
|
||||
"startAction": "Comparez les deux profils et choisissez celui qui doit survivre.",
|
||||
"expectedOutcome": "Les profils sont fusionnés, les réservations sont transférées, et le profil perdant devient une fiche archivée."
|
||||
},
|
||||
"ask-knowledge": {
|
||||
"title": "6. Poser une question à l'assistant de procédures",
|
||||
"whatYouWillSee": "Une réponse avec citation issue de la base de connaissances de démo délimitée.",
|
||||
"whyItMatters": "Le personnel a besoin d'une réponse rapide et étayée sur les procédures, sans deviner.",
|
||||
"startAction": "Cliquez sur l'une des questions suggérées, dans la langue actuelle de l'interface.",
|
||||
"expectedOutcome": "Vous voyez la réponse, la procédure utilisée et le texte source — ou un honnête « informations insuffisantes » si aucune n'existe."
|
||||
},
|
||||
"check-automation-audit": {
|
||||
"title": "7. Vérifier l'automatisation et la piste d'audit",
|
||||
"whatYouWillSee": "Le statut de livraison de la tâche d'automatisation de votre retour, et ses événements d'audit liés.",
|
||||
"whyItMatters": "Chaque action importante doit être traçable : qui a fait quoi, quand, et ce qui a suivi.",
|
||||
"startAction": "Ouvrez Intégrations pour voir le statut de livraison, et Piste d'audit pour l'historique complet.",
|
||||
"expectedOutcome": "Vous voyez une livraison réussie (ou récupérable) et une piste d'audit lisible de vos actions."
|
||||
},
|
||||
"review-real-vs-simulated": {
|
||||
"title": "8. Passer en revue ce qui est réel, simulé ou pas encore connecté",
|
||||
"whatYouWillSee": "Un aperçu de ce qui est fonctionnellement implémenté dans cette démo, ce qui est synthétique, et quelles intégrations ne sont pas encore en direct.",
|
||||
"whyItMatters": "Une démo n'est convaincante que si les visiteurs peuvent vérifier eux-mêmes ce qui fonctionne réellement et ce qui reste à venir.",
|
||||
"startAction": "Lisez la page « À propos de cette démo ».",
|
||||
"expectedOutcome": "Vous pouvez expliquer vous-même ce que {{productName}} est et n'est pas, sans explication verbale nécessaire."
|
||||
}
|
||||
}
|
||||
},
|
||||
"highlights": {
|
||||
"eyebrow": "Points forts pour recruteurs",
|
||||
"title": "Les choix les plus forts en 90 secondes",
|
||||
"description": "Trois vues courtes montrent comment {{productName}} combine fiabilité opérationnelle, corrections contrôlées et IA fondée sur des sources.",
|
||||
"duration": "± 30 s",
|
||||
"open": "Voir dans la plateforme",
|
||||
"proofLabel": "Preuve technique",
|
||||
"return": { "title": "Un retour qui ne peut pas échouer silencieusement", "body": "Découvrez la validation d'impact côté serveur, la persistance atomique et une outbox qui découple l'automatisation externe du comptoir.", "proof": "Transaction · idempotence · outbox · dégradation" },
|
||||
"quality": { "title": "Qualité des données sous contrôle humain", "body": "Comparez les profils en double, choisissez délibérément la fiche survivante et conservez l'historique des réservations et de l'audit.", "proof": "Règles explicables · aperçu · audit · rollback" },
|
||||
"knowledge": { "title": "Une IA qui doit montrer ses sources", "body": "Posez une question de procédure et inspectez la réponse, les extraits sources et l'état honnête de preuves insuffisantes.", "proof": "RAGcore · citations · dégradation sûre" },
|
||||
"storyTitle": "Voir les choix derrière le système ?",
|
||||
"storyBody": "La page Ingénierie relie les flux produit à l'architecture, la fiabilité et l'état honnête des intégrations.",
|
||||
"storyButton": "Ouvrir l'histoire d'ingénierie",
|
||||
"fullTourButton": "Démarrer la démo complète"
|
||||
},
|
||||
"scenarios": {
|
||||
"eyebrow": "Scénarios de démonstration",
|
||||
"title": "Essayer un scénario de démonstration",
|
||||
"description": "Cinq scénarios ciblés qui utilisent toujours les mêmes réservations, clients et véhicules fixes — toujours retrouvables après une réinitialisation.",
|
||||
"loading": "Chargement des scénarios…",
|
||||
"unavailable": "Les scénarios de démonstration n'ont pas pu être chargés. Veuillez réessayer.",
|
||||
"ready": "Prêt pour la démo",
|
||||
"notReady": "Non disponible",
|
||||
"duration": "Durée",
|
||||
"durationValue": "± {{minutes}} min",
|
||||
"role": "Rôle",
|
||||
"demonstrates": "Démontre :",
|
||||
"requiresRole": "Nécessite le rôle : {{roles}}.",
|
||||
"startScenario": "Démarrer le scénario",
|
||||
"roleOr": "{{a}} ou {{b}}",
|
||||
"roles": {
|
||||
"operations_manager": "Responsable des opérations",
|
||||
"rental_employee": "Collaborateur de location"
|
||||
},
|
||||
"items": {
|
||||
"return-anomaly": {
|
||||
"title": "Retour avec anomalie de kilométrage",
|
||||
"problem": "Un véhicule revient avec un relevé de kilométrage inférieur au dernier relevé enregistré — un signe d'erreur de saisie ou de véhicule confondu.",
|
||||
"demonstrates": "Le traitement du retour, la détection automatique de problèmes de qualité des données et la piste d'audit qui en résulte."
|
||||
},
|
||||
"duplicate-customer": {
|
||||
"title": "Fusionner un possible client en double",
|
||||
"problem": "Deux fiches client partagent la même adresse e-mail et le même numéro de téléphone — probablement la même personne, enregistrée deux fois.",
|
||||
"demonstrates": "La fusion de clients avec conservation de l'historique des réservations et de la piste d'audit."
|
||||
},
|
||||
"booking-overlap": {
|
||||
"title": "Résoudre des réservations qui se chevauchent",
|
||||
"problem": "Un véhicule est réservé en double pour des périodes qui se chevauchent — une erreur de planification à résoudre avant le départ.",
|
||||
"demonstrates": "La détection et la résolution contrôlée des conflits de planification."
|
||||
},
|
||||
"automation-retry": {
|
||||
"title": "Relancer une tâche d'automatisation échouée",
|
||||
"problem": "Un événement précédent n'a pas pu être livré à l'automatisation en raison d'une erreur de connexion simulée.",
|
||||
"demonstrates": "Une livraison fiable avec des nouvelles tentatives limitées et un statut d'échec visible."
|
||||
},
|
||||
"knowledge-question": {
|
||||
"title": "Poser une question de procédure",
|
||||
"problem": "Un employé n'est pas sûr de la procédure applicable à une situation opérationnelle précise.",
|
||||
"demonstrates": "Des réponses étayées par les sources, issues d'une base de connaissances de démo délimitée."
|
||||
}
|
||||
},
|
||||
"blockedReasons": {
|
||||
"bookingNotFound": "Réservation de démo BK-DEMO-RETURN introuvable. {{resetHint}}",
|
||||
"bookingAlreadyProcessed": "Cette réservation a déjà été traitée depuis la dernière réinitialisation. {{resetHint}}",
|
||||
"duplicateIssueNotFound": "Problème de démo DQ-DEMO-DUPLICATE introuvable. {{resetHint}}",
|
||||
"issueAlreadyResolved": "Ce problème a déjà été résolu depuis la dernière réinitialisation. {{resetHint}}",
|
||||
"overlapIssueNotFound": "Problème de démo DQ-DEMO-OVERLAP introuvable. {{resetHint}}",
|
||||
"failedEventNotFound": "L'événement simulé en échec est introuvable. {{resetHint}}",
|
||||
"eventAlreadyRecovered": "Cet événement a déjà été rétabli depuis la dernière réinitialisation. {{resetHint}}",
|
||||
"knowledgeUnavailable": "La base de connaissances de démo est actuellement indisponible.",
|
||||
"resetHint": "Réinitialisez les données de démo pour rendre ce scénario à nouveau disponible."
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"eyebrow": "À propos de cette démo",
|
||||
"title": "Ce que {{productName}} est et n'est pas",
|
||||
"description": "{{orgName}} est une organisation de location fictive qui rend cette démo concrète — pas une véritable entreprise.",
|
||||
"loading": "Chargement des informations de démo…",
|
||||
"unavailable": "Les informations de démonstration n'ont pas pu être chargées. Veuillez réessayer.",
|
||||
"ctaTitle": "Choisissez le temps dont vous disposez",
|
||||
"ctaBody": "Découvrez les trois moments d'ingénierie les plus forts en 90 secondes, ou suivez la démo opérationnelle complète.",
|
||||
"ctaButton": "Démarrer la démo complète",
|
||||
"quickTourButton": "Voir les points forts · 90 s",
|
||||
"scenarioButton": "Ouvrir le laboratoire de scénarios",
|
||||
"proofEyebrow": "Preuves d'ingénierie",
|
||||
"proofTitle": "Contrôle, fiabilité et explicabilité",
|
||||
"proofDescription": "Chaque choix technique soutient une action opérationnelle concrète.",
|
||||
"proofRulesTitle": "Les règles restent dans le domaine",
|
||||
"proofRulesBody": "FastAPI régit les retours, les statuts et les résolutions de qualité. n8n et les prompts ne prennent jamais de décisions faisant autorité.",
|
||||
"proofReliabilityTitle": "Valider d'abord, orchestrer ensuite",
|
||||
"proofReliabilityBody": "L'opération est validée atomiquement ; une outbox livre ensuite à n8n avec des tentatives limitées. Une panne ne bloque jamais le comptoir.",
|
||||
"proofAiTitle": "L'IA doit montrer ses preuves",
|
||||
"proofAiBody": "Les réponses RAGcore incluent des sources. Sans preuves suffisantes, l'assistant n'invente pas de réponse.",
|
||||
"architectureFlowTitle": "De l'action utilisateur au suivi contrôlé",
|
||||
"architectureFlowDescription": "Le système de référence reste séparé de l'orchestration externe et de l'IA.",
|
||||
"architectureFrontend": "Interface React accessible",
|
||||
"architectureApi": "Règles métier FastAPI",
|
||||
"architectureDatabase": "PostgreSQL + piste d'audit",
|
||||
"architectureOutbox": "Outbox fiable",
|
||||
"architectureExternal": "Services externes : n8n, RAGcore et MCP Hub",
|
||||
"architectureZonesLabel": "Limites de responsabilité de l'architecture",
|
||||
"architectureZoneIntent": "Intention utilisateur",
|
||||
"architectureZoneTransaction": "Transaction locale",
|
||||
"architectureZoneEdge": "Périphérie système récupérable",
|
||||
"architectureFlowLabel": "Flux système interactif de l'interface aux services externes",
|
||||
"architectureCommitBoundary": "Après validation",
|
||||
"architectureInteractionHint": "Sélectionnez une étape pour examiner sa garantie opérationnelle et ses points de contrôle.",
|
||||
"architectureSelectedStep": "Étape {{current}} sur {{total}}",
|
||||
"architectureBoundaryLabel": "Responsabilité",
|
||||
"architectureEvidenceLabel": "Points de contrôle",
|
||||
"architectureDetails": {
|
||||
"Frontend": {
|
||||
"summary": "Rend le rôle et l'intention explicites",
|
||||
"body": "Des formulaires accessibles recueillent les données validées et montrent l'impact avant confirmation. L'interface ne décide jamais seule d'un statut métier.",
|
||||
"proofOne": "Garde de route par rôle",
|
||||
"proofTwo": "Aperçu avant confirmation"
|
||||
},
|
||||
"Api": {
|
||||
"summary": "Valide chaque règle opérationnelle",
|
||||
"body": "FastAPI applique les statuts, invariants et règles de résolution à la frontière de l'API, indépendamment de ce que le navigateur envoie.",
|
||||
"proofOne": "Validation Pydantic",
|
||||
"proofTwo": "Décision métier côté serveur"
|
||||
},
|
||||
"Database": {
|
||||
"summary": "Valide les données et l'audit atomiquement",
|
||||
"body": "PostgreSQL enregistre la modification opérationnelle et sa preuve d'audit dans la même transaction, empêchant tout changement d'état partiel.",
|
||||
"proofOne": "UUID + référence publique",
|
||||
"proofTwo": "UTC + piste d'audit continue"
|
||||
},
|
||||
"Outbox": {
|
||||
"summary": "Enregistre durablement le suivi",
|
||||
"body": "L'enregistrement outbox est validé avec l'opération. Un worker ne livre qu'ensuite, de façon idempotente et avec des tentatives limitées, vers l'orchestration.",
|
||||
"proofOne": "Livraison après validation",
|
||||
"proofTwo": "Idempotence + tentatives limitées"
|
||||
},
|
||||
"External": {
|
||||
"summary": "Se dégrade sans perte locale",
|
||||
"body": "n8n, RAGcore et MCP Hub disposent de délais d'attente et d'un état de santé visible. Une panne reste récupérable et l'IA ne répond jamais sans preuves suffisantes.",
|
||||
"proofOne": "État de santé + délais",
|
||||
"proofTwo": "Aucune réponse sans preuve"
|
||||
}
|
||||
},
|
||||
"verificationTitle": "Construit pour être vérifiable",
|
||||
"verificationBody": "Les règles métier, contrats d'API, modes dégradés et la démo complète sont testés automatiquement. Le dépôt contient les commandes d'acceptation exactes et le dossier de preuves.",
|
||||
"problemTitle": "Le problème fictif",
|
||||
"problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. {{productName}} montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.",
|
||||
"scopeTitle": "Pour qui et avec quelle portée",
|
||||
"scopeBody": "Cette démo s'adresse à quiconque veut voir comment {{productName}} traite les problèmes opérationnels d'un petit loueur : Responsables des opérations et Collaborateurs de location, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.",
|
||||
"realTitle": "Ce qui fonctionne réellement",
|
||||
"realBody": "Tout ce qui suit est du code fonctionnel, pas seulement une maquette : accès et sessions basés sur les rôles, gestion des véhicules et réservations, traitement des retours avec validation côté serveur, cinq règles de qualité des données avec chacune sa propre étape de résolution, une piste d'audit complète, une livraison automatisée vers n8n avec nouvelles tentatives limitées, un déploiement basé sur Docker, et une suite de tests automatisés (backend et Playwright de bout en bout).",
|
||||
"syntheticTitle": "Ce qui est synthétique",
|
||||
"syntheticBody": "L'organisation, tous les clients, véhicules, réservations, historiques d'entretien, procédures de la base de connaissances et les scénarios préconfigurés sont entièrement inventés. Aucune donnée ne fait référence à une personne, un véhicule ou une entreprise réels ; les adresses e-mail utilisent uniquement le domaine de test {{testDomain}}.",
|
||||
"architectureTitle": "Architecture en bref",
|
||||
"architectureBody": "Un frontend React/TypeScript communique avec un backend FastAPI (PostgreSQL via des migrations SQLAlchemy/Alembic) ; les règles métier importantes vivent dans le backend, pas dans n8n ni dans des prompts. Les retours et autres événements sont d'abord validés localement, puis livrés de façon asynchrone à n8n via une tâche d'automatisation, de sorte qu'une panne temporaire de l'automatisation ne bloque jamais une action opérationnelle.",
|
||||
"securityTitle": "Sécurité et accès",
|
||||
"securityBody": "L'accès passe par des cookies de session signés, HTTP-only, par rôle ; chaque rôle est lié à un ensemble de routes autorisées, appliqué côté serveur et reflété dans la navigation. Les changements d'état importants sont toujours vérifiés et enregistrés — jamais corrigés automatiquement en silence.",
|
||||
"testingTitle": "Comment cela est testé",
|
||||
"testingBody": "Une suite de tests backend automatisée couvre les règles métier et les contrats d'API ; une suite Playwright de bout en bout complète couvre les parcours utilisateurs, y compris cette expérience de démo elle-même en trois langues. Chaque modification est également validée sur une installation propre (base de données vide, reconstruite à partir des données initiales) avant le déploiement.",
|
||||
"integrationsTitle": "Intégrations — étiquetées honnêtement",
|
||||
"integrationsDescription": "Ce qui est opérationnel, ce qui est en mode démo, et ce qui n'est pas encore connecté.",
|
||||
"resetTitle": "Restaurer l'environnement de démo",
|
||||
"resetBodyManager": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Utilisez <strong>Réinitialiser les données de démo</strong> dans la barre latérale pour recommencer.",
|
||||
"resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Un Responsable des opérations peut réinitialiser l'environnement de démo via la barre latérale.",
|
||||
"limitationsTitle": "Limitations",
|
||||
"limitationsBody": "Il s'agit d'une plateforme opérationnelle ciblée, pas d'un ERP complet. La comptabilité, les paiements, les réservations publiques et un CRM générique sont délibérément hors périmètre. Les intégrations externes sont évaluées séparément et honnêtement à partir de preuves récentes.",
|
||||
"unknown": "inconnu"
|
||||
},
|
||||
"integrationSummary": {
|
||||
"titles": {
|
||||
"n8n": "Automatisation (n8n)",
|
||||
"ragcore": "Assistant de connaissances (RAGcore)",
|
||||
"mcp_hub": "ITWorx MCP Hub"
|
||||
},
|
||||
"n8nDetail": "{{succeeded}} réussi(s) · {{failed}} échoué(s) · {{pending}} en attente.",
|
||||
"ragcoreDetail": "{{count}} procédures indexées dans {{collection}}.",
|
||||
"ragcoreSourceDetail": "{{source_count}} procédures sources gérées sont disponibles ; RAGcore ne fournit pas de comptage d'index fiable.",
|
||||
"mcpDetailOperational": "Des appels d'outils contrôlés en direct depuis le Hub sont confirmés dans la piste d'audit.",
|
||||
"mcpDetailPrepared": "Quatre outils en lecture seule sont prêts contractuellement ; aucun appel récent du Hub n'a été enregistré."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"generic": {
|
||||
"title": "Une erreur s'est produite",
|
||||
"explanation": "Cette action n'a pas pu être terminée. Veuillez réessayer."
|
||||
},
|
||||
"codes": {
|
||||
"VEHICLE_NOT_FOUND": {
|
||||
"title": "Véhicule introuvable",
|
||||
"explanation": "Ce véhicule est introuvable. Il a peut-être été supprimé, ou la référence est incorrecte."
|
||||
},
|
||||
"BOOKING_NOT_FOUND": {
|
||||
"title": "Réservation introuvable",
|
||||
"explanation": "Cette réservation est introuvable. Elle a peut-être été supprimée, ou la référence est incorrecte."
|
||||
},
|
||||
"CUSTOMER_NOT_FOUND": {
|
||||
"title": "Client introuvable",
|
||||
"explanation": "Cette fiche client est introuvable."
|
||||
},
|
||||
"CUSTOMER_ALREADY_MERGED": {
|
||||
"title": "Client déjà fusionné",
|
||||
"explanation": "L'une de ces fiches client a déjà été fusionnée avec une autre fiche.",
|
||||
"nextStep": "Rechargez le problème ; il est peut-être déjà résolu."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"ISSUE_CHANGED": {
|
||||
"title": "Les éléments de preuve ont changé",
|
||||
"explanation": "De nouveaux éléments ont été enregistrés pendant la préparation de cette correction.",
|
||||
"nextStep": "Actualisez le problème et réexaminez les éléments actuels avant de décider."
|
||||
},
|
||||
"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_EVENT_CORRELATION": {
|
||||
"title": "Référence de corrélation non valide",
|
||||
"explanation": "Le rappel d'automatisation ne contient pas de référence de corrélation valide."
|
||||
},
|
||||
"CALLBACK_EVENT_MISMATCH": {
|
||||
"title": "Le rappel concerne un autre événement",
|
||||
"explanation": "Le rappel d'automatisation fait référence à un événement différent de celui qui est mis à jour."
|
||||
},
|
||||
"CALLBACK_CORRELATION_MISMATCH": {
|
||||
"title": "La trace du rappel ne correspond pas",
|
||||
"explanation": "Le rappel d'automatisation ne correspond pas à la référence de corrélation enregistrée pour cet événement."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"UNKNOWN_WORKFLOW": {
|
||||
"title": "Workflow d’automatisation inconnu",
|
||||
"explanation": "Ce workflow ne fait pas partie des quatre automatisations gérées."
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"list": {
|
||||
"eyebrow": "Flotte / Registre",
|
||||
"title": "Flotte de véhicules",
|
||||
"description": "État opérationnel en direct, localisation et disponibilité pour l'entretien.",
|
||||
"searchLabel": "Rechercher",
|
||||
"searchPlaceholder": "Référence, marque ou localisation",
|
||||
"statusLabel": "Statut",
|
||||
"statusAll": "Tous les statuts",
|
||||
"attentionOnly": "Attention uniquement",
|
||||
"locationFilter": "Site",
|
||||
"locationPlaceholder": "Site opérationnel exact",
|
||||
"serviceDue": "Entretien en retard",
|
||||
"serviceRemaining": "{{count}} km restants",
|
||||
"attentionReasons": {
|
||||
"blocked_status": "Statut bloqué",
|
||||
"service_due": "Entretien en retard",
|
||||
"data_quality": "Problème de qualité ouvert"
|
||||
},
|
||||
"loading": "Chargement du registre de la flotte…",
|
||||
"empty": "Aucun véhicule trouvé",
|
||||
"emptyDetail": "Ajustez les filtres de flotte actuels.",
|
||||
"noMatch": "Aucun véhicule correspondant",
|
||||
"noMatchDetail": "Essayez un terme de recherche plus large.",
|
||||
"unavailable": "La liste des véhicules est actuellement indisponible.",
|
||||
"count": "{{count}} véhicules",
|
||||
"persisted": "Données de flotte persistantes",
|
||||
"columns": {
|
||||
"reference": "Référence",
|
||||
"makeModel": "Marque / modèle",
|
||||
"location": "Localisation",
|
||||
"status": "Statut",
|
||||
"odometer": "Kilométrage (km)",
|
||||
"service": "Entretien",
|
||||
"nextBooking": "Prochaine réservation",
|
||||
"attention": "Attention"
|
||||
},
|
||||
"needsAttention": "Nécessite de l'attention"
|
||||
},
|
||||
"statuses": {
|
||||
"available": "disponible",
|
||||
"rented": "loué",
|
||||
"cleaning": "nettoyage",
|
||||
"maintenance": "entretien",
|
||||
"blocked": "bloqué"
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Registre de la flotte",
|
||||
"eyebrow": "Flotte / Fiche véhicule",
|
||||
"notFound": "Ce véhicule est introuvable.",
|
||||
"loading": "Chargement de la fiche véhicule…",
|
||||
"needsAttention": "Nécessite de l'attention",
|
||||
"tabs": {
|
||||
"overview": "Aperçu",
|
||||
"bookings": "Réservations",
|
||||
"inspections": "Inspections",
|
||||
"maintenance": "Entretien",
|
||||
"quality": "Qualité"
|
||||
},
|
||||
"tabsAriaLabel": "Sections du véhicule",
|
||||
"overview": {
|
||||
"registration": "Immatriculation",
|
||||
"modelYear": "Année du modèle",
|
||||
"location": "Localisation",
|
||||
"odometer": "Kilométrage",
|
||||
"nextService": "Prochain entretien",
|
||||
"active": "Actif"
|
||||
},
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"noBookings": "Aucune réservation enregistrée.",
|
||||
"noInspections": "Aucune inspection enregistrée.",
|
||||
"noMaintenance": "Aucun historique d'entretien.",
|
||||
"noQualityIssues": "Aucun problème de qualité enregistré.",
|
||||
"fuel": "Carburant {{percent}}%",
|
||||
"damage": "Dommage",
|
||||
"technicalWarning": "Avertissement technique",
|
||||
"inspectionTypes": {
|
||||
"departure": "Inspection de départ",
|
||||
"return": "Inspection de retour"
|
||||
},
|
||||
"maintenanceCategories": {
|
||||
"periodic_service": "Entretien périodique",
|
||||
"repair": "Réparation",
|
||||
"inspection": "Contrôle technique",
|
||||
"tyres": "Pneus",
|
||||
"other": "Autre"
|
||||
}
|
||||
},
|
||||
"maintenance": { "actionsTitle": "Actions d’entretien", "actionsDescription": "Enregistrez les travaux effectués ou remettez un véhicule sûr en service.", "add": "Enregistrer un entretien", "date": "Date", "odometer": "Kilométrage", "category": "Catégorie", "nextService": "Prochain entretien (km)", "summary": "Travaux effectués", "cancel": "Annuler", "save": "Enregistrer", "saving": "Enregistrement…", "failed": "L’entretien n’a pas pu être enregistré.", "releaseReason": "Motif de remise en service", "releasePlaceholder": "Quel contrôle confirme que le véhicule peut être remis en service ?", "release": "Remettre en service", "releaseFailed": "Le véhicule ne peut pas encore être remis en service." }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"eyebrow": "Assurance / Intégrations",
|
||||
"title": "Contrôle des intégrations",
|
||||
"description": "Surveillez la santé des livraisons, la dégradation contrôlée et les événements de workflow réessayables.",
|
||||
"cards": {
|
||||
"checking": "Récupération de l’état actuel des intégrations…",
|
||||
"orchestrationKicker": "Orchestration",
|
||||
"n8nTitle": "Livraison n8n",
|
||||
"n8nSummary": "{{succeeded}} réussies · {{failed}} échouées · {{pending}} en attente · {{delivering}} en cours",
|
||||
"n8nFallback": "Les événements de retour sont d'abord validés localement, puis livrés via la tâche d'automatisation.",
|
||||
"knowledgeKicker": "Connaissances",
|
||||
"knowledgeTitle": "Assistant de connaissances",
|
||||
"knowledgeSummaryDemo": "Base de connaissances de démo · {{count}} procédures indexées dans {{collection}}.",
|
||||
"knowledgeSummaryRagcore": "RAGcore · {{count}} procédures indexées dans {{collection}}.",
|
||||
"knowledgeSummaryIndexUnknown": "RAGcore accessible · {{sourceCount}} documents sources dans cette langue · taille d’index non vérifiable pour {{collection}}.",
|
||||
"knowledgeSummarySyncReported": "RAGcore accessible · {{sourceCount}} sources dans cette langue · dernier rapport : {{synced}} traités, {{failed}} échoués à {{when}} · taille d’index non vérifiable pour {{collection}}.",
|
||||
"knowledgeUnavailable": "Preuves de santé actuellement indisponibles.",
|
||||
"gatewayKicker": "Passerelle d'outils",
|
||||
"mcpTitle": "MCP Hub",
|
||||
"mcpEnabled": "L'enregistrement est activé pour ce déploiement.",
|
||||
"mcpNotConnected": "Pas encore connecté — préparé pour de futurs appels d'outils contrôlés depuis le Hub.",
|
||||
"mcpNoEvidence": "Enregistré, mais aucun appel d'outil n'a encore été consigné.",
|
||||
"mcpEvidence": "ITWorx MCP Hub a exécuté {{tool}} en dernier · {{count}} appels contrôlés au total",
|
||||
"mcpTechnicalIdentity": "Identité client technique",
|
||||
"mcpHubReachable": "Hub accessible",
|
||||
"mcpHubUnreachable": "Hub inaccessible",
|
||||
"n8nDemoScenario": "Plus {{count}} scénario de démonstration préparé — une panne temporaire simulée, pas un problème d'intégration."
|
||||
},
|
||||
"statusLabels": {
|
||||
"checking": "Vérification…",
|
||||
"notConnected": "Non connecté",
|
||||
"deliveryFailed": "Livraison échouée",
|
||||
"retryAvailable": "Nouvelle tentative possible",
|
||||
"operational": "Opérationnel",
|
||||
"prepared": "Préparé",
|
||||
"demoMode": "Mode démo",
|
||||
"unavailable": "Indisponible"
|
||||
},
|
||||
"workflows": {
|
||||
"title": "Workflows d'automatisation",
|
||||
"description": "{{known}} workflows n8n canoniques sur {{expected}} disposent de preuves actuelles de fonctionnement.",
|
||||
"loadingEvidence": "Récupération des preuves actuelles des workflows…",
|
||||
"unavailable": "Les preuves des workflows sont actuellement indisponibles.",
|
||||
"notBuilt": "Pas encore créé",
|
||||
"noEvidence": "Aucune preuve pour l’instant",
|
||||
"healthy": "Sain",
|
||||
"stale": "Preuve obsolète",
|
||||
"failed": "Dernière exécution échouée",
|
||||
"executionId": "Exécution",
|
||||
"columns": {
|
||||
"name": "Workflow",
|
||||
"status": "Statut",
|
||||
"lastSeen": "Dernière preuve",
|
||||
"technicalId": "Détails"
|
||||
},
|
||||
"names": {
|
||||
"vehicleReturn": "Orchestration du retour de véhicule",
|
||||
"scheduledScan": "Analyse planifiée de la qualité des données",
|
||||
"ragcoreSync": "Synchronisation des procédures de connaissances",
|
||||
"errorHandler": "Gestionnaire d'erreurs de workflow"
|
||||
},
|
||||
"expectations": {
|
||||
"vehicleReturn": "Déclenché après l’enregistrement sécurisé d’un retour de véhicule.",
|
||||
"scheduledScan": "Planifié ; s’exécute normalement chaque heure.",
|
||||
"ragcoreSync": "Planifié ; s’exécute normalement chaque jour.",
|
||||
"errorHandler": "S’exécute uniquement lorsqu’un workflow réel échoue."
|
||||
},
|
||||
"errorHandler": {
|
||||
"summary": "{{count}} échec(s) d'automatisation enregistré(s) — le dernier provient de {{workflow}} à {{when}}.",
|
||||
"summaryEmpty": "Aucun échec d'automatisation n'a été enregistré."
|
||||
}
|
||||
},
|
||||
"ledger": {
|
||||
"title": "Tâches d'automatisation",
|
||||
"description": "Tentatives d'automatisation enregistrées avec les dernières preuves d'échec.",
|
||||
"filterLabel": "Vue",
|
||||
"filterNeedsAttention": "Nécessite de l'attention",
|
||||
"filterRecent": "Récent",
|
||||
"filterSucceeded": "Réussi",
|
||||
"filterAll": "Tout",
|
||||
"statusFilterLabel": "Statut",
|
||||
"statusAll": "Tous les statuts",
|
||||
"statusPending": "En attente",
|
||||
"statusDelivering": "En cours",
|
||||
"statusSucceeded": "Réussi",
|
||||
"statusFailed": "Échoué",
|
||||
"unavailable": "Les tâches d'automatisation sont actuellement indisponibles.",
|
||||
"managerOnly": "L'automatisation est visible uniquement pour les Responsables des opérations.",
|
||||
"loading": "Chargement des tâches d'automatisation…",
|
||||
"empty": "Aucune tâche d'automatisation ne correspond à ce filtre.",
|
||||
"count": "{{count}} événements de workflow",
|
||||
"boundedRetries": "Nouvelles tentatives limitées",
|
||||
"groupedSucceeded": "{{count}} retours de véhicules traités avec succès",
|
||||
"showIndividually": "Afficher les tâches individuelles",
|
||||
"hideIndividually": "Masquer les tâches individuelles",
|
||||
"columns": {
|
||||
"event": "Tâche",
|
||||
"type": "Type",
|
||||
"booking": "Réservation",
|
||||
"status": "Statut",
|
||||
"attempts": "Tentatives",
|
||||
"lastError": "Dernière erreur",
|
||||
"when": "Quand",
|
||||
"action": "Action"
|
||||
},
|
||||
"retry": "Réessayer",
|
||||
"retrying": "Nouvelle tentative…",
|
||||
"retryFailed": "Impossible de réessayer cette livraison.",
|
||||
"noAction": "—",
|
||||
"eventTypes": {
|
||||
"vehicle.returned.v1": "Retour de véhicule traité"
|
||||
},
|
||||
"errorCodes": {
|
||||
"connectionError": "Le service d'automatisation était temporairement injoignable. Le retour a été enregistré en toute sécurité et peut être soumis à nouveau.",
|
||||
"malformedPayload": "La tâche contenait des données incomplètes et n'a pas pu être livrée. Les données sous-jacentes restent enregistrées en toute sécurité.",
|
||||
"remoteReportedFailure": "Le service d'automatisation a refusé la livraison. La tâche peut être soumise à nouveau.",
|
||||
"staleLeaseRecovered": "Cette tâche a été récupérée après qu'une tentative de livraison précédente s'est arrêtée sans résultat.",
|
||||
"unknownError": "Une erreur inattendue s'est produite lors de la livraison de cette tâche.",
|
||||
"demoScenarioTimeout": "Délai d'attente simulé vers le service de workflow. Cette panne fait partie du scénario de démonstration préparé, ce n'est pas un incident réel."
|
||||
},
|
||||
"demoScenarioBadge": "Scénario de démonstration préparé",
|
||||
"demoScenarioExplanation": "Panne temporaire simulée, placée volontairement dans les données de démonstration pour montrer la relance et l'audit. Elle n'affecte pas la santé de l'automatisation.",
|
||||
"retryDemoScenario": "Relancer le scénario de démonstration"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"eyebrow": "Assurance / Connaissances étayées",
|
||||
"title": "Connaissance des procédures",
|
||||
"description": "Posez des questions opérationnelles. Les réponses ne s'affichent que lorsque {{provider}} renvoie suffisamment de preuves citées.",
|
||||
"providerDemo": "la base de connaissances de démo",
|
||||
"providerRagcore": "RAGcore",
|
||||
"providerChecking": "la source de connaissances",
|
||||
"providerUnavailable": "le service de connaissances",
|
||||
"statusChecking": "Vérification de la source…",
|
||||
"statusCheckingDetail": "Récupération de l’état actuel du fournisseur.",
|
||||
"statusAvailable": "Disponible",
|
||||
"statusUnavailable": "Indisponible",
|
||||
"proceduresIndexed": "{{count}} procédures indexées",
|
||||
"proceduresIndexUnknown": "La taille de l'index n'est pas disponible via cette connexion",
|
||||
"proceduresAvailable": "{{count}} procédures sources gérées disponibles",
|
||||
"technicalDetails": "Détails techniques du fournisseur",
|
||||
"progress": { "retrieving": "Sélection des sources", "checking": "Vérification des preuves", "composing": "Construction de la réponse" },
|
||||
"sourceTitles": {
|
||||
"damage-procedure": "Dommages au retour du véhicule",
|
||||
"vehicle-availability-procedure": "Rétablir la disponibilité du véhicule",
|
||||
"vehicle-return-procedure": "Enregistrer un retour de véhicule",
|
||||
"odometer-anomaly-procedure": "Examiner une anomalie kilométrique",
|
||||
"checkout-procedure": "Contrôles obligatoires au départ"
|
||||
},
|
||||
"statistics": {
|
||||
"scope": "Portée linguistique : les chiffres d’index et de sources concernent la langue actuelle ; le rapport n8n couvre les trois langues.",
|
||||
"verifiedIndexed": "correspondance exacte et publication active dans cette langue",
|
||||
"sourceDocuments": "documents sources dans cette langue",
|
||||
"reportedSynced": "synchronisation signalée par n8n",
|
||||
"reportedFailed": "échecs de synchronisation signalés",
|
||||
"lastSync": "Dernier rapport de synchronisation : {{when}}. L’état de l’index est contrôlé indépendamment via l’identité exacte et la version RAGcore active de chaque source gérée.",
|
||||
"noSyncReport": "Aucun rapport de synchronisation reçu. Les preuves d’indexation sont contrôlées séparément via les versions RAGcore actives."
|
||||
},
|
||||
"providerNote": "Cette démo répond à partir d'un petit ensemble fixe de procédures indexées — pas d'une connexion RAGcore en direct. Un backend RAGcore en direct reprendra plus tard la même interface sans changer le fonctionnement de cette page.",
|
||||
"askHeading": "Poser une question de procédure",
|
||||
"askSubheading": "Recherche → vérification des preuves → réponse étayée",
|
||||
"questionLabel": "Question",
|
||||
"questionPlaceholder": "p. ex. Que dois-je faire quand un véhicule revient endommagé ?",
|
||||
"ask": "Demander",
|
||||
"asking": "Question en cours…",
|
||||
"askFailed": "Impossible de joindre le service de connaissances.",
|
||||
"feedbackFailed": "Impossible d’enregistrer le retour.",
|
||||
"suggestedLabel": "Essayez :",
|
||||
"suggestedQuestions": [
|
||||
"Que dois-je faire quand un véhicule revient avec des dommages ?",
|
||||
"Comment enregistrer le retour d'un véhicule ?",
|
||||
"Quand un véhicule peut-il redevenir disponible ?",
|
||||
"Qui examine un relevé de kilométrage inhabituel ?",
|
||||
"Quels contrôles sont requis avant le départ ?"
|
||||
],
|
||||
"emptyTitle": "Des preuves avant les réponses",
|
||||
"emptyDescription": "Posez des questions sur les retours, les dommages, les inspections ou une autre procédure indexée. {{productName}} n'invente jamais de réponse en l'absence de preuves.",
|
||||
"retrievalFlow": {
|
||||
"question": "Question",
|
||||
"sources": "Sources",
|
||||
"answer": "Réponse"
|
||||
},
|
||||
"questionLabelExchange": "Question",
|
||||
"evidenceStates": {
|
||||
"grounded": "Étayé par des procédures citées",
|
||||
"insufficient": "Preuves insuffisantes",
|
||||
"unavailable": "Service de connaissances indisponible"
|
||||
},
|
||||
"unavailableBody": "Le service de connaissances est actuellement inaccessible. Les fonctions opérationnelles ne sont pas affectées — réessayez plus tard.",
|
||||
"sourceVersion": "v{{version}}",
|
||||
"answeredIn": "Réponse en {{seconds}} s",
|
||||
"sourceTechnicalVersion": "Version technique de la source disponible dans l’audit",
|
||||
"feedbackLabel": "Avis sur la réponse",
|
||||
"feedbackPrompt": "Cette réponse était-elle utile ?",
|
||||
"feedbackThanks": "Avis enregistré",
|
||||
"feedbackHelpful": "Oui",
|
||||
"feedbackNotHelpful": "Non"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"skipToContent": "Aller au contenu principal",
|
||||
"groups": {
|
||||
"operate": "Exploiter",
|
||||
"assure": "Assurer"
|
||||
},
|
||||
"items": {
|
||||
"overview": "Aperçu",
|
||||
"fleet": "Flotte",
|
||||
"bookings": "Réservations",
|
||||
"quality": "Qualité des données",
|
||||
"knowledge": "Connaissances",
|
||||
"integrations": "Intégrations",
|
||||
"audit": "Piste d'audit",
|
||||
"users": "Utilisateurs",
|
||||
"privacy": "Confidentialité",
|
||||
"engineering": "Ingénierie"
|
||||
},
|
||||
"primaryNavLabel": "Navigation principale",
|
||||
"mobileNavLabel": "Navigation mobile",
|
||||
"more": "Plus",
|
||||
"openNavigation": "Ouvrir la navigation",
|
||||
"closeNavigation": "Fermer la navigation",
|
||||
"sidebarEnvironment": "Environnement de démo",
|
||||
"sidebarEnvironmentDetail": "Données synthétiques uniquement",
|
||||
"resetDemoData": "Réinitialiser les données de démo",
|
||||
"resetConfirmTitle": "Confirmer la réinitialisation",
|
||||
"resetConfirmBody": "Toutes les modifications synthétiques seront annulées et les données de démo déterministes restaurées. Vous serez déconnecté.",
|
||||
"resetting": "Réinitialisation…",
|
||||
"resetConfirmYes": "Oui, réinitialiser",
|
||||
"resetCancel": "Annuler",
|
||||
"resetFailed": "Impossible de réinitialiser les données de démo.",
|
||||
"searchLabel": "Rechercher dans {{productName}}",
|
||||
"searchPlaceholder": "Rechercher flotte, réservation ou section…",
|
||||
"searchShortcutHint": "Ctrl K",
|
||||
"searchSearching": "Recherche…",
|
||||
"searchUnavailable": "La recherche est momentanément indisponible.",
|
||||
"searchNoResults": "Aucun résultat pour « {{query}} ».",
|
||||
"searchVehicleSummary": "{{make}} {{model}} · {{location}}",
|
||||
"searchSections": {
|
||||
"overview": "Tableau de bord opérationnel",
|
||||
"fleet": "Registre de la flotte",
|
||||
"bookings": "Réservations de location",
|
||||
"quality": "Atelier qualité",
|
||||
"knowledge": "Assistant de procédures",
|
||||
"integrations": "Statut d'automatisation et d'intégration",
|
||||
"audit": "Historique d’audit",
|
||||
"privacy": "Gestion de la confidentialité"
|
||||
},
|
||||
"switchRole": "Changer de rôle",
|
||||
"switchRoleTitle": "Changer de rôle de démo",
|
||||
"languageSwitcherLabel": "Changer de langue"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "managerOnly": "L’administration des utilisateurs est réservée aux Responsables des opérations.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "edit": "Modifier", "editTitle": "Modifier l’utilisateur {{ref}}", "newPassword": "Nouveau mot de passe", "passwordUnchanged": "Laisser vide pour ne pas modifier", "saveChanges": "Enregistrer les modifications", "cancel": "Annuler", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
|
||||
"roles": { "operations_manager": "Responsable des opérations", "rental_employee": "Employé de location" }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"eyebrow": "Surveiller / Gouvernance",
|
||||
"title": "Gestion de la confidentialité",
|
||||
"description": "Exportez les données personnelles, contrôlez la conservation et anonymisez avec une piste d’audit complète.",
|
||||
"managerOnly": "Ces fonctions de gouvernance sont réservées aux responsables des opérations.",
|
||||
"loading": "Chargement de la politique…",
|
||||
"error": "L’action n’a pas pu être exécutée. Vérifiez la référence et la durée de conservation.",
|
||||
"policyTitle": "Politique de conservation",
|
||||
"totalCustomers": "Clients",
|
||||
"eligibleCustomers": "Éligibles",
|
||||
"anonymizedCustomers": "Anonymisés",
|
||||
"bookingRetention": "Conservation minimale des réservations",
|
||||
"auditRetention": "Conservation de l’audit",
|
||||
"days_one": "{{count}} jour",
|
||||
"days_other": "{{count}} jours",
|
||||
"customerRef": "Référence client",
|
||||
"exports": {"title":"Exports de données","detail":"Téléchargez un dossier client JSON ou un export d’audit CSV limité. Chaque export est journalisé.","customer":"Télécharger le dossier","audit":"Télécharger l’audit CSV"},
|
||||
"anonymize": {"title":"Anonymiser un client","detail":"Irréversible. Les réservations actives ou de moins de {{days}} jours bloquent l’action.","reason":"Motif justifié","confirmation":"Saisissez {{ref}} pour confirmer","submit":"Anonymiser définitivement","working":"Anonymisation…","anonymized":"{{ref}} a été anonymisé.","already_anonymized":"{{ref}} était déjà anonymisé."}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"list": {
|
||||
"eyebrow": "Assurance / Atelier",
|
||||
"title": "Qualité des données",
|
||||
"description": "Résolvez les exceptions étayées par des preuves avant qu'elles ne perturbent l'exploitation.",
|
||||
"runScan": "Lancer le contrôle qualité",
|
||||
"confirmScanTitle": "Confirmer le contrôle qualité",
|
||||
"confirmScanBody": "Lancer maintenant le contrôle déterministe sur les cinq types de règles ?",
|
||||
"scanning": "Analyse en cours…",
|
||||
"confirmScanYes": "Oui, lancer",
|
||||
"cancel": "Annuler",
|
||||
"scanComplete": "Contrôle terminé : {{summary}}",
|
||||
"scanNoNew": "aucun nouveau problème trouvé (les problèmes ouverts existants ne sont pas recréés).",
|
||||
"scanFailed": "Impossible d'exécuter le contrôle qualité.",
|
||||
"statusLabel": "Statut",
|
||||
"statusAll": "Tous les statuts",
|
||||
"statusOpen": "Ouvert",
|
||||
"statusDeferred": "Reporté",
|
||||
"statusResolved": "Résolu",
|
||||
"statusRejected": "Rejeté",
|
||||
"ruleTypeLabel": "Type de règle",
|
||||
"ruleTypeAll": "Tous les types de règles",
|
||||
"severityLabel": "Gravité",
|
||||
"severityAll": "Tous les niveaux de gravité",
|
||||
"assigneeLabel": "Attribué à",
|
||||
"assigneeAll": "Tous les membres",
|
||||
"unassigned": "Non attribué",
|
||||
"overdueOnly": "En retard uniquement",
|
||||
"bulkTitle": "Mettre à jour le travail sélectionné",
|
||||
"selected": "{{count}} sélectionné(s)",
|
||||
"assignTo": "Attribuer à",
|
||||
"dueAt": "Échéance",
|
||||
"invalidDueAt": "Choisissez une date et une heure Europe/Brussels valides.",
|
||||
"bulkApply": "Mettre à jour la file",
|
||||
"bulkSaving": "Mise à jour…",
|
||||
"bulkFailed": "La file de travail n’a pas pu être mise à jour.",
|
||||
"clearSelection": "Effacer la sélection",
|
||||
"selectIssue": "Sélectionner le problème {{ref}}",
|
||||
"selectVisible": "Sélectionner les problèmes visibles",
|
||||
"deselectVisible": "Effacer la sélection visible",
|
||||
"openIssue": "Ouvrir le problème {{ref}}",
|
||||
"filtersToggle": "Filtres · {{count}} actif(s)",
|
||||
"activeFilters": "Filtres actifs",
|
||||
"demoScenariosOnly": "Scénarios de démo uniquement",
|
||||
"loading": "Chargement de l'atelier qualité…",
|
||||
"queueClear": "La file est vide",
|
||||
"noIssuesMatch": "Aucun problème ne correspond aux filtres actuels.",
|
||||
"noDemoIssuesMatch": "Aucun problème de scénario de démo ne correspond",
|
||||
"noDemoIssuesMatchDetail": "Décochez « Scénarios de démo uniquement » pour voir la file complète.",
|
||||
"unavailable": "Les problèmes de qualité des données sont actuellement indisponibles.",
|
||||
"count": "{{count}} problèmes",
|
||||
"evidenceBacked": "Détection étayée par des preuves",
|
||||
"columns": {
|
||||
"reference": "Référence",
|
||||
"select": "Sélectionner",
|
||||
"rule": "Règle",
|
||||
"entity": "Entité",
|
||||
"severity": "Gravité",
|
||||
"status": "Statut",
|
||||
"assignee": "Responsable",
|
||||
"due": "Échéance"
|
||||
}
|
||||
},
|
||||
"ruleTypes": {
|
||||
"possible_duplicate_customer": "Client peut-être en double",
|
||||
"missing_required_field": "Champ obligatoire manquant",
|
||||
"odometer_regression": "Anomalie de kilométrage",
|
||||
"booking_overlap": "Chevauchement de réservations",
|
||||
"vehicle_status_conflict": "Conflit de statut du véhicule"
|
||||
},
|
||||
"severities": {
|
||||
"high": "Critique",
|
||||
"medium": "Avertissement",
|
||||
"low": "Info"
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Atelier qualité",
|
||||
"eyebrow": "Qualité / {{rule}}",
|
||||
"title": "Examinez les preuves enregistrées et consignez une résolution auditée.",
|
||||
"notFound": "Ce problème est introuvable.",
|
||||
"loading": "Chargement des preuves du problème…",
|
||||
"managerOnly": "L'atelier qualité est visible uniquement pour les Responsables des opérations.",
|
||||
"managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Responsables des opérations.",
|
||||
"summary": {
|
||||
"rule": "Règle",
|
||||
"entity": "Entité",
|
||||
"evidenceSummary": "Résumé des preuves"
|
||||
},
|
||||
"resolved": {
|
||||
"title": "Problème {{ref}} résolu",
|
||||
"body": "La modification a été appliquée et enregistrée dans la piste d'audit.",
|
||||
"viewAudit": "Voir la piste d'audit",
|
||||
"viewVehicle": "Voir le véhicule",
|
||||
"continueDemo": "Poursuivre la démo"
|
||||
},
|
||||
"deferOrReject": {
|
||||
"heading": "Reporter ou rejeter",
|
||||
"description": "Reportez pour un examen ultérieur, ou rejetez si ce n'est pas un véritable problème.",
|
||||
"defer": "Reporter",
|
||||
"reject": "Rejeter",
|
||||
"deferFailed": "Impossible de reporter ce problème.",
|
||||
"rejectFailed": "Impossible de rejeter ce problème."
|
||||
},
|
||||
"explainer": {
|
||||
"whatIsWrong": "Ce qui ne va pas",
|
||||
"whyItMatters": "Pourquoi c'est important",
|
||||
"possible_duplicate_customer": {
|
||||
"whatIsWrong": "Deux profils clients partagent des données d'identification (e-mail, téléphone ou un nom très similaire) suffisamment fortement pour qu'il s'agisse probablement de la même personne, enregistrée deux fois.",
|
||||
"whyItMatters": "Les clients en double répartissent l'historique de réservation entre deux fiches, risquent une double facturation et compliquent les échanges avec le support."
|
||||
},
|
||||
"missing_required_field": {
|
||||
"whatIsWrong": "Cette fiche manque d'informations requises pour un fonctionnement normal (par exemple, un client sans e-mail ni numéro de téléphone enregistré).",
|
||||
"whyItMatters": "Sans ces données, l'entreprise ne peut pas contacter le client, ni identifier de façon fiable le véhicule pour les contrôles de conformité et de remise."
|
||||
},
|
||||
"odometer_regression": {
|
||||
"whatIsWrong": "Un relevé de kilométrage soumis est inférieur au dernier relevé connu (de référence) du véhicule.",
|
||||
"whyItMatters": "Un kilométrage en baisse signifie généralement une erreur de saisie ou que les relevés ont été enregistrés pour le mauvais véhicule. Laisser passer cela silencieusement corromprait la planification de l'entretien et l'historique kilométrique à la revente."
|
||||
},
|
||||
"booking_overlap": {
|
||||
"whatIsWrong": "Le même véhicule est engagé sur deux réservations dont les périodes se chevauchent.",
|
||||
"whyItMatters": "Une seule de ces réservations peut réellement être honorée. Non résolu, un client arriverait pour constater que son véhicule est déjà chez quelqu'un d'autre."
|
||||
},
|
||||
"vehicle_status_conflict": {
|
||||
"whatIsWrong": "Le statut opérationnel enregistré de ce véhicule ne correspond pas à ce que son propre historique de réservations et d'inspections implique.",
|
||||
"whyItMatters": "Un statut incorrect peut faire apparaître un véhicule indisponible comme réservable, ou masquer un véhicule disponible de la flotte."
|
||||
}
|
||||
},
|
||||
"evidence": {
|
||||
"duplicate.exact_email": "Adresse e-mail identique",
|
||||
"duplicate.exact_phone": "Numéro de téléphone identique",
|
||||
"duplicate.same_postal_code": "Même code postal",
|
||||
"duplicateSimilarName": "Nom fortement similaire (score {{score}})",
|
||||
"missingField": "Champ manquant : {{field}}",
|
||||
"overlapReservedBookings": "Réservations qui se chevauchent : {{refs}}",
|
||||
"odometerRegression": "La réservation {{laterRef}} a enregistré {{laterKm}} km, en dessous des {{earlierKm}} km enregistrés par la réservation antérieure {{earlierRef}}.",
|
||||
"upcomingBookingMissingInspection": "La réservation {{bookingRef}} commence bientôt mais l'inspection opérationnelle requise est encore manquante."
|
||||
},
|
||||
"duplicateCustomer": {
|
||||
"heading": "Comparer et fusionner",
|
||||
"description": "Choisissez le client de référence et examinez chaque champ divergent.",
|
||||
"keepAsSurvivor": "Conserver comme fiche principale",
|
||||
"differs": "Diffère",
|
||||
"match": "Identique",
|
||||
"summaryCounts": "{{matchCount}} champs identiques · {{conflictCount}} champs en conflit",
|
||||
"showMatchingFields": "Afficher aussi les champs identiques",
|
||||
"hideMatchingFields": "Masquer les champs identiques",
|
||||
"previewTitle": "Aperçu de la fiche fusionnée ({{ref}})",
|
||||
"mergePreview": "{{loser}} sera archivé en toute sécurité et lié à {{survivor}} ; son historique de réservations sera transféré.",
|
||||
"mergeInto": "Fusionner avec {{ref}}",
|
||||
"confirmMergeTitle": "Confirmer la fusion",
|
||||
"confirmMergeBody": "Fusionner {{loser}} avec {{survivor}} ? Cette action est irréversible.",
|
||||
"merging": "Fusion en cours…",
|
||||
"confirmMergeYes": "Oui, fusionner",
|
||||
"mergeFailed": "Impossible de fusionner ces clients.",
|
||||
"bothMissing": "Les deux clients de cette comparaison n'ont pas pu être chargés.",
|
||||
"fieldColumn": "Champ",
|
||||
"fields": {
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom",
|
||||
"email": "E-mail",
|
||||
"phone": "Téléphone",
|
||||
"postal_code": "Code postal",
|
||||
"city": "Ville"
|
||||
}
|
||||
},
|
||||
"missingField": {
|
||||
"heading": "Compléter les champs manquants",
|
||||
"description": "Complétez la fiche pour {{ref}}. Le problème se résout automatiquement dès qu'aucune donnée requise ne manque.",
|
||||
"atLeastOne": "Au moins un e-mail ou un numéro de téléphone est requis.",
|
||||
"saveAndRecheck": "Enregistrer et revérifier",
|
||||
"saving": "Enregistrement…",
|
||||
"saveFailed": "Impossible d'enregistrer ces champs.",
|
||||
"fields": {
|
||||
"first_name": "Prénom",
|
||||
"last_name": "Nom",
|
||||
"email": "E-mail",
|
||||
"phone": "Téléphone",
|
||||
"registration_number": "Numéro d'immatriculation",
|
||||
"make": "Marque",
|
||||
"model": "Modèle",
|
||||
"location": "Localisation"
|
||||
}
|
||||
},
|
||||
"odometerRegression": {
|
||||
"heading": "Résoudre l'anomalie de kilométrage",
|
||||
"description": "Le kilométrage de référence n'est jamais abaissé automatiquement — choisissez comment le régulariser.",
|
||||
"canonicalOdometer": "Kilométrage de référence",
|
||||
"decisionLegend": "Décision",
|
||||
"retainCanonical": "Conserver le relevé de référence",
|
||||
"retainCanonicalDetail": "Considérer le relevé soumis comme erroné ; rien ne change sur la fiche du véhicule.",
|
||||
"correctReading": "Corriger le relevé",
|
||||
"correctReadingDetail": "Mettre à jour à la fois la réservation et le kilométrage de référence avec la valeur correcte.",
|
||||
"noBookingAttached": "Aucune réservation liée à ce problème, seule l'option « conserver la référence » est donc disponible.",
|
||||
"booking": "Réservation",
|
||||
"correctedOdometer": "Kilométrage corrigé (km)",
|
||||
"note": "Note",
|
||||
"resolving": "Résolution en cours…",
|
||||
"resolveIssue": "Résoudre le problème",
|
||||
"resolveFailed": "Impossible de résoudre ce problème."
|
||||
},
|
||||
"bookingOverlap": {
|
||||
"heading": "Résoudre le chevauchement de réservations",
|
||||
"description": "Bloquez l'un des deux engagements qui se chevauchent. L'autre conserve son statut actuel.",
|
||||
"columns": {
|
||||
"booking": "Réservation",
|
||||
"window": "Période",
|
||||
"status": "Statut",
|
||||
"blockThis": "Bloquer celle-ci"
|
||||
},
|
||||
"blockLabel": "Bloquer {{ref}}",
|
||||
"note": "Note",
|
||||
"resolving": "Résolution en cours…",
|
||||
"blockButton": "Bloquer {{ref}}",
|
||||
"blockButtonFallback": "Bloquer la réservation",
|
||||
"resolveFailed": "Impossible de résoudre ce chevauchement."
|
||||
},
|
||||
"vehicleStatusConflict": {
|
||||
"heading": "Résoudre le conflit de statut",
|
||||
"description": "Une règle faisant autorité recommande un statut opérationnel corrigé pour ce véhicule.",
|
||||
"currentStatus": "Statut actuel",
|
||||
"reviewRecommendation": "Voir la recommandation",
|
||||
"loadingRecommendation": "Chargement de la recommandation…",
|
||||
"recommendationFailed": "Impossible de charger une recommandation.",
|
||||
"recommendedStatus": "Statut recommandé",
|
||||
"whyHeading": "Pourquoi",
|
||||
"evidenceHeading": "Faits",
|
||||
"consequenceHeading": "Conséquence",
|
||||
"consequence": {
|
||||
"statusWillChange": "Le statut du véhicule sera changé en {{status}}.",
|
||||
"issueWillBeRechecked": "Ce problème de qualité sera à nouveau contrôlé.",
|
||||
"changeWillBeAudited": "Le changement sera enregistré dans la piste d'audit.",
|
||||
"bookingsNotDeleted": "Les réservations elles-mêmes ne sont pas supprimées."
|
||||
},
|
||||
"changeStatusTo": "Changer le statut vers {{status}}",
|
||||
"applying": "Application en cours…",
|
||||
"applied": "Appliqué {{status}} — {{reason}}",
|
||||
"applyFailed": "Impossible d'appliquer un statut recommandé.",
|
||||
"staleRecommendation": "La situation a changé depuis l'affichage de cette recommandation. Consultez-la à nouveau avant de l'appliquer.",
|
||||
"reviewAgain": "Revoir la recommandation",
|
||||
"manualReview": {
|
||||
"heading": "Évaluation manuelle requise",
|
||||
"body": "Les faits concernant ce véhicule se contredisent. Un Responsable des opérations doit évaluer la situation en personne plutôt que d'appliquer un changement de statut automatique.",
|
||||
"hint": "Utilisez « Reporter » ou « Rejeter » ci-dessous, ou examinez manuellement le véhicule et les réservations concernées."
|
||||
},
|
||||
"noConflict": {
|
||||
"heading": "Aucun changement nécessaire",
|
||||
"body": "Le statut actuel de ce véhicule correspond déjà aux faits."
|
||||
},
|
||||
"evidence": {
|
||||
"activeBookings": "Réservation(s) active(s) : {{refs}}",
|
||||
"overlappingBookings": "Réservations qui se chevauchent : {{pairs}}",
|
||||
"serviceThresholdReached": "Le kilométrage ({{odometer}} km) a atteint le seuil d'entretien ({{threshold}} km).",
|
||||
"openOverlapIssue": "Problème de chevauchement de réservation ouvert : {{ref}}"
|
||||
},
|
||||
"reasonCodes": {
|
||||
"vehicle.active_rental": "Ce véhicule a une location active mais n'est pas enregistré comme loué. Le statut est corrigé pour refléter la situation réelle.",
|
||||
"vehicle.service_threshold_reached": "Ce véhicule a atteint son seuil d'entretien. Il est placé en entretien afin qu'il ne soit pas utilisé avant que la révision ait eu lieu.",
|
||||
"vehicle.booking_conflict": "Ce véhicule a deux réservations qui se chevauchent. Le bloquer évite qu'il soit à nouveau considéré comme disponible avant que le conflit de réservation ne soit résolu.",
|
||||
"vehicle.rental_ended": "La location de ce véhicule est terminée et il n'y a plus de réservation active. Il est remis en disponible.",
|
||||
"vehicle.manual_review_required": "Les faits concernant ce véhicule se contredisent (par exemple, une réservation active alors que le véhicule est en entretien, ou un seuil d'entretien atteint en même temps qu'une location active). Cela nécessite une évaluation humaine plutôt qu'un changement de statut automatique.",
|
||||
"vehicle.no_conflict": "Le statut actuel de ce véhicule correspond déjà aux faits. Aucun changement n'est nécessaire."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"progress": {
|
||||
"capture": "Saisie",
|
||||
"review": "Vérification",
|
||||
"result": "Résultat",
|
||||
"ariaLabel": "Progression de l'enregistrement du retour"
|
||||
},
|
||||
"capture": {
|
||||
"heading": "Enregistrer le retour du véhicule",
|
||||
"description": "Enregistrez l'état à la restitution. L'étape suivante évalue les conséquences opérationnelles exactes avant toute validation.",
|
||||
"endOdometer": "Kilométrage de retour (km)",
|
||||
"fuelLevel": "Niveau de carburant (%)",
|
||||
"conditionLegend": "État du véhicule",
|
||||
"cleanlinessOk": "Propreté acceptable",
|
||||
"damageReported": "Dommage signalé",
|
||||
"technicalWarning": "Avertissement technique",
|
||||
"notes": "Notes",
|
||||
"evaluating": "Évaluation…",
|
||||
"reviewReturn": "Vérifier le retour"
|
||||
},
|
||||
"review": {
|
||||
"heading": "Vérifier l'impact du retour",
|
||||
"description": "Ceci est l'évaluation faisant autorité du serveur sur ce que la validation entraînera — confirmez avant la mise à jour de l'état de la flotte et le déclenchement de l'automatisation.",
|
||||
"odometer": "Kilométrage",
|
||||
"fuel": "Carburant",
|
||||
"cleanliness": "Propreté",
|
||||
"cleanlinessAccepted": "Acceptée",
|
||||
"cleanlinessFollowUp": "Suivi nécessaire",
|
||||
"damage": "Dommage",
|
||||
"damageReported": "Signalé",
|
||||
"damageNone": "Aucun signalé",
|
||||
"technicalWarning": "Avertissement technique",
|
||||
"technicalWarningReported": "Signalé",
|
||||
"technicalWarningNone": "Aucun signalé",
|
||||
"expectedState": "État de flotte attendu :",
|
||||
"odometerRegressionWarning": "Le kilométrage saisi ({{submitted}} km) est inférieur au relevé de référence ({{canonical}} km). Le kilométrage de référence ne changera pas et un problème de qualité des données sera ouvert.",
|
||||
"nextBookingRisk": "La prochaine réservation {{ref}} débute {{when}} — peut être affectée par ce retour.",
|
||||
"nextBookingLowRisk": "La prochaine réservation {{ref}} débute {{when}} — risque faible.",
|
||||
"reasonCodes": {
|
||||
"returnBlockedDamageAndTechnical": "Des dégâts et une alerte technique ont tous deux été signalés lors de ce retour.",
|
||||
"returnBlockedDamage": "Des dégâts ont été signalés lors de ce retour.",
|
||||
"returnBlockedTechnicalWarning": "Une alerte technique a été signalée lors de ce retour.",
|
||||
"returnServiceThresholdReached": "Le kilométrage a atteint le seuil d'entretien de {{threshold_km}} km.",
|
||||
"returnRoutedToCleaning": "Aucun dégât, alerte technique ou seuil d'entretien atteint ; le véhicule part en nettoyage."
|
||||
},
|
||||
"commitList": {
|
||||
"inspection": "Créer une inspection de retour",
|
||||
"updateAtomic": "Mettre à jour la réservation et le véhicule de façon atomique",
|
||||
"queueAutomation": "Planifier l'automatisation après la validation locale"
|
||||
},
|
||||
"editDetails": "Modifier les détails",
|
||||
"registering": "Enregistrement…",
|
||||
"confirmReturn": "Confirmer le retour"
|
||||
},
|
||||
"result": {
|
||||
"committedLocally": "Validé localement",
|
||||
"heading": "Retour enregistré",
|
||||
"inspection": "Inspection",
|
||||
"resultingStatus": "Statut de véhicule résultant",
|
||||
"qualityIssue": "Problème de qualité",
|
||||
"noneCreated": "Aucun créé",
|
||||
"automationEvent": "Tâche d'automatisation",
|
||||
"queuedForDelivery": "En attente de traitement ({{ref}}) — validation locale réussie ; la livraison par automatisation est asynchrone et pas encore confirmée.",
|
||||
"nextBookingRisk": "Risque prochaine réservation",
|
||||
"nextBookingRiskValue": "{{ref}} {{status}}",
|
||||
"atRisk": "— peut être affectée",
|
||||
"lowRisk": "— risque faible",
|
||||
"noUpcomingBooking": "Aucune réservation à venir pour ce véhicule",
|
||||
"odometerRegressionNotice": "Le kilométrage saisi était inférieur au kilométrage de référence du véhicule. Il a été enregistré tel quel ; le kilométrage de référence n'a pas changé et un problème de qualité des données a été ouvert pour examen.",
|
||||
"viewVehicle": "Voir le véhicule {{ref}}",
|
||||
"viewAutomation": "Voir le statut d'automatisation",
|
||||
"viewAudit": "Voir la piste d'audit",
|
||||
"viewTrace": "Suivre l'opération complète",
|
||||
"continueDemo": "Poursuivre la démo"
|
||||
},
|
||||
"scenario": {
|
||||
"ariaLabel": "Scénario de démo",
|
||||
"title": "Scénario de démo : anomalie de kilométrage",
|
||||
"body": "Ce véhicule affiche actuellement {{odometer}} km. Le formulaire ci-dessous est pré-rempli avec un relevé de retour inférieur — signe d'une erreur de saisie ou d'un véhicule confondu. Confirmez le retour pour voir comment {{productName}} détecte et traite cela.",
|
||||
"preparing": "Préparation du scénario…"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"openRow": "Openen: {{label}}",
|
||||
"reducedMotion": "Beperkte beweging actief",
|
||||
"closeDialog": "Dialoogvenster sluiten",
|
||||
"expandSection": "Sectie uitklappen",
|
||||
"collapseSection": "Sectie inklappen"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"eyebrow": "Bewaken / Onveranderlijke geschiedenis",
|
||||
"title": "Auditgeschiedenis",
|
||||
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
|
||||
"actionFilterLabel": "Actie",
|
||||
"actionFilterPlaceholder": "bv. demo-login",
|
||||
"actorFilterLabel": "Uitvoerder",
|
||||
"actorFilterPlaceholder": "Naam of dienst",
|
||||
"entityFilterLabel": "Record",
|
||||
"entityFilterPlaceholder": "bv. MO-016",
|
||||
"fromFilterLabel": "Vanaf",
|
||||
"toFilterLabel": "Tot en met",
|
||||
"managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
|
||||
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
|
||||
"loading": "Auditgeschiedenis laden…",
|
||||
"unavailable": "Auditgeschiedenis is momenteel niet beschikbaar.",
|
||||
"empty": "Geen auditgebeurtenissen gevonden",
|
||||
"emptyDetail": "Pas het actiefilter aan.",
|
||||
"relatedFilterActive": "Enkel gebeurtenissen gekoppeld aan deze actie ({{count}} gerelateerde gebeurtenissen).",
|
||||
"clearFilter": "Filter wissen",
|
||||
"count": "{{count}} gebeurtenissen",
|
||||
"storedRendered": "UTC opgeslagen · Brussel weergegeven",
|
||||
"columns": {
|
||||
"when": "Wanneer",
|
||||
"actor": "Uitvoerder",
|
||||
"action": "Actie",
|
||||
"entity": "Entiteit",
|
||||
"change": "Wijziging",
|
||||
"followUp": "Vervolg",
|
||||
"details": "Details"
|
||||
},
|
||||
"viewRelatedEvents": "Gerelateerde gebeurtenissen bekijken",
|
||||
"noChangeDetail": "Geen wijzigingsdetail geregistreerd.",
|
||||
"noFieldChange": "Geen wijziging op veldniveau gedetecteerd.",
|
||||
"reference": "Referentie",
|
||||
"relatedEventsCount": "{{count}} gekoppelde gebeurtenis",
|
||||
"relatedEventsCount_other": "{{count}} gekoppelde gebeurtenissen",
|
||||
"showTechnicalEvents": "{{count}} technische gebeurtenis tonen",
|
||||
"showTechnicalEvents_other": "{{count}} technische gebeurtenissen tonen",
|
||||
"hideTechnicalEvents": "Technische gebeurtenissen verbergen",
|
||||
"technicalDetails": "Technische details",
|
||||
"fullReference": "Volledige referentie",
|
||||
"correlationId": "Gekoppelde gebeurtenis-ID",
|
||||
"diff": {
|
||||
"setTo": "{{field}}: ingesteld op {{value}}",
|
||||
"was": "{{field}}: was {{value}}",
|
||||
"changed": "{{field}}: {{before}} → {{after}}"
|
||||
},
|
||||
"actorTypes": {
|
||||
"user": "Gebruiker",
|
||||
"system": "Systeem",
|
||||
"service": "Dienst"
|
||||
},
|
||||
"fields": {
|
||||
"status": "Status",
|
||||
"operational_status": "Operationele status",
|
||||
"registration_number": "Kenteken",
|
||||
"make": "Merk",
|
||||
"model": "Model",
|
||||
"location": "Locatie",
|
||||
"first_name": "Voornaam",
|
||||
"last_name": "Achternaam",
|
||||
"email": "E-mail",
|
||||
"phone": "Telefoon",
|
||||
"postal_code": "Postcode",
|
||||
"city": "Stad",
|
||||
"booking_end_odometer_km": "Eindkilometerstand boeking",
|
||||
"vehicle_odometer_km": "Kilometerstand voertuig",
|
||||
"survivor": "Behouden profiel",
|
||||
"loser": "Samengevoegd profiel"
|
||||
},
|
||||
"trace": {
|
||||
"eyebrow": "Volledige verwerking",
|
||||
"title": "Van retour tot automatisering",
|
||||
"description": "Deze weergave koppelt de gebeurtenissen met dezelfde correlatiecode tot één controleerbare verwerking.",
|
||||
"steps": {
|
||||
"return": { "title": "Retour opgeslagen", "complete": "Boeking en inspectie zijn atomair verwerkt.", "pending": "Nog niet verwerkt." },
|
||||
"vehicle": { "title": "Wagenparkstatus afgeleid", "complete": "De server heeft de resulterende voertuigstatus vastgelegd.", "pending": "Statuswijziging nog niet gevonden." },
|
||||
"outbox": { "title": "Automatisering klaargezet", "complete": "De outbox-opdracht is in dezelfde transactie aangemaakt.", "pending": "Geen outbox-opdracht gevonden." },
|
||||
"delivery": { "title": "Externe opvolging", "complete": "n8n heeft de opvolging bevestigd.", "pending": "Aflevering is asynchroon en nog niet in deze audittrace bevestigd." }
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"demo_login": "Ingelogd",
|
||||
"demo_logout": "Uitgelogd",
|
||||
"demo_reset": "Demogegevens hersteld",
|
||||
"demo_data_seeded": "Demogegevens ingeladen",
|
||||
"return_registered": "Voertuigretour geregistreerd",
|
||||
"vehicle_status_changed": "Voertuigstatus gewijzigd",
|
||||
"data_quality_issue_resolved": "Datakwaliteitsprobleem opgelost",
|
||||
"data_quality_issue_deferred": "Datakwaliteitsprobleem uitgesteld",
|
||||
"data_quality_issue_rejected": "Datakwaliteitsprobleem verworpen",
|
||||
"data_quality_fields_provided": "Ontbrekende velden aangevuld",
|
||||
"data_quality_odometer_corrected": "Kilometerstand gecorrigeerd",
|
||||
"data_quality_odometer_retained": "Laatst bevestigde kilometerstand behouden",
|
||||
"data_quality_status_applied": "Aanbevolen status toegepast",
|
||||
"data_quality_booking_blocked": "Boeking geblokkeerd",
|
||||
"data_quality_scan_run": "Kwaliteitscontrole uitgevoerd",
|
||||
"customer_merged": "Klanten samengevoegd",
|
||||
"workflow_retry": "Automatisering opnieuw geprobeerd",
|
||||
"knowledge_question_asked": "Kennisvraag gesteld",
|
||||
"mcp_tool_request": "MCP-tool aangeroepen"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"brandTagline": "Bedieningscentrum",
|
||||
"orgLine": "Demo-organisatie: {{orgName}} (fictief)",
|
||||
"headline1": "Elke overdracht.",
|
||||
"headline2": "Eén helder overzicht.",
|
||||
"defaultDescription": "{{productName}} brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde vervolgstappen.",
|
||||
"footnote": "Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar",
|
||||
"accessEyebrow": "Demo-toegang",
|
||||
"accessHeading": "Kies hoe je wil starten",
|
||||
"accessIntro": "Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.",
|
||||
"startGuidedDemo": "Start begeleide demo",
|
||||
"guidedDemoChecking": "Begeleide demo voorbereiden…",
|
||||
"guidedDemoManifestUnavailable": "De begeleide demo-informatie kon niet geladen worden.",
|
||||
"guidedDemoNotReady": "De begeleide demo is momenteel niet klaar om te starten.",
|
||||
"guidedDemoRetry": "Demo-informatie opnieuw laden",
|
||||
"startRecruiterTour": "Bekijk de highlights in 90 seconden",
|
||||
"exploreAsOperationsManager": "Verken als Operationsmanager",
|
||||
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
|
||||
"exploreAsRentalEmployee": "Verken als Verhuurmedewerker",
|
||||
"exploreAsRentalEmployeeDetail": "Boekingen, retours, wagenpark en procedures",
|
||||
"safeByDesignTitle": "Veilig ontworpen",
|
||||
"safeByDesignDetail": "Elke actie wordt gelogd en is in deze demo herstelbaar.",
|
||||
"loginFailed": "De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.",
|
||||
"roleOperationsManager": "Operationsmanager",
|
||||
"roleRentalEmployee": "Verhuurmedewerker",
|
||||
"switchRole": "Wissel van rol",
|
||||
"logout": "Uitloggen",
|
||||
"emailLabel": "E-mailadres",
|
||||
"passwordLabel": "Wachtwoord",
|
||||
"signIn": "Aanmelden",
|
||||
"passwordLoginFailed": "Aanmelden mislukt. Controleer je gegevens.",
|
||||
"or": "of",
|
||||
"signInWithOrganization": "Aanmelden met {{provider}}"
|
||||
}
|
||||
Loaded 100 of 317 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user