M54: harden operations and demo resilience
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user