feat(demo): add Demo Guide (8-step guided tour) and scenario overview
Adds a compact "Probeer een demonstratiescenario" page listing the 5 named scenarios with live readiness from the manifest, plus a Demo Guide side panel (bottom sheet on mobile) that walks an Operations Manager through all 8 steps with per-step context, live-resolved routes, sessionStorage progress, and a "Demo opnieuw voorbereiden" restart. Login's guided-demo CTA now actually opens the guide. Fixes a real mobile topbar overflow the new guide trigger introduced.
This commit is contained in:
@@ -12,11 +12,16 @@ test("demo entry screen names the fictional org and never shows a password", asy
|
||||
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("start guided demo logs in as Operations Manager and marks the guide to start", async ({ page }) => {
|
||||
test("start guided demo logs in as Operations Manager and opens the guide at step 1", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard\?guide=start$/);
|
||||
// The ?guide=start marker is a one-shot signal the dashboard strips immediately after
|
||||
// consuming it, so assert on its effect (the guide panel opens at step 1) rather than
|
||||
// the transient URL, which can already be gone by the time this assertion runs.
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByText("Amelie De Ridder")).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
async function resetDemoData(request: APIRequestContext) {
|
||||
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
expect(login.ok()).toBeTruthy();
|
||||
const reset = await request.post("/api/v1/demo/reset");
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await page.goto("/scenarios");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
|
||||
const cards = page.locator(".scenario-card");
|
||||
await expect(cards).toHaveCount(5);
|
||||
for (const label of ["Klaar voor demo"]) {
|
||||
await expect(page.getByText(label).first()).toBeVisible();
|
||||
}
|
||||
await expect(page.getByText("Niet beschikbaar")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("starting a scenario navigates to its fixed record", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await page.goto("/scenarios");
|
||||
|
||||
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
|
||||
await duplicateCard.getByRole("link", { name: "Start scenario" }).click();
|
||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
|
||||
});
|
||||
|
||||
test("demo guide: navigating steps, jumping to a step, and closing works", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await expect(page.getByRole("heading", { name: "2. Open een boeking die aandacht nodig heeft" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
|
||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
|
||||
await expect(page).toHaveURL(/\/knowledge$/);
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Sluiten" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
|
||||
});
|
||||
|
||||
test("demo guide progress persists across navigation and the trigger shows it", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await page.getByRole("button", { name: "Sluiten" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /Demo-gids/ })).toContainText("2/8");
|
||||
|
||||
await page.goto("/vehicles");
|
||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
||||
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("demo guide is not shown to a rental employee", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("restarting the demo from the guide resets data and returns to login", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Start begeleide demo" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click();
|
||||
await expect(page).toHaveURL(/\/login$/, { timeout: 10000 });
|
||||
});
|
||||
+29
-24
@@ -1,6 +1,7 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { DemoManifestProvider } from "./context/DemoManifestContext";
|
||||
import { DemoGuideProvider } from "./context/DemoGuideContext";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
import { Login } from "./pages/Login";
|
||||
@@ -15,35 +16,39 @@ import { Automation } from "./pages/Automation";
|
||||
import { Knowledge } from "./pages/Knowledge";
|
||||
import { Audit } from "./pages/Audit";
|
||||
import { AboutDemo } from "./pages/AboutDemo";
|
||||
import { Scenarios } from "./pages/Scenarios";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<DemoManifestProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/vehicles" element={<Vehicles />} />
|
||||
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
|
||||
<Route path="/bookings" element={<Bookings />} />
|
||||
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
|
||||
<Route path="/data-quality" element={<DataQuality />} />
|
||||
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
|
||||
<Route path="/automation" element={<Automation />} />
|
||||
<Route path="/knowledge" element={<Knowledge />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
<Route path="/about" element={<AboutDemo />} />
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
<DemoGuideProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/vehicles" element={<Vehicles />} />
|
||||
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
|
||||
<Route path="/bookings" element={<Bookings />} />
|
||||
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
|
||||
<Route path="/data-quality" element={<DataQuality />} />
|
||||
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
|
||||
<Route path="/automation" element={<Automation />} />
|
||||
<Route path="/knowledge" element={<Knowledge />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
<Route path="/about" element={<AboutDemo />} />
|
||||
<Route path="/scenarios" element={<Scenarios />} />
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</DemoGuideProvider>
|
||||
</AuthProvider>
|
||||
</DemoManifestProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { Icon } from "./Icons";
|
||||
|
||||
export function DemoGuideTrigger() {
|
||||
const { user } = useAuth();
|
||||
const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide();
|
||||
|
||||
if (user?.role !== "operations_manager") return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="demo-guide-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
onClick={toggleGuide}
|
||||
>
|
||||
<Icon name="spark" />
|
||||
<span>Demo-gids</span>
|
||||
<span className="demo-guide-progress-pill">{completed.size}/{totalSteps}</span>
|
||||
<span className="visually-hidden">, huidige stap {currentIndex + 1}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DemoGuide() {
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuth();
|
||||
const { manifest, refresh } = useDemoManifest();
|
||||
const {
|
||||
open,
|
||||
closeGuide,
|
||||
currentIndex,
|
||||
completed,
|
||||
totalSteps,
|
||||
goToStep,
|
||||
completeAndAdvance,
|
||||
restart,
|
||||
} = useDemoGuide();
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const step = DEMO_GUIDE_STEPS[currentIndex];
|
||||
const isLastStep = currentIndex === totalSteps - 1;
|
||||
|
||||
function goToStepRoute() {
|
||||
navigate(step.route(manifest));
|
||||
}
|
||||
|
||||
async function handleRestartDemo() {
|
||||
setResetError(null);
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.post("/api/v1/demo/reset");
|
||||
restart();
|
||||
refresh();
|
||||
closeGuide();
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(err instanceof ApiError ? err.message : "De demo kon niet hersteld worden.");
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="demo-guide-panel" role="dialog" aria-label="Gegidste demo">
|
||||
<header className="demo-guide-header">
|
||||
<div>
|
||||
<p className="demo-guide-kicker">Gegidste demo · stap {currentIndex + 1} van {totalSteps}</p>
|
||||
<h2>{step.title}</h2>
|
||||
</div>
|
||||
<button type="button" className="icon-button" onClick={closeGuide} aria-label="Sluiten">
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="demo-guide-progress-bar" aria-hidden="true">
|
||||
{DEMO_GUIDE_STEPS.map((s, index) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className={
|
||||
index === currentIndex ? "is-current" : completed.has(s.id) ? "is-done" : ""
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="demo-guide-body">
|
||||
<p><strong>Wat je zal zien</strong><br />{step.whatYouWillSee}</p>
|
||||
<p><strong>Waarom dit relevant is</strong><br />{step.whyItMatters}</p>
|
||||
<p><strong>Aan de slag</strong><br />{step.startAction}</p>
|
||||
<p><strong>Verwacht resultaat</strong><br />{step.expectedOutcome}</p>
|
||||
</div>
|
||||
|
||||
<nav className="demo-guide-steps" aria-label="Alle stappen">
|
||||
{DEMO_GUIDE_STEPS.map((s, index) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={index === currentIndex ? "is-current" : ""}
|
||||
onClick={() => goToStep(index)}
|
||||
>
|
||||
{completed.has(s.id) && <Icon name="check" />}
|
||||
{s.title}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||
|
||||
<footer className="demo-guide-footer">
|
||||
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
|
||||
Ga naar deze stap
|
||||
</button>
|
||||
<button type="button" className="button button-primary" onClick={completeAndAdvance} disabled={isLastStep}>
|
||||
Volgende
|
||||
</button>
|
||||
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
|
||||
{resetting ? "Bezig met herstellen…" : "Demo opnieuw voorbereiden"}
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "../context/AuthContext";
|
||||
import type { Role, SearchResultItem } from "../api/types";
|
||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||
import { DemoBadge } from "./DemoBadge";
|
||||
import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
|
||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||
@@ -277,6 +278,7 @@ export function Layout() {
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-meta">
|
||||
<DemoGuideTrigger />
|
||||
<DemoBadge />
|
||||
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
|
||||
{user && (
|
||||
@@ -307,6 +309,8 @@ export function Layout() {
|
||||
<span>More</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<DemoGuide />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
|
||||
const STORAGE_KEY = "mobilityops.demo-guide-progress";
|
||||
|
||||
interface StoredProgress {
|
||||
currentIndex: number;
|
||||
completed: string[];
|
||||
}
|
||||
|
||||
function readProgress(): StoredProgress {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { currentIndex: 0, completed: [] };
|
||||
const parsed = JSON.parse(raw) as StoredProgress;
|
||||
return { currentIndex: parsed.currentIndex ?? 0, completed: parsed.completed ?? [] };
|
||||
} catch {
|
||||
return { currentIndex: 0, completed: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function writeProgress(progress: StoredProgress) {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
|
||||
}
|
||||
|
||||
interface DemoGuideState {
|
||||
open: boolean;
|
||||
currentIndex: number;
|
||||
completed: Set<string>;
|
||||
totalSteps: number;
|
||||
openGuide: () => void;
|
||||
closeGuide: () => void;
|
||||
toggleGuide: () => void;
|
||||
goToStep: (index: number) => void;
|
||||
completeAndAdvance: () => void;
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
const DemoGuideContext = createContext<DemoGuideState | undefined>(undefined);
|
||||
|
||||
export function DemoGuideProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [completed, setCompleted] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readProgress();
|
||||
setCurrentIndex(stored.currentIndex);
|
||||
setCompleted(new Set(stored.completed));
|
||||
}, []);
|
||||
|
||||
const persist = useCallback((index: number, done: Set<string>) => {
|
||||
writeProgress({ currentIndex: index, completed: Array.from(done) });
|
||||
}, []);
|
||||
|
||||
const goToStep = useCallback(
|
||||
(index: number) => {
|
||||
const clamped = Math.max(0, Math.min(index, DEMO_GUIDE_STEPS.length - 1));
|
||||
setCurrentIndex(clamped);
|
||||
persist(clamped, completed);
|
||||
},
|
||||
[completed, persist],
|
||||
);
|
||||
|
||||
const completeAndAdvance = useCallback(() => {
|
||||
setCompleted((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(DEMO_GUIDE_STEPS[currentIndex].id);
|
||||
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
|
||||
persist(nextIndex, next);
|
||||
setCurrentIndex(nextIndex);
|
||||
return next;
|
||||
});
|
||||
}, [currentIndex, persist]);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
setCurrentIndex(0);
|
||||
setCompleted(new Set());
|
||||
writeProgress({ currentIndex: 0, completed: [] });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DemoGuideContext.Provider
|
||||
value={{
|
||||
open,
|
||||
currentIndex,
|
||||
completed,
|
||||
totalSteps: DEMO_GUIDE_STEPS.length,
|
||||
openGuide: () => setOpen(true),
|
||||
closeGuide: () => setOpen(false),
|
||||
toggleGuide: () => setOpen((v) => !v),
|
||||
goToStep,
|
||||
completeAndAdvance,
|
||||
restart,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DemoGuideContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDemoGuide(): DemoGuideState {
|
||||
const ctx = useContext(DemoGuideContext);
|
||||
if (!ctx) throw new Error("useDemoGuide must be used within DemoGuideProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { DemoManifest } from "../api/types";
|
||||
|
||||
export interface DemoGuideStep {
|
||||
id: string;
|
||||
title: string;
|
||||
whatYouWillSee: string;
|
||||
whyItMatters: string;
|
||||
startAction: string;
|
||||
expectedOutcome: string;
|
||||
/** Resolves the route to link to, using live manifest data where a scenario supplies
|
||||
* a concrete record (booking/issue ref) instead of hardcoding one that could drift. */
|
||||
route: (manifest: DemoManifest | null) => string;
|
||||
}
|
||||
|
||||
export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [
|
||||
{
|
||||
id: "understand-state",
|
||||
title: "1. Begrijp de operationele status",
|
||||
whatYouWillSee: "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.",
|
||||
whyItMatters: "Een Operations Manager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.",
|
||||
startAction: "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.",
|
||||
expectedOutcome: "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen.",
|
||||
route: () => "/dashboard",
|
||||
},
|
||||
{
|
||||
id: "open-booking",
|
||||
title: "2. Open een boeking die aandacht nodig heeft",
|
||||
whatYouWillSee: "De boeking BK-DEMO-RETURN, actief en klaar voor retour vandaag.",
|
||||
whyItMatters: "Retours zijn het moment waarop foute kilometerstanden of schade voor het eerst zichtbaar worden.",
|
||||
startAction: "Open de boeking vanuit het dashboard of de boekingenlijst.",
|
||||
expectedOutcome: "Je ziet de boekingsdetails en de knop om de retour te verwerken.",
|
||||
route: (manifest) =>
|
||||
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings",
|
||||
},
|
||||
{
|
||||
id: "process-return",
|
||||
title: "3. Verwerk een retour met een afwijkende kilometerstand",
|
||||
whatYouWillSee: "Een retourformulier met een vooringevulde, verdachte kilometerstand lager dan de laatst gekende stand.",
|
||||
whyItMatters: "Een dalende kilometerstand wijst op een foutieve invoer of een verwisseld voertuig — dit moet vóór vrijgave worden opgemerkt.",
|
||||
startAction: "Vul de retour in met de voorgestelde waarde en bekijk de serverpreview vóór je bevestigt.",
|
||||
expectedOutcome: "De retour wordt verwerkt, het voertuig krijgt een passende status en er wordt automatisch een datakwaliteitsprobleem aangemaakt.",
|
||||
route: (manifest) =>
|
||||
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path ?? "/bookings",
|
||||
},
|
||||
{
|
||||
id: "handle-quality-issue",
|
||||
title: "4. Bekijk en behandel het gecreëerde datakwaliteitsprobleem",
|
||||
whatYouWillSee: "Een nieuw issue van het type 'kilometerstand-afwijking' bovenaan de werklijst.",
|
||||
whyItMatters: "Elk gedetecteerd probleem heeft één afgebakende oplossingsstap — niets wordt automatisch stilzwijgend gecorrigeerd.",
|
||||
startAction: "Open het datakwaliteitsoverzicht en kies het nieuwste issue.",
|
||||
expectedOutcome: "Je ziet de aanbevolen actie, kiest een oplossing en het issue wordt opgelost met een audit-spoor.",
|
||||
route: () => "/data-quality",
|
||||
},
|
||||
{
|
||||
id: "merge-duplicate",
|
||||
title: "5. Beoordeel en behandel een mogelijke dubbele klant",
|
||||
whatYouWillSee: "Twee klantprofielen met hetzelfde e-mailadres en telefoonnummer, naast elkaar vergeleken.",
|
||||
whyItMatters: "Dubbele klanten leiden tot verspreide boekingsgeschiedenis en verwarrende communicatie.",
|
||||
startAction: "Vergelijk beide profielen en kies welk profiel behouden blijft.",
|
||||
expectedOutcome: "De profielen worden samengevoegd, boekingen worden herverbonden en het verliezende profiel wordt een tombstone.",
|
||||
route: (manifest) =>
|
||||
manifest?.scenarios.find((s) => s.id === "duplicate-customer")?.start_path ?? "/data-quality",
|
||||
},
|
||||
{
|
||||
id: "ask-knowledge",
|
||||
title: "6. Stel een vraag aan de procedureassistent",
|
||||
whatYouWillSee: "Een antwoord met bronvermelding uit de afgebakende demokennisbank.",
|
||||
whyItMatters: "Medewerkers moeten snel een onderbouwd antwoord krijgen over procedures, zonder te gokken.",
|
||||
startAction: 'Stel de voorbeeldvraag "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?".',
|
||||
expectedOutcome: "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is.",
|
||||
route: () => "/knowledge",
|
||||
},
|
||||
{
|
||||
id: "check-automation-audit",
|
||||
title: "7. Controleer automatisering en audit trail",
|
||||
whatYouWillSee: "De status van de n8n-aflevering voor je retour, en de bijhorende audit-gebeurtenissen.",
|
||||
whyItMatters: "Elke belangrijke actie moet naspeurbaar zijn: wie deed wat, wanneer, en wat was het gevolg.",
|
||||
startAction: "Open Systemen om de afleverstatus te zien, en Audit trail voor het volledige spoor.",
|
||||
expectedOutcome: "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties.",
|
||||
route: () => "/automation",
|
||||
},
|
||||
{
|
||||
id: "review-real-vs-simulated",
|
||||
title: "8. Bekijk wat echt is, gesimuleerd is, of nog niet gekoppeld",
|
||||
whatYouWillSee: "Een overzicht van wat in deze demo functioneel geïmplementeerd is, wat synthetisch is, en welke koppelingen nog niet live zijn.",
|
||||
whyItMatters: "Een demo is pas overtuigend als bezoekers zelf kunnen nagaan wat echt werkt en wat nog toekomstmuziek is.",
|
||||
startAction: "Lees de pagina 'Over deze demo'.",
|
||||
expectedOutcome: "Je kan zelf uitleggen wat MobilityOps wel en niet is, zonder mondelinge toelichting.",
|
||||
route: () => "/about",
|
||||
},
|
||||
];
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { Dashboard as DashboardData, IntegrationStatus, KnowledgeHealth } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
@@ -26,8 +28,21 @@ function localTime(value: string, withDate = false) {
|
||||
|
||||
export function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const canSeeQuality = user?.role === "operations_manager";
|
||||
const canSeeAutomation = user?.role === "operations_manager";
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("guide") === "start") {
|
||||
restart();
|
||||
openGuide();
|
||||
setSearchParams({}, { replace: true });
|
||||
}
|
||||
// Only ever react to the initial `?guide=start` marker set by the login screen's
|
||||
// "Start begeleide demo" CTA, so this intentionally runs once on mount.
|
||||
}, []);
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
|
||||
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
|
||||
@@ -64,6 +79,26 @@ export function Dashboard() {
|
||||
<div className="page dashboard-page">
|
||||
<PageHeader eyebrow="Operations / Live overview" title="Good morning. Here’s the fleet." description="Readiness, exceptions and hand-offs across today’s operation." actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> View fleet</Link>} />
|
||||
|
||||
<section className="demo-start-panel" aria-label="Demo starten">
|
||||
<div>
|
||||
<Icon name="spark" />
|
||||
<div>
|
||||
<strong>Probeer een demonstratiescenario</strong>
|
||||
<span>
|
||||
{manifest ? `${manifest.scenarios.filter((s) => s.ready).length} van ${manifest.scenarios.length} scenario's klaar voor demo.` : "Vijf afgebakende scenario's."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="demo-start-actions">
|
||||
{user?.role === "operations_manager" && (
|
||||
<button type="button" className="button button-secondary" onClick={openGuide}>
|
||||
<Icon name="spark" /> {completed.size > 0 ? `Verder met demo-gids (${currentIndex + 1}/${totalSteps})` : "Start demo-gids"}
|
||||
</button>
|
||||
)}
|
||||
<Link className="button button-primary" to="/scenarios">Bekijk scenario's <Icon name="chevron" /></Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="readiness-band" aria-labelledby="readiness-heading">
|
||||
<div className="readiness-label">
|
||||
<span className="live-indicator" />
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { LoadingState, PageHeader } from "../components/PageChrome";
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
operations_manager: "Operations Manager",
|
||||
rental_employee: "Rental Employee",
|
||||
};
|
||||
|
||||
export function Scenarios() {
|
||||
const { manifest, loading } = useDemoManifest();
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
eyebrow="Demonstratiescenario's"
|
||||
title="Probeer een demonstratiescenario"
|
||||
description="Vijf afgebakende scenario's die telkens dezelfde vaste boekingen, klanten en voertuigen gebruiken — na een reset zijn ze altijd opnieuw te vinden."
|
||||
/>
|
||||
|
||||
{loading && <LoadingState label="Scenario's laden…" />}
|
||||
|
||||
{manifest && (
|
||||
<div className="scenario-grid">
|
||||
{manifest.scenarios.map((scenario) => {
|
||||
const canRun = !user || scenario.required_roles.includes(user.role);
|
||||
return (
|
||||
<article key={scenario.id} className="scenario-card">
|
||||
<header>
|
||||
<h2>{scenario.title}</h2>
|
||||
<span className={`badge ${scenario.ready ? "status-available" : "status-unavailable"}`}>
|
||||
{scenario.ready ? "Klaar voor demo" : "Niet beschikbaar"}
|
||||
</span>
|
||||
</header>
|
||||
<p className="scenario-problem">{scenario.operational_problem}</p>
|
||||
<dl className="scenario-meta">
|
||||
<div><dt>Duur</dt><dd>± {scenario.estimated_minutes} min</dd></div>
|
||||
<div><dt>Rol</dt><dd>{scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}</dd></div>
|
||||
</dl>
|
||||
<p className="scenario-demonstrates"><strong>Toont aan:</strong> {scenario.demonstrates}</p>
|
||||
{!scenario.ready && scenario.blocked_reason && (
|
||||
<p className="scenario-blocked"><Icon name="alert" /> {scenario.blocked_reason}</p>
|
||||
)}
|
||||
{!canRun && (
|
||||
<p className="scenario-blocked"><Icon name="alert" /> Vereist rol: {scenario.required_roles.map((r) => ROLE_LABEL[r]).join(" of ")}.</p>
|
||||
)}
|
||||
<Link
|
||||
className={`button ${scenario.ready && canRun ? "button-primary" : "button-secondary"}`}
|
||||
to={scenario.start_path}
|
||||
aria-disabled={!scenario.ready || !canRun}
|
||||
onClick={(event) => {
|
||||
if (!scenario.ready || !canRun) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
Start scenario <Icon name="chevron" />
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+50
-2
@@ -257,6 +257,21 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
|
||||
.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 170px; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }.integration-cards h2 { margin: 3px 0 7px; font-size: .95rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: .7rem; line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; }
|
||||
|
||||
.scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
|
||||
.scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.scenario-card header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.scenario-card h2 { margin: 0; font-size: 1rem; letter-spacing: -.015em; }
|
||||
.scenario-problem, .scenario-demonstrates { margin: 0; color: var(--muted); font-size: .78rem; line-height: 1.55; }
|
||||
.scenario-demonstrates strong { color: var(--ink-soft); }
|
||||
.scenario-meta { display: flex; gap: 20px; margin: 0; }
|
||||
.scenario-meta div { display: grid; gap: 2px; }
|
||||
.scenario-meta dt { color: var(--muted); font-size: .58rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; }
|
||||
.scenario-meta dd { margin: 0; color: var(--ink-soft); font-size: .76rem; font-weight: 600; }
|
||||
.scenario-blocked { display: flex; align-items: flex-start; gap: 6px; margin: 0; padding: 8px 10px; color: #8a5a12; background: #fdf3e2; border: 1px solid #f2ddb0; border-radius: 8px; font-size: .68rem; line-height: 1.45; }
|
||||
.scenario-blocked svg { width: 14px; flex-shrink: 0; margin-top: 1px; }
|
||||
.scenario-card > .button { align-self: flex-start; margin-top: auto; }
|
||||
.scenario-card > .button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
|
||||
|
||||
.knowledge-status { min-height: 58px; display: flex; align-items: center; gap: 11px; margin-bottom: 14px; padding: 10px 16px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.knowledge-status div { flex: 1; display: grid; gap: 3px; }.knowledge-status strong { font-size: .74rem; }.knowledge-status span:not(.health-orb), .knowledge-status small { color: var(--muted); font-size: .65rem; }.health-orb { width: 9px; height: 9px; border-radius: 50%; }.health-orb.is-healthy { background: var(--success); box-shadow: 0 0 0 4px var(--success-pale); }.health-orb.is-down { background: var(--critical); box-shadow: 0 0 0 4px var(--critical-pale); }
|
||||
.knowledge-form { margin-bottom: 18px; }.ask-heading { display: flex; gap: 11px; align-items: center; margin-bottom: 15px; }.ask-heading > span { width: 36px; height: 36px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.ask-heading svg { width: 18px; }.ask-heading h2 { margin: 0; font-size: .95rem; }.ask-heading p { margin: 3px 0 0; color: var(--muted); font-size: .65rem; }
|
||||
.knowledge-input-row { display: flex; gap: 8px; }.knowledge-input-row input { min-width: 0; flex: 1; }.knowledge-empty { min-height: 290px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 30px; color: var(--muted); background: white; border: 1px dashed var(--line-strong); border-radius: var(--radius); }.knowledge-empty > span { width: 50px; height: 50px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.knowledge-empty svg { width: 22px; }.knowledge-empty h2 { margin: 15px 0 7px; color: var(--ink); font-size: 1rem; }.knowledge-empty p { max-width: 540px; margin: 0; font-size: .75rem; line-height: 1.5; }
|
||||
@@ -278,17 +293,50 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.app-shell { grid-template-columns: 190px minmax(0, 1fr); }.sidebar { width: 190px; }.brand-lockup { padding-inline: 14px; }.readiness-band { grid-template-columns: 160px 1fr; }.readiness-band .inline-action { display: none; }.operations-grid { grid-template-columns: 1fr 1fr; }.timezone { display: none; }.review-facts { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
.demo-start-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 22px; padding: 16px 20px; background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: var(--radius); }
|
||||
.demo-start-panel > div:first-child { display: flex; align-items: center; gap: 12px; }
|
||||
.demo-start-panel > div:first-child > svg { width: 22px; height: 22px; color: var(--teal-dark); flex-shrink: 0; }
|
||||
.demo-start-panel strong { display: block; color: var(--ink); font-size: .85rem; }
|
||||
.demo-start-panel span { display: block; margin-top: 2px; color: var(--muted); font-size: .72rem; }
|
||||
.demo-start-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.demo-guide-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: .68rem; font-weight: 700; cursor: pointer; }
|
||||
.demo-guide-trigger:hover { background: #d9f0ea; }
|
||||
.demo-guide-trigger svg { width: 13px; height: 13px; }
|
||||
.demo-guide-progress-pill { padding: 1px 6px; color: var(--teal-dark); background: white; border-radius: 999px; font-size: .6rem; }
|
||||
|
||||
.demo-guide-panel { position: fixed; z-index: 40; top: 0; right: 0; width: min(400px, 92vw); height: 100vh; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: var(--surface); border-left: 1px solid var(--line); box-shadow: var(--shadow-float); overflow-y: auto; }
|
||||
.demo-guide-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.demo-guide-kicker { margin: 0 0 4px; color: var(--teal-dark); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.demo-guide-header h2 { margin: 0; font-size: 1.05rem; letter-spacing: -.015em; }
|
||||
.demo-guide-progress-bar { display: flex; gap: 4px; }
|
||||
.demo-guide-progress-bar span { flex: 1; height: 4px; background: var(--line); border-radius: 2px; }
|
||||
.demo-guide-progress-bar span.is-done { background: var(--teal); }
|
||||
.demo-guide-progress-bar span.is-current { background: var(--teal-dark); }
|
||||
.demo-guide-body { display: grid; gap: 12px; }
|
||||
.demo-guide-body p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
|
||||
.demo-guide-body p strong { color: var(--muted); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.demo-guide-steps { display: grid; gap: 3px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.demo-guide-steps button { display: flex; align-items: center; gap: 7px; padding: 7px 8px; text-align: left; color: var(--muted); background: transparent; border: 0; border-radius: var(--radius); font-size: .68rem; cursor: pointer; }
|
||||
.demo-guide-steps button svg { width: 12px; color: var(--teal); flex-shrink: 0; }
|
||||
.demo-guide-steps button:hover { background: var(--surface-subtle); }
|
||||
.demo-guide-steps button.is-current { color: var(--ink); background: var(--teal-pale); font-weight: 700; }
|
||||
.demo-guide-footer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.demo-guide-restart { width: 100%; padding: 8px; color: var(--muted); background: transparent; border: 1px dashed var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 600; cursor: pointer; }
|
||||
.demo-guide-restart:hover { color: var(--ink-soft); border-color: var(--muted-light); }
|
||||
.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 65px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .55rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 65px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
#main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
|
||||
#main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
|
||||
.filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; }
|
||||
.table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 34px; height: auto; display: grid; grid-template-columns: minmax(90px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 7px 12px; border: 0; text-align: right; font-size: .71rem; }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: .57rem; font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; }
|
||||
.tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; }
|
||||
.global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user