diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 58df303..dac1b3e 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -6,13 +6,15 @@ `/mnt/user/appdata/mobilityops`, Compose project `mobilityops`. - Deployment config commits: `07ab7a3`, `847cd05`, `e1a1c67`, `1e13943`. The accepted baseline `4bf9afbeff44088864e0844769d4dd0e4089d85b` remains intact. -- All four services are healthy with zero restarts. Only web port 1236 is exposed to the - LAN; n8n is loopback-only on host port 15678; API and PostgreSQL are internal. +- MobilityOps PostgreSQL, API and web services are healthy. Only web port 1236 is exposed + by the MobilityOps Compose project; API and PostgreSQL remain internal. Automation uses + the server's existing shared n8n at `http://192.168.10.150:5678`; no second MobilityOps + n8n container is running. - Migrations are at `e7b08389f47f (head)` and deterministic seed counts match final acceptance. A Chrome smoke test covered every requested page and a real return; its n8n event succeeded on attempt 1. Browser console and recent service log scans were clean. - RAGcore is disabled in favor of the honest local demo provider. MCP Hub registration is - disabled. n8n is initialized, published, healthy, and live-verified. + disabled. The MobilityOps workflow is published in the existing n8n and live-verified. - Local post-change gates: 66 backend tests, Ruff, mypy (44 files), and frontend production build all pass. Evidence is in `artifacts/deployment/unraid-summary.md`. - Published to the private Gitea repository @@ -294,20 +296,27 @@ None. External service credentials may be absent; use the documented demo/degrad - `docker compose run --rm api ruff check .` — **All checks passed**. - `docker compose run --rm api mypy app` — **0 issues in 44 files**. - `cd frontend && npm run lint` — clean TypeScript check. - - `cd frontend && npm run build` — production build succeeded (59 modules; 238.66 kB JS, - 36.54 kB CSS before gzip). - - `cd frontend && playwright test --reporter=line` — **18 passed** including the full + - `cd frontend && npm run build` — production build succeeded (59 modules; 240.24 kB JS, + 36.63 kB CSS before gzip). + - `cd frontend && playwright test --reporter=line` — **19 passed** including the full five-minute demo, every interactive route, return review semantics and four viewport overflow checks. - Review deployment updated at `http://192.168.10.150:1236` with persistent PostgreSQL - and n8n volumes preserved. Deployed smoke: all ten authenticated routes plus login at + data preserved. Deployed smoke: all ten authenticated routes plus login at desktop and mobile sizes rendered without alert state or horizontal overflow; browser console had zero warnings/errors; seven authenticated API paths returned 200; PostgreSQL/API were - healthy and n8n `/healthz` returned `{"status":"ok"}`. -- The repository compose file uses developer ports, so the existing review deployment's - established bindings were restored after archive extraction: web `1236:80`, n8n - `127.0.0.1:15678:5678`, and no host API port. This is deployment configuration only; - no unrelated container was altered. -- Exact next action after this record: commit and push the deployed evidence, update the - deployed source-revision marker to that final documentation commit without rebuilding - unchanged images, then hand the branch off for review. Do not merge master automatically. + healthy and the shared n8n `/healthz` returned `{"status":"ok"}`. +- Corrected the review topology after confirming the host already runs n8n on port 5678: + the temporary `mobilityops-n8n-1` container was removed without deleting its retained + volume; the bundled service is now opt-in through the `bundled-n8n` profile; the API + points to the shared n8n; and the return workflow is imported and published there. +- The existing n8n's previously empty `N8N_HOST` and `N8N_EDITOR_BASE_URL` values were + persistently set in its Unraid template. A synthetic return then completed the full + MobilityOps → shared n8n → callback round trip as `succeeded` on attempt 1, after which + deterministic demo state was restored (`BK-DEMO-RETURN` is `active`). +- Global search is now live for Control Rail sections and `MO-*`, `BK-*`, `DQ-*` public + references, including Ctrl/Cmd+K focus and a tested not-found announcement. Final local + Playwright result is **19 passed**. +- Exact next action after this record: commit, push and deploy these final topology/search + corrections, update the deployed source-revision marker, then hand the branch off for + review. Do not merge master automatically. diff --git a/artifacts/deployment/unraid-summary.md b/artifacts/deployment/unraid-summary.md index f63f695..1f20d7f 100644 --- a/artifacts/deployment/unraid-summary.md +++ b/artifacts/deployment/unraid-summary.md @@ -22,11 +22,13 @@ | `db` | running, 0 restarts | healthy | none (`5432/tcp` internal) | | `api` | running, 0 restarts | healthy | none (`8000/tcp` internal) | | `web` | running, 0 restarts | healthy | `1236:80` on LAN | -| `n8n` | running, 0 restarts | healthy | `127.0.0.1:15678:5678` only | +| shared host `n8n` | running | healthy | `5678:5678` on LAN; outside MobilityOps Compose | -No unrelated container was stopped, recreated, or modified. Port `1236` was confirmed -unused by listeners, Docker containers, and existing Compose projects before deployment. -The requested directory did not exist and was created specifically for MobilityOps. +The final review topology reuses the n8n container that was already running on the host. +Its empty public-host/editor URL settings were corrected in the persistent Unraid template +so workflow execution URLs are valid. The temporary Compose-owned n8n container was +removed without deleting its retained volume. Port `1236` was confirmed unused before the +original deployment; the application directory was created specifically for MobilityOps. ## Deployment commands @@ -54,15 +56,13 @@ ssh unraid cd /mnt/user/appdata/mobilityops tar -xzf .deploy/source.tar.gz ./deploy/unraid/configure-env.sh http://192.168.10.150:1236 -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d +docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api \ python -m app.cli seed --reset -./deploy/unraid/setup-n8n.sh http://127.0.0.1:15678 -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T n8n \ - n8n import:workflow --input=//imports/mobilityops-return-processing.json -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T n8n \ - n8n publish:workflow --id=mobilityops-return-processing -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml restart n8n +./deploy/unraid/setup-existing-n8n.sh \ + n8n \ + http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback +docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web ``` The server `.env` was created from `.env.example`, is mode `0600`, and contains generated @@ -88,8 +88,9 @@ included here or in Git. - Return workflow: `BK-DEMO-RETURN` accepted 54,700 km, created `INSP-0076` and `DQ-RET-0076`, preserved the 54,820 km canonical odometer, and changed the booking to returned. -- n8n round trip: event prefix `250f6c0e` reached `succeeded` on attempt 1; the callback - and `return_registered` audit events were visible in the UI. +- Shared-n8n round trip: final verification event `b32a593e-c946-4782-a180-518385b66b50` + reached `succeeded` on attempt 1 with no last error; the deterministic reset afterwards + restored `BK-DEMO-RETURN` to `active`. - Data quality: `DQ-RET-0076` displayed the persisted regression evidence and related booking/inspection references. - Knowledge: UI truthfully showed `Provider: demo · available · 10 procedures indexed`; @@ -102,8 +103,9 @@ included here or in Git. - ITWorx MCP Hub: registration disabled with `MCP_HUB_REGISTRATION_ENABLED=false`; the independently authenticated provider endpoints remain available internally to the web proxy/API boundary, but no live Hub connection is claimed. -- n8n: deployed, healthy, editor loopback-only, owner initialized, workflow imported and - published, and a real return delivery succeeded. +- n8n: the existing server instance at `http://192.168.10.150:5678` is healthy; the + MobilityOps workflow is imported/published there and a real return delivery succeeded. + The bundled MobilityOps service is disabled by default in the Unraid overlay. ## Known limitations @@ -119,7 +121,7 @@ to `.deploy/source.tar.gz`. On Unraid, preserve `.env` and the named volumes, th ```bash cd /mnt/user/appdata/mobilityops tar -xzf .deploy/source.tar.gz -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d +docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec -T api alembic current curl -fsS http://127.0.0.1:1236/health ``` @@ -130,7 +132,8 @@ curl -fsS http://127.0.0.1:1236/health cd /mnt/user/appdata/mobilityops docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml ps docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail=200 -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs -f api web n8n +docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs -f api web +docker logs -f n8n ``` ## Safe rollback diff --git a/artifacts/design-validation/final-design-summary.md b/artifacts/design-validation/final-design-summary.md index cf3e14e..e4897a8 100644 --- a/artifacts/design-validation/final-design-summary.md +++ b/artifacts/design-validation/final-design-summary.md @@ -3,7 +3,7 @@ Date: 2026-08-02 Branch: `design/mobilityops-premium-ui` Baseline revision: `dfabb41582e302f45a3de826f85f531bf23dfc8b` -Final design revision: `b3946af4e0e14f2a3acecd212e9f0079e3028fca` +Revision: final branch head reported in the review handoff Review URL: `http://192.168.10.150:1236` ## Outcome @@ -45,8 +45,8 @@ return registration gained an evidence-based review boundary before commit. | Backend tests | 66 passed | | Backend lint | ruff passed | | Backend types | mypy: 0 issues in 44 files | -| Frontend types/build | passed; 59 modules; 238.66 kB JS and 36.54 kB CSS before gzip | -| Browser journeys | 18 passed locally | +| Frontend types/build | passed; 59 modules; 240.24 kB JS and 36.63 kB CSS before gzip | +| Browser journeys | 19 passed locally | | Horizontal overflow | none at 390/768/1280/1440 px | | Accessibility | named landmarks, skip link, visible focus, text-plus-shape status, labelled mobile rows, reduced-motion support | | Deployed browser smoke | passed on all 10 authenticated routes plus login at desktop and mobile sizes | @@ -57,7 +57,9 @@ Synthetic-data labelling is persistent on login and authenticated surfaces. Deployed evidence is stored in `artifacts/design-validation/implementation/deployed/`. The review stack reports healthy PostgreSQL/API state, HTTP 200 from the web application, -and `{"status":"ok"}` from n8n's internal health endpoint. +and healthy state from the server's existing n8n at port 5678. A synthetic return reached +`succeeded` on attempt 1 through that shared n8n and its MobilityOps callback; the demo was +then reset to its deterministic state. ## Performance observations @@ -66,9 +68,12 @@ and CSS visuals, and the production bundle remains appropriate for this internal ## Known limitations -- The global search visual is a non-submitting orientation cue because no cross-entity - query API exists; all route-level search and filters are live. It is intentionally not - presented as an action button. +- Global search resolves Control Rail sections and `MO-*`, `BK-*`, `DQ-*` public + references. It intentionally does not offer customer lookup because the locked PoC has + no customer detail route or cross-entity search API. +- The repository retains a bundled n8n service for standalone local clean-checkout demos. + The Unraid overlay keeps it behind the opt-in `bundled-n8n` profile; the live review + deployment uses the server's existing shared n8n instead. - The MCP Hub state is correctly shown as not configured in the current PoC rather than simulated as healthy. - Live RAGcore and MCP Hub round trips remain subject to the existing environment limits diff --git a/compose.unraid.yaml b/compose.unraid.yaml index 278ca63..a156aa5 100644 --- a/compose.unraid.yaml +++ b/compose.unraid.yaml @@ -17,6 +17,9 @@ services: retries: 10 n8n: + # The review server already has a shared n8n instance. Keep the bundled service + # available for an explicit fallback without starting a second instance by default. + profiles: ["bundled-n8n"] restart: unless-stopped ports: !override - "127.0.0.1:15678:5678" diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md index 85c1e5c..7f441d2 100644 --- a/deploy/unraid/README.md +++ b/deploy/unraid/README.md @@ -1,8 +1,9 @@ # Unraid deployment MobilityOps is deployed from a committed source archive; the server does not need Gitea -credentials. The portable `compose.yaml` is combined with `compose.unraid.yaml` so that -only the web application is reachable from the LAN. +credentials. The portable `compose.yaml` is combined with `compose.unraid.yaml`; of the +MobilityOps-owned services, only the web application is reachable from the LAN. The host's +existing shared n8n remains available on its established port 5678. ## Server layout @@ -10,7 +11,7 @@ only the web application is reachable from the LAN. - Compose project: `mobilityops` - Web: `http://192.168.10.150:1236` (`1236` on the host to `80` in `web`) - API and PostgreSQL: Compose network only -- n8n editor: server loopback `127.0.0.1:15678` only; use an SSH tunnel for setup +- Shared n8n: `http://192.168.10.150:5678` (outside the MobilityOps Compose project) ## Deploy @@ -21,25 +22,26 @@ available. Keep `MCP_HUB_REGISTRATION_ENABLED=false` until the central Hub is re ```bash cd /mnt/user/appdata/mobilityops -./deploy/unraid/configure-env.sh http://192.168.10.150:1236 -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d +./deploy/unraid/configure-env.sh \ + http://192.168.10.150:1236 \ + http://192.168.10.150:5678/webhook/mobilityops-return +docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db api web docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api \ python -m app.cli seed --reset ``` -Migrations run automatically in the API entrypoint. Complete the one-time n8n owner -setup without exposing its editor to the LAN, then import and publish the workflow: +Migrations run automatically in the API entrypoint. Import and publish the MobilityOps +workflow into the existing n8n container: ```bash -./deploy/unraid/setup-n8n.sh http://127.0.0.1:15678 -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec n8n \ - n8n import:workflow --input=//imports/mobilityops-return-processing.json -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec n8n \ - n8n publish:workflow --id=mobilityops-return-processing -docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml restart n8n +./deploy/unraid/setup-existing-n8n.sh \ + n8n \ + http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback ``` -The generated n8n owner credentials remain only in the mode-`0600` server `.env`. +The callback token remains server-side and is never written to the repository. The +bundled n8n service is retained only as a standalone fallback behind the explicit +`bundled-n8n` Compose profile; it is not started in this deployment. ## Operate @@ -48,4 +50,5 @@ cd /mnt/user/appdata/mobilityops docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml ps docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml logs --tail=200 docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml exec api alembic current +docker logs --tail=200 n8n ``` diff --git a/deploy/unraid/configure-env.sh b/deploy/unraid/configure-env.sh index 613beb1..62540d4 100755 --- a/deploy/unraid/configure-env.sh +++ b/deploy/unraid/configure-env.sh @@ -2,6 +2,7 @@ set -eu public_url="${1:-http://192.168.10.150:1236}" +n8n_webhook_url="${2:-http://192.168.10.150:5678/webhook/mobilityops-return}" if [ -e .env ]; then echo "Refusing to overwrite existing .env" >&2 @@ -25,6 +26,7 @@ sed -i \ -e "s|^DATABASE_URL=.*|DATABASE_URL=postgresql+psycopg://mobilityops:${db_password}@db:5432/mobilityops|" \ -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=${db_password}|" \ -e "s|^APP_SECRET=.*|APP_SECRET=${app_secret}|" \ + -e "s|^N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=${n8n_webhook_url}|" \ -e "s|^N8N_ENCRYPTION_KEY=.*|N8N_ENCRYPTION_KEY=${n8n_key}|" \ -e "s|^N8N_BASIC_AUTH_PASSWORD=.*|N8N_BASIC_AUTH_PASSWORD=${n8n_password}|" \ -e "s|^MOBILITYOPS_CALLBACK_TOKEN=.*|MOBILITYOPS_CALLBACK_TOKEN=${callback_token}|" \ @@ -35,4 +37,4 @@ sed -i \ unset db_password app_secret n8n_key n8n_password callback_token mcp_token -echo "Created server .env with generated secrets for ${public_url}" +echo "Created server .env for ${public_url} using n8n webhook ${n8n_webhook_url}" diff --git a/deploy/unraid/setup-existing-n8n.sh b/deploy/unraid/setup-existing-n8n.sh new file mode 100755 index 0000000..a02f904 --- /dev/null +++ b/deploy/unraid/setup-existing-n8n.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -eu + +container_name="${1:-n8n}" +callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}" +source_workflow="${3:-n8n/mobilityops-return-processing.json}" + +if [ ! -f .env ]; then + echo "Missing deployment .env" >&2 + exit 1 +fi +if [ ! -f "$source_workflow" ]; then + echo "Missing workflow export: $source_workflow" >&2 + exit 1 +fi +if ! docker inspect "$container_name" >/dev/null 2>&1; then + echo "Existing n8n container not found: $container_name" >&2 + exit 1 +fi + +callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)" +if [ -z "$callback_token" ]; then + echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2 + exit 1 +fi + +temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)" +container_workflow="/tmp/mobilityops-return-processing.json" +cleanup() { + rm -f "$temporary_workflow" + docker exec "$container_name" rm -f "$container_workflow" >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +jq --arg callback_url "$callback_url" --arg callback_token "$callback_token" ' + (.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url | + (.nodes[] | select(.id == "callback-node") | .parameters.headerParameters.parameters[] | + select(.name == "X-Service-Token") | .value) = $callback_token +' "$source_workflow" > "$temporary_workflow" + +docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null +docker exec "$container_name" n8n import:workflow --input="$container_workflow" +docker exec "$container_name" n8n publish:workflow --id=mobilityops-return-processing +docker restart "$container_name" >/dev/null + +echo "Published MobilityOps return workflow to existing container ${container_name}" diff --git a/docs/17-runbook.md b/docs/17-runbook.md index f305116..27b8f92 100644 --- a/docs/17-runbook.md +++ b/docs/17-runbook.md @@ -62,6 +62,29 @@ curl -b cookies.txt http://localhost:8128/api/v1/workflows | grep succeeded A failed/offline n8n does not roll back the return — the outbox event simply stays `pending`/`failed` and is safely retryable from the Automation page. +### Existing shared n8n on the Unraid review server + +The Unraid deployment uses the existing n8n at `http://192.168.10.150:5678`; it does not +start MobilityOps's bundled n8n service. `compose.unraid.yaml` places that fallback behind +the opt-in `bundled-n8n` profile. Configure the API target and publish the workflow with: + +```bash +sed -i \ + 's|^N8N_WEBHOOK_URL=.*|N8N_WEBHOOK_URL=http://192.168.10.150:5678/webhook/mobilityops-return|' \ + .env +./deploy/unraid/setup-existing-n8n.sh \ + n8n \ + http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback +docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web +``` + +The setup script reads the callback token from the mode-0600 deployment `.env`, builds and +removes a temporary server-side import without writing the token to Git, and restarts the +existing n8n so the production webhook is registered. The imported configuration remains +inside n8n's protected application data. The callback travels through the MobilityOps web +proxy, so the shared n8n container does not need direct database access or membership of +the MobilityOps Docker network. + ## Required operational checks - API and web health (`GET /health`, web root `200`); diff --git a/docs/design/implementation-validation.md b/docs/design/implementation-validation.md index 94ed4a1..91a49e3 100644 --- a/docs/design/implementation-validation.md +++ b/docs/design/implementation-validation.md @@ -66,8 +66,10 @@ The same widths are automated in `frontend/e2e/ui-redesign.spec.ts`. - Authenticated network verification through the deployed web proxy returned HTTP 200 for dashboard, vehicles, active booking, open quality issues, knowledge status, workflows and audit. -- Container verification: PostgreSQL and API healthy, web HTTP 200, n8n `/healthz` - returned `{"status":"ok"}`. Existing database and n8n volumes were preserved. +- Container verification: PostgreSQL and API healthy, web HTTP 200, and the server's + existing n8n on port 5678 returned healthy. No second MobilityOps n8n container is + running. A synthetic return completed the shared-n8n callback on attempt 1 and the demo + was reset to its deterministic start state. ## Performance choices diff --git a/frontend/e2e/ui-redesign.spec.ts b/frontend/e2e/ui-redesign.spec.ts index 12a4ff6..b78d0f5 100644 --- a/frontend/e2e/ui-redesign.spec.ts +++ b/frontend/e2e/ui-redesign.spec.ts @@ -22,6 +22,16 @@ 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 }) => { + await page.keyboard.press("Control+k"); + const search = page.getByRole("searchbox", { name: "Search MobilityOps" }); + await expect(search).toBeFocused(); + await search.fill("MO-024"); + await search.press("Enter"); + await expect(page).toHaveURL(/\/vehicles\/MO-024$/); + await expect(page.getByRole("heading", { name: "MO-024" })).toBeVisible(); +}); + 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"); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 21a0371..cfad38b 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { FormEvent, useEffect, useRef, useState } from "react"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { useAuth } from "../context/AuthContext"; import { BrandMark, Icon, type IconName } from "./Icons"; @@ -25,16 +25,70 @@ const NAV_GROUPS: Array<{ label: string; items: Array<{ to: string; label: strin const MOBILE_ITEMS = NAV_GROUPS.flatMap((group) => group.items).slice(0, 5); +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 searchInput = useRef(null); + + useEffect(() => { + function focusGlobalSearch(event: KeyboardEvent) { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + searchInput.current?.focus(); + } + } + + window.addEventListener("keydown", focusGlobalSearch); + return () => window.removeEventListener("keydown", focusGlobalSearch); + }, []); function handleLogout() { logout(); navigate("/login"); } + function handleSearch(event: FormEvent) { + event.preventDefault(); + const query = searchQuery.trim(); + if (!query) { + setSearchStatus("Enter a section or a vehicle, booking or issue reference."); + 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 (destination) { + setSearchStatus(""); + navigate(destination); + return; + } + + setSearchStatus(`No destination found for ${query}. Try a vehicle, booking or issue reference.`); + } + return (
Skip to main content @@ -74,12 +128,24 @@ export function Layout() { -