feat: centralize API error localization

Replace the err instanceof ApiError ? err.message : t(fallback) anti-
pattern -- which showed raw English backend text for the common case and
only used the localized fallback for the rare network-failure case -- at
all 13 call sites across 7 files.

New frontend/src/api/errorMessages.ts (describeApiError) resolves a
caught error to a localized {title, explanation, nextStep?, technical}
by checking the 32 known AppError codes first, then known HTTP statuses
(401/403/404/409/422/500), then a fully generic fallback. New
ApiErrorNotice (PageChrome.tsx) renders title/explanation/nextStep with
the raw text demoted to a "Technical details"/"Details techniques"
disclosure -- never shown as the primary message.

ApiError itself is split out of client.ts into a standalone
api/apiError.ts with no import.meta.env dependency, so errorMessages.ts
(and its tests) can be loaded outside a Vite/browser context.
This commit is contained in:
NuklearRabbit
2026-08-04 03:08:07 +02:00
parent 94cfb7bcbb
commit d17af1c52a
16 changed files with 940 additions and 82 deletions
+205
View File
@@ -0,0 +1,205 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ApiError } from "../src/api/apiError";
import { describeApiError, KNOWN_CODES } from "../src/api/errorMessages";
// Pure Node-context checks for the central API-error-localization function (section 7 /
// 11 of the Fleet Ops final localization brief). No browser needed: describeApiError()
// only depends on a `t` function and a caught error, so it's tested here against the
// real locale JSON with a minimal i18next-shaped `t` stub -- proving the known-code and
// known-HTTP-status paths never leak raw backend English as the primary message, and
// that the raw text is always still available via `.technical` for "Technical details".
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
// Mirrors the (namespace, options.defaultValue) contract react-i18next's `t` exposes,
// resolving "namespace:dotted.path" against the real locale files for the given language.
function makeT(language: string): (key: string, options?: Record<string, unknown>) => string {
return (key: string, options?: Record<string, unknown>) => {
const [ns, ...rest] = key.includes(":") ? key.split(":") : ["errors", key];
const dottedPath = key.includes(":") ? rest.join(":") : rest.join("");
const data = loadNamespace(language, ns);
const value = dottedPath.split(".").reduce<unknown>((acc, part) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
return undefined;
}, data);
if (typeof value === "string") return value;
if (options && "defaultValue" in options) return String(options.defaultValue);
return key;
};
}
const KNOWN_HTTP_STATUSES = ["401", "403", "404", "409", "422", "500"];
test("every known AppError code has a non-empty title+explanation in all 3 locales", () => {
for (const language of LANGUAGES) {
const codes = loadNamespace(language, "errors").codes as Record<string, { title?: string; explanation?: string }>;
for (const code of KNOWN_CODES) {
expect(codes[code], `${language}/errors.json is missing codes.${code}`).toBeTruthy();
expect(codes[code]?.title?.trim().length ?? 0, `${language}/errors.json:codes.${code}.title is empty`).toBeGreaterThan(0);
expect(
codes[code]?.explanation?.trim().length ?? 0,
`${language}/errors.json:codes.${code}.explanation is empty`,
).toBeGreaterThan(0);
}
}
});
test("every known HTTP status fallback has a non-empty title+explanation in all 3 locales", () => {
for (const language of LANGUAGES) {
const http = loadNamespace(language, "errors").http as Record<string, { title?: string; explanation?: string }>;
for (const status of KNOWN_HTTP_STATUSES) {
expect(http[status], `${language}/errors.json is missing http.${status}`).toBeTruthy();
expect(http[status]?.title?.trim().length ?? 0, `${language}/errors.json:http.${status}.title is empty`).toBeGreaterThan(0);
expect(
http[status]?.explanation?.trim().length ?? 0,
`${language}/errors.json:http.${status}.explanation is empty`,
).toBeGreaterThan(0);
}
}
});
test("a known AppError code resolves to its localized codes.* entry, never the raw backend message", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "IntegrityError: duplicate key value violates unique constraint";
const err = new ApiError(409, "VEHICLE_NOT_FOUND", raw, "corr-1");
const info = describeApiError(t, err);
const expected = loadNamespace(language, "errors").codes as Record<string, { title: string; explanation: string; nextStep?: string }>;
expect(info.title).toBe(expected.VEHICLE_NOT_FOUND.title);
expect(info.explanation).toBe(expected.VEHICLE_NOT_FOUND.explanation);
expect(info.title).not.toBe(raw);
expect(info.explanation).not.toBe(raw);
// The raw backend text must still be reachable, just demoted to `.technical`.
expect(info.technical).toBe(raw);
}
});
test("a code with nextStep populates it; a code without nextStep leaves it undefined", () => {
const t = makeT("nl-BE");
const withNextStep = describeApiError(t, new ApiError(422, "EMPTY_VALUE", "raw", "c1"));
expect(withNextStep.nextStep).toBeTruthy();
const withoutNextStep = describeApiError(t, new ApiError(404, "CUSTOMER_NOT_FOUND", "raw", "c2"));
expect(withoutNextStep.nextStep).toBeUndefined();
});
test("an unrecognized AppError code falls back to the matching known HTTP status, not raw text", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "Some brand-new backend code nobody localized yet";
const err = new ApiError(404, "SOME_FUTURE_CODE_NOT_YET_LOCALIZED", raw, "corr-2");
const info = describeApiError(t, err);
const expected404 = (loadNamespace(language, "errors").http as Record<string, { title: string; explanation: string }>)["404"];
expect(info.title).toBe(expected404.title);
expect(info.explanation).toBe(expected404.explanation);
expect(info.title).not.toBe(raw);
expect(info.technical).toBe(raw);
}
});
test("an unrecognized code and an unrecognized HTTP status fall back to the fully generic message", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "418 I'm a teapot (never mapped)";
const err = new ApiError(418, "418", raw, "corr-3");
const info = describeApiError(t, err);
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
expect(info.title).toBe(generic.title);
expect(info.explanation).toBe(generic.explanation);
expect(info.technical).toBe(raw);
}
});
test("a stringified HTTP status used as the AppError code (plain HTTPException path) resolves via the http map", () => {
// Mirrors app/main.py's plain-HTTPException handler, which sets code = str(status_code)
// (e.g. "401") rather than a semantic AppError code -- see backend/app/main.py.
const t = makeT("fr-BE");
const err = new ApiError(401, "401", "Not authenticated", "corr-4");
const info = describeApiError(t, err);
const expected401 = (loadNamespace("fr-BE", "errors").http as Record<string, { title: string }>)["401"];
expect(info.title).toBe(expected401.title);
expect(info.title).not.toBe("Not authenticated");
});
test("a non-ApiError (e.g. network failure before any response) uses the fallback key, never a raw JS error message as the primary text", () => {
const t = makeT("nl-BE");
const networkFailure = new TypeError("Failed to fetch");
// Real call sites (e.g. Automation.tsx) invoke t() with their own default namespace
// already scoped via useTranslation("integrations"); this stub's default namespace is
// "errors", so the fallback key is qualified explicitly here to match.
const info = describeApiError(t, networkFailure, "integrations:ledger.retryFailed");
const expectedFallback = loadNamespace("nl-BE", "integrations").ledger as Record<string, string>;
expect(info.explanation).toBe(expectedFallback.retryFailed);
expect(info.explanation).not.toBe("Failed to fetch");
expect(info.technical).toBe("Failed to fetch");
});
// --- Backend/frontend AppError code drift guard ---
// KNOWN_CODES is a hand-maintained mirror of every `raise AppError("CODE", ...)` in the
// backend (see app/core/errors.py::AppError and every raise site). If the backend adds a
// new code and nobody updates KNOWN_CODES, it silently falls back to the generic-but-
// still-localized HTTP/generic message rather than raw English -- not a broken build, but
// a missed opportunity for a more specific message. This test surfaces that drift instead
// of letting it go unnoticed indefinitely.
const BACKEND_APP_DIR = path.resolve(__dirname, "../../backend/app");
function collectPyFiles(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return collectPyFiles(full);
return entry.name.endsWith(".py") ? [full] : [];
});
}
function collectBackendAppErrorCodes(): Set<string> {
const codes = new Set<string>();
for (const file of collectPyFiles(BACKEND_APP_DIR)) {
const source = fs.readFileSync(file, "utf-8");
const pattern = /AppError\(\s*"([A-Z_]+)"/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
codes.add(match[1]);
}
}
return codes;
}
test("frontend KNOWN_CODES exactly matches every AppError code actually raised by the backend", () => {
const backendCodes = collectBackendAppErrorCodes();
const frontendCodes = KNOWN_CODES;
const missingFromFrontend = [...backendCodes].filter((c) => !frontendCodes.has(c)).sort();
const staleInFrontend = [...frontendCodes].filter((c) => !backendCodes.has(c)).sort();
expect(
missingFromFrontend,
`Backend raises AppError code(s) with no localized entry in errorMessages.ts KNOWN_CODES ` +
`(they'll fall back to a generic/HTTP-status message): ${missingFromFrontend.join(", ")}`,
).toEqual([]);
expect(
staleInFrontend,
`errorMessages.ts KNOWN_CODES lists code(s) the backend never raises -- likely renamed or ` +
`removed on the backend side: ${staleInFrontend.join(", ")}`,
).toEqual([]);
});
test("a non-ApiError with no fallbackKey uses the fully generic explanation", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const info = describeApiError(t, new TypeError("Failed to fetch"));
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
expect(info.title).toBe(generic.title);
expect(info.explanation).toBe(generic.explanation);
}
});
+12
View File
@@ -0,0 +1,12 @@
export class ApiError extends Error {
status: number;
code: string;
correlationId: string;
constructor(status: number, code: string, message: string, correlationId: string) {
super(message);
this.status = status;
this.code = code;
this.correlationId = correlationId;
}
}
+4 -13
View File
@@ -1,3 +1,7 @@
import { ApiError } from "./apiError";
export { ApiError } from "./apiError";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
type UnauthorizedListener = () => void;
@@ -8,19 +12,6 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
return () => unauthorizedListeners.delete(listener);
}
export class ApiError extends Error {
status: number;
code: string;
correlationId: string;
constructor(status: number, code: string, message: string, correlationId: string) {
super(message);
this.status = status;
this.code = code;
this.correlationId = correlationId;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
+100
View File
@@ -0,0 +1,100 @@
import { ApiError } from "./apiError";
export interface ApiErrorInfo {
title: string;
explanation: string;
nextStep?: string;
technical: string;
}
type TFn = (key: string, options?: Record<string, unknown>) => string;
// Backend AppError codes this frontend knows how to present with a localized title,
// explanation and (where useful) a next step -- see
// backend/app/core/errors.py::AppError and every `raise AppError("CODE", ...)` site.
// Anything not in this list still gets a sensible HTTP-status-based fallback below, so
// a newly-introduced backend code never regresses to raw English -- it just falls back
// to a generic-but-localized message until this list is extended.
export const KNOWN_CODES = new Set([
"VEHICLE_NOT_FOUND",
"BOOKING_NOT_FOUND",
"CUSTOMER_NOT_FOUND",
"ENTITY_NOT_FOUND",
"EVENT_NOT_FOUND",
"ISSUE_NOT_FOUND",
"ISSUE_NOT_OPEN",
"BOOKING_NOT_ACTIVE",
"INVALID_BOOKING_STATE",
"NOT_RETRYABLE",
"CONFLICT_STILL_PRESENT",
"OVERLAP_STILL_PRESENT",
"EMPTY_VALUE",
"NO_FIELDS_PROVIDED",
"INVALID_FIELD",
"INVALID_FIELD_OVERRIDE",
"INVALID_SURVIVOR",
"INVALID_BOOKING_REFERENCE",
"INVALID_EVENT_ID",
"INVALID_IDEMPOTENCY_KEY",
"IDEMPOTENCY_KEY_REUSED",
"CORRECTED_VALUE_REQUIRED",
"CORRECTION_BELOW_CANONICAL",
"NOT_A_DUPLICATE_ISSUE",
"NOT_A_MISSING_FIELD_ISSUE",
"NOT_AN_ODOMETER_ISSUE",
"NOT_AN_OVERLAP_ISSUE",
"NOT_A_STATUS_CONFLICT_ISSUE",
"UNSUPPORTED_ENTITY",
"MANUAL_REVIEW_REQUIRED",
"NO_CONFLICT_DETECTED",
"RECOMMENDATION_STALE",
"UNAUTHORIZED_SERVICE",
]);
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
/**
* Turns a caught error into a localized {title, explanation, nextStep?, technical}
* for display. The raw backend/network text is only ever exposed as `technical`
* (shown under "Technical details" by ApiErrorNotice) -- never as the primary message.
*
* `fallbackKey` is an existing, already-localized `t()` key used as the explanation
* when the error isn't an ApiError at all (e.g. the fetch failed before a response
* existed) and errors:generic doesn't fit the specific action being attempted.
*/
export function describeApiError(t: TFn, err: unknown, fallbackKey?: string): ApiErrorInfo {
if (!(err instanceof ApiError)) {
return {
title: t("errors:generic.title"),
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
technical: err instanceof Error ? err.message : String(err),
};
}
if (KNOWN_CODES.has(err.code)) {
const nextStep = t(`errors:codes.${err.code}.nextStep`, { defaultValue: "" });
return {
title: t(`errors:codes.${err.code}.title`),
explanation: t(`errors:codes.${err.code}.explanation`),
nextStep: nextStep || undefined,
technical: err.message,
};
}
const httpKey = KNOWN_HTTP_STATUSES.has(err.code) ? err.code : String(err.status);
if (KNOWN_HTTP_STATUSES.has(httpKey)) {
const nextStep = t(`errors:http.${httpKey}.nextStep`, { defaultValue: "" });
return {
title: t(`errors:http.${httpKey}.title`),
explanation: t(`errors:http.${httpKey}.explanation`),
nextStep: nextStep || undefined,
technical: err.message,
};
}
return {
title: t("errors:generic.title"),
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
technical: err.message,
};
}
+6 -4
View File
@@ -1,13 +1,15 @@
import { useNavigate, useLocation } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useViewportTier } from "../hooks/useViewportTier";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons";
import { ApiErrorNotice } from "./PageChrome";
import { PRODUCT_NAME } from "../product";
export function DemoGuideTrigger() {
@@ -68,7 +70,7 @@ export function DemoGuide() {
setCollapsedToChip,
} = useDemoGuide();
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null);
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
const pendingTarget = useRef<string | null>(null);
@@ -119,7 +121,7 @@ export function DemoGuide() {
await logout();
navigate("/login");
} catch (err) {
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
setResetError(describeApiError(t, err, "guide.restartFailed"));
} finally {
setResetting(false);
}
@@ -227,7 +229,7 @@ export function DemoGuide() {
</nav>
)}
{resetError && <p className="error" role="alert">{resetError}</p>}
<ApiErrorNotice error={resetError} />
<footer className="demo-guide-footer">
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
+6 -4
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { useAuth } from "../context/AuthContext";
import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons";
@@ -11,6 +12,7 @@ import { LanguageSwitcher } from "./LanguageSwitcher";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { PRODUCT_NAME } from "../product";
import { ApiErrorNotice } from "./PageChrome";
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
vehicle: "fleet",
@@ -86,7 +88,7 @@ export function Layout() {
const [activeIndex, setActiveIndex] = useState(-1);
const [resetConfirming, setResetConfirming] = useState(false);
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null);
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const searchInput = useRef<HTMLInputElement>(null);
const searchBox = useRef<HTMLDivElement>(null);
@@ -163,7 +165,7 @@ export function Layout() {
await logout();
navigate("/login");
} catch (err) {
setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
setResetError(describeApiError(t, err, "resetFailed"));
setResetConfirming(false);
} finally {
setResetting(false);
@@ -231,7 +233,7 @@ export function Layout() {
</div>
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset">
{resetError && <p className="error" role="alert">{resetError}</p>}
<ApiErrorNotice error={resetError} />
{!resetConfirming ? (
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
{t("resetDemoData")}
+20
View File
@@ -67,6 +67,26 @@ export function ErrorState({ message }: { message: string }) {
);
}
// Shared rendering for describeApiError()'s output: a localized title + explanation +
// optional next step, with the raw backend/network text demoted to a "Technical
// details" disclosure -- never shown as the primary message. See
// frontend/src/api/errorMessages.ts and docs/fleet-ops-final-localization/audit.md.
export function ApiErrorNotice({ error }: { error: import("../api/errorMessages").ApiErrorInfo | null }) {
const { t } = useTranslation("common");
if (!error) return null;
return (
<div className="error api-error-notice" role="alert">
<strong>{error.title}</strong>
<p>{error.explanation}</p>
{error.nextStep && <p className="api-error-next-step">{error.nextStep}</p>}
<details className="evidence-disclosure">
<summary>{t("actions.technicalDetails")}</summary>
<pre className="evidence-block">{error.technical}</pre>
</details>
</div>
);
}
export function EmptyState({
icon = "check",
title,
+7 -5
View File
@@ -1,7 +1,8 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
@@ -10,6 +11,7 @@ import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons";
import { StatusBadge } from "./Badge";
import { ApiErrorNotice } from "./PageChrome";
function newIdempotencyKey(): string {
return typeof crypto.randomUUID === "function"
@@ -103,7 +105,7 @@ export function ReturnForm({
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [idempotencyKey] = useState(newIdempotencyKey);
const [step, setStep] = useState<"capture" | "review">("capture");
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
@@ -132,7 +134,7 @@ export function ReturnForm({
setPreview(evaluated);
setStep("review");
} catch (err) {
setError(err instanceof ApiError ? err.message : t("errors:generic"));
setError(describeApiError(t, err));
} finally {
setPreviewing(false);
}
@@ -148,7 +150,7 @@ export function ReturnForm({
);
onRegistered(registered);
} catch (err) {
setError(err instanceof ApiError ? err.message : t("errors:generic"));
setError(describeApiError(t, err));
} finally {
setSubmitting(false);
}
@@ -158,7 +160,7 @@ export function ReturnForm({
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
{step === "capture" ? <div className="return-capture">
<div className="form-grid"><label>
+178 -6
View File
@@ -1,8 +1,180 @@
{
"generic": "Something went wrong. Please try again.",
"workspaceLoadFailed": "We couldn't load this workspace.",
"unauthorized": "Your session has expired. Please log in again.",
"forbidden": "You don't have access to this section.",
"notFound": "This record could not be found.",
"networkUnavailable": "The connection to the server is currently unavailable."
"generic": {
"title": "Something went wrong",
"explanation": "This action could not be completed. Please try again."
},
"codes": {
"VEHICLE_NOT_FOUND": {
"title": "Vehicle not found",
"explanation": "This vehicle could not be found. It may have been removed or the reference may be incorrect."
},
"BOOKING_NOT_FOUND": {
"title": "Booking not found",
"explanation": "This booking could not be found. It may have been removed or the reference may be incorrect."
},
"CUSTOMER_NOT_FOUND": {
"title": "Customer not found",
"explanation": "This customer record could not be found."
},
"ENTITY_NOT_FOUND": {
"title": "Record not found",
"explanation": "The underlying record for this action could not be found."
},
"EVENT_NOT_FOUND": {
"title": "Workflow event not found",
"explanation": "This automation event could not be found."
},
"ISSUE_NOT_FOUND": {
"title": "Data-quality issue not found",
"explanation": "This data-quality issue could not be found."
},
"ISSUE_NOT_OPEN": {
"title": "Issue is no longer open",
"explanation": "This issue has already been resolved, deferred or rejected.",
"nextStep": "Refresh the page to see its current state."
},
"BOOKING_NOT_ACTIVE": {
"title": "Booking is not active",
"explanation": "Only a reserved or active booking can be used for this action."
},
"INVALID_BOOKING_STATE": {
"title": "Booking is in the wrong state",
"explanation": "This booking's current state does not allow this action."
},
"NOT_RETRYABLE": {
"title": "This event cannot be retried",
"explanation": "Only a failed delivery can be retried."
},
"CONFLICT_STILL_PRESENT": {
"title": "Conflict was not resolved",
"explanation": "Applying this change did not resolve the underlying conflict.",
"nextStep": "Review the recommendation again before retrying."
},
"OVERLAP_STILL_PRESENT": {
"title": "Overlap was not resolved",
"explanation": "Blocking this booking did not remove the overlap; another commitment remains."
},
"EMPTY_VALUE": {
"title": "A required field is blank",
"explanation": "This field cannot be left blank.",
"nextStep": "Enter a value and try again."
},
"NO_FIELDS_PROVIDED": {
"title": "No changes provided",
"explanation": "At least one field must be filled in to continue."
},
"INVALID_FIELD": {
"title": "Field not allowed here",
"explanation": "One of the fields provided is not permitted for this action."
},
"INVALID_FIELD_OVERRIDE": {
"title": "Field cannot be merged",
"explanation": "One of the selected fields cannot be used in this merge."
},
"INVALID_SURVIVOR": {
"title": "Invalid selection",
"explanation": "The record you selected to keep must be one of the two records being compared."
},
"INVALID_BOOKING_REFERENCE": {
"title": "Booking reference not valid here",
"explanation": "The selected booking is not one of the bookings related to this issue."
},
"INVALID_EVENT_ID": {
"title": "Invalid event reference",
"explanation": "This automation event reference is not valid."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "Request could not be repeated safely",
"explanation": "This request's tracking key is not valid.",
"nextStep": "Reload the page and try again."
},
"IDEMPOTENCY_KEY_REUSED": {
"title": "This action was already submitted",
"explanation": "An identical request was already processed with a different outcome.",
"nextStep": "Reload the page to see the current state before retrying."
},
"CORRECTED_VALUE_REQUIRED": {
"title": "A corrected value is required",
"explanation": "Choose \"correct the reading\" requires entering the corrected value."
},
"CORRECTION_BELOW_CANONICAL": {
"title": "Correction is below the confirmed reading",
"explanation": "A corrected odometer reading can never be lower than the last confirmed reading."
},
"NOT_A_DUPLICATE_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to possible-duplicate-customer issues."
},
"NOT_A_MISSING_FIELD_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to missing-required-field issues."
},
"NOT_AN_ODOMETER_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to odometer-regression issues."
},
"NOT_AN_OVERLAP_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to booking-overlap issues."
},
"NOT_A_STATUS_CONFLICT_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to vehicle-status-conflict issues."
},
"UNSUPPORTED_ENTITY": {
"title": "Not supported for this record type",
"explanation": "This action is not available for this kind of record."
},
"MANUAL_REVIEW_REQUIRED": {
"title": "Manual review required",
"explanation": "The facts for this vehicle contradict each other, so no automatic change is safe.",
"nextStep": "Defer or reject this issue, or investigate manually."
},
"NO_CONFLICT_DETECTED": {
"title": "Nothing to apply",
"explanation": "The current state no longer conflicts, so there is nothing left to apply."
},
"RECOMMENDATION_STALE": {
"title": "The situation has changed",
"explanation": "The underlying facts changed since this recommendation was shown.",
"nextStep": "Review the recommendation again before applying it."
},
"UNAUTHORIZED_SERVICE": {
"title": "Service authorisation failed",
"explanation": "This automated request could not be authorised."
}
},
"http": {
"401": {
"title": "Session expired",
"explanation": "Your session has expired.",
"nextStep": "Please log in again."
},
"403": {
"title": "Permission denied",
"explanation": "You don't have permission to perform this action."
},
"404": {
"title": "Not found",
"explanation": "This record could not be found."
},
"409": {
"title": "This action is no longer possible",
"explanation": "The underlying state has changed since this page was loaded.",
"nextStep": "Refresh the page and try again."
},
"422": {
"title": "Validation failed",
"explanation": "The information provided is not valid."
},
"500": {
"title": "Server error",
"explanation": "Something went wrong on our side."
},
"network": {
"title": "Connection unavailable",
"explanation": "The connection to the server is currently unavailable.",
"nextStep": "Check your connection and try again."
}
}
}
+178 -6
View File
@@ -1,8 +1,180 @@
{
"generic": "Une erreur est survenue. Veuillez réessayer.",
"workspaceLoadFailed": "Nous n'avons pas pu charger cet espace de travail.",
"unauthorized": "Votre session a expiré. Veuillez vous reconnecter.",
"forbidden": "Vous n'avez pas accès à cette section.",
"notFound": "Cette fiche est introuvable.",
"networkUnavailable": "La connexion au serveur est actuellement indisponible."
"generic": {
"title": "Une erreur s'est produite",
"explanation": "Cette action n'a pas pu être terminée. Veuillez réessayer."
},
"codes": {
"VEHICLE_NOT_FOUND": {
"title": "Véhicule introuvable",
"explanation": "Ce véhicule est introuvable. Il a peut-être été supprimé, ou la référence est incorrecte."
},
"BOOKING_NOT_FOUND": {
"title": "Réservation introuvable",
"explanation": "Cette réservation est introuvable. Elle a peut-être été supprimée, ou la référence est incorrecte."
},
"CUSTOMER_NOT_FOUND": {
"title": "Client introuvable",
"explanation": "Cette fiche client est introuvable."
},
"ENTITY_NOT_FOUND": {
"title": "Fiche introuvable",
"explanation": "La fiche sous-jacente à cette action est introuvable."
},
"EVENT_NOT_FOUND": {
"title": "Tâche d'automatisation introuvable",
"explanation": "Cet événement d'automatisation est introuvable."
},
"ISSUE_NOT_FOUND": {
"title": "Problème de qualité des données introuvable",
"explanation": "Ce problème de qualité des données est introuvable."
},
"ISSUE_NOT_OPEN": {
"title": "Le problème n'est plus ouvert",
"explanation": "Ce problème a déjà été résolu, reporté ou rejeté.",
"nextStep": "Actualisez la page pour voir son état actuel."
},
"BOOKING_NOT_ACTIVE": {
"title": "La réservation n'est pas active",
"explanation": "Seule une réservation réservée ou active peut être utilisée pour cette action."
},
"INVALID_BOOKING_STATE": {
"title": "La réservation est dans le mauvais état",
"explanation": "L'état actuel de cette réservation ne permet pas cette action."
},
"NOT_RETRYABLE": {
"title": "Cette tâche ne peut pas être relancée",
"explanation": "Seule une livraison échouée peut être relancée."
},
"CONFLICT_STILL_PRESENT": {
"title": "Le conflit n'a pas été résolu",
"explanation": "L'application de ce changement n'a pas résolu le conflit sous-jacent.",
"nextStep": "Consultez à nouveau la recommandation avant de réessayer."
},
"OVERLAP_STILL_PRESENT": {
"title": "Le chevauchement n'a pas été résolu",
"explanation": "Le blocage de cette réservation n'a pas supprimé le chevauchement ; un autre engagement subsiste."
},
"EMPTY_VALUE": {
"title": "Un champ requis est vide",
"explanation": "Ce champ ne peut pas rester vide.",
"nextStep": "Saisissez une valeur et réessayez."
},
"NO_FIELDS_PROVIDED": {
"title": "Aucune modification fournie",
"explanation": "Au moins un champ doit être complété pour continuer."
},
"INVALID_FIELD": {
"title": "Champ non autorisé ici",
"explanation": "L'un des champs fournis n'est pas autorisé pour cette action."
},
"INVALID_FIELD_OVERRIDE": {
"title": "Ce champ ne peut pas être fusionné",
"explanation": "L'un des champs sélectionnés ne peut pas être utilisé dans cette fusion."
},
"INVALID_SURVIVOR": {
"title": "Sélection non valide",
"explanation": "La fiche que vous souhaitez conserver doit être l'une des deux fiches comparées."
},
"INVALID_BOOKING_REFERENCE": {
"title": "Référence de réservation non valide ici",
"explanation": "La réservation sélectionnée ne fait pas partie des réservations liées à ce problème."
},
"INVALID_EVENT_ID": {
"title": "Référence d'événement non valide",
"explanation": "Cette référence d'événement d'automatisation n'est pas valide."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "La demande n'a pas pu être répétée en toute sécurité",
"explanation": "La clé de suivi de cette demande n'est pas valide.",
"nextStep": "Rechargez la page et réessayez."
},
"IDEMPOTENCY_KEY_REUSED": {
"title": "Cette action a déjà été soumise",
"explanation": "Une demande identique a déjà été traitée, avec un résultat différent.",
"nextStep": "Rechargez la page pour voir l'état actuel avant de réessayer."
},
"CORRECTED_VALUE_REQUIRED": {
"title": "Une valeur corrigée est requise",
"explanation": "Le choix « corriger le relevé » nécessite de saisir la valeur corrigée."
},
"CORRECTION_BELOW_CANONICAL": {
"title": "La correction est inférieure au relevé confirmé",
"explanation": "Un kilométrage corrigé ne peut jamais être inférieur au dernier relevé confirmé."
},
"NOT_A_DUPLICATE_ISSUE": {
"title": "Type de problème incorrect",
"explanation": "Cette action s'applique uniquement aux problèmes de client potentiellement en double."
},
"NOT_A_MISSING_FIELD_ISSUE": {
"title": "Type de problème incorrect",
"explanation": "Cette action s'applique uniquement aux problèmes de champ obligatoire manquant."
},
"NOT_AN_ODOMETER_ISSUE": {
"title": "Type de problème incorrect",
"explanation": "Cette action s'applique uniquement aux problèmes d'anomalie de kilométrage."
},
"NOT_AN_OVERLAP_ISSUE": {
"title": "Type de problème incorrect",
"explanation": "Cette action s'applique uniquement aux problèmes de chevauchement de réservations."
},
"NOT_A_STATUS_CONFLICT_ISSUE": {
"title": "Type de problème incorrect",
"explanation": "Cette action s'applique uniquement aux problèmes de conflit de statut du véhicule."
},
"UNSUPPORTED_ENTITY": {
"title": "Non pris en charge pour ce type de fiche",
"explanation": "Cette action n'est pas disponible pour ce type de fiche."
},
"MANUAL_REVIEW_REQUIRED": {
"title": "Évaluation manuelle requise",
"explanation": "Les faits concernant ce véhicule se contredisent, ce qui rend tout changement automatique non sûr.",
"nextStep": "Reportez ou rejetez ce problème, ou examinez-le manuellement."
},
"NO_CONFLICT_DETECTED": {
"title": "Rien à appliquer",
"explanation": "Le statut actuel correspond déjà aux faits, il n'y a donc plus rien à appliquer."
},
"RECOMMENDATION_STALE": {
"title": "La situation a changé entre-temps",
"explanation": "Les faits sous-jacents ont changé depuis l'affichage de cette recommandation.",
"nextStep": "Consultez à nouveau la recommandation avant de l'appliquer."
},
"UNAUTHORIZED_SERVICE": {
"title": "Échec de l'autorisation du service",
"explanation": "Cette demande automatisée n'a pas pu être autorisée."
}
},
"http": {
"401": {
"title": "Session expirée",
"explanation": "Votre session a expiré.",
"nextStep": "Veuillez vous reconnecter."
},
"403": {
"title": "Accès refusé",
"explanation": "Vous n'avez pas la permission d'effectuer cette action."
},
"404": {
"title": "Introuvable",
"explanation": "Cette fiche est introuvable."
},
"409": {
"title": "Cette action n'est plus possible",
"explanation": "L'état sous-jacent a changé depuis le chargement de cette page.",
"nextStep": "Actualisez la page et réessayez."
},
"422": {
"title": "Échec de la validation",
"explanation": "Les informations fournies ne sont pas valides."
},
"500": {
"title": "Erreur serveur",
"explanation": "Un problème est survenu de notre côté."
},
"network": {
"title": "Connexion indisponible",
"explanation": "La connexion au serveur est actuellement indisponible.",
"nextStep": "Vérifiez votre connexion et réessayez."
}
}
}
+178 -6
View File
@@ -1,8 +1,180 @@
{
"generic": "Er is een fout opgetreden. Probeer opnieuw.",
"workspaceLoadFailed": "We konden deze werkruimte niet laden.",
"unauthorized": "Je sessie is verlopen. Log opnieuw in.",
"forbidden": "Je hebt geen toegang tot dit onderdeel.",
"notFound": "Dit record kon niet gevonden worden.",
"networkUnavailable": "De verbinding met de server is momenteel niet beschikbaar."
"generic": {
"title": "Er is iets misgegaan",
"explanation": "Deze actie kon niet voltooid worden. Probeer opnieuw."
},
"codes": {
"VEHICLE_NOT_FOUND": {
"title": "Voertuig niet gevonden",
"explanation": "Dit voertuig kon niet gevonden worden. Het is mogelijk verwijderd, of de referentie klopt niet."
},
"BOOKING_NOT_FOUND": {
"title": "Boeking niet gevonden",
"explanation": "Deze boeking kon niet gevonden worden. Ze is mogelijk verwijderd, of de referentie klopt niet."
},
"CUSTOMER_NOT_FOUND": {
"title": "Klant niet gevonden",
"explanation": "Dit klantrecord kon niet gevonden worden."
},
"ENTITY_NOT_FOUND": {
"title": "Record niet gevonden",
"explanation": "Het onderliggende record voor deze actie kon niet gevonden worden."
},
"EVENT_NOT_FOUND": {
"title": "Automatiseringsopdracht niet gevonden",
"explanation": "Deze automatiseringsgebeurtenis kon niet gevonden worden."
},
"ISSUE_NOT_FOUND": {
"title": "Datakwaliteitsprobleem niet gevonden",
"explanation": "Dit datakwaliteitsprobleem kon niet gevonden worden."
},
"ISSUE_NOT_OPEN": {
"title": "Probleem is niet meer openstaand",
"explanation": "Dit probleem is al opgelost, uitgesteld of verworpen.",
"nextStep": "Vernieuw de pagina om de huidige status te zien."
},
"BOOKING_NOT_ACTIVE": {
"title": "Boeking is niet actief",
"explanation": "Enkel een gereserveerde of actieve boeking kan voor deze actie gebruikt worden."
},
"INVALID_BOOKING_STATE": {
"title": "Boeking heeft de verkeerde status",
"explanation": "De huidige status van deze boeking laat deze actie niet toe."
},
"NOT_RETRYABLE": {
"title": "Deze opdracht kan niet opnieuw geprobeerd worden",
"explanation": "Enkel een mislukte aflevering kan opnieuw geprobeerd worden."
},
"CONFLICT_STILL_PRESENT": {
"title": "Conflict is niet opgelost",
"explanation": "Het toepassen van deze wijziging heeft het onderliggende conflict niet opgelost.",
"nextStep": "Bekijk de aanbeveling opnieuw voordat je het nogmaals probeert."
},
"OVERLAP_STILL_PRESENT": {
"title": "Overlap is niet opgelost",
"explanation": "Het blokkeren van deze boeking heeft de overlap niet weggenomen; er blijft een andere verbintenis bestaan."
},
"EMPTY_VALUE": {
"title": "Een verplicht veld is leeg",
"explanation": "Dit veld mag niet leeg blijven.",
"nextStep": "Vul een waarde in en probeer opnieuw."
},
"NO_FIELDS_PROVIDED": {
"title": "Geen wijzigingen opgegeven",
"explanation": "Er moet minstens één veld ingevuld worden om verder te gaan."
},
"INVALID_FIELD": {
"title": "Veld hier niet toegelaten",
"explanation": "Eén van de opgegeven velden is niet toegelaten voor deze actie."
},
"INVALID_FIELD_OVERRIDE": {
"title": "Veld kan niet samengevoegd worden",
"explanation": "Eén van de gekozen velden kan niet gebruikt worden in deze samenvoeging."
},
"INVALID_SURVIVOR": {
"title": "Ongeldige keuze",
"explanation": "Het record dat je wil behouden moet één van de twee vergeleken records zijn."
},
"INVALID_BOOKING_REFERENCE": {
"title": "Boekingreferentie hier niet geldig",
"explanation": "De gekozen boeking hoort niet bij de boekingen die aan dit probleem gekoppeld zijn."
},
"INVALID_EVENT_ID": {
"title": "Ongeldige opdrachtreferentie",
"explanation": "Deze referentie naar een automatiseringsopdracht is niet geldig."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "Aanvraag kon niet veilig herhaald worden",
"explanation": "De trackingsleutel van deze aanvraag is niet geldig.",
"nextStep": "Herlaad de pagina en probeer opnieuw."
},
"IDEMPOTENCY_KEY_REUSED": {
"title": "Deze actie werd al ingediend",
"explanation": "Een identieke aanvraag werd al verwerkt, met een ander resultaat.",
"nextStep": "Herlaad de pagina om de huidige status te zien voordat je het nogmaals probeert."
},
"CORRECTED_VALUE_REQUIRED": {
"title": "Een gecorrigeerde waarde is vereist",
"explanation": "Voor de keuze \"stand corrigeren\" moet je de gecorrigeerde waarde invullen."
},
"CORRECTION_BELOW_CANONICAL": {
"title": "Correctie ligt onder de bevestigde stand",
"explanation": "Een gecorrigeerde kilometerstand mag nooit lager zijn dan de laatst bevestigde stand."
},
"NOT_A_DUPLICATE_ISSUE": {
"title": "Verkeerd probleemtype",
"explanation": "Deze actie geldt enkel voor problemen van het type mogelijke dubbele klant."
},
"NOT_A_MISSING_FIELD_ISSUE": {
"title": "Verkeerd probleemtype",
"explanation": "Deze actie geldt enkel voor problemen met een ontbrekend verplicht veld."
},
"NOT_AN_ODOMETER_ISSUE": {
"title": "Verkeerd probleemtype",
"explanation": "Deze actie geldt enkel voor problemen met een afwijkende kilometerstand."
},
"NOT_AN_OVERLAP_ISSUE": {
"title": "Verkeerd probleemtype",
"explanation": "Deze actie geldt enkel voor problemen met een overlappende boeking."
},
"NOT_A_STATUS_CONFLICT_ISSUE": {
"title": "Verkeerd probleemtype",
"explanation": "Deze actie geldt enkel voor problemen met een statusconflict van het voertuig."
},
"UNSUPPORTED_ENTITY": {
"title": "Niet ondersteund voor dit recordtype",
"explanation": "Deze actie is niet beschikbaar voor dit soort record."
},
"MANUAL_REVIEW_REQUIRED": {
"title": "Handmatige beoordeling vereist",
"explanation": "De feiten voor dit voertuig spreken elkaar tegen, waardoor geen automatische wijziging veilig is.",
"nextStep": "Stel dit probleem uit of verwerp het, of onderzoek het handmatig."
},
"NO_CONFLICT_DETECTED": {
"title": "Niets om toe te passen",
"explanation": "De huidige status komt al overeen met de feiten, dus er is niets meer om toe te passen."
},
"RECOMMENDATION_STALE": {
"title": "De situatie is intussen gewijzigd",
"explanation": "De onderliggende feiten zijn gewijzigd sinds deze aanbeveling getoond werd.",
"nextStep": "Bekijk de aanbeveling opnieuw voordat je ze toepast."
},
"UNAUTHORIZED_SERVICE": {
"title": "Dienstautorisatie mislukt",
"explanation": "Deze geautomatiseerde aanvraag kon niet geautoriseerd worden."
}
},
"http": {
"401": {
"title": "Sessie verlopen",
"explanation": "Je sessie is verlopen.",
"nextStep": "Log opnieuw in."
},
"403": {
"title": "Geen toegang",
"explanation": "Je hebt geen toestemming om deze actie uit te voeren."
},
"404": {
"title": "Niet gevonden",
"explanation": "Dit record kon niet gevonden worden."
},
"409": {
"title": "Deze actie is niet meer mogelijk",
"explanation": "De onderliggende status is gewijzigd sinds deze pagina geladen werd.",
"nextStep": "Vernieuw de pagina en probeer opnieuw."
},
"422": {
"title": "Validatie mislukt",
"explanation": "De opgegeven informatie is niet geldig."
},
"500": {
"title": "Serverfout",
"explanation": "Er is iets misgegaan aan onze kant."
},
"network": {
"title": "Verbinding niet beschikbaar",
"explanation": "De verbinding met de server is momenteel niet beschikbaar.",
"nextStep": "Controleer je verbinding en probeer opnieuw."
}
}
}
+6 -5
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { AutomationRun, IntegrationStatus, KnowledgeHealth } from "../api/types";
import { StatusBadge } from "../components/Badge";
import { useAuth } from "../context/AuthContext";
import { useLocaleFormat } from "../i18n/format";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { ApiErrorNotice, ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
type ViewFilter = "attention" | "recent" | "succeeded" | "all";
@@ -25,7 +26,7 @@ export function Automation() {
const [status, setStatus] = useState("");
const [view, setView] = useState<ViewFilter>("attention");
const [expandSucceeded, setExpandSucceeded] = useState(false);
const [retryError, setRetryError] = useState<string | null>(null);
const [retryError, setRetryError] = useState<ApiErrorInfo | null>(null);
const [retrying, setRetrying] = useState<string | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
@@ -70,7 +71,7 @@ export function Automation() {
load();
loadIntegrationStatus();
} catch (err) {
setRetryError(err instanceof ApiError ? err.message : t("ledger.retryFailed"));
setRetryError(describeApiError(t, err, "ledger.retryFailed"));
} finally {
setRetrying(null);
}
@@ -242,7 +243,7 @@ export function Automation() {
</form>
{error && <ErrorState message={error} />}
{retryError && <p className="error" role="alert">{retryError}</p>}
<ApiErrorNotice error={retryError} />
{!error && !runs && <LoadingState label={t("ledger.loading")} />}
{visibleRuns && visibleRuns.length === 0 && <p>{t("ledger.empty")}</p>}
+6 -5
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { DataQualityIssue, ScanResult } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
const RULE_TYPES = [
"possible_duplicate_customer",
@@ -23,7 +24,7 @@ export function DataQuality() {
const [status, setStatus] = useState("open");
const [ruleType, setRuleType] = useState("");
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
@@ -54,7 +55,7 @@ export function DataQuality() {
setConfirmingScan(false);
load();
} catch (err) {
setScanError(err instanceof ApiError ? err.message : t("list.scanFailed"));
setScanError(describeApiError(t, err, "list.scanFailed"));
} finally {
setScanning(false);
}
@@ -101,7 +102,7 @@ export function DataQuality() {
}
/>
{scanError && <p className="error" role="alert">{scanError}</p>}
<ApiErrorNotice error={scanError} />
{scanResult && (
<p className="quiet-empty" role="status">
{t("list.scanComplete", {
+22 -23
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type {
ApplyRecommendedStatusResult,
DataQualityIssueDetail as IssueDetail,
@@ -15,7 +16,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { ApiErrorNotice, ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
@@ -124,7 +125,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const { user } = useAuth();
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
const [fieldChoices, setFieldChoices] = useState<Record<string, "a" | "b">>({});
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [confirming, setConfirming] = useState(false);
@@ -155,7 +156,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.duplicateCustomer.mergeFailed"));
setError(describeApiError(t, err, "detail.duplicateCustomer.mergeFailed"));
setConfirming(false);
} finally {
setSubmitting(false);
@@ -171,7 +172,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
return (
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
<SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} />
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<fieldset className="choice-fieldset">
<legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
@@ -210,7 +211,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const differ = valueA !== valueB;
return (
<tr key={field}>
<th scope="row" data-label="Field">{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? <span className="difference-mark">{t("detail.duplicateCustomer.differs")}</span> : <span className="match-mark">{t("detail.duplicateCustomer.match")}</span>}</th>
<th scope="row" data-label={t("detail.duplicateCustomer.fieldColumn")}>{t(`detail.duplicateCustomer.fields.${field}`)}{differ ? <span className="difference-mark">{t("detail.duplicateCustomer.differs")}</span> : <span className="match-mark">{t("detail.duplicateCustomer.match")}</span>}</th>
<td data-label={a.public_ref}>
{differ ? (
<label className="checkbox-label">
@@ -285,7 +286,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
}
return initial;
});
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
@@ -299,7 +300,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields });
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.missingField.saveFailed"));
setError(describeApiError(t, err, "detail.missingField.saveFailed"));
} finally {
setSubmitting(false);
}
@@ -312,7 +313,7 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
title={t("detail.missingField.heading")}
description={t("detail.missingField.description", { ref: snapshot?.public_ref ?? issue.entity_ref })}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<div className="form-grid">
{fieldKeys.map((field) => (
<label key={field}>
@@ -345,7 +346,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
const [correctedValue, setCorrectedValue] = useState("");
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
@@ -362,7 +363,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.odometerRegression.resolveFailed"));
setError(describeApiError(t, err, "detail.odometerRegression.resolveFailed"));
} finally {
setSubmitting(false);
}
@@ -375,7 +376,7 @@ function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; on
title={t("detail.odometerRegression.heading")}
description={t("detail.odometerRegression.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<dl className="detail-grid">
<div><dt>{t("detail.odometerRegression.canonicalOdometer")}</dt><dd>{formatNumber(Number(issue.entity_snapshot?.odometer_km ?? 0))} km</dd></div>
</dl>
@@ -453,7 +454,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
const bookings = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const [bookingRef, setBookingRef] = useState(bookings[0]?.public_ref ?? "");
const [note, setNote] = useState("");
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
@@ -467,7 +468,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
});
onResolved();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.bookingOverlap.resolveFailed"));
setError(describeApiError(t, err, "detail.bookingOverlap.resolveFailed"));
} finally {
setSubmitting(false);
}
@@ -480,7 +481,7 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
title={t("detail.bookingOverlap.heading")}
description={t("detail.bookingOverlap.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<fieldset className="choice-fieldset choice-fieldset-grid">
<legend className="visually-hidden">{t("detail.bookingOverlap.columns.blockThis")}</legend>
{bookings.map((b) => (
@@ -570,7 +571,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
const [recommendation, setRecommendation] = useState<StatusRecommendation | null>(null);
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
const [stale, setStale] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -596,7 +597,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
);
setRecommendation(preview);
} catch (err) {
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.recommendationFailed"));
setError(describeApiError(t, err, "detail.vehicleStatusConflict.recommendationFailed"));
} finally {
setLoadingRecommendation(false);
}
@@ -614,12 +615,10 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
setResult(applied);
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.vehicleStatusConflict.applyFailed"));
if (err instanceof ApiError && err.code === "RECOMMENDATION_STALE") {
setError(t("detail.vehicleStatusConflict.staleRecommendation"));
setRecommendation(null);
setStale(true);
} else {
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
}
} finally {
setSubmitting(false);
@@ -645,7 +644,7 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
title={t("detail.vehicleStatusConflict.heading")}
description={t("detail.vehicleStatusConflict.description")}
/>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<dl className="detail-grid">
<div><dt>{t("detail.vehicleStatusConflict.currentStatus")}</dt><dd><StatusBadge status={String(issue.entity_snapshot?.operational_status ?? "")} label={t(`fleet:statuses.${issue.entity_snapshot?.operational_status}`, { defaultValue: String(issue.entity_snapshot?.operational_status ?? "") })} /></dd></div>
</dl>
@@ -739,7 +738,7 @@ export function DataQualityIssueDetail() {
const { publicRef } = useParams<{ publicRef: string }>();
const [issue, setIssue] = useState<IssueDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [justResolved, setJustResolved] = useState(false);
const load = useCallback(() => {
@@ -776,7 +775,7 @@ export function DataQualityIssueDetail() {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
load();
} catch (err) {
setActionError(err instanceof ApiError ? err.message : t(`detail.deferOrReject.${action}Failed`));
setActionError(describeApiError(t, err, `detail.deferOrReject.${action}Failed`));
}
}
@@ -808,7 +807,7 @@ export function DataQualityIssueDetail() {
<RuleExplainer ruleType={issue.rule_type} />
{actionError && <p className="error" role="alert">{actionError}</p>}
<ApiErrorNotice error={actionError} />
{justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && (
<section className="panel success-panel" aria-live="polite">
+6 -5
View File
@@ -1,9 +1,10 @@
import { useEffect, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { GroundedAnswer, KnowledgeHealth } from "../api/types";
import { Icon } from "../components/Icons";
import { PageHeader } from "../components/PageChrome";
import { ApiErrorNotice, PageHeader } from "../components/PageChrome";
import { PRODUCT_NAME } from "../product";
interface Exchange {
@@ -16,7 +17,7 @@ export function Knowledge() {
const [status, setStatus] = useState<KnowledgeHealth | null>(null);
const [question, setQuestion] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [exchanges, setExchanges] = useState<Exchange[]>([]);
const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE";
@@ -39,7 +40,7 @@ export function Knowledge() {
setExchanges((prev) => [{ question: questionText, answer }, ...prev]);
setQuestion("");
} catch (err) {
setError(err instanceof ApiError ? err.message : t("askFailed"));
setError(describeApiError(t, err, "askFailed"));
} finally {
setSubmitting(false);
}
@@ -107,7 +108,7 @@ export function Knowledge() {
{!submitting && <Icon name="chevron" />}
</button>
</div>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
<div className="knowledge-suggestions">
<span>{t("suggestedLabel")}</span>
{suggestedQuestions.map((q) => (
+6
View File
@@ -347,6 +347,12 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
.error { color: #9f2929; font-size: .74rem; font-weight: 600; }
.api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }
.api-error-notice strong { display: block; font-size: .78rem; }
.api-error-notice p { margin: 4px 0 0; font-weight: 400; }
.api-error-notice .api-error-next-step { color: #7a2323; font-style: italic; }
.api-error-notice .evidence-disclosure { margin-top: 8px; }
.api-error-notice .evidence-disclosure summary { font-weight: 700; cursor: pointer; }
.login-shell { min-height: 100vh; display: grid; grid-template-columns: minmax(420px, 1.05fr) minmax(430px, .95fr); background: white; }
.login-story { position: relative; min-height: 100vh; display: flex; flex-direction: column; padding: 34px 7vw 30px; overflow: hidden; color: white; background: var(--petrol); }