M54: harden operations and demo resilience
MobilityOps acceptance / backend (push) Failing after 19s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-24 03:31:03 +02:00
parent b0706989db
commit 81e3fd63bd
101 changed files with 5641 additions and 828 deletions
+6 -6
View File
@@ -4,8 +4,9 @@ import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { ArchitectureFlow } from "../components/ArchitectureFlow";
import { Icon, type IconName } from "../components/Icons";
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { PRODUCT_NAME } from "../product";
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = { n8n: "n8n", ragcore: "rag", mcp_hub: "mcp" };
@@ -19,7 +20,7 @@ const PROOFS: Array<{ id: "Rules" | "Reliability" | "Ai"; icon: IconName }> = [
export function AboutDemo() {
const { t } = useTranslation(["demo", "integrations"]);
const { formatDateTime } = useLocaleFormat();
const { manifest, loading } = useDemoManifest();
const { manifest, loading, error, refresh } = useDemoManifest();
const { user } = useAuth();
const { openGuide } = useDemoGuide();
const statusLabelKey = (key: string, statusCode: string) => key === "n8n" ? N8N_STATUS_LABEL_KEY[statusCode] ?? statusCode : statusCode;
@@ -28,6 +29,7 @@ export function AboutDemo() {
<div className="page engineering-story">
<PageHeader eyebrow={t("about.eyebrow")} title={t("about.title", { productName: PRODUCT_NAME })} description={manifest ? t("about.description", { orgName: manifest.organization_name }) : undefined} />
{loading && <LoadingState label={t("about.loading")} />}
{!loading && error && <ErrorState message={t("about.unavailable")} onRetry={refresh} />}
{manifest && <>
<section className="panel engineering-hero">
<div>
@@ -49,10 +51,8 @@ export function AboutDemo() {
</section>
<section className="panel architecture-story" aria-labelledby="architecture-heading">
<SectionHeading title={t("about.architectureFlowTitle")} description={t("about.architectureFlowDescription")} />
<div className="architecture-flow" id="architecture-heading">
{["Frontend", "Api", "Database", "Outbox", "External"].map((step, index) => <div className="architecture-step" key={step}><span>{index + 1}</span><strong>{t(`about.architecture${step}`)}</strong>{index < 4 && <Icon name="chevron" />}</div>)}
</div>
<SectionHeading headingId="architecture-heading" title={t("about.architectureFlowTitle")} description={t("about.architectureFlowDescription")} />
<ArchitectureFlow />
</section>
<div className="about-grid">
+37 -27
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
@@ -32,53 +32,63 @@ export function Automation() {
const [knowledgeSettled, setKnowledgeSettled] = useState(false);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
const [integrationSettled, setIntegrationSettled] = useState(false);
const [runsVersion, setRunsVersion] = useState(0);
const [integrationVersion, setIntegrationVersion] = useState(0);
const load = useCallback(() => {
useEffect(() => {
if (user?.role !== "operations_manager") return;
setRuns(null);
setError(null);
const params = new URLSearchParams();
if (status) params.set("status", status);
const controller = new AbortController();
api
.get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`)
.get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`, { signal: controller.signal })
.then(setRuns)
.catch(() =>
setError(user?.role === "operations_manager" ? t("ledger.unavailable") : t("ledger.managerOnly")),
);
}, [status, user, t]);
useEffect(() => {
load();
}, [load]);
.catch(() => {
if (!controller.signal.aborted) setError(t("ledger.unavailable"));
});
return () => controller.abort();
}, [status, user?.role, t, runsVersion]);
useEffect(() => {
setKnowledgeSettled(false);
const controller = new AbortController();
api
.get<KnowledgeHealth>("/api/v1/knowledge/status")
.get<KnowledgeHealth>("/api/v1/knowledge/status", { signal: controller.signal })
.then(setKnowledge)
.catch(() => setKnowledge(null))
.finally(() => setKnowledgeSettled(true));
}, []);
const loadIntegrationStatus = useCallback(() => {
setIntegrationSettled(false);
api
.get<IntegrationStatus>("/api/v1/integrations/status")
.then(setIntegrationStatus)
.catch(() => setIntegrationStatus(null))
.finally(() => setIntegrationSettled(true));
.catch(() => {
if (!controller.signal.aborted) setKnowledge(null);
})
.finally(() => {
if (!controller.signal.aborted) setKnowledgeSettled(true);
});
return () => controller.abort();
}, []);
useEffect(() => {
loadIntegrationStatus();
}, [loadIntegrationStatus]);
if (user?.role !== "operations_manager") return;
setIntegrationSettled(false);
const controller = new AbortController();
api
.get<IntegrationStatus>("/api/v1/integrations/status", { signal: controller.signal })
.then(setIntegrationStatus)
.catch(() => {
if (!controller.signal.aborted) setIntegrationStatus(null);
})
.finally(() => {
if (!controller.signal.aborted) setIntegrationSettled(true);
});
return () => controller.abort();
}, [integrationVersion, user?.role]);
async function handleRetry(eventId: string) {
setRetryError(null);
setRetrying(eventId);
try {
await api.post(`/api/v1/workflows/${eventId}/retry`);
load();
loadIntegrationStatus();
setRunsVersion((version) => version + 1);
setIntegrationVersion((version) => version + 1);
} catch (err) {
setRetryError(describeApiError(t, err, "ledger.retryFailed"));
} finally {
+17 -14
View File
@@ -6,7 +6,7 @@ import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { AvailableVehicle, Booking, CustomerOption } from "../api/types";
import { ApiErrorNotice, PageHeader } from "../components/PageChrome";
import { Icon } from "../components/Icons";
import { brusselsDateTimeFromNow, brusselsLocalToIso } from "../i18n/brusselsDateTime";
import { brusselsDateTimeFromNow, tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
export function BookingCreate() {
const { t } = useTranslation(["bookings", "errors"]);
@@ -18,16 +18,18 @@ export function BookingCreate() {
const [endsAt, setEndsAt] = useState(() => brusselsDateTimeFromNow(26));
const [vehicles, setVehicles] = useState<AvailableVehicle[]>([]);
const [vehicleRef, setVehicleRef] = useState("");
const [requirementsComplete, setRequirementsComplete] = useState(false);
const [vehicleQuery, setVehicleQuery] = useState("");
const [loadingVehicles, setLoadingVehicles] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const windowValid = useMemo(
() => Boolean(startsAt && endsAt && brusselsLocalToIso(endsAt) > brusselsLocalToIso(startsAt)),
[endsAt, startsAt],
const startsAtIso = useMemo(() => startsAt ? tryBrusselsLocalToIso(startsAt) : null, [startsAt]);
const endsAtIso = useMemo(() => endsAt ? tryBrusselsLocalToIso(endsAt) : null, [endsAt]);
const windowValid = Boolean(startsAtIso && endsAtIso && endsAtIso > startsAtIso);
const localTimeInvalid = Boolean(
(startsAt && !startsAtIso) || (endsAt && !endsAtIso),
);
const windowOrderInvalid = Boolean(startsAtIso && endsAtIso && endsAtIso <= startsAtIso);
useEffect(() => {
if (customerQuery.trim().length < 2) {
@@ -61,8 +63,8 @@ export function BookingCreate() {
const timeout = window.setTimeout(() => {
setLoadingVehicles(true);
const params = new URLSearchParams({
starts_at: brusselsLocalToIso(startsAt),
ends_at: brusselsLocalToIso(endsAt),
starts_at: startsAtIso as string,
ends_at: endsAtIso as string,
});
if (vehicleQuery.trim()) params.set("query", vehicleQuery.trim());
params.set("limit", "50");
@@ -82,19 +84,19 @@ export function BookingCreate() {
window.clearTimeout(timeout);
controller.abort();
};
}, [endsAt, startsAt, t, vehicleQuery, windowValid]);
}, [endsAtIso, startsAtIso, t, vehicleQuery, windowValid]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!startsAtIso || !endsAtIso || endsAtIso <= startsAtIso) return;
setError(null);
setSubmitting(true);
try {
const booking = await api.post<Booking>("/api/v1/bookings", {
customer_ref: customerRef,
vehicle_ref: vehicleRef,
starts_at: brusselsLocalToIso(startsAt),
ends_at: brusselsLocalToIso(endsAt),
requirements_complete: requirementsComplete,
starts_at: startsAtIso,
ends_at: endsAtIso,
});
navigate(`/bookings/${booking.public_ref}`);
} catch (err) {
@@ -111,14 +113,15 @@ export function BookingCreate() {
<ApiErrorNotice error={error} />
<form className="record-surface booking-create-form" onSubmit={submit}>
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required value={startsAt} onChange={(event) => setStartsAt(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required min={startsAt} value={endsAt} onChange={(event) => setEndsAt(event.target.value)} /></label>
<label>{t("create.startsAt")}<input type="datetime-local" required aria-invalid={Boolean(startsAt && !startsAtIso)} value={startsAt} onChange={(event) => setStartsAt(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required aria-invalid={Boolean((endsAt && !endsAtIso) || windowOrderInvalid)} min={startsAt} value={endsAt} onChange={(event) => setEndsAt(event.target.value)} /></label>
<label>{t("create.customerSearch")}<input type="search" value={customerQuery} onChange={(event) => setCustomerQuery(event.target.value)} placeholder={t("create.customerPlaceholder")} /></label>
<label>{t("create.customer")}<select required value={customerRef} onChange={(event) => setCustomerRef(event.target.value)} disabled={customers.length === 0}><option value="">{t(customers.length ? "create.chooseCustomer" : "create.searchFirst")}</option>{customers.map((customer) => <option key={customer.public_ref} value={customer.public_ref}>{customer.display_name} · {customer.public_ref}{customer.email ? ` · ${customer.email}` : ""}</option>)}</select></label>
<label>{t("create.vehicleSearch")}<input type="search" value={vehicleQuery} onChange={(event) => setVehicleQuery(event.target.value)} placeholder={t("create.vehiclePlaceholder")} /></label>
<label>{t("create.vehicle")}<select required value={vehicleRef} onChange={(event) => setVehicleRef(event.target.value)} disabled={!windowValid || loadingVehicles}><option value="">{t(loadingVehicles ? "create.loadingVehicles" : vehicles.length ? "create.chooseVehicle" : "create.noVehicles")}</option>{vehicles.map((vehicle) => <option key={vehicle.public_ref} value={vehicle.public_ref}>{vehicle.public_ref} · {vehicle.make} {vehicle.model} · {vehicle.location || t("create.locationUnknown")}</option>)}</select></label>
</div>
<label className="check-card"><input type="checkbox" checked={requirementsComplete} onChange={(event) => setRequirementsComplete(event.target.checked)} /> <span>{t("create.requirementsComplete")}</span></label>
{localTimeInvalid && <p className="error" role="alert">{t("create.invalidLocalTime")}</p>}
{!localTimeInvalid && windowOrderInvalid && <p className="error" role="alert">{t("create.endAfterStart")}</p>}
<div className="form-actions"><Link className="button button-secondary" to="/bookings">{t("create.cancel")}</Link><button className="button button-primary" type="submit" disabled={submitting || !windowValid || !customerRef || !vehicleRef}>{submitting ? t("create.saving") : t("create.save")}</button></div>
</form>
</div>
+20 -8
View File
@@ -1,4 +1,4 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
@@ -13,7 +13,7 @@ import { PRODUCT_NAME } from "../product";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { ApiErrorNotice } from "../components/PageChrome";
import { CheckoutForm } from "../components/CheckoutForm";
import { brusselsLocalToIso, toBrusselsDateTimeLocal } from "../i18n/brusselsDateTime";
import { toBrusselsDateTimeLocal, tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns", "errors"]);
@@ -35,6 +35,17 @@ export function BookingDetail() {
const [scheduleEnd, setScheduleEnd] = useState("");
const [scheduleReason, setScheduleReason] = useState("");
const [rescheduling, setRescheduling] = useState(false);
const scheduleStartIso = useMemo(
() => scheduleStart ? tryBrusselsLocalToIso(scheduleStart) : null,
[scheduleStart],
);
const scheduleEndIso = useMemo(
() => scheduleEnd ? tryBrusselsLocalToIso(scheduleEnd) : null,
[scheduleEnd],
);
const scheduleWindowValid = Boolean(
scheduleStartIso && scheduleEndIso && scheduleEndIso > scheduleStartIso,
);
const load = useCallback(() => {
if (!publicRef) return;
@@ -120,13 +131,13 @@ export function BookingDetail() {
async function reschedule(event: FormEvent) {
event.preventDefault();
if (!publicRef) return;
if (!publicRef || !scheduleStartIso || !scheduleEndIso || !scheduleWindowValid) return;
setRescheduling(true);
setActionError(null);
try {
const updated = await api.patch<Booking>(`/api/v1/bookings/${publicRef}/schedule`, {
starts_at: brusselsLocalToIso(scheduleStart),
ends_at: brusselsLocalToIso(scheduleEnd),
starts_at: scheduleStartIso,
ends_at: scheduleEndIso,
reason: scheduleReason,
});
setBooking(updated);
@@ -175,11 +186,12 @@ export function BookingDetail() {
<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>
<label>{t("create.startsAt")}<input type="datetime-local" required aria-invalid={Boolean(scheduleStart && !scheduleStartIso)} value={scheduleStart} onChange={(event) => setScheduleStart(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required aria-invalid={Boolean(scheduleEnd && !scheduleEndIso)} min={scheduleStart} value={scheduleEnd} onChange={(event) => setScheduleEnd(event.target.value)} /></label>
</div>
{scheduleStart && scheduleEnd && !scheduleWindowValid && <p className="error" role="alert">{t("detail.invalidScheduleWindow")}</p>}
<label>{t("detail.rescheduleReason")}<textarea required minLength={3} maxLength={500} value={scheduleReason} onChange={(event) => setScheduleReason(event.target.value)} /></label>
<div className="form-actions"><button className="button button-secondary" type="submit" disabled={rescheduling || scheduleReason.trim().length < 3 || scheduleEnd <= scheduleStart}>{rescheduling ? t("detail.rescheduling") : t("detail.confirmReschedule")}</button></div>
<div className="form-actions"><button className="button button-secondary" type="submit" disabled={rescheduling || scheduleReason.trim().length < 3 || !scheduleWindowValid}>{rescheduling ? t("detail.rescheduling") : t("detail.confirmReschedule")}</button></div>
</form>
</details>
) : null}
+50 -9
View File
@@ -67,21 +67,50 @@ export function Dashboard() {
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [dashboardRequest, setDashboardRequest] = useState(0);
const [severity, setSeverity] = useState("all");
const [query, setQuery] = useState("");
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]);
const controller = new AbortController();
setError(null);
api
.get<DashboardData>("/api/v1/dashboard", { signal: controller.signal })
.then((nextData) => {
if (!controller.signal.aborted) setData(nextData);
})
.catch(() => {
if (!controller.signal.aborted) setError(t("common:status.error"));
});
api
.get<KnowledgeHealth>("/api/v1/knowledge/status", { signal: controller.signal })
.then((nextKnowledge) => {
if (!controller.signal.aborted) setKnowledge(nextKnowledge);
})
.catch(() => {
if (!controller.signal.aborted) setKnowledge(null);
});
return () => controller.abort();
}, [dashboardRequest, t]);
useEffect(() => {
if (!canSeeAutomation) return;
if (!canSeeAutomation) {
setIntegrationStatus(null);
return;
}
const controller = new AbortController();
api
.get<IntegrationStatus>("/api/v1/integrations/status")
.then(setIntegrationStatus)
.catch(() => setIntegrationStatus(null));
}, [canSeeAutomation]);
.get<IntegrationStatus>("/api/v1/integrations/status", { signal: controller.signal })
.then((nextStatus) => {
if (!controller.signal.aborted) setIntegrationStatus(nextStatus);
})
.catch(() => {
if (!controller.signal.aborted) setIntegrationStatus(null);
});
return () => controller.abort();
}, [canSeeAutomation, dashboardRequest]);
const attention = useMemo(() => data?.attention_items.filter((item) => {
const matchesSeverity = severity === "all" || item.severity === severity;
@@ -104,7 +133,19 @@ export function Dashboard() {
].filter((tier) => tier.items.length > 0);
}, [attention, isUnfiltered]);
if (error) return <ErrorState message={error} />;
if (error) {
return (
<ErrorState
message={error}
onRetry={() => {
setData(null);
setKnowledge(null);
setIntegrationStatus(null);
setDashboardRequest((requestNumber) => requestNumber + 1);
}}
/>
);
}
if (!data) return <LoadingState label={t("common:status.loading")} />;
const latestRun = data.recent_automation[0];
+39 -21
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
@@ -9,7 +9,7 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
import { useLocaleFormat } from "../i18n/format";
import { brusselsLocalToIso } from "../i18n/brusselsDateTime";
import { tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
const RULE_TYPES = [
"possible_duplicate_customer",
@@ -36,7 +36,7 @@ export function DataQuality() {
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
const demoScenariosOnly = searchParams.get("demo") === "true";
const [users, setUsers] = useState<UserRecord[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkAssignee, setBulkAssignee] = useState("");
@@ -44,6 +44,12 @@ export function DataQuality() {
const [bulkSaving, setBulkSaving] = useState(false);
const [bulkError, setBulkError] = useState<ApiErrorInfo | null>(null);
const [filtersOpen, setFiltersOpen] = useState(false);
const [refreshVersion, setRefreshVersion] = useState(0);
const bulkDueAtIso = useMemo(
() => bulkDueAt ? tryBrusselsLocalToIso(bulkDueAt) : null,
[bulkDueAt],
);
const bulkDueAtInvalid = Boolean(bulkDueAt && !bulkDueAtIso);
function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams);
@@ -54,7 +60,7 @@ export function DataQuality() {
setSearchParams(next, { replace: true });
}
const load = useCallback(() => {
useEffect(() => {
if (user?.role !== "operations_manager") return;
// 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.
@@ -68,35 +74,46 @@ export function DataQuality() {
if (demoScenariosOnly) params.set("demo_only", "true");
params.set("page", String(page));
params.set("page_size", "25");
const controller = new AbortController();
api
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`, { signal: controller.signal })
.then(setIssues)
.catch(() => setError(t("list.unavailable")));
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user, t]);
useEffect(() => {
load();
}, [load]);
.catch(() => {
if (!controller.signal.aborted) setError(t("list.unavailable"));
});
return () => controller.abort();
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user?.role, t, refreshVersion]);
useEffect(() => {
if (user?.role !== "operations_manager") return;
api.get<UserRecord[]>("/api/v1/users").then((records) => setUsers(records.filter((record) => record.active))).catch(() => setUsers([]));
}, [user]);
const controller = new AbortController();
api.get<UserRecord[]>("/api/v1/users", { signal: controller.signal })
.then((records) => setUsers(records.filter((record) => record.active)))
.catch(() => {
if (!controller.signal.aborted) setUsers([]);
});
return () => controller.abort();
}, [user?.role]);
useEffect(() => {
// Bulk actions must never retain invisible rows after URL-driven filtering/history.
setSelected(new Set());
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page]);
async function applyBulkWork() {
if (selected.size === 0 || (!bulkAssignee && !bulkDueAt)) return;
if (selected.size === 0 || (!bulkAssignee && !bulkDueAt) || bulkDueAtInvalid) return;
setBulkSaving(true);
setBulkError(null);
try {
await api.post("/api/v1/data-quality/issues/bulk-work", {
issue_refs: [...selected],
assigned_to_ref: bulkAssignee || undefined,
due_at: bulkDueAt ? brusselsLocalToIso(bulkDueAt) : undefined,
due_at: bulkDueAtIso ?? undefined,
});
setSelected(new Set());
setBulkAssignee("");
setBulkDueAt("");
load();
setRefreshVersion((version) => version + 1);
} catch (err) {
setBulkError(describeApiError(t, err, "list.bulkFailed"));
} finally {
@@ -129,7 +146,7 @@ export function DataQuality() {
const result = await api.post<ScanResult>("/api/v1/data-quality/scan");
setScanResult(result);
setConfirmingScan(false);
load();
setRefreshVersion((version) => version + 1);
} catch (err) {
setScanError(describeApiError(t, err, "list.scanFailed"));
} finally {
@@ -246,7 +263,7 @@ export function DataQuality() {
<input
type="checkbox"
checked={demoScenariosOnly}
onChange={(e) => { setDemoScenariosOnly(e.target.checked); updateFilters({ demo: e.target.checked, page: 1 }); }}
onChange={(e) => updateFilters({ demo: e.target.checked, page: 1 })}
/>
{t("list.demoScenariosOnly")}
</label>
@@ -258,7 +275,7 @@ export function DataQuality() {
{severity && <button type="button" onClick={() => updateFilters({ severity: null, page: 1 })}>{t(`severities.${severity}`)} ×</button>}
{assignee && <button type="button" onClick={() => updateFilters({ assignee: null, page: 1 })}>{assignee === "unassigned" ? t("list.unassigned") : users.find((record) => record.public_ref === assignee)?.display_name ?? assignee} ×</button>}
{overdueOnly && <button type="button" onClick={() => updateFilters({ overdue: null, page: 1 })}>{t("list.overdueOnly")} ×</button>}
{demoScenariosOnly && <button type="button" onClick={() => { setDemoScenariosOnly(false); updateFilters({ demo: null, page: 1 }); }}>{t("list.demoScenariosOnly")} ×</button>}
{demoScenariosOnly && <button type="button" onClick={() => updateFilters({ demo: null, page: 1 })}>{t("list.demoScenariosOnly")} ×</button>}
</div>
)}
@@ -275,8 +292,9 @@ export function DataQuality() {
<div className="bulk-toolbar" role="region" aria-label={t("list.bulkTitle")}>
<strong>{t("list.selected", { count: selected.size })}</strong>
<label>{t("list.assignTo")}<select value={bulkAssignee} onChange={(event) => setBulkAssignee(event.target.value)}><option value="">—</option>{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}</select></label>
<label>{t("list.dueAt")}<input type="datetime-local" value={bulkDueAt} onChange={(event) => setBulkDueAt(event.target.value)} /></label>
<button type="button" className="button button-primary" disabled={bulkSaving || (!bulkAssignee && !bulkDueAt)} onClick={applyBulkWork}>{bulkSaving ? t("list.bulkSaving") : t("list.bulkApply")}</button>
<label>{t("list.dueAt")}<input type="datetime-local" aria-invalid={bulkDueAtInvalid} value={bulkDueAt} onChange={(event) => setBulkDueAt(event.target.value)} /></label>
{bulkDueAtInvalid && <span className="error" role="alert">{t("list.invalidDueAt")}</span>}
<button type="button" className="button button-primary" disabled={bulkSaving || (!bulkAssignee && !bulkDueAt) || bulkDueAtInvalid} onClick={applyBulkWork}>{bulkSaving ? t("list.bulkSaving") : t("list.bulkApply")}</button>
<button type="button" className="button button-secondary" onClick={() => setSelected(new Set())}>{t("list.clearSelection")}</button>
</div>
)}
@@ -136,7 +136,14 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const bookingSnapshots = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const configuredCorrectableRefs = Array.isArray(issue.evidence.correctable_booking_refs)
? issue.evidence.correctable_booking_refs.filter((ref): ref is string => typeof ref === "string")
: null;
const bookingSnapshots = issue.related_snapshots.filter(
(snapshot) =>
snapshot.entity_type === "booking" &&
(configuredCorrectableRefs === null || configuredCorrectableRefs.includes(snapshot.public_ref)),
);
const [decision, setDecision] = useState<"retain_canonical" | "correct_reading">("retain_canonical");
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
const [correctedValue, setCorrectedValue] = useState("");
+9 -3
View File
@@ -41,11 +41,17 @@ export function Knowledge() {
useEffect(() => {
setStatusSettled(false);
const controller = new AbortController();
api
.get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`)
.get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`, { signal: controller.signal })
.then(setStatus)
.catch(() => setStatus(null))
.finally(() => setStatusSettled(true));
.catch(() => {
if (!controller.signal.aborted) setStatus(null);
})
.finally(() => {
if (!controller.signal.aborted) setStatusSettled(true);
});
return () => controller.abort();
}, [language]);
async function ask(questionText: string) {
+30 -2
View File
@@ -11,7 +11,12 @@ import { PRODUCT_NAME } from "../product";
export function Login() {
const { t } = useTranslation(["auth", "common"]);
const { loginAs, loginWithPassword, loading, demoMode, oidcEnabled, oidcProviderName } = useAuth();
const { manifest } = useDemoManifest();
const {
manifest,
loading: manifestLoading,
error: manifestError,
refresh: refreshManifest,
} = useDemoManifest();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState("");
@@ -38,8 +43,14 @@ export function Login() {
}
}
async function handleGuidedDemo() {
if (manifestLoading || manifestError || !manifest?.guide_available) return;
await handleLogin("operations_manager", "/dashboard?guide=start");
}
const orgName = manifest?.organization_name ?? t("common:orgName");
const description = t("defaultDescription", { productName: PRODUCT_NAME });
const guidedDemoReady = manifest?.guide_available === true;
return (
<main className="login-shell">
@@ -84,10 +95,27 @@ export function Login() {
{t("startRecruiterTour")}
</button>
<button type="button" className="button button-secondary login-full-demo" disabled={loading} onClick={() => handleLogin("operations_manager", "/dashboard?guide=start")}>
<button
type="button"
className="button button-secondary login-full-demo"
disabled={loading || manifestLoading || manifestError || !guidedDemoReady}
onClick={handleGuidedDemo}
>
{t("startGuidedDemo")}
</button>
{manifestLoading && (
<p className="login-guide-status" role="status">{t("guidedDemoChecking")}</p>
)}
{!manifestLoading && (manifestError || !guidedDemoReady) && (
<div className="login-guide-status is-unavailable" role={manifestError ? "alert" : "status"}>
<span>{t(manifestError ? "guidedDemoManifestUnavailable" : "guidedDemoNotReady")}</span>
<button type="button" className="link-button" onClick={refreshManifest}>
{t("guidedDemoRetry")}
</button>
</div>
)}
<div className="login-options">
<button type="button" aria-label={t("exploreAsOperationsManager")} disabled={loading} onClick={() => handleLogin("operations_manager")}>
<span className="role-icon"><Icon name="activity" /></span>
+3 -2
View File
@@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { Icon } from "../components/Icons";
import { LoadingState, PageHeader } from "../components/PageChrome";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
export function Scenarios() {
const { t } = useTranslation("demo");
const { manifest, loading } = useDemoManifest();
const { manifest, loading, error, refresh } = useDemoManifest();
const { user } = useAuth();
function roleLabel(roles: string[]): string {
@@ -24,6 +24,7 @@ export function Scenarios() {
/>
{loading && <LoadingState label={t("scenarios.loading")} />}
{!loading && error && <ErrorState message={t("scenarios.unavailable")} onRetry={refresh} />}
{manifest && (
<div className="scenario-grid">
+11 -1
View File
@@ -22,11 +22,12 @@ export function Users() {
const [newPassword, setNewPassword] = useState("");
const load = useCallback(() => {
if (currentUser?.role !== "operations_manager") return;
api
.get<UserRecord[]>("/api/v1/users")
.then(setUsers)
.catch((err) => setError(describeApiError(t, err)));
}, [t]);
}, [currentUser?.role, t]);
useEffect(load, [load]);
@@ -91,6 +92,15 @@ export function Users() {
}
}
if (currentUser?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.managerOnly")} />
<p>{t("users.managerOnly")}</p>
</div>
);
}
return (
<div className="page">
<PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.description")} />
+5 -2
View File
@@ -21,6 +21,9 @@ export function VehicleDetail() {
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
const { user } = useAuth();
const visibleTabs = user?.role === "operations_manager"
? TABS
: TABS.filter((candidate) => candidate !== "quality");
const load = useCallback(() => {
if (!publicRef) return;
@@ -42,7 +45,7 @@ export function VehicleDetail() {
<PageHeader eyebrow={t("detail.eyebrow")} title={`${vehicle.public_ref} · ${vehicle.make} ${vehicle.model}`} description={`${vehicle.registration_number} · ${vehicle.location}`} actions={<div className="status-stack"><StatusBadge status={vehicle.operational_status} label={t(`statuses.${vehicle.operational_status}`, { defaultValue: vehicle.operational_status })} />{vehicle.attention && <span className="badge severity-high">{t("detail.needsAttention")}</span>}</div>} />
<div role="tablist" aria-label={t("detail.tabsAriaLabel")} className="tabs" aria-orientation="horizontal">
{TABS.map((tb) => (
{visibleTabs.map((tb) => (
<button
key={tb}
role="tab"
@@ -111,7 +114,7 @@ export function VehicleDetail() {
</ul>{user?.role === "operations_manager" && <VehicleMaintenanceActions vehicle={vehicle} onSaved={load} />}</>
)}
{tab === "quality" && (
{tab === "quality" && user?.role === "operations_manager" && (
<ul className="record-list">
{vehicle.quality_issues.length === 0 && <li>{t("detail.noQualityIssues")}</li>}
{vehicle.quality_issues.map((q) => (