fix(deploy): reuse shared server n8n

This commit is contained in:
NuklearRabbit
2026-08-02 03:56:17 +02:00
parent 686b62f592
commit 1f292e14bb
12 changed files with 234 additions and 61 deletions
+10
View File
@@ -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");
+71 -5
View File
@@ -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<HTMLInputElement>(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<HTMLFormElement>) {
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 (
<div className="app-shell">
<a className="skip-link" href="#main-content">Skip to main content</a>
@@ -74,12 +128,24 @@ export function Layout() {
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
<Icon name="menu" />
</button>
<label className="global-search">
<form className="global-search" role="search" onSubmit={handleSearch}>
<Icon name="search" />
<span className="visually-hidden">Search MobilityOps</span>
<input type="search" placeholder="Search fleet, booking or customer…" />
<label className="visually-hidden" htmlFor="global-search-input">Search MobilityOps</label>
<input
id="global-search-input"
ref={searchInput}
type="search"
value={searchQuery}
placeholder="Search fleet, booking or section…"
aria-describedby="global-search-status"
onChange={(event) => {
setSearchQuery(event.target.value);
setSearchStatus("");
}}
/>
<kbd>Ctrl K</kbd>
</label>
<span id="global-search-status" className="visually-hidden" aria-live="polite">{searchStatus}</span>
</form>
<div className="topbar-meta">
<span className="timezone"><Icon name="clock" /> Europe/Brussels</span>
{user && (
+1
View File
@@ -77,6 +77,7 @@ a:hover { color: var(--teal); }
.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 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; }
.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; }