M54: harden operations and demo resilience
MobilityOps acceptance / backend (push) Failing after 19s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-24 03:31:03 +02:00
parent b0706989db
commit 81e3fd63bd
101 changed files with 5641 additions and 828 deletions
+250
View File
@@ -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();
}
});
+97
View File
@@ -74,6 +74,7 @@ test("the collapsed chip has its own close control, independent of reopening it"
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 }) => {
@@ -106,6 +107,41 @@ test("restarting the demo from the guide resets data and returns to login", asyn
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");
@@ -147,6 +183,52 @@ test("Escape collapses the standard-tier panel, then closes it", async ({ page }
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 }) => {
@@ -161,3 +243,18 @@ test("going to a step scrolls, focuses and highlights the on-page target", async
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/);
});
+6 -1
View File
@@ -49,7 +49,12 @@ test("data quality list can filter to demo scenarios only", async ({ page }) =>
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const allRows = await page.locator(".data-table tbody tr").count();
await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).check();
// 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);
+62 -2
View File
@@ -1,4 +1,10 @@
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", {
@@ -12,6 +18,8 @@ async function resetDemoData(request: APIRequestContext) {
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);
@@ -50,14 +58,66 @@ test("five-minute demo script end to end", async ({ page, request }) => {
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 quality issue and queued automation event", async () => {
await expect(page.getByText(/DQ-RET-|Geen aangemaakt/)).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 () => {
+15
View File
@@ -1,5 +1,6 @@
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
@@ -66,3 +67,17 @@ 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");
});
+7
View File
@@ -106,6 +106,13 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
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 () => {
+26
View File
@@ -400,9 +400,35 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
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();
@@ -14,6 +14,20 @@ async function login(page: Page) {
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);
+59 -1
View File
@@ -26,11 +26,69 @@ test("Engineering Story exposes architecture, reliability and honest integration
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.getByText("PostgreSQL + audittrail")).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");
+4
View File
@@ -24,6 +24,7 @@ export const KNOWN_CODES = new Set([
"EVENT_NOT_FOUND",
"ISSUE_NOT_FOUND",
"ISSUE_NOT_OPEN",
"ISSUE_CHANGED",
"BOOKING_NOT_ACTIVE",
"INVALID_BOOKING_STATE",
"NOT_RETRYABLE",
@@ -36,6 +37,9 @@ export const KNOWN_CODES = new Set([
"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",
+1 -1
View File
@@ -245,7 +245,7 @@ export interface ReturnPreviewResult {
}
export interface EntitySnapshot {
entity_type: "customer" | "vehicle" | "booking" | "inspection";
entity_type: "customer" | "vehicle" | "booking" | "inspection" | "maintenance";
public_ref: string;
[key: string]: unknown;
}
@@ -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>
);
}
+64 -19
View File
@@ -1,5 +1,5 @@
import { useNavigate, useLocation } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
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";
@@ -12,6 +12,8 @@ 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();
@@ -21,6 +23,7 @@ export function DemoGuideTrigger() {
return (
<button
id={GUIDE_TRIGGER_ID}
type="button"
className="demo-guide-trigger"
aria-expanded={open}
@@ -35,25 +38,28 @@ export function DemoGuideTrigger() {
);
}
function highlightTarget(selector: string | undefined) {
if (!selector) return;
function highlightTarget(selector: string): (() => void) | null {
const el = document.querySelector<HTMLElement>(selector);
if (!el) return;
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");
window.setTimeout(() => {
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 location = useLocation();
const { logout } = useAuth();
const { manifest, refresh } = useDemoManifest();
const tier = useViewportTier();
@@ -73,10 +79,19 @@ export function DemoGuide() {
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) {
@@ -86,30 +101,60 @@ export function DemoGuide() {
} else if (tier === "mobile" && mobileSheetState !== "collapsed") {
setMobileSheetState("collapsed");
} else {
closeGuide();
closeAndRestoreFocus();
}
}
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide, setCollapsedToChip]);
}, [open, tier, collapsedToChip, mobileSheetState, closeAndRestoreFocus, setCollapsedToChip]);
useEffect(() => {
if (!pendingTarget.current) return;
if (!open) pendingTarget.current = null;
}, [open]);
useEffect(() => {
if (!open || !pendingTarget.current) return;
const target = pendingTarget.current;
pendingTarget.current = null;
const raf = requestAnimationFrame(() => highlightTarget(target));
return () => cancelAnimationFrame(raf);
}, [location.pathname]);
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);
@@ -117,7 +162,7 @@ export function DemoGuide() {
await api.post("/api/v1/demo/reset");
restart();
refresh();
closeGuide();
closeAndRestoreFocus();
await logout();
navigate("/login");
} catch (err) {
@@ -140,7 +185,7 @@ export function DemoGuide() {
{t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}
<Icon name="chevron" />
</button>
<button type="button" className="demo-guide-chip-close" onClick={closeGuide} aria-label={t("guide.close")}>
<button type="button" className="demo-guide-chip-close" onClick={closeAndRestoreFocus} aria-label={t("guide.close")}>
<Icon name="x" />
</button>
</div>
@@ -183,7 +228,7 @@ export function DemoGuide() {
<button
type="button"
className="icon-button"
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeGuide}
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeAndRestoreFocus}
aria-label={tier === "standard" ? t("guide.collapse") : t("guide.close")}
>
<Icon name="x" />
@@ -235,8 +280,8 @@ export function DemoGuide() {
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
{t("guide.goToStep")}
</button>
<button type="button" className="button button-primary" onClick={completeAndAdvance} disabled={isLastStep}>
{t("guide.next")}
<button 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}>
+10 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
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";
@@ -80,7 +80,7 @@ export function Layout() {
const { t } = useTranslation(["navigation", "common", "auth"]);
const { user, logout, demoMode } = useAuth();
const { manifest } = useDemoManifest();
const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide();
const { open: guideOpen, collapsedToChip: guideCollapsed, restart: restartGuide } = useDemoGuide();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -105,7 +105,7 @@ export function Layout() {
);
const mobileItems = useMemo(() => navGroups.flatMap((group) => group.items).slice(0, 5), [navGroups]);
useEffect(() => {
useLayoutEffect(() => {
function focusGlobalSearch(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
@@ -113,8 +113,10 @@ export function Layout() {
}
}
window.addEventListener("keydown", focusGlobalSearch);
return () => window.removeEventListener("keydown", focusGlobalSearch);
// 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(() => {
@@ -175,6 +177,9 @@ export function Layout() {
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();
+14 -2
View File
@@ -57,12 +57,24 @@ export function LoadingState({ label }: { label?: string }) {
);
}
export function ErrorState({ message }: { message: string }) {
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></div>
<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>
);
}
+4 -4
View File
@@ -25,7 +25,7 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const canSeeQualityIssue = user?.role === "operations_manager";
const canSeeManagerEvidence = user?.role === "operations_manager";
function continueDemo() {
completeAndAdvance();
@@ -43,7 +43,7 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
<dt>{t("result.qualityIssue")}</dt>
<dd>
{result.quality_issue_ref ? (
canSeeQualityIssue ? (
canSeeManagerEvidence ? (
<Link to={`/data-quality/${result.quality_issue_ref}`}>{result.quality_issue_ref}</Link>
) : (
result.quality_issue_ref
@@ -74,8 +74,8 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
)}
<div className="result-links">
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>{t("result.viewVehicle", { ref: result.vehicle_ref })}<Icon name="chevron" /></Link>
<Link className="button button-secondary" to="/automation">{t("result.viewAutomation")}<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/audit?correlation_id=${result.correlation_id}`}>{t("result.viewTrace")}<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" />
@@ -4,11 +4,12 @@ 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(() => new Date().toISOString().slice(0, 10));
const [occurredAt, setOccurredAt] = useState(() => toBrusselsDate(new Date()));
const [odometer, setOdometer] = useState(vehicle.odometer_km);
const [category, setCategory] = useState("periodic_service");
const [summary, setSummary] = useState("");
@@ -21,7 +22,7 @@ export function VehicleMaintenanceActions({ vehicle, onSaved }: { vehicle: Vehic
event.preventDefault(); setSaving(true); setError(null);
try {
await api.post<MaintenanceRecord>(`/api/v1/vehicles/${vehicle.public_ref}/maintenance`, {
occurred_at: new Date(`${occurredAt}T12:00:00`).toISOString(), odometer_km: odometer,
occurred_at: brusselsLocalToIso(`${occurredAt}T12:00`), odometer_km: odometer,
category, summary, next_service_km: nextService, mark_maintenance: true,
});
setShowRecord(false); setSummary(""); onSaved();
+29 -16
View File
@@ -29,10 +29,16 @@ function readCachedUser(): CurrentUser | null {
}
function cacheUser(user: CurrentUser | null) {
if (user) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} else {
sessionStorage.removeItem(STORAGE_KEY);
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.
}
}
@@ -47,32 +53,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [oidcProviderName, setOidcProviderName] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api.get<SystemStatus>("/api/v1/system/status")
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(() => setDemoMode(true))
.finally(() => api
.get<CurrentUser>("/api/v1/auth/session")
.catch(() => {
if (!controller.signal.aborted) setDemoMode(true);
});
void api
.get<CurrentUser>("/api/v1/auth/session", { signal: controller.signal })
.then((confirmed) => {
if (cancelled) return;
if (controller.signal.aborted) return;
setUser(confirmed);
cacheUser(confirmed);
})
.catch(() => {
if (cancelled) return;
if (controller.signal.aborted) return;
setUser(null);
cacheUser(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
}));
return () => {
cancelled = true;
};
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, []);
useEffect(
+16 -3
View File
@@ -12,15 +12,28 @@ function readProgress(): StoredProgress {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return { currentIndex: 0, completed: [] };
const parsed = JSON.parse(raw) as StoredProgress;
return { currentIndex: parsed.currentIndex ?? 0, completed: parsed.completed ?? [] };
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) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
} catch {
// Progress is a convenience. Blocked or full storage must never break the guide.
}
}
interface DemoGuideState {
+12 -9
View File
@@ -5,6 +5,7 @@ import type { DemoManifest } from "../api/types";
interface DemoManifestState {
manifest: DemoManifest | null;
loading: boolean;
error: boolean;
refresh: () => void;
}
@@ -13,32 +14,34 @@ const DemoManifestContext = createContext<DemoManifestState | undefined>(undefin
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(() => {
let cancelled = false;
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")
.get<DemoManifest>("/api/v1/demo/manifest", { signal: controller.signal })
.then((result) => {
if (!cancelled) setManifest(result);
if (!controller.signal.aborted) setManifest(result);
})
.catch(() => {
if (!cancelled) setManifest(null);
if (controller.signal.aborted) return;
setManifest(null);
setError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
if (!controller.signal.aborted) setLoading(false);
});
return () => {
cancelled = true;
};
return () => controller.abort();
}, [version]);
return (
<DemoManifestContext.Provider
value={{ manifest, loading, refresh: () => setVersion((v) => v + 1) }}
value={{ manifest, loading, error, refresh: () => setVersion((v) => v + 1) }}
>
{children}
</DemoManifestContext.Provider>
+22 -2
View File
@@ -21,7 +21,13 @@ function partsAt(value: Date): Record<string, string> {
export function toBrusselsDateTimeLocal(value: Date): string {
const parts = partsAt(value);
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;
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 {
@@ -42,7 +48,21 @@ export function brusselsLocalToIso(value: string): string {
const represented = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute);
candidate += wallClockUtc - represented;
}
return new Date(candidate).toISOString();
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. */
@@ -9,6 +9,10 @@
"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",
@@ -59,7 +59,8 @@
"loadingVehicles": "Checking availability…",
"chooseVehicle": "Select a vehicle",
"noVehicles": "No vehicle available for this window",
"requirementsComplete": "Driving licence and rental requirements have been checked",
"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…",
@@ -118,6 +119,7 @@
"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",
+45
View File
@@ -20,6 +20,7 @@
"expectedOutcome": "Expected outcome",
"goToStep": "Go to this step",
"next": "Next",
"finish": "Finish demo",
"close": "Close",
"restart": "Prepare demo again",
"restarting": "Restarting…",
@@ -107,6 +108,7 @@
"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",
@@ -164,6 +166,7 @@
"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",
@@ -185,6 +188,48 @@
"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",
@@ -38,6 +38,11 @@
"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."
@@ -88,6 +93,18 @@
"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.",
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Administration / Access", "title": "Users", "description": "Manage operational access and roles. Every change is audited.", "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." },
"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" }
}
@@ -30,6 +30,7 @@
"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.",
@@ -9,6 +9,10 @@
"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 nont pas pu être chargées.",
"guidedDemoNotReady": "La démo guidée nest 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",
@@ -59,7 +59,8 @@
"loadingVehicles": "Vérification des disponibilités…",
"chooseVehicle": "Sélectionnez un véhicule",
"noVehicles": "Aucun véhicule disponible pour cette période",
"requirementsComplete": "Le permis de conduire et les exigences de location ont été vérifiés",
"invalidLocalTime": "Choisissez une heure Europe/Brussels valide. Lheure sautée lors du passage à lheure d’été nexiste 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…",
@@ -118,6 +119,7 @@
"confirmReschedule": "Enregistrer la nouvelle période",
"rescheduling": "Enregistrement…",
"rescheduleFailed": "La réservation na 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",
+45
View File
@@ -20,6 +20,7 @@
"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…",
@@ -107,6 +108,7 @@
"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",
@@ -164,6 +166,7 @@
"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",
@@ -185,6 +188,48 @@
"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",
@@ -38,6 +38,11 @@
"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."
@@ -88,6 +93,18 @@
"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.",
@@ -1,4 +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.", "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 lutilisateur {{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." },
"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": "Ladministration 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 lutilisateur {{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" }
}
@@ -30,6 +30,7 @@
"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 na pas pu être mise à jour.",
@@ -9,6 +9,10 @@
"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",
@@ -59,7 +59,8 @@
"loadingVehicles": "Beschikbaarheid controleren…",
"chooseVehicle": "Selecteer een voertuig",
"noVehicles": "Geen beschikbaar voertuig in deze periode",
"requirementsComplete": "Rijbewijs- en huurvereisten zijn gecontroleerd",
"invalidLocalTime": "Kies een geldig tijdstip in Europe/Brussels. Het overgeslagen uur bij de omschakeling naar zomertijd bestaat niet.",
"endAfterStart": "Het einde moet na de start van de boeking vallen.",
"cancel": "Annuleren",
"save": "Boeking aanmaken",
"saving": "Boeking opslaan…",
@@ -118,6 +119,7 @@
"confirmReschedule": "Nieuwe periode opslaan",
"rescheduling": "Periode opslaan…",
"rescheduleFailed": "De reservatie kon niet worden verplaatst.",
"invalidScheduleWindow": "Kies geldige tijdstippen in Europe/Brussels en zorg dat het einde na de start valt.",
"yes": "Ja",
"no": "Nee",
"cancelAction": "Boeking annuleren",
+45
View File
@@ -20,6 +20,7 @@
"expectedOutcome": "Verwacht resultaat",
"goToStep": "Ga naar deze stap",
"next": "Volgende",
"finish": "Demo afronden",
"close": "Sluiten",
"restart": "Demo opnieuw voorbereiden",
"restarting": "Bezig met herstellen…",
@@ -107,6 +108,7 @@
"title": "Probeer een demonstratiescenario",
"description": "Vijf afgebakende scenario's die telkens dezelfde vaste boekingen, klanten en voertuigen gebruiken — na een reset zijn ze altijd opnieuw te vinden.",
"loading": "Scenario's laden…",
"unavailable": "De demonstratiescenario's konden niet geladen worden. Probeer het opnieuw.",
"ready": "Klaar voor demo",
"notReady": "Niet beschikbaar",
"duration": "Duur",
@@ -164,6 +166,7 @@
"title": "Wat {{productName}} wel en niet is",
"description": "{{orgName}} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.",
"loading": "Demo-informatie laden…",
"unavailable": "De demo-informatie kon niet geladen worden. Probeer het opnieuw.",
"ctaTitle": "Kies hoeveel tijd je hebt",
"ctaBody": "Bekijk de drie sterkste engineeringmomenten in 90 seconden, of doorloop de volledige operationele demo.",
"ctaButton": "Start volledige demo",
@@ -185,6 +188,48 @@
"architectureDatabase": "PostgreSQL + audittrail",
"architectureOutbox": "Betrouwbare outbox",
"architectureExternal": "Externe diensten: n8n, RAGcore en MCP Hub",
"architectureZonesLabel": "Verantwoordelijkheidsgrenzen in de architectuur",
"architectureZoneIntent": "Gebruikersintentie",
"architectureZoneTransaction": "Lokale transactie",
"architectureZoneEdge": "Herstelbare systeemrand",
"architectureFlowLabel": "Interactieve systeemflow van interface naar externe diensten",
"architectureCommitBoundary": "Na commit",
"architectureInteractionHint": "Selecteer een stap om de operationele garantie en controlepunten te bekijken.",
"architectureSelectedStep": "Stap {{current}} van {{total}}",
"architectureBoundaryLabel": "Verantwoordelijkheid",
"architectureEvidenceLabel": "Controlepunten",
"architectureDetails": {
"Frontend": {
"summary": "Maakt rol en intentie expliciet",
"body": "Toegankelijke formulieren verzamelen gevalideerde invoer en tonen de impact vóór bevestiging. De interface beslist nooit zelfstandig over een domeinstatus.",
"proofOne": "Routeguard per rol",
"proofTwo": "Preview vóór bevestiging"
},
"Api": {
"summary": "Valideert elke operationele regel",
"body": "FastAPI bewaakt statussen, invarianten en oplossingsregels aan de API-grens, onafhankelijk van wat de browser aanlevert.",
"proofOne": "Pydantic-validatie",
"proofTwo": "Domeinbeslissing op de server"
},
"Database": {
"summary": "Commit data en audit atomair",
"body": "PostgreSQL bewaart de operationele wijziging en het auditbewijs binnen dezelfde transactie, zodat een gedeeltelijke statuswijziging niet kan ontstaan.",
"proofOne": "UUID + publieke referentie",
"proofTwo": "UTC + onverbreekbare audittrail"
},
"Outbox": {
"summary": "Legt vervolgwerk duurzaam vast",
"body": "Het outboxrecord wordt samen met de operatie gecommit. Een worker levert pas daarna, idempotent en met begrensde herpogingen, aan de orkestratielaag.",
"proofOne": "Post-commit aflevering",
"proofTwo": "Idempotency + begrensde retries"
},
"External": {
"summary": "Degradeert zonder lokaal dataverlies",
"body": "n8n, RAGcore en MCP Hub hebben time-outs en zichtbare healthstatus. Een storing blijft herstelbaar en AI antwoordt nooit zonder voldoende bronbewijs.",
"proofOne": "Healthstatus + time-outs",
"proofTwo": "Geen antwoord zonder bewijs"
}
},
"verificationTitle": "Verifieerbaar gebouwd",
"verificationBody": "Domeinregels, API-contracten, degradatiemodi en de volledige demonstratie worden automatisch getest. De repository bevat de exacte acceptatiecommando's en bewijsbundel.",
"problemTitle": "Het fictieve probleem",
@@ -38,6 +38,11 @@
"explanation": "Dit probleem is al opgelost, uitgesteld of verworpen.",
"nextStep": "Vernieuw de pagina om de huidige status te zien."
},
"ISSUE_CHANGED": {
"title": "De bewijslast is gewijzigd",
"explanation": "Er werd nieuwe bewijslast geregistreerd terwijl deze correctie werd voorbereid.",
"nextStep": "Vernieuw het probleem en beoordeel de actuele bewijslast opnieuw."
},
"BOOKING_NOT_ACTIVE": {
"title": "Boeking is niet actief",
"explanation": "Enkel een gereserveerde of actieve boeking kan voor deze actie gebruikt worden."
@@ -88,6 +93,18 @@
"title": "Ongeldige opdrachtreferentie",
"explanation": "Deze referentie naar een automatiseringsopdracht is niet geldig."
},
"INVALID_EVENT_CORRELATION": {
"title": "Ongeldige correlatiereferentie",
"explanation": "De automatiseringscallback bevat geen geldige correlatiereferentie."
},
"CALLBACK_EVENT_MISMATCH": {
"title": "Callback hoort bij een ander event",
"explanation": "De automatiseringscallback verwijst naar een ander event dan het event dat wordt bijgewerkt."
},
"CALLBACK_CORRELATION_MISMATCH": {
"title": "Callbacktrace komt niet overeen",
"explanation": "De automatiseringscallback komt niet overeen met de opgeslagen correlatiereferentie van dit event."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "Aanvraag kon niet veilig herhaald worden",
"explanation": "De trackingsleutel van deze aanvraag is niet geldig.",
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "edit": "Bewerken", "editTitle": "Gebruiker {{ref}} bewerken", "newPassword": "Nieuw wachtwoord", "passwordUnchanged": "Leeg laten om niet te wijzigen", "saveChanges": "Wijzigingen opslaan", "cancel": "Annuleren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "managerOnly": "Gebruikersbeheer is uitsluitend beschikbaar voor Operationsmanagers.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "edit": "Bewerken", "editTitle": "Gebruiker {{ref}} bewerken", "newPassword": "Nieuw wachtwoord", "passwordUnchanged": "Leeg laten om niet te wijzigen", "saveChanges": "Wijzigingen opslaan", "cancel": "Annuleren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
"roles": { "operations_manager": "Operationeel beheerder", "rental_employee": "Verhuurmedewerker" }
}
@@ -30,6 +30,7 @@
"selected": "{{count}} geselecteerd",
"assignTo": "Toewijzen aan",
"dueAt": "Uiterste datum",
"invalidDueAt": "Kies een geldige datum en tijd in Europe/Brussels.",
"bulkApply": "Werkvoorraad bijwerken",
"bulkSaving": "Bijwerken…",
"bulkFailed": "De werkvoorraad kon niet bijgewerkt worden.",
+6 -6
View File
@@ -4,8 +4,9 @@ import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { ArchitectureFlow } from "../components/ArchitectureFlow";
import { Icon, type IconName } from "../components/Icons";
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { PRODUCT_NAME } from "../product";
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = { n8n: "n8n", ragcore: "rag", mcp_hub: "mcp" };
@@ -19,7 +20,7 @@ const PROOFS: Array<{ id: "Rules" | "Reliability" | "Ai"; icon: IconName }> = [
export function AboutDemo() {
const { t } = useTranslation(["demo", "integrations"]);
const { formatDateTime } = useLocaleFormat();
const { manifest, loading } = useDemoManifest();
const { manifest, loading, error, refresh } = useDemoManifest();
const { user } = useAuth();
const { openGuide } = useDemoGuide();
const statusLabelKey = (key: string, statusCode: string) => key === "n8n" ? N8N_STATUS_LABEL_KEY[statusCode] ?? statusCode : statusCode;
@@ -28,6 +29,7 @@ export function AboutDemo() {
<div className="page engineering-story">
<PageHeader eyebrow={t("about.eyebrow")} title={t("about.title", { productName: PRODUCT_NAME })} description={manifest ? t("about.description", { orgName: manifest.organization_name }) : undefined} />
{loading && <LoadingState label={t("about.loading")} />}
{!loading && error && <ErrorState message={t("about.unavailable")} onRetry={refresh} />}
{manifest && <>
<section className="panel engineering-hero">
<div>
@@ -49,10 +51,8 @@ export function AboutDemo() {
</section>
<section className="panel architecture-story" aria-labelledby="architecture-heading">
<SectionHeading title={t("about.architectureFlowTitle")} description={t("about.architectureFlowDescription")} />
<div className="architecture-flow" id="architecture-heading">
{["Frontend", "Api", "Database", "Outbox", "External"].map((step, index) => <div className="architecture-step" key={step}><span>{index + 1}</span><strong>{t(`about.architecture${step}`)}</strong>{index < 4 && <Icon name="chevron" />}</div>)}
</div>
<SectionHeading headingId="architecture-heading" title={t("about.architectureFlowTitle")} description={t("about.architectureFlowDescription")} />
<ArchitectureFlow />
</section>
<div className="about-grid">
+37 -27
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
@@ -32,53 +32,63 @@ export function Automation() {
const [knowledgeSettled, setKnowledgeSettled] = useState(false);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
const [integrationSettled, setIntegrationSettled] = useState(false);
const [runsVersion, setRunsVersion] = useState(0);
const [integrationVersion, setIntegrationVersion] = useState(0);
const load = useCallback(() => {
useEffect(() => {
if (user?.role !== "operations_manager") return;
setRuns(null);
setError(null);
const params = new URLSearchParams();
if (status) params.set("status", status);
const controller = new AbortController();
api
.get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`)
.get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`, { signal: controller.signal })
.then(setRuns)
.catch(() =>
setError(user?.role === "operations_manager" ? t("ledger.unavailable") : t("ledger.managerOnly")),
);
}, [status, user, t]);
useEffect(() => {
load();
}, [load]);
.catch(() => {
if (!controller.signal.aborted) setError(t("ledger.unavailable"));
});
return () => controller.abort();
}, [status, user?.role, t, runsVersion]);
useEffect(() => {
setKnowledgeSettled(false);
const controller = new AbortController();
api
.get<KnowledgeHealth>("/api/v1/knowledge/status")
.get<KnowledgeHealth>("/api/v1/knowledge/status", { signal: controller.signal })
.then(setKnowledge)
.catch(() => setKnowledge(null))
.finally(() => setKnowledgeSettled(true));
}, []);
const loadIntegrationStatus = useCallback(() => {
setIntegrationSettled(false);
api
.get<IntegrationStatus>("/api/v1/integrations/status")
.then(setIntegrationStatus)
.catch(() => setIntegrationStatus(null))
.finally(() => setIntegrationSettled(true));
.catch(() => {
if (!controller.signal.aborted) setKnowledge(null);
})
.finally(() => {
if (!controller.signal.aborted) setKnowledgeSettled(true);
});
return () => controller.abort();
}, []);
useEffect(() => {
loadIntegrationStatus();
}, [loadIntegrationStatus]);
if (user?.role !== "operations_manager") return;
setIntegrationSettled(false);
const controller = new AbortController();
api
.get<IntegrationStatus>("/api/v1/integrations/status", { signal: controller.signal })
.then(setIntegrationStatus)
.catch(() => {
if (!controller.signal.aborted) setIntegrationStatus(null);
})
.finally(() => {
if (!controller.signal.aborted) setIntegrationSettled(true);
});
return () => controller.abort();
}, [integrationVersion, user?.role]);
async function handleRetry(eventId: string) {
setRetryError(null);
setRetrying(eventId);
try {
await api.post(`/api/v1/workflows/${eventId}/retry`);
load();
loadIntegrationStatus();
setRunsVersion((version) => version + 1);
setIntegrationVersion((version) => version + 1);
} catch (err) {
setRetryError(describeApiError(t, err, "ledger.retryFailed"));
} finally {
+17 -14
View File
@@ -6,7 +6,7 @@ import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { AvailableVehicle, Booking, CustomerOption } from "../api/types";
import { ApiErrorNotice, PageHeader } from "../components/PageChrome";
import { Icon } from "../components/Icons";
import { brusselsDateTimeFromNow, brusselsLocalToIso } from "../i18n/brusselsDateTime";
import { brusselsDateTimeFromNow, tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
export function BookingCreate() {
const { t } = useTranslation(["bookings", "errors"]);
@@ -18,16 +18,18 @@ export function BookingCreate() {
const [endsAt, setEndsAt] = useState(() => brusselsDateTimeFromNow(26));
const [vehicles, setVehicles] = useState<AvailableVehicle[]>([]);
const [vehicleRef, setVehicleRef] = useState("");
const [requirementsComplete, setRequirementsComplete] = useState(false);
const [vehicleQuery, setVehicleQuery] = useState("");
const [loadingVehicles, setLoadingVehicles] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const windowValid = useMemo(
() => Boolean(startsAt && endsAt && brusselsLocalToIso(endsAt) > brusselsLocalToIso(startsAt)),
[endsAt, startsAt],
const startsAtIso = useMemo(() => startsAt ? tryBrusselsLocalToIso(startsAt) : null, [startsAt]);
const endsAtIso = useMemo(() => endsAt ? tryBrusselsLocalToIso(endsAt) : null, [endsAt]);
const windowValid = Boolean(startsAtIso && endsAtIso && endsAtIso > startsAtIso);
const localTimeInvalid = Boolean(
(startsAt && !startsAtIso) || (endsAt && !endsAtIso),
);
const windowOrderInvalid = Boolean(startsAtIso && endsAtIso && endsAtIso <= startsAtIso);
useEffect(() => {
if (customerQuery.trim().length < 2) {
@@ -61,8 +63,8 @@ export function BookingCreate() {
const timeout = window.setTimeout(() => {
setLoadingVehicles(true);
const params = new URLSearchParams({
starts_at: brusselsLocalToIso(startsAt),
ends_at: brusselsLocalToIso(endsAt),
starts_at: startsAtIso as string,
ends_at: endsAtIso as string,
});
if (vehicleQuery.trim()) params.set("query", vehicleQuery.trim());
params.set("limit", "50");
@@ -82,19 +84,19 @@ export function BookingCreate() {
window.clearTimeout(timeout);
controller.abort();
};
}, [endsAt, startsAt, t, vehicleQuery, windowValid]);
}, [endsAtIso, startsAtIso, t, vehicleQuery, windowValid]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!startsAtIso || !endsAtIso || endsAtIso <= startsAtIso) return;
setError(null);
setSubmitting(true);
try {
const booking = await api.post<Booking>("/api/v1/bookings", {
customer_ref: customerRef,
vehicle_ref: vehicleRef,
starts_at: brusselsLocalToIso(startsAt),
ends_at: brusselsLocalToIso(endsAt),
requirements_complete: requirementsComplete,
starts_at: startsAtIso,
ends_at: endsAtIso,
});
navigate(`/bookings/${booking.public_ref}`);
} catch (err) {
@@ -111,14 +113,15 @@ export function BookingCreate() {
<ApiErrorNotice error={error} />
<form className="record-surface booking-create-form" onSubmit={submit}>
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required value={startsAt} onChange={(event) => setStartsAt(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required min={startsAt} value={endsAt} onChange={(event) => setEndsAt(event.target.value)} /></label>
<label>{t("create.startsAt")}<input type="datetime-local" required aria-invalid={Boolean(startsAt && !startsAtIso)} value={startsAt} onChange={(event) => setStartsAt(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required aria-invalid={Boolean((endsAt && !endsAtIso) || windowOrderInvalid)} min={startsAt} value={endsAt} onChange={(event) => setEndsAt(event.target.value)} /></label>
<label>{t("create.customerSearch")}<input type="search" value={customerQuery} onChange={(event) => setCustomerQuery(event.target.value)} placeholder={t("create.customerPlaceholder")} /></label>
<label>{t("create.customer")}<select required value={customerRef} onChange={(event) => setCustomerRef(event.target.value)} disabled={customers.length === 0}><option value="">{t(customers.length ? "create.chooseCustomer" : "create.searchFirst")}</option>{customers.map((customer) => <option key={customer.public_ref} value={customer.public_ref}>{customer.display_name} · {customer.public_ref}{customer.email ? ` · ${customer.email}` : ""}</option>)}</select></label>
<label>{t("create.vehicleSearch")}<input type="search" value={vehicleQuery} onChange={(event) => setVehicleQuery(event.target.value)} placeholder={t("create.vehiclePlaceholder")} /></label>
<label>{t("create.vehicle")}<select required value={vehicleRef} onChange={(event) => setVehicleRef(event.target.value)} disabled={!windowValid || loadingVehicles}><option value="">{t(loadingVehicles ? "create.loadingVehicles" : vehicles.length ? "create.chooseVehicle" : "create.noVehicles")}</option>{vehicles.map((vehicle) => <option key={vehicle.public_ref} value={vehicle.public_ref}>{vehicle.public_ref} · {vehicle.make} {vehicle.model} · {vehicle.location || t("create.locationUnknown")}</option>)}</select></label>
</div>
<label className="check-card"><input type="checkbox" checked={requirementsComplete} onChange={(event) => setRequirementsComplete(event.target.checked)} /> <span>{t("create.requirementsComplete")}</span></label>
{localTimeInvalid && <p className="error" role="alert">{t("create.invalidLocalTime")}</p>}
{!localTimeInvalid && windowOrderInvalid && <p className="error" role="alert">{t("create.endAfterStart")}</p>}
<div className="form-actions"><Link className="button button-secondary" to="/bookings">{t("create.cancel")}</Link><button className="button button-primary" type="submit" disabled={submitting || !windowValid || !customerRef || !vehicleRef}>{submitting ? t("create.saving") : t("create.save")}</button></div>
</form>
</div>
+20 -8
View File
@@ -1,4 +1,4 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
@@ -13,7 +13,7 @@ import { PRODUCT_NAME } from "../product";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { ApiErrorNotice } from "../components/PageChrome";
import { CheckoutForm } from "../components/CheckoutForm";
import { brusselsLocalToIso, toBrusselsDateTimeLocal } from "../i18n/brusselsDateTime";
import { toBrusselsDateTimeLocal, tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns", "errors"]);
@@ -35,6 +35,17 @@ export function BookingDetail() {
const [scheduleEnd, setScheduleEnd] = useState("");
const [scheduleReason, setScheduleReason] = useState("");
const [rescheduling, setRescheduling] = useState(false);
const scheduleStartIso = useMemo(
() => scheduleStart ? tryBrusselsLocalToIso(scheduleStart) : null,
[scheduleStart],
);
const scheduleEndIso = useMemo(
() => scheduleEnd ? tryBrusselsLocalToIso(scheduleEnd) : null,
[scheduleEnd],
);
const scheduleWindowValid = Boolean(
scheduleStartIso && scheduleEndIso && scheduleEndIso > scheduleStartIso,
);
const load = useCallback(() => {
if (!publicRef) return;
@@ -120,13 +131,13 @@ export function BookingDetail() {
async function reschedule(event: FormEvent) {
event.preventDefault();
if (!publicRef) return;
if (!publicRef || !scheduleStartIso || !scheduleEndIso || !scheduleWindowValid) return;
setRescheduling(true);
setActionError(null);
try {
const updated = await api.patch<Booking>(`/api/v1/bookings/${publicRef}/schedule`, {
starts_at: brusselsLocalToIso(scheduleStart),
ends_at: brusselsLocalToIso(scheduleEnd),
starts_at: scheduleStartIso,
ends_at: scheduleEndIso,
reason: scheduleReason,
});
setBooking(updated);
@@ -175,11 +186,12 @@ export function BookingDetail() {
<p>{t("detail.rescheduleDetail")}</p>
<ApiErrorNotice error={actionErrorSource === "reschedule" ? actionError : null} />
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required value={scheduleStart} onChange={(event) => setScheduleStart(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required min={scheduleStart} value={scheduleEnd} onChange={(event) => setScheduleEnd(event.target.value)} /></label>
<label>{t("create.startsAt")}<input type="datetime-local" required aria-invalid={Boolean(scheduleStart && !scheduleStartIso)} value={scheduleStart} onChange={(event) => setScheduleStart(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required aria-invalid={Boolean(scheduleEnd && !scheduleEndIso)} min={scheduleStart} value={scheduleEnd} onChange={(event) => setScheduleEnd(event.target.value)} /></label>
</div>
{scheduleStart && scheduleEnd && !scheduleWindowValid && <p className="error" role="alert">{t("detail.invalidScheduleWindow")}</p>}
<label>{t("detail.rescheduleReason")}<textarea required minLength={3} maxLength={500} value={scheduleReason} onChange={(event) => setScheduleReason(event.target.value)} /></label>
<div className="form-actions"><button className="button button-secondary" type="submit" disabled={rescheduling || scheduleReason.trim().length < 3 || scheduleEnd <= scheduleStart}>{rescheduling ? t("detail.rescheduling") : t("detail.confirmReschedule")}</button></div>
<div className="form-actions"><button className="button button-secondary" type="submit" disabled={rescheduling || scheduleReason.trim().length < 3 || !scheduleWindowValid}>{rescheduling ? t("detail.rescheduling") : t("detail.confirmReschedule")}</button></div>
</form>
</details>
) : null}
+50 -9
View File
@@ -67,21 +67,50 @@ export function Dashboard() {
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [dashboardRequest, setDashboardRequest] = useState(0);
const [severity, setSeverity] = useState("all");
const [query, setQuery] = useState("");
useEffect(() => {
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError(t("common:status.error")));
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
}, [t]);
const controller = new AbortController();
setError(null);
api
.get<DashboardData>("/api/v1/dashboard", { signal: controller.signal })
.then((nextData) => {
if (!controller.signal.aborted) setData(nextData);
})
.catch(() => {
if (!controller.signal.aborted) setError(t("common:status.error"));
});
api
.get<KnowledgeHealth>("/api/v1/knowledge/status", { signal: controller.signal })
.then((nextKnowledge) => {
if (!controller.signal.aborted) setKnowledge(nextKnowledge);
})
.catch(() => {
if (!controller.signal.aborted) setKnowledge(null);
});
return () => controller.abort();
}, [dashboardRequest, t]);
useEffect(() => {
if (!canSeeAutomation) return;
if (!canSeeAutomation) {
setIntegrationStatus(null);
return;
}
const controller = new AbortController();
api
.get<IntegrationStatus>("/api/v1/integrations/status")
.then(setIntegrationStatus)
.catch(() => setIntegrationStatus(null));
}, [canSeeAutomation]);
.get<IntegrationStatus>("/api/v1/integrations/status", { signal: controller.signal })
.then((nextStatus) => {
if (!controller.signal.aborted) setIntegrationStatus(nextStatus);
})
.catch(() => {
if (!controller.signal.aborted) setIntegrationStatus(null);
});
return () => controller.abort();
}, [canSeeAutomation, dashboardRequest]);
const attention = useMemo(() => data?.attention_items.filter((item) => {
const matchesSeverity = severity === "all" || item.severity === severity;
@@ -104,7 +133,19 @@ export function Dashboard() {
].filter((tier) => tier.items.length > 0);
}, [attention, isUnfiltered]);
if (error) return <ErrorState message={error} />;
if (error) {
return (
<ErrorState
message={error}
onRetry={() => {
setData(null);
setKnowledge(null);
setIntegrationStatus(null);
setDashboardRequest((requestNumber) => requestNumber + 1);
}}
/>
);
}
if (!data) return <LoadingState label={t("common:status.loading")} />;
const latestRun = data.recent_automation[0];
+39 -21
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
@@ -9,7 +9,7 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
import { useLocaleFormat } from "../i18n/format";
import { brusselsLocalToIso } from "../i18n/brusselsDateTime";
import { tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
const RULE_TYPES = [
"possible_duplicate_customer",
@@ -36,7 +36,7 @@ export function DataQuality() {
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
const demoScenariosOnly = searchParams.get("demo") === "true";
const [users, setUsers] = useState<UserRecord[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkAssignee, setBulkAssignee] = useState("");
@@ -44,6 +44,12 @@ export function DataQuality() {
const [bulkSaving, setBulkSaving] = useState(false);
const [bulkError, setBulkError] = useState<ApiErrorInfo | null>(null);
const [filtersOpen, setFiltersOpen] = useState(false);
const [refreshVersion, setRefreshVersion] = useState(0);
const bulkDueAtIso = useMemo(
() => bulkDueAt ? tryBrusselsLocalToIso(bulkDueAt) : null,
[bulkDueAt],
);
const bulkDueAtInvalid = Boolean(bulkDueAt && !bulkDueAtIso);
function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams);
@@ -54,7 +60,7 @@ export function DataQuality() {
setSearchParams(next, { replace: true });
}
const load = useCallback(() => {
useEffect(() => {
if (user?.role !== "operations_manager") return;
// Keep the current rows visible while a filter change refetches (stale-while-revalidate);
// the initial load still shows the loading state because `issues` starts as null.
@@ -68,35 +74,46 @@ export function DataQuality() {
if (demoScenariosOnly) params.set("demo_only", "true");
params.set("page", String(page));
params.set("page_size", "25");
const controller = new AbortController();
api
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`, { signal: controller.signal })
.then(setIssues)
.catch(() => setError(t("list.unavailable")));
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user, t]);
useEffect(() => {
load();
}, [load]);
.catch(() => {
if (!controller.signal.aborted) setError(t("list.unavailable"));
});
return () => controller.abort();
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user?.role, t, refreshVersion]);
useEffect(() => {
if (user?.role !== "operations_manager") return;
api.get<UserRecord[]>("/api/v1/users").then((records) => setUsers(records.filter((record) => record.active))).catch(() => setUsers([]));
}, [user]);
const controller = new AbortController();
api.get<UserRecord[]>("/api/v1/users", { signal: controller.signal })
.then((records) => setUsers(records.filter((record) => record.active)))
.catch(() => {
if (!controller.signal.aborted) setUsers([]);
});
return () => controller.abort();
}, [user?.role]);
useEffect(() => {
// Bulk actions must never retain invisible rows after URL-driven filtering/history.
setSelected(new Set());
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page]);
async function applyBulkWork() {
if (selected.size === 0 || (!bulkAssignee && !bulkDueAt)) return;
if (selected.size === 0 || (!bulkAssignee && !bulkDueAt) || bulkDueAtInvalid) return;
setBulkSaving(true);
setBulkError(null);
try {
await api.post("/api/v1/data-quality/issues/bulk-work", {
issue_refs: [...selected],
assigned_to_ref: bulkAssignee || undefined,
due_at: bulkDueAt ? brusselsLocalToIso(bulkDueAt) : undefined,
due_at: bulkDueAtIso ?? undefined,
});
setSelected(new Set());
setBulkAssignee("");
setBulkDueAt("");
load();
setRefreshVersion((version) => version + 1);
} catch (err) {
setBulkError(describeApiError(t, err, "list.bulkFailed"));
} finally {
@@ -129,7 +146,7 @@ export function DataQuality() {
const result = await api.post<ScanResult>("/api/v1/data-quality/scan");
setScanResult(result);
setConfirmingScan(false);
load();
setRefreshVersion((version) => version + 1);
} catch (err) {
setScanError(describeApiError(t, err, "list.scanFailed"));
} finally {
@@ -246,7 +263,7 @@ export function DataQuality() {
<input
type="checkbox"
checked={demoScenariosOnly}
onChange={(e) => { setDemoScenariosOnly(e.target.checked); updateFilters({ demo: e.target.checked, page: 1 }); }}
onChange={(e) => updateFilters({ demo: e.target.checked, page: 1 })}
/>
{t("list.demoScenariosOnly")}
</label>
@@ -258,7 +275,7 @@ export function DataQuality() {
{severity && <button type="button" onClick={() => updateFilters({ severity: null, page: 1 })}>{t(`severities.${severity}`)} ×</button>}
{assignee && <button type="button" onClick={() => updateFilters({ assignee: null, page: 1 })}>{assignee === "unassigned" ? t("list.unassigned") : users.find((record) => record.public_ref === assignee)?.display_name ?? assignee} ×</button>}
{overdueOnly && <button type="button" onClick={() => updateFilters({ overdue: null, page: 1 })}>{t("list.overdueOnly")} ×</button>}
{demoScenariosOnly && <button type="button" onClick={() => { setDemoScenariosOnly(false); updateFilters({ demo: null, page: 1 }); }}>{t("list.demoScenariosOnly")} ×</button>}
{demoScenariosOnly && <button type="button" onClick={() => updateFilters({ demo: null, page: 1 })}>{t("list.demoScenariosOnly")} ×</button>}
</div>
)}
@@ -275,8 +292,9 @@ export function DataQuality() {
<div className="bulk-toolbar" role="region" aria-label={t("list.bulkTitle")}>
<strong>{t("list.selected", { count: selected.size })}</strong>
<label>{t("list.assignTo")}<select value={bulkAssignee} onChange={(event) => setBulkAssignee(event.target.value)}><option value=""></option>{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}</select></label>
<label>{t("list.dueAt")}<input type="datetime-local" value={bulkDueAt} onChange={(event) => setBulkDueAt(event.target.value)} /></label>
<button type="button" className="button button-primary" disabled={bulkSaving || (!bulkAssignee && !bulkDueAt)} onClick={applyBulkWork}>{bulkSaving ? t("list.bulkSaving") : t("list.bulkApply")}</button>
<label>{t("list.dueAt")}<input type="datetime-local" aria-invalid={bulkDueAtInvalid} value={bulkDueAt} onChange={(event) => setBulkDueAt(event.target.value)} /></label>
{bulkDueAtInvalid && <span className="error" role="alert">{t("list.invalidDueAt")}</span>}
<button type="button" className="button button-primary" disabled={bulkSaving || (!bulkAssignee && !bulkDueAt) || bulkDueAtInvalid} onClick={applyBulkWork}>{bulkSaving ? t("list.bulkSaving") : t("list.bulkApply")}</button>
<button type="button" className="button button-secondary" onClick={() => setSelected(new Set())}>{t("list.clearSelection")}</button>
</div>
)}
@@ -136,7 +136,14 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const bookingSnapshots = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const configuredCorrectableRefs = Array.isArray(issue.evidence.correctable_booking_refs)
? issue.evidence.correctable_booking_refs.filter((ref): ref is string => typeof ref === "string")
: null;
const bookingSnapshots = issue.related_snapshots.filter(
(snapshot) =>
snapshot.entity_type === "booking" &&
(configuredCorrectableRefs === null || configuredCorrectableRefs.includes(snapshot.public_ref)),
);
const [decision, setDecision] = useState<"retain_canonical" | "correct_reading">("retain_canonical");
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
const [correctedValue, setCorrectedValue] = useState("");
+9 -3
View File
@@ -41,11 +41,17 @@ export function Knowledge() {
useEffect(() => {
setStatusSettled(false);
const controller = new AbortController();
api
.get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`)
.get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`, { signal: controller.signal })
.then(setStatus)
.catch(() => setStatus(null))
.finally(() => setStatusSettled(true));
.catch(() => {
if (!controller.signal.aborted) setStatus(null);
})
.finally(() => {
if (!controller.signal.aborted) setStatusSettled(true);
});
return () => controller.abort();
}, [language]);
async function ask(questionText: string) {
+30 -2
View File
@@ -11,7 +11,12 @@ import { PRODUCT_NAME } from "../product";
export function Login() {
const { t } = useTranslation(["auth", "common"]);
const { loginAs, loginWithPassword, loading, demoMode, oidcEnabled, oidcProviderName } = useAuth();
const { manifest } = useDemoManifest();
const {
manifest,
loading: manifestLoading,
error: manifestError,
refresh: refreshManifest,
} = useDemoManifest();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState("");
@@ -38,8 +43,14 @@ export function Login() {
}
}
async function handleGuidedDemo() {
if (manifestLoading || manifestError || !manifest?.guide_available) return;
await handleLogin("operations_manager", "/dashboard?guide=start");
}
const orgName = manifest?.organization_name ?? t("common:orgName");
const description = t("defaultDescription", { productName: PRODUCT_NAME });
const guidedDemoReady = manifest?.guide_available === true;
return (
<main className="login-shell">
@@ -84,10 +95,27 @@ export function Login() {
{t("startRecruiterTour")}
</button>
<button type="button" className="button button-secondary login-full-demo" disabled={loading} onClick={() => handleLogin("operations_manager", "/dashboard?guide=start")}>
<button
type="button"
className="button button-secondary login-full-demo"
disabled={loading || manifestLoading || manifestError || !guidedDemoReady}
onClick={handleGuidedDemo}
>
{t("startGuidedDemo")}
</button>
{manifestLoading && (
<p className="login-guide-status" role="status">{t("guidedDemoChecking")}</p>
)}
{!manifestLoading && (manifestError || !guidedDemoReady) && (
<div className="login-guide-status is-unavailable" role={manifestError ? "alert" : "status"}>
<span>{t(manifestError ? "guidedDemoManifestUnavailable" : "guidedDemoNotReady")}</span>
<button type="button" className="link-button" onClick={refreshManifest}>
{t("guidedDemoRetry")}
</button>
</div>
)}
<div className="login-options">
<button type="button" aria-label={t("exploreAsOperationsManager")} disabled={loading} onClick={() => handleLogin("operations_manager")}>
<span className="role-icon"><Icon name="activity" /></span>
+3 -2
View File
@@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { Icon } from "../components/Icons";
import { LoadingState, PageHeader } from "../components/PageChrome";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
export function Scenarios() {
const { t } = useTranslation("demo");
const { manifest, loading } = useDemoManifest();
const { manifest, loading, error, refresh } = useDemoManifest();
const { user } = useAuth();
function roleLabel(roles: string[]): string {
@@ -24,6 +24,7 @@ export function Scenarios() {
/>
{loading && <LoadingState label={t("scenarios.loading")} />}
{!loading && error && <ErrorState message={t("scenarios.unavailable")} onRetry={refresh} />}
{manifest && (
<div className="scenario-grid">
+11 -1
View File
@@ -22,11 +22,12 @@ export function Users() {
const [newPassword, setNewPassword] = useState("");
const load = useCallback(() => {
if (currentUser?.role !== "operations_manager") return;
api
.get<UserRecord[]>("/api/v1/users")
.then(setUsers)
.catch((err) => setError(describeApiError(t, err)));
}, [t]);
}, [currentUser?.role, t]);
useEffect(load, [load]);
@@ -91,6 +92,15 @@ export function Users() {
}
}
if (currentUser?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.managerOnly")} />
<p>{t("users.managerOnly")}</p>
</div>
);
}
return (
<div className="page">
<PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.description")} />
+5 -2
View File
@@ -21,6 +21,9 @@ export function VehicleDetail() {
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
const { user } = useAuth();
const visibleTabs = user?.role === "operations_manager"
? TABS
: TABS.filter((candidate) => candidate !== "quality");
const load = useCallback(() => {
if (!publicRef) return;
@@ -42,7 +45,7 @@ export function VehicleDetail() {
<PageHeader eyebrow={t("detail.eyebrow")} title={`${vehicle.public_ref} · ${vehicle.make} ${vehicle.model}`} description={`${vehicle.registration_number} · ${vehicle.location}`} actions={<div className="status-stack"><StatusBadge status={vehicle.operational_status} label={t(`statuses.${vehicle.operational_status}`, { defaultValue: vehicle.operational_status })} />{vehicle.attention && <span className="badge severity-high">{t("detail.needsAttention")}</span>}</div>} />
<div role="tablist" aria-label={t("detail.tabsAriaLabel")} className="tabs" aria-orientation="horizontal">
{TABS.map((tb) => (
{visibleTabs.map((tb) => (
<button
key={tb}
role="tab"
@@ -111,7 +114,7 @@ export function VehicleDetail() {
</ul>{user?.role === "operations_manager" && <VehicleMaintenanceActions vehicle={vehicle} onSaved={load} />}</>
)}
{tab === "quality" && (
{tab === "quality" && user?.role === "operations_manager" && (
<ul className="record-list">
{vehicle.quality_issues.length === 0 && <li>{t("detail.noQualityIssues")}</li>}
{vehicle.quality_issues.map((q) => (
+77
View File
@@ -0,0 +1,77 @@
.architecture-story { margin-bottom: 26px; padding: 0; overflow: hidden; }
.architecture-explorer { padding: 18px 22px 22px; background: linear-gradient(180deg, #fff 0%, #fbfcfd 100%); }
.architecture-zones { display: grid; grid-template-columns: 1fr 3fr 1fr; gap: 12px; margin-bottom: 9px; }
.architecture-zones > span { min-height: 31px; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; font-size: .75rem; font-weight: 800; letter-spacing: .055em; text-transform: uppercase; }
.architecture-zones svg { width: 13px; height: 13px; }
.architecture-zones .zone-intent { background: var(--surface-subtle); }
.architecture-zones .zone-transaction { color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; }
.architecture-zones .zone-edge { color: var(--info); background: var(--info-pale); border-color: #cfe3ee; }
.architecture-flow { list-style: none; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin: 0; padding: 0; }
.architecture-step { position: relative; min-width: 0; }
.architecture-step button { position: relative; width: 100%; min-height: 164px; height: 100%; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; padding: 14px; overflow: hidden; color: var(--ink); text-align: left; background: white; border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer; transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease, background .18s ease; }
.architecture-step button::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--line-strong); transition: width .18s ease, background .18s ease; }
.architecture-step button:hover { border-color: #9fd5ce; box-shadow: 0 10px 24px rgba(15, 23, 42, .07); transform: translateY(-2px); }
.architecture-step button:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
.architecture-step.is-active button { background: #fbfffe; border-color: var(--teal); box-shadow: 0 12px 28px rgba(11, 111, 103, .11); transform: translateY(-2px); }
.architecture-step.is-active button::before { width: 4px; background: var(--teal); }
.architecture-step-edge.is-active button { background: #fbfdff; border-color: var(--info); box-shadow: 0 12px 28px rgba(53, 100, 128, .11); }
.architecture-step-edge.is-active button::before { background: var(--info); }
.architecture-step-top { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.architecture-step-number { color: var(--teal-dark); font-size: .75rem; font-weight: 850; letter-spacing: .1em; }
.architecture-step-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }
.architecture-step-icon svg { width: 15px; height: 15px; }
.architecture-step button > strong { font-size: .875rem; line-height: 1.35; }
.architecture-step button > small { color: var(--muted); font-size: .8125rem; line-height: 1.45; }
.architecture-step-meta { width: 100%; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 5px; margin-top: auto; }
.architecture-zone-tag { color: var(--muted); font-size: .75rem; font-weight: 800; letter-spacing: .045em; text-transform: uppercase; }
.architecture-commit-note { padding: 3px 6px; color: var(--info); background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: 999px; font-size: .75rem; font-weight: 800; white-space: nowrap; }
.architecture-connector { position: absolute; z-index: 4; top: calc(50% - 1px); left: 100%; width: 12px; height: 2px; background: var(--line-strong); pointer-events: none; }
.architecture-connector::after { content: ""; position: absolute; top: -3px; right: -1px; width: 7px; height: 7px; border-top: 2px solid var(--line-strong); border-right: 2px solid var(--line-strong); transform: rotate(45deg); }
.architecture-signal { position: absolute; z-index: 2; top: -2px; left: -2px; width: 6px; height: 6px; background: var(--teal); border-radius: 50%; box-shadow: 0 0 0 3px rgba(15, 143, 131, .12); animation: architecture-signal-x 2.4s ease-in-out infinite; }
.architecture-step:nth-child(2) .architecture-signal { animation-delay: .35s; }
.architecture-step:nth-child(3) .architecture-signal { animation-delay: .7s; }
.architecture-step:nth-child(4) .architecture-signal { animation-delay: 1.05s; }
.architecture-connector.is-commit-boundary { background: var(--info); }
.architecture-connector.is-commit-boundary::after { border-color: var(--info); }
.architecture-interaction-hint { margin: 12px 0 8px; color: var(--muted); font-size: .8125rem; text-align: center; }
.architecture-active-detail { position: relative; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr); gap: 22px; align-items: center; min-height: 142px; padding: 20px 22px; overflow: hidden; background: var(--petrol); border-radius: var(--radius); }
.architecture-active-detail::after { content: ""; position: absolute; right: -48px; bottom: -78px; width: 190px; height: 190px; border: 1px solid #2d4157; border-radius: 50%; box-shadow: 0 0 0 24px rgba(45, 65, 87, .22), 0 0 0 48px rgba(45, 65, 87, .12); pointer-events: none; }
.architecture-active-copy, .architecture-active-evidence { position: relative; z-index: 1; }
.architecture-active-copy > span { color: #5eead4; font-size: .75rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.architecture-active-copy h3 { margin: 7px 0 6px; color: white; font-size: 1.06rem; letter-spacing: -.015em; }
.architecture-active-copy p { max-width: 72ch; margin: 0; color: #bcc9d6; font-size: .8125rem; line-height: 1.6; }
.architecture-active-evidence { display: grid; gap: 10px; margin: 0; }
.architecture-active-evidence > div { padding: 10px 12px; background: rgba(255, 255, 255, .055); border: 1px solid #2d4157; border-radius: var(--radius); }
.architecture-active-evidence dt { color: #8fa2b6; font-size: .75rem; font-weight: 800; letter-spacing: .075em; text-transform: uppercase; }
.architecture-active-evidence dd { display: flex; flex-wrap: wrap; gap: 6px; margin: 5px 0 0; color: white; font-size: .8125rem; font-weight: 700; }
.architecture-active-evidence dd span { padding: 4px 7px; color: #d8e5ee; background: rgba(94, 234, 212, .08); border: 1px solid rgba(94, 234, 212, .18); border-radius: 999px; }
@keyframes architecture-signal-x { 0%, 20% { opacity: 0; transform: translateX(0); } 35% { opacity: 1; } 80% { opacity: 1; transform: translateX(10px); } 100% { opacity: 0; transform: translateX(10px); } }
@keyframes architecture-signal-y { 0%, 20% { opacity: 0; transform: translateY(0); } 35% { opacity: 1; } 80% { opacity: 1; transform: translateY(10px); } 100% { opacity: 0; transform: translateY(10px); } }
@media (prefers-reduced-motion: reduce) {
.architecture-signal { animation: none; }
}
@media (max-width: 1180px) {
.architecture-explorer { padding: 14px; }
.architecture-zones { display: none; }
.architecture-flow { grid-template-columns: 1fr; gap: 12px; padding: 0; }
.architecture-step button { min-height: 112px; padding: 14px 16px; }
.architecture-step button > small { max-width: 64ch; }
.architecture-connector { top: 100%; left: 27px; width: 2px; height: 12px; }
.architecture-connector::after { top: auto; right: -3px; bottom: -1px; transform: rotate(135deg); }
.architecture-signal { top: -2px; left: -2px; animation-name: architecture-signal-y; }
.architecture-active-detail { grid-template-columns: 1fr; gap: 16px; }
}
@media (max-width: 700px) {
.architecture-active-detail { min-height: 0; padding: 18px; }
.architecture-active-detail::after { opacity: .6; }
.architecture-active-evidence dd { display: grid; }
}
@media (forced-colors: active) {
.architecture-step.is-active button { outline: 2px solid Highlight; outline-offset: 2px; }
.architecture-signal, .architecture-active-detail::after { display: none; }
}
+8 -5
View File
@@ -398,7 +398,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.filter-presets { display: flex; flex-wrap: wrap; gap: 7px; margin: -6px 0 18px; }
.filter-presets button { min-height: 34px; padding: 5px 11px; color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; }
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.state-retry { margin-top: 12px; padding-block: 6px; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
.route-loading { min-height: 40vh; display: grid; place-items: center; }
.route-skeleton { width: min(760px, calc(100% - 40px)); display: grid; gap: 14px; }
.route-skeleton span { display: block; height: 18px; background: linear-gradient(90deg, #edf1f5 25%, #f8fafc 50%, #edf1f5 75%); background-size: 220% 100%; border-radius: var(--radius); animation: skeleton-shift 1.25s ease-in-out infinite; }
@@ -410,9 +410,11 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.engineering-hero h2 { max-width: 720px; margin: 0; font-size: clamp(1.6rem, 3vw, 2.35rem); line-height: 1.1; letter-spacing: -.035em; }.engineering-hero p:not(.page-eyebrow) { max-width: 680px; margin: 10px 0 0; color: #c4d0dc; line-height: 1.6; }.engineering-hero .page-eyebrow { color: #5eead4; }
.engineering-hero-actions { min-width: 220px; display: grid; gap: 9px; }.engineering-hero-actions .button { width: 100%; }
.standalone-heading { padding-inline: 0; border: 0; }.proof-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 26px; }.proof-card { padding: 22px; }.proof-card > span, .highlight-icon { width: 42px; height: 42px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.proof-card svg, .highlight-icon svg { width: 21px; }.proof-card h3 { margin: 18px 0 8px; font-size: 1rem; }.proof-card p { margin: 0; color: var(--muted); font-size: var(--type-body); line-height: 1.6; }
.architecture-story { margin-bottom: 26px; overflow: hidden; }.architecture-flow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 22px; }.architecture-step { position: relative; min-height: 104px; display: flex; flex-direction: column; justify-content: center; gap: 9px; padding: 16px; background: var(--surface-subtle); border: 1px solid var(--line); }.architecture-step > span { color: var(--teal-dark); font-size: var(--type-meta); font-weight: 800; }.architecture-step > strong { font-size: .82rem; line-height: 1.35; }.architecture-step > svg { position: absolute; z-index: 2; right: -12px; top: calc(50% - 12px); width: 24px; height: 24px; padding: 5px; color: white; background: var(--teal-dark); border-radius: 50%; }
.highlight-grid { list-style: none; display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin: 0 0 18px; padding: 0; counter-reset: highlight; }.highlight-card { min-height: 440px; display: flex; flex-direction: column; padding: 24px; }.highlight-card-top { display: flex; justify-content: space-between; align-items: center; }.highlight-number { color: var(--teal-dark); font-size: .72rem; font-weight: 800; letter-spacing: .1em; }.highlight-duration { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: var(--type-meta); }.highlight-duration svg { width: 14px; }.highlight-icon { margin-top: 32px; }.highlight-card h2 { margin: 18px 0 10px; font-size: 1.18rem; letter-spacing: -.02em; }.highlight-card > p { margin: 0; color: var(--muted); line-height: 1.65; }.highlight-proof { display: grid; gap: 4px; margin: auto 0 18px; padding: 13px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }.highlight-proof strong { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; letter-spacing: .06em; }.highlight-proof span { font-size: .77rem; font-weight: 700; }.highlights-next { display: flex; justify-content: space-between; align-items: center; gap: 24px; padding: 24px; }.highlights-next h2 { margin: 0; font-size: 1.15rem; }.highlights-next p:not(.page-eyebrow) { margin: 7px 0 0; color: var(--muted); }.highlights-next-actions { display: flex; flex-wrap: wrap; gap: 9px; }
.login-full-demo { width: 100%; min-height: 44px; margin: -8px 0 18px; }
.login-guide-status { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 28px; margin: -10px 0 16px; color: var(--muted); font-size: .75rem; text-align: center; }
.login-guide-status.is-unavailable { flex-wrap: wrap; color: var(--danger); }
.login-guide-status .link-button { min-height: 32px; padding: 3px 6px; color: inherit; font-size: inherit; }
/* Correlated operation trace */
.operation-trace { margin-bottom: 14px; padding: 22px; }.operation-trace h2 { margin: 0; font-size: 1.15rem; }.operation-trace > div > p:last-child { margin: 7px 0 0; color: var(--muted); }.operation-trace ol { list-style: none; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; margin: 20px 0 0; padding: 0; }.operation-trace li { position: relative; display: grid; grid-template-columns: 34px 1fr; gap: 10px; align-items: start; padding-right: 15px; }.operation-trace li::after { content: ""; position: absolute; top: 16px; left: 34px; right: 0; border-top: 1px solid var(--line-strong); }.operation-trace li:last-child::after { display: none; }.operation-trace li > span { position: relative; z-index: 1; width: 34px; height: 34px; display: grid; place-items: center; color: var(--muted); background: white; border: 1px solid var(--line-strong); border-radius: 50%; font-size: .72rem; font-weight: 800; }.operation-trace li.is-complete > span { color: white; background: var(--success); border-color: var(--success); }.operation-trace li svg { width: 17px; }.operation-trace li div { position: relative; z-index: 1; padding-right: 6px; background: white; }.operation-trace li strong { display: block; font-size: .78rem; }.operation-trace li small { display: block; margin-top: 4px; color: var(--muted); font-size: var(--type-meta); line-height: 1.4; }
@@ -527,10 +529,10 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
@media (max-width: 700px) {
:root { --type-body: .875rem; --type-meta: .75rem; --type-label: .75rem; --type-badge: .75rem; }
.icon-button, .language-switcher select, .demo-badge-trigger, .demo-guide-trigger, .back-link, .section-heading > a, .data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button, .toggle-matching-fields, .button-tertiary, .bulk-toolbar select, .bulk-toolbar input { min-width: 44px; min-height: 44px; }
.icon-button, .language-switcher select, .demo-badge-trigger, .demo-guide-trigger, .back-link, .section-heading > a, .data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button, .toggle-matching-fields, .button-tertiary, .bulk-toolbar select, .bulk-toolbar input, .state-retry { min-width: 44px; min-height: 44px; }
.demo-guide-trigger { width: 44px; justify-content: center; }.demo-badge-trigger { width: 44px; height: 44px; padding: 0; justify-content: center; font-size: 0; }.demo-badge-trigger svg { width: 16px; height: 16px; color: #48566a; }.language-switcher select { height: 44px; }
.mobile-nav span { max-width: 54px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.engineering-hero, .highlights-next { align-items: stretch; flex-direction: column; padding: 20px; }.engineering-hero-actions { min-width: 0; }.proof-grid, .highlight-grid { grid-template-columns: 1fr; }.highlight-card { min-height: 0; }.highlight-proof { margin-top: 22px; }.architecture-flow { grid-template-columns: 1fr; gap: 8px; padding: 14px; }.architecture-step { min-height: 72px; }.architecture-step > svg { right: calc(50% - 12px); top: auto; bottom: -16px; transform: rotate(90deg); }.highlights-next-actions { display: grid; }.operation-trace ol { grid-template-columns: 1fr; gap: 13px; }.operation-trace li { min-height: 54px; }.operation-trace li::after { top: 34px; bottom: -13px; left: 16px; right: auto; border-top: 0; border-left: 1px solid var(--line-strong); }.operation-trace li div { padding-bottom: 5px; }.knowledge-status { align-items: flex-start; }.knowledge-diagnostics { max-width: none; text-align: left; }.knowledge-progress { grid-template-columns: 1fr; }.retrieval-flow span { font-size: var(--type-meta); }.return-progress span { font-size: var(--type-meta); }.duplicate-compare > button { position: sticky; bottom: 78px; z-index: 6; width: 100%; box-shadow: var(--shadow-float); }
.engineering-hero, .highlights-next { align-items: stretch; flex-direction: column; padding: 20px; }.engineering-hero-actions { min-width: 0; }.proof-grid, .highlight-grid { grid-template-columns: 1fr; }.highlight-card { min-height: 0; }.highlight-proof { margin-top: 22px; }.highlights-next-actions { display: grid; }.operation-trace ol { grid-template-columns: 1fr; gap: 13px; }.operation-trace li { min-height: 54px; }.operation-trace li::after { top: 34px; bottom: -13px; left: 16px; right: auto; border-top: 0; border-left: 1px solid var(--line-strong); }.operation-trace li div { padding-bottom: 5px; }.knowledge-status { align-items: flex-start; }.knowledge-diagnostics { max-width: none; text-align: left; }.knowledge-progress { grid-template-columns: 1fr; }.retrieval-flow span { font-size: var(--type-meta); }.return-progress span { font-size: var(--type-meta); }.duplicate-compare > button { position: sticky; bottom: 78px; z-index: 6; width: 100%; box-shadow: var(--shadow-float); }
.resolution-actions { position: sticky; bottom: 72px; z-index: 6; padding: 9px; background: rgba(255,255,255,.97); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }.resolution-actions button { flex: 1; }
}
@@ -540,7 +542,8 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
}
@media (max-width: 700px) {
.page-actions { display: flex; min-height: 24px; }
.page-header { flex-direction: column; }
.page-actions > .button:only-child { width: 100%; min-height: 44px; }
}
@media (max-width: 440px) {