M39: harden application and acceptance gates
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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[]>(() => {
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user