M39: harden application and acceptance gates
MobilityOps acceptance / backend (push) Failing after 45s
MobilityOps acceptance / frontend (push) Successful in 32s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-17 03:17:44 +02:00
parent a9f48d6880
commit ae39a8947f
62 changed files with 5277 additions and 202 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
playwright-report
test-results
e2e
tsconfig.tsbuildinfo
.env*
+4
View File
@@ -4,6 +4,10 @@ COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
RUN npm ci --no-audit --no-fund
COPY public ./public
COPY src ./src
# Vite inlines VITE_* variables at build time; leave empty (default) so the SPA talks to
# the same origin and nginx proxies /api to the backend.
ARG VITE_API_BASE_URL=""
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build
FROM nginx:1.27-alpine
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import jsxA11y from "eslint-plugin-jsx-a11y";
import globals from "globals";
export default tseslint.config(
{ ignores: ["dist/", "playwright-report/", "test-results/", "node_modules/"] },
js.configs.recommended,
...tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
jsxA11y.flatConfigs.recommended,
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: { globals: { ...globals.browser } },
rules: {
// Hook dependency mistakes are real bugs in this codebase's data-loading effects.
"react-hooks/exhaustive-deps": "error",
// The React Compiler-oriented rules in react-hooks v7 flag the classic
// "reset state, then fetch in an effect" data-loading pattern this React 18 app
// uses deliberately; they are not bugs here.
"react-hooks/set-state-in-effect": "off",
"react-hooks/purity": "off",
"react-hooks/refs": "off",
// Choice cards wrap the input and put the visible text two spans deep.
"jsx-a11y/label-has-associated-control": ["error", { depth: 3 }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
},
},
{
files: ["e2e/**/*.ts", "*.config.ts"],
languageOptions: { globals: { ...globals.node } },
},
);
+12 -2
View File
@@ -7,6 +7,13 @@ server {
root /usr/share/nginx/html;
index index.html;
server_tokens off;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/css application/javascript application/json image/svg+xml font/woff2;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
@@ -44,8 +51,11 @@ server {
try_files $uri /index.html;
}
location ~* ^/assets/.+\.[a-fA-F0-9_-]+\.(css|js|woff2|png|svg)$ {
expires 1y;
# Vite emits content-hashed filenames like "Bookings-BXgkh2jX.js" (hyphen + 8 chars,
# base64url alphabet, one dot); anything under /assets/ matching that is immutable.
location ~* "^/assets/.+-[A-Za-z0-9_-]{8,}\.(css|js|woff2|png|svg)$" {
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
}
+3796 -32
View File
File diff suppressed because it is too large Load Diff
+12 -3
View File
@@ -7,22 +7,31 @@
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0",
"lint": "tsc -b --noEmit",
"lint": "tsc -b --noEmit && eslint .",
"test:e2e": "playwright test"
},
"dependencies": {
"i18next": "^26.3.6",
"i18next": "26.3.6",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-i18next": "^17.0.11",
"react-i18next": "17.0.11",
"react-router-dom": "7.18.2"
},
"devDependencies": {
"@eslint/js": "9.39.5",
"@playwright/test": "1.62.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.5",
"eslint": "9.39.5",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.11.0",
"typescript": "5.6.3",
"typescript-eslint": "8.67.0",
"vite": "8.2.1"
},
"engines": {
"node": ">=22"
}
}
+3
View File
@@ -11,6 +11,9 @@ export default defineConfig({
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
use: {
baseURL: process.env.MOBILITYOPS_PUBLIC_URL ?? "http://localhost:1228",
// Optional: point at a pre-installed Chromium (CI images, sandboxes) instead of the
// Playwright-managed download.
...(process.env.PW_CHROMIUM_PATH ? { launchOptions: { executablePath: process.env.PW_CHROMIUM_PATH } } : {}),
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
+13 -1
View File
@@ -14,11 +14,23 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
const DEFAULT_TIMEOUT_MS = 15_000;
/** `AbortSignal.any` with a fallback for browsers that predate it (Safari < 17.4, etc.). */
function combineSignals(caller: AbortSignal, timeout: AbortSignal): AbortSignal {
if (typeof AbortSignal.any === "function") return AbortSignal.any([caller, timeout]);
const combined = new AbortController();
const forward = (source: AbortSignal) => () => combined.abort(source.reason);
if (caller.aborted) combined.abort(caller.reason);
else caller.addEventListener("abort", forward(caller), { once: true });
if (timeout.aborted) combined.abort(timeout.reason);
else timeout.addEventListener("abort", forward(timeout), { once: true });
return combined.signal;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const timeoutController = new AbortController();
const timeout = window.setTimeout(() => timeoutController.abort("timeout"), DEFAULT_TIMEOUT_MS);
const signal = init?.signal
? AbortSignal.any([init.signal, timeoutController.signal])
? combineSignals(init.signal, timeoutController.signal)
: timeoutController.signal;
let response: Response;
try {
+1
View File
@@ -19,6 +19,7 @@ export const KNOWN_CODES = new Set([
"VEHICLE_NOT_FOUND",
"BOOKING_NOT_FOUND",
"CUSTOMER_NOT_FOUND",
"CUSTOMER_ALREADY_MERGED",
"ENTITY_NOT_FOUND",
"EVENT_NOT_FOUND",
"ISSUE_NOT_FOUND",
+1 -1
View File
@@ -91,7 +91,7 @@ export function DemoGuide() {
}
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide]);
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide, setCollapsedToChip]);
useEffect(() => {
if (!pendingTarget.current) return;
+15 -3
View File
@@ -128,19 +128,31 @@ export function Layout() {
}
setSearchLoading(true);
setSearchError(false);
// Abort the in-flight request when the query changes so a slow response for "a"
// can never overwrite the results for "ab" (or flip the loading state early).
const controller = new AbortController();
const timeout = window.setTimeout(() => {
api
.get<{ query: string; results: SearchResultItem[] }>(
`/api/v1/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal },
)
.then((response) => {
if (controller.signal.aborted) return;
setSearchResults(response.results);
setActiveIndex(-1);
})
.catch(() => setSearchError(true))
.finally(() => setSearchLoading(false));
.catch(() => {
if (!controller.signal.aborted) setSearchError(true);
})
.finally(() => {
if (!controller.signal.aborted) setSearchLoading(false);
});
}, 250);
return () => window.clearTimeout(timeout);
return () => {
window.clearTimeout(timeout);
controller.abort();
};
}, [searchQuery]);
useEffect(() => {
+8 -2
View File
@@ -18,8 +18,14 @@ const AuthContext = createContext<AuthState | undefined>(undefined);
const STORAGE_KEY = "mobilityops.demo-user";
function readCachedUser(): CurrentUser | null {
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as CurrentUser) : null;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as CurrentUser) : null;
} catch {
// A corrupted or blocked sessionStorage must never prevent the app from booting;
// the session endpoint remains the source of truth.
return null;
}
}
function cacheUser(user: CurrentUser | null) {
+1 -1
View File
@@ -35,5 +35,5 @@ export const DEMO_GUIDE_STEPS: DemoGuideStep[] = [
},
{ id: "ask-knowledge", route: () => "/knowledge", target: "#ask-heading" },
{ id: "check-automation-audit", route: () => "/automation", target: ".integration-cards" },
{ id: "review-real-vs-simulated", route: () => "/about", target: ".about-cta" },
{ id: "review-real-vs-simulated", route: () => "/about", target: ".engineering-hero" },
];
+12
View File
@@ -44,3 +44,15 @@ export function brusselsLocalToIso(value: string): string {
}
return new Date(candidate).toISOString();
}
/** Start of the given Brussels calendar day (YYYY-MM-DD) as a UTC ISO instant. */
export function brusselsDayStartIso(day: string): string {
return brusselsLocalToIso(`${day}T00:00`);
}
/** Start of the day *after* the given Brussels calendar day, i.e. an exclusive upper bound. */
export function brusselsNextDayStartIso(day: string): string {
const [year, month, date] = day.split("-").map(Number);
const next = new Date(Date.UTC(year, month - 1, date + 1));
return brusselsLocalToIso(`${next.toISOString().slice(0, 10)}T00:00`);
}
@@ -16,6 +16,11 @@
"title": "Customer not found",
"explanation": "This customer record could not be found."
},
"CUSTOMER_ALREADY_MERGED": {
"title": "Customer already merged",
"explanation": "One of these customer records has already been merged into another record.",
"nextStep": "Reload the issue; it may already be resolved."
},
"ENTITY_NOT_FOUND": {
"title": "Record not found",
"explanation": "The underlying record for this action could not be found."
@@ -16,6 +16,11 @@
"title": "Client introuvable",
"explanation": "Cette fiche client est introuvable."
},
"CUSTOMER_ALREADY_MERGED": {
"title": "Client déjà fusionné",
"explanation": "L'une de ces fiches client a déjà été fusionnée avec une autre fiche.",
"nextStep": "Rechargez le problème ; il est peut-être déjà résolu."
},
"ENTITY_NOT_FOUND": {
"title": "Fiche introuvable",
"explanation": "La fiche sous-jacente à cette action est introuvable."
@@ -16,6 +16,11 @@
"title": "Klant niet gevonden",
"explanation": "Dit klantrecord kon niet gevonden worden."
},
"CUSTOMER_ALREADY_MERGED": {
"title": "Klant al samengevoegd",
"explanation": "Eén van deze klantrecords is al samengevoegd met een ander record.",
"nextStep": "Herlaad het probleem; mogelijk is het al opgelost."
},
"ENTITY_NOT_FOUND": {
"title": "Record niet gevonden",
"explanation": "Het onderliggende record voor deze actie kon niet gevonden worden."
+13 -4
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { AuditEvent, Page } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { brusselsDayStartIso, brusselsNextDayStartIso } from "../i18n/brusselsDateTime";
import { useLocaleFormat } from "../i18n/format";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
@@ -78,7 +79,7 @@ export function Audit() {
if (value === null || value === "") next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
setSearchParams(next, { replace: true });
}
useEffect(() => {
@@ -90,9 +91,17 @@ export function Audit() {
if (actor) params.set("actor_label", actor);
if (entityRef) params.set("entity_ref", entityRef);
if (correlationId) params.set("correlation_id", correlationId);
if (from) params.set("occurred_from", `${from}T00:00:00Z`);
if (to) params.set("occurred_to", `${to}T23:59:59Z`);
api.get<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`).then(setEvents).catch(() => setError(t("unavailable")));
// Date filters are Brussels calendar days, not UTC midnight boundaries.
if (from) params.set("occurred_from", brusselsDayStartIso(from));
if (to) params.set("occurred_to", brusselsNextDayStartIso(to));
const controller = new AbortController();
api
.get<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`, { signal: controller.signal })
.then(setEvents)
.catch(() => {
if (!controller.signal.aborted) setError(t("unavailable"));
});
return () => controller.abort();
}, [action, actor, correlationId, entityRef, from, page, t, to, user]);
const groups = useMemo<EventGroup[]>(() => {
+10 -5
View File
@@ -27,6 +27,7 @@ export function BookingDetail() {
const [cancelReason, setCancelReason] = useState("");
const [cancelling, setCancelling] = useState(false);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [actionErrorSource, setActionErrorSource] = useState<"cancel" | "requirements" | "reschedule" | null>(null);
const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
const [requirementsConfirmation, setRequirementsConfirmation] = useState("");
const [confirmingRequirements, setConfirmingRequirements] = useState(false);
@@ -41,7 +42,7 @@ export function BookingDetail() {
.get<Booking>(`/api/v1/bookings/${publicRef}`)
.then(setBooking)
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
}, [publicRef, t]);
useEffect(() => {
setBooking(null);
@@ -54,7 +55,7 @@ export function BookingDetail() {
if (!booking) return;
setScheduleStart(toBrusselsDateTimeLocal(new Date(booking.starts_at)));
setScheduleEnd(toBrusselsDateTimeLocal(new Date(booking.ends_at)));
}, [booking?.public_ref]);
}, [booking]);
// The odometer-regression demo scenario supplies its own suspicious reading (per the
// brief: never ask a demo visitor to invent one) -- only fetched for that one known
@@ -87,6 +88,7 @@ export function BookingDetail() {
setCancelReason("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.cancelFailed"));
setActionErrorSource("cancel");
} finally {
setCancelling(false);
}
@@ -110,6 +112,7 @@ export function BookingDetail() {
setRequirementsConfirmation("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.requirementsFailed"));
setActionErrorSource("requirements");
} finally {
setConfirmingRequirements(false);
}
@@ -130,6 +133,7 @@ export function BookingDetail() {
setScheduleReason("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.rescheduleFailed"));
setActionErrorSource("reschedule");
} finally {
setRescheduling(false);
}
@@ -158,7 +162,7 @@ export function BookingDetail() {
<h2>{t("detail.requirementsAction")}</h2>
<p>{t("detail.requirementsActionDetail")}</p>
</div>
<ApiErrorNotice error={actionError} />
<ApiErrorNotice error={actionErrorSource === "requirements" ? actionError : null} />
<label>{t("detail.requirementsConfirmation")}<textarea required minLength={3} maxLength={500} value={requirementsConfirmation} onChange={(event) => setRequirementsConfirmation(event.target.value)} placeholder={t("detail.requirementsConfirmationPlaceholder")} /></label>
<div className="form-actions"><button className="button button-primary" type="submit" disabled={confirmingRequirements || requirementsConfirmation.trim().length < 3}>{confirmingRequirements ? t("detail.requirementsSaving") : t("detail.requirementsConfirm")}</button></div>
</form>
@@ -169,6 +173,7 @@ export function BookingDetail() {
<summary>{t("detail.rescheduleAction")}</summary>
<form onSubmit={reschedule}>
<p>{t("detail.rescheduleDetail")}</p>
<ApiErrorNotice error={actionErrorSource === "reschedule" ? actionError : null} />
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required value={scheduleStart} onChange={(event) => setScheduleStart(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required min={scheduleStart} value={scheduleEnd} onChange={(event) => setScheduleEnd(event.target.value)} /></label>
@@ -179,9 +184,9 @@ export function BookingDetail() {
</details>
) : null}
{booking.status === "reserved" && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
{(booking.status === "reserved" || booking.status === "blocked") && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
<h2>{t("detail.cancelAction")}</h2>
<ApiErrorNotice error={actionError} />
<ApiErrorNotice error={actionErrorSource === "cancel" ? actionError : null} />
<label>{t("detail.cancelReason")}<textarea required minLength={3} maxLength={500} value={cancelReason} onChange={(event) => setCancelReason(event.target.value)} placeholder={t("detail.cancelReasonPlaceholder")} /></label>
<div className="form-actions"><button className="button button-danger" type="submit" disabled={cancelling || cancelReason.trim().length < 3}>{cancelling ? t("detail.cancelling") : t("detail.confirmCancel")}</button></div>
</form>}
+16 -16
View File
@@ -3,6 +3,7 @@ import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Booking, Page } from "../api/types";
import { brusselsDayStartIso, brusselsNextDayStartIso, toBrusselsDateTimeLocal } from "../i18n/brusselsDateTime";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
@@ -10,15 +11,9 @@ import { Pagination } from "../components/Pagination";
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
function localDateString(date = new Date()): string {
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
}
function nextLocalDay(value: string): string {
const date = new Date(`${value}T12:00:00`);
date.setDate(date.getDate() + 1);
return localDateString(date);
/** Today's Brussels calendar day (YYYY-MM-DD) -- the operational "today", not the browser's. */
function brusselsToday(): string {
return toBrusselsDateTimeLocal(new Date()).slice(0, 10);
}
export function Bookings() {
@@ -41,7 +36,7 @@ export function Bookings() {
if (value === null || value === "") next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
setSearchParams(next, { replace: true });
}
useEffect(() => {
@@ -50,14 +45,19 @@ export function Bookings() {
const params = new URLSearchParams({ page: String(page), page_size: "25" });
if (status) params.set("status", status);
if (query) params.set("query", query);
if (startsFrom) params.set("starts_from", new Date(`${startsFrom}T00:00:00`).toISOString());
if (startsTo) params.set("starts_to", new Date(`${nextLocalDay(startsTo)}T00:00:00`).toISOString());
// Date filters are Brussels calendar days regardless of the browser's own time zone.
if (startsFrom) params.set("starts_from", brusselsDayStartIso(startsFrom));
if (startsTo) params.set("starts_to", brusselsNextDayStartIso(startsTo));
if (location) params.set("location", location);
params.set("sort", sort);
const controller = new AbortController();
api
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`)
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`, { signal: controller.signal })
.then(setBookings)
.catch(() => setError(t("list.unavailable")));
.catch(() => {
if (!controller.signal.aborted) setError(t("list.unavailable"));
});
return () => controller.abort();
}, [page, query, status, startsFrom, startsTo, location, sort, t]);
return (
@@ -82,8 +82,8 @@ export function Bookings() {
<label>{t("list.sortLabel")}<select value={sort} onChange={(event) => updateFilters({ sort: event.target.value, page: 1 })}><option value="operational">{t("list.sortOperational")}</option><option value="starts_asc">{t("list.sortAscending")}</option><option value="starts_desc">{t("list.sortDescending")}</option></select></label>
</form>
<div className="filter-presets" aria-label={t("list.presetsLabel")}>
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: localDateString(), status: null, page: 1 })}>{t("list.todayPreset")}</button>
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
<button type="button" onClick={() => updateFilters({ from: brusselsToday(), to: brusselsToday(), status: null, page: 1 })}>{t("list.todayPreset")}</button>
<button type="button" onClick={() => updateFilters({ from: brusselsToday(), to: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
<button type="button" onClick={() => setSearchParams(new URLSearchParams())}>{t("list.clearFilters")}</button>
</div>
+2 -1
View File
@@ -61,6 +61,7 @@ export function Dashboard() {
}
// 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.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [data, setData] = useState<DashboardData | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
@@ -72,7 +73,7 @@ export function Dashboard() {
useEffect(() => {
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError(t("common:status.error")));
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
}, []);
}, [t]);
useEffect(() => {
if (!canSeeAutomation) return;
+8 -4
View File
@@ -51,12 +51,13 @@ export function DataQuality() {
if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
setSearchParams(next, { replace: true });
}
const load = useCallback(() => {
if (user?.role !== "operations_manager") return;
setIssues(null);
// Keep the current rows visible while a filter change refetches (stale-while-revalidate);
// the initial load still shows the loading state because `issues` starts as null.
setError(null);
const params = new URLSearchParams();
if (status) params.set("status", status);
@@ -64,13 +65,14 @@ export function DataQuality() {
if (severity) params.set("severity", severity);
if (assignee) params.set("assigned_to_ref", assignee);
if (overdueOnly) params.set("overdue", "true");
if (demoScenariosOnly) params.set("demo_only", "true");
params.set("page", String(page));
params.set("page_size", "25");
api
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
.then(setIssues)
.catch(() => setError(t("list.unavailable")));
}, [status, ruleType, severity, assignee, overdueOnly, page, user, t]);
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user, t]);
useEffect(() => {
load();
@@ -145,6 +147,8 @@ export function DataQuality() {
}
const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0;
// The demo-scenario filter is applied server-side (so it spans all pages); the client-side
// guard only bridges the moment between toggling the box and the filtered page arriving.
const visibleIssues = issues
? demoScenariosOnly
? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
@@ -311,7 +315,7 @@ export function DataQuality() {
</tr>
))}
</tbody>
</table>{issues && !demoScenariosOnly && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
</table>{issues && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
)}
</div>
);
@@ -727,7 +727,7 @@ export function DataQualityIssueDetail() {
.get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`)
.then(setIssue)
.catch(() => setError(t("detail.notFound")));
}, [publicRef]);
}, [publicRef, t]);
useEffect(() => {
if (user?.role !== "operations_manager") return;
+7 -3
View File
@@ -28,7 +28,7 @@ export function Vehicles() {
if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next);
setSearchParams(next, { replace: true });
}
useEffect(() => {
@@ -41,10 +41,14 @@ export function Vehicles() {
if (location) params.set("location", location);
params.set("page", String(page));
params.set("page_size", "25");
const controller = new AbortController();
api
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`)
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`, { signal: controller.signal })
.then(setVehicles)
.catch(() => setError(t("list.unavailable")));
.catch(() => {
if (!controller.signal.aborted) setError(t("list.unavailable"));
});
return () => controller.abort();
}, [status, attentionOnly, query, location, page, t]);
return (
-3
View File
@@ -283,9 +283,6 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; }
.about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; }
.about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; }
.about-cta { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: var(--teal-pale); border-color: #bfe6df; }
.about-cta strong { display: block; color: var(--ink); font-size: .85rem; }
.about-cta p { margin: 2px 0 0; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin-bottom: 18px; }
.about-grid .about-card { margin-bottom: 0; }
.about-details summary { cursor: pointer; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; font-weight: 700; }