M29: polish recruiter engineering story
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const OUT = "../artifacts/evidence/screenshots";
|
||||
|
||||
test("capture current recruiter-facing evidence", async ({ page, request }) => {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("heading", { name: "Elke overdracht. Eén helder overzicht." })).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/1-login.png`, fullPage: true });
|
||||
|
||||
await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click();
|
||||
await expect(page).toHaveURL(/\/highlights$/);
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/2-highlights.png`, fullPage: true });
|
||||
|
||||
await page.goto("/about");
|
||||
await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/3-engineering-story.png`, fullPage: true });
|
||||
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/4-dashboard.png`, fullPage: true });
|
||||
|
||||
await page.goto("/knowledge");
|
||||
await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/5-knowledge-evidence.png`, fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/highlights");
|
||||
await expect(page.locator(".highlight-card").first()).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/6-highlights-mobile.png` });
|
||||
});
|
||||
@@ -5,7 +5,7 @@ test.describe.configure({ mode: "serial" });
|
||||
test("demo entry screen names the fictional org and never shows a password", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
|
||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||
await expect(page.getByText(/Synthetische demo · geen echte klant-/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
|
||||
|
||||
@@ -27,7 +27,7 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a
|
||||
await page.getByRole("button", { name: "Retour bevestigen" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Auditgeschiedenis bekijken" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Volledige verwerking volgen" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ test("five-minute demo script end to end", async ({ page, request }) => {
|
||||
|
||||
await test.step("1. login as Operations Manager", async () => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||
await expect(page.getByText(/Synthetische demo · geen echte klant-/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
@@ -90,7 +90,7 @@ test("five-minute demo script end to end", async ({ page, request }) => {
|
||||
await test.step("9. verify responsive navigation at mobile width", async () => {
|
||||
await page.setViewportSize({ width: 360, height: 800 });
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.getByText(/Synthetische demo/).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Synthetische demo/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Overzicht" }).first()).toBeVisible();
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
|
||||
@@ -107,7 +107,7 @@ test.describe("status-recommendation flow", () => {
|
||||
await resetDemoData(request);
|
||||
await loginAsOpsManager(page, "nl-BE");
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
|
||||
await expect(page.getByText("DQ-DEMO-STATUS", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
|
||||
await expect(page.getByText("Aanbevolen status")).toBeVisible();
|
||||
|
||||
@@ -185,7 +185,7 @@ test("data quality issue detail: defer and reject buttons work", async ({ page,
|
||||
expect(ref).not.toBe("");
|
||||
await firstLink.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
|
||||
await expect(page.getByRole("heading", { name: ref })).toBeVisible();
|
||||
await expect(page.getByText(ref, { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Defer" }).click();
|
||||
await expect(page.locator(".badge.status-deferred")).toBeVisible();
|
||||
});
|
||||
@@ -193,7 +193,7 @@ test("data quality issue detail: defer and reject buttons work", async ({ page,
|
||||
test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-ATTENTION");
|
||||
await expect(page.getByRole("heading", { name: "DQ-DEMO-ATTENTION" })).toBeVisible();
|
||||
await expect(page.getByText("DQ-DEMO-ATTENTION", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByLabel("Registration number").fill("TST-777");
|
||||
await page.getByLabel("Make").fill("TestMake");
|
||||
@@ -207,7 +207,7 @@ test("data quality: providing missing fields resolves a vehicle issue", async ({
|
||||
test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
|
||||
await expect(page.getByRole("heading", { name: "DQ-DEMO-OVERLAP" })).toBeVisible();
|
||||
await expect(page.getByText("DQ-DEMO-OVERLAP", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check();
|
||||
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
|
||||
@@ -227,7 +227,7 @@ test("data quality: applying the recommended status resolves a vehicle conflict"
|
||||
}) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality/DQ-DEMO-STATUS");
|
||||
await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
|
||||
await expect(page.getByText("DQ-DEMO-STATUS", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Review recommendation" }).click();
|
||||
await page.getByRole("button", { name: /^Change status to/ }).click();
|
||||
@@ -248,7 +248,7 @@ test("data quality: retaining canonical resolves an odometer regression issue",
|
||||
const target = issues[0];
|
||||
|
||||
await page.goto(`/data-quality/${target.public_ref}`);
|
||||
await expect(page.getByRole("heading", { name: target.public_ref })).toBeVisible();
|
||||
await expect(page.getByText(target.public_ref, { exact: true })).toBeVisible();
|
||||
await page.getByRole("radio", { name: /Retain canonical/ }).check();
|
||||
await page.getByRole("button", { name: "Resolve issue" }).click();
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("90-second recruiter entry exposes three verifiable engineering highlights", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/highlights$/);
|
||||
await expect(page.getByRole("heading", { name: "De sterkste keuzes in 90 seconden" })).toBeVisible();
|
||||
await expect(page.locator(".highlight-card")).toHaveCount(3);
|
||||
await expect(page.getByText("Transactie · idempotency · outbox · degradatie")).toBeVisible();
|
||||
await expect(page.getByText("RAGcore · citations · veilige degradatie")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Engineering Story exposes architecture, reliability and honest integration evidence", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await page.goto("/about");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible();
|
||||
await expect(page.getByText("Commit eerst, orkestreer daarna")).toBeVisible();
|
||||
await expect(page.getByText("AI moet bewijs tonen")).toBeVisible();
|
||||
await expect(page.getByText("PostgreSQL + audittrail")).toBeVisible();
|
||||
await expect(page.getByText(/nog niet live gekoppeld/)).toHaveCount(0);
|
||||
await expect(page.getByText(/unknown procedures/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("mobile recruiter surfaces remain readable without horizontal overflow", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click();
|
||||
await expect(page).toHaveURL(/\/highlights$/);
|
||||
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
await expect(page.locator(".highlight-card").first()).toBeVisible();
|
||||
});
|
||||
+11
-2
@@ -3,9 +3,18 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
|
||||
<meta name="description" content="Fleet Ops is a production-minded vehicle operations demo with reliable workflows, explainable data quality and source-grounded AI." />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Fleet Ops — connected vehicle operations" />
|
||||
<meta property="og:description" content="Explore reliable vehicle returns, controlled data-quality resolution and source-grounded operational knowledge." />
|
||||
<meta property="og:image" content="/og-fleet-ops.svg" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Fleet Ops — connected vehicle operations" />
|
||||
<meta name="twitter:description" content="A working synthetic-data platform for reliable mobility operations." />
|
||||
<meta name="twitter:image" content="/og-fleet-ops.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Fleet Ops</title>
|
||||
<title>Fleet Ops — connected vehicle operations</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,7 +4,7 @@ export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
// _capture-screenshots.spec.ts is evidence-generation tooling, not a regression test —
|
||||
// it mutates demo data via a reset and is run explicitly, not as part of the suite.
|
||||
testIgnore: "**/_*.spec.ts",
|
||||
testIgnore: process.env.CAPTURE_EVIDENCE === "1" ? undefined : "**/_*.spec.ts",
|
||||
timeout: 30_000,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Fleet Ops</title>
|
||||
<desc id="desc">Connected vehicle operations with reliable workflows, explainable data quality and grounded knowledge.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0f172a"/><stop offset="1" stop-color="#17394d"/></linearGradient>
|
||||
<radialGradient id="glow"><stop stop-color="#2dd4bf" stop-opacity=".34"/><stop offset="1" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<circle cx="1000" cy="92" r="360" fill="url(#glow)"/>
|
||||
<g transform="translate(78 72)">
|
||||
<path d="M42 72C16 42 7 24 7 8a35 35 0 1 1 70 0c0 16-9 34-35 64Z" fill="#fff" transform="translate(0 6) scale(.72)"/>
|
||||
<circle cx="30" cy="28" r="6" fill="#2dd4bf"/>
|
||||
<rect x="0" y="58" width="62" height="6" rx="3" fill="#2dd4bf"/>
|
||||
<text x="84" y="51" fill="#fff" font-family="Inter,Segoe UI,sans-serif" font-size="36" font-weight="750">Fleet Ops</text>
|
||||
</g>
|
||||
<text x="78" y="265" fill="#5eead4" font-family="Inter,Segoe UI,sans-serif" font-size="24" font-weight="750" letter-spacing="3">CONNECTED VEHICLE OPERATIONS</text>
|
||||
<text x="78" y="345" fill="#fff" font-family="Inter,Segoe UI,sans-serif" font-size="60" font-weight="760">Every hand-off.</text>
|
||||
<text x="78" y="413" fill="#fff" font-family="Inter,Segoe UI,sans-serif" font-size="60" font-weight="760">One clear view.</text>
|
||||
<g transform="translate(78 500)" font-family="Inter,Segoe UI,sans-serif" font-size="22" font-weight="650">
|
||||
<rect width="272" height="54" rx="27" fill="#fff" fill-opacity=".09" stroke="#fff" stroke-opacity=".16"/><text x="27" y="35" fill="#dbeafe">Reliable workflows</text>
|
||||
<rect x="292" width="288" height="54" rx="27" fill="#fff" fill-opacity=".09" stroke="#fff" stroke-opacity=".16"/><text x="319" y="35" fill="#dbeafe">Explainable quality</text>
|
||||
<rect x="600" width="290" height="54" rx="27" fill="#fff" fill-opacity=".09" stroke="#fff" stroke-opacity=".16"/><text x="627" y="35" fill="#dbeafe">Grounded knowledge</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -21,9 +21,10 @@ const AboutDemo = lazy(() => import("./pages/AboutDemo").then((module) => ({ def
|
||||
const Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios })));
|
||||
const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users })));
|
||||
const Privacy = lazy(() => import("./pages/Privacy").then((module) => ({ default: module.Privacy })));
|
||||
const Highlights = lazy(() => import("./pages/Highlights").then((module) => ({ default: module.Highlights })));
|
||||
|
||||
function deferredPage(element: ReactNode) {
|
||||
return <Suspense fallback={<div className="route-loading" role="status"><span className="spinner" /></div>}>{element}</Suspense>;
|
||||
return <Suspense fallback={<div className="route-loading" role="status" aria-label="Loading"><div className="route-skeleton"><span /><span /><span /></div></div>}>{element}</Suspense>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
@@ -55,6 +56,7 @@ export function App() {
|
||||
<Route path="/privacy" element={deferredPage(<Privacy />)} />
|
||||
<Route path="/about" element={deferredPage(<AboutDemo />)} />
|
||||
<Route path="/scenarios" element={deferredPage(<Scenarios />)} />
|
||||
<Route path="/highlights" element={deferredPage(<Highlights />)} />
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@@ -224,6 +224,7 @@ export interface RegisterReturnResult {
|
||||
odometer_regression: boolean;
|
||||
quality_issue_ref: string | null;
|
||||
workflow_event_id: string;
|
||||
correlation_id: string;
|
||||
next_booking_risk: NextBookingRisk | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
|
||||
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
|
||||
{ to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] },
|
||||
{ to: "/privacy", labelKey: "items.privacy", icon: "shield", roles: ["operations_manager"] },
|
||||
{ to: "/about", labelKey: "items.engineering", icon: "spark" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -75,7 +75,7 @@ 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">{t("result.viewAudit")}<Icon name="chevron" /></Link>
|
||||
<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" />
|
||||
|
||||
@@ -17,20 +17,20 @@ export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [
|
||||
{
|
||||
id: "open-booking",
|
||||
route: (manifest) =>
|
||||
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings",
|
||||
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings/BK-DEMO-RETURN",
|
||||
target: ".record-surface",
|
||||
},
|
||||
{
|
||||
id: "process-return",
|
||||
route: (manifest) =>
|
||||
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings",
|
||||
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings/BK-DEMO-RETURN",
|
||||
target: "#return-form-heading",
|
||||
},
|
||||
{ id: "handle-quality-issue", route: () => "/data-quality", target: ".table-shell" },
|
||||
{
|
||||
id: "merge-duplicate",
|
||||
route: (manifest) =>
|
||||
manifest?.scenarios.find((s) => s.id === "duplicate-customer")?.start_path ?? "/data-quality",
|
||||
manifest?.scenarios.find((s) => s.id === "duplicate-customer")?.start_path ?? "/data-quality/DQ-DEMO-DUPLICATE",
|
||||
target: "#compare-heading",
|
||||
},
|
||||
{ id: "ask-knowledge", route: () => "/knowledge", target: "#ask-heading" },
|
||||
|
||||
@@ -69,6 +69,17 @@
|
||||
"survivor": "Retained profile",
|
||||
"loser": "Merged profile"
|
||||
},
|
||||
"trace": {
|
||||
"eyebrow": "End-to-end trace",
|
||||
"title": "From return to automation",
|
||||
"description": "This view connects events sharing the same correlation ID into one verifiable operation.",
|
||||
"steps": {
|
||||
"return": { "title": "Return persisted", "complete": "Booking and inspection were processed atomically.", "pending": "Not processed yet." },
|
||||
"vehicle": { "title": "Fleet state derived", "complete": "The server persisted the resulting vehicle state.", "pending": "No state change found yet." },
|
||||
"outbox": { "title": "Automation queued", "complete": "The outbox job was created in the same transaction.", "pending": "No outbox job found." },
|
||||
"delivery": { "title": "External follow-up", "complete": "n8n confirmed the follow-up.", "pending": "Delivery is asynchronous and not yet confirmed in this audit trace." }
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"demo_login": "Logged in",
|
||||
"demo_logout": "Logged out",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"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",
|
||||
"startRecruiterTour": "See the highlights in 90 seconds",
|
||||
"exploreAsOperationsManager": "Explore as Operations Manager",
|
||||
"exploreAsOperationsManagerDetail": "Full overview, quality resolution and retries",
|
||||
"exploreAsRentalEmployee": "Explore as Rental Employee",
|
||||
|
||||
@@ -87,6 +87,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"highlights": {
|
||||
"eyebrow": "Recruiter highlights",
|
||||
"title": "The strongest choices in 90 seconds",
|
||||
"description": "Three short views show how {{productName}} combines operational reliability, controlled corrections and source-grounded AI.",
|
||||
"duration": "about 30 sec",
|
||||
"open": "View in the platform",
|
||||
"proofLabel": "Engineering proof",
|
||||
"return": { "title": "A return that cannot fail silently", "body": "See server-side impact validation, atomic persistence and an outbox that decouples external automation from the rental desk.", "proof": "Transaction · idempotency · outbox · degradation" },
|
||||
"quality": { "title": "Data quality with human control", "body": "Compare duplicate profiles, deliberately choose the surviving record and preserve booking and audit history.", "proof": "Explainable rules · preview · audit · rollback" },
|
||||
"knowledge": { "title": "AI that must show its sources", "body": "Ask a procedure question and inspect the answer, source excerpts and honest insufficient-evidence state.", "proof": "RAGcore · citations · safe degradation" },
|
||||
"storyTitle": "Want to see the choices behind the system?",
|
||||
"storyBody": "The Engineering page connects product flows to architecture, reliability and honest integration status.",
|
||||
"storyButton": "Open Engineering Story",
|
||||
"fullTourButton": "Start the complete demo"
|
||||
},
|
||||
"scenarios": {
|
||||
"eyebrow": "Demonstration scenarios",
|
||||
"title": "Try a demonstration scenario",
|
||||
@@ -149,9 +164,29 @@
|
||||
"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…",
|
||||
"ctaTitle": "Prefer to jump right in?",
|
||||
"ctaBody": "The guided demo walks through all eight steps above in practice.",
|
||||
"ctaButton": "Start guided demo",
|
||||
"ctaTitle": "Choose how much time you have",
|
||||
"ctaBody": "See the three strongest engineering moments in 90 seconds, or take the complete operational tour.",
|
||||
"ctaButton": "Start full demo",
|
||||
"quickTourButton": "See highlights · 90 sec",
|
||||
"scenarioButton": "Open scenario lab",
|
||||
"proofEyebrow": "Engineering evidence",
|
||||
"proofTitle": "Control, reliability and explainability",
|
||||
"proofDescription": "Every technical choice supports a concrete operational action.",
|
||||
"proofRulesTitle": "Rules stay with the domain",
|
||||
"proofRulesBody": "FastAPI governs returns, statuses and data-quality resolutions. n8n and prompts never make authoritative decisions.",
|
||||
"proofReliabilityTitle": "Commit first, orchestrate second",
|
||||
"proofReliabilityBody": "The operation commits atomically; an outbox then delivers to n8n with bounded retries. An outage never blocks the rental desk.",
|
||||
"proofAiTitle": "AI must show evidence",
|
||||
"proofAiBody": "RAGcore answers include sources. Without sufficient evidence the assistant does not invent an answer.",
|
||||
"architectureFlowTitle": "From user action to controlled follow-up",
|
||||
"architectureFlowDescription": "The system of record stays separate from external orchestration and AI.",
|
||||
"architectureFrontend": "Accessible React interface",
|
||||
"architectureApi": "FastAPI domain rules",
|
||||
"architectureDatabase": "PostgreSQL + audit trail",
|
||||
"architectureOutbox": "Reliable outbox",
|
||||
"architectureExternal": "External services: n8n, RAGcore and MCP Hub",
|
||||
"verificationTitle": "Built to be verified",
|
||||
"verificationBody": "Domain rules, API contracts, degraded modes and the complete demo are tested automatically. The repository contains the exact acceptance commands and evidence bundle.",
|
||||
"problemTitle": "The fictional problem",
|
||||
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. {{productName}} shows how one connected system flags these problems early and lets them be resolved under control.",
|
||||
"scopeTitle": "Who it's for and its scope",
|
||||
@@ -172,7 +207,7 @@
|
||||
"resetBodyManager": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. Use <strong>Reset demo data</strong> in the sidebar to start over.",
|
||||
"resetBodyEmployee": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. An Operations Manager can reset the demo environment via the sidebar.",
|
||||
"limitationsTitle": "Limitations",
|
||||
"limitationsBody": "This is a focused proof of concept, not a full ERP. RAGcore and the ITWorx MCP Hub are not yet live-connected; the knowledge assistant uses a local, scoped demo knowledge base instead of a live RAGcore environment.",
|
||||
"limitationsBody": "This is a focused operational platform, not a full ERP. Accounting, payments, public reservations and a generic CRM are deliberately out of scope. External integrations are assessed separately and honestly using recent evidence.",
|
||||
"unknown": "unknown"
|
||||
},
|
||||
"integrationSummary": {
|
||||
@@ -183,7 +218,8 @@
|
||||
},
|
||||
"n8nDetail": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending.",
|
||||
"ragcoreDetail": "{{count}} procedures indexed in {{collection}}.",
|
||||
"mcpDetailEnabled": "Prepared for future, controlled tool calls from the Hub.",
|
||||
"mcpDetailNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub."
|
||||
"ragcoreSourceDetail": "{{source_count}} managed source procedures available; RAGcore does not report a reliable index count.",
|
||||
"mcpDetailOperational": "Live controlled tool calls from the Hub are confirmed in the audit trail.",
|
||||
"mcpDetailPrepared": "Four read-only tools are contract-ready; no recent Hub call has been recorded."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
"statusUnavailable": "Unavailable",
|
||||
"proceduresIndexed": "{{count}} procedures indexed",
|
||||
"proceduresIndexUnknown": "Index size is not available through this connection",
|
||||
"proceduresAvailable": "{{count}} managed source procedures available",
|
||||
"technicalDetails": "Technical provider details",
|
||||
"progress": { "retrieving": "Selecting sources", "checking": "Checking evidence", "composing": "Building answer" },
|
||||
"sourceTitles": {
|
||||
"damage-procedure": "Vehicle return damage",
|
||||
"vehicle-availability-procedure": "Restoring vehicle availability",
|
||||
"vehicle-return-procedure": "Registering a vehicle return",
|
||||
"odometer-anomaly-procedure": "Reviewing an odometer anomaly",
|
||||
"checkout-procedure": "Required checkout controls"
|
||||
},
|
||||
"statistics": {
|
||||
"sourceDocuments": "source documents in this language",
|
||||
"reportedSynced": "sync reported by n8n",
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"integrations": "Integrations",
|
||||
"audit": "Audit trail",
|
||||
"users": "Users",
|
||||
"privacy": "Privacy"
|
||||
"privacy": "Privacy",
|
||||
"engineering": "Engineering"
|
||||
},
|
||||
"primaryNavLabel": "Primary navigation",
|
||||
"mobileNavLabel": "Mobile navigation",
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
"showMatchingFields": "Also show matching fields",
|
||||
"hideMatchingFields": "Hide matching fields",
|
||||
"previewTitle": "Preview of the merged record ({{ref}})",
|
||||
"mergePreview": "{{loser}} will become a tombstone linked to {{survivor}}; its bookings will be rewired.",
|
||||
"mergePreview": "{{loser}} will be safely archived and linked to {{survivor}}; its booking history moves with it.",
|
||||
"mergeInto": "Merge into {{ref}}",
|
||||
"confirmMergeTitle": "Confirm merge",
|
||||
"confirmMergeBody": "Merge {{loser}} into {{survivor}}? This cannot be undone.",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"viewVehicle": "View vehicle {{ref}}",
|
||||
"viewAutomation": "View automation status",
|
||||
"viewAudit": "View audit trail",
|
||||
"viewTrace": "Trace the complete operation",
|
||||
"continueDemo": "Continue the demo"
|
||||
},
|
||||
"scenario": {
|
||||
|
||||
@@ -69,6 +69,17 @@
|
||||
"survivor": "Profil conservé",
|
||||
"loser": "Profil fusionné"
|
||||
},
|
||||
"trace": {
|
||||
"eyebrow": "Trace de bout en bout",
|
||||
"title": "Du retour à l'automatisation",
|
||||
"description": "Cette vue relie les événements partageant le même identifiant de corrélation en une opération vérifiable.",
|
||||
"steps": {
|
||||
"return": { "title": "Retour enregistré", "complete": "La réservation et l'inspection ont été traitées atomiquement.", "pending": "Pas encore traité." },
|
||||
"vehicle": { "title": "État de flotte dérivé", "complete": "Le serveur a enregistré l'état résultant du véhicule.", "pending": "Aucun changement d'état trouvé." },
|
||||
"outbox": { "title": "Automatisation planifiée", "complete": "La tâche outbox a été créée dans la même transaction.", "pending": "Aucune tâche outbox trouvée." },
|
||||
"delivery": { "title": "Suivi externe", "complete": "n8n a confirmé le suivi.", "pending": "La livraison est asynchrone et n'est pas encore confirmée dans cette trace." }
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"demo_login": "Connecté",
|
||||
"demo_logout": "Déconnecté",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"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",
|
||||
"startRecruiterTour": "Voir les points forts en 90 secondes",
|
||||
"exploreAsOperationsManager": "Explorer en tant que Responsable des opérations",
|
||||
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
|
||||
"exploreAsRentalEmployee": "Explorer en tant que Collaborateur de location",
|
||||
|
||||
@@ -87,6 +87,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"highlights": {
|
||||
"eyebrow": "Points forts pour recruteurs",
|
||||
"title": "Les choix les plus forts en 90 secondes",
|
||||
"description": "Trois vues courtes montrent comment {{productName}} combine fiabilité opérationnelle, corrections contrôlées et IA fondée sur des sources.",
|
||||
"duration": "± 30 s",
|
||||
"open": "Voir dans la plateforme",
|
||||
"proofLabel": "Preuve technique",
|
||||
"return": { "title": "Un retour qui ne peut pas échouer silencieusement", "body": "Découvrez la validation d'impact côté serveur, la persistance atomique et une outbox qui découple l'automatisation externe du comptoir.", "proof": "Transaction · idempotence · outbox · dégradation" },
|
||||
"quality": { "title": "Qualité des données sous contrôle humain", "body": "Comparez les profils en double, choisissez délibérément la fiche survivante et conservez l'historique des réservations et de l'audit.", "proof": "Règles explicables · aperçu · audit · rollback" },
|
||||
"knowledge": { "title": "Une IA qui doit montrer ses sources", "body": "Posez une question de procédure et inspectez la réponse, les extraits sources et l'état honnête de preuves insuffisantes.", "proof": "RAGcore · citations · dégradation sûre" },
|
||||
"storyTitle": "Voir les choix derrière le système ?",
|
||||
"storyBody": "La page Ingénierie relie les flux produit à l'architecture, la fiabilité et l'état honnête des intégrations.",
|
||||
"storyButton": "Ouvrir l'histoire d'ingénierie",
|
||||
"fullTourButton": "Démarrer la démo complète"
|
||||
},
|
||||
"scenarios": {
|
||||
"eyebrow": "Scénarios de démonstration",
|
||||
"title": "Essayer un scénario de démonstration",
|
||||
@@ -149,9 +164,29 @@
|
||||
"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…",
|
||||
"ctaTitle": "Vous préférez vous lancer directement ?",
|
||||
"ctaBody": "La démo guidée parcourt les huit étapes ci-dessus en pratique.",
|
||||
"ctaButton": "Démarrer la démo guidée",
|
||||
"ctaTitle": "Choisissez le temps dont vous disposez",
|
||||
"ctaBody": "Découvrez les trois moments d'ingénierie les plus forts en 90 secondes, ou suivez la démo opérationnelle complète.",
|
||||
"ctaButton": "Démarrer la démo complète",
|
||||
"quickTourButton": "Voir les points forts · 90 s",
|
||||
"scenarioButton": "Ouvrir le laboratoire de scénarios",
|
||||
"proofEyebrow": "Preuves d'ingénierie",
|
||||
"proofTitle": "Contrôle, fiabilité et explicabilité",
|
||||
"proofDescription": "Chaque choix technique soutient une action opérationnelle concrète.",
|
||||
"proofRulesTitle": "Les règles restent dans le domaine",
|
||||
"proofRulesBody": "FastAPI régit les retours, les statuts et les résolutions de qualité. n8n et les prompts ne prennent jamais de décisions faisant autorité.",
|
||||
"proofReliabilityTitle": "Valider d'abord, orchestrer ensuite",
|
||||
"proofReliabilityBody": "L'opération est validée atomiquement ; une outbox livre ensuite à n8n avec des tentatives limitées. Une panne ne bloque jamais le comptoir.",
|
||||
"proofAiTitle": "L'IA doit montrer ses preuves",
|
||||
"proofAiBody": "Les réponses RAGcore incluent des sources. Sans preuves suffisantes, l'assistant n'invente pas de réponse.",
|
||||
"architectureFlowTitle": "De l'action utilisateur au suivi contrôlé",
|
||||
"architectureFlowDescription": "Le système de référence reste séparé de l'orchestration externe et de l'IA.",
|
||||
"architectureFrontend": "Interface React accessible",
|
||||
"architectureApi": "Règles métier FastAPI",
|
||||
"architectureDatabase": "PostgreSQL + piste d'audit",
|
||||
"architectureOutbox": "Outbox fiable",
|
||||
"architectureExternal": "Services externes : n8n, RAGcore et MCP Hub",
|
||||
"verificationTitle": "Construit pour être vérifiable",
|
||||
"verificationBody": "Les règles métier, contrats d'API, modes dégradés et la démo complète sont testés automatiquement. Le dépôt contient les commandes d'acceptation exactes et le dossier de preuves.",
|
||||
"problemTitle": "Le problème fictif",
|
||||
"problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. {{productName}} montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.",
|
||||
"scopeTitle": "Pour qui et avec quelle portée",
|
||||
@@ -172,7 +207,7 @@
|
||||
"resetBodyManager": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Utilisez <strong>Réinitialiser les données de démo</strong> dans la barre latérale pour recommencer.",
|
||||
"resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Un Responsable des opérations peut réinitialiser l'environnement de démo via la barre latérale.",
|
||||
"limitationsTitle": "Limitations",
|
||||
"limitationsBody": "Ceci est une preuve de concept ciblée, pas un ERP complet. RAGcore et l'ITWorx MCP Hub ne sont pas encore connectés en direct ; l'assistant de connaissances utilise une base de connaissances de démo locale et délimitée au lieu d'un environnement RAGcore en direct.",
|
||||
"limitationsBody": "Il s'agit d'une plateforme opérationnelle ciblée, pas d'un ERP complet. La comptabilité, les paiements, les réservations publiques et un CRM générique sont délibérément hors périmètre. Les intégrations externes sont évaluées séparément et honnêtement à partir de preuves récentes.",
|
||||
"unknown": "inconnu"
|
||||
},
|
||||
"integrationSummary": {
|
||||
@@ -183,7 +218,8 @@
|
||||
},
|
||||
"n8nDetail": "{{succeeded}} réussi(s) · {{failed}} échoué(s) · {{pending}} en attente.",
|
||||
"ragcoreDetail": "{{count}} procédures indexées dans {{collection}}.",
|
||||
"mcpDetailEnabled": "Préparé pour de futurs appels d'outils contrôlés depuis le Hub.",
|
||||
"mcpDetailNotConnected": "Pas encore connecté — préparé pour de futurs appels d'outils contrôlés depuis le Hub."
|
||||
"ragcoreSourceDetail": "{{source_count}} procédures sources gérées sont disponibles ; RAGcore ne fournit pas de comptage d'index fiable.",
|
||||
"mcpDetailOperational": "Des appels d'outils contrôlés en direct depuis le Hub sont confirmés dans la piste d'audit.",
|
||||
"mcpDetailPrepared": "Quatre outils en lecture seule sont prêts contractuellement ; aucun appel récent du Hub n'a été enregistré."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,17 @@
|
||||
"statusAvailable": "Disponible",
|
||||
"statusUnavailable": "Indisponible",
|
||||
"proceduresIndexed": "{{count}} procédures indexées",
|
||||
"proceduresIndexUnknown": "La taille de l’index n’est pas disponible via cette connexion",
|
||||
"proceduresIndexUnknown": "La taille de l'index n'est pas disponible via cette connexion",
|
||||
"proceduresAvailable": "{{count}} procédures sources gérées disponibles",
|
||||
"technicalDetails": "Détails techniques du fournisseur",
|
||||
"progress": { "retrieving": "Sélection des sources", "checking": "Vérification des preuves", "composing": "Construction de la réponse" },
|
||||
"sourceTitles": {
|
||||
"damage-procedure": "Dommages au retour du véhicule",
|
||||
"vehicle-availability-procedure": "Rétablir la disponibilité du véhicule",
|
||||
"vehicle-return-procedure": "Enregistrer un retour de véhicule",
|
||||
"odometer-anomaly-procedure": "Examiner une anomalie kilométrique",
|
||||
"checkout-procedure": "Contrôles obligatoires au départ"
|
||||
},
|
||||
"statistics": {
|
||||
"sourceDocuments": "documents sources dans cette langue",
|
||||
"reportedSynced": "synchronisation signalée par n8n",
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"integrations": "Intégrations",
|
||||
"audit": "Piste d'audit",
|
||||
"users": "Utilisateurs",
|
||||
"privacy": "Confidentialité"
|
||||
"privacy": "Confidentialité",
|
||||
"engineering": "Ingénierie"
|
||||
},
|
||||
"primaryNavLabel": "Navigation principale",
|
||||
"mobileNavLabel": "Navigation mobile",
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
"showMatchingFields": "Afficher aussi les champs identiques",
|
||||
"hideMatchingFields": "Masquer les champs identiques",
|
||||
"previewTitle": "Aperçu de la fiche fusionnée ({{ref}})",
|
||||
"mergePreview": "{{loser}} deviendra une fiche archivée liée à {{survivor}} ; ses réservations seront transférées.",
|
||||
"mergePreview": "{{loser}} sera archivé en toute sécurité et lié à {{survivor}} ; son historique de réservations sera transféré.",
|
||||
"mergeInto": "Fusionner avec {{ref}}",
|
||||
"confirmMergeTitle": "Confirmer la fusion",
|
||||
"confirmMergeBody": "Fusionner {{loser}} avec {{survivor}} ? Cette action est irréversible.",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"viewVehicle": "Voir le véhicule {{ref}}",
|
||||
"viewAutomation": "Voir le statut d'automatisation",
|
||||
"viewAudit": "Voir la piste d'audit",
|
||||
"viewTrace": "Suivre l'opération complète",
|
||||
"continueDemo": "Poursuivre la démo"
|
||||
},
|
||||
"scenario": {
|
||||
|
||||
@@ -69,6 +69,17 @@
|
||||
"survivor": "Behouden profiel",
|
||||
"loser": "Samengevoegd profiel"
|
||||
},
|
||||
"trace": {
|
||||
"eyebrow": "Volledige verwerking",
|
||||
"title": "Van retour tot automatisering",
|
||||
"description": "Deze weergave koppelt de gebeurtenissen met dezelfde correlatiecode tot één controleerbare verwerking.",
|
||||
"steps": {
|
||||
"return": { "title": "Retour opgeslagen", "complete": "Boeking en inspectie zijn atomair verwerkt.", "pending": "Nog niet verwerkt." },
|
||||
"vehicle": { "title": "Wagenparkstatus afgeleid", "complete": "De server heeft de resulterende voertuigstatus vastgelegd.", "pending": "Statuswijziging nog niet gevonden." },
|
||||
"outbox": { "title": "Automatisering klaargezet", "complete": "De outbox-opdracht is in dezelfde transactie aangemaakt.", "pending": "Geen outbox-opdracht gevonden." },
|
||||
"delivery": { "title": "Externe opvolging", "complete": "n8n heeft de opvolging bevestigd.", "pending": "Aflevering is asynchroon en nog niet in deze audittrace bevestigd." }
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"demo_login": "Ingelogd",
|
||||
"demo_logout": "Uitgelogd",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"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",
|
||||
"startRecruiterTour": "Bekijk de highlights in 90 seconden",
|
||||
"exploreAsOperationsManager": "Verken als Operationsmanager",
|
||||
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
|
||||
"exploreAsRentalEmployee": "Verken als Verhuurmedewerker",
|
||||
|
||||
@@ -87,6 +87,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"highlights": {
|
||||
"eyebrow": "Highlights voor recruiters",
|
||||
"title": "De sterkste keuzes in 90 seconden",
|
||||
"description": "Drie korte inkijkjes tonen hoe {{productName}} operationele betrouwbaarheid, gecontroleerde correcties en brongebaseerde AI samenbrengt.",
|
||||
"duration": "± 30 sec",
|
||||
"open": "Bekijk in het platform",
|
||||
"proofLabel": "Technisch bewijs",
|
||||
"return": { "title": "Een retour die niet stil kan falen", "body": "Bekijk serverzijdige impactvalidatie, atomaire opslag en een outbox die externe automatisering loskoppelt van de balie.", "proof": "Transactie · idempotency · outbox · degradatie" },
|
||||
"quality": { "title": "Datakwaliteit met menselijke controle", "body": "Vergelijk dubbele profielen, kies bewust het overlevende record en behoud boekings- en auditgeschiedenis.", "proof": "Uitlegbare regels · preview · audit · rollback" },
|
||||
"knowledge": { "title": "AI die zijn bronnen moet tonen", "body": "Stel een procedurevraag en inspecteer het antwoord, de bronfragmenten en de eerlijke insufficient-evidence toestand.", "proof": "RAGcore · citations · veilige degradatie" },
|
||||
"storyTitle": "Wil je de keuzes achter het systeem zien?",
|
||||
"storyBody": "De Engineering-pagina verbindt de productflows met architectuur, betrouwbaarheid en eerlijke integratiestatus.",
|
||||
"storyButton": "Open het engineeringverhaal",
|
||||
"fullTourButton": "Start de volledige demo"
|
||||
},
|
||||
"scenarios": {
|
||||
"eyebrow": "Demonstratiescenario's",
|
||||
"title": "Probeer een demonstratiescenario",
|
||||
@@ -149,9 +164,29 @@
|
||||
"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…",
|
||||
"ctaTitle": "Liever meteen aan de slag?",
|
||||
"ctaBody": "De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.",
|
||||
"ctaButton": "Start begeleide demo",
|
||||
"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",
|
||||
"quickTourButton": "Bekijk highlights · 90 sec",
|
||||
"scenarioButton": "Open scenariolab",
|
||||
"proofEyebrow": "Engineeringbewijs",
|
||||
"proofTitle": "Controle, betrouwbaarheid en uitlegbaarheid",
|
||||
"proofDescription": "Elke technische keuze ondersteunt een concrete operationele handeling.",
|
||||
"proofRulesTitle": "Regels blijven bij het domein",
|
||||
"proofRulesBody": "FastAPI bewaakt retouren, statussen en datakwaliteitsoplossingen. n8n en prompts nemen nooit gezaghebbende beslissingen.",
|
||||
"proofReliabilityTitle": "Commit eerst, orkestreer daarna",
|
||||
"proofReliabilityBody": "De operatie commit atomair; een outbox levert nadien met begrensde herpogingen aan n8n. Een storing blokkeert de verhuurbalie niet.",
|
||||
"proofAiTitle": "AI moet bewijs tonen",
|
||||
"proofAiBody": "RAGcore-antwoorden bevatten bronnen. Zonder voldoende evidentie geeft de assistent geen verzonnen antwoord.",
|
||||
"architectureFlowTitle": "Van gebruikersactie tot gecontroleerd vervolg",
|
||||
"architectureFlowDescription": "Het systeem van record blijft gescheiden van externe orkestratie en AI.",
|
||||
"architectureFrontend": "Toegankelijke React-interface",
|
||||
"architectureApi": "FastAPI-domeinregels",
|
||||
"architectureDatabase": "PostgreSQL + audittrail",
|
||||
"architectureOutbox": "Betrouwbare outbox",
|
||||
"architectureExternal": "Externe diensten: n8n, RAGcore en MCP Hub",
|
||||
"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",
|
||||
"problemBody": "{{orgName}} verhuurt zo'n 50 campers en bestelwagens vanuit één hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. {{productName}} toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.",
|
||||
"scopeTitle": "Voor wie en met welke scope",
|
||||
@@ -172,7 +207,7 @@
|
||||
"resetBodyManager": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Gebruik <strong>Demogegevens herstellen</strong> in de zijbalk om opnieuw te beginnen.",
|
||||
"resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Een Operationsmanager kan de demo-omgeving herstellen via de zijbalk.",
|
||||
"limitationsTitle": "Beperkingen",
|
||||
"limitationsBody": "Dit is een gerichte proof of concept, geen volledig ERP. RAGcore en de ITWorx MCP Hub zijn nog niet live gekoppeld; de kennisassistent gebruikt een lokale, afgebakende demokennisbank in plaats van een live RAGcore-omgeving.",
|
||||
"limitationsBody": "Dit is een doelgericht operationeel platform, geen volledig ERP. Boekhouding, betalingen, publieke reserveringen en een generieke CRM vallen bewust buiten scope. Externe integraties worden afzonderlijk en eerlijk op recent bewijs beoordeeld.",
|
||||
"unknown": "onbekend"
|
||||
},
|
||||
"integrationSummary": {
|
||||
@@ -183,7 +218,8 @@
|
||||
},
|
||||
"n8nDetail": "{{succeeded}} geslaagd · {{failed}} mislukt · {{pending}} in wachtrij.",
|
||||
"ragcoreDetail": "{{count}} procedures geïndexeerd in {{collection}}.",
|
||||
"mcpDetailEnabled": "Voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.",
|
||||
"mcpDetailNotConnected": "Nog niet gekoppeld — voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub."
|
||||
"ragcoreSourceDetail": "{{source_count}} beheerde bronprocedures beschikbaar; RAGcore rapporteert geen betrouwbare indextelling.",
|
||||
"mcpDetailOperational": "Live gecontroleerde tool-aanroepen vanuit de Hub zijn in de auditgeschiedenis bevestigd.",
|
||||
"mcpDetailPrepared": "Vier read-only tools zijn contractueel voorbereid; er is nog geen recente Hub-aanroep geregistreerd."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
"statusUnavailable": "Niet beschikbaar",
|
||||
"proceduresIndexed": "{{count}} procedures geïndexeerd",
|
||||
"proceduresIndexUnknown": "Indexomvang niet beschikbaar via deze koppeling",
|
||||
"proceduresAvailable": "{{count}} beheerde bronprocedures beschikbaar",
|
||||
"technicalDetails": "Technische providerdetails",
|
||||
"progress": { "retrieving": "Bronnen selecteren", "checking": "Evidentie controleren", "composing": "Antwoord opbouwen" },
|
||||
"sourceTitles": {
|
||||
"damage-procedure": "Schade bij voertuigretour",
|
||||
"vehicle-availability-procedure": "Voertuig opnieuw beschikbaar stellen",
|
||||
"vehicle-return-procedure": "Voertuigretour registreren",
|
||||
"odometer-anomaly-procedure": "Afwijkende kilometerstand beoordelen",
|
||||
"checkout-procedure": "Verplichte vertrekcontroles"
|
||||
},
|
||||
"statistics": {
|
||||
"sourceDocuments": "brondocumenten in deze taal",
|
||||
"reportedSynced": "sync door n8n gerapporteerd",
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"integrations": "Integraties",
|
||||
"audit": "Auditgeschiedenis",
|
||||
"users": "Gebruikers",
|
||||
"privacy": "Privacy"
|
||||
"privacy": "Privacy",
|
||||
"engineering": "Techniek"
|
||||
},
|
||||
"primaryNavLabel": "Hoofdnavigatie",
|
||||
"mobileNavLabel": "Mobiele navigatie",
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
"showMatchingFields": "Ook overeenkomende velden tonen",
|
||||
"hideMatchingFields": "Overeenkomende velden verbergen",
|
||||
"previewTitle": "Voorbeeld van het samengevoegde record ({{ref}})",
|
||||
"mergePreview": "{{loser}} wordt een tombstone gekoppeld aan {{survivor}}; de boekingen worden herverbonden.",
|
||||
"mergePreview": "{{loser}} wordt veilig gearchiveerd en gekoppeld aan {{survivor}}; de boekingsgeschiedenis verhuist mee.",
|
||||
"mergeInto": "Samenvoegen met {{ref}}",
|
||||
"confirmMergeTitle": "Samenvoegen bevestigen",
|
||||
"confirmMergeBody": "{{loser}} samenvoegen met {{survivor}}? Dit kan niet ongedaan gemaakt worden.",
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"viewVehicle": "Voertuig {{ref}} bekijken",
|
||||
"viewAutomation": "Automatiseringsstatus bekijken",
|
||||
"viewAudit": "Auditgeschiedenis bekijken",
|
||||
"viewTrace": "Volledige verwerking volgen",
|
||||
"continueDemo": "Ga verder met de demo"
|
||||
},
|
||||
"scenario": {
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { Icon, type IconName } from "../components/Icons";
|
||||
import { 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",
|
||||
};
|
||||
|
||||
const N8N_STATUS_LABEL_KEY: Record<string, string> = {
|
||||
disabled: "notConnected",
|
||||
unavailable: "deliveryFailed",
|
||||
degraded: "retryAvailable",
|
||||
operational: "operational",
|
||||
no_evidence: "prepared",
|
||||
};
|
||||
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = { n8n: "n8n", ragcore: "rag", mcp_hub: "mcp" };
|
||||
const N8N_STATUS_LABEL_KEY: Record<string, string> = { disabled: "notConnected", unavailable: "deliveryFailed", degraded: "retryAvailable", operational: "operational", no_evidence: "prepared" };
|
||||
const PROOFS: Array<{ id: "Rules" | "Reliability" | "Ai"; icon: IconName }> = [
|
||||
{ id: "Rules", icon: "shield" },
|
||||
{ id: "Reliability", icon: "integrations" },
|
||||
{ id: "Ai", icon: "knowledge" },
|
||||
];
|
||||
|
||||
export function AboutDemo() {
|
||||
const { t } = useTranslation(["demo", "integrations"]);
|
||||
@@ -27,112 +22,56 @@ export function AboutDemo() {
|
||||
const { manifest, loading } = useDemoManifest();
|
||||
const { user } = useAuth();
|
||||
const { openGuide } = useDemoGuide();
|
||||
|
||||
function statusLabelKey(key: string, statusCode: string): string {
|
||||
if (key === "n8n") return N8N_STATUS_LABEL_KEY[statusCode] ?? statusCode;
|
||||
return statusCode;
|
||||
}
|
||||
const statusLabelKey = (key: string, statusCode: string) => key === "n8n" ? N8N_STATUS_LABEL_KEY[statusCode] ?? statusCode : statusCode;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
eyebrow={t("about.eyebrow")}
|
||||
title={t("about.title", { productName: PRODUCT_NAME })}
|
||||
description={manifest ? t("about.description", { orgName: manifest.organization_name }) : undefined}
|
||||
/>
|
||||
|
||||
<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")} />}
|
||||
|
||||
{manifest && (
|
||||
<>
|
||||
{user?.role === "operations_manager" && (
|
||||
<section className="record-surface about-card about-cta">
|
||||
<div>
|
||||
<strong>{t("about.ctaTitle")}</strong>
|
||||
<p>{t("about.ctaBody")}</p>
|
||||
</div>
|
||||
<button type="button" className="button button-primary" onClick={openGuide}>
|
||||
<Icon name="spark" /> {t("about.ctaButton")}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>{t("about.problemTitle")}</h2>
|
||||
<p>{t("about.problemBody", { orgName: manifest.organization_name, productName: PRODUCT_NAME })}</p>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>{t("about.scopeTitle")}</h2>
|
||||
<p>{t("about.scopeBody", { productName: PRODUCT_NAME })}</p>
|
||||
</section>
|
||||
|
||||
<div className="about-grid">
|
||||
<div className="record-surface about-card">
|
||||
<h2>{t("about.realTitle")}</h2>
|
||||
<p>{t("about.realBody")}</p>
|
||||
</div>
|
||||
|
||||
<div className="record-surface about-card">
|
||||
<h2>{t("about.syntheticTitle")}</h2>
|
||||
<p>{t("about.syntheticBody", { testDomain: ".test" })}</p>
|
||||
</div>
|
||||
|
||||
<details className="record-surface about-card about-details">
|
||||
<summary>{t("about.architectureTitle")}</summary>
|
||||
<p>{t("about.architectureBody")}</p>
|
||||
</details>
|
||||
|
||||
<details className="record-surface about-card about-details">
|
||||
<summary>{t("about.securityTitle")}</summary>
|
||||
<p>{t("about.securityBody")}</p>
|
||||
</details>
|
||||
|
||||
<details className="record-surface about-card about-details">
|
||||
<summary>{t("about.testingTitle")}</summary>
|
||||
<p>{t("about.testingBody")}</p>
|
||||
</details>
|
||||
{manifest && <>
|
||||
<section className="panel engineering-hero">
|
||||
<div>
|
||||
<p className="page-eyebrow">{t("about.proofEyebrow")}</p>
|
||||
<h2>{t("about.proofTitle")}</h2>
|
||||
<p>{t("about.proofDescription")}</p>
|
||||
</div>
|
||||
<div className="engineering-hero-actions">
|
||||
<Link className="button button-primary" to="/highlights"><Icon name="spark" /> {t("about.quickTourButton")}</Link>
|
||||
{user?.role === "operations_manager" && <button type="button" className="button button-secondary" onClick={openGuide}>{t("about.ctaButton")}</button>}
|
||||
<Link className="button button-secondary" to="/scenarios">{t("about.scenarioButton")}</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-label={t("about.integrationsTitle")} className="record-surface">
|
||||
<SectionHeading title={t("about.integrationsTitle")} description={t("about.integrationsDescription")} />
|
||||
<div className="integration-cards">
|
||||
{manifest.integrations.map((integration) => (
|
||||
<article key={integration.key}>
|
||||
<IntegrationMark kind={INTEGRATION_ICON[integration.key]} />
|
||||
<div>
|
||||
<span className="integration-kicker">
|
||||
{t(`integrations:statusLabels.${statusLabelKey(integration.key, integration.status_code)}`)}
|
||||
</span>
|
||||
<h2>{t(`integrationSummary.titles.${integration.key}`, { ns: "demo" })}</h2>
|
||||
<p>{t(`integrationSummary.${integration.detail_code}`, { ns: "demo", ...integration.detail_params })}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section aria-label={t("about.proofTitle")}>
|
||||
<div className="proof-grid">
|
||||
{PROOFS.map((proof) => <article className="panel proof-card" key={proof.id}><span><Icon name={proof.icon} /></span><h3>{t(`about.proof${proof.id}Title`)}</h3><p>{t(`about.proof${proof.id}Body`)}</p></article>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>{t("about.resetTitle")}</h2>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={user?.role === "operations_manager" ? "about.resetBodyManager" : "about.resetBodyEmployee"}
|
||||
t={t}
|
||||
values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("about.unknown") }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section className="access-note record-surface" aria-label={t("about.limitationsTitle")}>
|
||||
<Icon name="shield" />
|
||||
<p>
|
||||
<strong>{t("about.limitationsTitle")}</strong>
|
||||
<span>{t("about.limitationsBody")}</span>
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
<div className="about-grid">
|
||||
<div className="record-surface about-card"><h2>{t("about.problemTitle")}</h2><p>{t("about.problemBody", { orgName: manifest.organization_name, productName: PRODUCT_NAME })}</p></div>
|
||||
<div className="record-surface about-card"><h2>{t("about.scopeTitle")}</h2><p>{t("about.scopeBody", { productName: PRODUCT_NAME })}</p></div>
|
||||
<div className="record-surface about-card"><h2>{t("about.realTitle")}</h2><p>{t("about.realBody")}</p></div>
|
||||
<div className="record-surface about-card"><h2>{t("about.syntheticTitle")}</h2><p>{t("about.syntheticBody", { testDomain: ".test" })}</p></div>
|
||||
<div className="record-surface about-card"><h2>{t("about.verificationTitle")}</h2><p>{t("about.verificationBody")}</p></div>
|
||||
<div className="record-surface about-card"><h2>{t("about.securityTitle")}</h2><p>{t("about.securityBody")}</p></div>
|
||||
</div>
|
||||
|
||||
<section aria-label={t("about.integrationsTitle")} className="record-surface">
|
||||
<SectionHeading title={t("about.integrationsTitle")} description={t("about.integrationsDescription")} />
|
||||
<div className="integration-cards">{manifest.integrations.map((integration) => <article key={integration.key}><IntegrationMark kind={INTEGRATION_ICON[integration.key]} /><div><span className="integration-kicker">{t(`integrations:statusLabels.${statusLabelKey(integration.key, integration.status_code)}`)}</span><h2>{t(`integrationSummary.titles.${integration.key}`, { ns: "demo" })}</h2><p>{t(`integrationSummary.${integration.detail_code}`, { ns: "demo", ...integration.detail_params })}</p></div></article>)}</div>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card"><h2>{t("about.resetTitle")}</h2><p><Trans i18nKey={user?.role === "operations_manager" ? "about.resetBodyManager" : "about.resetBodyEmployee"} t={t} values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("about.unknown") }} components={{ strong: <strong /> }} /></p></section>
|
||||
<section className="access-note record-surface" aria-label={t("about.limitationsTitle")}><Icon name="shield" /><p><strong>{t("about.limitationsTitle")}</strong><span>{t("about.limitationsBody")}</span></p></section>
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuth } from "../context/AuthContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
import { Icon } from "../components/Icons";
|
||||
|
||||
function humanizeField(field: string): string {
|
||||
const spaced = field.replace(/_/g, " ");
|
||||
@@ -39,6 +40,22 @@ function ChangeDiff({ before, after, t }: { before: Record<string, unknown> | nu
|
||||
|
||||
interface EventGroup { correlationId: string; primary: AuditEvent; related: AuditEvent[]; }
|
||||
|
||||
function OperationTrace({ events, t }: { events: AuditEvent[]; t: (key: string, options?: Record<string, unknown>) => string }) {
|
||||
const actions = new Set(events.map((event) => event.action));
|
||||
const isReturn = actions.has("return_registered");
|
||||
if (!isReturn) return null;
|
||||
const steps = [
|
||||
{ key: "return", complete: isReturn },
|
||||
{ key: "vehicle", complete: actions.has("vehicle_status_changed") },
|
||||
{ key: "outbox", complete: isReturn },
|
||||
{ key: "delivery", complete: actions.has("n8n_return_followup_recorded") },
|
||||
];
|
||||
return <section className="panel operation-trace" aria-labelledby="operation-trace-title">
|
||||
<div><p className="page-eyebrow">{t("trace.eyebrow")}</p><h2 id="operation-trace-title">{t("trace.title")}</h2><p>{t("trace.description")}</p></div>
|
||||
<ol>{steps.map((step, index) => <li className={step.complete ? "is-complete" : "is-pending"} key={step.key}><span>{step.complete ? <Icon name="check" /> : index + 1}</span><div><strong>{t(`trace.steps.${step.key}.title`)}</strong><small>{t(`trace.steps.${step.key}.${step.complete ? "complete" : "pending"}`)}</small></div></li>)}</ol>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function Audit() {
|
||||
const { t } = useTranslation("audit");
|
||||
const { formatDateTime } = useLocaleFormat();
|
||||
@@ -95,6 +112,7 @@ export function Audit() {
|
||||
<label>{t("fromFilterLabel")}<input type="date" value={from} onChange={(e) => updateParams({ from: e.target.value, page: 1 })} /></label>
|
||||
<label>{t("toFilterLabel")}<input type="date" value={to} onChange={(e) => updateParams({ to: e.target.value, page: 1 })} /></label>
|
||||
</form>
|
||||
{correlationId && events && <OperationTrace events={events.items} t={t} />}
|
||||
{correlationId && <p className="quiet-empty" role="status">{t("relatedFilterActive", { count: events?.total ?? 0 })} <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: null, page: 1 })}>{t("clearFilter")}</button></p>}
|
||||
{error && <ErrorState message={error} />}
|
||||
{!error && !events && <LoadingState label={t("loading")} />}
|
||||
|
||||
@@ -774,7 +774,7 @@ export function DataQualityIssueDetail() {
|
||||
return (
|
||||
<div className="page">
|
||||
<Link className="back-link" to="/data-quality"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
|
||||
<PageHeader eyebrow={t("detail.eyebrow", { rule: t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") }) })} title={issue.public_ref} description={t("detail.title")} actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} label={t(`list.status${issue.status.charAt(0).toUpperCase()}${issue.status.slice(1)}`, { defaultValue: issue.status })} /></div>} />
|
||||
<PageHeader eyebrow={issue.public_ref} title={t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })} description={t("detail.title")} actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} label={t(`list.status${issue.status.charAt(0).toUpperCase()}${issue.status.slice(1)}`, { defaultValue: issue.status })} /></div>} />
|
||||
<section className="record-surface" aria-label={t("detail.summary.rule")}><dl className="detail-grid">
|
||||
<div><dt>{t("detail.summary.rule")}</dt><dd>{t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })}</dd></div>
|
||||
<div><dt>{t("detail.summary.entity")}</dt><dd>{issue.entity_type === "vehicle" ? <Link to={`/vehicles/${issue.entity_ref}`}>{issue.entity_ref}</Link> : issue.entity_ref}</dd></div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Icon, type IconName } from "../components/Icons";
|
||||
import { PageHeader } from "../components/PageChrome";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
|
||||
const HIGHLIGHTS: Array<{ id: "return" | "quality" | "knowledge"; to: string; icon: IconName }> = [
|
||||
{ id: "return", to: "/bookings/BK-DEMO-RETURN", icon: "bookings" },
|
||||
{ id: "quality", to: "/data-quality/DQ-DEMO-DUPLICATE", icon: "quality" },
|
||||
{ id: "knowledge", to: "/knowledge", icon: "knowledge" },
|
||||
];
|
||||
|
||||
export function Highlights() {
|
||||
const { t } = useTranslation("demo");
|
||||
const { openGuide } = useDemoGuide();
|
||||
|
||||
return (
|
||||
<div className="page recruiter-highlights">
|
||||
<PageHeader
|
||||
eyebrow={t("highlights.eyebrow")}
|
||||
title={t("highlights.title")}
|
||||
description={t("highlights.description", { productName: PRODUCT_NAME })}
|
||||
/>
|
||||
|
||||
<ol className="highlight-grid">
|
||||
{HIGHLIGHTS.map((highlight, index) => (
|
||||
<li key={highlight.id} className="panel highlight-card">
|
||||
<div className="highlight-card-top">
|
||||
<span className="highlight-number">0{index + 1}</span>
|
||||
<span className="highlight-duration"><Icon name="clock" /> {t("highlights.duration")}</span>
|
||||
</div>
|
||||
<span className="highlight-icon"><Icon name={highlight.icon} /></span>
|
||||
<h2>{t(`highlights.${highlight.id}.title`)}</h2>
|
||||
<p>{t(`highlights.${highlight.id}.body`)}</p>
|
||||
<div className="highlight-proof">
|
||||
<strong>{t("highlights.proofLabel")}</strong>
|
||||
<span>{t(`highlights.${highlight.id}.proof`)}</span>
|
||||
</div>
|
||||
<Link className="button button-primary" to={highlight.to}>
|
||||
{t("highlights.open")} <Icon name="chevron" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<section className="panel highlights-next">
|
||||
<div>
|
||||
<p className="page-eyebrow">{t("highlights.proofLabel")}</p>
|
||||
<h2>{t("highlights.storyTitle")}</h2>
|
||||
<p>{t("highlights.storyBody")}</p>
|
||||
</div>
|
||||
<div className="highlights-next-actions">
|
||||
<Link className="button button-primary" to="/about">{t("highlights.storyButton")} <Icon name="chevron" /></Link>
|
||||
<button type="button" className="button button-secondary" onClick={openGuide}><Icon name="spark" /> {t("highlights.fullTourButton")}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,12 @@ function isOpaqueVersion(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(value);
|
||||
}
|
||||
|
||||
function sourceDisplayTitle(title: string, t: (key: string, options?: Record<string, unknown>) => string): string {
|
||||
const base = title.replace(/\.md$/i, "");
|
||||
const fallback = base.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
||||
return t(`sourceTitles.${base}`, { defaultValue: fallback });
|
||||
}
|
||||
|
||||
export function Knowledge() {
|
||||
const { t, i18n } = useTranslation(["knowledge", "errors"]);
|
||||
const { formatDateTime } = useLocaleFormat();
|
||||
@@ -27,6 +33,7 @@ export function Knowledge() {
|
||||
const [statusSettled, setStatusSettled] = useState(false);
|
||||
const [question, setQuestion] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loadingStage, setLoadingStage] = useState(0);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
const [exchanges, setExchanges] = useState<Exchange[]>([]);
|
||||
|
||||
@@ -44,6 +51,9 @@ export function Knowledge() {
|
||||
async function ask(questionText: string) {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
setLoadingStage(0);
|
||||
const sourceTimer = window.setTimeout(() => setLoadingStage(1), 900);
|
||||
const evidenceTimer = window.setTimeout(() => setLoadingStage(2), 2600);
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", {
|
||||
@@ -61,6 +71,8 @@ export function Knowledge() {
|
||||
} catch (err) {
|
||||
setError(describeApiError(t, err, "askFailed"));
|
||||
} finally {
|
||||
window.clearTimeout(sourceTimer);
|
||||
window.clearTimeout(evidenceTimer);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
@@ -124,11 +136,11 @@ export function Knowledge() {
|
||||
<span>
|
||||
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "}
|
||||
{status.document_count === null
|
||||
? t("proceduresIndexUnknown")
|
||||
? t("proceduresAvailable", { count: status.source_document_count })
|
||||
: t("proceduresIndexed", { count: status.document_count })}
|
||||
</span>
|
||||
</div>
|
||||
<small>{status.collection}</small>
|
||||
<details className="knowledge-diagnostics"><summary>{t("technicalDetails")}</summary><small>{status.collection} · {status.detail}</small></details>
|
||||
</div>
|
||||
)}
|
||||
{statusSettled && !status && (
|
||||
@@ -181,6 +193,9 @@ export function Knowledge() {
|
||||
</button>
|
||||
</div>
|
||||
<ApiErrorNotice error={error} />
|
||||
{submitting && <div className="knowledge-progress" role="status" aria-live="polite">
|
||||
{["retrieving", "checking", "composing"].map((stage, index) => <span className={index <= loadingStage ? "is-active" : ""} key={stage}>{index < loadingStage ? <Icon name="check" /> : <i />}{t(`progress.${stage}`)}</span>)}
|
||||
</div>}
|
||||
<div className="knowledge-suggestions">
|
||||
<span>{t("suggestedLabel")}</span>
|
||||
{suggestedQuestions.map((suggestion) => (
|
||||
@@ -230,7 +245,7 @@ export function Knowledge() {
|
||||
<details open={sourceIndex === 0}>
|
||||
<summary>
|
||||
<span className="source-number">{sourceIndex + 1}</span>
|
||||
<span className="source-title">{source.title}</span>
|
||||
<span className="source-title">{sourceDisplayTitle(source.title, t)}</span>
|
||||
{source.section && <span className="source-section">{source.section}</span>}
|
||||
</summary>
|
||||
<p className="source-excerpt">{source.excerpt}</p>
|
||||
|
||||
@@ -17,11 +17,11 @@ export function Login() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
async function handleLogin(role: Role, guided = false) {
|
||||
async function handleLogin(role: Role, destination = "/dashboard") {
|
||||
setError(null);
|
||||
try {
|
||||
await loginAs(role);
|
||||
navigate(guided ? "/dashboard?guide=start" : "/dashboard");
|
||||
navigate(destination);
|
||||
} catch {
|
||||
setError(t("loginFailed"));
|
||||
}
|
||||
@@ -78,9 +78,13 @@ export function Login() {
|
||||
type="button"
|
||||
className="button button-primary login-guide-cta"
|
||||
disabled={loading}
|
||||
onClick={() => handleLogin("operations_manager", true)}
|
||||
onClick={() => handleLogin("operations_manager", "/highlights")}
|
||||
>
|
||||
<Icon name="spark" />
|
||||
{t("startRecruiterTour")}
|
||||
</button>
|
||||
|
||||
<button type="button" className="button button-secondary login-full-demo" disabled={loading} onClick={() => handleLogin("operations_manager", "/dashboard?guide=start")}>
|
||||
{t("startGuidedDemo")}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -435,6 +435,25 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
|
||||
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
|
||||
.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; }
|
||||
.route-skeleton span:first-child { width: 42%; height: 34px; }.route-skeleton span:nth-child(2) { width: 76%; }.route-skeleton span:last-child { height: 180px; }
|
||||
@keyframes skeleton-shift { to { background-position: -220% 0; } }
|
||||
|
||||
/* Recruiter-facing product story */
|
||||
.engineering-hero { display: flex; justify-content: space-between; align-items: center; gap: 28px; margin-bottom: 26px; padding: 28px; background: linear-gradient(135deg, var(--petrol), #18344a); color: white; overflow: hidden; }
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* Knowledge wait and diagnostics */
|
||||
.knowledge-diagnostics { max-width: 260px; text-align: right; }.knowledge-diagnostics summary { color: var(--teal-dark); font-size: var(--type-meta); font-weight: 700; cursor: pointer; }.knowledge-diagnostics small { display: block; margin-top: 5px; overflow-wrap: anywhere; }.knowledge-progress { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin: 12px 0; padding: 11px; background: var(--surface-subtle); border: 1px solid var(--line); }.knowledge-progress span { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--type-meta); font-weight: 700; }.knowledge-progress span.is-active { color: var(--teal-dark); }.knowledge-progress i, .knowledge-progress svg { width: 15px; height: 15px; flex: 0 0 auto; }.knowledge-progress i { border: 2px solid var(--line-strong); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
.error { color: #9f2929; font-size: .74rem; font-weight: 600; }
|
||||
.api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }
|
||||
.api-error-notice strong { display: block; font-size: .78rem; }
|
||||
@@ -540,6 +559,19 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
.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); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
.route-skeleton span, .knowledge-progress i { animation: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.page-actions { display: flex; min-height: 24px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user