M54: harden operations and demo resilience
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 n’ont pas pu être chargées.",
|
||||
"guidedDemoNotReady": "La démo guidée n’est pas prête à démarrer pour le moment.",
|
||||
"guidedDemoRetry": "Recharger les informations de démo",
|
||||
"startRecruiterTour": "Voir les points forts en 90 secondes",
|
||||
"exploreAsOperationsManager": "Explorer en tant que Responsable des opérations",
|
||||
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
|
||||
|
||||
@@ -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. L’heure sautée lors du passage à l’heure d’été n’existe pas.",
|
||||
"endAfterStart": "La fin de la réservation doit être postérieure à son début.",
|
||||
"cancel": "Annuler",
|
||||
"save": "Créer la réservation",
|
||||
"saving": "Enregistrement…",
|
||||
@@ -118,6 +119,7 @@
|
||||
"confirmReschedule": "Enregistrer la nouvelle période",
|
||||
"rescheduling": "Enregistrement…",
|
||||
"rescheduleFailed": "La réservation n’a pas pu être replanifiée.",
|
||||
"invalidScheduleWindow": "Choisissez des heures Europe/Brussels valides et assurez-vous que la fin suit le début.",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"cancelAction": "Annuler la réservation",
|
||||
|
||||
@@ -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 l’utilisateur {{ref}}", "newPassword": "Nouveau mot de passe", "passwordUnchanged": "Laisser vide pour ne pas modifier", "saveChanges": "Enregistrer les modifications", "cancel": "Annuler", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
|
||||
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "managerOnly": "L’administration des utilisateurs est réservée aux Responsables des opérations.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "edit": "Modifier", "editTitle": "Modifier l’utilisateur {{ref}}", "newPassword": "Nouveau mot de passe", "passwordUnchanged": "Laisser vide pour ne pas modifier", "saveChanges": "Enregistrer les modifications", "cancel": "Annuler", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
|
||||
"roles": { "operations_manager": "Responsable des opérations", "rental_employee": "Employé de location" }
|
||||
}
|
||||
|
||||
@@ -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 n’a 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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user