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.
This commit is contained in:
@@ -14,6 +14,13 @@ TZ=Europe/Brussels
|
|||||||
# otherwise browsers will silently drop the cookie and no one can log in.
|
# otherwise browsers will silently drop the cookie and no one can log in.
|
||||||
SESSION_COOKIE_SECURE=false
|
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
|
||||||
N8N_BASE_URL=http://n8n:5678
|
N8N_BASE_URL=http://n8n:5678
|
||||||
N8N_WEBHOOK_URL=http://n8n:5678/webhook/mobilityops-return
|
N8N_WEBHOOK_URL=http://n8n:5678/webhook/mobilityops-return
|
||||||
|
|||||||
@@ -548,3 +548,40 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
|
|||||||
`http://192.168.10.150:1236/` returns 200.
|
`http://192.168.10.150:1236/` returns 200.
|
||||||
- Exact next action: `GET /api/v1/demo/manifest` + Dutch demo entry screen + permanent
|
- 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).
|
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.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
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.config import get_settings
|
||||||
from app.core.security import SessionPayload, create_session_token, read_session_token
|
from app.core.security import SessionPayload, create_session_token, read_session_token
|
||||||
from app.models.user import User
|
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.seed_loader import reset_and_seed
|
||||||
from app.services.audit import record_audit_event
|
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"])
|
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
||||||
settings = get_settings()
|
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)
|
@router.post("/login", response_model=CurrentUser)
|
||||||
def demo_login(
|
def demo_login(
|
||||||
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
|
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
|
||||||
@@ -92,6 +101,11 @@ def demo_reset(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: CurrentUser = Depends(require_operations_manager),
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> dict:
|
) -> 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)
|
result = reset_and_seed(db)
|
||||||
record_audit_event(
|
record_audit_event(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -1,75 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_db, require_operations_manager
|
from app.api.deps import get_db, require_operations_manager
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.models.outbox import OutboxEvent
|
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
IntegrationStatusOut,
|
IntegrationStatusOut,
|
||||||
McpHubIntegrationStatus,
|
McpHubIntegrationStatus,
|
||||||
N8nIntegrationStatus,
|
|
||||||
)
|
)
|
||||||
|
from app.services.integration_status import derive_n8n_status
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||||
settings = get_settings()
|
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)
|
@router.get("/status", response_model=IntegrationStatusOut)
|
||||||
def integration_status(
|
def integration_status(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: CurrentUser = Depends(require_operations_manager),
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> IntegrationStatusOut:
|
) -> IntegrationStatusOut:
|
||||||
return IntegrationStatusOut(
|
return IntegrationStatusOut(
|
||||||
n8n=_n8n_status(db),
|
n8n=derive_n8n_status(db),
|
||||||
mcp_hub=McpHubIntegrationStatus(
|
mcp_hub=McpHubIntegrationStatus(
|
||||||
registration_enabled=settings.mcp_hub_registration_enabled,
|
registration_enabled=settings.mcp_hub_registration_enabled,
|
||||||
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
|
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
|
|||||||
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||||
mcp_hub_registration_enabled: bool = False
|
mcp_hub_registration_enabled: bool = False
|
||||||
cors_allow_origins: str = "http://localhost:1228"
|
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
|
@lru_cache
|
||||||
|
|||||||
@@ -197,6 +197,40 @@ class IntegrationStatusOut(BaseModel):
|
|||||||
mcp_hub: McpHubIntegrationStatus
|
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):
|
class VehicleDetailOut(VehicleOut):
|
||||||
bookings: list[BookingSummaryOut] = Field(default_factory=list)
|
bookings: list[BookingSummaryOut] = Field(default_factory=list)
|
||||||
inspections: list[InspectionOut] = Field(default_factory=list)
|
inspections: list[InspectionOut] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -18,6 +18,21 @@ def test_operations_manager_can_reset_demo(ops_client):
|
|||||||
response = ops_client.post("/api/v1/demo/reset")
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["counts"]["vehicles"] == 50
|
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):
|
def test_session_endpoint_requires_authentication(client):
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -34,6 +34,9 @@ services:
|
|||||||
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
|
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
|
||||||
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
|
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
|
||||||
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-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:
|
ports:
|
||||||
- "8128:8000"
|
- "8128:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -11,10 +11,17 @@ The demo may use signed server-issued sessions or short-lived JWTs. Demo-role bu
|
|||||||
### System and demo
|
### System and demo
|
||||||
|
|
||||||
- `GET /health`
|
- `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`
|
- `POST /api/v1/demo/login`
|
||||||
- `GET /api/v1/demo/session` — confirms the current session; `Cache-Control: no-store`
|
- `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/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
|
### Dashboard
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => {
|
|||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.screenshot({ path: `${OUT}/1-login.png` });
|
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 expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
|
||||||
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
|
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 test.step("1. login as Operations Manager", async () => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await expect(page.getByText(/Synthetic proof of concept/)).toBeVisible();
|
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
|
||||||
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$/);
|
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 test.step("9. verify responsive navigation at mobile width", async () => {
|
||||||
await page.setViewportSize({ width: 360, height: 800 });
|
await page.setViewportSize({ width: 360, height: 800 });
|
||||||
await page.goto("/dashboard");
|
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();
|
await expect(page.getByRole("link", { name: "Overview" }).first()).toBeVisible();
|
||||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ test.describe.configure({ mode: "serial" });
|
|||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/login");
|
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$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -340,7 +340,7 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.getByRole("button", { name: "Switch role" }).click();
|
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$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
// Manager-only nav items are not shown at all, not merely disabled.
|
// 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$/);
|
await expect(page).toHaveURL(/\/login$/);
|
||||||
|
|
||||||
// The reset must not have affected the ability to log back in against fresh data.
|
// 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$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -392,7 +392,7 @@ test("rental employee direct API access to manager-only endpoints is rejected",
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.getByRole("button", { name: "Switch role" }).click();
|
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$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
|
|
||||||
// page.request shares the browser context's cookies, and (via the web container's
|
// page.request shares the browser context's cookies, and (via the web container's
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ async function resetDemoData(request: APIRequestContext) {
|
|||||||
test.beforeEach(async ({ page, request }) => {
|
test.beforeEach(async ({ page, request }) => {
|
||||||
await resetDemoData(request);
|
await resetDemoData(request);
|
||||||
await page.goto("/login");
|
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$/);
|
await expect(page).toHaveURL(/\/dashboard$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
import { DemoManifestProvider } from "./context/DemoManifestContext";
|
||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
import { RequireAuth } from "./components/RequireAuth";
|
import { RequireAuth } from "./components/RequireAuth";
|
||||||
import { Login } from "./pages/Login";
|
import { Login } from "./pages/Login";
|
||||||
@@ -13,9 +14,11 @@ import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
|
|||||||
import { Automation } from "./pages/Automation";
|
import { Automation } from "./pages/Automation";
|
||||||
import { Knowledge } from "./pages/Knowledge";
|
import { Knowledge } from "./pages/Knowledge";
|
||||||
import { Audit } from "./pages/Audit";
|
import { Audit } from "./pages/Audit";
|
||||||
|
import { AboutDemo } from "./pages/AboutDemo";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
<DemoManifestProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
@@ -36,10 +39,12 @@ export function App() {
|
|||||||
<Route path="/automation" element={<Automation />} />
|
<Route path="/automation" element={<Automation />} />
|
||||||
<Route path="/knowledge" element={<Knowledge />} />
|
<Route path="/knowledge" element={<Knowledge />} />
|
||||||
<Route path="/audit" element={<Audit />} />
|
<Route path="/audit" element={<Audit />} />
|
||||||
|
<Route path="/about" element={<AboutDemo />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</DemoManifestProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,6 +252,40 @@ export interface IntegrationStatus {
|
|||||||
mcp_hub: McpHubIntegrationStatus;
|
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 {
|
export interface AuditEvent {
|
||||||
id: string;
|
id: string;
|
||||||
actor_type: string;
|
actor_type: string;
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="demo-badge" ref={boxRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="demo-badge-trigger"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<Icon name="shield" />
|
||||||
|
<span>Synthetische demo</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="demo-badge-popover" role="dialog" aria-label="Over deze demo-omgeving">
|
||||||
|
<button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label="Sluiten">
|
||||||
|
<Icon name="x" />
|
||||||
|
</button>
|
||||||
|
<p>
|
||||||
|
{manifest ? (
|
||||||
|
<>
|
||||||
|
<strong>{manifest.organization_name}</strong> is een fictieve organisatie. Alle
|
||||||
|
namen, voertuigen en boekingen zijn synthetisch.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Alle namen, voertuigen en boekingen in deze omgeving zijn synthetisch."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
De workflows, controles en automatisering zijn echt geïmplementeerd — enkel de
|
||||||
|
gegevens zijn verzonnen.
|
||||||
|
</p>
|
||||||
|
{manifest && (
|
||||||
|
<p className="demo-badge-reset">
|
||||||
|
Laatste reset: <strong>{formatDateTime(manifest.last_reset_at)}</strong> · deze
|
||||||
|
omgeving is op elk moment herstelbaar.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link to="/about" onClick={() => setOpen(false)}>
|
||||||
|
Over deze demo <Icon name="chevron" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { api, ApiError } from "../api/client";
|
|||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import type { Role, SearchResultItem } from "../api/types";
|
import type { Role, SearchResultItem } from "../api/types";
|
||||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||||
|
import { DemoBadge } from "./DemoBadge";
|
||||||
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
|
|
||||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||||
vehicle: "fleet",
|
vehicle: "fleet",
|
||||||
@@ -42,6 +44,7 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
|
|||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const { manifest } = useDemoManifest();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
@@ -192,7 +195,7 @@ export function Layout() {
|
|||||||
<span className="environment-dot" />
|
<span className="environment-dot" />
|
||||||
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
|
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
|
||||||
</div>
|
</div>
|
||||||
{user?.role === "operations_manager" && (
|
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
|
||||||
<div className="sidebar-reset">
|
<div className="sidebar-reset">
|
||||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||||
{!resetConfirming ? (
|
{!resetConfirming ? (
|
||||||
@@ -274,6 +277,7 @@ export function Layout() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-meta">
|
<div className="topbar-meta">
|
||||||
|
<DemoBadge />
|
||||||
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
|
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
|
||||||
{user && (
|
{user && (
|
||||||
<div className="operator">
|
<div className="operator">
|
||||||
@@ -287,7 +291,6 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p className="demo-banner"><Icon name="shield" /> Synthetic demo data · no real customer or vehicle information</p>
|
|
||||||
<main id="main-content" tabIndex={-1}><Outlet /></main>
|
<main id="main-content" tabIndex={-1}><Outlet /></main>
|
||||||
<footer className="app-footer"><span>MobilityOps PoC</span><span>Europe/Brussels · Synthetic demo data</span></footer>
|
<footer className="app-footer"><span>MobilityOps PoC</span><span>Europe/Brussels · Synthetic demo data</span></footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<DemoManifestState | undefined>(undefined);
|
||||||
|
|
||||||
|
export function DemoManifestProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [manifest, setManifest] = useState<DemoManifest | null>(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<DemoManifest>("/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 (
|
||||||
|
<DemoManifestContext.Provider
|
||||||
|
value={{ manifest, loading, refresh: () => setVersion((v) => v + 1) }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DemoManifestContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDemoManifest(): DemoManifestState {
|
||||||
|
const ctx = useContext(DemoManifestContext);
|
||||||
|
if (!ctx) throw new Error("useDemoManifest must be used within DemoManifestProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -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<string, "n8n" | "rag" | "mcp"> = {
|
||||||
|
n8n: "n8n",
|
||||||
|
ragcore: "rag",
|
||||||
|
mcp_hub: "mcp",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AboutDemo() {
|
||||||
|
const { manifest, loading } = useDemoManifest();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="Over deze demo"
|
||||||
|
title="Wat MobilityOps wel en niet is"
|
||||||
|
description={
|
||||||
|
manifest
|
||||||
|
? `${manifest.organization_name} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && <LoadingState label="Demo-informatie laden…" />}
|
||||||
|
|
||||||
|
{manifest && (
|
||||||
|
<>
|
||||||
|
<section className="record-surface about-card">
|
||||||
|
<h2>Het fictieve probleem</h2>
|
||||||
|
<p>
|
||||||
|
{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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="record-surface about-card">
|
||||||
|
<h2>Wat écht werkt</h2>
|
||||||
|
<p>
|
||||||
|
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).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="record-surface about-card">
|
||||||
|
<h2>Wat synthetisch is</h2>
|
||||||
|
<p>
|
||||||
|
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 <code>.test</code>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label="Koppelingsstatus" className="record-surface">
|
||||||
|
<SectionHeading
|
||||||
|
title="Koppelingen — eerlijk gelabeld"
|
||||||
|
description="Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is."
|
||||||
|
/>
|
||||||
|
<div className="integration-cards">
|
||||||
|
{manifest.integrations.map((integration) => (
|
||||||
|
<article key={integration.key}>
|
||||||
|
<IntegrationMark kind={INTEGRATION_ICON[integration.key]} />
|
||||||
|
<div>
|
||||||
|
<span className="integration-kicker">{integration.status_label}</span>
|
||||||
|
<h2>{integration.label}</h2>
|
||||||
|
<p>{integration.detail}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="record-surface about-card">
|
||||||
|
<h2>Demo-omgeving herstellen</h2>
|
||||||
|
<p>
|
||||||
|
De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset:{" "}
|
||||||
|
<strong>{formatDateTime(manifest.last_reset_at)}</strong>.{" "}
|
||||||
|
{user?.role === "operations_manager" ? (
|
||||||
|
<>
|
||||||
|
Gebruik <strong>Reset demo data</strong> in de zijbalk om opnieuw te beginnen.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Een Operations Manager kan de demo-omgeving herstellen via de zijbalk.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="access-note record-surface" aria-label="Beperkingen">
|
||||||
|
<Icon name="shield" />
|
||||||
|
<p>
|
||||||
|
<strong>Beperkingen</strong>
|
||||||
|
<span>
|
||||||
|
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.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +1,41 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
import type { Role } from "../api/types";
|
import type { Role } from "../api/types";
|
||||||
import { BrandMark, Icon } from "../components/Icons";
|
import { BrandMark, Icon } from "../components/Icons";
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const { loginAs, loading } = useAuth();
|
const { loginAs, loading } = useAuth();
|
||||||
|
const { manifest } = useDemoManifest();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleLogin(role: Role) {
|
async function handleLogin(role: Role, guided = false) {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await loginAs(role);
|
await loginAs(role);
|
||||||
navigate("/dashboard");
|
navigate(guided ? "/dashboard?guide=start" : "/dashboard");
|
||||||
} catch {
|
} 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 (
|
return (
|
||||||
<main className="login-shell">
|
<main className="login-shell">
|
||||||
<section className="login-story" aria-labelledby="product-name">
|
<section className="login-story" aria-labelledby="product-name">
|
||||||
<div className="brand-lockup login-brand"><BrandMark className="brand-mark" /><div><strong>MobilityOps</strong><span>Control centre</span></div></div>
|
<div className="brand-lockup login-brand"><BrandMark className="brand-mark" /><div><strong>MobilityOps</strong><span>Bedieningscentrum</span></div></div>
|
||||||
<div className="login-message">
|
<div className="login-message">
|
||||||
<p className="eyebrow">Connected mobility operations</p>
|
<p className="eyebrow">Demo-organisatie: {orgName} (fictief)</p>
|
||||||
<h1 id="product-name">Every hand-off.<br />One clear view.</h1>
|
<h1 id="product-name">Elke overdracht.<br />Eén helder overzicht.</h1>
|
||||||
<p>Turn fleet state, rental returns, data quality and automation into one calm operational rhythm.</p>
|
<p>{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="control-illustration" aria-hidden="true">
|
<div className="control-illustration" aria-hidden="true">
|
||||||
<div className="illustration-orbit orbit-one"><span /></div>
|
<div className="illustration-orbit orbit-one"><span /></div>
|
||||||
@@ -36,28 +45,39 @@ export function Login() {
|
|||||||
<span className="illustration-node node-two"><Icon name="bookings" /></span>
|
<span className="illustration-node node-two"><Icon name="bookings" /></span>
|
||||||
<span className="illustration-node node-three"><Icon name="quality" /></span>
|
<span className="illustration-node node-three"><Icon name="quality" /></span>
|
||||||
</div>
|
</div>
|
||||||
<p className="login-footnote"><Icon name="shield" /> Synthetic proof of concept · no real customer data</p>
|
<p className="login-footnote"><Icon name="shield" /> Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="login-access" aria-labelledby="login-heading">
|
<section className="login-access" aria-labelledby="login-heading">
|
||||||
<div className="login-panel">
|
<div className="login-panel">
|
||||||
<p className="page-eyebrow">Demo access</p>
|
<p className="page-eyebrow">Demo-toegang</p>
|
||||||
<h2 id="login-heading">Choose your workspace</h2>
|
<h2 id="login-heading">Kies hoe je wil starten</h2>
|
||||||
<p className="login-intro">No password is required. Each role opens a scoped synthetic environment.</p>
|
<p className="login-intro">Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.</p>
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
{error && <p className="error" role="alert">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button-primary login-guide-cta"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => handleLogin("operations_manager", true)}
|
||||||
|
>
|
||||||
|
<Icon name="spark" />
|
||||||
|
Start begeleide demo
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="login-options">
|
<div className="login-options">
|
||||||
<button type="button" aria-label="Open as Operations Manager" disabled={loading} onClick={() => handleLogin("operations_manager")}>
|
<button type="button" aria-label="Verken als Operations Manager" disabled={loading} onClick={() => handleLogin("operations_manager")}>
|
||||||
<span className="role-icon"><Icon name="activity" /></span>
|
<span className="role-icon"><Icon name="activity" /></span>
|
||||||
<span><strong>Operations Manager</strong><small>Full overview, quality resolution and retries</small></span>
|
<span><strong>Verken als Operations Manager</strong><small>Volledig overzicht, kwaliteitsoplossing en herpogingen</small></span>
|
||||||
<Icon name="chevron" />
|
<Icon name="chevron" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" aria-label="Open as Rental Employee" disabled={loading} onClick={() => handleLogin("rental_employee")}>
|
<button type="button" aria-label="Verken als Rental Employee" disabled={loading} onClick={() => handleLogin("rental_employee")}>
|
||||||
<span className="role-icon"><Icon name="user" /></span>
|
<span className="role-icon"><Icon name="user" /></span>
|
||||||
<span><strong>Rental Employee</strong><small>Bookings, returns, fleet and procedures</small></span>
|
<span><strong>Verken als Rental Employee</strong><small>Boekingen, retours, wagenpark en procedures</small></span>
|
||||||
<Icon name="chevron" />
|
<Icon name="chevron" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="access-note"><Icon name="shield" /><p><strong>Safe by design</strong><span>All actions are audited and resettable in this demo.</span></p></div>
|
<div className="access-note"><Icon name="shield" /><p><strong>Veilig ontworpen</strong><span>Elke actie wordt gelogd en is in deze demo herstelbaar.</span></p></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+16
-4
@@ -103,8 +103,17 @@ a:hover { color: var(--teal); }
|
|||||||
.icon-button:hover { background: var(--surface-subtle); border-color: var(--line); }
|
.icon-button:hover { background: var(--surface-subtle); border-color: var(--line); }
|
||||||
.icon-button svg { width: 18px; height: 18px; }
|
.icon-button svg { width: 18px; height: 18px; }
|
||||||
.mobile-menu { display: none; }
|
.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-badge { position: relative; }
|
||||||
.demo-banner svg { width: 14px; height: 14px; }
|
.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; }
|
#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; }
|
.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; }
|
.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 { 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); }
|
.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; }
|
.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 { 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 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; }
|
.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; }
|
.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%; }
|
.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-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) {
|
@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); }
|
.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) {
|
@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; }
|
.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; }
|
.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; }
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user