From ac427f44270aa5458e04a951b67b453111e7ca07 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:45:55 +0200 Subject: [PATCH] feat(demo): add demo manifest, Dutch demo entry, permanent badge and About page Adds GET /api/v1/demo/manifest as a single source of truth for the demo's fictional org identity (Northstar Mobility -- surfacing the project's already-locked tenant name), synthetic-data/reset state, and live scenario readiness. Rewrites the login screen in Dutch with an honest, no-password demo entry and a guided-demo entry point, replaces the loud full-width demo banner with a subtle badge + popover, and adds a compact About page explaining what's real vs. synthetic vs. not yet connected. --- .env.example | 7 + PROJECT_STATE.md | 37 +++ backend/app/api/routers/demo.py | 18 +- backend/app/api/routers/integration_status.py | 51 +--- backend/app/core/config.py | 3 + backend/app/schemas.py | 34 +++ backend/app/services/demo_manifest.py | 241 ++++++++++++++++++ backend/app/services/integration_status.py | 55 ++++ backend/tests/test_auth.py | 15 ++ backend/tests/test_demo_manifest.py | 55 ++++ compose.yaml | 3 + docs/05-api-contract.md | 9 +- frontend/e2e/_capture-screenshots.spec.ts | 2 +- frontend/e2e/demo-entry.spec.ts | 56 ++++ frontend/e2e/demo.spec.ts | 6 +- frontend/e2e/interactive-elements.spec.ts | 8 +- frontend/e2e/ui-redesign.spec.ts | 2 +- frontend/src/App.tsx | 55 ++-- frontend/src/api/types.ts | 34 +++ frontend/src/components/DemoBadge.tsx | 81 ++++++ frontend/src/components/Layout.tsx | 7 +- frontend/src/context/DemoManifestContext.tsx | 52 ++++ frontend/src/pages/AboutDemo.tsx | 124 +++++++++ frontend/src/pages/Login.tsx | 52 ++-- frontend/src/styles.css | 20 +- 25 files changed, 919 insertions(+), 108 deletions(-) create mode 100644 backend/app/services/demo_manifest.py create mode 100644 backend/app/services/integration_status.py create mode 100644 backend/tests/test_demo_manifest.py create mode 100644 frontend/e2e/demo-entry.spec.ts create mode 100644 frontend/src/components/DemoBadge.tsx create mode 100644 frontend/src/context/DemoManifestContext.tsx create mode 100644 frontend/src/pages/AboutDemo.tsx diff --git a/.env.example b/.env.example index 9c0a478..bb2d926 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,13 @@ TZ=Europe/Brussels # otherwise browsers will silently drop the cookie and no one can log in. SESSION_COOKIE_SECURE=false +# Demo presentation (fictional org identity, badge/manifest, reset safety valve). +# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent +# of role -- a safety valve for any environment where the dataset must not be rebuildable. +DEMO_ORGANIZATION_NAME=Northstar Mobility +DEMO_TIMEZONE=Europe/Brussels +DEMO_ALLOW_RESET=true + # n8n N8N_BASE_URL=http://n8n:5678 N8N_WEBHOOK_URL=http://n8n:5678/webhook/mobilityops-return diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 5f512e3..aa9e1a0 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -548,3 +548,40 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem `http://192.168.10.150:1236/` returns 200. - Exact next action: `GET /api/v1/demo/manifest` + Dutch demo entry screen + permanent demo badge (task #30), then the Demo Guide + scenario overview (task #31). + +### Batch 2 — demo manifest, Dutch demo entry, permanent demo badge, About page (complete) + +- `GET /api/v1/demo/manifest` (unauthenticated): single source of truth for demo org + identity, synthetic-data flag, reset allowance/timestamp/anchor date, guide + availability, and the 5 named scenarios with **live** readiness (queries the actual + `BK-DEMO-RETURN`/`DQ-DEMO-DUPLICATE`/`DQ-DEMO-OVERLAP`/seeded-failed-event/knowledge- + provider records — not hardcoded), plus plain-language integration summaries. Backed by + new `backend/app/services/demo_manifest.py`. Refactored the n8n status derivation out of + `integration_status.py` into a shared `services/integration_status.py` so the manifest + and the existing authenticated `/integrations/status` endpoint reuse one implementation. +- New settings (`backend/app/core/config.py`, wired through `compose.yaml`/`.env.example`): + `DEMO_ORGANIZATION_NAME` (default "Northstar Mobility" — surfaces the project's already- + locked fictitious tenant, previously only used internally as the `ragcore_tenant` slug), + `DEMO_TIMEZONE`, `DEMO_ALLOW_RESET` (a safety valve — `false` makes `POST + /api/v1/demo/reset` return 403 regardless of role; the now-dead `demo_today` setting + removed in Batch 1 stays removed). +- Rewrote `Login.tsx` in Dutch: names the fictional org, one-sentence explanation sourced + from the manifest, no password shown/copyable anywhere, "Start begeleide demo" primary + CTA (logs in as Operations Manager, navigates to `/dashboard?guide=start` for task #31 to + consume) plus "Verken als Operations Manager"/"Verken als Rental Employee" secondary + actions. Added a permanent demo badge (topbar pill + popover: synthetic notice, + "workflows are real" reassurance, last-reset timestamp, link to `/about`) replacing the + old full-width static `.demo-banner` bar — subtle by design per the brief, not a warning + bar. New `/about` + page (`AboutDemo.tsx`) covering the fictional problem, what's really implemented, what's + synthetic, honest per-integration labels (via the manifest), and a reset pointer — + reachable from the badge popover, not added to primary nav (preserves the existing Control + Rail nav per the "not a redesign" constraint). Frontend nav/design otherwise untouched. +- Evidence: `pytest` **127 passed**, `ruff check .` clean, `mypy app` clean (48 files); + frontend `tsc -b` clean, `npm run build` clean; full Playwright suite **41 passed** + (37 existing + 4 new `demo-entry.spec.ts` covering entry copy/no-password, guided-demo + login redirect, badge popover content + About link, and Escape/outside-click close). + Updated stale English login-button aria-labels and login-copy assertions across the + existing specs to match the new Dutch copy. +- 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. diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py index 0ba66f7..97c5a78 100644 --- a/backend/app/api/routers/demo.py +++ b/backend/app/api/routers/demo.py @@ -3,7 +3,7 @@ from __future__ import annotations import time import uuid -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy import select from sqlalchemy.orm import Session @@ -11,14 +11,23 @@ from app.api.deps import get_current_user, get_db, require_operations_manager from app.core.config import get_settings from app.core.security import SessionPayload, create_session_token, read_session_token from app.models.user import User -from app.schemas import CurrentUser, DemoLoginRequest +from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut from app.seed_loader import reset_and_seed from app.services.audit import record_audit_event +from app.services.demo_manifest import build_demo_manifest router = APIRouter(prefix="/api/v1/demo", tags=["demo"]) settings = get_settings() +@router.get("/manifest", response_model=DemoManifestOut) +def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut: + # Deliberately unauthenticated: the demo-entry screen and the permanent demo badge + # both need this before any session exists. Nothing here is sensitive — it's the same + # honest "what is this demo" summary a logged-in user would see. + return build_demo_manifest(db) + + @router.post("/login", response_model=CurrentUser) def demo_login( body: DemoLoginRequest, response: Response, db: Session = Depends(get_db) @@ -92,6 +101,11 @@ def demo_reset( db: Session = Depends(get_db), user: CurrentUser = Depends(require_operations_manager), ) -> dict: + if not settings.demo_allow_reset: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Demo reset is disabled on this deployment.", + ) result = reset_and_seed(db) record_audit_event( db, diff --git a/backend/app/api/routers/integration_status.py b/backend/app/api/routers/integration_status.py index b337709..c1f08f8 100644 --- a/backend/app/api/routers/integration_status.py +++ b/backend/app/api/routers/integration_status.py @@ -1,75 +1,28 @@ from __future__ import annotations -from typing import Literal - from fastapi import APIRouter, Depends -from sqlalchemy import func, select from sqlalchemy.orm import Session from app.api.deps import get_db, require_operations_manager from app.core.config import get_settings -from app.models.outbox import OutboxEvent from app.schemas import ( CurrentUser, IntegrationStatusOut, McpHubIntegrationStatus, - N8nIntegrationStatus, ) +from app.services.integration_status import derive_n8n_status router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"]) settings = get_settings() -def _n8n_status(db: Session) -> N8nIntegrationStatus: - counts: dict[str, int] = dict( - db.execute( - select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status) - ).all() # type: ignore[arg-type] - ) - pending = counts.get("pending", 0) - delivering = counts.get("delivering", 0) - failed = counts.get("failed", 0) - succeeded = counts.get("succeeded", 0) - - latest_success_at = db.scalar( - select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded") - ) - latest_failure_at = db.scalar( - select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed") - ) - - state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] - if not settings.n8n_dispatch_enabled: - state = "disabled" - elif failed > 0 and succeeded == 0: - state = "unavailable" - elif failed > 0: - state = "degraded" - elif succeeded > 0 or pending > 0 or delivering > 0: - state = "operational" - else: - state = "no_evidence" - - return N8nIntegrationStatus( - configured=bool(settings.n8n_webhook_url), - dispatch_enabled=settings.n8n_dispatch_enabled, - state=state, - pending=pending, - delivering=delivering, - failed=failed, - succeeded=succeeded, - latest_success_at=latest_success_at, - latest_failure_at=latest_failure_at, - ) - - @router.get("/status", response_model=IntegrationStatusOut) def integration_status( db: Session = Depends(get_db), _user: CurrentUser = Depends(require_operations_manager), ) -> IntegrationStatusOut: return IntegrationStatusOut( - n8n=_n8n_status(db), + n8n=derive_n8n_status(db), mcp_hub=McpHubIntegrationStatus( registration_enabled=settings.mcp_hub_registration_enabled, state="configured" if settings.mcp_hub_registration_enabled else "not_configured", diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8f7a49d..62cb07a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -32,6 +32,9 @@ class Settings(BaseSettings): mcp_hub_service_token: str = "replace-me-mcp-hub-token" mcp_hub_registration_enabled: bool = False cors_allow_origins: str = "http://localhost:1228" + demo_organization_name: str = "Northstar Mobility" + demo_timezone: str = "Europe/Brussels" + demo_allow_reset: bool = True @lru_cache diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ad3836b..0bbe2a9 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -197,6 +197,40 @@ class IntegrationStatusOut(BaseModel): mcp_hub: McpHubIntegrationStatus +class DemoScenarioOut(BaseModel): + id: str + title: str + operational_problem: str + estimated_minutes: int + required_roles: list[Role] + start_path: str + demonstrates: str + ready: bool + blocked_reason: str | None = None + + +class DemoIntegrationSummaryOut(BaseModel): + key: Literal["n8n", "ragcore", "mcp_hub"] + label: str + status_label: str + detail: str + + +class DemoManifestOut(BaseModel): + demo_mode: bool + organization_name: str + organization_description: str + timezone: str + synthetic_data: bool + allow_reset: bool + last_reset_at: datetime | None + anchor_date: str | None + guide_available: bool + required_roles: list[Role] + scenarios: list[DemoScenarioOut] + integrations: list[DemoIntegrationSummaryOut] + + class VehicleDetailOut(VehicleOut): bookings: list[BookingSummaryOut] = Field(default_factory=list) inspections: list[InspectionOut] = Field(default_factory=list) diff --git a/backend/app/services/demo_manifest.py b/backend/app/services/demo_manifest.py new file mode 100644 index 0000000..f9b38b2 --- /dev/null +++ b/backend/app/services/demo_manifest.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.models.audit import AuditEvent +from app.models.booking import Booking +from app.models.data_quality import DataQualityIssue +from app.models.outbox import OutboxEvent +from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut +from app.services.integration_status import derive_n8n_status +from app.services.knowledge import get_knowledge_provider + +settings = get_settings() + +# The default name matches the project's locked fictitious tenant (see PROJECT_STATE.md +# "Locked decisions"; the same slug already backs `ragcore_tenant`) — this surfaces that +# existing decision in the UI rather than inventing a new one. Configurable via +# DEMO_ORGANIZATION_NAME so a redeployment can rebrand the fictional org without a code change. +ORGANIZATION_DESCRIPTION = ( + "MobilityOps brengt voertuig-, boekings- en operationele gegevens samen, " + "ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en " + "automatiseert gecontroleerde vervolgstappen." +) + +_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020" + +_N8N_STATE_LABELS = { + "disabled": "Niet gekoppeld", + "unavailable": "Verwerking mislukt", + "degraded": "Opnieuw proberen mogelijk", + "operational": "Operationeel", + "no_evidence": "Voorbereid", +} + + +def _last_reset(db: Session) -> tuple[datetime | None, str | None]: + marker = db.scalar( + select(AuditEvent) + .where(AuditEvent.action == "demo_data_seeded") + .order_by(AuditEvent.occurred_at.desc()) + ) + if marker is None: + return None, None + metadata = marker.metadata_json or {} + return marker.occurred_at, metadata.get("anchor_date") + + +def _scenarios(db: Session) -> list[DemoScenarioOut]: + booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN")) + duplicate_issue = db.scalar( + select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE") + ) + overlap_issue = db.scalar( + select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP") + ) + failed_run = db.scalar( + select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID) + ) + knowledge_health = get_knowledge_provider().health() + reset_hint = "Reset de demo-data om dit scenario opnieuw beschikbaar te maken." + + return [ + DemoScenarioOut( + id="return-anomaly", + title="Retour met afwijkende kilometerstand", + operational_problem=( + "Een voertuig komt terug met een kilometerstand die lager ligt dan de " + "laatst geregistreerde stand — een teken van een foutieve invoer of een " + "verwisseld voertuig." + ), + estimated_minutes=3, + required_roles=["rental_employee", "operations_manager"], + start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings", + demonstrates=( + "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de " + "audit trail die daaruit ontstaat." + ), + ready=bool( + booking and booking.status == "active" and booking.end_odometer_km is None + ), + blocked_reason=( + None + if booking and booking.status == "active" and booking.end_odometer_km is None + else ( + f"Demoboeking BK-DEMO-RETURN niet gevonden. {reset_hint}" + if booking is None + else f"Deze boeking is al verwerkt sinds de laatste reset. {reset_hint}" + ) + ), + ), + DemoScenarioOut( + id="duplicate-customer", + title="Mogelijke dubbele klant samenvoegen", + operational_problem=( + "Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — " + "waarschijnlijk dezelfde persoon, twee keer geregistreerd." + ), + estimated_minutes=3, + required_roles=["operations_manager"], + start_path=( + f"/data-quality/{duplicate_issue.public_ref}" + if duplicate_issue + else "/data-quality" + ), + demonstrates=( + "Samenvoegen van klanten met behoud van boekingsgeschiedenis en audit trail." + ), + ready=bool(duplicate_issue and duplicate_issue.status == "open"), + blocked_reason=( + None + if duplicate_issue and duplicate_issue.status == "open" + else ( + f"Demo-issue DQ-DEMO-DUPLICATE niet gevonden. {reset_hint}" + if duplicate_issue is None + else f"Dit issue is al opgelost sinds de laatste reset. {reset_hint}" + ) + ), + ), + DemoScenarioOut( + id="booking-overlap", + title="Overlappende boekingen herstellen", + operational_problem=( + "Eén voertuig staat dubbel gereserveerd voor overlappende periodes — een " + "planningsfout die vóór vertrek moet worden opgelost." + ), + estimated_minutes=2, + required_roles=["operations_manager"], + start_path=( + f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality" + ), + demonstrates="Detectie en gecontroleerde oplossing van planningsconflicten.", + ready=bool(overlap_issue and overlap_issue.status == "open"), + blocked_reason=( + None + if overlap_issue and overlap_issue.status == "open" + else ( + f"Demo-issue DQ-DEMO-OVERLAP niet gevonden. {reset_hint}" + if overlap_issue is None + else f"Dit issue is al opgelost sinds de laatste reset. {reset_hint}" + ) + ), + ), + DemoScenarioOut( + id="automation-retry", + title="Mislukte automatisering opnieuw proberen", + operational_problem=( + "Eén eerdere gebeurtenis kon niet worden afgeleverd aan de automatisering " + "door een gesimuleerde verbindingsfout." + ), + estimated_minutes=2, + required_roles=["operations_manager"], + start_path="/automation", + demonstrates=( + "Betrouwbare aflevering met begrensde herpogingen en zichtbare foutstatus." + ), + ready=bool(failed_run and failed_run.delivery_status == "failed"), + blocked_reason=( + None + if failed_run and failed_run.delivery_status == "failed" + else ( + f"Gesimuleerde mislukte gebeurtenis niet gevonden. {reset_hint}" + if failed_run is None + else f"Deze gebeurtenis is al hersteld sinds de laatste reset. {reset_hint}" + ) + ), + ), + DemoScenarioOut( + id="knowledge-question", + title="Een procedurevraag stellen", + operational_problem=( + "Een medewerker weet niet zeker welke procedure van toepassing is bij een " + "specifieke operationele situatie." + ), + estimated_minutes=2, + required_roles=["rental_employee", "operations_manager"], + start_path="/knowledge", + demonstrates=( + "Antwoorden met brongebaseerde onderbouwing uit een afgebakende demokennisbank." + ), + ready=knowledge_health.available, + blocked_reason=( + None + if knowledge_health.available + else "De demokennisbank is momenteel niet beschikbaar." + ), + ), + ] + + +def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: + n8n = derive_n8n_status(db) + knowledge_health = get_knowledge_provider().health() + + return [ + DemoIntegrationSummaryOut( + key="n8n", + label="Automatisering (n8n)", + status_label=_N8N_STATE_LABELS.get(n8n.state, n8n.state), + detail=f"{n8n.succeeded} geslaagd, {n8n.failed} mislukt, {n8n.pending} in wachtrij.", + ), + DemoIntegrationSummaryOut( + key="ragcore", + label="Kennisassistent (RAGcore)", + status_label=( + "Demomodus — lokale kennisprovider" + if knowledge_health.provider != "ragcore" + else "Operationeel" + ), + detail=knowledge_health.detail, + ), + DemoIntegrationSummaryOut( + key="mcp_hub", + label="ITWorx MCP Hub", + status_label=( + "Operationeel" if settings.mcp_hub_registration_enabled else "Niet gekoppeld" + ), + detail="Voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.", + ), + ] + + +def build_demo_manifest(db: Session) -> DemoManifestOut: + last_reset_at, anchor_date = _last_reset(db) + return DemoManifestOut( + demo_mode=settings.mobilityops_demo_mode, + organization_name=settings.demo_organization_name, + organization_description=ORGANIZATION_DESCRIPTION, + timezone=settings.demo_timezone, + synthetic_data=True, + allow_reset=settings.demo_allow_reset, + last_reset_at=last_reset_at, + anchor_date=anchor_date, + guide_available=True, + required_roles=["operations_manager", "rental_employee"], + scenarios=_scenarios(db), + integrations=_integrations(db), + ) diff --git a/backend/app/services/integration_status.py b/backend/app/services/integration_status.py new file mode 100644 index 0000000..d647272 --- /dev/null +++ b/backend/app/services/integration_status.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Literal + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.models.outbox import OutboxEvent +from app.schemas import N8nIntegrationStatus + +settings = get_settings() + + +def derive_n8n_status(db: Session) -> N8nIntegrationStatus: + counts: dict[str, int] = dict( + db.execute( + select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status) + ).all() # type: ignore[arg-type] + ) + pending = counts.get("pending", 0) + delivering = counts.get("delivering", 0) + failed = counts.get("failed", 0) + succeeded = counts.get("succeeded", 0) + + latest_success_at = db.scalar( + select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded") + ) + latest_failure_at = db.scalar( + select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed") + ) + + state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] + if not settings.n8n_dispatch_enabled: + state = "disabled" + elif failed > 0 and succeeded == 0: + state = "unavailable" + elif failed > 0: + state = "degraded" + elif succeeded > 0 or pending > 0 or delivering > 0: + state = "operational" + else: + state = "no_evidence" + + return N8nIntegrationStatus( + configured=bool(settings.n8n_webhook_url), + dispatch_enabled=settings.n8n_dispatch_enabled, + state=state, + pending=pending, + delivering=delivering, + failed=failed, + succeeded=succeeded, + latest_success_at=latest_success_at, + latest_failure_at=latest_failure_at, + ) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 8bbeeb8..143757f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -18,6 +18,21 @@ def test_operations_manager_can_reset_demo(ops_client): response = ops_client.post("/api/v1/demo/reset") assert response.status_code == 200 assert response.json()["counts"]["vehicles"] == 50 + assert response.json()["anchor_date"] + assert response.json()["seeded_at"] + + +def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch): + import app.api.routers.demo as demo_router + + monkeypatch.setattr(demo_router.settings, "demo_allow_reset", False) + response = ops_client.post("/api/v1/demo/reset") + assert response.status_code == 403 + + # Restore real demo data: this test intentionally disabled reset, so a following test + # module must not inherit a database left mid-mutation by an earlier test. + monkeypatch.setattr(demo_router.settings, "demo_allow_reset", True) + assert ops_client.post("/api/v1/demo/reset").status_code == 200 def test_session_endpoint_requires_authentication(client): diff --git a/backend/tests/test_demo_manifest.py b/backend/tests/test_demo_manifest.py new file mode 100644 index 0000000..b5877ac --- /dev/null +++ b/backend/tests/test_demo_manifest.py @@ -0,0 +1,55 @@ +from app.core.db import SessionLocal +from app.seed_loader import reset_and_seed + + +def test_demo_manifest_is_public(client): + # No login call at all -- the demo-entry screen and badge need this before any + # session exists. + response = client.get("/api/v1/demo/manifest") + assert response.status_code == 200 + + +def test_demo_manifest_shape(client): + body = client.get("/api/v1/demo/manifest").json() + assert body["organization_name"] == "Northstar Mobility" + assert body["demo_mode"] is True + assert body["synthetic_data"] is True + assert body["allow_reset"] is True + assert body["timezone"] == "Europe/Brussels" + assert body["guide_available"] is True + assert set(body["required_roles"]) == {"operations_manager", "rental_employee"} + assert body["last_reset_at"] is not None + assert body["anchor_date"] is not None + + scenario_ids = {s["id"] for s in body["scenarios"]} + assert scenario_ids == { + "return-anomaly", + "duplicate-customer", + "booking-overlap", + "automation-retry", + "knowledge-question", + } + integration_keys = {i["key"] for i in body["integrations"]} + assert integration_keys == {"n8n", "ragcore", "mcp_hub"} + + +def test_demo_manifest_scenarios_ready_after_fresh_reset(client): + db = SessionLocal() + try: + reset_and_seed(db) + finally: + db.close() + + body = client.get("/api/v1/demo/manifest").json() + scenarios = {s["id"]: s for s in body["scenarios"]} + for scenario_id, scenario in scenarios.items(): + assert scenario["ready"] is True, f"{scenario_id} should be ready right after a reset" + assert scenario["blocked_reason"] is None + assert scenario["start_path"] + + +def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client): + body = client.get("/api/v1/demo/manifest").json() + ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore") + assert "Demomodus" in ragcore["status_label"] + assert "RAGcore" not in ragcore["status_label"] diff --git a/compose.yaml b/compose.yaml index a5f7759..5e0eadc 100644 --- a/compose.yaml +++ b/compose.yaml @@ -34,6 +34,9 @@ services: N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return} N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token} MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token} + DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility} + DEMO_TIMEZONE: ${DEMO_TIMEZONE:-Europe/Brussels} + DEMO_ALLOW_RESET: ${DEMO_ALLOW_RESET:-true} ports: - "8128:8000" depends_on: diff --git a/docs/05-api-contract.md b/docs/05-api-contract.md index 7f2d194..7cc5216 100644 --- a/docs/05-api-contract.md +++ b/docs/05-api-contract.md @@ -11,10 +11,17 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu ### System and demo - `GET /health` +- `GET /api/v1/demo/manifest` — unauthenticated; demo org name/description, synthetic-data + flag, reset allowance and timestamp, guide availability, the 5 named scenarios (with + live readiness derived from actual records, not hardcoded), and plain-language + integration summaries. Single source of truth for the demo-entry screen, the permanent + demo badge, the scenario overview and the About page — avoids duplicating this logic + per surface. - `POST /api/v1/demo/login` - `GET /api/v1/demo/session` — confirms the current session; `Cache-Control: no-store` - `POST /api/v1/demo/logout` — safe to call without a session -- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own session +- `POST /api/v1/demo/reset` — Operations Manager only; invalidates the caller's own + session; returns 403 if `DEMO_ALLOW_RESET=false` ### Dashboard diff --git a/frontend/e2e/_capture-screenshots.spec.ts b/frontend/e2e/_capture-screenshots.spec.ts index 8e62da6..10ffeb0 100644 --- a/frontend/e2e/_capture-screenshots.spec.ts +++ b/frontend/e2e/_capture-screenshots.spec.ts @@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => { await page.goto("/login"); await page.screenshot({ path: `${OUT}/1-login.png` }); - await page.getByRole("button", { name: "Open as Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible(); await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true }); diff --git a/frontend/e2e/demo-entry.spec.ts b/frontend/e2e/demo-entry.spec.ts new file mode 100644 index 0000000..63a97df --- /dev/null +++ b/frontend/e2e/demo-entry.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; + +test.describe.configure({ mode: "serial" }); + +test("demo entry screen names the fictional org and never shows a password", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByText(/Northstar Mobility/)).toBeVisible(); + await expect(page.getByText(/Synthetische demo/)).toBeVisible(); + await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Verken als Operations Manager" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Verken als Rental Employee" })).toBeVisible(); + 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 }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Start begeleide demo" }).click(); + await expect(page).toHaveURL(/\/dashboard\?guide=start$/); + await expect(page.getByText("Amelie De Ridder")).toBeVisible(); +}); + +test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + + const trigger = page.getByRole("button", { name: /Synthetische demo/ }); + await expect(trigger).toBeVisible(); + await trigger.click(); + await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible(); + await expect(page.getByText(/Laatste reset:/)).toBeVisible(); + + await page.getByRole("link", { name: /Over deze demo/ }).click(); + await expect(page).toHaveURL(/\/about$/); + await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible(); + await expect(page.getByText("Northstar Mobility").first()).toBeVisible(); + await expect(page.getByText("Demomodus — lokale kennisprovider")).toBeVisible(); + await expect(page.getByText("Niet gekoppeld").first()).toBeVisible(); +}); + +test("badge popover closes on Escape and outside click", async ({ page }) => { + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); + await expect(page).toHaveURL(/\/dashboard$/); + + const trigger = page.getByRole("button", { name: /Synthetische demo/ }); + await trigger.click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toBeHidden(); + + await trigger.click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.mouse.click(10, 10); + await expect(page.getByRole("dialog")).toBeHidden(); +}); diff --git a/frontend/e2e/demo.spec.ts b/frontend/e2e/demo.spec.ts index 9b926f7..5afb8f8 100644 --- a/frontend/e2e/demo.spec.ts +++ b/frontend/e2e/demo.spec.ts @@ -18,8 +18,8 @@ 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(/Synthetic proof of concept/)).toBeVisible(); - await page.getByRole("button", { name: "Open as Operations Manager" }).click(); + await expect(page.getByText(/Synthetische demo/)).toBeVisible(); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); @@ -89,7 +89,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(/Synthetic demo data/).first()).toBeVisible(); + await expect(page.getByText(/Synthetische demo/).first()).toBeVisible(); await expect(page.getByRole("link", { name: "Overview" }).first()).toBeVisible(); const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth); const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); diff --git a/frontend/e2e/interactive-elements.spec.ts b/frontend/e2e/interactive-elements.spec.ts index 143e870..05b0f8b 100644 --- a/frontend/e2e/interactive-elements.spec.ts +++ b/frontend/e2e/interactive-elements.spec.ts @@ -9,7 +9,7 @@ test.describe.configure({ mode: "serial" }); test.beforeEach(async ({ page }) => { await page.goto("/login"); - await page.getByRole("button", { name: "Open as Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); @@ -340,7 +340,7 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa page, }) => { await page.getByRole("button", { name: "Switch role" }).click(); - await page.getByRole("button", { name: "Open as Rental Employee" }).click(); + await page.getByRole("button", { name: "Verken als Rental Employee" }).click(); await expect(page).toHaveURL(/\/dashboard$/); // Manager-only nav items are not shown at all, not merely disabled. @@ -374,7 +374,7 @@ test("operations manager can reset demo data and is returned to login", async ({ await expect(page).toHaveURL(/\/login$/); // The reset must not have affected the ability to log back in against fresh data. - await page.getByRole("button", { name: "Open as Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); @@ -392,7 +392,7 @@ test("rental employee direct API access to manager-only endpoints is rejected", page, }) => { await page.getByRole("button", { name: "Switch role" }).click(); - await page.getByRole("button", { name: "Open as Rental Employee" }).click(); + await page.getByRole("button", { name: "Verken als Rental Employee" }).click(); await expect(page).toHaveURL(/\/dashboard$/); // page.request shares the browser context's cookies, and (via the web container's diff --git a/frontend/e2e/ui-redesign.spec.ts b/frontend/e2e/ui-redesign.spec.ts index 00896f7..6d3fe9c 100644 --- a/frontend/e2e/ui-redesign.spec.ts +++ b/frontend/e2e/ui-redesign.spec.ts @@ -8,7 +8,7 @@ async function resetDemoData(request: APIRequestContext) { test.beforeEach(async ({ page, request }) => { await resetDemoData(request); await page.goto("/login"); - await page.getByRole("button", { name: "Open as Operations Manager" }).click(); + await page.getByRole("button", { name: "Verken als Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8f5b257..7ce1051 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider } from "./context/AuthContext"; +import { DemoManifestProvider } from "./context/DemoManifestContext"; import { Layout } from "./components/Layout"; import { RequireAuth } from "./components/RequireAuth"; import { Login } from "./pages/Login"; @@ -13,33 +14,37 @@ import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail"; import { Automation } from "./pages/Automation"; import { Knowledge } from "./pages/Knowledge"; import { Audit } from "./pages/Audit"; +import { AboutDemo } from "./pages/AboutDemo"; export function App() { return ( - - - } /> - - - - } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - } /> - - + + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + + ); } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 715e29b..495150c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -252,6 +252,40 @@ export interface IntegrationStatus { mcp_hub: McpHubIntegrationStatus; } +export interface DemoScenario { + id: string; + title: string; + operational_problem: string; + estimated_minutes: number; + required_roles: Role[]; + start_path: string; + demonstrates: string; + ready: boolean; + blocked_reason: string | null; +} + +export interface DemoIntegrationSummary { + key: "n8n" | "ragcore" | "mcp_hub"; + label: string; + status_label: string; + detail: string; +} + +export interface DemoManifest { + demo_mode: boolean; + organization_name: string; + organization_description: string; + timezone: string; + synthetic_data: boolean; + allow_reset: boolean; + last_reset_at: string | null; + anchor_date: string | null; + guide_available: boolean; + required_roles: Role[]; + scenarios: DemoScenario[]; + integrations: DemoIntegrationSummary[]; +} + export interface AuditEvent { id: string; actor_type: string; diff --git a/frontend/src/components/DemoBadge.tsx b/frontend/src/components/DemoBadge.tsx new file mode 100644 index 0000000..5813234 --- /dev/null +++ b/frontend/src/components/DemoBadge.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { Icon } from "./Icons"; + +function formatDateTime(value: string | null): string { + if (!value) return "onbekend"; + return new Date(value).toLocaleString("nl-BE", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Europe/Brussels", + }); +} + +export function DemoBadge() { + const { manifest } = useDemoManifest(); + const [open, setOpen] = useState(false); + const boxRef = useRef(null); + + useEffect(() => { + function handleOutsideClick(event: MouseEvent) { + if (boxRef.current && !boxRef.current.contains(event.target as Node)) { + setOpen(false); + } + } + function handleEscape(event: KeyboardEvent) { + if (event.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", handleOutsideClick); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + document.removeEventListener("keydown", handleEscape); + }; + }, []); + + return ( +
+ + {open && ( +
+ +

+ {manifest ? ( + <> + {manifest.organization_name} is een fictieve organisatie. Alle + namen, voertuigen en boekingen zijn synthetisch. + + ) : ( + "Alle namen, voertuigen en boekingen in deze omgeving zijn synthetisch." + )} +

+

+ De workflows, controles en automatisering zijn echt geïmplementeerd — enkel de + gegevens zijn verzonnen. +

+ {manifest && ( +

+ Laatste reset: {formatDateTime(manifest.last_reset_at)} · deze + omgeving is op elk moment herstelbaar. +

+ )} + setOpen(false)}> + Over deze demo + +
+ )} +
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index fd109eb..ffad620 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -4,6 +4,8 @@ import { api, ApiError } from "../api/client"; import { useAuth } from "../context/AuthContext"; import type { Role, SearchResultItem } from "../api/types"; import { BrandMark, Icon, type IconName } from "./Icons"; +import { DemoBadge } from "./DemoBadge"; +import { useDemoManifest } from "../context/DemoManifestContext"; const SEARCH_ICON: Record = { vehicle: "fleet", @@ -42,6 +44,7 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [ export function Layout() { const { user, logout } = useAuth(); + const { manifest } = useDemoManifest(); const navigate = useNavigate(); const [mobileOpen, setMobileOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -192,7 +195,7 @@ export function Layout() {
Demo environmentSynthetic data only
- {user?.role === "operations_manager" && ( + {user?.role === "operations_manager" && manifest?.allow_reset !== false && (
{resetError &&

{resetError}

} {!resetConfirming ? ( @@ -274,6 +277,7 @@ export function Layout() { )}
+ Europe/Brussels {user && (
@@ -287,7 +291,6 @@ export function Layout() {
-

Synthetic demo data · no real customer or vehicle information

MobilityOps PoCEurope/Brussels · Synthetic demo data
diff --git a/frontend/src/context/DemoManifestContext.tsx b/frontend/src/context/DemoManifestContext.tsx new file mode 100644 index 0000000..74b02a3 --- /dev/null +++ b/frontend/src/context/DemoManifestContext.tsx @@ -0,0 +1,52 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { api } from "../api/client"; +import type { DemoManifest } from "../api/types"; + +interface DemoManifestState { + manifest: DemoManifest | null; + loading: boolean; + refresh: () => void; +} + +const DemoManifestContext = createContext(undefined); + +export function DemoManifestProvider({ children }: { children: ReactNode }) { + const [manifest, setManifest] = useState(null); + const [loading, setLoading] = useState(true); + const [version, setVersion] = useState(0); + + useEffect(() => { + let cancelled = false; + setLoading(true); + // Public endpoint by design: the demo-entry screen needs this before any session + // exists, so it is never gated behind auth. + api + .get("/api/v1/demo/manifest") + .then((result) => { + if (!cancelled) setManifest(result); + }) + .catch(() => { + if (!cancelled) setManifest(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [version]); + + return ( + setVersion((v) => v + 1) }} + > + {children} + + ); +} + +export function useDemoManifest(): DemoManifestState { + const ctx = useContext(DemoManifestContext); + if (!ctx) throw new Error("useDemoManifest must be used within DemoManifestProvider"); + return ctx; +} diff --git a/frontend/src/pages/AboutDemo.tsx b/frontend/src/pages/AboutDemo.tsx new file mode 100644 index 0000000..5c2733e --- /dev/null +++ b/frontend/src/pages/AboutDemo.tsx @@ -0,0 +1,124 @@ +import { useAuth } from "../context/AuthContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; +import { Icon } from "../components/Icons"; +import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome"; + +function formatDateTime(value: string | null): string { + if (!value) return "onbekend"; + return new Date(value).toLocaleString("nl-BE", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Europe/Brussels", + }); +} + +const INTEGRATION_ICON: Record = { + n8n: "n8n", + ragcore: "rag", + mcp_hub: "mcp", +}; + +export function AboutDemo() { + const { manifest, loading } = useDemoManifest(); + const { user } = useAuth(); + + return ( +
+ + + {loading && } + + {manifest && ( + <> +
+

Het fictieve probleem

+

+ {manifest.organization_name} 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. + MobilityOps toont hoe één samenhangend systeem die problemen vroeg signaleert en + gecontroleerd laat oplossen. +

+
+ +
+

Wat écht werkt

+

+ Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde + toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met + serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen + oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n + met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde + testsuite (backend en Playwright end-to-end). +

+
+ +
+

Wat synthetisch is

+

+ De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis, + procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig + verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of + bedrijf; e-mailadressen gebruiken uitsluitend het testdomein .test. +

+
+ +
+ +
+ {manifest.integrations.map((integration) => ( +
+ +
+ {integration.status_label} +

{integration.label}

+

{integration.detail}

+
+
+ ))} +
+
+ +
+

Demo-omgeving herstellen

+

+ De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset:{" "} + {formatDateTime(manifest.last_reset_at)}.{" "} + {user?.role === "operations_manager" ? ( + <> + Gebruik Reset demo data in de zijbalk om opnieuw te beginnen. + + ) : ( + <>Een Operations Manager kan de demo-omgeving herstellen via de zijbalk. + )} +

+
+ +
+ +

+ Beperkingen + + 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. + +

+
+ + )} +
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index a62355f..c62a73e 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,32 +1,41 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; +import { useDemoManifest } from "../context/DemoManifestContext"; import type { Role } from "../api/types"; import { BrandMark, Icon } from "../components/Icons"; export function Login() { const { loginAs, loading } = useAuth(); + const { manifest } = useDemoManifest(); const navigate = useNavigate(); const [error, setError] = useState(null); - async function handleLogin(role: Role) { + async function handleLogin(role: Role, guided = false) { setError(null); try { await loginAs(role); - navigate("/dashboard"); + navigate(guided ? "/dashboard?guide=start" : "/dashboard"); } catch { - setError("Could not start a demo session. The API may be unavailable."); + setError("De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar."); } } + const orgName = manifest?.organization_name ?? "Northstar Mobility"; + const description = + manifest?.organization_description ?? + "MobilityOps brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt " + + "verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde " + + "vervolgstappen."; + return (
-
MobilityOpsControl centre
+
MobilityOpsBedieningscentrum
-

Connected mobility operations

-

Every hand-off.
One clear view.

-

Turn fleet state, rental returns, data quality and automation into one calm operational rhythm.

+

Demo-organisatie: {orgName} (fictief)

+

Elke overdracht.
Eén helder overzicht.

+

{description}

-

Synthetic proof of concept · no real customer data

+

Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar

-

Demo access

-

Choose your workspace

-

No password is required. Each role opens a scoped synthetic environment.

+

Demo-toegang

+

Kies hoe je wil starten

+

Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.

{error &&

{error}

} + + +
- -
-

Safe by designAll actions are audited and resettable in this demo.

+

Veilig ontworpenElke actie wordt gelogd en is in deze demo herstelbaar.

diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a38409a..c313c7d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -103,8 +103,17 @@ a:hover { color: var(--teal); } .icon-button:hover { background: var(--surface-subtle); border-color: var(--line); } .icon-button svg { width: 18px; height: 18px; } .mobile-menu { display: none; } -.demo-banner { min-height: 32px; margin: 0; display: flex; align-items: center; justify-content: center; gap: 7px; padding: 6px 20px; color: #48566a; background: #eaf0f5; border-bottom: 1px solid #d7e0e8; font-size: .68rem; font-weight: 600; letter-spacing: .02em; } -.demo-banner svg { width: 14px; height: 14px; } +.demo-badge { position: relative; } +.demo-badge-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: #48566a; background: #eaf0f5; border: 1px solid #d7e0e8; border-radius: 999px; font-size: .68rem; font-weight: 700; letter-spacing: .02em; cursor: pointer; } +.demo-badge-trigger:hover { background: #dfe8ef; } +.demo-badge-trigger svg { width: 13px; height: 13px; } +.demo-badge-popover { position: absolute; z-index: 30; top: calc(100% + 8px); right: 0; width: min(320px, 84vw); padding: 16px; display: grid; gap: 10px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); } +.demo-badge-popover p { margin: 0; color: var(--ink-soft); font-size: .74rem; line-height: 1.55; } +.demo-badge-reset { color: var(--muted) !important; font-size: .68rem !important; } +.demo-badge-popover a { display: inline-flex; align-items: center; gap: 4px; color: var(--teal-dark); text-decoration: none; font-size: .74rem; font-weight: 700; } +.demo-badge-popover a svg { width: 13px; } +.demo-badge-close { position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; } +.demo-badge-close svg { width: 14px; height: 14px; } #main-content { width: min(1320px, calc(100% - 56px)); margin: 0 auto; padding: 38px 0 64px; flex: 1; } .app-footer { min-height: 52px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 0 28px; color: var(--muted); border-top: 1px solid var(--line); font-size: .68rem; } .mobile-nav, .nav-scrim { display: none; } @@ -219,6 +228,9 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .tabs button { position: relative; min-height: 44px; padding: 8px 13px; border: 0; background: transparent; color: var(--muted); font-size: .72rem; font-weight: 700; text-transform: capitalize; cursor: pointer; } .tabs button.active { color: var(--teal-dark); }.tabs button.active::after { content: ""; position: absolute; inset: auto 7px -1px; height: 2px; background: var(--teal); } .record-surface { padding: 18px; margin-bottom: 18px; } +.about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; } +.about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; } +.about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; } .detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); } .detail-grid div { min-height: 76px; padding: 13px 14px; background: white; } .detail-grid dt { margin: 0; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: .82rem; font-weight: 700; } @@ -260,7 +272,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .login-brand { height: auto; padding: 0; border: 0; }.login-brand .brand-mark { width: 36px; height: 36px; }.login-brand strong { font-size: 1.05rem; }.login-message { position: relative; z-index: 2; max-width: 620px; margin: auto 0 22px; }.login-message .eyebrow { color: #5eead4; }.login-message h1 { margin: 0; color: white; font-size: clamp(2.8rem, 5.5vw, 5.4rem); line-height: .98; letter-spacing: -.06em; }.login-message > p:last-child { max-width: 520px; margin: 23px 0 0; color: #a9b7c8; font-size: .98rem; line-height: 1.65; } .control-illustration { position: absolute; width: min(35vw, 500px); aspect-ratio: 1; right: -80px; top: 7vh; opacity: .88; }.illustration-orbit { position: absolute; inset: 10%; border: 1px solid #29435a; border-radius: 50%; }.orbit-two { inset: 28%; border-style: dashed; transform: rotate(35deg); }.illustration-orbit::before, .illustration-orbit::after { content: ""; position: absolute; background: #2dd4bf; border-radius: 50%; }.illustration-orbit::before { width: 7px; height: 7px; top: 13%; left: 16%; }.illustration-orbit::after { width: 5px; height: 5px; right: 2%; bottom: 33%; }.illustration-core { position: absolute; inset: 40%; display: grid; place-items: center; color: white; background: #172a3e; border: 1px solid #2c465e; border-radius: 50%; }.illustration-core svg { width: 45px; }.illustration-node { position: absolute; width: 42px; height: 42px; display: grid; place-items: center; color: #5eead4; background: #152b3f; border: 1px solid #2c465e; border-radius: 50%; }.illustration-node svg { width: 18px; }.node-one { top: 13%; left: 20%; }.node-two { right: 5%; bottom: 27%; }.node-three { left: 15%; bottom: 12%; } .login-footnote { position: relative; z-index: 2; display: flex; align-items: center; gap: 7px; margin: auto 0 0; color: #7f90a4; font-size: .68rem; }.login-footnote svg { width: 14px; } -.login-access { display: grid; place-items: center; padding: 46px max(38px, 7vw); background: #f8fafb; }.login-panel { width: min(470px, 100%); }.login-panel h2 { margin: 0; color: var(--ink); font-size: 1.7rem; letter-spacing: -.04em; }.login-intro { margin: 9px 0 26px; color: var(--muted); font-size: .8rem; line-height: 1.55; }.login-options { display: grid; gap: 10px; }.login-options button { min-height: 76px; display: grid; grid-template-columns: 40px 1fr 18px; align-items: center; gap: 12px; padding: 13px; text-align: left; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 7px 20px rgba(15,23,42,.04); cursor: pointer; transition: border .16s ease, transform .16s ease, box-shadow .16s ease; }.login-options button:hover { transform: translateY(-2px); border-color: var(--teal); box-shadow: var(--shadow-float); }.login-options button > svg { width: 17px; color: var(--teal-dark); }.login-options button > span:nth-child(2) { display: grid; gap: 5px; }.login-options strong { font-size: .8rem; }.login-options small { color: var(--muted); font-size: .66rem; }.role-icon { width: 40px; height: 40px; display: grid !important; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.role-icon svg { width: 18px; }.access-note { display: flex; gap: 10px; margin-top: 22px; padding-top: 20px; color: var(--muted); border-top: 1px solid var(--line); }.access-note > svg { width: 18px; color: var(--teal-dark); }.access-note p { display: grid; gap: 3px; margin: 0; }.access-note strong { color: var(--ink-soft); font-size: .69rem; }.access-note span { font-size: .64rem; } +.login-access { display: grid; place-items: center; padding: 46px max(38px, 7vw); background: #f8fafb; }.login-panel { width: min(470px, 100%); }.login-panel h2 { margin: 0; color: var(--ink); font-size: 1.7rem; letter-spacing: -.04em; }.login-intro { margin: 9px 0 26px; color: var(--muted); font-size: .8rem; line-height: 1.55; }.login-guide-cta { width: 100%; min-height: 48px; margin-bottom: 18px; font-size: .85rem; }.login-options { display: grid; gap: 10px; }.login-options button { min-height: 76px; display: grid; grid-template-columns: 40px 1fr 18px; align-items: center; gap: 12px; padding: 13px; text-align: left; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 7px 20px rgba(15,23,42,.04); cursor: pointer; transition: border .16s ease, transform .16s ease, box-shadow .16s ease; }.login-options button:hover { transform: translateY(-2px); border-color: var(--teal); box-shadow: var(--shadow-float); }.login-options button > svg { width: 17px; color: var(--teal-dark); }.login-options button > span:nth-child(2) { display: grid; gap: 5px; }.login-options strong { font-size: .8rem; }.login-options small { color: var(--muted); font-size: .66rem; }.role-icon { width: 40px; height: 40px; display: grid !important; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.role-icon svg { width: 18px; }.access-note { display: flex; gap: 10px; margin-top: 22px; padding-top: 20px; color: var(--muted); border-top: 1px solid var(--line); }.access-note > svg { width: 18px; color: var(--teal-dark); }.access-note p { display: grid; gap: 3px; margin: 0; }.access-note strong { color: var(--ink-soft); font-size: .69rem; }.access-note span { font-size: .64rem; } @media (max-width: 1120px) { .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); } @@ -271,7 +283,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details } @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-banner { min-height: 28px; padding-inline: 10px; font-size: .59rem; }.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; }.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; }