feat(search): add role-aware backend search and truthful n8n status
Two new endpoints. GET /api/v1/search returns bounded typed results (vehicle, booking, data-quality-issue, application section) instead of the frontend guessing routes from regex patterns against public-ref prefixes; data-quality and manager-only sections are filtered server-side by role, and customers are deliberately never returned since no customer detail route exists in this PoC. GET /api/v1/integrations/status aggregates outbox delivery counts (pending/delivering/succeeded/failed) into a single truthful n8n state (disabled/unavailable/degraded/operational/no_evidence) instead of the UI showing whichever status the single most recent event happened to be in -- a vehicle_status_conflict-style bug where one stale failure or one lucky success could misreport the dispatcher's actual health. Also fixes a real config gap this surfaced: MCP_HUB_REGISTRATION_ENABLED was documented in .env.example but had no corresponding Settings field, so it was silently ignored by pydantic-settings' extra="ignore" and never actually read anywhere in the codebase.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.schemas import (
|
||||
CurrentUser,
|
||||
IntegrationStatusOut,
|
||||
McpHubIntegrationStatus,
|
||||
N8nIntegrationStatus,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
counts: dict[str, int] = dict(
|
||||
db.execute(
|
||||
select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status)
|
||||
).all() # type: ignore[arg-type]
|
||||
)
|
||||
pending = counts.get("pending", 0)
|
||||
delivering = counts.get("delivering", 0)
|
||||
failed = counts.get("failed", 0)
|
||||
succeeded = counts.get("succeeded", 0)
|
||||
|
||||
latest_success_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
|
||||
)
|
||||
latest_failure_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed")
|
||||
)
|
||||
|
||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||
if not settings.n8n_dispatch_enabled:
|
||||
state = "disabled"
|
||||
elif failed > 0 and succeeded == 0:
|
||||
state = "unavailable"
|
||||
elif failed > 0:
|
||||
state = "degraded"
|
||||
elif succeeded > 0 or pending > 0 or delivering > 0:
|
||||
state = "operational"
|
||||
else:
|
||||
state = "no_evidence"
|
||||
|
||||
return N8nIntegrationStatus(
|
||||
configured=bool(settings.n8n_webhook_url),
|
||||
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||
state=state,
|
||||
pending=pending,
|
||||
delivering=delivering,
|
||||
failed=failed,
|
||||
succeeded=succeeded,
|
||||
latest_success_at=latest_success_at,
|
||||
latest_failure_at=latest_failure_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=IntegrationStatusOut)
|
||||
def integration_status(
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> IntegrationStatusOut:
|
||||
return IntegrationStatusOut(
|
||||
n8n=_n8n_status(db),
|
||||
mcp_hub=McpHubIntegrationStatus(
|
||||
registration_enabled=settings.mcp_hub_registration_enabled,
|
||||
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, SearchResponse, SearchResultItem
|
||||
|
||||
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
||||
|
||||
# Static application sections. Manager-only sections are filtered by role, mirroring the
|
||||
# same nav visibility rule Layout.tsx applies -- search must never surface a destination
|
||||
# the current role can't actually reach.
|
||||
_SECTIONS: list[dict] = [
|
||||
{
|
||||
"label": "Overview",
|
||||
"detail": "Operations dashboard",
|
||||
"link": "/dashboard",
|
||||
"terms": ["overview", "dashboard", "readiness"],
|
||||
},
|
||||
{
|
||||
"label": "Fleet",
|
||||
"detail": "Vehicle registry",
|
||||
"link": "/vehicles",
|
||||
"terms": ["fleet", "vehicle", "vehicles"],
|
||||
},
|
||||
{
|
||||
"label": "Bookings",
|
||||
"detail": "Rental bookings",
|
||||
"link": "/bookings",
|
||||
"terms": ["booking", "bookings", "rental"],
|
||||
},
|
||||
{
|
||||
"label": "Data quality",
|
||||
"detail": "Quality workbench",
|
||||
"link": "/data-quality",
|
||||
"terms": ["quality", "data quality", "issues"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
{
|
||||
"label": "Knowledge",
|
||||
"detail": "Procedure assistant",
|
||||
"link": "/knowledge",
|
||||
"terms": ["knowledge", "procedures"],
|
||||
},
|
||||
{
|
||||
"label": "Integrations",
|
||||
"detail": "Automation and integration status",
|
||||
"link": "/automation",
|
||||
"terms": ["automation", "integrations", "systems", "n8n"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
{
|
||||
"label": "Audit trail",
|
||||
"detail": "Audit history",
|
||||
"link": "/audit",
|
||||
"terms": ["audit", "history"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=SearchResponse)
|
||||
def search(
|
||||
q: str = Query(min_length=1, max_length=100),
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> SearchResponse:
|
||||
query = q.strip()
|
||||
normalized = query.lower()
|
||||
results: list[SearchResultItem] = []
|
||||
|
||||
for section in _SECTIONS:
|
||||
role = section.get("role")
|
||||
if role and user.role != role:
|
||||
continue
|
||||
terms: list[str] = section["terms"]
|
||||
if any(term in normalized or normalized in term for term in terms):
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="section",
|
||||
label=section["label"],
|
||||
detail=section["detail"],
|
||||
link=section["link"],
|
||||
)
|
||||
)
|
||||
|
||||
like = f"%{query}%"
|
||||
for v in db.scalars(
|
||||
select(Vehicle)
|
||||
.where(
|
||||
or_(
|
||||
Vehicle.public_ref.ilike(like),
|
||||
Vehicle.make.ilike(like),
|
||||
Vehicle.model.ilike(like),
|
||||
Vehicle.registration_number.ilike(like),
|
||||
Vehicle.location.ilike(like),
|
||||
)
|
||||
)
|
||||
.order_by(Vehicle.public_ref)
|
||||
.limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="vehicle",
|
||||
label=v.public_ref,
|
||||
detail=f"{v.make} {v.model} · {v.location}",
|
||||
link=f"/vehicles/{v.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
for b in db.scalars(
|
||||
select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="booking",
|
||||
label=b.public_ref,
|
||||
detail=b.status,
|
||||
link=f"/bookings/{b.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
# No customer detail route exists in this proof of concept, so customers are
|
||||
# deliberately never returned here -- there is nowhere useful to send the user.
|
||||
if user.role == "operations_manager":
|
||||
for i in db.scalars(
|
||||
select(DataQualityIssue)
|
||||
.where(DataQualityIssue.public_ref.ilike(like))
|
||||
.order_by(DataQualityIssue.detected_at.desc())
|
||||
.limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="data_quality_issue",
|
||||
label=i.public_ref,
|
||||
detail=i.rule_type.replace("_", " "),
|
||||
link=f"/data-quality/{i.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
return SearchResponse(query=query, results=results[:10])
|
||||
@@ -29,6 +29,7 @@ class Settings(BaseSettings):
|
||||
seed_dir: str = "/app/seed"
|
||||
knowledge_dir: str = "/app/knowledge/procedures"
|
||||
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||
mcp_hub_registration_enabled: bool = False
|
||||
cors_allow_origins: str = "http://localhost:1228"
|
||||
demo_today: str = "2026-08-01"
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ from app.api.routers import (
|
||||
dashboard,
|
||||
data_quality,
|
||||
demo,
|
||||
integration_status,
|
||||
integrations,
|
||||
knowledge,
|
||||
mcp_integrations,
|
||||
search,
|
||||
vehicles,
|
||||
workflows,
|
||||
)
|
||||
@@ -88,3 +90,5 @@ app.include_router(workflows.router)
|
||||
app.include_router(integrations.router)
|
||||
app.include_router(knowledge.router)
|
||||
app.include_router(mcp_integrations.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(integration_status.router)
|
||||
|
||||
@@ -163,6 +163,40 @@ class ApplyRecommendedStatusResult(BaseModel):
|
||||
reason: str
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
|
||||
label: str
|
||||
detail: str
|
||||
link: str
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
query: str
|
||||
results: list[SearchResultItem]
|
||||
|
||||
|
||||
class N8nIntegrationStatus(BaseModel):
|
||||
configured: bool
|
||||
dispatch_enabled: bool
|
||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||
pending: int
|
||||
delivering: int
|
||||
failed: int
|
||||
succeeded: int
|
||||
latest_success_at: datetime | None
|
||||
latest_failure_at: datetime | None
|
||||
|
||||
|
||||
class McpHubIntegrationStatus(BaseModel):
|
||||
registration_enabled: bool
|
||||
state: Literal["not_configured", "configured"]
|
||||
|
||||
|
||||
class IntegrationStatusOut(BaseModel):
|
||||
n8n: N8nIntegrationStatus
|
||||
mcp_hub: McpHubIntegrationStatus
|
||||
|
||||
|
||||
class VehicleDetailOut(VehicleOut):
|
||||
bookings: list[BookingSummaryOut] = Field(default_factory=list)
|
||||
inspections: list[InspectionOut] = Field(default_factory=list)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
def test_integration_status_requires_operations_manager(employee_client):
|
||||
response = employee_client.get("/api/v1/integrations/status")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_integration_status_requires_authentication(client):
|
||||
response = client.get("/api/v1/integrations/status")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_integration_status_reflects_seeded_mixed_outcomes(ops_client):
|
||||
response = ops_client.get("/api/v1/integrations/status")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
|
||||
n8n = body["n8n"]
|
||||
assert n8n["dispatch_enabled"] is True
|
||||
assert n8n["succeeded"] >= 1
|
||||
assert n8n["failed"] >= 1
|
||||
# The seed deliberately carries both failed and succeeded events, so a single most-
|
||||
# recent-event read would misreport health -- the aggregate must call this "degraded",
|
||||
# not "operational" or "unavailable".
|
||||
assert n8n["state"] == "degraded"
|
||||
assert n8n["latest_success_at"] is not None
|
||||
assert n8n["latest_failure_at"] is not None
|
||||
|
||||
mcp_hub = body["mcp_hub"]
|
||||
assert mcp_hub["registration_enabled"] is False
|
||||
assert mcp_hub["state"] == "not_configured"
|
||||
|
||||
|
||||
def test_integration_status_is_operational_once_all_failed_events_resolved(ops_client):
|
||||
failed = ops_client.get("/api/v1/workflows", params={"status": "failed"}).json()
|
||||
for run in failed:
|
||||
retried = ops_client.post(f"/api/v1/workflows/{run['event_id']}/retry")
|
||||
assert retried.status_code == 200
|
||||
|
||||
response = ops_client.get("/api/v1/integrations/status")
|
||||
body = response.json()["n8n"]
|
||||
assert body["failed"] == 0
|
||||
assert body["state"] == "operational"
|
||||
@@ -0,0 +1,57 @@
|
||||
def test_search_requires_authentication(client):
|
||||
response = client.get("/api/v1/search", params={"q": "MO-001"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_search_finds_a_vehicle_by_reference(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "MO-001"})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
match = next((r for r in body["results"] if r["type"] == "vehicle"), None)
|
||||
assert match is not None
|
||||
assert match["label"] == "MO-001"
|
||||
assert match["link"] == "/vehicles/MO-001"
|
||||
|
||||
|
||||
def test_search_finds_a_booking_by_reference(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "BK-DEMO-RETURN"})
|
||||
assert response.status_code == 200
|
||||
match = next((r for r in response.json()["results"] if r["type"] == "booking"), None)
|
||||
assert match is not None
|
||||
assert match["link"] == "/bookings/BK-DEMO-RETURN"
|
||||
|
||||
|
||||
def test_search_finds_a_data_quality_issue_for_operations_manager(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"})
|
||||
assert response.status_code == 200
|
||||
match = next((r for r in response.json()["results"] if r["type"] == "data_quality_issue"), None)
|
||||
assert match is not None
|
||||
assert match["link"] == "/data-quality/DQ-DEMO-OVERLAP"
|
||||
|
||||
|
||||
def test_search_never_returns_data_quality_issues_for_rental_employee(employee_client):
|
||||
response = employee_client.get("/api/v1/search", params={"q": "DQ-DEMO-OVERLAP"})
|
||||
assert response.status_code == 200
|
||||
assert all(r["type"] != "data_quality_issue" for r in response.json()["results"])
|
||||
|
||||
|
||||
def test_search_section_result_visible_to_operations_manager(ops_client):
|
||||
result = ops_client.get("/api/v1/search", params={"q": "audit"}).json()
|
||||
assert any(r["type"] == "section" and r["link"] == "/audit" for r in result["results"])
|
||||
|
||||
|
||||
def test_search_section_result_hidden_from_rental_employee(employee_client):
|
||||
result = employee_client.get("/api/v1/search", params={"q": "audit"}).json()
|
||||
assert all(r["link"] != "/audit" for r in result["results"])
|
||||
|
||||
|
||||
def test_search_never_returns_customer_results(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "CUS-0012"})
|
||||
assert response.status_code == 200
|
||||
assert all(r["type"] != "customer" for r in response.json()["results"])
|
||||
|
||||
|
||||
def test_search_no_match_returns_empty_results(ops_client):
|
||||
response = ops_client.get("/api/v1/search", params={"q": "zzz-no-such-thing-zzz"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["results"] == []
|
||||
@@ -20,16 +20,50 @@ test("control-centre shell exposes landmarks, persisted readiness and active nav
|
||||
await expect(page.getByRole("link", { name: "Overview" }).first()).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
test("global search supports its keyboard shortcut and public references", async ({ page }) => {
|
||||
test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => {
|
||||
await page.keyboard.press("Control+k");
|
||||
const search = page.getByRole("searchbox", { name: "Search MobilityOps" });
|
||||
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
|
||||
await expect(search).toBeFocused();
|
||||
await search.fill("MO-024");
|
||||
await search.press("Enter");
|
||||
const result = page.getByRole("option", { name: /MO-024/ });
|
||||
await expect(result).toBeVisible();
|
||||
await result.click();
|
||||
await expect(page).toHaveURL(/\/vehicles\/MO-024$/);
|
||||
await expect(page.getByRole("heading", { name: "MO-024" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("global search supports arrow-key navigation and Enter to select", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
|
||||
await search.fill("fleet");
|
||||
await expect(page.getByRole("option", { name: /Fleet/ })).toBeVisible();
|
||||
await search.press("ArrowDown");
|
||||
await search.press("Enter");
|
||||
await expect(page).toHaveURL(/\/vehicles$/);
|
||||
});
|
||||
|
||||
test("global search shows a no-results state and closes on Escape", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
|
||||
await search.fill("zzz-nothing-matches-zzz");
|
||||
await expect(page.getByText(/No matches for/)).toBeVisible();
|
||||
await search.press("Escape");
|
||||
await expect(page.getByRole("listbox")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("global search finds a booking and a data-quality issue by reference", async ({ page }) => {
|
||||
const search = page.getByRole("combobox", { name: "Search MobilityOps" });
|
||||
await search.fill("BK-DEMO-RETURN");
|
||||
const bookingResult = page.getByRole("option", { name: /BK-DEMO-RETURN/ });
|
||||
await expect(bookingResult).toBeVisible();
|
||||
await bookingResult.click();
|
||||
await expect(page).toHaveURL(/\/bookings\/BK-DEMO-RETURN$/);
|
||||
|
||||
await search.fill("DQ-DEMO-OVERLAP");
|
||||
const issueResult = page.getByRole("option", { name: /DQ-DEMO-OVERLAP/ });
|
||||
await expect(issueResult).toBeVisible();
|
||||
await issueResult.click();
|
||||
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-OVERLAP$/);
|
||||
});
|
||||
|
||||
test("return review separates capture from irreversible commit", async ({ page }) => {
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
await page.getByLabel("End odometer (km)").fill("60000");
|
||||
|
||||
@@ -180,6 +180,18 @@ export interface ScanResult {
|
||||
created: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
type: "vehicle" | "booking" | "data_quality_issue" | "section";
|
||||
label: string;
|
||||
detail: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
query: string;
|
||||
results: SearchResultItem[];
|
||||
}
|
||||
|
||||
export interface MergeCustomersRequest {
|
||||
survivor_ref: string;
|
||||
field_overrides?: Record<string, string>;
|
||||
@@ -218,6 +230,28 @@ export interface KnowledgeHealth {
|
||||
document_count: number;
|
||||
}
|
||||
|
||||
export interface N8nIntegrationStatus {
|
||||
configured: boolean;
|
||||
dispatch_enabled: boolean;
|
||||
state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence";
|
||||
pending: number;
|
||||
delivering: number;
|
||||
failed: number;
|
||||
succeeded: number;
|
||||
latest_success_at: string | null;
|
||||
latest_failure_at: string | null;
|
||||
}
|
||||
|
||||
export interface McpHubIntegrationStatus {
|
||||
registration_enabled: boolean;
|
||||
state: "not_configured" | "configured";
|
||||
}
|
||||
|
||||
export interface IntegrationStatus {
|
||||
n8n: N8nIntegrationStatus;
|
||||
mcp_hub: McpHubIntegrationStatus;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
actor_type: string;
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import type { Role } from "../api/types";
|
||||
import type { Role, SearchResultItem } from "../api/types";
|
||||
import { BrandMark, Icon, type IconName } from "./Icons";
|
||||
|
||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||
vehicle: "fleet",
|
||||
booking: "bookings",
|
||||
data_quality_issue: "quality",
|
||||
section: "chevron",
|
||||
};
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
@@ -32,23 +40,21 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
|
||||
},
|
||||
];
|
||||
|
||||
const SEARCH_DESTINATIONS = [
|
||||
{ to: "/dashboard", terms: ["overview", "dashboard", "readiness"] },
|
||||
{ to: "/vehicles", terms: ["fleet", "vehicle", "vehicles"] },
|
||||
{ to: "/bookings", terms: ["booking", "bookings", "rental"] },
|
||||
{ to: "/data-quality", terms: ["quality", "data quality", "issues"] },
|
||||
{ to: "/knowledge", terms: ["knowledge", "procedures"] },
|
||||
{ to: "/automation", terms: ["automation", "integrations", "systems", "n8n"] },
|
||||
{ to: "/audit", terms: ["audit", "history"] },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchStatus, setSearchStatus] = useState("");
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [resetConfirming, setResetConfirming] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
const searchInput = useRef<HTMLInputElement>(null);
|
||||
const searchBox = useRef<HTMLDivElement>(null);
|
||||
|
||||
const navGroups = useMemo(
|
||||
() =>
|
||||
@@ -72,38 +78,88 @@ export function Layout() {
|
||||
return () => window.removeEventListener("keydown", focusGlobalSearch);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setSearchResults([]);
|
||||
setSearchLoading(false);
|
||||
setSearchError(false);
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
setSearchLoading(true);
|
||||
setSearchError(false);
|
||||
const timeout = window.setTimeout(() => {
|
||||
api
|
||||
.get<{ query: string; results: SearchResultItem[] }>(
|
||||
`/api/v1/search?q=${encodeURIComponent(query)}`,
|
||||
)
|
||||
.then((response) => {
|
||||
setSearchResults(response.results);
|
||||
setActiveIndex(-1);
|
||||
})
|
||||
.catch(() => setSearchError(true))
|
||||
.finally(() => setSearchLoading(false));
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (searchBox.current && !searchBox.current.contains(event.target as Node)) {
|
||||
setSearchOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
function handleSearch(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const query = searchQuery.trim();
|
||||
if (!query) {
|
||||
setSearchStatus("Enter a section or a vehicle, booking or issue reference.");
|
||||
async function handleDemoReset() {
|
||||
setResetError(null);
|
||||
setResetting(true);
|
||||
try {
|
||||
await api.post("/api/v1/demo/reset");
|
||||
// The server invalidates the acting session as part of reset; drop local state the
|
||||
// same way an explicit logout would and return to the login screen.
|
||||
await logout();
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
setResetError(err instanceof ApiError ? err.message : "Could not reset demo data.");
|
||||
setResetConfirming(false);
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function selectResult(item: SearchResultItem) {
|
||||
setSearchOpen(false);
|
||||
setSearchQuery("");
|
||||
setSearchResults([]);
|
||||
navigate(item.link);
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Escape") {
|
||||
setSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
const publicRef = query.toUpperCase();
|
||||
let destination: string | undefined;
|
||||
|
||||
if (/^MO-\d+$/.test(publicRef)) destination = `/vehicles/${publicRef}`;
|
||||
else if (/^BK-[A-Z0-9-]+$/.test(publicRef)) destination = `/bookings/${publicRef}`;
|
||||
else if (/^DQ-[A-Z0-9-]+$/.test(publicRef)) destination = `/data-quality/${publicRef}`;
|
||||
else {
|
||||
const normalized = query.toLowerCase();
|
||||
destination = SEARCH_DESTINATIONS.find(({ terms }) =>
|
||||
terms.some((term) => term.includes(normalized) || normalized.includes(term)),
|
||||
)?.to;
|
||||
if (!searchOpen || searchResults.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % searchResults.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((i) => (i <= 0 ? searchResults.length - 1 : i - 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const target = searchResults[activeIndex] ?? searchResults[0];
|
||||
if (target) selectResult(target);
|
||||
}
|
||||
|
||||
if (destination) {
|
||||
setSearchStatus("");
|
||||
navigate(destination);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchStatus(`No destination found for ${query}. Try a vehicle, booking or issue reference.`);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -136,6 +192,26 @@ export function Layout() {
|
||||
<span className="environment-dot" />
|
||||
<div><strong>Demo environment</strong><span>Synthetic data only</span></div>
|
||||
</div>
|
||||
{user?.role === "operations_manager" && (
|
||||
<div className="sidebar-reset">
|
||||
{resetError && <p className="error" role="alert">{resetError}</p>}
|
||||
{!resetConfirming ? (
|
||||
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
|
||||
Reset demo data
|
||||
</button>
|
||||
) : (
|
||||
<div className="confirm-bar" role="alertdialog" aria-label="Confirm demo reset">
|
||||
<p>All synthetic changes will be discarded and deterministic demo data restored. You will be signed out.</p>
|
||||
<button type="button" onClick={handleDemoReset} disabled={resetting}>
|
||||
{resetting ? "Resetting…" : "Yes, reset"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setResetConfirming(false)} disabled={resetting}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{mobileOpen && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileOpen(false)} />}
|
||||
@@ -145,24 +221,58 @@ export function Layout() {
|
||||
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<form className="global-search" role="search" onSubmit={handleSearch}>
|
||||
<div className="global-search" role="search" ref={searchBox}>
|
||||
<Icon name="search" />
|
||||
<label className="visually-hidden" htmlFor="global-search-input">Search MobilityOps</label>
|
||||
<input
|
||||
id="global-search-input"
|
||||
ref={searchInput}
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-expanded={searchOpen}
|
||||
aria-controls="global-search-results"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeIndex >= 0 ? `search-result-${activeIndex}` : undefined}
|
||||
value={searchQuery}
|
||||
placeholder="Search fleet, booking or section…"
|
||||
aria-describedby="global-search-status"
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value);
|
||||
setSearchStatus("");
|
||||
setSearchOpen(true);
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
<kbd>Ctrl K</kbd>
|
||||
<span id="global-search-status" className="visually-hidden" aria-live="polite">{searchStatus}</span>
|
||||
</form>
|
||||
{searchOpen && searchQuery.trim() && (
|
||||
<div className="search-results" id="global-search-results" role="listbox">
|
||||
{searchLoading && <p className="search-status">Searching…</p>}
|
||||
{!searchLoading && searchError && <p className="search-status">Search is unavailable right now.</p>}
|
||||
{!searchLoading && !searchError && searchResults.length === 0 && (
|
||||
<p className="search-status">No matches for "{searchQuery.trim()}".</p>
|
||||
)}
|
||||
{!searchLoading &&
|
||||
!searchError &&
|
||||
searchResults.map((item, index) => (
|
||||
<button
|
||||
key={`${item.type}-${item.link}`}
|
||||
id={`search-result-${index}`}
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
type="button"
|
||||
className={`search-result ${index === activeIndex ? "is-active" : ""}`}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
onClick={() => selectResult(item)}
|
||||
>
|
||||
<Icon name={SEARCH_ICON[item.type]} />
|
||||
<span className="search-result-copy">
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.detail}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-meta">
|
||||
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
|
||||
{user && (
|
||||
|
||||
+13
-1
@@ -70,15 +70,27 @@ a:hover { color: var(--teal); }
|
||||
.sidebar-foot div { display: grid; gap: 2px; }
|
||||
.sidebar-foot strong { font-size: .72rem; color: #dbe5ef; }
|
||||
.sidebar-foot span:not(.environment-dot) { font-size: .66rem; color: #7f8da1; }
|
||||
.sidebar-reset { margin: 0 12px 12px; display: grid; gap: 8px; }
|
||||
.sidebar-reset .button { width: 100%; }
|
||||
.sidebar-reset .confirm-bar { background: var(--surface); border-radius: var(--radius); padding: 10px; display: grid; gap: 8px; }
|
||||
.sidebar-reset .confirm-bar p { margin: 0; font-size: .7rem; color: var(--ink-soft); }
|
||||
.environment-dot, .live-indicator { width: 8px; height: 8px; border-radius: 50%; background: #2dd4bf; box-shadow: 0 0 0 4px rgba(45, 212, 191, .12); }
|
||||
|
||||
.app-workspace { grid-column: 2; min-width: 0; min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.topbar { height: 64px; display: flex; align-items: center; gap: 20px; padding: 0 28px; background: rgba(255,255,255,.97); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
||||
.global-search { width: min(410px, 42vw); min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 10px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); }
|
||||
.global-search { position: relative; width: min(410px, 42vw); min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 10px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); }
|
||||
.global-search svg { width: 16px; }
|
||||
.global-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: .8rem; }
|
||||
.global-search:focus-within { border-color: var(--teal); box-shadow: 0 0 0 3px rgba(18, 132, 126, .14); }
|
||||
.global-search kbd { padding: 2px 5px; border: 1px solid var(--line); background: white; color: var(--muted); font-size: .64rem; border-radius: 3px; }
|
||||
.search-results { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 30; max-height: 60vh; overflow-y: auto; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); padding: 6px; }
|
||||
.search-status { margin: 0; padding: 10px 8px; font-size: .78rem; color: var(--muted); }
|
||||
.search-result { width: 100%; display: flex; align-items: center; gap: 10px; padding: 8px; border: 0; border-radius: var(--radius); background: transparent; color: var(--ink); text-align: left; cursor: pointer; font: inherit; }
|
||||
.search-result svg { width: 16px; flex-shrink: 0; color: var(--muted); }
|
||||
.search-result:hover, .search-result.is-active { background: var(--surface-subtle); }
|
||||
.search-result-copy { display: flex; flex-direction: column; min-width: 0; }
|
||||
.search-result-copy strong { font-size: .82rem; }
|
||||
.search-result-copy small { color: var(--muted); font-size: .72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 18px; }
|
||||
.timezone { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .72rem; white-space: nowrap; }
|
||||
.timezone svg { width: 15px; }
|
||||
|
||||
Reference in New Issue
Block a user