diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index e031e88..8d65d2f 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -593,3 +593,41 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem server demo-ready. - Exact next action: Demo Guide (collapsible panel, 8 steps) + scenario overview (5 cards on the dashboard, consuming `/api/v1/demo/manifest`'s `scenarios` array) — task #31. + +### Batch 3 — Demo Guide + scenario overview (complete) + +- New `/scenarios` page (`Scenarios.tsx`): all 5 named scenarios as cards (title, + operational problem, duration, required role(s), "toont aan", ready/blocked status + from the manifest, "Start scenario" linking to the live `start_path`). Dashboard gets + one compact "Probeer een demonstratiescenario" panel (not 5 more cards — keeps the + existing dashboard uncluttered per the brief) showing readiness count and, for + Operations Managers, a guide resume/start control. +- New Demo Guide: `DemoGuideContext` (sessionStorage-persisted `currentIndex`/`completed` + set — browser-only, never touches auth or business logic), 8 static steps + (`data/demoGuideSteps.ts`) each with what-you'll-see/why/start-action/expected-outcome, + resolving live routes from the manifest for the two scenario-backed steps (return, + duplicate-merge) so they can't drift from actual records. `DemoGuide.tsx` renders a + fixed side panel (desktop) that becomes a bottom sheet at ≤700px via CSS only (no + layout duplication); `DemoGuideTrigger` (topbar, Operations-Manager-only — the 8 steps + require OM throughout) shows a live `completed/8` pill. "Demo opnieuw voorbereiden" + calls the real reset endpoint, resets guide progress, and returns to `/login` (mirrors + the existing sidebar reset flow). Login's "Start begeleide demo" logs in as OM and + passes a one-shot `?guide=start` marker the dashboard consumes once then strips. +- Fixed a real regression caught by the responsive-overflow tests: the new topbar guide + trigger pushed `.topbar-meta` past the viewport at ≤420px; fixed by hiding the guide + trigger (icon+pill) at that breakpoint — the Dashboard's own "Start demo-gids" control + remains reachable there. Also fixed a genuine mobile overflow in the new + `.demo-start-panel` (flex items without `min-width:0`/wrap on narrow screens). +- Fixed one fragile new test (asserted on the transient `?guide=start` URL param, which + the app intentionally strips immediately — changed to assert the guide's actual open + state instead) and two Playwright strict-mode ambiguous-match errors; confirmed the + full 41+6=47-test suite passes twice in a row after these fixes (ruling out flakiness). +- Evidence: frontend `tsc -b` clean, `npm run build` clean; full Playwright suite + **47 passed** (41 existing + 6 new `demo-guide.spec.ts`: scenario overview shows 5 + ready cards after reset, starting a scenario navigates to its fixed record, guide + step navigation/jump/close, progress persists across page navigation, guide hidden + from Rental Employee, restart-from-guide resets data and returns to login). Backend + untouched this batch (no re-run needed; last backend gate was 127 passed/ruff/mypy + clean in Batch 2). +- Exact next action: layer plain-language Dutch explanation onto the return flow, the 5 + data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33. diff --git a/frontend/e2e/demo-entry.spec.ts b/frontend/e2e/demo-entry.spec.ts index 63a97df..aedc9d1 100644 --- a/frontend/e2e/demo-entry.spec.ts +++ b/frontend/e2e/demo-entry.spec.ts @@ -12,11 +12,16 @@ test("demo entry screen names the fictional org and never shows a password", asy await expect(page.locator('input[type="password"]')).toHaveCount(0); }); -test("start guided demo logs in as Operations Manager and marks the guide to start", async ({ page }) => { +test("start guided demo logs in as Operations Manager and opens the guide at step 1", async ({ page }) => { await page.goto("/login"); await page.getByRole("button", { name: "Start begeleide demo" }).click(); - await expect(page).toHaveURL(/\/dashboard\?guide=start$/); + // The ?guide=start marker is a one-shot signal the dashboard strips immediately after + // consuming it, so assert on its effect (the guide panel opens at step 1) rather than + // the transient URL, which can already be gone by the time this assertion runs. + await expect(page).toHaveURL(/\/dashboard/); await expect(page.getByText("Amelie De Ridder")).toBeVisible(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible(); }); test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => { diff --git a/frontend/e2e/demo-guide.spec.ts b/frontend/e2e/demo-guide.spec.ts new file mode 100644 index 0000000..6482511 --- /dev/null +++ b/frontend/e2e/demo-guide.spec.ts @@ -0,0 +1,85 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +async function resetDemoData(request: APIRequestContext) { + const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + expect(login.ok()).toBeTruthy(); + const reset = await request.post("/api/v1/demo/reset"); + expect(reset.ok()).toBeTruthy(); +} + +test.describe.configure({ mode: "serial" }); + +test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => { + await resetDemoData(request); + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.goto("/scenarios"); + + await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible(); + const cards = page.locator(".scenario-card"); + await expect(cards).toHaveCount(5); + for (const label of ["Klaar voor demo"]) { + await expect(page.getByText(label).first()).toBeVisible(); + } + await expect(page.getByText("Niet beschikbaar")).toHaveCount(0); +}); + +test("starting a scenario navigates to its fixed record", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await page.goto("/scenarios"); + + const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" }); + await duplicateCard.getByRole("link", { name: "Start scenario" }).click(); + await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/); +}); + +test("demo guide: navigating steps, jumping to a step, and closing works", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible(); + + await page.getByRole("button", { name: "Volgende" }).click(); + await expect(page.getByRole("heading", { name: "2. Open een boeking die aandacht nodig heeft" })).toBeVisible(); + + await page.getByRole("button", { name: /6\. Stel een vraag/ }).click(); + await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible(); + + await page.getByRole("button", { name: "Ga naar deze stap" }).click(); + await expect(page).toHaveURL(/\/knowledge$/); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + + await page.getByRole("button", { name: "Sluiten" }).click(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden(); +}); + +test("demo guide progress persists across navigation and the trigger shows it", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await page.getByRole("button", { name: "Volgende" }).click(); + await page.getByRole("button", { name: "Volgende" }).click(); + await page.getByRole("button", { name: "Sluiten" }).click(); + + await expect(page.getByRole("button", { name: /Demo-gids/ })).toContainText("2/8"); + + await page.goto("/vehicles"); + await page.getByRole("button", { name: /Demo-gids/ }).click(); + await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible(); +}); + +test("demo guide is not shown to a rental employee", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Rental Employee" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0); +}); + +test("restarting the demo from the guide resets data and returns to login", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible(); + + await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click(); + await expect(page).toHaveURL(/\/login$/, { timeout: 10000 }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ce1051..3cefb0f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider } from "./context/AuthContext"; import { DemoManifestProvider } from "./context/DemoManifestContext"; +import { DemoGuideProvider } from "./context/DemoGuideContext"; import { Layout } from "./components/Layout"; import { RequireAuth } from "./components/RequireAuth"; import { Login } from "./pages/Login"; @@ -15,35 +16,39 @@ import { Automation } from "./pages/Automation"; import { Knowledge } from "./pages/Knowledge"; import { Audit } from "./pages/Audit"; import { AboutDemo } from "./pages/AboutDemo"; +import { Scenarios } from "./pages/Scenarios"; export function App() { return ( - - } /> - - - - } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + ); diff --git a/frontend/src/components/DemoGuide.tsx b/frontend/src/components/DemoGuide.tsx new file mode 100644 index 0000000..650d87d --- /dev/null +++ b/frontend/src/components/DemoGuide.tsx @@ -0,0 +1,134 @@ +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { api, ApiError } from "../api/client"; +import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; +import { Icon } from "./Icons"; + +export function DemoGuideTrigger() { + const { user } = useAuth(); + const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide(); + + if (user?.role !== "operations_manager") return null; + + return ( + + ); +} + +export function DemoGuide() { + const navigate = useNavigate(); + const { logout } = useAuth(); + const { manifest, refresh } = useDemoManifest(); + const { + open, + closeGuide, + currentIndex, + completed, + totalSteps, + goToStep, + completeAndAdvance, + restart, + } = useDemoGuide(); + const [resetting, setResetting] = useState(false); + const [resetError, setResetError] = useState(null); + + if (!open) return null; + + const step = DEMO_GUIDE_STEPS[currentIndex]; + const isLastStep = currentIndex === totalSteps - 1; + + function goToStepRoute() { + navigate(step.route(manifest)); + } + + async function handleRestartDemo() { + setResetError(null); + setResetting(true); + try { + await api.post("/api/v1/demo/reset"); + restart(); + refresh(); + closeGuide(); + await logout(); + navigate("/login"); + } catch (err) { + setResetError(err instanceof ApiError ? err.message : "De demo kon niet hersteld worden."); + } finally { + setResetting(false); + } + } + + return ( + + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ffad620..0a833ef 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -5,6 +5,7 @@ import { useAuth } from "../context/AuthContext"; import type { Role, SearchResultItem } from "../api/types"; import { BrandMark, Icon, type IconName } from "./Icons"; import { DemoBadge } from "./DemoBadge"; +import { DemoGuide, DemoGuideTrigger } from "./DemoGuide"; import { useDemoManifest } from "../context/DemoManifestContext"; const SEARCH_ICON: Record = { @@ -277,6 +278,7 @@ export function Layout() { )}
+ Europe/Brussels {user && ( @@ -307,6 +309,8 @@ export function Layout() { More + +
); } diff --git a/frontend/src/context/DemoGuideContext.tsx b/frontend/src/context/DemoGuideContext.tsx new file mode 100644 index 0000000..f2742b6 --- /dev/null +++ b/frontend/src/context/DemoGuideContext.tsx @@ -0,0 +1,106 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; +import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps"; + +const STORAGE_KEY = "mobilityops.demo-guide-progress"; + +interface StoredProgress { + currentIndex: number; + completed: string[]; +} + +function readProgress(): StoredProgress { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return { currentIndex: 0, completed: [] }; + const parsed = JSON.parse(raw) as StoredProgress; + return { currentIndex: parsed.currentIndex ?? 0, completed: parsed.completed ?? [] }; + } catch { + return { currentIndex: 0, completed: [] }; + } +} + +function writeProgress(progress: StoredProgress) { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress)); +} + +interface DemoGuideState { + open: boolean; + currentIndex: number; + completed: Set; + totalSteps: number; + openGuide: () => void; + closeGuide: () => void; + toggleGuide: () => void; + goToStep: (index: number) => void; + completeAndAdvance: () => void; + restart: () => void; +} + +const DemoGuideContext = createContext(undefined); + +export function DemoGuideProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); + const [completed, setCompleted] = useState>(new Set()); + + useEffect(() => { + const stored = readProgress(); + setCurrentIndex(stored.currentIndex); + setCompleted(new Set(stored.completed)); + }, []); + + const persist = useCallback((index: number, done: Set) => { + writeProgress({ currentIndex: index, completed: Array.from(done) }); + }, []); + + const goToStep = useCallback( + (index: number) => { + const clamped = Math.max(0, Math.min(index, DEMO_GUIDE_STEPS.length - 1)); + setCurrentIndex(clamped); + persist(clamped, completed); + }, + [completed, persist], + ); + + const completeAndAdvance = useCallback(() => { + setCompleted((prev) => { + const next = new Set(prev); + next.add(DEMO_GUIDE_STEPS[currentIndex].id); + const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1); + persist(nextIndex, next); + setCurrentIndex(nextIndex); + return next; + }); + }, [currentIndex, persist]); + + const restart = useCallback(() => { + setCurrentIndex(0); + setCompleted(new Set()); + writeProgress({ currentIndex: 0, completed: [] }); + }, []); + + return ( + setOpen(true), + closeGuide: () => setOpen(false), + toggleGuide: () => setOpen((v) => !v), + goToStep, + completeAndAdvance, + restart, + }} + > + {children} + + ); +} + +export function useDemoGuide(): DemoGuideState { + const ctx = useContext(DemoGuideContext); + if (!ctx) throw new Error("useDemoGuide must be used within DemoGuideProvider"); + return ctx; +} diff --git a/frontend/src/data/demoGuideSteps.ts b/frontend/src/data/demoGuideSteps.ts new file mode 100644 index 0000000..22f7f45 --- /dev/null +++ b/frontend/src/data/demoGuideSteps.ts @@ -0,0 +1,91 @@ +import type { DemoManifest } from "../api/types"; + +export interface DemoGuideStep { + id: string; + title: string; + whatYouWillSee: string; + whyItMatters: string; + startAction: string; + expectedOutcome: string; + /** Resolves the route to link to, using live manifest data where a scenario supplies + * a concrete record (booking/issue ref) instead of hardcoding one that could drift. */ + route: (manifest: DemoManifest | null) => string; +} + +export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [ + { + id: "understand-state", + title: "1. Begrijp de operationele status", + whatYouWillSee: "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.", + whyItMatters: "Een Operations Manager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.", + startAction: "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.", + expectedOutcome: "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen.", + route: () => "/dashboard", + }, + { + id: "open-booking", + title: "2. Open een boeking die aandacht nodig heeft", + whatYouWillSee: "De boeking BK-DEMO-RETURN, actief en klaar voor retour vandaag.", + whyItMatters: "Retours zijn het moment waarop foute kilometerstanden of schade voor het eerst zichtbaar worden.", + startAction: "Open de boeking vanuit het dashboard of de boekingenlijst.", + expectedOutcome: "Je ziet de boekingsdetails en de knop om de retour te verwerken.", + route: (manifest) => + manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings", + }, + { + id: "process-return", + title: "3. Verwerk een retour met een afwijkende kilometerstand", + whatYouWillSee: "Een retourformulier met een vooringevulde, verdachte kilometerstand lager dan de laatst gekende stand.", + whyItMatters: "Een dalende kilometerstand wijst op een foutieve invoer of een verwisseld voertuig — dit moet vóór vrijgave worden opgemerkt.", + startAction: "Vul de retour in met de voorgestelde waarde en bekijk de serverpreview vóór je bevestigt.", + expectedOutcome: "De retour wordt verwerkt, het voertuig krijgt een passende status en er wordt automatisch een datakwaliteitsprobleem aangemaakt.", + route: (manifest) => + manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings", + }, + { + id: "handle-quality-issue", + title: "4. Bekijk en behandel het gecreëerde datakwaliteitsprobleem", + whatYouWillSee: "Een nieuw issue van het type 'kilometerstand-afwijking' bovenaan de werklijst.", + whyItMatters: "Elk gedetecteerd probleem heeft één afgebakende oplossingsstap — niets wordt automatisch stilzwijgend gecorrigeerd.", + startAction: "Open het datakwaliteitsoverzicht en kies het nieuwste issue.", + expectedOutcome: "Je ziet de aanbevolen actie, kiest een oplossing en het issue wordt opgelost met een audit-spoor.", + route: () => "/data-quality", + }, + { + id: "merge-duplicate", + title: "5. Beoordeel en behandel een mogelijke dubbele klant", + whatYouWillSee: "Twee klantprofielen met hetzelfde e-mailadres en telefoonnummer, naast elkaar vergeleken.", + whyItMatters: "Dubbele klanten leiden tot verspreide boekingsgeschiedenis en verwarrende communicatie.", + startAction: "Vergelijk beide profielen en kies welk profiel behouden blijft.", + expectedOutcome: "De profielen worden samengevoegd, boekingen worden herverbonden en het verliezende profiel wordt een tombstone.", + route: (manifest) => + manifest?.scenarios.find((s) => s.id === "duplicate-customer")?.start_path ?? "/data-quality", + }, + { + id: "ask-knowledge", + title: "6. Stel een vraag aan de procedureassistent", + whatYouWillSee: "Een antwoord met bronvermelding uit de afgebakende demokennisbank.", + whyItMatters: "Medewerkers moeten snel een onderbouwd antwoord krijgen over procedures, zonder te gokken.", + startAction: 'Stel de voorbeeldvraag "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?".', + expectedOutcome: "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is.", + route: () => "/knowledge", + }, + { + id: "check-automation-audit", + title: "7. Controleer automatisering en audit trail", + whatYouWillSee: "De status van de n8n-aflevering voor je retour, en de bijhorende audit-gebeurtenissen.", + whyItMatters: "Elke belangrijke actie moet naspeurbaar zijn: wie deed wat, wanneer, en wat was het gevolg.", + startAction: "Open Systemen om de afleverstatus te zien, en Audit trail voor het volledige spoor.", + expectedOutcome: "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties.", + route: () => "/automation", + }, + { + id: "review-real-vs-simulated", + title: "8. Bekijk wat echt is, gesimuleerd is, of nog niet gekoppeld", + whatYouWillSee: "Een overzicht van wat in deze demo functioneel geïmplementeerd is, wat synthetisch is, en welke koppelingen nog niet live zijn.", + whyItMatters: "Een demo is pas overtuigend als bezoekers zelf kunnen nagaan wat echt werkt en wat nog toekomstmuziek is.", + startAction: "Lees de pagina 'Over deze demo'.", + expectedOutcome: "Je kan zelf uitleggen wat MobilityOps wel en niet is, zonder mondelinge toelichting.", + route: () => "/about", + }, +]; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 54751d7..41f61d3 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { api } from "../api/client"; import type { Dashboard as DashboardData, IntegrationStatus, KnowledgeHealth } from "../api/types"; import { useAuth } from "../context/AuthContext"; +import { useDemoGuide } from "../context/DemoGuideContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; import { SeverityBadge, StatusBadge } from "../components/Badge"; import { Icon } from "../components/Icons"; import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; @@ -26,8 +28,21 @@ function localTime(value: string, withDate = false) { export function Dashboard() { const { user } = useAuth(); + const { manifest } = useDemoManifest(); + const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide(); + const [searchParams, setSearchParams] = useSearchParams(); const canSeeQuality = user?.role === "operations_manager"; const canSeeAutomation = user?.role === "operations_manager"; + + useEffect(() => { + if (searchParams.get("guide") === "start") { + restart(); + openGuide(); + setSearchParams({}, { replace: true }); + } + // Only ever react to the initial `?guide=start` marker set by the login screen's + // "Start begeleide demo" CTA, so this intentionally runs once on mount. + }, []); const [data, setData] = useState(null); const [knowledge, setKnowledge] = useState(null); const [integrationStatus, setIntegrationStatus] = useState(null); @@ -64,6 +79,26 @@ export function Dashboard() {
View fleet} /> +
+
+ +
+ Probeer een demonstratiescenario + + {manifest ? `${manifest.scenarios.filter((s) => s.ready).length} van ${manifest.scenarios.length} scenario's klaar voor demo.` : "Vijf afgebakende scenario's."} + +
+
+
+ {user?.role === "operations_manager" && ( + + )} + Bekijk scenario's +
+
+
diff --git a/frontend/src/pages/Scenarios.tsx b/frontend/src/pages/Scenarios.tsx new file mode 100644 index 0000000..d930cbb --- /dev/null +++ b/frontend/src/pages/Scenarios.tsx @@ -0,0 +1,67 @@ +import { Link } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { Icon } from "../components/Icons"; +import { LoadingState, PageHeader } from "../components/PageChrome"; + +const ROLE_LABEL: Record = { + operations_manager: "Operations Manager", + rental_employee: "Rental Employee", +}; + +export function Scenarios() { + const { manifest, loading } = useDemoManifest(); + const { user } = useAuth(); + + return ( +
+ + + {loading && } + + {manifest && ( +
+ {manifest.scenarios.map((scenario) => { + const canRun = !user || scenario.required_roles.includes(user.role); + return ( +
+
+

{scenario.title}

+ + {scenario.ready ? "Klaar voor demo" : "Niet beschikbaar"} + +
+

{scenario.operational_problem}

+
+
Duur
± {scenario.estimated_minutes} min
+
Rol
{scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}
+
+

Toont aan: {scenario.demonstrates}

+ {!scenario.ready && scenario.blocked_reason && ( +

{scenario.blocked_reason}

+ )} + {!canRun && ( +

Vereist rol: {scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}.

+ )} + { + if (!scenario.ready || !canRun) event.preventDefault(); + }} + > + Start scenario + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index c313c7d..b90b5a2 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -257,6 +257,21 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 170px; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }.integration-cards h2 { margin: 3px 0 7px; font-size: .95rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: .7rem; line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; } +.scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } +.scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } +.scenario-card header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.scenario-card h2 { margin: 0; font-size: 1rem; letter-spacing: -.015em; } +.scenario-problem, .scenario-demonstrates { margin: 0; color: var(--muted); font-size: .78rem; line-height: 1.55; } +.scenario-demonstrates strong { color: var(--ink-soft); } +.scenario-meta { display: flex; gap: 20px; margin: 0; } +.scenario-meta div { display: grid; gap: 2px; } +.scenario-meta dt { color: var(--muted); font-size: .58rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; } +.scenario-meta dd { margin: 0; color: var(--ink-soft); font-size: .76rem; font-weight: 600; } +.scenario-blocked { display: flex; align-items: flex-start; gap: 6px; margin: 0; padding: 8px 10px; color: #8a5a12; background: #fdf3e2; border: 1px solid #f2ddb0; border-radius: 8px; font-size: .68rem; line-height: 1.45; } +.scenario-blocked svg { width: 14px; flex-shrink: 0; margin-top: 1px; } +.scenario-card > .button { align-self: flex-start; margin-top: auto; } +.scenario-card > .button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; } + .knowledge-status { min-height: 58px; display: flex; align-items: center; gap: 11px; margin-bottom: 14px; padding: 10px 16px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.knowledge-status div { flex: 1; display: grid; gap: 3px; }.knowledge-status strong { font-size: .74rem; }.knowledge-status span:not(.health-orb), .knowledge-status small { color: var(--muted); font-size: .65rem; }.health-orb { width: 9px; height: 9px; border-radius: 50%; }.health-orb.is-healthy { background: var(--success); box-shadow: 0 0 0 4px var(--success-pale); }.health-orb.is-down { background: var(--critical); box-shadow: 0 0 0 4px var(--critical-pale); } .knowledge-form { margin-bottom: 18px; }.ask-heading { display: flex; gap: 11px; align-items: center; margin-bottom: 15px; }.ask-heading > span { width: 36px; height: 36px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.ask-heading svg { width: 18px; }.ask-heading h2 { margin: 0; font-size: .95rem; }.ask-heading p { margin: 3px 0 0; color: var(--muted); font-size: .65rem; } .knowledge-input-row { display: flex; gap: 8px; }.knowledge-input-row input { min-width: 0; flex: 1; }.knowledge-empty { min-height: 290px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 30px; color: var(--muted); background: white; border: 1px dashed var(--line-strong); border-radius: var(--radius); }.knowledge-empty > span { width: 50px; height: 50px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.knowledge-empty svg { width: 22px; }.knowledge-empty h2 { margin: 15px 0 7px; color: var(--ink); font-size: 1rem; }.knowledge-empty p { max-width: 540px; margin: 0; font-size: .75rem; line-height: 1.5; } @@ -278,17 +293,50 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .app-shell { grid-template-columns: 190px minmax(0, 1fr); }.sidebar { width: 190px; }.brand-lockup { padding-inline: 14px; }.readiness-band { grid-template-columns: 160px 1fr; }.readiness-band .inline-action { display: none; }.operations-grid { grid-template-columns: 1fr 1fr; }.timezone { display: none; }.review-facts { grid-template-columns: repeat(3, 1fr); } } +.demo-start-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 22px; padding: 16px 20px; background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: var(--radius); } +.demo-start-panel > div:first-child { display: flex; align-items: center; gap: 12px; } +.demo-start-panel > div:first-child > svg { width: 22px; height: 22px; color: var(--teal-dark); flex-shrink: 0; } +.demo-start-panel strong { display: block; color: var(--ink); font-size: .85rem; } +.demo-start-panel span { display: block; margin-top: 2px; color: var(--muted); font-size: .72rem; } +.demo-start-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +.demo-guide-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: .68rem; font-weight: 700; cursor: pointer; } +.demo-guide-trigger:hover { background: #d9f0ea; } +.demo-guide-trigger svg { width: 13px; height: 13px; } +.demo-guide-progress-pill { padding: 1px 6px; color: var(--teal-dark); background: white; border-radius: 999px; font-size: .6rem; } + +.demo-guide-panel { position: fixed; z-index: 40; top: 0; right: 0; width: min(400px, 92vw); height: 100vh; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: var(--surface); border-left: 1px solid var(--line); box-shadow: var(--shadow-float); overflow-y: auto; } +.demo-guide-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.demo-guide-kicker { margin: 0 0 4px; color: var(--teal-dark); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } +.demo-guide-header h2 { margin: 0; font-size: 1.05rem; letter-spacing: -.015em; } +.demo-guide-progress-bar { display: flex; gap: 4px; } +.demo-guide-progress-bar span { flex: 1; height: 4px; background: var(--line); border-radius: 2px; } +.demo-guide-progress-bar span.is-done { background: var(--teal); } +.demo-guide-progress-bar span.is-current { background: var(--teal-dark); } +.demo-guide-body { display: grid; gap: 12px; } +.demo-guide-body p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; } +.demo-guide-body p strong { color: var(--muted); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } +.demo-guide-steps { display: grid; gap: 3px; padding-top: 12px; border-top: 1px solid var(--line); } +.demo-guide-steps button { display: flex; align-items: center; gap: 7px; padding: 7px 8px; text-align: left; color: var(--muted); background: transparent; border: 0; border-radius: var(--radius); font-size: .68rem; cursor: pointer; } +.demo-guide-steps button svg { width: 12px; color: var(--teal); flex-shrink: 0; } +.demo-guide-steps button:hover { background: var(--surface-subtle); } +.demo-guide-steps button.is-current { color: var(--ink); background: var(--teal-pale); font-weight: 700; } +.demo-guide-footer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--line); } +.demo-guide-restart { width: 100%; padding: 8px; color: var(--muted); background: transparent; border: 1px dashed var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 600; cursor: pointer; } +.demo-guide-restart:hover { color: var(--ink-soft); border-color: var(--muted-light); } +.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; } + @media (max-width: 960px) { .app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 65px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .55rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 65px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; } } @media (max-width: 700px) { - #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; } + #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; } .filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; } .table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 34px; height: auto; display: grid; grid-template-columns: minmax(90px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 7px 12px; border: 0; text-align: right; font-size: .71rem; }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: .57rem; font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; } .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: 420px) { - .global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; } + .global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; } }