Publish curated MobilityOps source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:03:33 +02:00
commit 7adc072f5e
317 files changed
+48851

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
import { lazy, Suspense, type ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext";
import { DemoManifestProvider } from "./context/DemoManifestContext";
import { DemoGuideProvider } from "./context/DemoGuideContext";
import { Layout } from "./components/Layout";
import { RequireAuth } from "./components/RequireAuth";
const Login = lazy(() => import("./pages/Login").then((module) => ({ default: module.Login })));
const Dashboard = lazy(() => import("./pages/Dashboard").then((module) => ({ default: module.Dashboard })));
const Vehicles = lazy(() => import("./pages/Vehicles").then((module) => ({ default: module.Vehicles })));
const VehicleDetail = lazy(() => import("./pages/VehicleDetail").then((module) => ({ default: module.VehicleDetail })));
const Bookings = lazy(() => import("./pages/Bookings").then((module) => ({ default: module.Bookings })));
const BookingDetail = lazy(() => import("./pages/BookingDetail").then((module) => ({ default: module.BookingDetail })));
const BookingCreate = lazy(() => import("./pages/BookingCreate").then((module) => ({ default: module.BookingCreate })));
const DataQuality = lazy(() => import("./pages/DataQuality").then((module) => ({ default: module.DataQuality })));
const DataQualityIssueDetail = lazy(() => import("./pages/DataQualityIssueDetail").then((module) => ({ default: module.DataQualityIssueDetail })));
const Automation = lazy(() => import("./pages/Automation").then((module) => ({ default: module.Automation })));
const Knowledge = lazy(() => import("./pages/Knowledge").then((module) => ({ default: module.Knowledge })));
const Audit = lazy(() => import("./pages/Audit").then((module) => ({ default: module.Audit })));
const AboutDemo = lazy(() => import("./pages/AboutDemo").then((module) => ({ default: module.AboutDemo })));
const Scenarios = lazy(() => import("./pages/Scenarios").then((module) => ({ default: module.Scenarios })));
const Users = lazy(() => import("./pages/Users").then((module) => ({ default: module.Users })));
const Privacy = lazy(() => import("./pages/Privacy").then((module) => ({ default: module.Privacy })));
const Highlights = lazy(() => import("./pages/Highlights").then((module) => ({ default: module.Highlights })));
function deferredPage(element: ReactNode) {
return <Suspense fallback={<div className="route-loading" role="status" aria-label="Loading"><div className="route-skeleton"><span /><span /><span /></div></div>}>{element}</Suspense>;
}
export function App() {
return (
<DemoManifestProvider>
<AuthProvider>
<DemoGuideProvider>
<Routes>
<Route path="/login" element={deferredPage(<Login />)} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/dashboard" element={deferredPage(<Dashboard />)} />
<Route path="/vehicles" element={deferredPage(<Vehicles />)} />
<Route path="/vehicles/:publicRef" element={deferredPage(<VehicleDetail />)} />
<Route path="/bookings" element={deferredPage(<Bookings />)} />
<Route path="/bookings/new" element={deferredPage(<BookingCreate />)} />
<Route path="/bookings/:publicRef" element={deferredPage(<BookingDetail />)} />
<Route path="/data-quality" element={deferredPage(<DataQuality />)} />
<Route path="/data-quality/:publicRef" element={deferredPage(<DataQualityIssueDetail />)} />
<Route path="/automation" element={deferredPage(<Automation />)} />
<Route path="/knowledge" element={deferredPage(<Knowledge />)} />
<Route path="/audit" element={deferredPage(<Audit />)} />
<Route path="/users" element={deferredPage(<Users />)} />
<Route path="/privacy" element={deferredPage(<Privacy />)} />
<Route path="/about" element={deferredPage(<AboutDemo />)} />
<Route path="/scenarios" element={deferredPage(<Scenarios />)} />
<Route path="/highlights" element={deferredPage(<Highlights />)} />
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</DemoGuideProvider>
</AuthProvider>
</DemoManifestProvider>
);
}
+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;
}
}
+86
View File
@@ -0,0 +1,86 @@
import { ApiError } from "./apiError";
export { ApiError } from "./apiError";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
type UnauthorizedListener = () => void;
const unauthorizedListeners = new Set<UnauthorizedListener>();
export function onUnauthorized(listener: UnauthorizedListener): () => void {
unauthorizedListeners.add(listener);
return () => unauthorizedListeners.delete(listener);
}
const DEFAULT_TIMEOUT_MS = 15_000;
/** `AbortSignal.any` with a fallback for browsers that predate it (Safari < 17.4, etc.). */
function combineSignals(caller: AbortSignal, timeout: AbortSignal): AbortSignal {
if (typeof AbortSignal.any === "function") return AbortSignal.any([caller, timeout]);
const combined = new AbortController();
const forward = (source: AbortSignal) => () => combined.abort(source.reason);
if (caller.aborted) combined.abort(caller.reason);
else caller.addEventListener("abort", forward(caller), { once: true });
if (timeout.aborted) combined.abort(timeout.reason);
else timeout.addEventListener("abort", forward(timeout), { once: true });
return combined.signal;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const timeoutController = new AbortController();
const timeout = window.setTimeout(() => timeoutController.abort("timeout"), DEFAULT_TIMEOUT_MS);
const signal = init?.signal
? combineSignals(init.signal, timeoutController.signal)
: timeoutController.signal;
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`, {
...init,
signal,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
} catch (error) {
if (timeoutController.signal.aborted && !init?.signal?.aborted) {
throw new ApiError(408, "REQUEST_TIMEOUT", "The request took too long.", "");
}
throw error;
} finally {
window.clearTimeout(timeout);
}
if (!response.ok) {
let body: { error?: { code: string; message: string; correlation_id: string } } | undefined;
try {
body = await response.json();
} catch {
body = undefined;
}
const error = body?.error;
if (response.status === 401) {
unauthorizedListeners.forEach((listener) => listener());
}
throw new ApiError(
response.status,
error?.code ?? String(response.status),
error?.message ?? response.statusText,
error?.correlation_id ?? "",
);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export const api = {
get: <T>(path: string, init?: Pick<RequestInit, "signal">) => request<T>(path, init),
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
request<T>(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
};
+106
View File
@@ -0,0 +1,106 @@
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",
"CUSTOMER_ALREADY_MERGED",
"ENTITY_NOT_FOUND",
"EVENT_NOT_FOUND",
"ISSUE_NOT_FOUND",
"ISSUE_NOT_OPEN",
"ISSUE_CHANGED",
"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_EVENT_CORRELATION",
"CALLBACK_EVENT_MISMATCH",
"CALLBACK_CORRELATION_MISMATCH",
"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",
"UNKNOWN_WORKFLOW",
]);
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,
};
}
+442
View File
@@ -0,0 +1,442 @@
export type Role = "operations_manager" | "rental_employee";
export interface CurrentUser {
public_ref: string;
display_name: string;
role: Role;
}
export interface SystemStatus {
service: string;
environment: string;
demo_mode: boolean;
knowledge_provider: string;
oidc_enabled: boolean;
oidc_provider_name: string | null;
}
export interface Vehicle {
public_ref: string;
make: string;
model: string;
model_year: number;
registration_number: string;
location: string;
operational_status: string;
odometer_km: number;
next_service_km: number;
active: boolean;
attention: boolean;
attention_reason: "blocked_status" | "service_due" | "data_quality" | null;
service_remaining_km: number;
next_booking_ref: string | null;
next_booking_at: string | null;
}
export interface Page<T> {
items: T[];
page: number;
page_size: number;
total: number;
total_pages: number;
}
export interface BookingSummary {
public_ref: string;
customer_ref: string;
vehicle_ref: string;
starts_at: string;
ends_at: string;
status: string;
}
export interface Booking extends BookingSummary {
start_odometer_km: number | null;
end_odometer_km: number | null;
requirements_complete: boolean;
customer_name: string;
}
export interface CustomerOption {
public_ref: string;
display_name: string;
email: string | null;
}
export interface AvailableVehicle {
public_ref: string;
make: string;
model: string;
registration_number: string;
location: string;
operational_status: string;
}
export interface CheckoutBookingResult {
booking_ref: string;
vehicle_ref: string;
inspection_ref: string;
booking_status: string;
resulting_vehicle_status: string;
activated: boolean;
attention_reasons: string[];
}
export interface UserRecord {
public_ref: string;
email: string | null;
display_name: string;
role: Role;
active: boolean;
}
export interface PrivacyRetention {
minimum_booking_retention_days: number;
audit_retention_days: number;
customers_total: number;
customers_anonymized: number;
customers_eligible: number;
}
export interface CustomerAnonymizeResult {
public_ref: string;
anonymized_at: string;
status: "anonymized" | "already_anonymized";
}
export interface Inspection {
public_ref: string;
booking_ref: string;
type: string;
fuel_level_percent: number;
cleanliness_ok: boolean;
damage_reported: boolean;
technical_warning: boolean;
odometer_km: number;
completed_at: string;
}
export interface MaintenanceRecord {
public_ref: string;
occurred_at: string;
odometer_km: number;
category: string;
summary: string;
}
export interface DataQualityIssue {
public_ref: string;
rule_type: string;
entity_type: string;
entity_ref: string;
severity: "low" | "medium" | "high";
status: "open" | "deferred" | "resolved" | "rejected";
evidence: Record<string, unknown>;
detected_at: string;
due_at: string | null;
assigned_to_ref: string | null;
assigned_to_name: string | null;
overdue: boolean;
resolved_at: string | null;
}
export interface VehicleDetail extends Vehicle {
bookings: BookingSummary[];
inspections: Inspection[];
maintenance: MaintenanceRecord[];
quality_issues: DataQualityIssue[];
}
export interface DashboardMetrics {
available: number;
rented: number;
cleaning: number;
maintenance: number;
blocked: number;
open_quality_issues: number;
pending_or_failed_workflows: number;
}
export interface EvidenceSignal {
code: string;
params?: Record<string, unknown>;
}
export interface AttentionItem {
kind: string;
severity: "low" | "medium" | "high";
rule_type: string;
evidence_signals: EvidenceSignal[];
link_type: "vehicle" | "booking" | "customer";
link_ref: string;
issue_ref: string | null;
}
export interface TodayItem {
kind: "departure" | "return";
booking_ref: string;
vehicle_ref: string;
scheduled_at: string;
}
export interface AutomationRun {
event_id: string;
event_type: string;
aggregate_ref: string;
status: string;
attempts: number;
last_error: string | null;
last_error_code: string | null;
/** True for the failure the demo seed plants on purpose (see the backend's
* DEMO_SCENARIO_ERROR_CODE). Drives the "prepared scenario" labelling instead of
* showing it as an unexplained production error. */
is_demo_scenario: boolean;
occurred_at: string;
}
export interface Dashboard {
metrics: DashboardMetrics;
attention_items: AttentionItem[];
today: TodayItem[];
recent_automation: AutomationRun[];
}
export interface RegisterReturnRequest {
end_odometer_km: number;
fuel_level_percent: number;
cleanliness_ok: boolean;
damage_reported: boolean;
technical_warning: boolean;
notes?: string;
}
export interface NextBookingRisk {
booking_ref: string;
starts_at: string;
at_risk: boolean;
}
export interface RegisterReturnResult {
booking_ref: string;
vehicle_ref: string;
inspection_ref: string;
resulting_vehicle_status: string;
odometer_regression: boolean;
quality_issue_ref: string | null;
workflow_event_id: string;
correlation_id: string;
next_booking_risk: NextBookingRisk | null;
}
export interface ReturnPreviewResult {
booking_ref: string;
vehicle_ref: string;
canonical_odometer_km: number;
submitted_odometer_km: number;
odometer_regression: boolean;
resulting_odometer_km: number;
resulting_vehicle_status: string;
status_reason: string;
status_reason_code: string;
status_reason_params: Record<string, string | number>;
would_create_quality_issue: boolean;
attention_reasons: string[];
next_booking_risk: NextBookingRisk | null;
}
export interface EntitySnapshot {
entity_type: "customer" | "vehicle" | "booking" | "inspection" | "maintenance";
public_ref: string;
[key: string]: unknown;
}
export interface DataQualityIssueDetail extends DataQualityIssue {
entity_snapshot: EntitySnapshot | null;
related_snapshots: EntitySnapshot[];
}
export interface ApplyRecommendedStatusResult {
issue: DataQualityIssue;
applied_status: string;
reason_code: string;
}
export interface VehicleStatusFacts {
active_booking_refs: string[];
overlapping_booking_pairs: string[][];
service_threshold_reached: boolean;
odometer_km: number;
next_service_km: number;
open_booking_overlap_issue_ref: string | null;
}
export interface StatusRecommendation {
current_status: string;
recommended_status: string | null;
recommendation_code: string;
safe_to_apply: boolean;
manual_review_required: boolean;
facts: VehicleStatusFacts;
blocking_reasons: string[];
recommendation_token: string;
}
export interface ScanResult {
created: Record<string, number>;
}
export interface SearchResultItem {
type: "vehicle" | "booking" | "data_quality_issue" | "section";
label: string;
detail_code: string;
detail_params: Record<string, string>;
link: string;
}
export interface SearchResponse {
query: string;
results: SearchResultItem[];
}
export interface MergeCustomersRequest {
survivor_ref: string;
field_overrides?: Record<string, string>;
}
export interface MergeCustomersResult {
issue_ref: string;
survivor_ref: string;
loser_ref: string;
rewired_bookings: number;
}
export interface SourceCard {
document_id: string;
title: string;
version: string;
section: string;
excerpt: string;
}
export interface GroundedAnswer {
answer: string;
evidence_state: "grounded" | "insufficient" | "unavailable";
sources: SourceCard[];
provider: string;
correlation_id: string;
}
export interface KnowledgeHealth {
provider: string;
available: boolean;
detail: string;
tenant: string;
workspace: string;
collection: string;
document_count: number | null;
source_document_count: number;
reported_synced_document_count: number | null;
reported_failed_document_count: number | null;
last_sync_at: string | null;
statistics_state: "verified" | "sync_reported" | "not_reported";
}
export interface N8nWorkflowEvidence {
name: string;
built: boolean;
last_seen_at: string | null;
state: "no_evidence" | "healthy" | "stale" | "failed";
last_status: "succeeded" | "failed" | null;
last_execution_id: string | null;
}
export interface N8nErrorHandlerStatus {
total_failures_registered: number;
latest_failure_at: string | null;
latest_failure_workflow: string | null;
}
export interface N8nIntegrationStatus {
configured: boolean;
dispatch_enabled: boolean;
state: "disabled" | "unavailable" | "degraded" | "operational" | "no_evidence";
pending: number;
delivering: number;
/** Every failed delivery, prepared and real together. */
failed: number;
/** Failures that were not planted by the demo seed -- the only ones that affect state. */
unexpected_failed: number;
/** Prepared demo failures. Visible and counted, but never a health signal. */
demo_scenario_failed: number;
succeeded: number;
latest_success_at: string | null;
/** Most recent real failure; a prepared one never sets this. */
latest_failure_at: string | null;
latest_demo_scenario_at: string | null;
expected_workflow_count: number;
known_workflow_count: number;
workflows: N8nWorkflowEvidence[];
error_handler: N8nErrorHandlerStatus;
}
export interface McpHubIntegrationStatus {
registration_enabled: boolean;
state: "not_configured" | "no_evidence" | "operational";
total_calls: number;
last_tool: string | null;
last_client: string | null;
last_called_at: string | null;
hub_reachable: boolean | null;
}
export interface IntegrationStatus {
n8n: N8nIntegrationStatus;
mcp_hub: McpHubIntegrationStatus;
}
export interface DemoScenario {
id: string;
estimated_minutes: number;
required_roles: Role[];
start_path: string;
ready: boolean;
blocked_reason_code: string | null;
blocked_reason_params: Record<string, string>;
}
export interface DemoIntegrationSummary {
key: "n8n" | "ragcore" | "mcp_hub";
status_code: string;
detail_code: string;
detail_params: Record<string, string | number>;
}
export interface DemoManifest {
demo_mode: boolean;
organization_name: string;
timezone: string;
synthetic_data: boolean;
allow_reset: boolean;
last_reset_at: string | null;
anchor_date: string | null;
guide_available: boolean;
required_roles: Role[];
scenarios: DemoScenario[];
integrations: DemoIntegrationSummary[];
}
export interface AuditEvent {
id: string;
actor_type: string;
actor_label: string;
action: string;
entity_type: string;
entity_id: string | null;
entity_ref: string | null;
entity_link: string | null;
correlation_id: string;
occurred_at: string;
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
metadata: Record<string, unknown> | null;
}
@@ -0,0 +1,104 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Icon, type IconName } from "./Icons";
import "../styles-architecture-flow.css";
type ArchitectureStepId = "Frontend" | "Api" | "Database" | "Outbox" | "External";
type ArchitectureZone = "intent" | "transaction" | "edge";
const ARCHITECTURE_STEPS: Array<{
id: ArchitectureStepId;
icon: IconName;
zone: ArchitectureZone;
}> = [
{ id: "Frontend", icon: "user", zone: "intent" },
{ id: "Api", icon: "shield", zone: "transaction" },
{ id: "Database", icon: "audit", zone: "transaction" },
{ id: "Outbox", icon: "activity", zone: "transaction" },
{ id: "External", icon: "integrations", zone: "edge" },
];
const ZONE_TRANSLATION_KEYS: Record<ArchitectureZone, string> = {
intent: "architectureZoneIntent",
transaction: "architectureZoneTransaction",
edge: "architectureZoneEdge",
};
export function ArchitectureFlow() {
const { t } = useTranslation("demo");
const [activeStepId, setActiveStepId] = useState<ArchitectureStepId>("Database");
const activeIndex = ARCHITECTURE_STEPS.findIndex((step) => step.id === activeStepId);
const activeStep = ARCHITECTURE_STEPS[activeIndex];
return (
<div className="architecture-explorer">
<div className="architecture-zones" aria-label={t("about.architectureZonesLabel")}>
<span className="zone-intent"><Icon name="user" /> {t("about.architectureZoneIntent")}</span>
<span className="zone-transaction"><Icon name="shield" /> {t("about.architectureZoneTransaction")}</span>
<span className="zone-edge"><Icon name="integrations" /> {t("about.architectureZoneEdge")}</span>
</div>
<ol className="architecture-flow" aria-label={t("about.architectureFlowLabel")}>
{ARCHITECTURE_STEPS.map((step, index) => {
const isActive = step.id === activeStepId;
const isCommitBoundary = step.id === "Outbox";
return (
<li
className={`architecture-step architecture-step-${step.zone}${isActive ? " is-active" : ""}`}
key={step.id}
>
<button
type="button"
aria-pressed={isActive}
aria-controls="architecture-active-detail"
onClick={() => setActiveStepId(step.id)}
>
<span className="architecture-step-top">
<span className="architecture-step-number">0{index + 1}</span>
<span className="architecture-step-icon"><Icon name={step.icon} /></span>
</span>
<strong>{t(`about.architecture${step.id}`)}</strong>
<small>{t(`about.architectureDetails.${step.id}.summary`)}</small>
<span className="architecture-step-meta">
<span className="architecture-zone-tag">{t(`about.${ZONE_TRANSLATION_KEYS[step.zone]}`)}</span>
{isCommitBoundary ? <span className="architecture-commit-note">→ {t("about.architectureCommitBoundary")}</span> : null}
</span>
</button>
{index < ARCHITECTURE_STEPS.length - 1 ? (
<span className={`architecture-connector${isCommitBoundary ? " is-commit-boundary" : ""}`} aria-hidden="true">
<span className="architecture-signal" />
</span>
) : null}
</li>
);
})}
</ol>
<p className="architecture-interaction-hint">{t("about.architectureInteractionHint")}</p>
<section
className={`architecture-active-detail architecture-active-detail-${activeStep.zone}`}
id="architecture-active-detail"
aria-live="polite"
>
<div className="architecture-active-copy">
<span>{t("about.architectureSelectedStep", { current: activeIndex + 1, total: ARCHITECTURE_STEPS.length })}</span>
<h3>{t(`about.architecture${activeStep.id}`)}</h3>
<p>{t(`about.architectureDetails.${activeStep.id}.body`)}</p>
</div>
<dl className="architecture-active-evidence">
<div>
<dt>{t("about.architectureBoundaryLabel")}</dt>
<dd>{t(`about.${ZONE_TRANSLATION_KEYS[activeStep.zone]}`)}</dd>
</div>
<div>
<dt>{t("about.architectureEvidenceLabel")}</dt>
<dd>
<span>{t(`about.architectureDetails.${activeStep.id}.proofOne`)}</span>
<span>{t(`about.architectureDetails.${activeStep.id}.proofTwo`)}</span>
</dd>
</div>
</dl>
</section>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { useTranslation } from "react-i18next";
export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
const { t } = useTranslation("quality");
return <span className={`badge severity-${severity}`}>{t(`severities.${severity}`)}</span>;
}
export function StatusBadge({ status, label }: { status: string; label?: string }) {
return <span className={`badge status-${status}`}>{label ?? status.replace(/_/g, " ")}</span>;
}
+65
View File
@@ -0,0 +1,65 @@
import { FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { CheckoutBookingResult, VehicleDetail } from "../api/types";
import { ApiErrorNotice, LoadingState, SectionHeading } from "./PageChrome";
export function CheckoutForm({ bookingRef, vehicleRef, onRecorded }: { bookingRef: string; vehicleRef: string; onRecorded: (result: CheckoutBookingResult) => void }) {
const { t } = useTranslation(["bookings", "errors"]);
const [odometer, setOdometer] = useState<number | null>(null);
const [fuel, setFuel] = useState(100);
const [clean, setClean] = useState(true);
const [damage, setDamage] = useState(false);
const [warning, setWarning] = useState(false);
const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
useEffect(() => {
api.get<VehicleDetail>(`/api/v1/vehicles/${vehicleRef}`)
.then((vehicle) => setOdometer(vehicle.odometer_km))
.catch((err) => setError(describeApiError(t, err)));
}, [t, vehicleRef]);
async function submit(event: FormEvent) {
event.preventDefault();
if (odometer === null) return;
setSaving(true);
setError(null);
try {
const result = await api.post<CheckoutBookingResult>(`/api/v1/bookings/${bookingRef}/checkout`, {
start_odometer_km: odometer,
fuel_level_percent: fuel,
cleanliness_ok: clean,
damage_reported: damage,
technical_warning: warning,
notes: notes || null,
});
onRecorded(result);
} catch (err) {
setError(describeApiError(t, err, "bookings:checkout.failed"));
} finally {
setSaving(false);
}
}
if (odometer === null && !error) return <LoadingState label={t("checkout.preparing")} />;
return <form className="return-form record-surface" onSubmit={submit}>
<SectionHeading title={t("checkout.title")} description={t("checkout.description")} />
<div className="return-capture">
<ApiErrorNotice error={error} />
<div className="form-grid">
<label>{t("checkout.odometer")}<input type="number" min={0} required value={odometer ?? ""} onChange={(event) => setOdometer(Number(event.target.value))} /></label>
<label>{t("checkout.fuel")}<input type="number" min={0} max={100} required value={fuel} onChange={(event) => setFuel(Number(event.target.value))} /></label>
</div>
<fieldset className="condition-fieldset"><legend>{t("checkout.condition")}</legend>
<label className="check-card"><input type="checkbox" checked={clean} onChange={(event) => setClean(event.target.checked)} /> {t("checkout.clean")}</label>
<label className="check-card"><input type="checkbox" checked={damage} onChange={(event) => setDamage(event.target.checked)} /> {t("checkout.damage")}</label>
<label className="check-card"><input type="checkbox" checked={warning} onChange={(event) => setWarning(event.target.checked)} /> {t("checkout.warning")}</label>
</fieldset>
<label>{t("checkout.notes")}<textarea maxLength={2000} value={notes} onChange={(event) => setNotes(event.target.value)} /></label>
</div>
<div className="form-actions"><button type="submit" className="button button-primary" disabled={saving || odometer === null}>{saving ? t("checkout.saving") : t("checkout.submit")}</button></div>
</form>;
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { Icon } from "./Icons";
export function DemoBadge() {
const { t } = useTranslation("demo");
const { formatDateTime } = useLocaleFormat();
const { manifest } = useDemoManifest();
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if (boxRef.current && !boxRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleOutsideClick);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleOutsideClick);
document.removeEventListener("keydown", handleEscape);
};
}, []);
return (
<div className="demo-badge" ref={boxRef}>
<button
type="button"
className="demo-badge-trigger"
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen((v) => !v)}
>
<Icon name="shield" />
<span>{t("badge.trigger")}</span>
</button>
{open && (
<div className="demo-badge-popover" role="dialog" aria-label={t("badge.dialogLabel")}>
<button type="button" className="icon-button demo-badge-close" onClick={() => setOpen(false)} aria-label={t("badge.close")}>
<Icon name="x" />
</button>
<p>
{manifest ? (
<Trans i18nKey="badge.orgIntro" t={t} values={{ orgName: manifest.organization_name }} components={{ strong: <strong /> }} />
) : (
t("badge.orgIntroFallback")
)}
</p>
<p>{t("badge.realWorkflows")}</p>
{manifest && (
<p className="demo-badge-reset">
<Trans
i18nKey="badge.lastReset"
t={t}
values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("badge.unknown") }}
components={{ strong: <strong /> }}
/>
</p>
)}
<Link to="/about" onClick={() => setOpen(false)}>
{t("badge.aboutLink")} <Icon name="chevron" />
</Link>
</div>
)}
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
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";
const GUIDE_TRIGGER_ID = "demo-guide-trigger";
export function DemoGuideTrigger() {
const { t } = useTranslation("demo");
const { user } = useAuth();
const { open, toggleGuide, currentIndex, completed, totalSteps } = useDemoGuide();
if (user?.role !== "operations_manager") return null;
return (
<button
id={GUIDE_TRIGGER_ID}
type="button"
className="demo-guide-trigger"
aria-expanded={open}
aria-haspopup="dialog"
onClick={toggleGuide}
>
<Icon name="spark" />
<span>{t("guide.trigger")}</span>
<span className="demo-guide-progress-pill">{completed.size}/{totalSteps}</span>
<span className="visually-hidden">, {t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</span>
</button>
);
}
function highlightTarget(selector: string): (() => void) | null {
const el = document.querySelector<HTMLElement>(selector);
if (!el) return null;
el.scrollIntoView({ behavior: "smooth", block: "center" });
const previousTabIndex = el.getAttribute("tabindex");
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
el.classList.add("demo-guide-highlight");
const timeout = window.setTimeout(() => {
el.classList.remove("demo-guide-highlight");
if (previousTabIndex === null) el.removeAttribute("tabindex");
}, 2200);
return () => {
window.clearTimeout(timeout);
el.classList.remove("demo-guide-highlight");
if (previousTabIndex === null) el.removeAttribute("tabindex");
};
}
export function DemoGuide() {
const { t } = useTranslation("demo");
const navigate = useNavigate();
const { logout } = useAuth();
const { manifest, refresh } = useDemoManifest();
const tier = useViewportTier();
const {
open,
closeGuide,
currentIndex,
completed,
totalSteps,
goToStep,
completeAndAdvance,
restart,
collapsedToChip,
setCollapsedToChip,
} = useDemoGuide();
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
const pendingTarget = useRef<string | null>(null);
const [targetRequest, setTargetRequest] = useState(0);
const step = DEMO_GUIDE_STEPS[currentIndex];
const isLastStep = currentIndex === totalSteps - 1;
const closeAndRestoreFocus = useCallback(() => {
pendingTarget.current = null;
closeGuide();
window.requestAnimationFrame(() => {
document.getElementById(GUIDE_TRIGGER_ID)?.focus();
});
}, [closeGuide]);
useEffect(() => {
if (!open) return;
function handleKeydown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
if (tier === "standard" && !collapsedToChip) {
setCollapsedToChip(true);
} else if (tier === "mobile" && mobileSheetState !== "collapsed") {
setMobileSheetState("collapsed");
} else {
closeAndRestoreFocus();
}
}
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
}, [open, tier, collapsedToChip, mobileSheetState, closeAndRestoreFocus, setCollapsedToChip]);
useEffect(() => {
if (!open) pendingTarget.current = null;
}, [open]);
useEffect(() => {
if (!open || !pendingTarget.current) return;
const target = pendingTarget.current;
let attempts = 0;
let timer = 0;
let cancelled = false;
let removeHighlight: (() => void) | null = null;
const findAndHighlight = () => {
if (cancelled) return;
removeHighlight = highlightTarget(target);
if (removeHighlight) {
pendingTarget.current = null;
return;
}
attempts += 1;
if (attempts < 40) timer = window.setTimeout(findAndHighlight, 50);
else pendingTarget.current = null;
};
timer = window.setTimeout(findAndHighlight, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
removeHighlight?.();
};
}, [open, targetRequest]);
if (!open) return null;
function goToStepRoute() {
pendingTarget.current = step.target ?? null;
navigate(step.route(manifest));
// A navigation to the already-active route does not necessarily change pathname.
// This request counter guarantees the bounded target lookup still runs.
setTargetRequest((request) => request + 1);
if (tier === "standard") setCollapsedToChip(true);
if (tier === "mobile") setMobileSheetState("collapsed");
}
function handlePrimaryAction() {
completeAndAdvance();
if (isLastStep) closeAndRestoreFocus();
}
async function handleRestartDemo() {
setResetError(null);
setResetting(true);
try {
await api.post("/api/v1/demo/reset");
restart();
refresh();
closeAndRestoreFocus();
await logout();
navigate("/login");
} catch (err) {
setResetError(describeApiError(t, err, "guide.restartFailed"));
} finally {
setResetting(false);
}
}
if (tier === "standard" && collapsedToChip) {
return (
<div className="demo-guide-chip">
<button
type="button"
className="demo-guide-chip-expand"
onClick={() => setCollapsedToChip(false)}
aria-label={`${t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}, ${t("guide.expand")}`}
>
<Icon name="spark" />
{t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}
<Icon name="chevron" />
</button>
<button type="button" className="demo-guide-chip-close" onClick={closeAndRestoreFocus} aria-label={t("guide.close")}>
<Icon name="x" />
</button>
</div>
);
}
const panelClassName = [
"demo-guide-panel",
tier === "wide" ? "is-wide" : "",
tier === "mobile" ? `is-mobile sheet-${mobileSheetState}` : "",
]
.filter(Boolean)
.join(" ");
return (
<aside className={panelClassName} role="dialog" aria-label={t("guide.dialogLabel")}>
{tier === "mobile" && (
<button
type="button"
className="demo-guide-sheet-handle"
onClick={() =>
setMobileSheetState((s) => (s === "collapsed" ? "half" : s === "half" ? "full" : "collapsed"))
}
aria-label={
mobileSheetState === "full"
? t("guide.collapse")
: t("guide.expand")
}
>
<span aria-hidden="true" />
</button>
)}
<header className="demo-guide-header">
<div>
<p className="demo-guide-kicker">{t("guide.kicker", { current: currentIndex + 1, total: totalSteps })}</p>
<h2>{t(`guide.steps.${step.id}.title`)}</h2>
</div>
{tier !== "mobile" && (
<button
type="button"
className="icon-button"
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeAndRestoreFocus}
aria-label={tier === "standard" ? t("guide.collapse") : t("guide.close")}
>
<Icon name="x" />
</button>
)}
</header>
{mobileSheetState !== "collapsed" && (
<>
<div className="demo-guide-progress-bar" aria-hidden="true">
{DEMO_GUIDE_STEPS.map((s, index) => (
<span
key={s.id}
className={
index === currentIndex ? "is-current" : completed.has(s.id) ? "is-done" : ""
}
/>
))}
</div>
{(mobileSheetState !== "half" || tier !== "mobile") && (
<div className="demo-guide-body">
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`, { productName: PRODUCT_NAME })}</p>
</div>
)}
{tier !== "mobile" && (
<nav className="demo-guide-steps" aria-label={t("guide.allStepsLabel")}>
{DEMO_GUIDE_STEPS.map((s, index) => (
<button
key={s.id}
type="button"
className={index === currentIndex ? "is-current" : ""}
onClick={() => goToStep(index)}
>
{completed.has(s.id) && <Icon name="check" />}
{t(`guide.steps.${s.id}.title`)}
</button>
))}
</nav>
)}
<ApiErrorNotice error={resetError} />
<footer className="demo-guide-footer">
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
{t("guide.goToStep")}
</button>
<button type="button" className="button button-primary" onClick={handlePrimaryAction}>
{t(isLastStep ? "guide.finish" : "guide.next")}
</button>
{tier !== "mobile" && (
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
{resetting ? t("guide.restarting") : t("guide.restart")}
</button>
)}
</footer>
</>
)}
</aside>
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { SVGProps } from "react";
export type IconName =
| "activity"
| "alert"
| "arrow-left"
| "audit"
| "bookings"
| "check"
| "chevron"
| "clock"
| "fleet"
| "integrations"
| "knowledge"
| "logout"
| "menu"
| "quality"
| "search"
| "shield"
| "spark"
| "user"
| "x";
const paths: Record<IconName, JSX.Element> = {
activity: <><path d="M3 12h4l2.5-6 5 12 2.5-6h4" /></>,
alert: <><path d="M10.3 3.4 2.6 17a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 3.4a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4" /><path d="M12 17h.01" /></>,
"arrow-left": <><path d="m15 18-6-6 6-6" /></>,
audit: <><path d="M9 4h6" /><path d="M9 2h6v4H9z" /><path d="M7 4H5a2 2 0 0 0-2 2v14h18V6a2 2 0 0 0-2-2h-2" /><path d="m8 13 2 2 5-5" /></>,
bookings: <><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M16 3v4M8 3v4M3 10h18" /></>,
check: <><path d="m5 12 4 4L19 6" /></>,
chevron: <><path d="m9 18 6-6-6-6" /></>,
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
fleet: <><path d="m5 16-1 3h2" /><path d="m19 16 1 3h-2" /><path d="M4 16h16l-1.4-6.2A2.3 2.3 0 0 0 16.4 8H7.6a2.3 2.3 0 0 0-2.2 1.8L4 16Z" /><path d="M6 13h12" /><circle cx="7" cy="16" r="1" /><circle cx="17" cy="16" r="1" /></>,
integrations: <><circle cx="12" cy="12" r="3" /><circle cx="5" cy="6" r="2" /><circle cx="19" cy="6" r="2" /><circle cx="12" cy="20" r="2" /><path d="m7 7.5 2.7 2.3m4.6 0L17 7.5M12 15v3" /></>,
knowledge: <><path d="M4 5.5A3.5 3.5 0 0 1 7.5 2H11v18H7.5A3.5 3.5 0 0 0 4 23V5.5Z" /><path d="M20 5.5A3.5 3.5 0 0 0 16.5 2H13v18h3.5A3.5 3.5 0 0 1 20 23V5.5Z" /></>,
logout: <><path d="M10 17l5-5-5-5M15 12H3" /><path d="M14 4h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5" /></>,
menu: <><path d="M4 7h16M4 12h16M4 17h16" /></>,
quality: <><path d="m12 3 2.2 4.5 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5-3.6-3.5 5-.7L12 3Z" /></>,
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>,
shield: <><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" /><path d="m9 12 2 2 4-4" /></>,
spark: <><path d="m12 3 1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z" /><path d="m19 15 .8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15Z" /></>,
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>,
x: <><path d="m6 6 12 12M18 6 6 18" /></>,
};
export function Icon({ name, ...props }: { name: IconName } & SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
{...props}
>
{paths[name]}
</svg>
);
}
export function BrandMark({ className = "" }: { className?: string }) {
return (
<svg className={className} aria-hidden="true" viewBox="0 0 32 32" fill="none">
<path d="M16 25.5c-4.4-5.1-6-8.2-6-11a6 6 0 1 1 12 0c0 2.8-1.6 5.9-6 11Z" fill="currentColor" />
<circle cx="16" cy="14.3" r="2.1" fill="#2dd4bf" />
<rect x="3" y="20.6" width="26" height="2.4" rx="1.2" fill="#2dd4bf" />
</svg>
);
}
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import {
persistLanguage,
SUPPORTED_LANGUAGES,
type SupportedLanguage,
} from "../i18n/config";
export function LanguageSwitcher({ compact = false }: { compact?: boolean }) {
const { t, i18n } = useTranslation("common");
const current = (i18n.language as SupportedLanguage) || "nl-BE";
function handleChange(next: SupportedLanguage) {
void i18n.changeLanguage(next);
persistLanguage(next);
}
return (
<label className={`language-switcher ${compact ? "language-switcher-compact" : ""}`}>
<span className="visually-hidden">{t("language.label")}</span>
<select
value={current}
onChange={(event) => handleChange(event.target.value as SupportedLanguage)}
aria-label={t("language.label")}
>
{SUPPORTED_LANGUAGES.map((lang) => (
<option key={lang} value={lang}>
{t(`language.${lang}`)}
</option>
))}
</select>
</label>
);
}
+376
View File
@@ -0,0 +1,376 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
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";
import { DemoBadge } from "./DemoBadge";
import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
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",
booking: "bookings",
data_quality_issue: "quality",
section: "chevron",
};
// The backend only ever sends a stable code + raw data params (never English prose) --
// see app/api/routers/search.py. Localizing here means the label/detail always follow
// the operator's selected locale, in every namespace search results can point to.
function searchResultLabel(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
if (item.type === "section") return t(`navigation:items.${item.label}`, { defaultValue: item.label });
return item.label;
}
function searchResultDetail(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
switch (item.type) {
case "section":
return t(`searchSections.${item.detail_code}`, { defaultValue: item.detail_code });
case "vehicle":
return t("searchVehicleSummary", { ...item.detail_params });
case "booking":
return t(`bookings:statuses.${item.detail_code}`, { defaultValue: item.detail_code });
case "data_quality_issue":
return t(`quality:ruleTypes.${item.detail_code}`, {
defaultValue: item.detail_code.replace(/_/g, " "),
});
default:
return item.detail_code;
}
}
interface NavItem {
to: string;
labelKey: string;
icon: IconName;
roles?: Role[];
}
const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
{
labelKey: "groups.operate",
items: [
{ to: "/dashboard", labelKey: "items.overview", icon: "activity" },
{ to: "/vehicles", labelKey: "items.fleet", icon: "fleet" },
{ to: "/bookings", labelKey: "items.bookings", icon: "bookings" },
{ to: "/data-quality", labelKey: "items.quality", icon: "quality", roles: ["operations_manager"] },
],
},
{
labelKey: "groups.assure",
items: [
{ to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" },
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
{ to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] },
{ to: "/privacy", labelKey: "items.privacy", icon: "shield", roles: ["operations_manager"] },
{ to: "/about", labelKey: "items.engineering", icon: "spark" },
],
},
];
export function Layout() {
const { t } = useTranslation(["navigation", "common", "auth"]);
const { user, logout, demoMode } = useAuth();
const { manifest } = useDemoManifest();
const { open: guideOpen, collapsedToChip: guideCollapsed, restart: restartGuide } = useDemoGuide();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const [resetConfirming, setResetConfirming] = useState(false);
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const searchInput = useRef<HTMLInputElement>(null);
const searchBox = useRef<HTMLDivElement>(null);
const navGroups = useMemo(
() =>
NAV_GROUPS.map((group) => ({
...group,
items: group.items.filter((item) => !item.roles || (user && item.roles.includes(user.role))),
})).filter((group) => group.items.length > 0),
[user],
);
const mobileItems = useMemo(() => navGroups.flatMap((group) => group.items).slice(0, 5), [navGroups]);
useLayoutEffect(() => {
function focusGlobalSearch(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
searchInput.current?.focus();
}
}
// Capture the shortcut before a nested control can consume it, and register before
// paint so Ctrl/Cmd+K is available as soon as the visible shell is interactive.
document.addEventListener("keydown", focusGlobalSearch, true);
return () => document.removeEventListener("keydown", focusGlobalSearch, true);
}, []);
useEffect(() => {
const query = searchQuery.trim();
if (!query) {
setSearchResults([]);
setSearchLoading(false);
setSearchError(false);
setActiveIndex(-1);
return;
}
setSearchLoading(true);
setSearchError(false);
// Abort the in-flight request when the query changes so a slow response for "a"
// can never overwrite the results for "ab" (or flip the loading state early).
const controller = new AbortController();
const timeout = window.setTimeout(() => {
api
.get<{ query: string; results: SearchResultItem[] }>(
`/api/v1/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal },
)
.then((response) => {
if (controller.signal.aborted) return;
setSearchResults(response.results);
setActiveIndex(-1);
})
.catch(() => {
if (!controller.signal.aborted) setSearchError(true);
})
.finally(() => {
if (!controller.signal.aborted) setSearchLoading(false);
});
}, 250);
return () => {
window.clearTimeout(timeout);
controller.abort();
};
}, [searchQuery]);
useEffect(() => {
function handleOutsideClick(event: MouseEvent) {
if (searchBox.current && !searchBox.current.contains(event.target as Node)) {
setSearchOpen(false);
}
}
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, []);
async function handleLogout() {
await logout();
navigate("/login");
}
async function handleDemoReset() {
setResetError(null);
setResetting(true);
try {
await api.post("/api/v1/demo/reset");
// Reset guide progress only after the server has confirmed that the data reset
// committed. A failed reset must leave the visitor's current walkthrough intact.
restartGuide();
// The server invalidates the acting session as part of reset; drop local state the
// same way an explicit logout would and return to the login screen.
await logout();
navigate("/login");
} catch (err) {
setResetError(describeApiError(t, err, "resetFailed"));
setResetConfirming(false);
} finally {
setResetting(false);
}
}
function selectResult(item: SearchResultItem) {
setSearchOpen(false);
setSearchQuery("");
setSearchResults([]);
navigate(item.link);
}
function handleSearchKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
if (event.key === "Escape") {
setSearchOpen(false);
return;
}
if (!searchOpen || searchResults.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((i) => (i + 1) % searchResults.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((i) => (i <= 0 ? searchResults.length - 1 : i - 1));
} else if (event.key === "Enter") {
event.preventDefault();
const target = searchResults[activeIndex] ?? searchResults[0];
if (target) selectResult(target);
}
}
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">{t("skipToContent")}</a>
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
<div className="brand-lockup">
<BrandMark className="brand-mark" />
<div><strong>{PRODUCT_NAME}</strong><span>{t("common:brandTagline")}</span></div>
</div>
<div className="sidebar-language">
<LanguageSwitcher />
</div>
<nav aria-label={t("primaryNavLabel")}>
{navGroups.map((group) => (
<div className="nav-group" key={group.labelKey}>
<p>{t(group.labelKey)}</p>
<ul>
{group.items.map((item) => (
<li key={item.to}>
<NavLink to={item.to} onClick={() => setMobileOpen(false)}>
<Icon name={item.icon} />
<span>{t(item.labelKey)}</span>
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
<div className="sidebar-foot">
<span className="environment-dot" />
<div><strong>{t("sidebarEnvironment")}</strong><span>{t("sidebarEnvironmentDetail")}</span></div>
</div>
{demoMode && user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset">
<ApiErrorNotice error={resetError} />
{!resetConfirming ? (
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
{t("resetDemoData")}
</button>
) : (
<div className="confirm-bar" role="alertdialog" aria-label={t("resetConfirmTitle")}>
<p>{t("resetConfirmBody")}</p>
<button type="button" onClick={handleDemoReset} disabled={resetting}>
{resetting ? t("resetting") : t("resetConfirmYes")}
</button>
<button type="button" onClick={() => setResetConfirming(false)} disabled={resetting}>
{t("resetCancel")}
</button>
</div>
)}
</div>
)}
</aside>
{mobileOpen && <button className="nav-scrim" aria-label={t("closeNavigation")} onClick={() => setMobileOpen(false)} />}
<div className={`app-workspace ${guideOpen ? "guide-open" : ""} ${guideOpen && guideCollapsed ? "guide-collapsed" : ""}`}>
<header className="topbar">
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label={t("openNavigation")}>
<Icon name="menu" />
</button>
<div className="global-search" role="search" ref={searchBox}>
<Icon name="search" />
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel", { productName: PRODUCT_NAME })}</label>
<input
id="global-search-input"
ref={searchInput}
type="search"
role="combobox"
aria-expanded={searchOpen}
aria-controls="global-search-results"
aria-autocomplete="list"
aria-activedescendant={activeIndex >= 0 ? `search-result-${activeIndex}` : undefined}
value={searchQuery}
placeholder={t("searchPlaceholder")}
onFocus={() => setSearchOpen(true)}
onChange={(event) => {
setSearchQuery(event.target.value);
setSearchOpen(true);
}}
onKeyDown={handleSearchKeyDown}
/>
<kbd>{t("searchShortcutHint")}</kbd>
{searchOpen && searchQuery.trim() && (
<div className="search-results" id="global-search-results" role="listbox">
{searchLoading && <p className="search-status">{t("searchSearching")}</p>}
{!searchLoading && searchError && <p className="search-status">{t("searchUnavailable")}</p>}
{!searchLoading && !searchError && searchResults.length === 0 && (
<p className="search-status">{t("searchNoResults", { query: searchQuery.trim() })}</p>
)}
{!searchLoading &&
!searchError &&
searchResults.map((item, index) => (
<button
key={`${item.type}-${item.link}`}
id={`search-result-${index}`}
role="option"
aria-selected={index === activeIndex}
type="button"
className={`search-result ${index === activeIndex ? "is-active" : ""}`}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => selectResult(item)}
>
<Icon name={SEARCH_ICON[item.type]} />
<span className="search-result-copy">
<strong>{searchResultLabel(t, item)}</strong>
<small>{searchResultDetail(t, item)}</small>
</span>
</button>
))}
</div>
)}
</div>
<div className="topbar-meta">
{demoMode && <DemoGuideTrigger />}
{demoMode && <DemoBadge />}
{user && <LanguageSwitcher compact />}
{user && (
<details className="operator-menu">
<summary className="operator">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</summary>
<div className="operator-popover">
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
<button className="operator-logout" type="button" onClick={handleLogout}>
<Icon name="logout" /> {t("switchRole")}
</button>
</div>
</details>
)}
</div>
</header>
<main id="main-content" tabIndex={-1}><Outlet /></main>
<footer className="app-footer"><span>{t("common:footer.productLine", { productName: PRODUCT_NAME })}</span><span>{t("common:footer.locale")}</span></footer>
</div>
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
{mobileItems.map((item) => (
<NavLink key={item.to} to={item.to}>
<Icon name={item.icon} />
<span>{t(item.labelKey)}</span>
</NavLink>
))}
<button type="button" onClick={() => setMobileOpen(true)}>
<Icon name="menu" />
<span>{t("more")}</span>
</button>
</nav>
{demoMode && <DemoGuide />}
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Icon, type IconName } from "./Icons";
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow: string;
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div>
<p className="page-eyebrow">{eyebrow}</p>
<h1>{title}</h1>
{description && <p className="page-description">{description}</p>}
</div>
{actions && <div className="page-actions">{actions}</div>}
</header>
);
}
export function SectionHeading({
title,
description,
action,
headingId,
}: {
title: string;
description?: string;
action?: ReactNode;
headingId?: string;
}) {
return (
<div className="section-heading">
<div>
<h2 id={headingId}>{title}</h2>
{description && <p>{description}</p>}
</div>
{action}
</div>
);
}
export function LoadingState({ label }: { label?: string }) {
const { t } = useTranslation("common");
return (
<div className="state-panel" role="status">
<span className="spinner" aria-hidden="true" />
<p>{label ?? t("states.loadingDefault")}</p>
</div>
);
}
export function ErrorState({
message,
onRetry,
retryLabel,
}: {
message: string;
onRetry?: () => void;
retryLabel?: string;
}) {
const { t } = useTranslation("common");
return (
<div className="state-panel state-error" role="alert">
<Icon name="alert" />
<div>
<strong>{t("states.errorTitle")}</strong>
<p>{message}</p>
{onRetry ? <button type="button" className="button button-secondary state-retry" onClick={onRetry}>{retryLabel ?? t("actions.retry")}</button> : null}
</div>
</div>
);
}
// 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,
detail,
}: {
icon?: IconName;
title: string;
detail: string;
}) {
return (
<div className="state-panel state-empty">
<Icon name={icon} />
<div><strong>{title}</strong><p>{detail}</p></div>
</div>
);
}
export function IntegrationMark({ kind }: { kind: "n8n" | "rag" | "mcp" }) {
const labels = { n8n: "n8n", rag: "RA", mcp: "MC" };
return <span className={`integration-mark integration-${kind}`} aria-hidden="true">{labels[kind]}</span>;
}
+25
View File
@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
export function Pagination({
page,
totalPages,
onPageChange,
}: {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}) {
const { t } = useTranslation("common");
if (totalPages <= 1) return null;
return (
<nav className="pagination" aria-label={t("pagination.label")}>
<button type="button" onClick={() => onPageChange(page - 1)} disabled={page <= 1}>
{t("pagination.previous")}
</button>
<span aria-current="page">{t("pagination.pageOf", { page, total: totalPages })}</span>
<button type="button" onClick={() => onPageChange(page + 1)} disabled={page >= totalPages}>
{t("pagination.next")}
</button>
</nav>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { LoadingState } from "./PageChrome";
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="page">
<LoadingState />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
+272
View File
@@ -0,0 +1,272 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
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";
import { useDemoManifest } from "../context/DemoManifestContext";
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"
? crypto.randomUUID()
: `key-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function ReturnResultPanel({ result }: { result: RegisterReturnResult }) {
const { t } = useTranslation("returns");
const { user } = useAuth();
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const canSeeManagerEvidence = user?.role === "operations_manager";
function continueDemo() {
completeAndAdvance();
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
}
return (
<section className="panel return-result success-panel" aria-labelledby="return-result-heading" aria-live="polite">
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">{t("result.committedLocally")}</p><h2 id="return-result-heading">{t("result.heading")}</h2></div></div>
<dl className="detail-grid">
<div><dt>{t("result.inspection")}</dt><dd>{result.inspection_ref}</dd></div>
<div><dt>{t("result.resultingStatus")}</dt><dd><StatusBadge status={result.resulting_vehicle_status} label={t(`fleet:statuses.${result.resulting_vehicle_status}`, { defaultValue: result.resulting_vehicle_status })} /></dd></div>
<div>
<dt>{t("result.qualityIssue")}</dt>
<dd>
{result.quality_issue_ref ? (
canSeeManagerEvidence ? (
<Link to={`/data-quality/${result.quality_issue_ref}`}>{result.quality_issue_ref}</Link>
) : (
result.quality_issue_ref
)
) : (
t("result.noneCreated")
)}
</dd>
</div>
<div>
<dt>{t("result.automationEvent")}</dt>
<dd>{t("result.queuedForDelivery", { ref: result.workflow_event_id.slice(0, 8) })}</dd>
</div>
<div>
<dt>{t("result.nextBookingRisk")}</dt>
<dd>
{result.next_booking_risk
? t("result.nextBookingRiskValue", {
ref: result.next_booking_risk.booking_ref,
status: result.next_booking_risk.at_risk ? t("result.atRisk") : t("result.lowRisk"),
})
: t("result.noUpcomingBooking")}
</dd>
</div>
</dl>
{result.odometer_regression && (
<p className="error" role="alert">{t("result.odometerRegressionNotice")}</p>
)}
<div className="result-links">
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>{t("result.viewVehicle", { ref: result.vehicle_ref })}<Icon name="chevron" /></Link>
{canSeeManagerEvidence && <Link className="button button-secondary" to="/automation">{t("result.viewAutomation")}<Icon name="chevron" /></Link>}
{canSeeManagerEvidence && <Link className="button button-secondary" to={`/audit?correlation_id=${result.correlation_id}`}>{t("result.viewTrace")}<Icon name="chevron" /></Link>}
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
{t("result.continueDemo")} <Icon name="chevron" />
</button>
)}
</div>
</section>
);
}
export function ReturnForm({
bookingRef,
onRegistered,
suggestedOdometerKm,
}: {
bookingRef: string;
onRegistered: (result: RegisterReturnResult) => void;
suggestedOdometerKm?: number;
}) {
const { t } = useTranslation(["returns", "errors"]);
const { formatNumber, formatDateTime } = useLocaleFormat();
const [odometer, setOdometer] = useState(suggestedOdometerKm !== undefined ? String(suggestedOdometerKm) : "");
const [fuel, setFuel] = useState("50");
const [cleanlinessOk, setCleanlinessOk] = useState(true);
const [damageReported, setDamageReported] = useState(false);
const [technicalWarning, setTechnicalWarning] = useState(false);
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [previewing, setPreviewing] = useState(false);
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);
function currentBody(): RegisterReturnRequest {
return {
end_odometer_km: Number(odometer),
fuel_level_percent: Number(fuel),
cleanliness_ok: cleanlinessOk,
damage_reported: damageReported,
technical_warning: technicalWarning,
notes: notes || undefined,
};
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (step === "capture") {
setError(null);
setPreviewing(true);
try {
const evaluated = await api.post<ReturnPreviewResult>(
`/api/v1/bookings/${bookingRef}/return-preview`,
currentBody(),
);
setPreview(evaluated);
setStep("review");
} catch (err) {
setError(describeApiError(t, err));
} finally {
setPreviewing(false);
}
return;
}
setError(null);
setSubmitting(true);
try {
const registered = await api.post<RegisterReturnResult>(
`/api/v1/bookings/${bookingRef}/return`,
currentBody(),
{ "Idempotency-Key": idempotencyKey },
);
onRegistered(registered);
} catch (err) {
setError(describeApiError(t, err));
} finally {
setSubmitting(false);
}
}
return (
<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>
<ApiErrorNotice error={error} />
{step === "capture" ? <div className="return-capture">
<div className="form-grid"><label>
{t("capture.endOdometer")}
<input
type="number"
required
min={0}
value={odometer}
onChange={(e) => setOdometer(e.target.value)}
/>
</label>
<label>
{t("capture.fuelLevel")}
<input
type="number"
required
min={0}
max={100}
value={fuel}
onChange={(e) => setFuel(e.target.value)}
/>
</label></div>
<fieldset className="condition-fieldset"><legend>{t("capture.conditionLegend")}</legend><label className="checkbox-label check-card">
<input
type="checkbox"
checked={cleanlinessOk}
onChange={(e) => setCleanlinessOk(e.target.checked)}
/>
{t("capture.cleanlinessOk")}
</label>
<label className="checkbox-label check-card">
<input
type="checkbox"
checked={damageReported}
onChange={(e) => setDamageReported(e.target.checked)}
/>
{t("capture.damageReported")}
</label>
<label className="checkbox-label check-card">
<input
type="checkbox"
checked={technicalWarning}
onChange={(e) => setTechnicalWarning(e.target.checked)}
/>
{t("capture.technicalWarning")}
</label></fieldset>
<label>
{t("capture.notes")}
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} rows={3} />
</label></div> : preview && <div className="return-review" aria-live="polite">
<dl className="review-facts"><div><dt>{t("review.odometer")}</dt><dd>{formatNumber(Number(odometer))} km</dd></div><div><dt>{t("review.fuel")}</dt><dd>{fuel}%</dd></div><div><dt>{t("review.cleanliness")}</dt><dd>{cleanlinessOk ? t("review.cleanlinessAccepted") : t("review.cleanlinessFollowUp")}</dd></div><div><dt>{t("review.damage")}</dt><dd>{damageReported ? t("review.damageReported") : t("review.damageNone")}</dd></div><div><dt>{t("review.technicalWarning")}</dt><dd>{technicalWarning ? t("review.technicalWarningReported") : t("review.technicalWarningNone")}</dd></div></dl>
<div className={`impact-preview ${preview.attention_reasons.length > 0 ? "impact-warning" : "impact-ready"}`}>
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
<div>
<strong>{t("review.expectedState")} <StatusBadge status={preview.resulting_vehicle_status} label={t(`fleet:statuses.${preview.resulting_vehicle_status}`, { defaultValue: preview.resulting_vehicle_status })} /></strong>
<p>{t(`review.reasonCodes.${preview.status_reason_code}`, {
defaultValue: preview.status_reason,
threshold_km:
typeof preview.status_reason_params.threshold_km === "number"
? formatNumber(preview.status_reason_params.threshold_km)
: undefined,
})}</p>
<details className="evidence-disclosure">
<summary>{t("common:actions.technicalDetails")}</summary>
<pre className="evidence-block">{preview.status_reason}</pre>
</details>
</div>
</div>
{preview.odometer_regression && (
<p className="error" role="alert">
{t("review.odometerRegressionWarning", {
submitted: formatNumber(preview.submitted_odometer_km),
canonical: formatNumber(preview.canonical_odometer_km),
})}
</p>
)}
{preview.next_booking_risk && (
<p className={preview.next_booking_risk.at_risk ? "error" : undefined} role={preview.next_booking_risk.at_risk ? "alert" : undefined}>
{t(preview.next_booking_risk.at_risk ? "review.nextBookingRisk" : "review.nextBookingLowRisk", {
ref: preview.next_booking_risk.booking_ref,
when: formatDateTime(preview.next_booking_risk.starts_at),
})}
</p>
)}
<ul className="commit-list"><li><Icon name="check" /> {t("review.commitList.inspection")}</li><li><Icon name="check" /> {t("review.commitList.updateAtomic")}</li><li><Icon name="check" /> {t("review.commitList.queueAutomation")}</li></ul>
</div>}
<div className="form-actions">
{step === "review" && <button className="button button-secondary" type="button" onClick={() => setStep("capture")} disabled={submitting}><Icon name="arrow-left" /> {t("review.editDetails")}</button>}
<button className="button button-primary" type="submit" disabled={submitting || previewing}>
{step === "capture"
? previewing
? t("capture.evaluating")
: t("capture.reviewReturn")
: submitting
? t("review.registering")
: t("review.confirmReturn")}{" "}
{step === "capture" && !previewing && <Icon name="chevron" />}
</button>
</div>
</form>
);
}
@@ -0,0 +1,57 @@
import { FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { MaintenanceRecord, VehicleDetail } from "../api/types";
import { ApiErrorNotice } from "./PageChrome";
import { brusselsLocalToIso, toBrusselsDate } from "../i18n/brusselsDateTime";
export function VehicleMaintenanceActions({ vehicle, onSaved }: { vehicle: VehicleDetail; onSaved: () => void }) {
const { t } = useTranslation(["fleet", "errors"]);
const [showRecord, setShowRecord] = useState(false);
const [occurredAt, setOccurredAt] = useState(() => toBrusselsDate(new Date()));
const [odometer, setOdometer] = useState(vehicle.odometer_km);
const [category, setCategory] = useState("periodic_service");
const [summary, setSummary] = useState("");
const [nextService, setNextService] = useState(vehicle.next_service_km);
const [releaseReason, setReleaseReason] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
async function recordMaintenance(event: FormEvent) {
event.preventDefault(); setSaving(true); setError(null);
try {
await api.post<MaintenanceRecord>(`/api/v1/vehicles/${vehicle.public_ref}/maintenance`, {
occurred_at: brusselsLocalToIso(`${occurredAt}T12:00`), odometer_km: odometer,
category, summary, next_service_km: nextService, mark_maintenance: true,
});
setShowRecord(false); setSummary(""); onSaved();
} catch (err) { setError(describeApiError(t, err, "fleet:maintenance.failed")); }
finally { setSaving(false); }
}
async function release(event: FormEvent) {
event.preventDefault(); setSaving(true); setError(null);
try {
await api.post(`/api/v1/vehicles/${vehicle.public_ref}/release`, { reason: releaseReason });
setReleaseReason(""); onSaved();
} catch (err) { setError(describeApiError(t, err, "fleet:maintenance.releaseFailed")); }
finally { setSaving(false); }
}
return <section className="record-surface maintenance-actions">
<div className="section-heading"><div><h2>{t("maintenance.actionsTitle")}</h2><p>{t("maintenance.actionsDescription")}</p></div>{!showRecord && <button type="button" className="button button-secondary" onClick={() => setShowRecord(true)}>{t("maintenance.add")}</button>}</div>
<ApiErrorNotice error={error} />
{showRecord && <form onSubmit={recordMaintenance}>
<div className="form-grid">
<label>{t("maintenance.date")}<input type="date" required value={occurredAt} onChange={(event) => setOccurredAt(event.target.value)} /></label>
<label>{t("maintenance.odometer")}<input type="number" min={vehicle.odometer_km} required value={odometer} onChange={(event) => setOdometer(Number(event.target.value))} /></label>
<label>{t("maintenance.category")}<select value={category} onChange={(event) => setCategory(event.target.value)}>{["periodic_service", "repair", "inspection", "tyres", "other"].map((value) => <option value={value} key={value}>{t(`detail.maintenanceCategories.${value}`, { defaultValue: value })}</option>)}</select></label>
<label>{t("maintenance.nextService")}<input type="number" min={odometer} required value={nextService} onChange={(event) => setNextService(Number(event.target.value))} /></label>
</div>
<label>{t("maintenance.summary")}<textarea required minLength={3} maxLength={2000} value={summary} onChange={(event) => setSummary(event.target.value)} /></label>
<div className="form-actions"><button type="button" className="button button-secondary" onClick={() => setShowRecord(false)}>{t("maintenance.cancel")}</button><button className="button button-primary" disabled={saving}>{saving ? t("maintenance.saving") : t("maintenance.save")}</button></div>
</form>}
{["cleaning", "maintenance", "blocked"].includes(vehicle.operational_status) && <form className="release-form" onSubmit={release}><label>{t("maintenance.releaseReason")}<input required minLength={3} maxLength={500} value={releaseReason} onChange={(event) => setReleaseReason(event.target.value)} placeholder={t("maintenance.releasePlaceholder")} /></label><button className="button button-primary" disabled={saving || releaseReason.trim().length < 3}>{t("maintenance.release")}</button></form>}
</section>;
}
+157
View File
@@ -0,0 +1,157 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { api, ApiError, onUnauthorized } from "../api/client";
import type { CurrentUser, Role, SystemStatus } from "../api/types";
interface AuthState {
user: CurrentUser | null;
loading: boolean;
demoMode: boolean;
oidcEnabled: boolean;
oidcProviderName: string | null;
loginAs: (role: Role) => Promise<void>;
loginWithPassword: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
const STORAGE_KEY = "mobilityops.demo-user";
function readCachedUser(): CurrentUser | null {
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as CurrentUser) : null;
} catch {
// A corrupted or blocked sessionStorage must never prevent the app from booting;
// the session endpoint remains the source of truth.
return null;
}
}
function cacheUser(user: CurrentUser | null) {
try {
if (user) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
} catch {
// Session storage is only a paint optimisation. Browser privacy settings or a full
// storage quota must never turn a successful server-side login/logout into a client
// failure; the HttpOnly session cookie remains authoritative.
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
// The cached value only avoids a login-screen flash while the server round trip below is
// in flight; it is never trusted on its own. `loading` stays true until the server has
// confirmed (or rejected) the session cookie.
const [user, setUser] = useState<CurrentUser | null>(() => readCachedUser());
const [loading, setLoading] = useState(true);
const [demoMode, setDemoMode] = useState(true);
const [oidcEnabled, setOidcEnabled] = useState(false);
const [oidcProviderName, setOidcProviderName] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
// System capabilities and session identity are independent reads. In particular, a
// slow public status endpoint must not hold an already valid session (or the login
// controls) behind its timeout.
void api.get<SystemStatus>("/api/v1/system/status", { signal: controller.signal })
.then((system) => {
if (controller.signal.aborted) return;
setDemoMode(system.demo_mode);
setOidcEnabled(system.oidc_enabled);
setOidcProviderName(system.oidc_provider_name);
})
.catch(() => {
if (!controller.signal.aborted) setDemoMode(true);
});
void api
.get<CurrentUser>("/api/v1/auth/session", { signal: controller.signal })
.then((confirmed) => {
if (controller.signal.aborted) return;
setUser(confirmed);
cacheUser(confirmed);
})
.catch(() => {
if (controller.signal.aborted) return;
setUser(null);
cacheUser(null);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, []);
useEffect(
() =>
onUnauthorized(() => {
setUser(null);
cacheUser(null);
}),
[],
);
const loginAs = useCallback(async (role: Role) => {
setLoading(true);
try {
const loggedIn = await api.post<CurrentUser>("/api/v1/demo/login", { role });
setUser(loggedIn);
cacheUser(loggedIn);
} finally {
setLoading(false);
}
}, []);
const loginWithPassword = useCallback(async (email: string, password: string) => {
setLoading(true);
try {
const loggedIn = await api.post<CurrentUser>("/api/v1/auth/login", { email, password });
setUser(loggedIn);
cacheUser(loggedIn);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(async () => {
try {
// A logout response expires an HttpOnly cookie. Chromium can expose a very small
// race between resolving fetch() and applying that Set-Cookie header to an
// immediate top-level navigation. Confirm the server now rejects the session
// before allowing the router to continue; retrying logout is idempotent.
for (let attempt = 0; attempt < 3; attempt += 1) {
await api.post("/api/v1/auth/logout");
try {
await api.get<CurrentUser>("/api/v1/auth/session");
} catch (err) {
if (err instanceof ApiError && err.status === 401) return;
throw err;
}
}
} catch {
// Best effort: the cookie is cleared server-side when it works, and the client has
// still drops its own state when the request itself is unavailable.
} finally {
// Keep RequireAuth from redirecting to /login while the server-side invalidation
// is still in flight. Otherwise a very fast navigation can race ahead of logout.
setUser(null);
cacheUser(null);
}
}, []);
return (
<AuthContext.Provider value={{ user, loading, demoMode, oidcEnabled, oidcProviderName, loginAs, loginWithPassword, logout }}>{children}</AuthContext.Provider>
);
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+130
View File
@@ -0,0 +1,130 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
const STORAGE_KEY = "mobilityops.demo-guide-progress";
interface StoredProgress {
currentIndex: number;
completed: string[];
}
function readProgress(): StoredProgress {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return { currentIndex: 0, completed: [] };
const parsed = JSON.parse(raw) as Partial<StoredProgress>;
const validStepIds = new Set(DEMO_GUIDE_STEPS.map((step) => step.id));
const currentIndex = Number.isInteger(parsed.currentIndex)
? Math.max(0, Math.min(parsed.currentIndex as number, DEMO_GUIDE_STEPS.length - 1))
: 0;
const completed = Array.isArray(parsed.completed)
? parsed.completed.filter(
(stepId): stepId is string => typeof stepId === "string" && validStepIds.has(stepId),
)
: [];
return { currentIndex, completed: Array.from(new Set(completed)) };
} catch {
return { currentIndex: 0, completed: [] };
}
}
function writeProgress(progress: StoredProgress) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
} catch {
// Progress is a convenience. Blocked or full storage must never break the guide.
}
}
interface DemoGuideState {
open: boolean;
currentIndex: number;
completed: Set<string>;
totalSteps: number;
collapsedToChip: boolean;
setCollapsedToChip: (value: boolean) => void;
openGuide: () => void;
closeGuide: () => void;
toggleGuide: () => void;
goToStep: (index: number) => void;
completeAndAdvance: () => void;
restart: () => void;
}
const DemoGuideContext = createContext<DemoGuideState | undefined>(undefined);
export function DemoGuideProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [completed, setCompleted] = useState<Set<string>>(new Set());
const [collapsedToChip, setCollapsedToChip] = useState(false);
useEffect(() => {
const stored = readProgress();
setCurrentIndex(stored.currentIndex);
setCompleted(new Set(stored.completed));
}, []);
const persist = useCallback((index: number, done: Set<string>) => {
writeProgress({ currentIndex: index, completed: Array.from(done) });
}, []);
const goToStep = useCallback(
(index: number) => {
const clamped = Math.max(0, Math.min(index, DEMO_GUIDE_STEPS.length - 1));
setCurrentIndex(clamped);
persist(clamped, completed);
},
[completed, persist],
);
const completeAndAdvance = useCallback(() => {
setCompleted((prev) => {
const next = new Set(prev);
next.add(DEMO_GUIDE_STEPS[currentIndex].id);
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
persist(nextIndex, next);
setCurrentIndex(nextIndex);
return next;
});
}, [currentIndex, persist]);
const restart = useCallback(() => {
setCurrentIndex(0);
setCompleted(new Set());
writeProgress({ currentIndex: 0, completed: [] });
}, []);
return (
<DemoGuideContext.Provider
value={{
open,
currentIndex,
completed,
totalSteps: DEMO_GUIDE_STEPS.length,
collapsedToChip,
setCollapsedToChip,
openGuide: () => {
setCollapsedToChip(false);
setOpen(true);
},
closeGuide: () => setOpen(false),
toggleGuide: () => {
setCollapsedToChip(false);
setOpen((v) => !v);
},
goToStep,
completeAndAdvance,
restart,
}}
>
{children}
</DemoGuideContext.Provider>
);
}
export function useDemoGuide(): DemoGuideState {
const ctx = useContext(DemoGuideContext);
if (!ctx) throw new Error("useDemoGuide must be used within DemoGuideProvider");
return ctx;
}
@@ -0,0 +1,55 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { api } from "../api/client";
import type { DemoManifest } from "../api/types";
interface DemoManifestState {
manifest: DemoManifest | null;
loading: boolean;
error: boolean;
refresh: () => void;
}
const DemoManifestContext = createContext<DemoManifestState | undefined>(undefined);
export function DemoManifestProvider({ children }: { children: ReactNode }) {
const [manifest, setManifest] = useState<DemoManifest | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [version, setVersion] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(false);
// Public endpoint by design: the demo-entry screen needs this before any session
// exists, so it is never gated behind auth.
api
.get<DemoManifest>("/api/v1/demo/manifest", { signal: controller.signal })
.then((result) => {
if (!controller.signal.aborted) setManifest(result);
})
.catch(() => {
if (controller.signal.aborted) return;
setManifest(null);
setError(true);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [version]);
return (
<DemoManifestContext.Provider
value={{ manifest, loading, error, refresh: () => setVersion((v) => v + 1) }}
>
{children}
</DemoManifestContext.Provider>
);
}
export function useDemoManifest(): DemoManifestState {
const ctx = useContext(DemoManifestContext);
if (!ctx) throw new Error("useDemoManifest must be used within DemoManifestProvider");
return ctx;
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
export type ViewportTier = "wide" | "standard" | "mobile";
// Breakpoints reuse the app's existing, already-measured layout thresholds (see
// styles.css): 700px is where the sidebar collapses into the mobile bottom nav, and
// 1440px is the largest width in the project's own responsive test matrix -- chosen as
// the "extra-wide desktop" floor rather than an arbitrary new number.
const MOBILE_QUERY = "(max-width: 700px)";
const WIDE_QUERY = "(min-width: 1440px)";
function computeTier(): ViewportTier {
if (typeof window === "undefined") return "standard";
if (window.matchMedia(MOBILE_QUERY).matches) return "mobile";
if (window.matchMedia(WIDE_QUERY).matches) return "wide";
return "standard";
}
export function useViewportTier(): ViewportTier {
const [tier, setTier] = useState<ViewportTier>(computeTier);
useEffect(() => {
const mobile = window.matchMedia(MOBILE_QUERY);
const wide = window.matchMedia(WIDE_QUERY);
const update = () => setTier(computeTier());
mobile.addEventListener("change", update);
wide.addEventListener("change", update);
update();
return () => {
mobile.removeEventListener("change", update);
wide.removeEventListener("change", update);
};
}, []);
return tier;
}
+78
View File
@@ -0,0 +1,78 @@
const BRUSSELS_TIME_ZONE = "Europe/Brussels";
const partsFormatter = new Intl.DateTimeFormat("en-CA", {
timeZone: BRUSSELS_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
function partsAt(value: Date): Record<string, string> {
return Object.fromEntries(
partsFormatter.formatToParts(value)
.filter((part) => part.type !== "literal")
.map((part) => [part.type, part.value]),
);
}
export function toBrusselsDateTimeLocal(value: Date): string {
const parts = partsAt(value);
return `${toBrusselsDate(value)}T${parts.hour}:${parts.minute}`;
}
/** Render an instant as the Fleet Ops Europe/Brussels calendar date (YYYY-MM-DD). */
export function toBrusselsDate(value: Date): string {
const parts = partsAt(value);
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function brusselsDateTimeFromNow(hours: number): string {
const value = new Date(Date.now() + hours * 60 * 60 * 1000);
value.setUTCMinutes(0, 0, 0);
return toBrusselsDateTimeLocal(value);
}
/** Convert a Fleet Ops Europe/Brussels wall-clock value to an unambiguous UTC instant. */
export function brusselsLocalToIso(value: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value);
if (!match) throw new Error("Invalid Brussels date-time");
const [, year, month, day, hour, minute] = match;
const wallClockUtc = Date.UTC(+year, +month - 1, +day, +hour, +minute);
let candidate = wallClockUtc;
for (let iteration = 0; iteration < 3; iteration += 1) {
const parts = partsAt(new Date(candidate));
const represented = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute);
candidate += wallClockUtc - represented;
}
const result = new Date(candidate);
// Date.UTC normalises impossible calendar values and Brussels' spring transition
// contains a wall-clock hour that does not exist. Never silently shift either one.
if (toBrusselsDateTimeLocal(result) !== value) {
throw new Error("Invalid or non-existent Brussels date-time");
}
return result.toISOString();
}
export function tryBrusselsLocalToIso(value: string): string | null {
try {
return brusselsLocalToIso(value);
} catch {
return null;
}
}
/** Start of the given Brussels calendar day (YYYY-MM-DD) as a UTC ISO instant. */
export function brusselsDayStartIso(day: string): string {
return brusselsLocalToIso(`${day}T00:00`);
}
/** Start of the day *after* the given Brussels calendar day, i.e. an exclusive upper bound. */
export function brusselsNextDayStartIso(day: string): string {
const [year, month, date] = day.split("-").map(Number);
const next = new Date(Date.UTC(year, month - 1, date + 1));
return brusselsLocalToIso(`${next.toISOString().slice(0, 10)}T00:00`);
}
+175
View File
@@ -0,0 +1,175 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import commonNl from "./locales/nl-BE/common.json";
import authNl from "./locales/nl-BE/auth.json";
import navigationNl from "./locales/nl-BE/navigation.json";
import dashboardNl from "./locales/nl-BE/dashboard.json";
import fleetNl from "./locales/nl-BE/fleet.json";
import bookingsNl from "./locales/nl-BE/bookings.json";
import returnsNl from "./locales/nl-BE/returns.json";
import qualityNl from "./locales/nl-BE/quality.json";
import knowledgeNl from "./locales/nl-BE/knowledge.json";
import integrationsNl from "./locales/nl-BE/integrations.json";
import auditNl from "./locales/nl-BE/audit.json";
import demoNl from "./locales/nl-BE/demo.json";
import errorsNl from "./locales/nl-BE/errors.json";
import accessibilityNl from "./locales/nl-BE/accessibility.json";
import operationsNl from "./locales/nl-BE/operations.json";
import privacyNl from "./locales/nl-BE/privacy.json";
import commonEn from "./locales/en-GB/common.json";
import authEn from "./locales/en-GB/auth.json";
import navigationEn from "./locales/en-GB/navigation.json";
import dashboardEn from "./locales/en-GB/dashboard.json";
import fleetEn from "./locales/en-GB/fleet.json";
import bookingsEn from "./locales/en-GB/bookings.json";
import returnsEn from "./locales/en-GB/returns.json";
import qualityEn from "./locales/en-GB/quality.json";
import knowledgeEn from "./locales/en-GB/knowledge.json";
import integrationsEn from "./locales/en-GB/integrations.json";
import auditEn from "./locales/en-GB/audit.json";
import demoEn from "./locales/en-GB/demo.json";
import errorsEn from "./locales/en-GB/errors.json";
import accessibilityEn from "./locales/en-GB/accessibility.json";
import operationsEn from "./locales/en-GB/operations.json";
import privacyEn from "./locales/en-GB/privacy.json";
import commonFr from "./locales/fr-BE/common.json";
import authFr from "./locales/fr-BE/auth.json";
import navigationFr from "./locales/fr-BE/navigation.json";
import dashboardFr from "./locales/fr-BE/dashboard.json";
import fleetFr from "./locales/fr-BE/fleet.json";
import bookingsFr from "./locales/fr-BE/bookings.json";
import returnsFr from "./locales/fr-BE/returns.json";
import qualityFr from "./locales/fr-BE/quality.json";
import knowledgeFr from "./locales/fr-BE/knowledge.json";
import integrationsFr from "./locales/fr-BE/integrations.json";
import auditFr from "./locales/fr-BE/audit.json";
import demoFr from "./locales/fr-BE/demo.json";
import errorsFr from "./locales/fr-BE/errors.json";
import accessibilityFr from "./locales/fr-BE/accessibility.json";
import operationsFr from "./locales/fr-BE/operations.json";
import privacyFr from "./locales/fr-BE/privacy.json";
export const SUPPORTED_LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
export const DEFAULT_LANGUAGE: SupportedLanguage = "nl-BE";
export const LANGUAGE_STORAGE_KEY = "fleetops.language";
export const NAMESPACES = [
"common",
"auth",
"navigation",
"dashboard",
"fleet",
"bookings",
"returns",
"quality",
"knowledge",
"integrations",
"audit",
"demo",
"errors",
"accessibility",
"operations",
"privacy",
] as const;
function readStoredLanguage(): SupportedLanguage {
try {
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
if (stored && (SUPPORTED_LANGUAGES as readonly string[]).includes(stored)) {
return stored as SupportedLanguage;
}
} catch {
// localStorage unavailable (e.g. private-mode edge cases) -- fall back to default.
}
return DEFAULT_LANGUAGE;
}
export function persistLanguage(language: SupportedLanguage): void {
try {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
} catch {
// Best effort only; the in-memory i18next language still changes for this session.
}
}
void i18n
.use(initReactI18next)
.init({
lng: readStoredLanguage(),
fallbackLng: DEFAULT_LANGUAGE,
supportedLngs: SUPPORTED_LANGUAGES,
ns: NAMESPACES,
defaultNS: "common",
// A missing key must never render silently as empty/blank text -- surface it loudly
// (the key itself) so it's caught immediately in development and by the
// translation-coverage test, rather than shipping a blank UI label.
returnEmptyString: false,
interpolation: { escapeValue: false },
resources: {
"nl-BE": {
common: commonNl,
auth: authNl,
navigation: navigationNl,
dashboard: dashboardNl,
fleet: fleetNl,
bookings: bookingsNl,
returns: returnsNl,
quality: qualityNl,
knowledge: knowledgeNl,
integrations: integrationsNl,
audit: auditNl,
demo: demoNl,
errors: errorsNl,
accessibility: accessibilityNl,
operations: operationsNl,
privacy: privacyNl,
},
"en-GB": {
common: commonEn,
auth: authEn,
navigation: navigationEn,
dashboard: dashboardEn,
fleet: fleetEn,
bookings: bookingsEn,
returns: returnsEn,
quality: qualityEn,
knowledge: knowledgeEn,
integrations: integrationsEn,
audit: auditEn,
demo: demoEn,
errors: errorsEn,
accessibility: accessibilityEn,
operations: operationsEn,
privacy: privacyEn,
},
"fr-BE": {
common: commonFr,
auth: authFr,
navigation: navigationFr,
dashboard: dashboardFr,
fleet: fleetFr,
bookings: bookingsFr,
returns: returnsFr,
quality: qualityFr,
knowledge: knowledgeFr,
integrations: integrationsFr,
audit: auditFr,
demo: demoFr,
errors: errorsFr,
accessibility: accessibilityFr,
operations: operationsFr,
privacy: privacyFr,
},
},
});
document.documentElement.lang = i18n.language;
i18n.on("languageChanged", (lng) => {
document.documentElement.lang = lng;
});
export default i18n;
+37
View File
@@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
const TIME_ZONE = "Europe/Brussels";
export function useLocaleFormat() {
const { i18n } = useTranslation();
const locale = i18n.language || "nl-BE";
return {
locale,
formatDate(value: string | Date): string {
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: TIME_ZONE }).format(
new Date(value),
);
},
formatShortDate(value: string | Date): string {
return new Intl.DateTimeFormat(locale, { day: "2-digit", month: "short", timeZone: TIME_ZONE }).format(
new Date(value),
);
},
formatDateTime(value: string | Date): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
timeZone: TIME_ZONE,
}).format(new Date(value));
},
formatTime(value: string | Date): string {
return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", timeZone: TIME_ZONE }).format(
new Date(value),
);
},
formatNumber(value: number): string {
return new Intl.NumberFormat(locale).format(value);
},
};
}
+28
View File
@@ -0,0 +1,28 @@
export type GreetingPeriod = "morning" | "afternoon" | "evening" | "night";
const BRUSSELS_TIME_ZONE = "Europe/Brussels";
// DST-safe: Intl.DateTimeFormat resolves the correct Europe/Brussels wall-clock hour for
// any instant, automatically accounting for the CET/CEST transition -- no manual UTC
// offset math (which would silently break twice a year) is needed. `hourCycle: "h23"`
// pins the output to a plain 0-23 range (some engines otherwise render midnight as "24").
export function getBrusselsHour(date: Date): number {
const formatter = new Intl.DateTimeFormat("en-GB", {
timeZone: BRUSSELS_TIME_ZONE,
hour: "numeric",
hourCycle: "h23",
});
return Number(formatter.format(date));
}
// Boundaries per the Fleet Ops final localization brief (section 9):
// 05:00-11:59 morning, 12:00-17:59 afternoon, 18:00-22:59 evening, 23:00-04:59 night.
// `date` defaults to `new Date()` but accepts any Date so callers (and tests) can inject
// a fixed clock instead of depending on the real wall clock.
export function getGreetingPeriod(date: Date = new Date()): GreetingPeriod {
const hour = getBrusselsHour(date);
if (hour >= 5 && hour < 12) return "morning";
if (hour >= 12 && hour < 18) return "afternoon";
if (hour >= 18 && hour < 23) return "evening";
return "night";
}
@@ -0,0 +1,7 @@
{
"openRow": "Open: {{label}}",
"reducedMotion": "Reduced motion active",
"closeDialog": "Close dialog",
"expandSection": "Expand section",
"collapseSection": "Collapse section"
}
+104
View File
@@ -0,0 +1,104 @@
{
"eyebrow": "Assure / Immutable history",
"title": "Audit trail",
"description": "Trace important state changes, actors and correlation references.",
"actionFilterLabel": "Action",
"actionFilterPlaceholder": "e.g. demo_login",
"actorFilterLabel": "Actor",
"actorFilterPlaceholder": "Name or service",
"entityFilterLabel": "Record",
"entityFilterPlaceholder": "e.g. MO-016",
"fromFilterLabel": "From",
"toFilterLabel": "To",
"managerOnly": "The audit trail is visible to Operations Managers only.",
"managerOnlyDetail": "Audit history is visible to Operations Managers only.",
"loading": "Loading audit trail…",
"unavailable": "Audit trail is unavailable right now.",
"empty": "No audit events found",
"emptyDetail": "Adjust the action filter.",
"relatedFilterActive": "Showing only events linked to this action ({{count}} related events).",
"clearFilter": "Clear this filter",
"count": "{{count}} events",
"storedRendered": "UTC stored · Brussels rendered",
"columns": {
"when": "When",
"actor": "Actor",
"action": "Action",
"entity": "Entity",
"change": "Change",
"followUp": "Follow-up",
"details": "Details"
},
"viewRelatedEvents": "View related events",
"noChangeDetail": "No recorded change detail.",
"noFieldChange": "No field-level change detected.",
"reference": "Reference",
"relatedEventsCount": "{{count}} linked event",
"relatedEventsCount_other": "{{count}} linked events",
"showTechnicalEvents": "Show {{count}} technical event",
"showTechnicalEvents_other": "Show {{count}} technical events",
"hideTechnicalEvents": "Hide technical events",
"technicalDetails": "Technical details",
"fullReference": "Full reference",
"correlationId": "Correlation ID",
"diff": {
"setTo": "{{field}}: set to {{value}}",
"was": "{{field}}: was {{value}}",
"changed": "{{field}}: {{before}} → {{after}}"
},
"actorTypes": {
"user": "User",
"system": "System",
"service": "Service"
},
"fields": {
"status": "Status",
"operational_status": "Operational status",
"registration_number": "Registration number",
"make": "Make",
"model": "Model",
"location": "Location",
"first_name": "First name",
"last_name": "Last name",
"email": "Email",
"phone": "Phone",
"postal_code": "Postal code",
"city": "City",
"booking_end_odometer_km": "Booking end odometer",
"vehicle_odometer_km": "Vehicle odometer",
"survivor": "Retained profile",
"loser": "Merged profile"
},
"trace": {
"eyebrow": "End-to-end trace",
"title": "From return to automation",
"description": "This view connects events sharing the same correlation ID into one verifiable operation.",
"steps": {
"return": { "title": "Return persisted", "complete": "Booking and inspection were processed atomically.", "pending": "Not processed yet." },
"vehicle": { "title": "Fleet state derived", "complete": "The server persisted the resulting vehicle state.", "pending": "No state change found yet." },
"outbox": { "title": "Automation queued", "complete": "The outbox job was created in the same transaction.", "pending": "No outbox job found." },
"delivery": { "title": "External follow-up", "complete": "n8n confirmed the follow-up.", "pending": "Delivery is asynchronous and not yet confirmed in this audit trace." }
}
},
"actions": {
"demo_login": "Logged in",
"demo_logout": "Logged out",
"demo_reset": "Demo data reset",
"demo_data_seeded": "Demo data seeded",
"return_registered": "Vehicle return registered",
"vehicle_status_changed": "Vehicle status changed",
"data_quality_issue_resolved": "Data-quality issue resolved",
"data_quality_issue_deferred": "Data-quality issue deferred",
"data_quality_issue_rejected": "Data-quality issue rejected",
"data_quality_fields_provided": "Missing fields provided",
"data_quality_odometer_corrected": "Odometer corrected",
"data_quality_odometer_retained": "Canonical odometer retained",
"data_quality_status_applied": "Recommended status applied",
"data_quality_booking_blocked": "Booking blocked",
"data_quality_scan_run": "Quality scan run",
"customer_merged": "Customers merged",
"workflow_retry": "Automation retried",
"knowledge_question_asked": "Knowledge question asked",
"mcp_tool_request": "MCP tool called"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"brandTagline": "Control Centre",
"orgLine": "Demo organisation: {{orgName}} (fictional)",
"headline1": "Every hand-off.",
"headline2": "One clear view.",
"defaultDescription": "{{productName}} brings vehicle, booking and operational data together, supports rental processes, detects data-quality problems and automates controlled follow-up steps.",
"footnote": "Synthetic demo · no real customer or vehicle data · resettable at any time",
"accessEyebrow": "Demo access",
"accessHeading": "Choose how to start",
"accessIntro": "No password needed. Each role opens a scoped synthetic environment — all workflows and controls are really implemented.",
"startGuidedDemo": "Start guided demo",
"guidedDemoChecking": "Preparing the guided demo…",
"guidedDemoManifestUnavailable": "The guided-demo information could not be loaded.",
"guidedDemoNotReady": "The guided demo is not ready to start right now.",
"guidedDemoRetry": "Reload demo information",
"startRecruiterTour": "See the highlights in 90 seconds",
"exploreAsOperationsManager": "Explore as Operations Manager",
"exploreAsOperationsManagerDetail": "Full overview, quality resolution and retries",
"exploreAsRentalEmployee": "Explore as Rental Employee",
"exploreAsRentalEmployeeDetail": "Bookings, returns, fleet and procedures",
"safeByDesignTitle": "Safe by design",
"safeByDesignDetail": "Every action is logged and is resettable in this demo.",
"loginFailed": "The demo session could not be started. The API may be unreachable.",
"roleOperationsManager": "Operations manager",
"roleRentalEmployee": "Rental employee",
"switchRole": "Switch role",
"logout": "Log out",
"emailLabel": "Email address",
"passwordLabel": "Password",
"signIn": "Sign in",
"passwordLoginFailed": "Sign-in failed. Check your credentials.",
"or": "or",
"signInWithOrganization": "Sign in with {{provider}}"
}
@@ -0,0 +1,132 @@
{
"list": {
"eyebrow": "Operations / Schedule",
"title": "Bookings",
"description": "Review active rental windows and upcoming vehicle commitments.",
"create": "New booking",
"searchLabel": "Search",
"searchPlaceholder": "Booking, customer or vehicle",
"statusLabel": "Status",
"statusAll": "All statuses",
"fromLabel": "From date",
"toLabel": "Through date",
"locationLabel": "Location",
"locationPlaceholder": "Exact operating location",
"sortLabel": "Sort",
"sortOperational": "Operational priority",
"sortAscending": "Start date ascending",
"sortDescending": "Start date descending",
"presetsLabel": "Quick filters",
"todayPreset": "Today",
"upcomingPreset": "Upcoming reservations",
"clearFilters": "Clear filters",
"loading": "Loading booking ledger…",
"empty": "No bookings found",
"emptyDetail": "Adjust the booking status filter.",
"noMatch": "No matching bookings",
"noMatchDetail": "Try a broader search term.",
"unavailable": "Booking list is unavailable right now.",
"count": "{{count}} bookings",
"pageOf": "Page {{page}} of {{total}}",
"columns": {
"reference": "Reference",
"customer": "Customer",
"vehicle": "Vehicle",
"window": "Window",
"status": "Status"
},
"paginationLabel": "Booking pages",
"previous": "Previous",
"next": "Next",
"rangeOf": "{{from}}–{{to}} of {{total}}"
},
"create": {
"backLink": "Booking ledger",
"eyebrow": "Bookings / New reservation",
"title": "Create booking",
"description": "Select a customer and a vehicle that is available for the full rental window.",
"startsAt": "Starts",
"endsAt": "Ends",
"customerSearch": "Search customer",
"customerPlaceholder": "Name, email or customer reference",
"customer": "Customer",
"searchFirst": "Search for a customer first",
"chooseCustomer": "Select a customer",
"vehicle": "Available vehicle",
"vehicleSearch": "Search vehicle",
"vehiclePlaceholder": "Reference, make, model, registration or location",
"locationUnknown": "location unknown",
"loadingVehicles": "Checking availability…",
"chooseVehicle": "Select a vehicle",
"noVehicles": "No vehicle available for this window",
"invalidLocalTime": "Choose a valid Europe/Brussels time. The skipped hour during the spring clock change does not exist.",
"endAfterStart": "The booking end must be after its start.",
"cancel": "Cancel",
"save": "Create booking",
"saving": "Saving booking…",
"failed": "The booking could not be created. Check the window and availability."
},
"statuses": {
"reserved": "reserved",
"active": "active",
"returned": "returned",
"cancelled": "cancelled",
"blocked": "blocked"
},
"checkout": {
"title": "Check out vehicle",
"description": "Record departure condition. Any exception automatically blocks the rental for follow-up.",
"preparing": "Preparing departure inspection…",
"odometer": "Start odometer",
"fuel": "Fuel level (%)",
"condition": "Departure condition",
"clean": "Vehicle is clean",
"damage": "Damage found",
"warning": "Technical warning",
"notes": "Notes",
"submit": "Record inspection and start rental",
"saving": "Recording checkout…",
"failed": "The departure inspection could not be recorded.",
"activatedTitle": "Rental started",
"activatedDetail": "Departure inspection {{inspection}} was saved and the vehicle is now rented.",
"blockedTitle": "Rental blocked",
"blockedDetail": "Departure inspection {{inspection}} contains an exception. The vehicle was safely removed from service."
},
"detail": {
"backLink": "Booking ledger",
"eyebrow": "Bookings / Rental record",
"notFound": "This booking could not be found.",
"loading": "Loading booking record…",
"customer": "Customer",
"vehicle": "Vehicle",
"starts": "Starts",
"ends": "Ends",
"startOdometer": "Start odometer",
"endOdometer": "End odometer",
"startOdometerPending": "Not yet recorded — trip hasn't started yet",
"endOdometerPending": "Not yet recorded — not yet closed",
"requirementsComplete": "Requirements complete",
"requirementsAction": "Check requirements",
"requirementsActionDetail": "The departure inspection remains locked until the driving licence and rental requirements are explicitly confirmed.",
"requirementsConfirmation": "Check evidence",
"requirementsConfirmationPlaceholder": "Record what was checked",
"requirementsConfirm": "Confirm requirements",
"requirementsSaving": "Confirming…",
"requirementsFailed": "The requirements could not be confirmed.",
"rescheduleAction": "Reschedule reservation",
"rescheduleDetail": "Change the rental window. Availability is checked again before the change is saved.",
"rescheduleReason": "Reason for change",
"confirmReschedule": "Save new window",
"rescheduling": "Saving window…",
"rescheduleFailed": "The reservation could not be rescheduled.",
"invalidScheduleWindow": "Choose valid Europe/Brussels times and make sure the end is after the start.",
"yes": "Yes",
"no": "No",
"cancelAction": "Cancel booking",
"cancelReason": "Reason",
"cancelReasonPlaceholder": "Why is this booking being cancelled?",
"confirmCancel": "Confirm cancellation",
"cancelling": "Cancelling…",
"cancelFailed": "The booking could not be cancelled."
}
}
@@ -0,0 +1,49 @@
{
"orgName": "Northstar Mobility",
"brandTagline": "Control Centre",
"actions": {
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"close": "Close",
"back": "Back",
"next": "Next",
"retry": "Retry",
"reset": "Reset",
"yes": "Yes",
"no": "No",
"viewDetails": "View details",
"technicalDetails": "Technical details"
},
"status": {
"loading": "Loading…",
"saving": "Saving…",
"error": "Something went wrong",
"success": "Success",
"noResults": "No results"
},
"pagination": {
"label": "Page navigation",
"previous": "Previous",
"next": "Next",
"pageOf": "Page {{page}} of {{total}}"
},
"states": {
"loadingDefault": "Loading workspace…",
"errorTitle": "We couldn't load this workspace."
},
"timezone": "Europe/Brussels",
"language": {
"label": "Language",
"nl-BE": "Nederlands",
"en-GB": "English",
"fr-BE": "Français"
},
"footer": {
"productLine": "{{productName}} Demo",
"locale": "Europe/Brussels · Synthetic demo data"
},
"demo": {
"syntheticBadge": "Synthetic demo"
}
}
@@ -0,0 +1,85 @@
{
"eyebrow": "Operations / Live overview",
"greeting": {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
"night": "Welcome back"
},
"greetingBody": {
"morning": "Here's today's fleet status.",
"afternoon": "Here's the current overview of your fleet.",
"evening": "Here's this evening's fleet overview.",
"night": "Here's the latest overview of your fleet."
},
"description": "Readiness, exceptions and hand-offs across today's operation.",
"viewFleet": "View fleet",
"demoStart": {
"title": "Try a demonstration scenario",
"readyCount": "{{ready}} of {{total}} scenarios ready for demo.",
"readyCountFallback": "Five focused scenarios.",
"startGuide": "Start demo guide",
"resumeGuide": "Continue demo guide ({{current}}/{{total}})",
"viewScenarios": "View scenarios"
},
"readiness": {
"title": "Fleet readiness",
"description": "Live from persisted vehicle state",
"openFleet": "Open fleet",
"available": "Available",
"rented": "Rented",
"cleaning": "Cleaning",
"maintenance": "Maintenance",
"blocked": "Blocked"
},
"attention": {
"title": "Attention queue",
"description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions",
"reviewQueue": "Review queue",
"remaining_one": "One more attention item",
"remaining_other": "{{count}} more attention items",
"remainingHint": "Open the complete work queue",
"filterPlaceholder": "Filter issues…",
"filterAriaLabel": "Search attention queue",
"severityAll": "All severity",
"severityHigh": "Critical",
"severityMedium": "Warning",
"severityLow": "Info",
"severityAriaLabel": "Severity",
"tierNow": "Handle now",
"tierToday": "Follow up today",
"tierLater": "Review later",
"empty": "No issues match this filter.",
"openRecord": "Open {{title}}"
},
"movements": {
"title": "Today's movements",
"description": "Departures and returns in Europe/Brussels",
"allBookings": "All bookings",
"empty": "No movements scheduled today.",
"departure": "departure",
"return": "return",
"openBooking": "Open booking {{ref}}"
},
"integrationPulse": {
"title": "Integration pulse",
"description": "Current evidence from connected services",
"systemDetail": "System detail",
"n8nTitle": "n8n delivery",
"n8nSummary": "{{succeeded}} succeeded · {{failed}} failed",
"n8nLatest": "Latest event {{ref}}",
"n8nNoEvidence": "No workflow evidence recorded",
"knowledgeTitle": "Knowledge assistant",
"knowledgeSummary": "{{count}} procedures indexed",
"knowledgeIndexUnknown": "{{count}} knowledge sources · index size not verifiable",
"knowledgeUnavailable": "Health check unavailable",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Registration enabled",
"mcpNotConnected": "Not yet connected"
},
"recent": {
"title": "Recent activity",
"description": "Latest audited workflow changes",
"empty": "No automation activity recorded."
}
}
+270
View File
@@ -0,0 +1,270 @@
{
"badge": {
"trigger": "Synthetic demo",
"dialogLabel": "About this demo environment",
"close": "Close",
"orgIntro": "<strong>{{orgName}}</strong> is a fictional organisation. All names, vehicles and bookings are synthetic.",
"orgIntroFallback": "All names, vehicles and bookings in this environment are synthetic.",
"realWorkflows": "The workflows, controls and automation are really implemented — only the data is invented.",
"lastReset": "Last reset: <strong>{{when}}</strong> · this environment is resettable at any time.",
"aboutLink": "About this demo",
"unknown": "unknown"
},
"guide": {
"dialogLabel": "Guided demo",
"trigger": "Demo guide",
"kicker": "Guided demo · step {{current}} of {{total}}",
"whatYouWillSee": "What you'll see",
"whyItMatters": "Why it matters",
"startAction": "Get started",
"expectedOutcome": "Expected outcome",
"goToStep": "Go to this step",
"next": "Next",
"finish": "Finish demo",
"close": "Close",
"restart": "Prepare demo again",
"restarting": "Restarting…",
"restartFailed": "Could not restart the demo.",
"allStepsLabel": "All steps",
"progressChip": "Demo guide · step {{current}} of {{total}}",
"expand": "Expand demo guide",
"collapse": "Collapse demo guide",
"steps": {
"understand-state": {
"title": "1. Understand the operational state",
"whatYouWillSee": "The dashboard shows fleet readiness, open attention points and today's movements.",
"whyItMatters": "An Operations Manager starts every day with this overview to decide where to step in.",
"startAction": "Open the dashboard and review the attention queue and today's timeline.",
"expectedOutcome": "You see which bookings, vehicles or data-quality issues need attention."
},
"open-booking": {
"title": "2. Open the booking needing attention",
"whatYouWillSee": "Booking BK-DEMO-RETURN, active and due for return today.",
"whyItMatters": "Returns are the moment incorrect odometer readings or damage first become visible.",
"startAction": "Open the booking from the dashboard or the booking list.",
"expectedOutcome": "You see the booking details and the button to process the return."
},
"process-return": {
"title": "3. Process a return with an odometer anomaly",
"whatYouWillSee": "A return form pre-filled with a suspicious odometer reading below the last known reading.",
"whyItMatters": "A falling odometer signals a data-entry mistake or a mixed-up vehicle — this must be caught before release.",
"startAction": "Submit the return with the suggested value and review the server preview before confirming.",
"expectedOutcome": "The return is processed, the vehicle gets an appropriate status, and a data-quality issue is created automatically."
},
"handle-quality-issue": {
"title": "4. Handle the created data-quality issue",
"whatYouWillSee": "A new 'odometer regression' issue at the top of the work queue.",
"whyItMatters": "Every detected issue has one bounded resolution step — nothing is silently auto-corrected.",
"startAction": "Open the data-quality overview and choose the newest issue.",
"expectedOutcome": "You see the recommended action, choose a resolution, and the issue resolves with an audit trail."
},
"merge-duplicate": {
"title": "5. Review and handle a possible duplicate customer",
"whatYouWillSee": "Two customer profiles with the same email and phone number, compared side by side.",
"whyItMatters": "Duplicate customers split booking history and confuse communication.",
"startAction": "Compare both profiles and choose which one survives.",
"expectedOutcome": "The profiles are merged, bookings are rewired, and the losing profile becomes a tombstone."
},
"ask-knowledge": {
"title": "6. Ask the procedure assistant a question",
"whatYouWillSee": "An answer with citation from the scoped demo knowledge base.",
"whyItMatters": "Staff need a quick, grounded answer about procedures, without guessing.",
"startAction": "Click one of the suggested questions, in the current interface language.",
"expectedOutcome": "You see the answer, the procedure used, and the source text — or an honest 'insufficient information' if none exists."
},
"check-automation-audit": {
"title": "7. Check automation and the audit trail",
"whatYouWillSee": "The delivery status of your return's automation job, and its related audit events.",
"whyItMatters": "Every important action must be traceable: who did what, when, and what followed.",
"startAction": "Open Integrations to see delivery status, and Audit trail for the full record.",
"expectedOutcome": "You see a successful (or recoverable) delivery and a readable audit trail of your actions."
},
"review-real-vs-simulated": {
"title": "8. Review what's real, simulated, or not yet connected",
"whatYouWillSee": "An overview of what's functionally implemented in this demo, what's synthetic, and which integrations aren't live yet.",
"whyItMatters": "A demo is only convincing if visitors can verify for themselves what really works and what's still ahead.",
"startAction": "Read the 'About this demo' page.",
"expectedOutcome": "You can explain yourself what {{productName}} is and isn't, with no verbal explanation needed."
}
}
},
"highlights": {
"eyebrow": "Recruiter highlights",
"title": "The strongest choices in 90 seconds",
"description": "Three short views show how {{productName}} combines operational reliability, controlled corrections and source-grounded AI.",
"duration": "about 30 sec",
"open": "View in the platform",
"proofLabel": "Engineering proof",
"return": { "title": "A return that cannot fail silently", "body": "See server-side impact validation, atomic persistence and an outbox that decouples external automation from the rental desk.", "proof": "Transaction · idempotency · outbox · degradation" },
"quality": { "title": "Data quality with human control", "body": "Compare duplicate profiles, deliberately choose the surviving record and preserve booking and audit history.", "proof": "Explainable rules · preview · audit · rollback" },
"knowledge": { "title": "AI that must show its sources", "body": "Ask a procedure question and inspect the answer, source excerpts and honest insufficient-evidence state.", "proof": "RAGcore · citations · safe degradation" },
"storyTitle": "Want to see the choices behind the system?",
"storyBody": "The Engineering page connects product flows to architecture, reliability and honest integration status.",
"storyButton": "Open Engineering Story",
"fullTourButton": "Start the complete demo"
},
"scenarios": {
"eyebrow": "Demonstration scenarios",
"title": "Try a demonstration scenario",
"description": "Five focused scenarios that always use the same fixed bookings, customers and vehicles — always re-findable after a reset.",
"loading": "Loading scenarios…",
"unavailable": "The demo scenarios could not be loaded. Please try again.",
"ready": "Ready for demo",
"notReady": "Not available",
"duration": "Duration",
"durationValue": "± {{minutes}} min",
"role": "Role",
"demonstrates": "Demonstrates:",
"requiresRole": "Requires role: {{roles}}.",
"startScenario": "Start scenario",
"roleOr": "{{a}} or {{b}}",
"roles": {
"operations_manager": "Operations Manager",
"rental_employee": "Rental Employee"
},
"items": {
"return-anomaly": {
"title": "Return with an odometer anomaly",
"problem": "A vehicle comes back with an odometer reading lower than the last recorded reading — a sign of a data-entry mistake or a mixed-up vehicle.",
"demonstrates": "Return processing, automatic data-quality detection, and the audit trail it produces."
},
"duplicate-customer": {
"title": "Merge a possible duplicate customer",
"problem": "Two customer profiles share the same email address and phone number — likely the same person, registered twice.",
"demonstrates": "Merging customers while preserving booking history and the audit trail."
},
"booking-overlap": {
"title": "Resolve overlapping bookings",
"problem": "One vehicle is double-booked for overlapping periods — a scheduling error that must be resolved before departure.",
"demonstrates": "Detection and controlled resolution of scheduling conflicts."
},
"automation-retry": {
"title": "Retry a failed automation job",
"problem": "One earlier event could not be delivered to the automation job due to a simulated connection error.",
"demonstrates": "Reliable delivery with bounded retries and visible failure status."
},
"knowledge-question": {
"title": "Ask a procedure question",
"problem": "An employee isn't sure which procedure applies to a specific operational situation.",
"demonstrates": "Source-grounded answers from a scoped demo knowledge base."
}
},
"blockedReasons": {
"bookingNotFound": "Demo booking BK-DEMO-RETURN not found. {{resetHint}}",
"bookingAlreadyProcessed": "This booking has already been processed since the last reset. {{resetHint}}",
"duplicateIssueNotFound": "Demo issue DQ-DEMO-DUPLICATE not found. {{resetHint}}",
"issueAlreadyResolved": "This issue has already been resolved since the last reset. {{resetHint}}",
"overlapIssueNotFound": "Demo issue DQ-DEMO-OVERLAP not found. {{resetHint}}",
"failedEventNotFound": "The simulated failed event was not found. {{resetHint}}",
"eventAlreadyRecovered": "This event has already been recovered since the last reset. {{resetHint}}",
"knowledgeUnavailable": "The demo knowledge base is currently unavailable.",
"resetHint": "Reset the demo data to make this scenario available again."
}
},
"about": {
"eyebrow": "About this demo",
"title": "What {{productName}} is and isn't",
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
"loading": "Loading demo information…",
"unavailable": "The demo information could not be loaded. Please try again.",
"ctaTitle": "Choose how much time you have",
"ctaBody": "See the three strongest engineering moments in 90 seconds, or take the complete operational tour.",
"ctaButton": "Start full demo",
"quickTourButton": "See highlights · 90 sec",
"scenarioButton": "Open scenario lab",
"proofEyebrow": "Engineering evidence",
"proofTitle": "Control, reliability and explainability",
"proofDescription": "Every technical choice supports a concrete operational action.",
"proofRulesTitle": "Rules stay with the domain",
"proofRulesBody": "FastAPI governs returns, statuses and data-quality resolutions. n8n and prompts never make authoritative decisions.",
"proofReliabilityTitle": "Commit first, orchestrate second",
"proofReliabilityBody": "The operation commits atomically; an outbox then delivers to n8n with bounded retries. An outage never blocks the rental desk.",
"proofAiTitle": "AI must show evidence",
"proofAiBody": "RAGcore answers include sources. Without sufficient evidence the assistant does not invent an answer.",
"architectureFlowTitle": "From user action to controlled follow-up",
"architectureFlowDescription": "The system of record stays separate from external orchestration and AI.",
"architectureFrontend": "Accessible React interface",
"architectureApi": "FastAPI domain rules",
"architectureDatabase": "PostgreSQL + audit trail",
"architectureOutbox": "Reliable outbox",
"architectureExternal": "External services: n8n, RAGcore and MCP Hub",
"architectureZonesLabel": "Architecture responsibility boundaries",
"architectureZoneIntent": "User intent",
"architectureZoneTransaction": "Local transaction",
"architectureZoneEdge": "Recoverable system edge",
"architectureFlowLabel": "Interactive system flow from interface to external services",
"architectureCommitBoundary": "After commit",
"architectureInteractionHint": "Select a step to inspect its operational guarantee and control points.",
"architectureSelectedStep": "Step {{current}} of {{total}}",
"architectureBoundaryLabel": "Responsibility",
"architectureEvidenceLabel": "Control points",
"architectureDetails": {
"Frontend": {
"summary": "Makes role and intent explicit",
"body": "Accessible forms collect validated input and show the impact before confirmation. The interface never decides a domain status on its own.",
"proofOne": "Role-based route guard",
"proofTwo": "Preview before confirmation"
},
"Api": {
"summary": "Validates every operational rule",
"body": "FastAPI enforces statuses, invariants and resolution rules at the API boundary, independently of what the browser submits.",
"proofOne": "Pydantic validation",
"proofTwo": "Server-side domain decision"
},
"Database": {
"summary": "Commits data and audit atomically",
"body": "PostgreSQL stores the operational change and its audit evidence in the same transaction, preventing a partial state change.",
"proofOne": "UUID + public reference",
"proofTwo": "UTC + unbroken audit trail"
},
"Outbox": {
"summary": "Records follow-up work durably",
"body": "The outbox record commits with the operation. A worker only delivers afterwards, idempotently and with bounded retries, to the orchestration layer.",
"proofOne": "Post-commit delivery",
"proofTwo": "Idempotency + bounded retries"
},
"External": {
"summary": "Degrades without local data loss",
"body": "n8n, RAGcore and MCP Hub have timeouts and visible health state. Failure remains recoverable and AI never answers without sufficient source evidence.",
"proofOne": "Health state + timeouts",
"proofTwo": "No answer without evidence"
}
},
"verificationTitle": "Built to be verified",
"verificationBody": "Domain rules, API contracts, degraded modes and the complete demo are tested automatically. The repository contains the exact acceptance commands and evidence bundle.",
"problemTitle": "The fictional problem",
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. {{productName}} shows how one connected system flags these problems early and lets them be resolved under control.",
"scopeTitle": "Who it's for and its scope",
"scopeBody": "This demo is for anyone who wants to see how {{productName}} tackles operational problems at a small rental company: Operations Managers and Rental Employees, and anyone evaluating the approach. The scope is deliberately focused on one connected proof of concept — no accounting, no payments, no public reservations, no full CRM or ERP.",
"realTitle": "What really works",
"realBody": "Everything below is functional code, not just a mockup: role-based access and sessions, vehicle and booking management, return processing with server-side validation, five data-quality rules each with its own resolution step, a full audit trail, automated delivery to n8n with bounded retries, Docker-based deployment, and an automated test suite (backend and Playwright end-to-end).",
"syntheticTitle": "What's synthetic",
"syntheticBody": "The organisation, all customers, vehicles, bookings, maintenance history, procedures in the knowledge base, and the pre-set-up scenarios are entirely invented. No data refers to a real person, vehicle or company; email addresses only use the {{testDomain}} test domain.",
"architectureTitle": "Architecture in brief",
"architectureBody": "A React/TypeScript frontend talks to a FastAPI backend (PostgreSQL via SQLAlchemy/Alembic migrations); important business rules live in the backend, not in n8n or in prompts. Returns and other events are committed locally first and only then delivered asynchronously to n8n through an automation job, so a temporary automation outage never blocks an operational action.",
"securityTitle": "Security and access",
"securityBody": "Access runs through signed, HTTP-only session cookies per role; each role is bound to a set of allowed routes, both enforced server-side and reflected in navigation. Important state changes are always checked and logged — never silently auto-corrected.",
"testingTitle": "How this is tested",
"testingBody": "An automated backend test suite covers business rules and API contracts; a full Playwright end-to-end suite covers the user flows, including this demo experience itself in three languages. Every change is also validated against a clean checkout (empty database, rebuilt from seed data) before deployment.",
"integrationsTitle": "Integrations — honestly labelled",
"integrationsDescription": "What's operational, what's demo mode, and what's not yet connected.",
"resetTitle": "Restoring the demo environment",
"resetBodyManager": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. Use <strong>Reset demo data</strong> in the sidebar to start over.",
"resetBodyEmployee": "The environment can be reset to its starting state at any time. Last reset: <strong>{{when}}</strong>. An Operations Manager can reset the demo environment via the sidebar.",
"limitationsTitle": "Limitations",
"limitationsBody": "This is a focused operational platform, not a full ERP. Accounting, payments, public reservations and a generic CRM are deliberately out of scope. External integrations are assessed separately and honestly using recent evidence.",
"unknown": "unknown"
},
"integrationSummary": {
"titles": {
"n8n": "Automation (n8n)",
"ragcore": "Knowledge assistant (RAGcore)",
"mcp_hub": "ITWorx MCP Hub"
},
"n8nDetail": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending.",
"ragcoreDetail": "{{count}} procedures indexed in {{collection}}.",
"ragcoreSourceDetail": "{{source_count}} managed source procedures available; RAGcore does not report a reliable index count.",
"mcpDetailOperational": "Live controlled tool calls from the Hub are confirmed in the audit trail.",
"mcpDetailPrepared": "Four read-only tools are contract-ready; no recent Hub call has been recorded."
}
}
+206
View File
@@ -0,0 +1,206 @@
{
"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."
},
"CUSTOMER_ALREADY_MERGED": {
"title": "Customer already merged",
"explanation": "One of these customer records has already been merged into another record.",
"nextStep": "Reload the issue; it may already be resolved."
},
"ENTITY_NOT_FOUND": {
"title": "Record not found",
"explanation": "The underlying record for this action could not be found."
},
"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."
},
"ISSUE_CHANGED": {
"title": "Issue evidence has changed",
"explanation": "New evidence was recorded while this correction was being prepared.",
"nextStep": "Refresh the issue and review the current evidence before deciding again."
},
"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_EVENT_CORRELATION": {
"title": "Invalid correlation reference",
"explanation": "The automation callback does not contain a valid correlation reference."
},
"CALLBACK_EVENT_MISMATCH": {
"title": "Callback event does not match",
"explanation": "The automation callback refers to a different event than the event being updated."
},
"CALLBACK_CORRELATION_MISMATCH": {
"title": "Callback trace does not match",
"explanation": "The automation callback does not match the correlation reference stored for this event."
},
"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."
},
"UNKNOWN_WORKFLOW": {
"title": "Unknown automation workflow",
"explanation": "This workflow is not one of the four managed automation workflows."
}
},
"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."
}
}
}
@@ -0,0 +1,91 @@
{
"list": {
"eyebrow": "Fleet / Registry",
"title": "Vehicle fleet",
"description": "Live operational state, location and service readiness.",
"searchLabel": "Search",
"searchPlaceholder": "Reference, make or location",
"statusLabel": "Status",
"statusAll": "All statuses",
"attentionOnly": "Attention only",
"locationFilter": "Location",
"locationPlaceholder": "Exact operating location",
"serviceDue": "Service overdue",
"serviceRemaining": "{{count}} km remaining",
"attentionReasons": {
"blocked_status": "Blocked status",
"service_due": "Service overdue",
"data_quality": "Open quality issue"
},
"loading": "Loading fleet registry…",
"empty": "No vehicles found",
"emptyDetail": "Adjust the current fleet filters.",
"noMatch": "No matching vehicles",
"noMatchDetail": "Try a broader search term.",
"unavailable": "Vehicle list is unavailable right now.",
"count": "{{count}} vehicles",
"persisted": "Persisted fleet data",
"columns": {
"reference": "Reference",
"makeModel": "Make / model",
"location": "Location",
"status": "Status",
"odometer": "Odometer (km)",
"service": "Service",
"nextBooking": "Next booking",
"attention": "Attention"
},
"needsAttention": "Needs attention"
},
"statuses": {
"available": "available",
"rented": "rented",
"cleaning": "cleaning",
"maintenance": "maintenance",
"blocked": "blocked"
},
"detail": {
"backLink": "Fleet registry",
"eyebrow": "Fleet / Vehicle record",
"notFound": "This vehicle could not be found.",
"loading": "Loading vehicle record…",
"needsAttention": "Needs attention",
"tabs": {
"overview": "Overview",
"bookings": "Bookings",
"inspections": "Inspections",
"maintenance": "Maintenance",
"quality": "Quality"
},
"tabsAriaLabel": "Vehicle sections",
"overview": {
"registration": "Registration",
"modelYear": "Model year",
"location": "Location",
"odometer": "Odometer",
"nextService": "Next service",
"active": "Active"
},
"yes": "Yes",
"no": "No",
"noBookings": "No bookings recorded.",
"noInspections": "No inspections recorded.",
"noMaintenance": "No maintenance records.",
"noQualityIssues": "No quality issues recorded.",
"fuel": "Fuel {{percent}}%",
"damage": "Damage",
"technicalWarning": "Technical warning",
"inspectionTypes": {
"departure": "Departure inspection",
"return": "Return inspection"
},
"maintenanceCategories": {
"periodic_service": "Periodic service",
"repair": "Repair",
"inspection": "Technical inspection",
"tyres": "Tyres",
"other": "Other"
}
},
"maintenance": { "actionsTitle": "Maintenance actions", "actionsDescription": "Record completed work or release a safe vehicle back into service.", "add": "Record maintenance", "date": "Date", "odometer": "Odometer", "category": "Category", "nextService": "Next service (km)", "summary": "Work completed", "cancel": "Cancel", "save": "Save maintenance", "saving": "Saving…", "failed": "Maintenance could not be saved.", "releaseReason": "Release reason", "releasePlaceholder": "Which check confirms the vehicle is safe for service?", "release": "Release vehicle", "releaseFailed": "The vehicle cannot be released yet." }
}
@@ -0,0 +1,125 @@
{
"eyebrow": "Assure / Integrations",
"title": "Integration control",
"description": "Monitor delivery health, graceful degradation and retryable workflow events.",
"cards": {
"checking": "Retrieving current integration status…",
"orchestrationKicker": "Orchestration",
"n8nTitle": "n8n delivery",
"n8nSummary": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending · {{delivering}} delivering",
"n8nFallback": "Return events are committed locally first and then delivered through the automation job.",
"knowledgeKicker": "Knowledge",
"knowledgeTitle": "Knowledge assistant",
"knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.",
"knowledgeSummaryIndexUnknown": "RAGcore reachable · {{sourceCount}} source documents for this language · index size not verifiable for {{collection}}.",
"knowledgeSummarySyncReported": "RAGcore reachable · {{sourceCount}} sources for this language · latest sync reported {{synced}} processed, {{failed}} failed at {{when}} · index size not verifiable for {{collection}}.",
"knowledgeUnavailable": "Health evidence is currently unavailable.",
"gatewayKicker": "Tool gateway",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Registration is enabled for this deployment.",
"mcpNotConnected": "Not yet connected — prepared for future controlled tool calls from the Hub.",
"mcpNoEvidence": "Registered, but no tool call has been recorded yet.",
"mcpEvidence": "ITWorx MCP Hub last ran {{tool}} · {{count}} controlled calls in total",
"mcpTechnicalIdentity": "Technical client identity",
"mcpHubReachable": "Hub reachable",
"mcpHubUnreachable": "Hub unreachable",
"n8nDemoScenario": "Plus {{count}} prepared demo scenario — a simulated temporary failure, not an integration problem."
},
"statusLabels": {
"checking": "Checking…",
"notConnected": "Not connected",
"deliveryFailed": "Delivery failed",
"retryAvailable": "Retry available",
"operational": "Operational",
"prepared": "Prepared",
"demoMode": "Demo mode",
"unavailable": "Unavailable"
},
"workflows": {
"title": "Automation workflows",
"description": "{{known}} of {{expected}} canonical n8n workflows have live evidence of running.",
"loadingEvidence": "Retrieving current workflow evidence…",
"unavailable": "Workflow evidence is currently unavailable.",
"notBuilt": "Not built yet",
"noEvidence": "No evidence yet",
"healthy": "Healthy",
"stale": "Evidence is stale",
"failed": "Latest execution failed",
"executionId": "Execution",
"columns": {
"name": "Workflow",
"status": "Status",
"lastSeen": "Last evidence",
"technicalId": "Details"
},
"names": {
"vehicleReturn": "Vehicle return orchestration",
"scheduledScan": "Scheduled data quality scan",
"ragcoreSync": "Knowledge procedure sync",
"errorHandler": "Workflow error handler"
},
"expectations": {
"vehicleReturn": "Event-driven after a vehicle return is safely committed.",
"scheduledScan": "Scheduled; normally runs every hour.",
"ragcoreSync": "Scheduled; normally runs daily.",
"errorHandler": "Runs only when a real workflow execution fails."
},
"errorHandler": {
"summary": "{{count}} automation failure(s) registered — latest from {{workflow}} at {{when}}.",
"summaryEmpty": "No automation failures have been registered."
}
},
"ledger": {
"title": "Automation jobs",
"description": "Persisted automation attempts with the latest failure evidence.",
"filterLabel": "View",
"filterNeedsAttention": "Needs attention",
"filterRecent": "Recent",
"filterSucceeded": "Succeeded",
"filterAll": "All",
"statusFilterLabel": "Status",
"statusAll": "All statuses",
"statusPending": "Pending",
"statusDelivering": "Delivering",
"statusSucceeded": "Succeeded",
"statusFailed": "Failed",
"unavailable": "Automation jobs are unavailable right now.",
"managerOnly": "Automation is visible to Operations Managers only.",
"loading": "Loading automation jobs…",
"empty": "No automation jobs match this filter.",
"count": "{{count}} workflow events",
"boundedRetries": "Bounded retries",
"groupedSucceeded": "{{count}} succeeded vehicle-return jobs",
"showIndividually": "Show individual jobs",
"hideIndividually": "Hide individual jobs",
"columns": {
"event": "Job",
"type": "Type",
"booking": "Booking",
"status": "Status",
"attempts": "Attempts",
"lastError": "Last error",
"when": "When",
"action": "Action"
},
"retry": "Retry",
"retrying": "Retrying…",
"retryFailed": "Could not retry this delivery.",
"noAction": "—",
"eventTypes": {
"vehicle.returned.v1": "Vehicle return processed"
},
"errorCodes": {
"connectionError": "The workflow service was temporarily unreachable. The return was safely saved and can be retried.",
"malformedPayload": "The job contained incomplete data and could not be delivered. The underlying data remains safely stored.",
"remoteReportedFailure": "The workflow service declined the delivery. The job can be retried.",
"staleLeaseRecovered": "This job was recovered after an earlier delivery attempt stalled without a result.",
"unknownError": "An unexpected error occurred while delivering this job.",
"demoScenarioTimeout": "Simulated timeout towards the workflow service. This failure is part of the prepared demo scenario, not a real incident."
},
"demoScenarioBadge": "Prepared demo scenario",
"demoScenarioExplanation": "Simulated temporary failure, planted by the demo data on purpose to show retry and audit. It does not affect the health of the automation.",
"retryDemoScenario": "Retry demo scenario"
}
}
@@ -0,0 +1,73 @@
{
"eyebrow": "Assure / Grounded knowledge",
"title": "Procedure knowledge",
"description": "Ask operational questions. Answers are shown only when {{provider}} returns sufficient cited evidence.",
"providerDemo": "the demo knowledge base",
"providerRagcore": "RAGcore",
"providerChecking": "the knowledge source",
"providerUnavailable": "the knowledge service",
"statusChecking": "Checking knowledge source…",
"statusCheckingDetail": "Retrieving the current provider status.",
"statusAvailable": "Available",
"statusUnavailable": "Unavailable",
"proceduresIndexed": "{{count}} procedures indexed",
"proceduresIndexUnknown": "Index size is not available through this connection",
"proceduresAvailable": "{{count}} managed source procedures available",
"technicalDetails": "Technical provider details",
"progress": { "retrieving": "Selecting sources", "checking": "Checking evidence", "composing": "Building answer" },
"sourceTitles": {
"damage-procedure": "Vehicle return damage",
"vehicle-availability-procedure": "Restoring vehicle availability",
"vehicle-return-procedure": "Registering a vehicle return",
"odometer-anomaly-procedure": "Reviewing an odometer anomaly",
"checkout-procedure": "Required checkout controls"
},
"statistics": {
"scope": "Language scope: index and source counts apply to the current language; the n8n sync report covers all three languages.",
"verifiedIndexed": "exactly matched and actively published in this language",
"sourceDocuments": "source documents in this language",
"reportedSynced": "sync reported by n8n",
"reportedFailed": "sync failures reported",
"lastSync": "Latest sync report: {{when}}. Index state is independently checked through each managed source's exact identity and active RAGcore version.",
"noSyncReport": "No sync report has been received yet. Index evidence is checked separately through active RAGcore versions."
},
"providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.",
"askHeading": "Ask a procedure question",
"askSubheading": "Retrieval → evidence check → grounded answer",
"questionLabel": "Question",
"questionPlaceholder": "e.g. What must I do when a vehicle returns with damage?",
"ask": "Ask",
"asking": "Asking…",
"askFailed": "Could not reach the knowledge service.",
"feedbackFailed": "Could not save feedback.",
"suggestedLabel": "Try one:",
"suggestedQuestions": [
"What must I do when a vehicle returns with damage?",
"How do I register a vehicle return?",
"When may a vehicle be made available again?",
"Who reviews an unusual odometer reading?",
"Which checks are required before checkout?"
],
"emptyTitle": "Evidence before answers",
"emptyDescription": "Ask about returns, damage, inspections or another indexed procedure. {{productName}} will not invent an answer when evidence is missing.",
"retrievalFlow": {
"question": "Question",
"sources": "Sources",
"answer": "Answer"
},
"questionLabelExchange": "Question",
"evidenceStates": {
"grounded": "Grounded in cited procedures",
"insufficient": "Insufficient evidence",
"unavailable": "Knowledge service unavailable"
},
"unavailableBody": "The knowledge service is currently unreachable. Operational features are unaffected — try again later.",
"sourceVersion": "v{{version}}",
"answeredIn": "Answered in {{seconds}} s",
"sourceTechnicalVersion": "Technical source version available in the audit log",
"feedbackLabel": "Answer feedback",
"feedbackPrompt": "Was this answer useful?",
"feedbackThanks": "Feedback saved",
"feedbackHelpful": "Yes",
"feedbackNotHelpful": "No"
}
@@ -0,0 +1,53 @@
{
"skipToContent": "Skip to main content",
"groups": {
"operate": "Operate",
"assure": "Assure"
},
"items": {
"overview": "Overview",
"fleet": "Fleet",
"bookings": "Bookings",
"quality": "Data quality",
"knowledge": "Knowledge",
"integrations": "Integrations",
"audit": "Audit trail",
"users": "Users",
"privacy": "Privacy",
"engineering": "Engineering"
},
"primaryNavLabel": "Primary navigation",
"mobileNavLabel": "Mobile navigation",
"more": "More",
"openNavigation": "Open navigation",
"closeNavigation": "Close navigation",
"sidebarEnvironment": "Demo environment",
"sidebarEnvironmentDetail": "Synthetic data only",
"resetDemoData": "Reset demo data",
"resetConfirmTitle": "Confirm demo reset",
"resetConfirmBody": "All synthetic changes will be discarded and deterministic demo data restored. You will be signed out.",
"resetting": "Resetting…",
"resetConfirmYes": "Yes, reset",
"resetCancel": "Cancel",
"resetFailed": "Could not reset demo data.",
"searchLabel": "Search {{productName}}",
"searchPlaceholder": "Search fleet, booking or section…",
"searchShortcutHint": "Ctrl K",
"searchSearching": "Searching…",
"searchUnavailable": "Search is unavailable right now.",
"searchNoResults": "No matches for \"{{query}}\".",
"searchVehicleSummary": "{{make}} {{model}} · {{location}}",
"searchSections": {
"overview": "Operations dashboard",
"fleet": "Vehicle registry",
"bookings": "Rental bookings",
"quality": "Quality workbench",
"knowledge": "Procedure assistant",
"integrations": "Automation and integration status",
"audit": "Audit history",
"privacy": "Privacy management"
},
"switchRole": "Switch role",
"switchRoleTitle": "Switch demo role",
"languageSwitcherLabel": "Change language"
}
@@ -0,0 +1,4 @@
{
"users": { "eyebrow": "Administration / Access", "title": "Users", "description": "Manage operational access and roles. Every change is audited.", "managerOnly": "User administration is available to Operations Managers only.", "addTitle": "Add user", "name": "Name", "email": "Email", "role": "Role", "password": "Temporary password", "status": "Status", "action": "Action", "add": "Add user", "saving": "Saving…", "loading": "Loading users…", "active": "active", "inactive": "inactive", "activate": "Activate", "deactivate": "Deactivate", "edit": "Edit", "editTitle": "Edit user {{ref}}", "newPassword": "New password", "passwordUnchanged": "Leave empty to keep unchanged", "saveChanges": "Save changes", "cancel": "Cancel", "createFailed": "The user could not be created.", "updateFailed": "The user could not be updated." },
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Rental employee" }
}
@@ -0,0 +1,19 @@
{
"eyebrow": "Assure / Governance",
"title": "Privacy management",
"description": "Export personal data, monitor retention and anonymise safely with a complete audit trail.",
"managerOnly": "These governance functions are available to Operations Managers only.",
"loading": "Loading privacy policy…",
"error": "The privacy action could not be completed. Check the reference and retention period.",
"policyTitle": "Retention policy",
"totalCustomers": "Customers",
"eligibleCustomers": "Eligible",
"anonymizedCustomers": "Anonymised",
"bookingRetention": "Minimum booking retention",
"auditRetention": "Audit retention",
"days_one": "{{count}} day",
"days_other": "{{count}} days",
"customerRef": "Customer reference",
"exports": {"title":"Data exports","detail":"Download a customer file as JSON or a bounded audit export as CSV. Every export is logged.","customer":"Download customer file","audit":"Download audit CSV"},
"anonymize": {"title":"Anonymise customer","detail":"Irreversible. Active bookings and bookings newer than {{days}} days block this action.","reason":"Reasoned justification","confirmation":"Type {{ref}} to confirm","submit":"Anonymise permanently","working":"Anonymising…","anonymized":"{{ref}} was anonymised.","already_anonymized":"{{ref}} was already anonymised."}
}
@@ -0,0 +1,265 @@
{
"list": {
"eyebrow": "Assure / Workbench",
"title": "Data quality",
"description": "Resolve evidence-backed exceptions before they disrupt operations.",
"runScan": "Run quality scan",
"confirmScanTitle": "Confirm quality scan",
"confirmScanBody": "Run the deterministic scan across all five rule types now?",
"scanning": "Scanning…",
"confirmScanYes": "Yes, run scan",
"cancel": "Cancel",
"scanComplete": "Scan complete: {{summary}}",
"scanNoNew": "no new issues found (existing open issues are not recreated).",
"scanFailed": "Could not run the quality scan.",
"statusLabel": "Status",
"statusAll": "All statuses",
"statusOpen": "Open",
"statusDeferred": "Deferred",
"statusResolved": "Resolved",
"statusRejected": "Rejected",
"ruleTypeLabel": "Rule type",
"ruleTypeAll": "All rule types",
"severityLabel": "Severity",
"severityAll": "All severity levels",
"assigneeLabel": "Assigned to",
"assigneeAll": "All team members",
"unassigned": "Unassigned",
"overdueOnly": "Overdue only",
"bulkTitle": "Update selected work",
"selected": "{{count}} selected",
"assignTo": "Assign to",
"dueAt": "Due date",
"invalidDueAt": "Choose a valid Europe/Brussels date and time.",
"bulkApply": "Update work queue",
"bulkSaving": "Updating…",
"bulkFailed": "The work queue could not be updated.",
"clearSelection": "Clear selection",
"selectIssue": "Select issue {{ref}}",
"selectVisible": "Select visible issues",
"deselectVisible": "Clear visible selection",
"openIssue": "Open issue {{ref}}",
"filtersToggle": "Filters · {{count}} active",
"activeFilters": "Active filters",
"demoScenariosOnly": "Demo scenarios only",
"loading": "Loading quality workbench…",
"queueClear": "Queue is clear",
"noIssuesMatch": "No issues match the current filters.",
"noDemoIssuesMatch": "No demo-scenario issues match",
"noDemoIssuesMatchDetail": "Uncheck 'Demo scenarios only' to see the full queue.",
"unavailable": "Data-quality issues are unavailable right now.",
"count": "{{count}} issues",
"evidenceBacked": "Evidence-backed detection",
"columns": {
"reference": "Reference",
"select": "Select",
"rule": "Rule",
"entity": "Entity",
"severity": "Severity",
"status": "Status",
"assignee": "Assignee",
"due": "Due date"
}
},
"ruleTypes": {
"possible_duplicate_customer": "Possible duplicate customer",
"missing_required_field": "Missing required field",
"odometer_regression": "Odometer regression",
"booking_overlap": "Booking overlap",
"vehicle_status_conflict": "Vehicle status conflict"
},
"severities": {
"high": "Critical",
"medium": "Warning",
"low": "Info"
},
"detail": {
"backLink": "Quality workbench",
"eyebrow": "Quality / {{rule}}",
"title": "Review persisted evidence and record an audited resolution.",
"notFound": "This issue could not be found.",
"loading": "Loading issue evidence…",
"managerOnly": "The quality workbench is visible to Operations Managers only.",
"managerOnlyDetail": "Data-quality evidence and resolutions are visible to Operations Managers only.",
"summary": {
"rule": "Rule",
"entity": "Entity",
"evidenceSummary": "Evidence summary"
},
"resolved": {
"title": "Issue {{ref}} resolved",
"body": "The change has been applied and is recorded in the audit trail.",
"viewAudit": "View audit trail",
"viewVehicle": "View vehicle",
"continueDemo": "Continue the demo"
},
"deferOrReject": {
"heading": "Defer or reject",
"description": "Defer to review later, or reject if this is not a real issue.",
"defer": "Defer",
"reject": "Reject",
"deferFailed": "Could not defer this issue.",
"rejectFailed": "Could not reject this issue."
},
"explainer": {
"whatIsWrong": "What's wrong",
"whyItMatters": "Why it matters",
"possible_duplicate_customer": {
"whatIsWrong": "Two customer profiles share identifying details (email, phone or a very similar name) strongly enough that they are likely the same person, registered twice.",
"whyItMatters": "Duplicate customers split booking history across two records, risk duplicate billing, and confuse support conversations."
},
"missing_required_field": {
"whatIsWrong": "This record is missing information that's required for normal operation (for example, a customer with neither an email nor a phone number on file).",
"whyItMatters": "Without this data, the business can't reach the customer, or can't reliably identify the vehicle for compliance and hand-off checks."
},
"odometer_regression": {
"whatIsWrong": "A submitted odometer reading is lower than the vehicle's last known (canonical) reading.",
"whyItMatters": "A falling odometer usually means a data-entry mistake or that readings were recorded against the wrong vehicle. Letting it through silently would corrupt maintenance scheduling and resale mileage history."
},
"booking_overlap": {
"whatIsWrong": "The same vehicle is committed to two bookings whose date ranges overlap.",
"whyItMatters": "Only one of these bookings can actually be honoured. Left unresolved, a customer would arrive to find their vehicle already out with someone else."
},
"vehicle_status_conflict": {
"whatIsWrong": "This vehicle's stored operational status doesn't match what its own booking and inspection history implies it should be.",
"whyItMatters": "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet."
}
},
"evidence": {
"duplicate.exact_email": "Identical email address",
"duplicate.exact_phone": "Identical phone number",
"duplicate.same_postal_code": "Same postal code",
"duplicateSimilarName": "Strongly similar name (score {{score}})",
"missingField": "Missing field: {{field}}",
"overlapReservedBookings": "Overlapping bookings: {{refs}}",
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing."
},
"duplicateCustomer": {
"heading": "Compare and merge",
"description": "Choose the canonical customer and review each conflicting field.",
"keepAsSurvivor": "Keep as survivor",
"differs": "Differs",
"match": "Match",
"summaryCounts": "{{matchCount}} matching fields · {{conflictCount}} conflicting fields",
"showMatchingFields": "Also show matching fields",
"hideMatchingFields": "Hide matching fields",
"previewTitle": "Preview of the merged record ({{ref}})",
"mergePreview": "{{loser}} will be safely archived and linked to {{survivor}}; its booking history moves with it.",
"mergeInto": "Merge into {{ref}}",
"confirmMergeTitle": "Confirm merge",
"confirmMergeBody": "Merge {{loser}} into {{survivor}}? This cannot be undone.",
"merging": "Merging…",
"confirmMergeYes": "Yes, merge",
"mergeFailed": "Could not merge these customers.",
"bothMissing": "Both customers in this comparison could not be loaded.",
"fieldColumn": "Field",
"fields": {
"first_name": "First name",
"last_name": "Last name",
"email": "Email",
"phone": "Phone",
"postal_code": "Postal code",
"city": "City"
}
},
"missingField": {
"heading": "Provide the missing fields",
"description": "Complete the record for {{ref}}. The issue resolves automatically once nothing required is missing.",
"atLeastOne": "At least one of email or phone is required.",
"saveAndRecheck": "Save and re-check",
"saving": "Saving…",
"saveFailed": "Could not save these fields.",
"fields": {
"first_name": "First name",
"last_name": "Last name",
"email": "Email",
"phone": "Phone",
"registration_number": "Registration number",
"make": "Make",
"model": "Model",
"location": "Location"
}
},
"odometerRegression": {
"heading": "Resolve the odometer regression",
"description": "The canonical odometer is never lowered automatically -- choose how to reconcile it.",
"canonicalOdometer": "Canonical odometer",
"decisionLegend": "Decision",
"retainCanonical": "Retain canonical reading",
"retainCanonicalDetail": "Treat the submitted reading as erroneous; nothing changes on the vehicle record.",
"correctReading": "Correct the reading",
"correctReadingDetail": "Update both the booking and the canonical odometer with the correct value.",
"noBookingAttached": "No related booking is attached to this issue, so only 'retain canonical' is available.",
"booking": "Booking",
"correctedOdometer": "Corrected odometer (km)",
"note": "Note",
"resolving": "Resolving…",
"resolveIssue": "Resolve issue",
"resolveFailed": "Could not resolve this issue."
},
"bookingOverlap": {
"heading": "Resolve the booking overlap",
"description": "Block one of the two overlapping commitments. The other keeps its current status.",
"columns": {
"booking": "Booking",
"window": "Window",
"status": "Status",
"blockThis": "Block this one"
},
"blockLabel": "Block {{ref}}",
"note": "Note",
"resolving": "Resolving…",
"blockButton": "Block {{ref}}",
"blockButtonFallback": "Block booking",
"resolveFailed": "Could not resolve this overlap."
},
"vehicleStatusConflict": {
"heading": "Resolve the status conflict",
"description": "One authoritative rule recommends a corrected operational status for this vehicle.",
"currentStatus": "Current status",
"reviewRecommendation": "Review recommendation",
"loadingRecommendation": "Loading recommendation…",
"recommendationFailed": "Could not load a recommendation.",
"recommendedStatus": "Recommended status",
"whyHeading": "Why",
"evidenceHeading": "Facts",
"consequenceHeading": "Consequence",
"consequence": {
"statusWillChange": "The vehicle status will change to {{status}}.",
"issueWillBeRechecked": "This quality issue will be re-checked.",
"changeWillBeAudited": "The change will be recorded in the audit trail.",
"bookingsNotDeleted": "The bookings themselves are not deleted."
},
"changeStatusTo": "Change status to {{status}}",
"applying": "Applying…",
"applied": "Applied {{status}} — {{reason}}",
"applyFailed": "Could not apply a recommended status.",
"staleRecommendation": "The situation has changed since this recommendation was shown. Review it again before applying.",
"reviewAgain": "Review recommendation again",
"manualReview": {
"heading": "Manual review required",
"body": "The facts for this vehicle contradict each other. An Operations Manager needs to review this in person rather than apply an automatic status change.",
"hint": "Use 'Defer' or 'Reject' below, or investigate the vehicle and the related bookings manually."
},
"noConflict": {
"heading": "No change needed",
"body": "This vehicle's current status already matches the facts."
},
"evidence": {
"activeBookings": "Active booking(s): {{refs}}",
"overlappingBookings": "Overlapping bookings: {{pairs}}",
"serviceThresholdReached": "Odometer reading ({{odometer}} km) has reached the service threshold ({{threshold}} km).",
"openOverlapIssue": "Open booking-overlap issue: {{ref}}"
},
"reasonCodes": {
"vehicle.active_rental": "This vehicle has an active rental but is not recorded as rented. The status is corrected to reflect the actual situation.",
"vehicle.service_threshold_reached": "This vehicle has reached its service threshold. It is set to maintenance so it is not deployed before servicing takes place.",
"vehicle.booking_conflict": "This vehicle has two overlapping bookings. Blocking it prevents it from being considered available again before the booking conflict is resolved.",
"vehicle.rental_ended": "The rental for this vehicle has ended and there is no active booking left. It is set back to available.",
"vehicle.manual_review_required": "The facts for this vehicle contradict each other (for example, an active booking while the vehicle is in maintenance, or a service threshold reached alongside an active rental). This requires a human review rather than an automatic status change.",
"vehicle.no_conflict": "This vehicle's current status already matches the facts. No change is needed."
}
}
}
}
@@ -0,0 +1,82 @@
{
"progress": {
"capture": "Capture",
"review": "Review",
"result": "Result",
"ariaLabel": "Return registration progress"
},
"capture": {
"heading": "Register vehicle return",
"description": "Record the hand-back condition. The next step evaluates the exact operational consequences before anything is committed.",
"endOdometer": "End odometer (km)",
"fuelLevel": "Fuel level (%)",
"conditionLegend": "Vehicle condition",
"cleanlinessOk": "Cleanliness acceptable",
"damageReported": "Damage reported",
"technicalWarning": "Technical warning",
"notes": "Notes",
"evaluating": "Evaluating…",
"reviewReturn": "Review return"
},
"review": {
"heading": "Review return impact",
"description": "This is the server's authoritative evaluation of what committing will do — confirm before it updates fleet state and queues automation.",
"odometer": "Odometer",
"fuel": "Fuel",
"cleanliness": "Cleanliness",
"cleanlinessAccepted": "Accepted",
"cleanlinessFollowUp": "Follow-up needed",
"damage": "Damage",
"damageReported": "Reported",
"damageNone": "None reported",
"technicalWarning": "Technical warning",
"technicalWarningReported": "Reported",
"technicalWarningNone": "None reported",
"expectedState": "Expected fleet state:",
"odometerRegressionWarning": "Submitted odometer ({{submitted}} km) is below the canonical reading ({{canonical}} km). The canonical odometer will not change, and a data-quality issue will be opened.",
"nextBookingRisk": "Next booking {{ref}} starts {{when}} — may be affected by this return.",
"nextBookingLowRisk": "Next booking {{ref}} starts {{when}} — low risk.",
"reasonCodes": {
"returnBlockedDamageAndTechnical": "Both damage and a technical warning were reported on this return.",
"returnBlockedDamage": "Damage was reported on this return.",
"returnBlockedTechnicalWarning": "A technical warning was reported on this return.",
"returnServiceThresholdReached": "The odometer reading has reached the {{threshold_km}} km service threshold.",
"returnRoutedToCleaning": "No damage, technical warning or service threshold reached; the vehicle goes to cleaning."
},
"commitList": {
"inspection": "Create a return inspection",
"updateAtomic": "Update the booking and vehicle atomically",
"queueAutomation": "Queue automation after the local commit"
},
"editDetails": "Edit details",
"registering": "Registering…",
"confirmReturn": "Confirm return"
},
"result": {
"committedLocally": "Committed locally",
"heading": "Return registered",
"inspection": "Inspection",
"resultingStatus": "Resulting vehicle status",
"qualityIssue": "Quality issue",
"noneCreated": "None created",
"automationEvent": "Automation job",
"queuedForDelivery": "Queued for delivery ({{ref}}) — local commit succeeded; automation delivery is asynchronous and not yet confirmed.",
"nextBookingRisk": "Next booking risk",
"nextBookingRiskValue": "{{ref}} {{status}}",
"atRisk": "— may be affected",
"lowRisk": "— low risk",
"noUpcomingBooking": "No upcoming booking for this vehicle",
"odometerRegressionNotice": "The submitted odometer reading was below the vehicle's canonical odometer. It was recorded as-is; the canonical odometer was not changed, and a data-quality issue was opened for review.",
"viewVehicle": "View vehicle {{ref}}",
"viewAutomation": "View automation status",
"viewAudit": "View audit trail",
"viewTrace": "Trace the complete operation",
"continueDemo": "Continue the demo"
},
"scenario": {
"ariaLabel": "Demo scenario",
"title": "Demo scenario: odometer anomaly",
"body": "This vehicle currently reads {{odometer}} km. The form below is pre-filled with a return reading below that — a sign of a data-entry mistake or a mixed-up vehicle. Confirm the return to see how {{productName}} detects and handles this.",
"preparing": "Preparing scenario…"
}
}
@@ -0,0 +1,7 @@
{
"openRow": "Ouvrir : {{label}}",
"reducedMotion": "Mouvement réduit actif",
"closeDialog": "Fermer la boîte de dialogue",
"expandSection": "Déplier la section",
"collapseSection": "Replier la section"
}
+104
View File
@@ -0,0 +1,104 @@
{
"eyebrow": "Assurance / Historique immuable",
"title": "Piste d'audit",
"description": "Suivez les changements d'état importants, les acteurs et les références corrélées.",
"actionFilterLabel": "Action",
"actionFilterPlaceholder": "p. ex. demo_login",
"actorFilterLabel": "Intervenant",
"actorFilterPlaceholder": "Nom ou service",
"entityFilterLabel": "Dossier",
"entityFilterPlaceholder": "p. ex. MO-016",
"fromFilterLabel": "À partir du",
"toFilterLabel": "Jusqu’au",
"managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.",
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.",
"loading": "Chargement de la piste d'audit…",
"unavailable": "La piste d'audit est actuellement indisponible.",
"empty": "Aucun événement d'audit trouvé",
"emptyDetail": "Ajustez le filtre d'action.",
"relatedFilterActive": "Affichage uniquement des événements liés à cette action ({{count}} événements liés).",
"clearFilter": "Effacer ce filtre",
"count": "{{count}} événements",
"storedRendered": "Stocké en UTC · Affiché en heure de Bruxelles",
"columns": {
"when": "Quand",
"actor": "Auteur",
"action": "Action",
"entity": "Entité",
"change": "Changement",
"followUp": "Suite",
"details": "Détails"
},
"viewRelatedEvents": "Voir les événements liés",
"noChangeDetail": "Aucun détail de changement enregistré.",
"noFieldChange": "Aucun changement au niveau des champs détecté.",
"reference": "Référence",
"relatedEventsCount": "{{count}} événement lié",
"relatedEventsCount_other": "{{count}} événements liés",
"showTechnicalEvents": "Afficher {{count}} événement technique",
"showTechnicalEvents_other": "Afficher {{count}} événements techniques",
"hideTechnicalEvents": "Masquer les événements techniques",
"technicalDetails": "Détails techniques",
"fullReference": "Référence complète",
"correlationId": "ID de corrélation",
"diff": {
"setTo": "{{field}} : défini à {{value}}",
"was": "{{field}} : était {{value}}",
"changed": "{{field}} : {{before}} → {{after}}"
},
"actorTypes": {
"user": "Utilisateur",
"system": "Système",
"service": "Service"
},
"fields": {
"status": "Statut",
"operational_status": "Statut opérationnel",
"registration_number": "Plaque d'immatriculation",
"make": "Marque",
"model": "Modèle",
"location": "Emplacement",
"first_name": "Prénom",
"last_name": "Nom",
"email": "E-mail",
"phone": "Téléphone",
"postal_code": "Code postal",
"city": "Ville",
"booking_end_odometer_km": "Kilométrage final de la réservation",
"vehicle_odometer_km": "Kilométrage du véhicule",
"survivor": "Profil conservé",
"loser": "Profil fusionné"
},
"trace": {
"eyebrow": "Trace de bout en bout",
"title": "Du retour à l'automatisation",
"description": "Cette vue relie les événements partageant le même identifiant de corrélation en une opération vérifiable.",
"steps": {
"return": { "title": "Retour enregistré", "complete": "La réservation et l'inspection ont été traitées atomiquement.", "pending": "Pas encore traité." },
"vehicle": { "title": "État de flotte dérivé", "complete": "Le serveur a enregistré l'état résultant du véhicule.", "pending": "Aucun changement d'état trouvé." },
"outbox": { "title": "Automatisation planifiée", "complete": "La tâche outbox a été créée dans la même transaction.", "pending": "Aucune tâche outbox trouvée." },
"delivery": { "title": "Suivi externe", "complete": "n8n a confirmé le suivi.", "pending": "La livraison est asynchrone et n'est pas encore confirmée dans cette trace." }
}
},
"actions": {
"demo_login": "Connecté",
"demo_logout": "Déconnecté",
"demo_reset": "Données de démo réinitialisées",
"demo_data_seeded": "Données de démo chargées",
"return_registered": "Retour de véhicule enregistré",
"vehicle_status_changed": "Statut du véhicule modifié",
"data_quality_issue_resolved": "Problème de qualité résolu",
"data_quality_issue_deferred": "Problème de qualité reporté",
"data_quality_issue_rejected": "Problème de qualité rejeté",
"data_quality_fields_provided": "Champs manquants complétés",
"data_quality_odometer_corrected": "Kilométrage corrigé",
"data_quality_odometer_retained": "Kilométrage de référence conservé",
"data_quality_status_applied": "Statut recommandé appliqué",
"data_quality_booking_blocked": "Réservation bloquée",
"data_quality_scan_run": "Contrôle qualité exécuté",
"customer_merged": "Clients fusionnés",
"workflow_retry": "Automatisation retentée",
"knowledge_question_asked": "Question de connaissance posée",
"mcp_tool_request": "Outil MCP appelé"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"brandTagline": "Centre de contrôle",
"orgLine": "Organisation de démo : {{orgName}} (fictive)",
"headline1": "Chaque transfert.",
"headline2": "Une vue claire.",
"defaultDescription": "{{productName}} rassemble les données de véhicules, de réservations et d'exploitation, soutient les processus de location, détecte les problèmes de qualité des données et automatise les suites contrôlées.",
"footnote": "Démo synthétique · aucune donnée client ou véhicule réelle · réinitialisable à tout moment",
"accessEyebrow": "Accès démo",
"accessHeading": "Choisissez comment démarrer",
"accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.",
"startGuidedDemo": "Démarrer la démo guidée",
"guidedDemoChecking": "Préparation de la démo guidée…",
"guidedDemoManifestUnavailable": "Les informations de la démo guidée n’ont pas pu être chargées.",
"guidedDemoNotReady": "La démo guidée n’est pas prête à démarrer pour le moment.",
"guidedDemoRetry": "Recharger les informations de démo",
"startRecruiterTour": "Voir les points forts en 90 secondes",
"exploreAsOperationsManager": "Explorer en tant que Responsable des opérations",
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
"exploreAsRentalEmployee": "Explorer en tant que Collaborateur de location",
"exploreAsRentalEmployeeDetail": "Réservations, retours, flotte et procédures",
"safeByDesignTitle": "Conçu pour la sécurité",
"safeByDesignDetail": "Chaque action est enregistrée et peut être réinitialisée dans cette démo.",
"loginFailed": "La session de démo n'a pas pu démarrer. L'API est peut-être inaccessible.",
"roleOperationsManager": "Responsable des opérations",
"roleRentalEmployee": "Collaborateur de location",
"switchRole": "Changer de rôle",
"logout": "Déconnexion",
"emailLabel": "Adresse e-mail",
"passwordLabel": "Mot de passe",
"signIn": "Se connecter",
"passwordLoginFailed": "Connexion impossible. Vérifiez vos identifiants.",
"or": "ou",
"signInWithOrganization": "Se connecter avec {{provider}}"
}
@@ -0,0 +1,132 @@
{
"list": {
"eyebrow": "Exploitation / Planning",
"title": "Réservations",
"description": "Consultez les périodes de location actives et les engagements de véhicules à venir.",
"create": "Nouvelle réservation",
"searchLabel": "Rechercher",
"searchPlaceholder": "Réservation, client ou véhicule",
"statusLabel": "Statut",
"statusAll": "Tous les statuts",
"fromLabel": "À partir du",
"toLabel": "Jusqu’au",
"locationLabel": "Site",
"locationPlaceholder": "Site opérationnel exact",
"sortLabel": "Tri",
"sortOperational": "Priorité opérationnelle",
"sortAscending": "Date de début croissante",
"sortDescending": "Date de début décroissante",
"presetsLabel": "Filtres rapides",
"todayPreset": "Aujourd’hui",
"upcomingPreset": "Réservations à venir",
"clearFilters": "Effacer les filtres",
"loading": "Chargement du registre des réservations…",
"empty": "Aucune réservation trouvée",
"emptyDetail": "Ajustez le filtre de statut des réservations.",
"noMatch": "Aucune réservation correspondante",
"noMatchDetail": "Essayez un terme de recherche plus large.",
"unavailable": "La liste des réservations est actuellement indisponible.",
"count": "{{count}} réservations",
"pageOf": "Page {{page}} sur {{total}}",
"columns": {
"reference": "Référence",
"customer": "Client",
"vehicle": "Véhicule",
"window": "Période",
"status": "Statut"
},
"paginationLabel": "Pages de réservations",
"previous": "Précédent",
"next": "Suivant",
"rangeOf": "{{from}}–{{to}} sur {{total}}"
},
"create": {
"backLink": "Registre des réservations",
"eyebrow": "Réservations / Nouvelle réservation",
"title": "Créer une réservation",
"description": "Sélectionnez un client et un véhicule disponible pendant toute la période de location.",
"startsAt": "Début",
"endsAt": "Fin",
"customerSearch": "Rechercher un client",
"customerPlaceholder": "Nom, e-mail ou référence client",
"customer": "Client",
"searchFirst": "Recherchez d'abord un client",
"chooseCustomer": "Sélectionnez un client",
"vehicle": "Véhicule disponible",
"vehicleSearch": "Rechercher un véhicule",
"vehiclePlaceholder": "Référence, marque, modèle, plaque ou site",
"locationUnknown": "site inconnu",
"loadingVehicles": "Vérification des disponibilités…",
"chooseVehicle": "Sélectionnez un véhicule",
"noVehicles": "Aucun véhicule disponible pour cette période",
"invalidLocalTime": "Choisissez une heure Europe/Brussels valide. L’heure sautée lors du passage à l’heure d’été n’existe pas.",
"endAfterStart": "La fin de la réservation doit être postérieure à son début.",
"cancel": "Annuler",
"save": "Créer la réservation",
"saving": "Enregistrement…",
"failed": "La réservation n'a pas pu être créée. Vérifiez la période et les disponibilités."
},
"statuses": {
"reserved": "réservé",
"active": "actif",
"returned": "retourné",
"cancelled": "annulé",
"blocked": "bloqué"
},
"checkout": {
"title": "Remettre le véhicule",
"description": "Enregistrez l’état de départ. Toute anomalie bloque automatiquement la location pour suivi.",
"preparing": "Préparation de l’inspection de départ…",
"odometer": "Kilométrage de départ",
"fuel": "Niveau de carburant (%)",
"condition": "État au départ",
"clean": "Le véhicule est propre",
"damage": "Dommage constaté",
"warning": "Alerte technique",
"notes": "Notes",
"submit": "Enregistrer l’inspection et démarrer la location",
"saving": "Enregistrement du départ…",
"failed": "L’inspection de départ n’a pas pu être enregistrée.",
"activatedTitle": "Location démarrée",
"activatedDetail": "L’inspection {{inspection}} est enregistrée et le véhicule est loué.",
"blockedTitle": "Location bloquée",
"blockedDetail": "L’inspection {{inspection}} contient une anomalie. Le véhicule a été retiré du service en toute sécurité."
},
"detail": {
"backLink": "Registre des réservations",
"eyebrow": "Réservations / Fiche de location",
"notFound": "Cette réservation est introuvable.",
"loading": "Chargement de la fiche de réservation…",
"customer": "Client",
"vehicle": "Véhicule",
"starts": "Début",
"ends": "Fin",
"startOdometer": "Kilométrage de départ",
"endOdometer": "Kilométrage de retour",
"startOdometerPending": "Pas encore enregistré — trajet pas encore commencé",
"endOdometerPending": "Pas encore enregistré — pas encore clôturé",
"requirementsComplete": "Exigences complètes",
"requirementsAction": "Contrôler les exigences",
"requirementsActionDetail": "L’inspection de départ reste verrouillée jusqu’à la confirmation explicite du permis et des exigences de location.",
"requirementsConfirmation": "Preuve du contrôle",
"requirementsConfirmationPlaceholder": "Notez ce qui a été contrôlé",
"requirementsConfirm": "Confirmer les exigences",
"requirementsSaving": "Confirmation…",
"requirementsFailed": "Les exigences n’ont pas pu être confirmées.",
"rescheduleAction": "Replanifier la réservation",
"rescheduleDetail": "Modifiez la période de location. La disponibilité est revérifiée avant l’enregistrement.",
"rescheduleReason": "Motif de la modification",
"confirmReschedule": "Enregistrer la nouvelle période",
"rescheduling": "Enregistrement…",
"rescheduleFailed": "La réservation n’a pas pu être replanifiée.",
"invalidScheduleWindow": "Choisissez des heures Europe/Brussels valides et assurez-vous que la fin suit le début.",
"yes": "Oui",
"no": "Non",
"cancelAction": "Annuler la réservation",
"cancelReason": "Motif",
"cancelReasonPlaceholder": "Pourquoi cette réservation est-elle annulée ?",
"confirmCancel": "Confirmer l'annulation",
"cancelling": "Annulation…",
"cancelFailed": "La réservation n'a pas pu être annulée."
}
}
@@ -0,0 +1,49 @@
{
"orgName": "Northstar Mobility",
"brandTagline": "Centre de contrôle",
"actions": {
"save": "Enregistrer",
"cancel": "Annuler",
"confirm": "Confirmer",
"close": "Fermer",
"back": "Retour",
"next": "Suivant",
"retry": "Réessayer",
"reset": "Réinitialiser",
"yes": "Oui",
"no": "Non",
"viewDetails": "Voir les détails",
"technicalDetails": "Détails techniques"
},
"status": {
"loading": "Chargement…",
"saving": "Enregistrement…",
"error": "Une erreur est survenue",
"success": "Réussi",
"noResults": "Aucun résultat"
},
"pagination": {
"label": "Navigation entre les pages",
"previous": "Précédent",
"next": "Suivant",
"pageOf": "Page {{page}} sur {{total}}"
},
"states": {
"loadingDefault": "Chargement de l'espace de travail…",
"errorTitle": "Impossible de charger cet espace de travail."
},
"timezone": "Europe/Bruxelles",
"language": {
"label": "Langue",
"nl-BE": "Nederlands",
"en-GB": "English",
"fr-BE": "Français"
},
"footer": {
"productLine": "{{productName}} Demo",
"locale": "Europe/Bruxelles · Données de démonstration synthétiques"
},
"demo": {
"syntheticBadge": "Démo synthétique"
}
}
@@ -0,0 +1,85 @@
{
"eyebrow": "Exploitation / Aperçu en direct",
"greeting": {
"morning": "Bonjour",
"afternoon": "Bonjour",
"evening": "Bonsoir",
"night": "Bon retour"
},
"greetingBody": {
"morning": "Voici l'état de votre flotte pour aujourd'hui.",
"afternoon": "Voici l'aperçu actuel de votre flotte.",
"evening": "Voici l'aperçu de votre flotte pour ce soir.",
"night": "Voici le dernier aperçu de votre flotte."
},
"description": "Disponibilité, exceptions et transferts de l'activité en cours.",
"viewFleet": "Voir la flotte",
"demoStart": {
"title": "Essayer un scénario de démonstration",
"readyCount": "{{ready}} scénarios sur {{total}} prêts pour la démo.",
"readyCountFallback": "Cinq scénarios ciblés.",
"startGuide": "Démarrer le guide de démo",
"resumeGuide": "Poursuivre le guide de démo ({{current}}/{{total}})",
"viewScenarios": "Voir les scénarios"
},
"readiness": {
"title": "Disponibilité de la flotte",
"description": "En direct depuis l'état persistant des véhicules",
"openFleet": "Ouvrir la flotte",
"available": "Disponible",
"rented": "Loué",
"cleaning": "Nettoyage",
"maintenance": "Entretien",
"blocked": "Bloqué"
},
"attention": {
"title": "File d'attention",
"description": "{{openIssues}} problèmes de qualité ouverts · {{workflowExceptions}} exceptions de workflow",
"reviewQueue": "Examiner la file",
"remaining_one": "Encore un point d’attention",
"remaining_other": "Encore {{count}} points d’attention",
"remainingHint": "Ouvrir la file de travail complète",
"filterPlaceholder": "Filtrer les problèmes…",
"filterAriaLabel": "Rechercher dans la file d'attention",
"severityAll": "Toute gravité",
"severityHigh": "Critique",
"severityMedium": "Avertissement",
"severityLow": "Info",
"severityAriaLabel": "Gravité",
"tierNow": "À traiter maintenant",
"tierToday": "À suivre aujourd'hui",
"tierLater": "À contrôler plus tard",
"empty": "Aucun problème ne correspond à ce filtre.",
"openRecord": "Ouvrir {{title}}"
},
"movements": {
"title": "Mouvements du jour",
"description": "Départs et retours en Europe/Bruxelles",
"allBookings": "Toutes les réservations",
"empty": "Aucun mouvement prévu aujourd'hui.",
"departure": "départ",
"return": "retour",
"openBooking": "Ouvrir la réservation {{ref}}"
},
"integrationPulse": {
"title": "État des intégrations",
"description": "Preuves actuelles des services connectés",
"systemDetail": "Détail du système",
"n8nTitle": "Livraison n8n",
"n8nSummary": "{{succeeded}} réussies · {{failed}} échouées",
"n8nLatest": "Dernier événement {{ref}}",
"n8nNoEvidence": "Aucune preuve d'automatisation enregistrée",
"knowledgeTitle": "Assistant de connaissances",
"knowledgeSummary": "{{count}} procédures indexées",
"knowledgeIndexUnknown": "{{count}} sources de connaissances · taille d’index non vérifiable",
"knowledgeUnavailable": "Vérification de santé indisponible",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Enregistrement activé",
"mcpNotConnected": "Pas encore connecté"
},
"recent": {
"title": "Activité récente",
"description": "Derniers changements de workflow audités",
"empty": "Aucune activité d'automatisation enregistrée."
}
}
+270
View File
@@ -0,0 +1,270 @@
{
"badge": {
"trigger": "Démo synthétique",
"dialogLabel": "À propos de cet environnement de démo",
"close": "Fermer",
"orgIntro": "<strong>{{orgName}}</strong> est une organisation fictive. Tous les noms, véhicules et réservations sont synthétiques.",
"orgIntroFallback": "Tous les noms, véhicules et réservations de cet environnement sont synthétiques.",
"realWorkflows": "Les workflows, contrôles et l'automatisation sont réellement implémentés — seules les données sont inventées.",
"lastReset": "Dernière réinitialisation : <strong>{{when}}</strong> · cet environnement peut être réinitialisé à tout moment.",
"aboutLink": "À propos de cette démo",
"unknown": "inconnu"
},
"guide": {
"dialogLabel": "Démo guidée",
"trigger": "Guide de démo",
"kicker": "Démo guidée · étape {{current}} sur {{total}}",
"whatYouWillSee": "Ce que vous allez voir",
"whyItMatters": "Pourquoi c'est important",
"startAction": "Pour commencer",
"expectedOutcome": "Résultat attendu",
"goToStep": "Aller à cette étape",
"next": "Suivant",
"finish": "Terminer la démo",
"close": "Fermer",
"restart": "Préparer à nouveau la démo",
"restarting": "Réinitialisation…",
"restartFailed": "Impossible de réinitialiser la démo.",
"allStepsLabel": "Toutes les étapes",
"progressChip": "Guide de démo · étape {{current}} sur {{total}}",
"expand": "Déplier le guide de démo",
"collapse": "Replier le guide de démo",
"steps": {
"understand-state": {
"title": "1. Comprendre l'état opérationnel",
"whatYouWillSee": "Le tableau de bord affiche la disponibilité de la flotte, les points d'attention ouverts et les mouvements du jour.",
"whyItMatters": "Un Responsable des opérations commence chaque journée par cet aperçu pour décider où intervenir.",
"startAction": "Ouvrez le tableau de bord et consultez la file d'attention et la chronologie du jour.",
"expectedOutcome": "Vous voyez quelles réservations, véhicules ou problèmes de qualité nécessitent de l'attention."
},
"open-booking": {
"title": "2. Ouvrir la réservation nécessitant de l'attention",
"whatYouWillSee": "La réservation BK-DEMO-RETURN, active et à retourner aujourd'hui.",
"whyItMatters": "Les retours sont le moment où les kilométrages incorrects ou les dommages deviennent visibles pour la première fois.",
"startAction": "Ouvrez la réservation depuis le tableau de bord ou la liste des réservations.",
"expectedOutcome": "Vous voyez les détails de la réservation et le bouton pour traiter le retour."
},
"process-return": {
"title": "3. Traiter un retour avec une anomalie de kilométrage",
"whatYouWillSee": "Un formulaire de retour pré-rempli avec un relevé de kilométrage suspect, inférieur au dernier relevé connu.",
"whyItMatters": "Un kilométrage en baisse signale une erreur de saisie ou un véhicule confondu — cela doit être détecté avant la libération.",
"startAction": "Soumettez le retour avec la valeur suggérée et examinez l'aperçu du serveur avant de confirmer.",
"expectedOutcome": "Le retour est traité, le véhicule reçoit un statut approprié, et un problème de qualité des données est créé automatiquement."
},
"handle-quality-issue": {
"title": "4. Traiter le problème de qualité créé",
"whatYouWillSee": "Un nouveau problème « anomalie de kilométrage » en haut de la file de travail.",
"whyItMatters": "Chaque problème détecté a une étape de résolution unique et délimitée — rien n'est corrigé automatiquement en silence.",
"startAction": "Ouvrez l'aperçu qualité des données et choisissez le problème le plus récent.",
"expectedOutcome": "Vous voyez l'action recommandée, choisissez une résolution, et le problème se résout avec une piste d'audit."
},
"merge-duplicate": {
"title": "5. Examiner et traiter un client potentiellement en double",
"whatYouWillSee": "Deux profils clients avec le même e-mail et numéro de téléphone, comparés côte à côte.",
"whyItMatters": "Les clients en double divisent l'historique de réservation et compliquent la communication.",
"startAction": "Comparez les deux profils et choisissez celui qui doit survivre.",
"expectedOutcome": "Les profils sont fusionnés, les réservations sont transférées, et le profil perdant devient une fiche archivée."
},
"ask-knowledge": {
"title": "6. Poser une question à l'assistant de procédures",
"whatYouWillSee": "Une réponse avec citation issue de la base de connaissances de démo délimitée.",
"whyItMatters": "Le personnel a besoin d'une réponse rapide et étayée sur les procédures, sans deviner.",
"startAction": "Cliquez sur l'une des questions suggérées, dans la langue actuelle de l'interface.",
"expectedOutcome": "Vous voyez la réponse, la procédure utilisée et le texte source — ou un honnête « informations insuffisantes » si aucune n'existe."
},
"check-automation-audit": {
"title": "7. Vérifier l'automatisation et la piste d'audit",
"whatYouWillSee": "Le statut de livraison de la tâche d'automatisation de votre retour, et ses événements d'audit liés.",
"whyItMatters": "Chaque action importante doit être traçable : qui a fait quoi, quand, et ce qui a suivi.",
"startAction": "Ouvrez Intégrations pour voir le statut de livraison, et Piste d'audit pour l'historique complet.",
"expectedOutcome": "Vous voyez une livraison réussie (ou récupérable) et une piste d'audit lisible de vos actions."
},
"review-real-vs-simulated": {
"title": "8. Passer en revue ce qui est réel, simulé ou pas encore connecté",
"whatYouWillSee": "Un aperçu de ce qui est fonctionnellement implémenté dans cette démo, ce qui est synthétique, et quelles intégrations ne sont pas encore en direct.",
"whyItMatters": "Une démo n'est convaincante que si les visiteurs peuvent vérifier eux-mêmes ce qui fonctionne réellement et ce qui reste à venir.",
"startAction": "Lisez la page « À propos de cette démo ».",
"expectedOutcome": "Vous pouvez expliquer vous-même ce que {{productName}} est et n'est pas, sans explication verbale nécessaire."
}
}
},
"highlights": {
"eyebrow": "Points forts pour recruteurs",
"title": "Les choix les plus forts en 90 secondes",
"description": "Trois vues courtes montrent comment {{productName}} combine fiabilité opérationnelle, corrections contrôlées et IA fondée sur des sources.",
"duration": "± 30 s",
"open": "Voir dans la plateforme",
"proofLabel": "Preuve technique",
"return": { "title": "Un retour qui ne peut pas échouer silencieusement", "body": "Découvrez la validation d'impact côté serveur, la persistance atomique et une outbox qui découple l'automatisation externe du comptoir.", "proof": "Transaction · idempotence · outbox · dégradation" },
"quality": { "title": "Qualité des données sous contrôle humain", "body": "Comparez les profils en double, choisissez délibérément la fiche survivante et conservez l'historique des réservations et de l'audit.", "proof": "Règles explicables · aperçu · audit · rollback" },
"knowledge": { "title": "Une IA qui doit montrer ses sources", "body": "Posez une question de procédure et inspectez la réponse, les extraits sources et l'état honnête de preuves insuffisantes.", "proof": "RAGcore · citations · dégradation sûre" },
"storyTitle": "Voir les choix derrière le système ?",
"storyBody": "La page Ingénierie relie les flux produit à l'architecture, la fiabilité et l'état honnête des intégrations.",
"storyButton": "Ouvrir l'histoire d'ingénierie",
"fullTourButton": "Démarrer la démo complète"
},
"scenarios": {
"eyebrow": "Scénarios de démonstration",
"title": "Essayer un scénario de démonstration",
"description": "Cinq scénarios ciblés qui utilisent toujours les mêmes réservations, clients et véhicules fixes — toujours retrouvables après une réinitialisation.",
"loading": "Chargement des scénarios…",
"unavailable": "Les scénarios de démonstration n'ont pas pu être chargés. Veuillez réessayer.",
"ready": "Prêt pour la démo",
"notReady": "Non disponible",
"duration": "Durée",
"durationValue": "± {{minutes}} min",
"role": "Rôle",
"demonstrates": "Démontre :",
"requiresRole": "Nécessite le rôle : {{roles}}.",
"startScenario": "Démarrer le scénario",
"roleOr": "{{a}} ou {{b}}",
"roles": {
"operations_manager": "Responsable des opérations",
"rental_employee": "Collaborateur de location"
},
"items": {
"return-anomaly": {
"title": "Retour avec anomalie de kilométrage",
"problem": "Un véhicule revient avec un relevé de kilométrage inférieur au dernier relevé enregistré — un signe d'erreur de saisie ou de véhicule confondu.",
"demonstrates": "Le traitement du retour, la détection automatique de problèmes de qualité des données et la piste d'audit qui en résulte."
},
"duplicate-customer": {
"title": "Fusionner un possible client en double",
"problem": "Deux fiches client partagent la même adresse e-mail et le même numéro de téléphone — probablement la même personne, enregistrée deux fois.",
"demonstrates": "La fusion de clients avec conservation de l'historique des réservations et de la piste d'audit."
},
"booking-overlap": {
"title": "Résoudre des réservations qui se chevauchent",
"problem": "Un véhicule est réservé en double pour des périodes qui se chevauchent — une erreur de planification à résoudre avant le départ.",
"demonstrates": "La détection et la résolution contrôlée des conflits de planification."
},
"automation-retry": {
"title": "Relancer une tâche d'automatisation échouée",
"problem": "Un événement précédent n'a pas pu être livré à l'automatisation en raison d'une erreur de connexion simulée.",
"demonstrates": "Une livraison fiable avec des nouvelles tentatives limitées et un statut d'échec visible."
},
"knowledge-question": {
"title": "Poser une question de procédure",
"problem": "Un employé n'est pas sûr de la procédure applicable à une situation opérationnelle précise.",
"demonstrates": "Des réponses étayées par les sources, issues d'une base de connaissances de démo délimitée."
}
},
"blockedReasons": {
"bookingNotFound": "Réservation de démo BK-DEMO-RETURN introuvable. {{resetHint}}",
"bookingAlreadyProcessed": "Cette réservation a déjà été traitée depuis la dernière réinitialisation. {{resetHint}}",
"duplicateIssueNotFound": "Problème de démo DQ-DEMO-DUPLICATE introuvable. {{resetHint}}",
"issueAlreadyResolved": "Ce problème a déjà été résolu depuis la dernière réinitialisation. {{resetHint}}",
"overlapIssueNotFound": "Problème de démo DQ-DEMO-OVERLAP introuvable. {{resetHint}}",
"failedEventNotFound": "L'événement simulé en échec est introuvable. {{resetHint}}",
"eventAlreadyRecovered": "Cet événement a déjà été rétabli depuis la dernière réinitialisation. {{resetHint}}",
"knowledgeUnavailable": "La base de connaissances de démo est actuellement indisponible.",
"resetHint": "Réinitialisez les données de démo pour rendre ce scénario à nouveau disponible."
}
},
"about": {
"eyebrow": "À propos de cette démo",
"title": "Ce que {{productName}} est et n'est pas",
"description": "{{orgName}} est une organisation de location fictive qui rend cette démo concrète — pas une véritable entreprise.",
"loading": "Chargement des informations de démo…",
"unavailable": "Les informations de démonstration n'ont pas pu être chargées. Veuillez réessayer.",
"ctaTitle": "Choisissez le temps dont vous disposez",
"ctaBody": "Découvrez les trois moments d'ingénierie les plus forts en 90 secondes, ou suivez la démo opérationnelle complète.",
"ctaButton": "Démarrer la démo complète",
"quickTourButton": "Voir les points forts · 90 s",
"scenarioButton": "Ouvrir le laboratoire de scénarios",
"proofEyebrow": "Preuves d'ingénierie",
"proofTitle": "Contrôle, fiabilité et explicabilité",
"proofDescription": "Chaque choix technique soutient une action opérationnelle concrète.",
"proofRulesTitle": "Les règles restent dans le domaine",
"proofRulesBody": "FastAPI régit les retours, les statuts et les résolutions de qualité. n8n et les prompts ne prennent jamais de décisions faisant autorité.",
"proofReliabilityTitle": "Valider d'abord, orchestrer ensuite",
"proofReliabilityBody": "L'opération est validée atomiquement ; une outbox livre ensuite à n8n avec des tentatives limitées. Une panne ne bloque jamais le comptoir.",
"proofAiTitle": "L'IA doit montrer ses preuves",
"proofAiBody": "Les réponses RAGcore incluent des sources. Sans preuves suffisantes, l'assistant n'invente pas de réponse.",
"architectureFlowTitle": "De l'action utilisateur au suivi contrôlé",
"architectureFlowDescription": "Le système de référence reste séparé de l'orchestration externe et de l'IA.",
"architectureFrontend": "Interface React accessible",
"architectureApi": "Règles métier FastAPI",
"architectureDatabase": "PostgreSQL + piste d'audit",
"architectureOutbox": "Outbox fiable",
"architectureExternal": "Services externes : n8n, RAGcore et MCP Hub",
"architectureZonesLabel": "Limites de responsabilité de l'architecture",
"architectureZoneIntent": "Intention utilisateur",
"architectureZoneTransaction": "Transaction locale",
"architectureZoneEdge": "Périphérie système récupérable",
"architectureFlowLabel": "Flux système interactif de l'interface aux services externes",
"architectureCommitBoundary": "Après validation",
"architectureInteractionHint": "Sélectionnez une étape pour examiner sa garantie opérationnelle et ses points de contrôle.",
"architectureSelectedStep": "Étape {{current}} sur {{total}}",
"architectureBoundaryLabel": "Responsabilité",
"architectureEvidenceLabel": "Points de contrôle",
"architectureDetails": {
"Frontend": {
"summary": "Rend le rôle et l'intention explicites",
"body": "Des formulaires accessibles recueillent les données validées et montrent l'impact avant confirmation. L'interface ne décide jamais seule d'un statut métier.",
"proofOne": "Garde de route par rôle",
"proofTwo": "Aperçu avant confirmation"
},
"Api": {
"summary": "Valide chaque règle opérationnelle",
"body": "FastAPI applique les statuts, invariants et règles de résolution à la frontière de l'API, indépendamment de ce que le navigateur envoie.",
"proofOne": "Validation Pydantic",
"proofTwo": "Décision métier côté serveur"
},
"Database": {
"summary": "Valide les données et l'audit atomiquement",
"body": "PostgreSQL enregistre la modification opérationnelle et sa preuve d'audit dans la même transaction, empêchant tout changement d'état partiel.",
"proofOne": "UUID + référence publique",
"proofTwo": "UTC + piste d'audit continue"
},
"Outbox": {
"summary": "Enregistre durablement le suivi",
"body": "L'enregistrement outbox est validé avec l'opération. Un worker ne livre qu'ensuite, de façon idempotente et avec des tentatives limitées, vers l'orchestration.",
"proofOne": "Livraison après validation",
"proofTwo": "Idempotence + tentatives limitées"
},
"External": {
"summary": "Se dégrade sans perte locale",
"body": "n8n, RAGcore et MCP Hub disposent de délais d'attente et d'un état de santé visible. Une panne reste récupérable et l'IA ne répond jamais sans preuves suffisantes.",
"proofOne": "État de santé + délais",
"proofTwo": "Aucune réponse sans preuve"
}
},
"verificationTitle": "Construit pour être vérifiable",
"verificationBody": "Les règles métier, contrats d'API, modes dégradés et la démo complète sont testés automatiquement. Le dépôt contient les commandes d'acceptation exactes et le dossier de preuves.",
"problemTitle": "Le problème fictif",
"problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. {{productName}} montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.",
"scopeTitle": "Pour qui et avec quelle portée",
"scopeBody": "Cette démo s'adresse à quiconque veut voir comment {{productName}} traite les problèmes opérationnels d'un petit loueur : Responsables des opérations et Collaborateurs de location, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.",
"realTitle": "Ce qui fonctionne réellement",
"realBody": "Tout ce qui suit est du code fonctionnel, pas seulement une maquette : accès et sessions basés sur les rôles, gestion des véhicules et réservations, traitement des retours avec validation côté serveur, cinq règles de qualité des données avec chacune sa propre étape de résolution, une piste d'audit complète, une livraison automatisée vers n8n avec nouvelles tentatives limitées, un déploiement basé sur Docker, et une suite de tests automatisés (backend et Playwright de bout en bout).",
"syntheticTitle": "Ce qui est synthétique",
"syntheticBody": "L'organisation, tous les clients, véhicules, réservations, historiques d'entretien, procédures de la base de connaissances et les scénarios préconfigurés sont entièrement inventés. Aucune donnée ne fait référence à une personne, un véhicule ou une entreprise réels ; les adresses e-mail utilisent uniquement le domaine de test {{testDomain}}.",
"architectureTitle": "Architecture en bref",
"architectureBody": "Un frontend React/TypeScript communique avec un backend FastAPI (PostgreSQL via des migrations SQLAlchemy/Alembic) ; les règles métier importantes vivent dans le backend, pas dans n8n ni dans des prompts. Les retours et autres événements sont d'abord validés localement, puis livrés de façon asynchrone à n8n via une tâche d'automatisation, de sorte qu'une panne temporaire de l'automatisation ne bloque jamais une action opérationnelle.",
"securityTitle": "Sécurité et accès",
"securityBody": "L'accès passe par des cookies de session signés, HTTP-only, par rôle ; chaque rôle est lié à un ensemble de routes autorisées, appliqué côté serveur et reflété dans la navigation. Les changements d'état importants sont toujours vérifiés et enregistrés — jamais corrigés automatiquement en silence.",
"testingTitle": "Comment cela est testé",
"testingBody": "Une suite de tests backend automatisée couvre les règles métier et les contrats d'API ; une suite Playwright de bout en bout complète couvre les parcours utilisateurs, y compris cette expérience de démo elle-même en trois langues. Chaque modification est également validée sur une installation propre (base de données vide, reconstruite à partir des données initiales) avant le déploiement.",
"integrationsTitle": "Intégrations — étiquetées honnêtement",
"integrationsDescription": "Ce qui est opérationnel, ce qui est en mode démo, et ce qui n'est pas encore connecté.",
"resetTitle": "Restaurer l'environnement de démo",
"resetBodyManager": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Utilisez <strong>Réinitialiser les données de démo</strong> dans la barre latérale pour recommencer.",
"resetBodyEmployee": "L'environnement peut être réinitialisé à son état de départ à tout moment. Dernière réinitialisation : <strong>{{when}}</strong>. Un Responsable des opérations peut réinitialiser l'environnement de démo via la barre latérale.",
"limitationsTitle": "Limitations",
"limitationsBody": "Il s'agit d'une plateforme opérationnelle ciblée, pas d'un ERP complet. La comptabilité, les paiements, les réservations publiques et un CRM générique sont délibérément hors périmètre. Les intégrations externes sont évaluées séparément et honnêtement à partir de preuves récentes.",
"unknown": "inconnu"
},
"integrationSummary": {
"titles": {
"n8n": "Automatisation (n8n)",
"ragcore": "Assistant de connaissances (RAGcore)",
"mcp_hub": "ITWorx MCP Hub"
},
"n8nDetail": "{{succeeded}} réussi(s) · {{failed}} échoué(s) · {{pending}} en attente.",
"ragcoreDetail": "{{count}} procédures indexées dans {{collection}}.",
"ragcoreSourceDetail": "{{source_count}} procédures sources gérées sont disponibles ; RAGcore ne fournit pas de comptage d'index fiable.",
"mcpDetailOperational": "Des appels d'outils contrôlés en direct depuis le Hub sont confirmés dans la piste d'audit.",
"mcpDetailPrepared": "Quatre outils en lecture seule sont prêts contractuellement ; aucun appel récent du Hub n'a été enregistré."
}
}
+206
View File
@@ -0,0 +1,206 @@
{
"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."
},
"CUSTOMER_ALREADY_MERGED": {
"title": "Client déjà fusionné",
"explanation": "L'une de ces fiches client a déjà été fusionnée avec une autre fiche.",
"nextStep": "Rechargez le problème ; il est peut-être déjà résolu."
},
"ENTITY_NOT_FOUND": {
"title": "Fiche introuvable",
"explanation": "La fiche sous-jacente à cette action est introuvable."
},
"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."
},
"ISSUE_CHANGED": {
"title": "Les éléments de preuve ont changé",
"explanation": "De nouveaux éléments ont été enregistrés pendant la préparation de cette correction.",
"nextStep": "Actualisez le problème et réexaminez les éléments actuels avant de décider."
},
"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_EVENT_CORRELATION": {
"title": "Référence de corrélation non valide",
"explanation": "Le rappel d'automatisation ne contient pas de référence de corrélation valide."
},
"CALLBACK_EVENT_MISMATCH": {
"title": "Le rappel concerne un autre événement",
"explanation": "Le rappel d'automatisation fait référence à un événement différent de celui qui est mis à jour."
},
"CALLBACK_CORRELATION_MISMATCH": {
"title": "La trace du rappel ne correspond pas",
"explanation": "Le rappel d'automatisation ne correspond pas à la référence de corrélation enregistrée pour cet événement."
},
"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."
},
"UNKNOWN_WORKFLOW": {
"title": "Workflow d’automatisation inconnu",
"explanation": "Ce workflow ne fait pas partie des quatre automatisations gérées."
}
},
"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."
}
}
}
@@ -0,0 +1,91 @@
{
"list": {
"eyebrow": "Flotte / Registre",
"title": "Flotte de véhicules",
"description": "État opérationnel en direct, localisation et disponibilité pour l'entretien.",
"searchLabel": "Rechercher",
"searchPlaceholder": "Référence, marque ou localisation",
"statusLabel": "Statut",
"statusAll": "Tous les statuts",
"attentionOnly": "Attention uniquement",
"locationFilter": "Site",
"locationPlaceholder": "Site opérationnel exact",
"serviceDue": "Entretien en retard",
"serviceRemaining": "{{count}} km restants",
"attentionReasons": {
"blocked_status": "Statut bloqué",
"service_due": "Entretien en retard",
"data_quality": "Problème de qualité ouvert"
},
"loading": "Chargement du registre de la flotte…",
"empty": "Aucun véhicule trouvé",
"emptyDetail": "Ajustez les filtres de flotte actuels.",
"noMatch": "Aucun véhicule correspondant",
"noMatchDetail": "Essayez un terme de recherche plus large.",
"unavailable": "La liste des véhicules est actuellement indisponible.",
"count": "{{count}} véhicules",
"persisted": "Données de flotte persistantes",
"columns": {
"reference": "Référence",
"makeModel": "Marque / modèle",
"location": "Localisation",
"status": "Statut",
"odometer": "Kilométrage (km)",
"service": "Entretien",
"nextBooking": "Prochaine réservation",
"attention": "Attention"
},
"needsAttention": "Nécessite de l'attention"
},
"statuses": {
"available": "disponible",
"rented": "loué",
"cleaning": "nettoyage",
"maintenance": "entretien",
"blocked": "bloqué"
},
"detail": {
"backLink": "Registre de la flotte",
"eyebrow": "Flotte / Fiche véhicule",
"notFound": "Ce véhicule est introuvable.",
"loading": "Chargement de la fiche véhicule…",
"needsAttention": "Nécessite de l'attention",
"tabs": {
"overview": "Aperçu",
"bookings": "Réservations",
"inspections": "Inspections",
"maintenance": "Entretien",
"quality": "Qualité"
},
"tabsAriaLabel": "Sections du véhicule",
"overview": {
"registration": "Immatriculation",
"modelYear": "Année du modèle",
"location": "Localisation",
"odometer": "Kilométrage",
"nextService": "Prochain entretien",
"active": "Actif"
},
"yes": "Oui",
"no": "Non",
"noBookings": "Aucune réservation enregistrée.",
"noInspections": "Aucune inspection enregistrée.",
"noMaintenance": "Aucun historique d'entretien.",
"noQualityIssues": "Aucun problème de qualité enregistré.",
"fuel": "Carburant {{percent}}%",
"damage": "Dommage",
"technicalWarning": "Avertissement technique",
"inspectionTypes": {
"departure": "Inspection de départ",
"return": "Inspection de retour"
},
"maintenanceCategories": {
"periodic_service": "Entretien périodique",
"repair": "Réparation",
"inspection": "Contrôle technique",
"tyres": "Pneus",
"other": "Autre"
}
},
"maintenance": { "actionsTitle": "Actions d’entretien", "actionsDescription": "Enregistrez les travaux effectués ou remettez un véhicule sûr en service.", "add": "Enregistrer un entretien", "date": "Date", "odometer": "Kilométrage", "category": "Catégorie", "nextService": "Prochain entretien (km)", "summary": "Travaux effectués", "cancel": "Annuler", "save": "Enregistrer", "saving": "Enregistrement…", "failed": "L’entretien n’a pas pu être enregistré.", "releaseReason": "Motif de remise en service", "releasePlaceholder": "Quel contrôle confirme que le véhicule peut être remis en service ?", "release": "Remettre en service", "releaseFailed": "Le véhicule ne peut pas encore être remis en service." }
}
@@ -0,0 +1,125 @@
{
"eyebrow": "Assurance / Intégrations",
"title": "Contrôle des intégrations",
"description": "Surveillez la santé des livraisons, la dégradation contrôlée et les événements de workflow réessayables.",
"cards": {
"checking": "Récupération de l’état actuel des intégrations…",
"orchestrationKicker": "Orchestration",
"n8nTitle": "Livraison n8n",
"n8nSummary": "{{succeeded}} réussies · {{failed}} échouées · {{pending}} en attente · {{delivering}} en cours",
"n8nFallback": "Les événements de retour sont d'abord validés localement, puis livrés via la tâche d'automatisation.",
"knowledgeKicker": "Connaissances",
"knowledgeTitle": "Assistant de connaissances",
"knowledgeSummaryDemo": "Base de connaissances de démo · {{count}} procédures indexées dans {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procédures indexées dans {{collection}}.",
"knowledgeSummaryIndexUnknown": "RAGcore accessible · {{sourceCount}} documents sources dans cette langue · taille d’index non vérifiable pour {{collection}}.",
"knowledgeSummarySyncReported": "RAGcore accessible · {{sourceCount}} sources dans cette langue · dernier rapport : {{synced}} traités, {{failed}} échoués à {{when}} · taille d’index non vérifiable pour {{collection}}.",
"knowledgeUnavailable": "Preuves de santé actuellement indisponibles.",
"gatewayKicker": "Passerelle d'outils",
"mcpTitle": "MCP Hub",
"mcpEnabled": "L'enregistrement est activé pour ce déploiement.",
"mcpNotConnected": "Pas encore connecté — préparé pour de futurs appels d'outils contrôlés depuis le Hub.",
"mcpNoEvidence": "Enregistré, mais aucun appel d'outil n'a encore été consigné.",
"mcpEvidence": "ITWorx MCP Hub a exécuté {{tool}} en dernier · {{count}} appels contrôlés au total",
"mcpTechnicalIdentity": "Identité client technique",
"mcpHubReachable": "Hub accessible",
"mcpHubUnreachable": "Hub inaccessible",
"n8nDemoScenario": "Plus {{count}} scénario de démonstration préparé — une panne temporaire simulée, pas un problème d'intégration."
},
"statusLabels": {
"checking": "Vérification…",
"notConnected": "Non connecté",
"deliveryFailed": "Livraison échouée",
"retryAvailable": "Nouvelle tentative possible",
"operational": "Opérationnel",
"prepared": "Préparé",
"demoMode": "Mode démo",
"unavailable": "Indisponible"
},
"workflows": {
"title": "Workflows d'automatisation",
"description": "{{known}} workflows n8n canoniques sur {{expected}} disposent de preuves actuelles de fonctionnement.",
"loadingEvidence": "Récupération des preuves actuelles des workflows…",
"unavailable": "Les preuves des workflows sont actuellement indisponibles.",
"notBuilt": "Pas encore créé",
"noEvidence": "Aucune preuve pour l’instant",
"healthy": "Sain",
"stale": "Preuve obsolète",
"failed": "Dernière exécution échouée",
"executionId": "Exécution",
"columns": {
"name": "Workflow",
"status": "Statut",
"lastSeen": "Dernière preuve",
"technicalId": "Détails"
},
"names": {
"vehicleReturn": "Orchestration du retour de véhicule",
"scheduledScan": "Analyse planifiée de la qualité des données",
"ragcoreSync": "Synchronisation des procédures de connaissances",
"errorHandler": "Gestionnaire d'erreurs de workflow"
},
"expectations": {
"vehicleReturn": "Déclenché après l’enregistrement sécurisé d’un retour de véhicule.",
"scheduledScan": "Planifié ; s’exécute normalement chaque heure.",
"ragcoreSync": "Planifié ; s’exécute normalement chaque jour.",
"errorHandler": "S’exécute uniquement lorsqu’un workflow réel échoue."
},
"errorHandler": {
"summary": "{{count}} échec(s) d'automatisation enregistré(s) — le dernier provient de {{workflow}} à {{when}}.",
"summaryEmpty": "Aucun échec d'automatisation n'a été enregistré."
}
},
"ledger": {
"title": "Tâches d'automatisation",
"description": "Tentatives d'automatisation enregistrées avec les dernières preuves d'échec.",
"filterLabel": "Vue",
"filterNeedsAttention": "Nécessite de l'attention",
"filterRecent": "Récent",
"filterSucceeded": "Réussi",
"filterAll": "Tout",
"statusFilterLabel": "Statut",
"statusAll": "Tous les statuts",
"statusPending": "En attente",
"statusDelivering": "En cours",
"statusSucceeded": "Réussi",
"statusFailed": "Échoué",
"unavailable": "Les tâches d'automatisation sont actuellement indisponibles.",
"managerOnly": "L'automatisation est visible uniquement pour les Responsables des opérations.",
"loading": "Chargement des tâches d'automatisation…",
"empty": "Aucune tâche d'automatisation ne correspond à ce filtre.",
"count": "{{count}} événements de workflow",
"boundedRetries": "Nouvelles tentatives limitées",
"groupedSucceeded": "{{count}} retours de véhicules traités avec succès",
"showIndividually": "Afficher les tâches individuelles",
"hideIndividually": "Masquer les tâches individuelles",
"columns": {
"event": "Tâche",
"type": "Type",
"booking": "Réservation",
"status": "Statut",
"attempts": "Tentatives",
"lastError": "Dernière erreur",
"when": "Quand",
"action": "Action"
},
"retry": "Réessayer",
"retrying": "Nouvelle tentative…",
"retryFailed": "Impossible de réessayer cette livraison.",
"noAction": "—",
"eventTypes": {
"vehicle.returned.v1": "Retour de véhicule traité"
},
"errorCodes": {
"connectionError": "Le service d'automatisation était temporairement injoignable. Le retour a été enregistré en toute sécurité et peut être soumis à nouveau.",
"malformedPayload": "La tâche contenait des données incomplètes et n'a pas pu être livrée. Les données sous-jacentes restent enregistrées en toute sécurité.",
"remoteReportedFailure": "Le service d'automatisation a refusé la livraison. La tâche peut être soumise à nouveau.",
"staleLeaseRecovered": "Cette tâche a été récupérée après qu'une tentative de livraison précédente s'est arrêtée sans résultat.",
"unknownError": "Une erreur inattendue s'est produite lors de la livraison de cette tâche.",
"demoScenarioTimeout": "Délai d'attente simulé vers le service de workflow. Cette panne fait partie du scénario de démonstration préparé, ce n'est pas un incident réel."
},
"demoScenarioBadge": "Scénario de démonstration préparé",
"demoScenarioExplanation": "Panne temporaire simulée, placée volontairement dans les données de démonstration pour montrer la relance et l'audit. Elle n'affecte pas la santé de l'automatisation.",
"retryDemoScenario": "Relancer le scénario de démonstration"
}
}
@@ -0,0 +1,73 @@
{
"eyebrow": "Assurance / Connaissances étayées",
"title": "Connaissance des procédures",
"description": "Posez des questions opérationnelles. Les réponses ne s'affichent que lorsque {{provider}} renvoie suffisamment de preuves citées.",
"providerDemo": "la base de connaissances de démo",
"providerRagcore": "RAGcore",
"providerChecking": "la source de connaissances",
"providerUnavailable": "le service de connaissances",
"statusChecking": "Vérification de la source…",
"statusCheckingDetail": "Récupération de l’état actuel du fournisseur.",
"statusAvailable": "Disponible",
"statusUnavailable": "Indisponible",
"proceduresIndexed": "{{count}} procédures indexées",
"proceduresIndexUnknown": "La taille de l'index n'est pas disponible via cette connexion",
"proceduresAvailable": "{{count}} procédures sources gérées disponibles",
"technicalDetails": "Détails techniques du fournisseur",
"progress": { "retrieving": "Sélection des sources", "checking": "Vérification des preuves", "composing": "Construction de la réponse" },
"sourceTitles": {
"damage-procedure": "Dommages au retour du véhicule",
"vehicle-availability-procedure": "Rétablir la disponibilité du véhicule",
"vehicle-return-procedure": "Enregistrer un retour de véhicule",
"odometer-anomaly-procedure": "Examiner une anomalie kilométrique",
"checkout-procedure": "Contrôles obligatoires au départ"
},
"statistics": {
"scope": "Portée linguistique : les chiffres d’index et de sources concernent la langue actuelle ; le rapport n8n couvre les trois langues.",
"verifiedIndexed": "correspondance exacte et publication active dans cette langue",
"sourceDocuments": "documents sources dans cette langue",
"reportedSynced": "synchronisation signalée par n8n",
"reportedFailed": "échecs de synchronisation signalés",
"lastSync": "Dernier rapport de synchronisation : {{when}}. L’état de l’index est contrôlé indépendamment via l’identité exacte et la version RAGcore active de chaque source gérée.",
"noSyncReport": "Aucun rapport de synchronisation reçu. Les preuves d’indexation sont contrôlées séparément via les versions RAGcore actives."
},
"providerNote": "Cette démo répond à partir d'un petit ensemble fixe de procédures indexées — pas d'une connexion RAGcore en direct. Un backend RAGcore en direct reprendra plus tard la même interface sans changer le fonctionnement de cette page.",
"askHeading": "Poser une question de procédure",
"askSubheading": "Recherche → vérification des preuves → réponse étayée",
"questionLabel": "Question",
"questionPlaceholder": "p. ex. Que dois-je faire quand un véhicule revient endommagé ?",
"ask": "Demander",
"asking": "Question en cours…",
"askFailed": "Impossible de joindre le service de connaissances.",
"feedbackFailed": "Impossible d’enregistrer le retour.",
"suggestedLabel": "Essayez :",
"suggestedQuestions": [
"Que dois-je faire quand un véhicule revient avec des dommages ?",
"Comment enregistrer le retour d'un véhicule ?",
"Quand un véhicule peut-il redevenir disponible ?",
"Qui examine un relevé de kilométrage inhabituel ?",
"Quels contrôles sont requis avant le départ ?"
],
"emptyTitle": "Des preuves avant les réponses",
"emptyDescription": "Posez des questions sur les retours, les dommages, les inspections ou une autre procédure indexée. {{productName}} n'invente jamais de réponse en l'absence de preuves.",
"retrievalFlow": {
"question": "Question",
"sources": "Sources",
"answer": "Réponse"
},
"questionLabelExchange": "Question",
"evidenceStates": {
"grounded": "Étayé par des procédures citées",
"insufficient": "Preuves insuffisantes",
"unavailable": "Service de connaissances indisponible"
},
"unavailableBody": "Le service de connaissances est actuellement inaccessible. Les fonctions opérationnelles ne sont pas affectées — réessayez plus tard.",
"sourceVersion": "v{{version}}",
"answeredIn": "Réponse en {{seconds}} s",
"sourceTechnicalVersion": "Version technique de la source disponible dans l’audit",
"feedbackLabel": "Avis sur la réponse",
"feedbackPrompt": "Cette réponse était-elle utile ?",
"feedbackThanks": "Avis enregistré",
"feedbackHelpful": "Oui",
"feedbackNotHelpful": "Non"
}
@@ -0,0 +1,53 @@
{
"skipToContent": "Aller au contenu principal",
"groups": {
"operate": "Exploiter",
"assure": "Assurer"
},
"items": {
"overview": "Aperçu",
"fleet": "Flotte",
"bookings": "Réservations",
"quality": "Qualité des données",
"knowledge": "Connaissances",
"integrations": "Intégrations",
"audit": "Piste d'audit",
"users": "Utilisateurs",
"privacy": "Confidentialité",
"engineering": "Ingénierie"
},
"primaryNavLabel": "Navigation principale",
"mobileNavLabel": "Navigation mobile",
"more": "Plus",
"openNavigation": "Ouvrir la navigation",
"closeNavigation": "Fermer la navigation",
"sidebarEnvironment": "Environnement de démo",
"sidebarEnvironmentDetail": "Données synthétiques uniquement",
"resetDemoData": "Réinitialiser les données de démo",
"resetConfirmTitle": "Confirmer la réinitialisation",
"resetConfirmBody": "Toutes les modifications synthétiques seront annulées et les données de démo déterministes restaurées. Vous serez déconnecté.",
"resetting": "Réinitialisation…",
"resetConfirmYes": "Oui, réinitialiser",
"resetCancel": "Annuler",
"resetFailed": "Impossible de réinitialiser les données de démo.",
"searchLabel": "Rechercher dans {{productName}}",
"searchPlaceholder": "Rechercher flotte, réservation ou section…",
"searchShortcutHint": "Ctrl K",
"searchSearching": "Recherche…",
"searchUnavailable": "La recherche est momentanément indisponible.",
"searchNoResults": "Aucun résultat pour « {{query}} ».",
"searchVehicleSummary": "{{make}} {{model}} · {{location}}",
"searchSections": {
"overview": "Tableau de bord opérationnel",
"fleet": "Registre de la flotte",
"bookings": "Réservations de location",
"quality": "Atelier qualité",
"knowledge": "Assistant de procédures",
"integrations": "Statut d'automatisation et d'intégration",
"audit": "Historique d’audit",
"privacy": "Gestion de la confidentialité"
},
"switchRole": "Changer de rôle",
"switchRoleTitle": "Changer de rôle de démo",
"languageSwitcherLabel": "Changer de langue"
}
@@ -0,0 +1,4 @@
{
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "managerOnly": "L’administration des utilisateurs est réservée aux Responsables des opérations.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "edit": "Modifier", "editTitle": "Modifier l’utilisateur {{ref}}", "newPassword": "Nouveau mot de passe", "passwordUnchanged": "Laisser vide pour ne pas modifier", "saveChanges": "Enregistrer les modifications", "cancel": "Annuler", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
"roles": { "operations_manager": "Responsable des opérations", "rental_employee": "Employé de location" }
}
@@ -0,0 +1,19 @@
{
"eyebrow": "Surveiller / Gouvernance",
"title": "Gestion de la confidentialité",
"description": "Exportez les données personnelles, contrôlez la conservation et anonymisez avec une piste d’audit complète.",
"managerOnly": "Ces fonctions de gouvernance sont réservées aux responsables des opérations.",
"loading": "Chargement de la politique…",
"error": "L’action n’a pas pu être exécutée. Vérifiez la référence et la durée de conservation.",
"policyTitle": "Politique de conservation",
"totalCustomers": "Clients",
"eligibleCustomers": "Éligibles",
"anonymizedCustomers": "Anonymisés",
"bookingRetention": "Conservation minimale des réservations",
"auditRetention": "Conservation de l’audit",
"days_one": "{{count}} jour",
"days_other": "{{count}} jours",
"customerRef": "Référence client",
"exports": {"title":"Exports de données","detail":"Téléchargez un dossier client JSON ou un export d’audit CSV limité. Chaque export est journalisé.","customer":"Télécharger le dossier","audit":"Télécharger l’audit CSV"},
"anonymize": {"title":"Anonymiser un client","detail":"Irréversible. Les réservations actives ou de moins de {{days}} jours bloquent l’action.","reason":"Motif justifié","confirmation":"Saisissez {{ref}} pour confirmer","submit":"Anonymiser définitivement","working":"Anonymisation…","anonymized":"{{ref}} a été anonymisé.","already_anonymized":"{{ref}} était déjà anonymisé."}
}
@@ -0,0 +1,265 @@
{
"list": {
"eyebrow": "Assurance / Atelier",
"title": "Qualité des données",
"description": "Résolvez les exceptions étayées par des preuves avant qu'elles ne perturbent l'exploitation.",
"runScan": "Lancer le contrôle qualité",
"confirmScanTitle": "Confirmer le contrôle qualité",
"confirmScanBody": "Lancer maintenant le contrôle déterministe sur les cinq types de règles ?",
"scanning": "Analyse en cours…",
"confirmScanYes": "Oui, lancer",
"cancel": "Annuler",
"scanComplete": "Contrôle terminé : {{summary}}",
"scanNoNew": "aucun nouveau problème trouvé (les problèmes ouverts existants ne sont pas recréés).",
"scanFailed": "Impossible d'exécuter le contrôle qualité.",
"statusLabel": "Statut",
"statusAll": "Tous les statuts",
"statusOpen": "Ouvert",
"statusDeferred": "Reporté",
"statusResolved": "Résolu",
"statusRejected": "Rejeté",
"ruleTypeLabel": "Type de règle",
"ruleTypeAll": "Tous les types de règles",
"severityLabel": "Gravité",
"severityAll": "Tous les niveaux de gravité",
"assigneeLabel": "Attribué à",
"assigneeAll": "Tous les membres",
"unassigned": "Non attribué",
"overdueOnly": "En retard uniquement",
"bulkTitle": "Mettre à jour le travail sélectionné",
"selected": "{{count}} sélectionné(s)",
"assignTo": "Attribuer à",
"dueAt": "Échéance",
"invalidDueAt": "Choisissez une date et une heure Europe/Brussels valides.",
"bulkApply": "Mettre à jour la file",
"bulkSaving": "Mise à jour…",
"bulkFailed": "La file de travail n’a pas pu être mise à jour.",
"clearSelection": "Effacer la sélection",
"selectIssue": "Sélectionner le problème {{ref}}",
"selectVisible": "Sélectionner les problèmes visibles",
"deselectVisible": "Effacer la sélection visible",
"openIssue": "Ouvrir le problème {{ref}}",
"filtersToggle": "Filtres · {{count}} actif(s)",
"activeFilters": "Filtres actifs",
"demoScenariosOnly": "Scénarios de démo uniquement",
"loading": "Chargement de l'atelier qualité…",
"queueClear": "La file est vide",
"noIssuesMatch": "Aucun problème ne correspond aux filtres actuels.",
"noDemoIssuesMatch": "Aucun problème de scénario de démo ne correspond",
"noDemoIssuesMatchDetail": "Décochez « Scénarios de démo uniquement » pour voir la file complète.",
"unavailable": "Les problèmes de qualité des données sont actuellement indisponibles.",
"count": "{{count}} problèmes",
"evidenceBacked": "Détection étayée par des preuves",
"columns": {
"reference": "Référence",
"select": "Sélectionner",
"rule": "Règle",
"entity": "Entité",
"severity": "Gravité",
"status": "Statut",
"assignee": "Responsable",
"due": "Échéance"
}
},
"ruleTypes": {
"possible_duplicate_customer": "Client peut-être en double",
"missing_required_field": "Champ obligatoire manquant",
"odometer_regression": "Anomalie de kilométrage",
"booking_overlap": "Chevauchement de réservations",
"vehicle_status_conflict": "Conflit de statut du véhicule"
},
"severities": {
"high": "Critique",
"medium": "Avertissement",
"low": "Info"
},
"detail": {
"backLink": "Atelier qualité",
"eyebrow": "Qualité / {{rule}}",
"title": "Examinez les preuves enregistrées et consignez une résolution auditée.",
"notFound": "Ce problème est introuvable.",
"loading": "Chargement des preuves du problème…",
"managerOnly": "L'atelier qualité est visible uniquement pour les Responsables des opérations.",
"managerOnlyDetail": "Les preuves et résolutions de qualité des données sont visibles uniquement pour les Responsables des opérations.",
"summary": {
"rule": "Règle",
"entity": "Entité",
"evidenceSummary": "Résumé des preuves"
},
"resolved": {
"title": "Problème {{ref}} résolu",
"body": "La modification a été appliquée et enregistrée dans la piste d'audit.",
"viewAudit": "Voir la piste d'audit",
"viewVehicle": "Voir le véhicule",
"continueDemo": "Poursuivre la démo"
},
"deferOrReject": {
"heading": "Reporter ou rejeter",
"description": "Reportez pour un examen ultérieur, ou rejetez si ce n'est pas un véritable problème.",
"defer": "Reporter",
"reject": "Rejeter",
"deferFailed": "Impossible de reporter ce problème.",
"rejectFailed": "Impossible de rejeter ce problème."
},
"explainer": {
"whatIsWrong": "Ce qui ne va pas",
"whyItMatters": "Pourquoi c'est important",
"possible_duplicate_customer": {
"whatIsWrong": "Deux profils clients partagent des données d'identification (e-mail, téléphone ou un nom très similaire) suffisamment fortement pour qu'il s'agisse probablement de la même personne, enregistrée deux fois.",
"whyItMatters": "Les clients en double répartissent l'historique de réservation entre deux fiches, risquent une double facturation et compliquent les échanges avec le support."
},
"missing_required_field": {
"whatIsWrong": "Cette fiche manque d'informations requises pour un fonctionnement normal (par exemple, un client sans e-mail ni numéro de téléphone enregistré).",
"whyItMatters": "Sans ces données, l'entreprise ne peut pas contacter le client, ni identifier de façon fiable le véhicule pour les contrôles de conformité et de remise."
},
"odometer_regression": {
"whatIsWrong": "Un relevé de kilométrage soumis est inférieur au dernier relevé connu (de référence) du véhicule.",
"whyItMatters": "Un kilométrage en baisse signifie généralement une erreur de saisie ou que les relevés ont été enregistrés pour le mauvais véhicule. Laisser passer cela silencieusement corromprait la planification de l'entretien et l'historique kilométrique à la revente."
},
"booking_overlap": {
"whatIsWrong": "Le même véhicule est engagé sur deux réservations dont les périodes se chevauchent.",
"whyItMatters": "Une seule de ces réservations peut réellement être honorée. Non résolu, un client arriverait pour constater que son véhicule est déjà chez quelqu'un d'autre."
},
"vehicle_status_conflict": {
"whatIsWrong": "Le statut opérationnel enregistré de ce véhicule ne correspond pas à ce que son propre historique de réservations et d'inspections implique.",
"whyItMatters": "Un statut incorrect peut faire apparaître un véhicule indisponible comme réservable, ou masquer un véhicule disponible de la flotte."
}
},
"evidence": {
"duplicate.exact_email": "Adresse e-mail identique",
"duplicate.exact_phone": "Numéro de téléphone identique",
"duplicate.same_postal_code": "Même code postal",
"duplicateSimilarName": "Nom fortement similaire (score {{score}})",
"missingField": "Champ manquant : {{field}}",
"overlapReservedBookings": "Réservations qui se chevauchent : {{refs}}",
"odometerRegression": "La réservation {{laterRef}} a enregistré {{laterKm}} km, en dessous des {{earlierKm}} km enregistrés par la réservation antérieure {{earlierRef}}.",
"upcomingBookingMissingInspection": "La réservation {{bookingRef}} commence bientôt mais l'inspection opérationnelle requise est encore manquante."
},
"duplicateCustomer": {
"heading": "Comparer et fusionner",
"description": "Choisissez le client de référence et examinez chaque champ divergent.",
"keepAsSurvivor": "Conserver comme fiche principale",
"differs": "Diffère",
"match": "Identique",
"summaryCounts": "{{matchCount}} champs identiques · {{conflictCount}} champs en conflit",
"showMatchingFields": "Afficher aussi les champs identiques",
"hideMatchingFields": "Masquer les champs identiques",
"previewTitle": "Aperçu de la fiche fusionnée ({{ref}})",
"mergePreview": "{{loser}} sera archivé en toute sécurité et lié à {{survivor}} ; son historique de réservations sera transféré.",
"mergeInto": "Fusionner avec {{ref}}",
"confirmMergeTitle": "Confirmer la fusion",
"confirmMergeBody": "Fusionner {{loser}} avec {{survivor}} ? Cette action est irréversible.",
"merging": "Fusion en cours…",
"confirmMergeYes": "Oui, fusionner",
"mergeFailed": "Impossible de fusionner ces clients.",
"bothMissing": "Les deux clients de cette comparaison n'ont pas pu être chargés.",
"fieldColumn": "Champ",
"fields": {
"first_name": "Prénom",
"last_name": "Nom",
"email": "E-mail",
"phone": "Téléphone",
"postal_code": "Code postal",
"city": "Ville"
}
},
"missingField": {
"heading": "Compléter les champs manquants",
"description": "Complétez la fiche pour {{ref}}. Le problème se résout automatiquement dès qu'aucune donnée requise ne manque.",
"atLeastOne": "Au moins un e-mail ou un numéro de téléphone est requis.",
"saveAndRecheck": "Enregistrer et revérifier",
"saving": "Enregistrement…",
"saveFailed": "Impossible d'enregistrer ces champs.",
"fields": {
"first_name": "Prénom",
"last_name": "Nom",
"email": "E-mail",
"phone": "Téléphone",
"registration_number": "Numéro d'immatriculation",
"make": "Marque",
"model": "Modèle",
"location": "Localisation"
}
},
"odometerRegression": {
"heading": "Résoudre l'anomalie de kilométrage",
"description": "Le kilométrage de référence n'est jamais abaissé automatiquement — choisissez comment le régulariser.",
"canonicalOdometer": "Kilométrage de référence",
"decisionLegend": "Décision",
"retainCanonical": "Conserver le relevé de référence",
"retainCanonicalDetail": "Considérer le relevé soumis comme erroné ; rien ne change sur la fiche du véhicule.",
"correctReading": "Corriger le relevé",
"correctReadingDetail": "Mettre à jour à la fois la réservation et le kilométrage de référence avec la valeur correcte.",
"noBookingAttached": "Aucune réservation liée à ce problème, seule l'option « conserver la référence » est donc disponible.",
"booking": "Réservation",
"correctedOdometer": "Kilométrage corrigé (km)",
"note": "Note",
"resolving": "Résolution en cours…",
"resolveIssue": "Résoudre le problème",
"resolveFailed": "Impossible de résoudre ce problème."
},
"bookingOverlap": {
"heading": "Résoudre le chevauchement de réservations",
"description": "Bloquez l'un des deux engagements qui se chevauchent. L'autre conserve son statut actuel.",
"columns": {
"booking": "Réservation",
"window": "Période",
"status": "Statut",
"blockThis": "Bloquer celle-ci"
},
"blockLabel": "Bloquer {{ref}}",
"note": "Note",
"resolving": "Résolution en cours…",
"blockButton": "Bloquer {{ref}}",
"blockButtonFallback": "Bloquer la réservation",
"resolveFailed": "Impossible de résoudre ce chevauchement."
},
"vehicleStatusConflict": {
"heading": "Résoudre le conflit de statut",
"description": "Une règle faisant autorité recommande un statut opérationnel corrigé pour ce véhicule.",
"currentStatus": "Statut actuel",
"reviewRecommendation": "Voir la recommandation",
"loadingRecommendation": "Chargement de la recommandation…",
"recommendationFailed": "Impossible de charger une recommandation.",
"recommendedStatus": "Statut recommandé",
"whyHeading": "Pourquoi",
"evidenceHeading": "Faits",
"consequenceHeading": "Conséquence",
"consequence": {
"statusWillChange": "Le statut du véhicule sera changé en {{status}}.",
"issueWillBeRechecked": "Ce problème de qualité sera à nouveau contrôlé.",
"changeWillBeAudited": "Le changement sera enregistré dans la piste d'audit.",
"bookingsNotDeleted": "Les réservations elles-mêmes ne sont pas supprimées."
},
"changeStatusTo": "Changer le statut vers {{status}}",
"applying": "Application en cours…",
"applied": "Appliqué {{status}} — {{reason}}",
"applyFailed": "Impossible d'appliquer un statut recommandé.",
"staleRecommendation": "La situation a changé depuis l'affichage de cette recommandation. Consultez-la à nouveau avant de l'appliquer.",
"reviewAgain": "Revoir la recommandation",
"manualReview": {
"heading": "Évaluation manuelle requise",
"body": "Les faits concernant ce véhicule se contredisent. Un Responsable des opérations doit évaluer la situation en personne plutôt que d'appliquer un changement de statut automatique.",
"hint": "Utilisez « Reporter » ou « Rejeter » ci-dessous, ou examinez manuellement le véhicule et les réservations concernées."
},
"noConflict": {
"heading": "Aucun changement nécessaire",
"body": "Le statut actuel de ce véhicule correspond déjà aux faits."
},
"evidence": {
"activeBookings": "Réservation(s) active(s) : {{refs}}",
"overlappingBookings": "Réservations qui se chevauchent : {{pairs}}",
"serviceThresholdReached": "Le kilométrage ({{odometer}} km) a atteint le seuil d'entretien ({{threshold}} km).",
"openOverlapIssue": "Problème de chevauchement de réservation ouvert : {{ref}}"
},
"reasonCodes": {
"vehicle.active_rental": "Ce véhicule a une location active mais n'est pas enregistré comme loué. Le statut est corrigé pour refléter la situation réelle.",
"vehicle.service_threshold_reached": "Ce véhicule a atteint son seuil d'entretien. Il est placé en entretien afin qu'il ne soit pas utilisé avant que la révision ait eu lieu.",
"vehicle.booking_conflict": "Ce véhicule a deux réservations qui se chevauchent. Le bloquer évite qu'il soit à nouveau considéré comme disponible avant que le conflit de réservation ne soit résolu.",
"vehicle.rental_ended": "La location de ce véhicule est terminée et il n'y a plus de réservation active. Il est remis en disponible.",
"vehicle.manual_review_required": "Les faits concernant ce véhicule se contredisent (par exemple, une réservation active alors que le véhicule est en entretien, ou un seuil d'entretien atteint en même temps qu'une location active). Cela nécessite une évaluation humaine plutôt qu'un changement de statut automatique.",
"vehicle.no_conflict": "Le statut actuel de ce véhicule correspond déjà aux faits. Aucun changement n'est nécessaire."
}
}
}
}
@@ -0,0 +1,82 @@
{
"progress": {
"capture": "Saisie",
"review": "Vérification",
"result": "Résultat",
"ariaLabel": "Progression de l'enregistrement du retour"
},
"capture": {
"heading": "Enregistrer le retour du véhicule",
"description": "Enregistrez l'état à la restitution. L'étape suivante évalue les conséquences opérationnelles exactes avant toute validation.",
"endOdometer": "Kilométrage de retour (km)",
"fuelLevel": "Niveau de carburant (%)",
"conditionLegend": "État du véhicule",
"cleanlinessOk": "Propreté acceptable",
"damageReported": "Dommage signalé",
"technicalWarning": "Avertissement technique",
"notes": "Notes",
"evaluating": "Évaluation…",
"reviewReturn": "Vérifier le retour"
},
"review": {
"heading": "Vérifier l'impact du retour",
"description": "Ceci est l'évaluation faisant autorité du serveur sur ce que la validation entraînera — confirmez avant la mise à jour de l'état de la flotte et le déclenchement de l'automatisation.",
"odometer": "Kilométrage",
"fuel": "Carburant",
"cleanliness": "Propreté",
"cleanlinessAccepted": "Acceptée",
"cleanlinessFollowUp": "Suivi nécessaire",
"damage": "Dommage",
"damageReported": "Signalé",
"damageNone": "Aucun signalé",
"technicalWarning": "Avertissement technique",
"technicalWarningReported": "Signalé",
"technicalWarningNone": "Aucun signalé",
"expectedState": "État de flotte attendu :",
"odometerRegressionWarning": "Le kilométrage saisi ({{submitted}} km) est inférieur au relevé de référence ({{canonical}} km). Le kilométrage de référence ne changera pas et un problème de qualité des données sera ouvert.",
"nextBookingRisk": "La prochaine réservation {{ref}} débute {{when}} — peut être affectée par ce retour.",
"nextBookingLowRisk": "La prochaine réservation {{ref}} débute {{when}} — risque faible.",
"reasonCodes": {
"returnBlockedDamageAndTechnical": "Des dégâts et une alerte technique ont tous deux été signalés lors de ce retour.",
"returnBlockedDamage": "Des dégâts ont été signalés lors de ce retour.",
"returnBlockedTechnicalWarning": "Une alerte technique a été signalée lors de ce retour.",
"returnServiceThresholdReached": "Le kilométrage a atteint le seuil d'entretien de {{threshold_km}} km.",
"returnRoutedToCleaning": "Aucun dégât, alerte technique ou seuil d'entretien atteint ; le véhicule part en nettoyage."
},
"commitList": {
"inspection": "Créer une inspection de retour",
"updateAtomic": "Mettre à jour la réservation et le véhicule de façon atomique",
"queueAutomation": "Planifier l'automatisation après la validation locale"
},
"editDetails": "Modifier les détails",
"registering": "Enregistrement…",
"confirmReturn": "Confirmer le retour"
},
"result": {
"committedLocally": "Validé localement",
"heading": "Retour enregistré",
"inspection": "Inspection",
"resultingStatus": "Statut de véhicule résultant",
"qualityIssue": "Problème de qualité",
"noneCreated": "Aucun créé",
"automationEvent": "Tâche d'automatisation",
"queuedForDelivery": "En attente de traitement ({{ref}}) — validation locale réussie ; la livraison par automatisation est asynchrone et pas encore confirmée.",
"nextBookingRisk": "Risque prochaine réservation",
"nextBookingRiskValue": "{{ref}} {{status}}",
"atRisk": "— peut être affectée",
"lowRisk": "— risque faible",
"noUpcomingBooking": "Aucune réservation à venir pour ce véhicule",
"odometerRegressionNotice": "Le kilométrage saisi était inférieur au kilométrage de référence du véhicule. Il a été enregistré tel quel ; le kilométrage de référence n'a pas changé et un problème de qualité des données a été ouvert pour examen.",
"viewVehicle": "Voir le véhicule {{ref}}",
"viewAutomation": "Voir le statut d'automatisation",
"viewAudit": "Voir la piste d'audit",
"viewTrace": "Suivre l'opération complète",
"continueDemo": "Poursuivre la démo"
},
"scenario": {
"ariaLabel": "Scénario de démo",
"title": "Scénario de démo : anomalie de kilométrage",
"body": "Ce véhicule affiche actuellement {{odometer}} km. Le formulaire ci-dessous est pré-rempli avec un relevé de retour inférieur — signe d'une erreur de saisie ou d'un véhicule confondu. Confirmez le retour pour voir comment {{productName}} détecte et traite cela.",
"preparing": "Préparation du scénario…"
}
}
@@ -0,0 +1,7 @@
{
"openRow": "Openen: {{label}}",
"reducedMotion": "Beperkte beweging actief",
"closeDialog": "Dialoogvenster sluiten",
"expandSection": "Sectie uitklappen",
"collapseSection": "Sectie inklappen"
}
+104
View File
@@ -0,0 +1,104 @@
{
"eyebrow": "Bewaken / Onveranderlijke geschiedenis",
"title": "Auditgeschiedenis",
"description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.",
"actionFilterLabel": "Actie",
"actionFilterPlaceholder": "bv. demo-login",
"actorFilterLabel": "Uitvoerder",
"actorFilterPlaceholder": "Naam of dienst",
"entityFilterLabel": "Record",
"entityFilterPlaceholder": "bv. MO-016",
"fromFilterLabel": "Vanaf",
"toFilterLabel": "Tot en met",
"managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
"managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.",
"loading": "Auditgeschiedenis laden…",
"unavailable": "Auditgeschiedenis is momenteel niet beschikbaar.",
"empty": "Geen auditgebeurtenissen gevonden",
"emptyDetail": "Pas het actiefilter aan.",
"relatedFilterActive": "Enkel gebeurtenissen gekoppeld aan deze actie ({{count}} gerelateerde gebeurtenissen).",
"clearFilter": "Filter wissen",
"count": "{{count}} gebeurtenissen",
"storedRendered": "UTC opgeslagen · Brussel weergegeven",
"columns": {
"when": "Wanneer",
"actor": "Uitvoerder",
"action": "Actie",
"entity": "Entiteit",
"change": "Wijziging",
"followUp": "Vervolg",
"details": "Details"
},
"viewRelatedEvents": "Gerelateerde gebeurtenissen bekijken",
"noChangeDetail": "Geen wijzigingsdetail geregistreerd.",
"noFieldChange": "Geen wijziging op veldniveau gedetecteerd.",
"reference": "Referentie",
"relatedEventsCount": "{{count}} gekoppelde gebeurtenis",
"relatedEventsCount_other": "{{count}} gekoppelde gebeurtenissen",
"showTechnicalEvents": "{{count}} technische gebeurtenis tonen",
"showTechnicalEvents_other": "{{count}} technische gebeurtenissen tonen",
"hideTechnicalEvents": "Technische gebeurtenissen verbergen",
"technicalDetails": "Technische details",
"fullReference": "Volledige referentie",
"correlationId": "Gekoppelde gebeurtenis-ID",
"diff": {
"setTo": "{{field}}: ingesteld op {{value}}",
"was": "{{field}}: was {{value}}",
"changed": "{{field}}: {{before}} → {{after}}"
},
"actorTypes": {
"user": "Gebruiker",
"system": "Systeem",
"service": "Dienst"
},
"fields": {
"status": "Status",
"operational_status": "Operationele status",
"registration_number": "Kenteken",
"make": "Merk",
"model": "Model",
"location": "Locatie",
"first_name": "Voornaam",
"last_name": "Achternaam",
"email": "E-mail",
"phone": "Telefoon",
"postal_code": "Postcode",
"city": "Stad",
"booking_end_odometer_km": "Eindkilometerstand boeking",
"vehicle_odometer_km": "Kilometerstand voertuig",
"survivor": "Behouden profiel",
"loser": "Samengevoegd profiel"
},
"trace": {
"eyebrow": "Volledige verwerking",
"title": "Van retour tot automatisering",
"description": "Deze weergave koppelt de gebeurtenissen met dezelfde correlatiecode tot één controleerbare verwerking.",
"steps": {
"return": { "title": "Retour opgeslagen", "complete": "Boeking en inspectie zijn atomair verwerkt.", "pending": "Nog niet verwerkt." },
"vehicle": { "title": "Wagenparkstatus afgeleid", "complete": "De server heeft de resulterende voertuigstatus vastgelegd.", "pending": "Statuswijziging nog niet gevonden." },
"outbox": { "title": "Automatisering klaargezet", "complete": "De outbox-opdracht is in dezelfde transactie aangemaakt.", "pending": "Geen outbox-opdracht gevonden." },
"delivery": { "title": "Externe opvolging", "complete": "n8n heeft de opvolging bevestigd.", "pending": "Aflevering is asynchroon en nog niet in deze audittrace bevestigd." }
}
},
"actions": {
"demo_login": "Ingelogd",
"demo_logout": "Uitgelogd",
"demo_reset": "Demogegevens hersteld",
"demo_data_seeded": "Demogegevens ingeladen",
"return_registered": "Voertuigretour geregistreerd",
"vehicle_status_changed": "Voertuigstatus gewijzigd",
"data_quality_issue_resolved": "Datakwaliteitsprobleem opgelost",
"data_quality_issue_deferred": "Datakwaliteitsprobleem uitgesteld",
"data_quality_issue_rejected": "Datakwaliteitsprobleem verworpen",
"data_quality_fields_provided": "Ontbrekende velden aangevuld",
"data_quality_odometer_corrected": "Kilometerstand gecorrigeerd",
"data_quality_odometer_retained": "Laatst bevestigde kilometerstand behouden",
"data_quality_status_applied": "Aanbevolen status toegepast",
"data_quality_booking_blocked": "Boeking geblokkeerd",
"data_quality_scan_run": "Kwaliteitscontrole uitgevoerd",
"customer_merged": "Klanten samengevoegd",
"workflow_retry": "Automatisering opnieuw geprobeerd",
"knowledge_question_asked": "Kennisvraag gesteld",
"mcp_tool_request": "MCP-tool aangeroepen"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"brandTagline": "Bedieningscentrum",
"orgLine": "Demo-organisatie: {{orgName}} (fictief)",
"headline1": "Elke overdracht.",
"headline2": "Eén helder overzicht.",
"defaultDescription": "{{productName}} brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde vervolgstappen.",
"footnote": "Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar",
"accessEyebrow": "Demo-toegang",
"accessHeading": "Kies hoe je wil starten",
"accessIntro": "Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.",
"startGuidedDemo": "Start begeleide demo",
"guidedDemoChecking": "Begeleide demo voorbereiden…",
"guidedDemoManifestUnavailable": "De begeleide demo-informatie kon niet geladen worden.",
"guidedDemoNotReady": "De begeleide demo is momenteel niet klaar om te starten.",
"guidedDemoRetry": "Demo-informatie opnieuw laden",
"startRecruiterTour": "Bekijk de highlights in 90 seconden",
"exploreAsOperationsManager": "Verken als Operationsmanager",
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
"exploreAsRentalEmployee": "Verken als Verhuurmedewerker",
"exploreAsRentalEmployeeDetail": "Boekingen, retours, wagenpark en procedures",
"safeByDesignTitle": "Veilig ontworpen",
"safeByDesignDetail": "Elke actie wordt gelogd en is in deze demo herstelbaar.",
"loginFailed": "De demo-sessie kon niet gestart worden. De API is mogelijk niet bereikbaar.",
"roleOperationsManager": "Operationsmanager",
"roleRentalEmployee": "Verhuurmedewerker",
"switchRole": "Wissel van rol",
"logout": "Uitloggen",
"emailLabel": "E-mailadres",
"passwordLabel": "Wachtwoord",
"signIn": "Aanmelden",
"passwordLoginFailed": "Aanmelden mislukt. Controleer je gegevens.",
"or": "of",
"signInWithOrganization": "Aanmelden met {{provider}}"
}
@@ -0,0 +1,132 @@
{
"list": {
"eyebrow": "Uitvoeren / Planning",
"title": "Boekingen",
"description": "Bekijk actieve verhuurperiodes en aankomende voertuigverbintenissen.",
"create": "Nieuwe boeking",
"searchLabel": "Zoeken",
"searchPlaceholder": "Boeking, klant of voertuig",
"statusLabel": "Status",
"statusAll": "Alle statussen",
"fromLabel": "Vanaf datum",
"toLabel": "Tot en met datum",
"locationLabel": "Locatie",
"locationPlaceholder": "Exacte standplaats",
"sortLabel": "Sortering",
"sortOperational": "Operationele prioriteit",
"sortAscending": "Startdatum oplopend",
"sortDescending": "Startdatum aflopend",
"presetsLabel": "Snelfilters",
"todayPreset": "Vandaag",
"upcomingPreset": "Aankomende reservaties",
"clearFilters": "Filters wissen",
"loading": "Boekingsoverzicht laden…",
"empty": "Geen boekingen gevonden",
"emptyDetail": "Pas het statusfilter voor boekingen aan.",
"noMatch": "Geen overeenkomende boekingen",
"noMatchDetail": "Probeer een bredere zoekterm.",
"unavailable": "Boekingsoverzicht is momenteel niet beschikbaar.",
"count": "{{count}} boekingen",
"pageOf": "Pagina {{page}} van {{total}}",
"columns": {
"reference": "Referentie",
"customer": "Klant",
"vehicle": "Voertuig",
"window": "Periode",
"status": "Status"
},
"paginationLabel": "Boekingspagina's",
"previous": "Vorige",
"next": "Volgende",
"rangeOf": "{{from}}–{{to}} van {{total}}"
},
"create": {
"backLink": "Boekingsoverzicht",
"eyebrow": "Boekingen / Nieuwe reservatie",
"title": "Boeking aanmaken",
"description": "Selecteer een klant en een voertuig dat voor de volledige huurperiode beschikbaar is.",
"startsAt": "Start",
"endsAt": "Einde",
"customerSearch": "Klant zoeken",
"customerPlaceholder": "Naam, e-mail of klantreferentie",
"customer": "Klant",
"searchFirst": "Zoek eerst een klant",
"chooseCustomer": "Selecteer een klant",
"vehicle": "Beschikbaar voertuig",
"vehicleSearch": "Voertuig zoeken",
"vehiclePlaceholder": "Referentie, merk, model, nummerplaat of locatie",
"locationUnknown": "locatie onbekend",
"loadingVehicles": "Beschikbaarheid controleren…",
"chooseVehicle": "Selecteer een voertuig",
"noVehicles": "Geen beschikbaar voertuig in deze periode",
"invalidLocalTime": "Kies een geldig tijdstip in Europe/Brussels. Het overgeslagen uur bij de omschakeling naar zomertijd bestaat niet.",
"endAfterStart": "Het einde moet na de start van de boeking vallen.",
"cancel": "Annuleren",
"save": "Boeking aanmaken",
"saving": "Boeking opslaan…",
"failed": "De boeking kon niet worden aangemaakt. Controleer de periode en beschikbaarheid."
},
"statuses": {
"reserved": "gereserveerd",
"active": "actief",
"returned": "geretourneerd",
"cancelled": "geannuleerd",
"blocked": "geblokkeerd"
},
"checkout": {
"title": "Voertuig uitchecken",
"description": "Leg de vertrekstaat vast. Een afwijking blokkeert de huur automatisch voor opvolging.",
"preparing": "Vertrekinspectie voorbereiden…",
"odometer": "Startkilometerstand",
"fuel": "Brandstofniveau (%)",
"condition": "Vertrekstaat",
"clean": "Voertuig is schoon",
"damage": "Schade vastgesteld",
"warning": "Technische melding",
"notes": "Notities",
"submit": "Inspectie vastleggen en huur starten",
"saving": "Vertrek vastleggen…",
"failed": "De vertrekinspectie kon niet worden vastgelegd.",
"activatedTitle": "Huur is gestart",
"activatedDetail": "Vertrekinspectie {{inspection}} is opgeslagen en het voertuig staat op verhuurd.",
"blockedTitle": "Huur is geblokkeerd",
"blockedDetail": "Vertrekinspectie {{inspection}} bevat een afwijking. Het voertuig is veilig uit inzet genomen."
},
"detail": {
"backLink": "Boekingsoverzicht",
"eyebrow": "Boekingen / Huurrecord",
"notFound": "Deze boeking kon niet gevonden worden.",
"loading": "Boekingsrecord laden…",
"customer": "Klant",
"vehicle": "Voertuig",
"starts": "Start",
"ends": "Einde",
"startOdometer": "Startkilometerstand",
"endOdometer": "Eindkilometerstand",
"startOdometerPending": "Nog niet vastgelegd — rit nog niet gestart",
"endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten",
"requirementsComplete": "Vereisten volledig",
"requirementsAction": "Vereisten controleren",
"requirementsActionDetail": "De vertrekinspectie blijft vergrendeld totdat rijbewijs en huurvereisten expliciet zijn bevestigd.",
"requirementsConfirmation": "Controlebewijs",
"requirementsConfirmationPlaceholder": "Noteer wat gecontroleerd werd",
"requirementsConfirm": "Vereisten bevestigen",
"requirementsSaving": "Bevestigen…",
"requirementsFailed": "De vereisten konden niet worden bevestigd.",
"rescheduleAction": "Reservatie verplaatsen",
"rescheduleDetail": "Wijzig de huurperiode. Beschikbaarheid wordt opnieuw gecontroleerd voordat de wijziging wordt opgeslagen.",
"rescheduleReason": "Reden voor wijziging",
"confirmReschedule": "Nieuwe periode opslaan",
"rescheduling": "Periode opslaan…",
"rescheduleFailed": "De reservatie kon niet worden verplaatst.",
"invalidScheduleWindow": "Kies geldige tijdstippen in Europe/Brussels en zorg dat het einde na de start valt.",
"yes": "Ja",
"no": "Nee",
"cancelAction": "Boeking annuleren",
"cancelReason": "Reden",
"cancelReasonPlaceholder": "Waarom wordt deze boeking geannuleerd?",
"confirmCancel": "Annulering bevestigen",
"cancelling": "Annuleren…",
"cancelFailed": "De boeking kon niet worden geannuleerd."
}
}
@@ -0,0 +1,49 @@
{
"orgName": "Northstar Mobility",
"brandTagline": "Bedieningscentrum",
"actions": {
"save": "Opslaan",
"cancel": "Annuleren",
"confirm": "Bevestigen",
"close": "Sluiten",
"back": "Terug",
"next": "Volgende",
"retry": "Opnieuw proberen",
"reset": "Herstellen",
"yes": "Ja",
"no": "Nee",
"viewDetails": "Details bekijken",
"technicalDetails": "Technische details"
},
"status": {
"loading": "Laden…",
"saving": "Opslaan…",
"error": "Er ging iets mis",
"success": "Gelukt",
"noResults": "Geen resultaten"
},
"pagination": {
"label": "Paginanavigatie",
"previous": "Vorige",
"next": "Volgende",
"pageOf": "Pagina {{page}} van {{total}}"
},
"states": {
"loadingDefault": "Werkruimte wordt geladen…",
"errorTitle": "We konden deze werkruimte niet laden."
},
"timezone": "Europa/Brussel",
"language": {
"label": "Taal",
"nl-BE": "Nederlands",
"en-GB": "English",
"fr-BE": "Français"
},
"footer": {
"productLine": "{{productName}} Demo",
"locale": "Europa/Brussel · Synthetische demogegevens"
},
"demo": {
"syntheticBadge": "Synthetische demo"
}
}
@@ -0,0 +1,85 @@
{
"eyebrow": "Uitvoeren / Live overzicht",
"greeting": {
"morning": "Goedemorgen",
"afternoon": "Goedemiddag",
"evening": "Goedenavond",
"night": "Welkom terug"
},
"greetingBody": {
"morning": "Hier is de status van je wagenpark voor vandaag.",
"afternoon": "Hier is het actuele overzicht van je wagenpark.",
"evening": "Hier is het overzicht van je wagenpark voor vanavond.",
"night": "Hier is het laatste overzicht van je wagenpark."
},
"description": "Beschikbaarheid, uitzonderingen en overdrachten doorheen de huidige werking.",
"viewFleet": "Wagenpark bekijken",
"demoStart": {
"title": "Probeer een demonstratiescenario",
"readyCount": "{{ready}} van {{total}} scenario's klaar voor demo.",
"readyCountFallback": "Vijf afgebakende scenario's.",
"startGuide": "Start demo-gids",
"resumeGuide": "Verder met demo-gids ({{current}}/{{total}})",
"viewScenarios": "Bekijk scenario's"
},
"readiness": {
"title": "Wagenparkstatus",
"description": "Live vanuit persistente voertuigstatus",
"openFleet": "Wagenpark openen",
"available": "Beschikbaar",
"rented": "Verhuurd",
"cleaning": "Reiniging",
"maintenance": "Onderhoud",
"blocked": "Geblokkeerd"
},
"attention": {
"title": "Aandachtspunten",
"description": "{{openIssues}} open datakwaliteitsproblemen · {{workflowExceptions}} automatiseringsuitzonderingen",
"reviewQueue": "Wachtrij bekijken",
"remaining_one": "Nog één aandachtspunt",
"remaining_other": "Nog {{count}} aandachtspunten",
"remainingHint": "Open de volledige werklijst",
"filterPlaceholder": "Filter aandachtspunten…",
"filterAriaLabel": "Zoek in aandachtspunten",
"severityAll": "Alle ernst",
"severityHigh": "Kritiek",
"severityMedium": "Waarschuwing",
"severityLow": "Info",
"severityAriaLabel": "Ernst",
"tierNow": "Nu behandelen",
"tierToday": "Vandaag opvolgen",
"tierLater": "Later controleren",
"empty": "Geen aandachtspunten voor dit filter.",
"openRecord": "Open {{title}}"
},
"movements": {
"title": "Bewegingen vandaag",
"description": "Vertrekken en retours in Europa/Brussel",
"allBookings": "Alle boekingen",
"empty": "Geen bewegingen gepland vandaag.",
"departure": "vertrek",
"return": "retour",
"openBooking": "Open boeking {{ref}}"
},
"integrationPulse": {
"title": "Integratiestatus",
"description": "Actuele evidentie van gekoppelde diensten",
"systemDetail": "Systeemdetail",
"n8nTitle": "n8n-aflevering",
"n8nSummary": "{{succeeded}} geslaagd · {{failed}} mislukt",
"n8nLatest": "Meest recente gebeurtenis {{ref}}",
"n8nNoEvidence": "Geen automatiseringsevidentie geregistreerd",
"knowledgeTitle": "Kennisassistent",
"knowledgeSummary": "{{count}} procedures geïndexeerd",
"knowledgeIndexUnknown": "{{count}} kennisbronnen · indexomvang niet verifieerbaar",
"knowledgeUnavailable": "Statuscontrole niet beschikbaar",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Registratie ingeschakeld",
"mcpNotConnected": "Nog niet gekoppeld"
},
"recent": {
"title": "Recente activiteit",
"description": "Laatst geauditeerde workflowwijzigingen",
"empty": "Geen automatiseringsactiviteit geregistreerd."
}
}
+270
View File
@@ -0,0 +1,270 @@
{
"badge": {
"trigger": "Synthetische demo",
"dialogLabel": "Over deze demo-omgeving",
"close": "Sluiten",
"orgIntro": "<strong>{{orgName}}</strong> is een fictieve organisatie. Alle namen, voertuigen en boekingen zijn synthetisch.",
"orgIntroFallback": "Alle namen, voertuigen en boekingen in deze omgeving zijn synthetisch.",
"realWorkflows": "De workflows, controles en automatisering zijn echt geïmplementeerd — enkel de gegevens zijn verzonnen.",
"lastReset": "Laatste reset: <strong>{{when}}</strong> · deze omgeving is op elk moment herstelbaar.",
"aboutLink": "Over deze demo",
"unknown": "onbekend"
},
"guide": {
"dialogLabel": "Gegidste demo",
"trigger": "Demo-gids",
"kicker": "Gegidste demo · stap {{current}} van {{total}}",
"whatYouWillSee": "Wat je zal zien",
"whyItMatters": "Waarom dit relevant is",
"startAction": "Aan de slag",
"expectedOutcome": "Verwacht resultaat",
"goToStep": "Ga naar deze stap",
"next": "Volgende",
"finish": "Demo afronden",
"close": "Sluiten",
"restart": "Demo opnieuw voorbereiden",
"restarting": "Bezig met herstellen…",
"restartFailed": "De demo kon niet hersteld worden.",
"allStepsLabel": "Alle stappen",
"progressChip": "Demo-gids · stap {{current}} van {{total}}",
"expand": "Demo-gids uitklappen",
"collapse": "Demo-gids inklappen",
"steps": {
"understand-state": {
"title": "1. Begrijp de operationele status",
"whatYouWillSee": "Het dashboard toont de wagenparkstatus, openstaande aandachtspunten en de bewegingen van vandaag.",
"whyItMatters": "Een Operationsmanager start elke dag met dit overzicht om te bepalen waar ingrijpen nodig is.",
"startAction": "Open het dashboard en bekijk de aandachtslijst en de tijdlijn van vandaag.",
"expectedOutcome": "Je ziet welke boekingen, voertuigen of datakwaliteitsproblemen aandacht vragen."
},
"open-booking": {
"title": "2. Open een boeking die aandacht nodig heeft",
"whatYouWillSee": "De boeking BK-DEMO-RETURN, actief en klaar voor retour vandaag.",
"whyItMatters": "Retours zijn het moment waarop foute kilometerstanden of schade voor het eerst zichtbaar worden.",
"startAction": "Open de boeking vanuit het dashboard of de boekingenlijst.",
"expectedOutcome": "Je ziet de boekingsdetails en de knop om de retour te verwerken."
},
"process-return": {
"title": "3. Verwerk een retour met een afwijkende kilometerstand",
"whatYouWillSee": "Een retourformulier met een vooringevulde, verdachte kilometerstand lager dan de laatst gekende stand.",
"whyItMatters": "Een dalende kilometerstand wijst op een foutieve invoer of een verwisseld voertuig — dit moet vóór vrijgave worden opgemerkt.",
"startAction": "Vul de retour in met de voorgestelde waarde en bekijk de serverpreview vóór je bevestigt.",
"expectedOutcome": "De retour wordt verwerkt, het voertuig krijgt een passende status en er wordt automatisch een datakwaliteitsprobleem aangemaakt."
},
"handle-quality-issue": {
"title": "4. Bekijk en behandel het gecreëerde datakwaliteitsprobleem",
"whatYouWillSee": "Een nieuw issue van het type 'kilometerstand-afwijking' bovenaan de werklijst.",
"whyItMatters": "Elk gedetecteerd probleem heeft één afgebakende oplossingsstap — niets wordt automatisch stilzwijgend gecorrigeerd.",
"startAction": "Open het datakwaliteitsoverzicht en kies het nieuwste issue.",
"expectedOutcome": "Je ziet de aanbevolen actie, kiest een oplossing en het issue wordt opgelost met een audit-spoor."
},
"merge-duplicate": {
"title": "5. Beoordeel en behandel een mogelijke dubbele klant",
"whatYouWillSee": "Twee klantprofielen met hetzelfde e-mailadres en telefoonnummer, naast elkaar vergeleken.",
"whyItMatters": "Dubbele klanten leiden tot verspreide boekingsgeschiedenis en verwarrende communicatie.",
"startAction": "Vergelijk beide profielen en kies welk profiel behouden blijft.",
"expectedOutcome": "De profielen worden samengevoegd, boekingen worden herverbonden en het verliezende profiel wordt een tombstone."
},
"ask-knowledge": {
"title": "6. Stel een vraag aan de procedureassistent",
"whatYouWillSee": "Een antwoord met bronvermelding uit de afgebakende demokennisbank.",
"whyItMatters": "Medewerkers moeten snel een onderbouwd antwoord krijgen over procedures, zonder te gokken.",
"startAction": "Klik op één van de voorbeeldvragen, in de huidige interfacetaal.",
"expectedOutcome": "Je ziet het antwoord, de gebruikte procedure en de brontekst — of een eerlijk 'onvoldoende informatie' als dat niet aanwezig is."
},
"check-automation-audit": {
"title": "7. Controleer automatisering en auditgeschiedenis",
"whatYouWillSee": "De status van de n8n-aflevering voor je retour, en de bijhorende audit-gebeurtenissen.",
"whyItMatters": "Elke belangrijke actie moet naspeurbaar zijn: wie deed wat, wanneer, en wat was het gevolg.",
"startAction": "Open Integraties om de afleverstatus te zien, en Auditgeschiedenis voor het volledige spoor.",
"expectedOutcome": "Je ziet een geslaagde (of herstelbare) aflevering en een leesbaar audit-overzicht van je acties."
},
"review-real-vs-simulated": {
"title": "8. Bekijk wat echt is, gesimuleerd is, of nog niet gekoppeld",
"whatYouWillSee": "Een overzicht van wat in deze demo functioneel geïmplementeerd is, wat synthetisch is, en welke koppelingen nog niet live zijn.",
"whyItMatters": "Een demo is pas overtuigend als bezoekers zelf kunnen nagaan wat echt werkt en wat nog toekomstmuziek is.",
"startAction": "Lees de pagina 'Over deze demo'.",
"expectedOutcome": "Je kan zelf uitleggen wat {{productName}} wel en niet is, zonder mondelinge toelichting."
}
}
},
"highlights": {
"eyebrow": "Highlights voor recruiters",
"title": "De sterkste keuzes in 90 seconden",
"description": "Drie korte inkijkjes tonen hoe {{productName}} operationele betrouwbaarheid, gecontroleerde correcties en brongebaseerde AI samenbrengt.",
"duration": "± 30 sec",
"open": "Bekijk in het platform",
"proofLabel": "Technisch bewijs",
"return": { "title": "Een retour die niet stil kan falen", "body": "Bekijk serverzijdige impactvalidatie, atomaire opslag en een outbox die externe automatisering loskoppelt van de balie.", "proof": "Transactie · idempotency · outbox · degradatie" },
"quality": { "title": "Datakwaliteit met menselijke controle", "body": "Vergelijk dubbele profielen, kies bewust het overlevende record en behoud boekings- en auditgeschiedenis.", "proof": "Uitlegbare regels · preview · audit · rollback" },
"knowledge": { "title": "AI die zijn bronnen moet tonen", "body": "Stel een procedurevraag en inspecteer het antwoord, de bronfragmenten en de eerlijke insufficient-evidence toestand.", "proof": "RAGcore · citations · veilige degradatie" },
"storyTitle": "Wil je de keuzes achter het systeem zien?",
"storyBody": "De Engineering-pagina verbindt de productflows met architectuur, betrouwbaarheid en eerlijke integratiestatus.",
"storyButton": "Open het engineeringverhaal",
"fullTourButton": "Start de volledige demo"
},
"scenarios": {
"eyebrow": "Demonstratiescenario's",
"title": "Probeer een demonstratiescenario",
"description": "Vijf afgebakende scenario's die telkens dezelfde vaste boekingen, klanten en voertuigen gebruiken — na een reset zijn ze altijd opnieuw te vinden.",
"loading": "Scenario's laden…",
"unavailable": "De demonstratiescenario's konden niet geladen worden. Probeer het opnieuw.",
"ready": "Klaar voor demo",
"notReady": "Niet beschikbaar",
"duration": "Duur",
"durationValue": "± {{minutes}} min",
"role": "Rol",
"demonstrates": "Toont aan:",
"requiresRole": "Vereist rol: {{roles}}.",
"startScenario": "Scenario starten",
"roleOr": "{{a}} of {{b}}",
"roles": {
"operations_manager": "Operationsmanager",
"rental_employee": "Verhuurmedewerker"
},
"items": {
"return-anomaly": {
"title": "Retour met afwijkende kilometerstand",
"problem": "Een voertuig komt terug met een kilometerstand die lager ligt dan de laatst geregistreerde stand — een teken van een foutieve invoer of een verwisseld voertuig.",
"demonstrates": "Retourverwerking, automatische detectie van datakwaliteitsproblemen en de auditgeschiedenis die daaruit ontstaat."
},
"duplicate-customer": {
"title": "Mogelijke dubbele klant samenvoegen",
"problem": "Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — waarschijnlijk dezelfde persoon, twee keer geregistreerd.",
"demonstrates": "Samenvoegen van klanten met behoud van boekingsgeschiedenis en auditgeschiedenis."
},
"booking-overlap": {
"title": "Overlappende boekingen herstellen",
"problem": "Eén voertuig staat dubbel gereserveerd voor overlappende periodes — een planningsfout die vóór vertrek moet worden opgelost.",
"demonstrates": "Detectie en gecontroleerde oplossing van planningsconflicten."
},
"automation-retry": {
"title": "Mislukte automatisering opnieuw proberen",
"problem": "Eén eerdere gebeurtenis kon niet worden afgeleverd aan de automatisering door een gesimuleerde verbindingsfout.",
"demonstrates": "Betrouwbare aflevering met begrensde herpogingen en zichtbare foutstatus."
},
"knowledge-question": {
"title": "Een procedurevraag stellen",
"problem": "Een medewerker weet niet zeker welke procedure van toepassing is bij een specifieke operationele situatie.",
"demonstrates": "Antwoorden met brongebaseerde onderbouwing uit een afgebakende demokennisbank."
}
},
"blockedReasons": {
"bookingNotFound": "Demoboeking BK-DEMO-RETURN niet gevonden. {{resetHint}}",
"bookingAlreadyProcessed": "Deze boeking is al verwerkt sinds de laatste reset. {{resetHint}}",
"duplicateIssueNotFound": "Demo-issue DQ-DEMO-DUPLICATE niet gevonden. {{resetHint}}",
"issueAlreadyResolved": "Dit issue is al opgelost sinds de laatste reset. {{resetHint}}",
"overlapIssueNotFound": "Demo-issue DQ-DEMO-OVERLAP niet gevonden. {{resetHint}}",
"failedEventNotFound": "Gesimuleerde mislukte gebeurtenis niet gevonden. {{resetHint}}",
"eventAlreadyRecovered": "Deze gebeurtenis is al hersteld sinds de laatste reset. {{resetHint}}",
"knowledgeUnavailable": "De demokennisbank is momenteel niet beschikbaar.",
"resetHint": "Reset de demogegevens om dit scenario opnieuw beschikbaar te maken."
}
},
"about": {
"eyebrow": "Over deze demo",
"title": "Wat {{productName}} wel en niet is",
"description": "{{orgName}} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.",
"loading": "Demo-informatie laden…",
"unavailable": "De demo-informatie kon niet geladen worden. Probeer het opnieuw.",
"ctaTitle": "Kies hoeveel tijd je hebt",
"ctaBody": "Bekijk de drie sterkste engineeringmomenten in 90 seconden, of doorloop de volledige operationele demo.",
"ctaButton": "Start volledige demo",
"quickTourButton": "Bekijk highlights · 90 sec",
"scenarioButton": "Open scenariolab",
"proofEyebrow": "Engineeringbewijs",
"proofTitle": "Controle, betrouwbaarheid en uitlegbaarheid",
"proofDescription": "Elke technische keuze ondersteunt een concrete operationele handeling.",
"proofRulesTitle": "Regels blijven bij het domein",
"proofRulesBody": "FastAPI bewaakt retouren, statussen en datakwaliteitsoplossingen. n8n en prompts nemen nooit gezaghebbende beslissingen.",
"proofReliabilityTitle": "Commit eerst, orkestreer daarna",
"proofReliabilityBody": "De operatie commit atomair; een outbox levert nadien met begrensde herpogingen aan n8n. Een storing blokkeert de verhuurbalie niet.",
"proofAiTitle": "AI moet bewijs tonen",
"proofAiBody": "RAGcore-antwoorden bevatten bronnen. Zonder voldoende evidentie geeft de assistent geen verzonnen antwoord.",
"architectureFlowTitle": "Van gebruikersactie tot gecontroleerd vervolg",
"architectureFlowDescription": "Het systeem van record blijft gescheiden van externe orkestratie en AI.",
"architectureFrontend": "Toegankelijke React-interface",
"architectureApi": "FastAPI-domeinregels",
"architectureDatabase": "PostgreSQL + audittrail",
"architectureOutbox": "Betrouwbare outbox",
"architectureExternal": "Externe diensten: n8n, RAGcore en MCP Hub",
"architectureZonesLabel": "Verantwoordelijkheidsgrenzen in de architectuur",
"architectureZoneIntent": "Gebruikersintentie",
"architectureZoneTransaction": "Lokale transactie",
"architectureZoneEdge": "Herstelbare systeemrand",
"architectureFlowLabel": "Interactieve systeemflow van interface naar externe diensten",
"architectureCommitBoundary": "Na commit",
"architectureInteractionHint": "Selecteer een stap om de operationele garantie en controlepunten te bekijken.",
"architectureSelectedStep": "Stap {{current}} van {{total}}",
"architectureBoundaryLabel": "Verantwoordelijkheid",
"architectureEvidenceLabel": "Controlepunten",
"architectureDetails": {
"Frontend": {
"summary": "Maakt rol en intentie expliciet",
"body": "Toegankelijke formulieren verzamelen gevalideerde invoer en tonen de impact vóór bevestiging. De interface beslist nooit zelfstandig over een domeinstatus.",
"proofOne": "Routeguard per rol",
"proofTwo": "Preview vóór bevestiging"
},
"Api": {
"summary": "Valideert elke operationele regel",
"body": "FastAPI bewaakt statussen, invarianten en oplossingsregels aan de API-grens, onafhankelijk van wat de browser aanlevert.",
"proofOne": "Pydantic-validatie",
"proofTwo": "Domeinbeslissing op de server"
},
"Database": {
"summary": "Commit data en audit atomair",
"body": "PostgreSQL bewaart de operationele wijziging en het auditbewijs binnen dezelfde transactie, zodat een gedeeltelijke statuswijziging niet kan ontstaan.",
"proofOne": "UUID + publieke referentie",
"proofTwo": "UTC + onverbreekbare audittrail"
},
"Outbox": {
"summary": "Legt vervolgwerk duurzaam vast",
"body": "Het outboxrecord wordt samen met de operatie gecommit. Een worker levert pas daarna, idempotent en met begrensde herpogingen, aan de orkestratielaag.",
"proofOne": "Post-commit aflevering",
"proofTwo": "Idempotency + begrensde retries"
},
"External": {
"summary": "Degradeert zonder lokaal dataverlies",
"body": "n8n, RAGcore en MCP Hub hebben time-outs en zichtbare healthstatus. Een storing blijft herstelbaar en AI antwoordt nooit zonder voldoende bronbewijs.",
"proofOne": "Healthstatus + time-outs",
"proofTwo": "Geen antwoord zonder bewijs"
}
},
"verificationTitle": "Verifieerbaar gebouwd",
"verificationBody": "Domeinregels, API-contracten, degradatiemodi en de volledige demonstratie worden automatisch getest. De repository bevat de exacte acceptatiecommando's en bewijsbundel.",
"problemTitle": "Het fictieve probleem",
"problemBody": "{{orgName}} verhuurt zo'n 50 campers en bestelwagens vanuit één hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. {{productName}} toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.",
"scopeTitle": "Voor wie en met welke scope",
"scopeBody": "Deze demo is bedoeld voor wie wil zien hoe {{productName}} operationele problemen bij een kleine verhuurder aanpakt: Operationsmanagers en Verhuurmedewerkers, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.",
"realTitle": "Wat écht werkt",
"realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige auditgeschiedenis, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).",
"syntheticTitle": "Wat synthetisch is",
"syntheticBody": "De organisatie, alle klanten, voertuigen, boekingen, onderhoudsgeschiedenis, procedures in de kennisbank en de vooraf ingerichte scenario's zijn volledig verzonnen. Geen enkel gegeven verwijst naar een bestaand persoon, voertuig of bedrijf; e-mailadressen gebruiken uitsluitend het testdomein {{testDomain}}.",
"architectureTitle": "Architectuur in het kort",
"architectureBody": "Een React/TypeScript-frontend praat met een FastAPI-backend (PostgreSQL via SQLAlchemy/Alembic-migraties); belangrijke bedrijfsregels leven in de backend, niet in n8n of in prompts. Retours en andere gebeurtenissen worden eerst lokaal gecommit en pas daarna asynchroon via een automatiseringsopdracht aan n8n afgeleverd, zodat een tijdelijke storing in de automatisering nooit een operationele actie blokkeert.",
"securityTitle": "Beveiliging en toegang",
"securityBody": "Toegang verloopt via ondertekende, HTTP-only sessiecookies per rol; elke rol gebonden aan een set toegestane routes, zowel serverzijdig afgedwongen als in de navigatie weerspiegeld. Belangrijke statuswijzigingen worden altijd gecontroleerd en gelogd — nooit stilzwijgend automatisch gecorrigeerd.",
"testingTitle": "Hoe dit getest is",
"testingBody": "Een geautomatiseerde backend-testsuite dekt bedrijfsregels en API-contracten; een volledige Playwright-eindtot-eind-suite dekt de gebruikersstromen, inclusief deze demo-ervaring zelf in drie talen. Elke wijziging wordt bovendien tegen een schone checkout (lege database, opnieuw opgebouwd vanaf de seed-data) gevalideerd voor deployment.",
"integrationsTitle": "Koppelingen — eerlijk gelabeld",
"integrationsDescription": "Wat operationeel is, wat demomodus is, en wat nog niet gekoppeld is.",
"resetTitle": "Demo-omgeving herstellen",
"resetBodyManager": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Gebruik <strong>Demogegevens herstellen</strong> in de zijbalk om opnieuw te beginnen.",
"resetBodyEmployee": "De omgeving is op elk moment terug te zetten naar de startsituatie. Laatste reset: <strong>{{when}}</strong>. Een Operationsmanager kan de demo-omgeving herstellen via de zijbalk.",
"limitationsTitle": "Beperkingen",
"limitationsBody": "Dit is een doelgericht operationeel platform, geen volledig ERP. Boekhouding, betalingen, publieke reserveringen en een generieke CRM vallen bewust buiten scope. Externe integraties worden afzonderlijk en eerlijk op recent bewijs beoordeeld.",
"unknown": "onbekend"
},
"integrationSummary": {
"titles": {
"n8n": "Automatisering (n8n)",
"ragcore": "Kennisassistent (RAGcore)",
"mcp_hub": "ITWorx MCP Hub"
},
"n8nDetail": "{{succeeded}} geslaagd · {{failed}} mislukt · {{pending}} in wachtrij.",
"ragcoreDetail": "{{count}} procedures geïndexeerd in {{collection}}.",
"ragcoreSourceDetail": "{{source_count}} beheerde bronprocedures beschikbaar; RAGcore rapporteert geen betrouwbare indextelling.",
"mcpDetailOperational": "Live gecontroleerde tool-aanroepen vanuit de Hub zijn in de auditgeschiedenis bevestigd.",
"mcpDetailPrepared": "Vier read-only tools zijn contractueel voorbereid; er is nog geen recente Hub-aanroep geregistreerd."
}
}
+206
View File
@@ -0,0 +1,206 @@
{
"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."
},
"CUSTOMER_ALREADY_MERGED": {
"title": "Klant al samengevoegd",
"explanation": "Eén van deze klantrecords is al samengevoegd met een ander record.",
"nextStep": "Herlaad het probleem; mogelijk is het al opgelost."
},
"ENTITY_NOT_FOUND": {
"title": "Record niet gevonden",
"explanation": "Het onderliggende record voor deze actie kon niet gevonden worden."
},
"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."
},
"ISSUE_CHANGED": {
"title": "De bewijslast is gewijzigd",
"explanation": "Er werd nieuwe bewijslast geregistreerd terwijl deze correctie werd voorbereid.",
"nextStep": "Vernieuw het probleem en beoordeel de actuele bewijslast opnieuw."
},
"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_EVENT_CORRELATION": {
"title": "Ongeldige correlatiereferentie",
"explanation": "De automatiseringscallback bevat geen geldige correlatiereferentie."
},
"CALLBACK_EVENT_MISMATCH": {
"title": "Callback hoort bij een ander event",
"explanation": "De automatiseringscallback verwijst naar een ander event dan het event dat wordt bijgewerkt."
},
"CALLBACK_CORRELATION_MISMATCH": {
"title": "Callbacktrace komt niet overeen",
"explanation": "De automatiseringscallback komt niet overeen met de opgeslagen correlatiereferentie van dit event."
},
"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."
},
"UNKNOWN_WORKFLOW": {
"title": "Onbekende automatiseringsworkflow",
"explanation": "Deze workflow hoort niet bij de vier beheerde automatiseringsflows."
}
},
"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."
}
}
}
@@ -0,0 +1,91 @@
{
"list": {
"eyebrow": "Wagenpark / Register",
"title": "Wagenpark",
"description": "Live operationele status, locatie en onderhoudsgereedheid.",
"searchLabel": "Zoeken",
"searchPlaceholder": "Referentie, merk of locatie",
"statusLabel": "Status",
"statusAll": "Alle statussen",
"attentionOnly": "Enkel aandachtspunten",
"locationFilter": "Locatie",
"locationPlaceholder": "Exacte standplaats",
"serviceDue": "Onderhoud vervallen",
"serviceRemaining": "{{count}} km resterend",
"attentionReasons": {
"blocked_status": "Geblokkeerde status",
"service_due": "Onderhoud vervallen",
"data_quality": "Open kwaliteitsprobleem"
},
"loading": "Wagenparkregister laden…",
"empty": "Geen voertuigen gevonden",
"emptyDetail": "Pas de huidige wagenparkfilters aan.",
"noMatch": "Geen overeenkomende voertuigen",
"noMatchDetail": "Probeer een bredere zoekterm.",
"unavailable": "Wagenparklijst is momenteel niet beschikbaar.",
"count": "{{count}} voertuigen",
"persisted": "Persistente wagenparkgegevens",
"columns": {
"reference": "Referentie",
"makeModel": "Merk / model",
"location": "Locatie",
"status": "Status",
"odometer": "Kilometerstand (km)",
"service": "Onderhoud",
"nextBooking": "Volgende boeking",
"attention": "Aandacht"
},
"needsAttention": "Vraagt aandacht"
},
"statuses": {
"available": "beschikbaar",
"rented": "verhuurd",
"cleaning": "reiniging",
"maintenance": "onderhoud",
"blocked": "geblokkeerd"
},
"detail": {
"backLink": "Wagenparkregister",
"eyebrow": "Wagenpark / Voertuigrecord",
"notFound": "Dit voertuig kon niet gevonden worden.",
"loading": "Voertuigrecord laden…",
"needsAttention": "Vraagt aandacht",
"tabs": {
"overview": "Overzicht",
"bookings": "Boekingen",
"inspections": "Inspecties",
"maintenance": "Onderhoud",
"quality": "Kwaliteit"
},
"tabsAriaLabel": "Voertuigonderdelen",
"overview": {
"registration": "Kenteken",
"modelYear": "Bouwjaar",
"location": "Locatie",
"odometer": "Kilometerstand",
"nextService": "Volgende onderhoudsbeurt",
"active": "Actief"
},
"yes": "Ja",
"no": "Nee",
"noBookings": "Geen boekingen geregistreerd.",
"noInspections": "Geen inspecties geregistreerd.",
"noMaintenance": "Geen onderhoudsgegevens.",
"noQualityIssues": "Geen kwaliteitsproblemen geregistreerd.",
"fuel": "Brandstof {{percent}}%",
"damage": "Schade",
"technicalWarning": "Technische melding",
"inspectionTypes": {
"departure": "Vertrekinspectie",
"return": "Retourinspectie"
},
"maintenanceCategories": {
"periodic_service": "Periodiek onderhoud",
"repair": "Herstelling",
"inspection": "Technische inspectie",
"tyres": "Banden",
"other": "Overig"
}
},
"maintenance": { "actionsTitle": "Onderhoudsacties", "actionsDescription": "Registreer uitgevoerd werk of geef een veilig voertuig opnieuw vrij.", "add": "Onderhoud registreren", "date": "Datum", "odometer": "Kilometerstand", "category": "Categorie", "nextService": "Volgend onderhoud (km)", "summary": "Uitgevoerd werk", "cancel": "Annuleren", "save": "Onderhoud opslaan", "saving": "Opslaan…", "failed": "Het onderhoud kon niet worden opgeslagen.", "releaseReason": "Reden voor vrijgave", "releasePlaceholder": "Welke controle bevestigt dat het voertuig veilig inzetbaar is?", "release": "Voertuig vrijgeven", "releaseFailed": "Het voertuig kan nog niet worden vrijgegeven." }
}
@@ -0,0 +1,125 @@
{
"eyebrow": "Bewaken / Integraties",
"title": "Integratiebeheer",
"description": "Bewaak afleverkwaliteit, beperkte beschikbaarheid en herprobeerbare workflowgebeurtenissen.",
"cards": {
"checking": "Actuele integratiestatus ophalen…",
"orchestrationKicker": "Orkestratie",
"n8nTitle": "n8n-aflevering",
"n8nSummary": "{{succeeded}} geslaagd · {{failed}} mislukt · {{pending}} in wachtrij · {{delivering}} bezig",
"n8nFallback": "Retourgebeurtenissen worden eerst lokaal verwerkt en pas daarna via de automatiseringsopdracht afgeleverd.",
"knowledgeKicker": "Kennis",
"knowledgeTitle": "Kennisassistent",
"knowledgeSummaryDemo": "Demokennisbank · {{count}} procedures geïndexeerd in {{collection}}.",
"knowledgeSummaryRagcore": "RAGcore · {{count}} procedures geïndexeerd in {{collection}}.",
"knowledgeSummaryIndexUnknown": "RAGcore bereikbaar · {{sourceCount}} brondocumenten voor deze taal · indexomvang niet verifieerbaar voor {{collection}}.",
"knowledgeSummarySyncReported": "RAGcore bereikbaar · {{sourceCount}} bronnen voor deze taal · laatste syncrapport {{synced}} verwerkt, {{failed}} mislukt op {{when}} · indexomvang niet verifieerbaar voor {{collection}}.",
"knowledgeUnavailable": "Statusevidentie momenteel niet beschikbaar.",
"gatewayKicker": "Tool-gateway",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Registratie is ingeschakeld voor deze omgeving.",
"mcpNotConnected": "Nog niet gekoppeld — voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.",
"mcpNoEvidence": "Geregistreerd, maar er is nog geen tool-aanroep geregistreerd.",
"mcpEvidence": "ITWorx MCP Hub voerde laatst {{tool}} uit · {{count}} gecontroleerde aanroepen in totaal",
"mcpTechnicalIdentity": "Technische clientidentiteit",
"mcpHubReachable": "Hub bereikbaar",
"mcpHubUnreachable": "Hub onbereikbaar",
"n8nDemoScenario": "Plus {{count}} voorbereid demoscenario — een gesimuleerde tijdelijke fout, geen integratieprobleem."
},
"statusLabels": {
"checking": "Controleren…",
"notConnected": "Niet gekoppeld",
"deliveryFailed": "Verwerking mislukt",
"retryAvailable": "Opnieuw proberen mogelijk",
"operational": "Operationeel",
"prepared": "Voorbereid",
"demoMode": "Demomodus",
"unavailable": "Niet beschikbaar"
},
"workflows": {
"title": "Automatiseringsworkflows",
"description": "{{known}} van {{expected}} canonieke n8n-workflows hebben actuele evidentie van werking.",
"loadingEvidence": "Actuele workflowevidentie ophalen…",
"unavailable": "Workflowevidentie is momenteel niet beschikbaar.",
"notBuilt": "Nog niet gebouwd",
"noEvidence": "Nog geen evidentie",
"healthy": "Gezond",
"stale": "Evidentie verouderd",
"failed": "Laatste uitvoering mislukt",
"executionId": "Uitvoering",
"columns": {
"name": "Workflow",
"status": "Status",
"lastSeen": "Laatste evidentie",
"technicalId": "Details"
},
"names": {
"vehicleReturn": "Voertuigretour-orkestratie",
"scheduledScan": "Geplande datakwaliteitsscan",
"ragcoreSync": "Synchronisatie kennisprocedures",
"errorHandler": "Workflowfoutafhandelaar"
},
"expectations": {
"vehicleReturn": "Gebeurtenisgestuurd na een veilig opgeslagen voertuigretour.",
"scheduledScan": "Gepland; normaal ieder uur actief.",
"ragcoreSync": "Gepland; normaal dagelijks actief.",
"errorHandler": "Wordt alleen actief wanneer een echte workflowuitvoering mislukt."
},
"errorHandler": {
"summary": "{{count}} automatiseringsfout(en) geregistreerd — laatste van {{workflow}} om {{when}}.",
"summaryEmpty": "Er zijn geen automatiseringsfouten geregistreerd."
}
},
"ledger": {
"title": "Automatiseringsopdrachten",
"description": "Vastgelegde automatiseringspogingen met de recentste foutevidentie.",
"filterLabel": "Weergave",
"filterNeedsAttention": "Vraagt aandacht",
"filterRecent": "Recentste",
"filterSucceeded": "Geslaagd",
"filterAll": "Alle",
"statusFilterLabel": "Status",
"statusAll": "Alle statussen",
"statusPending": "In wachtrij",
"statusDelivering": "Bezig",
"statusSucceeded": "Geslaagd",
"statusFailed": "Mislukt",
"unavailable": "Automatiseringsopdrachten zijn momenteel niet beschikbaar.",
"managerOnly": "Automatisering is enkel zichtbaar voor Operationsmanagers.",
"loading": "Automatiseringsopdrachten laden…",
"empty": "Geen automatiseringsopdrachten voor dit filter.",
"count": "{{count}} workflowgebeurtenissen",
"boundedRetries": "Begrensde herpogingen",
"groupedSucceeded": "{{count}} geslaagde voertuigretourverwerkingen",
"showIndividually": "Individuele opdrachten tonen",
"hideIndividually": "Individuele opdrachten verbergen",
"columns": {
"event": "Opdracht",
"type": "Type",
"booking": "Boeking",
"status": "Status",
"attempts": "Pogingen",
"lastError": "Laatste fout",
"when": "Wanneer",
"action": "Actie"
},
"retry": "Opnieuw proberen",
"retrying": "Bezig met opnieuw proberen…",
"retryFailed": "Kon deze aflevering niet opnieuw proberen.",
"noAction": "—",
"eventTypes": {
"vehicle.returned.v1": "Voertuigretour verwerkt"
},
"errorCodes": {
"connectionError": "De workflowdienst was tijdelijk niet bereikbaar. De retour is veilig opgeslagen en kan opnieuw worden aangeboden.",
"malformedPayload": "De opdracht bevatte onvolledige gegevens en kon niet worden afgeleverd. De onderliggende gegevens blijven veilig bewaard.",
"remoteReportedFailure": "De workflowdienst heeft de aflevering geweigerd. De opdracht kan opnieuw worden aangeboden.",
"staleLeaseRecovered": "Deze opdracht werd hersteld nadat een eerdere afleverpoging vastliep zonder resultaat.",
"unknownError": "Er is een onverwachte fout opgetreden bij het afleveren van deze opdracht.",
"demoScenarioTimeout": "Gesimuleerde time-out richting de workflowdienst. Deze fout hoort bij het voorbereide demoscenario en is geen echt incident."
},
"demoScenarioBadge": "Voorbereid demoscenario",
"demoScenarioExplanation": "Gesimuleerde tijdelijke fout, bewust in de demodata gezet om retry en audit te tonen. Ze heeft geen invloed op de gezondheid van de automatisering.",
"retryDemoScenario": "Demoscenario opnieuw proberen"
}
}
@@ -0,0 +1,73 @@
{
"eyebrow": "Bewaken / Onderbouwde kennis",
"title": "Procedurekennis",
"description": "Stel operationele vragen. Antwoorden verschijnen enkel wanneer {{provider}} voldoende onderbouwde evidentie teruggeeft.",
"providerDemo": "de demokennisbank",
"providerRagcore": "RAGcore",
"providerChecking": "de kennisbron",
"providerUnavailable": "de kennisdienst",
"statusChecking": "Kennisbron controleren…",
"statusCheckingDetail": "De actuele providerstatus wordt opgehaald.",
"statusAvailable": "Beschikbaar",
"statusUnavailable": "Niet beschikbaar",
"proceduresIndexed": "{{count}} procedures geïndexeerd",
"proceduresIndexUnknown": "Indexomvang niet beschikbaar via deze koppeling",
"proceduresAvailable": "{{count}} beheerde bronprocedures beschikbaar",
"technicalDetails": "Technische providerdetails",
"progress": { "retrieving": "Bronnen selecteren", "checking": "Evidentie controleren", "composing": "Antwoord opbouwen" },
"sourceTitles": {
"damage-procedure": "Schade bij voertuigretour",
"vehicle-availability-procedure": "Voertuig opnieuw beschikbaar stellen",
"vehicle-return-procedure": "Voertuigretour registreren",
"odometer-anomaly-procedure": "Afwijkende kilometerstand beoordelen",
"checkout-procedure": "Verplichte vertrekcontroles"
},
"statistics": {
"scope": "Taalbereik: index- en bronaantallen gelden voor de huidige taal; het n8n-syncrapport omvat alle drie talen.",
"verifiedIndexed": "exact gevonden en actief gepubliceerd in deze taal",
"sourceDocuments": "brondocumenten in deze taal",
"reportedSynced": "sync door n8n gerapporteerd",
"reportedFailed": "syncfouten gerapporteerd",
"lastSync": "Laatste syncrapport: {{when}}. De indexstand wordt onafhankelijk gecontroleerd via de exacte bronidentiteit en actieve RAGcore-versie.",
"noSyncReport": "Nog geen syncrapport ontvangen. Indexbewijs wordt afzonderlijk via de actieve RAGcore-versies gecontroleerd."
},
"providerNote": "Deze demo beantwoordt vanuit een kleine, vaste set geïndexeerde procedures — geen live RAGcore-koppeling. Een live RAGcore-backend zal later dezelfde interface overnemen, zonder dat deze pagina verandert.",
"askHeading": "Stel een procedurevraag",
"askSubheading": "Ophalen → evidentiecontrole → onderbouwd antwoord",
"questionLabel": "Vraag",
"questionPlaceholder": "bv. Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
"ask": "Vraag stellen",
"asking": "Bezig met vragen…",
"askFailed": "Kon de kennisdienst niet bereiken.",
"feedbackFailed": "Kon feedback niet opslaan.",
"suggestedLabel": "Probeer:",
"suggestedQuestions": [
"Wat moet ik doen wanneer een voertuig terugkomt met schade?",
"Hoe registreer ik een voertuigretour?",
"Wanneer mag een voertuig opnieuw beschikbaar gezet worden?",
"Wie beoordeelt een ongewone kilometerstand?",
"Welke controles zijn verplicht vóór vertrek?"
],
"emptyTitle": "Evidentie voor antwoorden",
"emptyDescription": "Vraag naar retours, schade, inspecties of een andere geïndexeerde procedure. {{productName}} verzint geen antwoord wanneer evidentie ontbreekt.",
"retrievalFlow": {
"question": "Vraag",
"sources": "Bronnen",
"answer": "Antwoord"
},
"questionLabelExchange": "Vraag",
"evidenceStates": {
"grounded": "Onderbouwd met geciteerde procedures",
"insufficient": "Onvoldoende evidentie",
"unavailable": "Kennisdienst niet beschikbaar"
},
"unavailableBody": "De kennisdienst is momenteel niet bereikbaar. Operationele functies zijn hier niet door beïnvloed — probeer later opnieuw.",
"sourceVersion": "v{{version}}",
"answeredIn": "Beantwoord in {{seconds}} s",
"sourceTechnicalVersion": "Technische bronversie beschikbaar in audit",
"feedbackLabel": "Feedback op antwoord",
"feedbackPrompt": "Was dit antwoord bruikbaar?",
"feedbackThanks": "Feedback opgeslagen",
"feedbackHelpful": "Ja",
"feedbackNotHelpful": "Nee"
}
@@ -0,0 +1,53 @@
{
"skipToContent": "Ga naar hoofdinhoud",
"groups": {
"operate": "Uitvoeren",
"assure": "Bewaken"
},
"items": {
"overview": "Overzicht",
"fleet": "Wagenpark",
"bookings": "Boekingen",
"quality": "Datakwaliteit",
"knowledge": "Kennis",
"integrations": "Integraties",
"audit": "Auditgeschiedenis",
"users": "Gebruikers",
"privacy": "Privacy",
"engineering": "Techniek"
},
"primaryNavLabel": "Hoofdnavigatie",
"mobileNavLabel": "Mobiele navigatie",
"more": "Meer",
"openNavigation": "Navigatie openen",
"closeNavigation": "Navigatie sluiten",
"sidebarEnvironment": "Demo-omgeving",
"sidebarEnvironmentDetail": "Enkel synthetische gegevens",
"resetDemoData": "Demogegevens herstellen",
"resetConfirmTitle": "Demoreset bevestigen",
"resetConfirmBody": "Alle synthetische wijzigingen worden ongedaan gemaakt en de deterministische demogegevens worden hersteld. Je wordt uitgelogd.",
"resetting": "Bezig met herstellen…",
"resetConfirmYes": "Ja, herstellen",
"resetCancel": "Annuleren",
"resetFailed": "Demogegevens konden niet hersteld worden.",
"searchLabel": "Zoek in {{productName}}",
"searchPlaceholder": "Zoek wagenpark, boeking of onderdeel…",
"searchShortcutHint": "Ctrl K",
"searchSearching": "Zoeken…",
"searchUnavailable": "Zoeken is momenteel niet beschikbaar.",
"searchNoResults": "Geen resultaten voor \"{{query}}\".",
"searchVehicleSummary": "{{make}} {{model}} · {{location}}",
"searchSections": {
"overview": "Operationeel dashboard",
"fleet": "Wagenparkregister",
"bookings": "Verhuurboekingen",
"quality": "Kwaliteitswerkbank",
"knowledge": "Procedureassistent",
"integrations": "Automatiserings- en integratiestatus",
"audit": "Auditgeschiedenis",
"privacy": "Privacybeheer"
},
"switchRole": "Wissel van rol",
"switchRoleTitle": "Wissel van demo-rol",
"languageSwitcherLabel": "Taal wijzigen"
}
@@ -0,0 +1,4 @@
{
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "managerOnly": "Gebruikersbeheer is uitsluitend beschikbaar voor Operationsmanagers.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "edit": "Bewerken", "editTitle": "Gebruiker {{ref}} bewerken", "newPassword": "Nieuw wachtwoord", "passwordUnchanged": "Leeg laten om niet te wijzigen", "saveChanges": "Wijzigingen opslaan", "cancel": "Annuleren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
"roles": { "operations_manager": "Operationeel beheerder", "rental_employee": "Verhuurmedewerker" }
}
@@ -0,0 +1,19 @@
{
"eyebrow": "Bewaken / Governance",
"title": "Privacybeheer",
"description": "Exporteer persoonsgegevens, bewaak bewaartermijnen en anonimiseer veilig met een volledig auditspoor.",
"managerOnly": "Deze governancefuncties zijn uitsluitend beschikbaar voor Operationsmanagers.",
"loading": "Privacybeleid laden…",
"error": "De privacyactie kon niet worden uitgevoerd. Controleer de referentie en bewaartermijn.",
"policyTitle": "Bewaarbeleid",
"totalCustomers": "Klanten",
"eligibleCustomers": "Anonimiseerbaar",
"anonymizedCustomers": "Geanonimiseerd",
"bookingRetention": "Minimum boekingsretentie",
"auditRetention": "Auditretentie",
"days_one": "{{count}} dag",
"days_other": "{{count}} dagen",
"customerRef": "Klantreferentie",
"exports": {"title":"Gegevensexport","detail":"Download een klantdossier als JSON of een begrensde auditexport als CSV. Elke export wordt gelogd.","customer":"Klantdossier downloaden","audit":"Audit CSV downloaden"},
"anonymize": {"title":"Klant anonimiseren","detail":"Onomkeerbaar. Actieve boekingen en boekingen jonger dan {{days}} dagen blokkeren deze actie.","reason":"Gemotiveerde reden","confirmation":"Typ {{ref}} ter bevestiging","submit":"Definitief anonimiseren","working":"Anonimiseren…","anonymized":"{{ref}} is geanonimiseerd.","already_anonymized":"{{ref}} was al geanonimiseerd."}
}
@@ -0,0 +1,265 @@
{
"list": {
"eyebrow": "Bewaken / Werkbank",
"title": "Datakwaliteit",
"description": "Los evidentie-onderbouwde uitzonderingen op voor ze de werking verstoren.",
"runScan": "Kwaliteitscontrole uitvoeren",
"confirmScanTitle": "Kwaliteitscontrole bevestigen",
"confirmScanBody": "Nu de deterministische controle over alle vijf regeltypes uitvoeren?",
"scanning": "Bezig met controleren…",
"confirmScanYes": "Ja, uitvoeren",
"cancel": "Annuleren",
"scanComplete": "Controle voltooid: {{summary}}",
"scanNoNew": "geen nieuwe problemen gevonden (bestaande open problemen worden niet opnieuw aangemaakt).",
"scanFailed": "Kwaliteitscontrole kon niet uitgevoerd worden.",
"statusLabel": "Status",
"statusAll": "Alle statussen",
"statusOpen": "Openstaand",
"statusDeferred": "Uitgesteld",
"statusResolved": "Opgelost",
"statusRejected": "Verworpen",
"ruleTypeLabel": "Regeltype",
"ruleTypeAll": "Alle regeltypes",
"severityLabel": "Ernst",
"severityAll": "Alle ernstniveaus",
"assigneeLabel": "Toegewezen aan",
"assigneeAll": "Alle medewerkers",
"unassigned": "Niet toegewezen",
"overdueOnly": "Enkel over tijd",
"bulkTitle": "Geselecteerde werkvoorraad bijwerken",
"selected": "{{count}} geselecteerd",
"assignTo": "Toewijzen aan",
"dueAt": "Uiterste datum",
"invalidDueAt": "Kies een geldige datum en tijd in Europe/Brussels.",
"bulkApply": "Werkvoorraad bijwerken",
"bulkSaving": "Bijwerken…",
"bulkFailed": "De werkvoorraad kon niet bijgewerkt worden.",
"clearSelection": "Selectie wissen",
"selectIssue": "Probleem {{ref}} selecteren",
"selectVisible": "Zichtbare problemen selecteren",
"deselectVisible": "Zichtbare selectie wissen",
"openIssue": "Probleem {{ref}} openen",
"filtersToggle": "Filters · {{count}} actief",
"activeFilters": "Actieve filters",
"demoScenariosOnly": "Enkel demoscenario's",
"loading": "Kwaliteitswerkbank laden…",
"queueClear": "Wachtrij is leeg",
"noIssuesMatch": "Geen problemen komen overeen met de huidige filters.",
"noDemoIssuesMatch": "Geen demoscenario-problemen komen overeen",
"noDemoIssuesMatchDetail": "Vink 'Enkel demoscenario's' uit om de volledige wachtrij te zien.",
"unavailable": "Datakwaliteitsproblemen zijn momenteel niet beschikbaar.",
"count": "{{count}} problemen",
"evidenceBacked": "Evidentie-onderbouwde detectie",
"columns": {
"reference": "Referentie",
"select": "Selecteren",
"rule": "Regel",
"entity": "Entiteit",
"severity": "Ernst",
"status": "Status",
"assignee": "Behandelaar",
"due": "Uiterste datum"
}
},
"ruleTypes": {
"possible_duplicate_customer": "Mogelijke dubbele klant",
"missing_required_field": "Ontbrekend verplicht veld",
"odometer_regression": "Afwijkende kilometerstand",
"booking_overlap": "Overlappende boeking",
"vehicle_status_conflict": "Statusconflict voertuig"
},
"severities": {
"high": "Kritiek",
"medium": "Waarschuwing",
"low": "Info"
},
"detail": {
"backLink": "Kwaliteitswerkbank",
"eyebrow": "Kwaliteit / {{rule}}",
"title": "Bekijk vastgelegde evidentie en registreer een geauditeerde oplossing.",
"notFound": "Dit probleem kon niet gevonden worden.",
"loading": "Probleemevidentie laden…",
"managerOnly": "De kwaliteitswerkbank is enkel zichtbaar voor Operationsmanagers.",
"managerOnlyDetail": "Datakwaliteitsevidentie en -oplossingen zijn enkel zichtbaar voor Operationsmanagers.",
"summary": {
"rule": "Regel",
"entity": "Entiteit",
"evidenceSummary": "Evidentiesamenvatting"
},
"resolved": {
"title": "Probleem {{ref}} opgelost",
"body": "De wijziging is doorgevoerd en vastgelegd in de auditgeschiedenis.",
"viewAudit": "Auditgeschiedenis bekijken",
"viewVehicle": "Voertuig bekijken",
"continueDemo": "Ga verder met de demo"
},
"deferOrReject": {
"heading": "Uitstellen of verwerpen",
"description": "Stel uit om later opnieuw te bekijken, of verwerp als dit geen echt probleem is.",
"defer": "Uitstellen",
"reject": "Verwerpen",
"deferFailed": "Kon dit probleem niet uitstellen.",
"rejectFailed": "Kon dit probleem niet verwerpen."
},
"explainer": {
"whatIsWrong": "Wat is er mis",
"whyItMatters": "Waarom dit belangrijk is",
"possible_duplicate_customer": {
"whatIsWrong": "Twee klantprofielen delen identificerende gegevens (e-mail, telefoon of een erg gelijkende naam) sterk genoeg om waarschijnlijk dezelfde persoon te zijn, twee keer geregistreerd.",
"whyItMatters": "Dubbele klanten verdelen de boekingsgeschiedenis over twee records, riskeren dubbele facturatie en verwarren klantcontacten."
},
"missing_required_field": {
"whatIsWrong": "Dit record mist gegevens die vereist zijn voor normale werking (bijvoorbeeld een klant zonder e-mail of telefoonnummer).",
"whyItMatters": "Zonder deze gegevens kan het bedrijf de klant niet bereiken, of het voertuig niet betrouwbaar identificeren voor compliance- en overdrachtscontroles."
},
"odometer_regression": {
"whatIsWrong": "Een ingevoerde kilometerstand ligt lager dan de laatst bevestigde (canonieke) stand van het voertuig.",
"whyItMatters": "Een dalende kilometerstand wijst meestal op een invoerfout of een verwisseld voertuig. Dit stilzwijgend doorlaten zou de onderhoudsplanning en de kilometerhistoriek bij wederverkoop verstoren."
},
"booking_overlap": {
"whatIsWrong": "Hetzelfde voertuig is toegewezen aan twee boekingen met overlappende periodes.",
"whyItMatters": "Slechts één van deze boekingen kan effectief worden nagekomen. Onopgelost zou een klant aankomen en merken dat zijn voertuig al bij iemand anders is."
},
"vehicle_status_conflict": {
"whatIsWrong": "De opgeslagen operationele status van dit voertuig komt niet overeen met wat de eigen boekings- en inspectiegeschiedenis impliceert.",
"whyItMatters": "Een foutieve status kan een niet-beschikbaar voertuig boekbaar laten lijken, of een beschikbaar voertuig verbergen voor het wagenpark."
}
},
"evidence": {
"duplicate.exact_email": "Identiek e-mailadres",
"duplicate.exact_phone": "Identiek telefoonnummer",
"duplicate.same_postal_code": "Dezelfde postcode",
"duplicateSimilarName": "Sterk gelijkende naam (score {{score}})",
"missingField": "Ontbrekend veld: {{field}}",
"overlapReservedBookings": "Overlappende reserveringen: {{refs}}",
"odometerRegression": "Boeking {{laterRef}} registreerde {{laterKm}} km, lager dan de {{earlierKm}} km van eerdere boeking {{earlierRef}}.",
"upcomingBookingMissingInspection": "Boeking {{bookingRef}} start binnenkort maar de vereiste operationele inspectie ontbreekt nog."
},
"duplicateCustomer": {
"heading": "Vergelijken en samenvoegen",
"description": "Kies de klant die behouden blijft en bekijk elk afwijkend veld.",
"keepAsSurvivor": "Behouden als hoofdprofiel",
"differs": "Verschilt",
"match": "Gelijk",
"summaryCounts": "{{matchCount}} overeenkomende velden · {{conflictCount}} conflicterende velden",
"showMatchingFields": "Ook overeenkomende velden tonen",
"hideMatchingFields": "Overeenkomende velden verbergen",
"previewTitle": "Voorbeeld van het samengevoegde record ({{ref}})",
"mergePreview": "{{loser}} wordt veilig gearchiveerd en gekoppeld aan {{survivor}}; de boekingsgeschiedenis verhuist mee.",
"mergeInto": "Samenvoegen met {{ref}}",
"confirmMergeTitle": "Samenvoegen bevestigen",
"confirmMergeBody": "{{loser}} samenvoegen met {{survivor}}? Dit kan niet ongedaan gemaakt worden.",
"merging": "Bezig met samenvoegen…",
"confirmMergeYes": "Ja, samenvoegen",
"mergeFailed": "Kon deze klanten niet samenvoegen.",
"bothMissing": "Beide klanten in deze vergelijking konden niet geladen worden.",
"fieldColumn": "Veld",
"fields": {
"first_name": "Voornaam",
"last_name": "Achternaam",
"email": "E-mail",
"phone": "Telefoon",
"postal_code": "Postcode",
"city": "Stad"
}
},
"missingField": {
"heading": "Ontbrekende velden invullen",
"description": "Vervolledig het record voor {{ref}}. Het probleem wordt automatisch opgelost zodra niets verplichts meer ontbreekt.",
"atLeastOne": "Minstens één van e-mail of telefoon is vereist.",
"saveAndRecheck": "Opslaan en herchecken",
"saving": "Opslaan…",
"saveFailed": "Kon deze velden niet opslaan.",
"fields": {
"first_name": "Voornaam",
"last_name": "Achternaam",
"email": "E-mail",
"phone": "Telefoon",
"registration_number": "Kenteken",
"make": "Merk",
"model": "Model",
"location": "Locatie"
}
},
"odometerRegression": {
"heading": "Kilometerafwijking oplossen",
"description": "De laatst bevestigde kilometerstand wordt nooit automatisch verlaagd — kies hoe dit te verwerken.",
"canonicalOdometer": "Laatst bevestigde kilometerstand",
"decisionLegend": "Beslissing",
"retainCanonical": "Laatst bevestigde stand behouden",
"retainCanonicalDetail": "Behandel de ingevoerde stand als foutief; er verandert niets aan het voertuigrecord.",
"correctReading": "Stand corrigeren",
"correctReadingDetail": "Werk zowel de boeking als de laatst bevestigde kilometerstand bij met de juiste waarde.",
"noBookingAttached": "Geen gekoppelde boeking bij dit probleem, dus enkel 'laatst bevestigde stand behouden' is beschikbaar.",
"booking": "Boeking",
"correctedOdometer": "Gecorrigeerde kilometerstand (km)",
"note": "Notitie",
"resolving": "Bezig met oplossen…",
"resolveIssue": "Probleem oplossen",
"resolveFailed": "Kon dit probleem niet oplossen."
},
"bookingOverlap": {
"heading": "Boekingsoverlap oplossen",
"description": "Blokkeer één van de twee overlappende verbintenissen. De andere behoudt zijn huidige status.",
"columns": {
"booking": "Boeking",
"window": "Periode",
"status": "Status",
"blockThis": "Deze blokkeren"
},
"blockLabel": "{{ref}} blokkeren",
"note": "Notitie",
"resolving": "Bezig met oplossen…",
"blockButton": "{{ref}} blokkeren",
"blockButtonFallback": "Boeking blokkeren",
"resolveFailed": "Kon deze overlap niet oplossen."
},
"vehicleStatusConflict": {
"heading": "Statusconflict oplossen",
"description": "Eén gezaghebbende regel beveelt een gecorrigeerde operationele status voor dit voertuig aan.",
"currentStatus": "Huidige status",
"reviewRecommendation": "Aanbeveling bekijken",
"loadingRecommendation": "Aanbeveling laden…",
"recommendationFailed": "Kon geen aanbeveling ophalen.",
"recommendedStatus": "Aanbevolen status",
"whyHeading": "Waarom",
"evidenceHeading": "Feiten",
"consequenceHeading": "Gevolg",
"consequence": {
"statusWillChange": "De voertuigstatus wordt gewijzigd naar {{status}}.",
"issueWillBeRechecked": "Dit kwaliteitsprobleem wordt opnieuw gecontroleerd.",
"changeWillBeAudited": "De wijziging wordt vastgelegd in de auditgeschiedenis.",
"bookingsNotDeleted": "De boekingen zelf worden niet verwijderd."
},
"changeStatusTo": "Status wijzigen naar {{status}}",
"applying": "Bezig met toepassen…",
"applied": "Toegepast {{status}} — {{reason}}",
"applyFailed": "Kon geen aanbevolen status toepassen.",
"staleRecommendation": "De situatie is intussen gewijzigd. Bekijk de aanbeveling opnieuw voor u ze toepast.",
"reviewAgain": "Aanbeveling opnieuw bekijken",
"manualReview": {
"heading": "Handmatige beoordeling vereist",
"body": "De feiten voor dit voertuig spreken elkaar tegen. Een Operationsmanager moet dit persoonlijk beoordelen in plaats van een automatische statuswijziging toe te passen.",
"hint": "Gebruik 'Uitstellen' of 'Verwerpen' hieronder, of onderzoek het voertuig en de betrokken boekingen manueel."
},
"noConflict": {
"heading": "Geen wijziging nodig",
"body": "De huidige status van dit voertuig komt al overeen met de feiten."
},
"evidence": {
"activeBookings": "Actieve boeking(en): {{refs}}",
"overlappingBookings": "Overlappende reserveringen: {{pairs}}",
"serviceThresholdReached": "Kilometerstand ({{odometer}} km) heeft de onderhoudsdrempel ({{threshold}} km) bereikt.",
"openOverlapIssue": "Openstaand boekingsoverlapprobleem: {{ref}}"
},
"reasonCodes": {
"vehicle.active_rental": "Dit voertuig heeft een actieve verhuring maar staat niet als verhuurd geregistreerd. De status wordt gecorrigeerd zodat ze de werkelijke situatie weerspiegelt.",
"vehicle.service_threshold_reached": "Dit voertuig heeft de onderhoudsdrempel bereikt. Het wordt naar onderhoud gezet zodat het niet wordt ingezet voordat de service is uitgevoerd.",
"vehicle.booking_conflict": "Dit voertuig heeft twee overlappende reserveringen. Het blokkeren voorkomt dat het opnieuw als inzetbaar wordt beschouwd voordat het boekingsconflict is opgelost.",
"vehicle.rental_ended": "De verhuring voor dit voertuig is beëindigd en er is geen actieve boeking meer. Het wordt terug beschikbaar gezet.",
"vehicle.manual_review_required": "De feiten voor dit voertuig spreken elkaar tegen (bijvoorbeeld een actieve boeking terwijl het voertuig in onderhoud staat, of een onderhoudsdrempel die samenvalt met een actieve verhuring). Dit vereist een menselijke beoordeling in plaats van een automatische statuswijziging.",
"vehicle.no_conflict": "De huidige status van dit voertuig komt al overeen met de feiten. Er is geen wijziging nodig."
}
}
}
}
@@ -0,0 +1,82 @@
{
"progress": {
"capture": "Vastleggen",
"review": "Nakijken",
"result": "Resultaat",
"ariaLabel": "Voortgang retourregistratie"
},
"capture": {
"heading": "Voertuigretour registreren",
"description": "Leg de staat bij inlevering vast. De volgende stap evalueert de exacte operationele gevolgen voor er iets wordt bevestigd.",
"endOdometer": "Eindkilometerstand (km)",
"fuelLevel": "Brandstofniveau (%)",
"conditionLegend": "Voertuigstaat",
"cleanlinessOk": "Netheid aanvaardbaar",
"damageReported": "Schade gemeld",
"technicalWarning": "Technische melding",
"notes": "Notities",
"evaluating": "Evalueren…",
"reviewReturn": "Retour nakijken"
},
"review": {
"heading": "Retourimpact nakijken",
"description": "Dit is de gezaghebbende serverevaluatie van wat bevestigen zal doen — controleer dit voor het de wagenparkstatus bijwerkt en automatisering start.",
"odometer": "Kilometerstand",
"fuel": "Brandstof",
"cleanliness": "Netheid",
"cleanlinessAccepted": "Aanvaard",
"cleanlinessFollowUp": "Opvolging nodig",
"damage": "Schade",
"damageReported": "Gemeld",
"damageNone": "Niet gemeld",
"technicalWarning": "Technische melding",
"technicalWarningReported": "Gemeld",
"technicalWarningNone": "Niet gemeld",
"expectedState": "Verwachte wagenparkstatus:",
"odometerRegressionWarning": "De ingevoerde kilometerstand ({{submitted}} km) ligt onder de laatst bevestigde stand ({{canonical}} km). De laatst bevestigde kilometerstand wijzigt niet en er wordt een datakwaliteitsprobleem geopend.",
"nextBookingRisk": "Volgende boeking {{ref}} start {{when}} — mogelijk beïnvloed door deze retour.",
"nextBookingLowRisk": "Volgende boeking {{ref}} start {{when}} — laag risico.",
"reasonCodes": {
"returnBlockedDamageAndTechnical": "Zowel schade als een technische melding werden gemeld bij deze retour.",
"returnBlockedDamage": "Er werd schade gemeld bij deze retour.",
"returnBlockedTechnicalWarning": "Er werd een technische melding gemeld bij deze retour.",
"returnServiceThresholdReached": "De kilometerstand heeft de onderhoudsdrempel van {{threshold_km}} km bereikt.",
"returnRoutedToCleaning": "Geen schade, technische melding of onderhoudsdrempel; het voertuig gaat naar reiniging."
},
"commitList": {
"inspection": "Retourinspectie aanmaken",
"updateAtomic": "Boeking en voertuig atomair bijwerken",
"queueAutomation": "Automatisering inplannen na de lokale verwerking"
},
"editDetails": "Gegevens bewerken",
"registering": "Registreren…",
"confirmReturn": "Retour bevestigen"
},
"result": {
"committedLocally": "Lokaal verwerkt",
"heading": "Retour geregistreerd",
"inspection": "Inspectie",
"resultingStatus": "Resulterende wagenparkstatus",
"qualityIssue": "Datakwaliteitsprobleem",
"noneCreated": "Geen aangemaakt",
"automationEvent": "Automatiseringsopdracht",
"queuedForDelivery": "Klaargezet voor verwerking ({{ref}}) — lokale verwerking gelukt; aflevering via automatisering gebeurt asynchroon en is nog niet bevestigd.",
"nextBookingRisk": "Risico volgende boeking",
"nextBookingRiskValue": "{{ref}} {{status}}",
"atRisk": "— mogelijk beïnvloed",
"lowRisk": "— laag risico",
"noUpcomingBooking": "Geen aankomende boeking voor dit voertuig",
"odometerRegressionNotice": "De ingevoerde kilometerstand lag onder de laatst bevestigde stand van het voertuig. Ze werd zo geregistreerd; de laatst bevestigde kilometerstand is niet gewijzigd en er is een datakwaliteitsprobleem geopend ter controle.",
"viewVehicle": "Voertuig {{ref}} bekijken",
"viewAutomation": "Automatiseringsstatus bekijken",
"viewAudit": "Auditgeschiedenis bekijken",
"viewTrace": "Volledige verwerking volgen",
"continueDemo": "Ga verder met de demo"
},
"scenario": {
"ariaLabel": "Demonstratiescenario",
"title": "Demonstratiescenario: afwijkende kilometerstand",
"body": "Dit voertuig staat momenteel op {{odometer}} km. Het onderstaande formulier is vooraf ingevuld met een retourstand die daaronder ligt — een teken van een foutieve invoer of een verwisseld voertuig. Bevestig de retour om te zien hoe {{productName}} dit detecteert en afhandelt.",
"preparing": "Scenario voorbereiden…"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
import { getGreetingPeriod, type GreetingPeriod } from "./greeting";
// Checked frequently enough that a period rollover (e.g. 11:59 -> 12:00) is picked up
// within the Dashboard while the app stays open, without a page reload -- see section 9
// of the Fleet Ops final localization brief.
const CHECK_INTERVAL_MS = 30_000;
export function useGreetingPeriod(): GreetingPeriod {
const [period, setPeriod] = useState<GreetingPeriod>(() => getGreetingPeriod());
useEffect(() => {
const id = setInterval(() => {
setPeriod((current) => {
const next = getGreetingPeriod();
return next === current ? current : next;
});
}, CHECK_INTERVAL_MS);
return () => clearInterval(id);
}, []);
return period;
}
+14
View File
@@ -0,0 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "./i18n/config";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+77
View File
@@ -0,0 +1,77 @@
import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
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 { 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" };
const N8N_STATUS_LABEL_KEY: Record<string, string> = { disabled: "notConnected", unavailable: "deliveryFailed", degraded: "retryAvailable", operational: "operational", no_evidence: "prepared" };
const PROOFS: Array<{ id: "Rules" | "Reliability" | "Ai"; icon: IconName }> = [
{ id: "Rules", icon: "shield" },
{ id: "Reliability", icon: "integrations" },
{ id: "Ai", icon: "knowledge" },
];
export function AboutDemo() {
const { t } = useTranslation(["demo", "integrations"]);
const { formatDateTime } = useLocaleFormat();
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;
return (
<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>
<p className="page-eyebrow">{t("about.proofEyebrow")}</p>
<h2>{t("about.proofTitle")}</h2>
<p>{t("about.proofDescription")}</p>
</div>
<div className="engineering-hero-actions">
<Link className="button button-primary" to="/highlights"><Icon name="spark" /> {t("about.quickTourButton")}</Link>
{user?.role === "operations_manager" && <button type="button" className="button button-secondary" onClick={openGuide}>{t("about.ctaButton")}</button>}
<Link className="button button-secondary" to="/scenarios">{t("about.scenarioButton")}</Link>
</div>
</section>
<section aria-label={t("about.proofTitle")}>
<div className="proof-grid">
{PROOFS.map((proof) => <article className="panel proof-card" key={proof.id}><span><Icon name={proof.icon} /></span><h3>{t(`about.proof${proof.id}Title`)}</h3><p>{t(`about.proof${proof.id}Body`)}</p></article>)}
</div>
</section>
<section className="panel architecture-story" aria-labelledby="architecture-heading">
<SectionHeading headingId="architecture-heading" title={t("about.architectureFlowTitle")} description={t("about.architectureFlowDescription")} />
<ArchitectureFlow />
</section>
<div className="about-grid">
<div className="record-surface about-card"><h2>{t("about.problemTitle")}</h2><p>{t("about.problemBody", { orgName: manifest.organization_name, productName: PRODUCT_NAME })}</p></div>
<div className="record-surface about-card"><h2>{t("about.scopeTitle")}</h2><p>{t("about.scopeBody", { productName: PRODUCT_NAME })}</p></div>
<div className="record-surface about-card"><h2>{t("about.realTitle")}</h2><p>{t("about.realBody")}</p></div>
<div className="record-surface about-card"><h2>{t("about.syntheticTitle")}</h2><p>{t("about.syntheticBody", { testDomain: ".test" })}</p></div>
<div className="record-surface about-card"><h2>{t("about.verificationTitle")}</h2><p>{t("about.verificationBody")}</p></div>
<div className="record-surface about-card"><h2>{t("about.securityTitle")}</h2><p>{t("about.securityBody")}</p></div>
</div>
<section aria-label={t("about.integrationsTitle")} className="record-surface">
<SectionHeading title={t("about.integrationsTitle")} description={t("about.integrationsDescription")} />
<div className="integration-cards">{manifest.integrations.map((integration) => <article key={integration.key}><IntegrationMark kind={INTEGRATION_ICON[integration.key]} /><div><span className="integration-kicker">{t(`integrations:statusLabels.${statusLabelKey(integration.key, integration.status_code)}`)}</span><h2>{t(`integrationSummary.titles.${integration.key}`, { ns: "demo" })}</h2><p>{t(`integrationSummary.${integration.detail_code}`, { ns: "demo", ...integration.detail_params })}</p></div></article>)}</div>
</section>
<section className="record-surface about-card"><h2>{t("about.resetTitle")}</h2><p><Trans i18nKey={user?.role === "operations_manager" ? "about.resetBodyManager" : "about.resetBodyEmployee"} t={t} values={{ when: manifest.last_reset_at ? formatDateTime(manifest.last_reset_at) : t("about.unknown") }} components={{ strong: <strong /> }} /></p></section>
<section className="access-note record-surface" aria-label={t("about.limitationsTitle")}><Icon name="shield" /><p><strong>{t("about.limitationsTitle")}</strong><span>{t("about.limitationsBody")}</span></p></section>
</>}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { AuditEvent, Page } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { brusselsDayStartIso, brusselsNextDayStartIso } from "../i18n/brusselsDateTime";
import { useLocaleFormat } from "../i18n/format";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
import { Icon } from "../components/Icons";
function humanizeField(field: string): string {
const spaced = field.replace(/_/g, " ");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
function shortRef(id: string): string {
return `AUD-${id.slice(0, 8).toUpperCase()}`;
}
function actionLabel(t: (key: string, options?: Record<string, unknown>) => string, action: string): string {
return t(`actions.${action}`, { defaultValue: action.replace(/_/g, " ") });
}
function ChangeDiff({ before, after, t }: { before: Record<string, unknown> | null; after: Record<string, unknown> | null; t: (key: string, options?: Record<string, unknown>) => string }) {
if (!before && !after) return <p className="table-subtext">{t("noChangeDetail")}</p>;
const lines = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])]
.filter((key) => JSON.stringify(before?.[key]) !== JSON.stringify(after?.[key]))
.slice(0, 3)
.map((key) => {
const field = t(`fields.${key}`, { defaultValue: humanizeField(key) });
const b = before?.[key];
const a = after?.[key];
if (b === undefined) return t("diff.setTo", { field, value: JSON.stringify(a) });
if (a === undefined) return t("diff.was", { field, value: JSON.stringify(b) });
return t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) });
});
return lines.length ? <ul className="change-diff">{lines.map((line) => <li key={line}>{line}</li>)}</ul> : <p className="table-subtext">{t("noFieldChange")}</p>;
}
interface EventGroup { correlationId: string; primary: AuditEvent; related: AuditEvent[]; }
function OperationTrace({ events, t }: { events: AuditEvent[]; t: (key: string, options?: Record<string, unknown>) => string }) {
const actions = new Set(events.map((event) => event.action));
const isReturn = actions.has("return_registered");
if (!isReturn) return null;
const steps = [
{ key: "return", complete: isReturn },
{ key: "vehicle", complete: actions.has("vehicle_status_changed") },
{ key: "outbox", complete: isReturn },
{ key: "delivery", complete: actions.has("n8n_return_followup_recorded") },
];
return <section className="panel operation-trace" aria-labelledby="operation-trace-title">
<div><p className="page-eyebrow">{t("trace.eyebrow")}</p><h2 id="operation-trace-title">{t("trace.title")}</h2><p>{t("trace.description")}</p></div>
<ol>{steps.map((step, index) => <li className={step.complete ? "is-complete" : "is-pending"} key={step.key}><span>{step.complete ? <Icon name="check" /> : index + 1}</span><div><strong>{t(`trace.steps.${step.key}.title`)}</strong><small>{t(`trace.steps.${step.key}.${step.complete ? "complete" : "pending"}`)}</small></div></li>)}</ol>
</section>;
}
export function Audit() {
const { t } = useTranslation("audit");
const { formatDateTime } = useLocaleFormat();
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [events, setEvents] = useState<Page<AuditEvent> | null>(null);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const action = searchParams.get("action") ?? "";
const actor = searchParams.get("actor") ?? "";
const entityRef = searchParams.get("entity_ref") ?? "";
const from = searchParams.get("from") ?? "";
const to = searchParams.get("to") ?? "";
const correlationId = searchParams.get("correlation_id") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
function updateParams(updates: Record<string, string | number | null>) {
const next = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "") next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next, { replace: true });
}
useEffect(() => {
if (user?.role !== "operations_manager") return;
setEvents(null);
setError(null);
const params = new URLSearchParams({ page: String(page), page_size: "25" });
if (action) params.set("action", action);
if (actor) params.set("actor_label", actor);
if (entityRef) params.set("entity_ref", entityRef);
if (correlationId) params.set("correlation_id", correlationId);
// Date filters are Brussels calendar days, not UTC midnight boundaries.
if (from) params.set("occurred_from", brusselsDayStartIso(from));
if (to) params.set("occurred_to", brusselsNextDayStartIso(to));
const controller = new AbortController();
api
.get<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`, { signal: controller.signal })
.then(setEvents)
.catch(() => {
if (!controller.signal.aborted) setError(t("unavailable"));
});
return () => controller.abort();
}, [action, actor, correlationId, entityRef, from, page, t, to, user]);
const groups = useMemo<EventGroup[]>(() => {
const byCorrelation = new Map<string, AuditEvent[]>();
for (const event of events?.items ?? []) byCorrelation.set(event.correlation_id, [...(byCorrelation.get(event.correlation_id) ?? []), event]);
return [...byCorrelation.entries()].map(([id, grouped]) => ({ correlationId: id, primary: grouped[0], related: grouped.slice(1) }));
}, [events]);
if (user?.role !== "operations_manager") return <div className="page"><PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} /><p>{t("managerOnlyDetail")}</p></div>;
return <div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
<form className="filters" aria-label={t("title")} onSubmit={(event) => event.preventDefault()}>
<label>{t("actionFilterLabel")}<input type="search" value={action} onChange={(e) => updateParams({ action: e.target.value, page: 1 })} placeholder={t("actionFilterPlaceholder")} /></label>
<label>{t("actorFilterLabel")}<input type="search" value={actor} onChange={(e) => updateParams({ actor: e.target.value, page: 1 })} placeholder={t("actorFilterPlaceholder")} /></label>
<label>{t("entityFilterLabel")}<input type="search" value={entityRef} onChange={(e) => updateParams({ entity_ref: e.target.value, page: 1 })} placeholder={t("entityFilterPlaceholder")} /></label>
<label>{t("fromFilterLabel")}<input type="date" value={from} onChange={(e) => updateParams({ from: e.target.value, page: 1 })} /></label>
<label>{t("toFilterLabel")}<input type="date" value={to} onChange={(e) => updateParams({ to: e.target.value, page: 1 })} /></label>
</form>
{correlationId && events && <OperationTrace events={events.items} t={t} />}
{correlationId && <p className="quiet-empty" role="status">{t("relatedFilterActive", { count: events?.total ?? 0 })} <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: null, page: 1 })}>{t("clearFilter")}</button></p>}
{error && <ErrorState message={error} />}
{!error && !events && <LoadingState label={t("loading")} />}
{events && events.items.length === 0 && <EmptyState icon="audit" title={t("empty")} detail={t("emptyDetail")} />}
{groups.length > 0 && <div className="table-shell audit-shell">
<div className="table-meta"><span>{t("count", { count: events!.total })}</span><span>{t("storedRendered")}</span></div>
<ul className="audit-group-list">
{groups.map((group) => {
const e = group.primary;
const isExpanded = expanded[group.correlationId] ?? false;
return <li key={group.correlationId} className="audit-group panel">
<div className="audit-group-summary">
<div className="audit-group-heading"><strong>{actionLabel(t, e.action)}</strong><span className="table-subtext">{formatDateTime(e.occurred_at)}</span></div>
<div className="audit-group-meta"><span><strong>{e.actor_label}</strong> <small className="table-subtext">{t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}</small></span>{e.entity_link ? <Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link> : <span>{e.entity_ref ?? e.entity_type}</span>}</div>
<ChangeDiff before={e.before} after={e.after} t={t} />
<div className="audit-group-actions">
{group.related.length > 0 && <button type="button" className="link-button" onClick={() => setExpanded((current) => ({ ...current, [group.correlationId]: !current[group.correlationId] }))}>{isExpanded ? t("hideTechnicalEvents") : t("showTechnicalEvents", { count: group.related.length })}</button>}
{!correlationId && <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: group.correlationId, page: 1 })}>{t("viewRelatedEvents")}</button>}
</div>
</div>
<details className="evidence-disclosure"><summary>{t("technicalDetails")}</summary><dl className="detail-grid audit-technical-grid"><div><dt>{t("reference")}</dt><dd>{shortRef(e.id)}</dd></div><div><dt>{t("fullReference")}</dt><dd className="mono">{e.id}</dd></div><div><dt>{t("correlationId")}</dt><dd className="mono">{e.correlation_id}</dd></div></dl><pre className="evidence-block">{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre></details>
{isExpanded && group.related.length > 0 && <ul className="audit-related-list">{group.related.map((related) => <li key={related.id}><div className="audit-group-heading"><strong>{actionLabel(t, related.action)}</strong><span className="table-subtext">{formatDateTime(related.occurred_at)}</span></div><ChangeDiff before={related.before} after={related.after} t={t} /><details className="evidence-disclosure"><summary>{t("technicalDetails")}</summary><pre className="evidence-block">{JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}</pre></details></li>)}</ul>}
</li>;
})}
</ul>
<Pagination page={events!.page} totalPages={events!.total_pages} onPageChange={(nextPage) => updateParams({ page: nextPage })} />
</div>}
</div>;
}
+454
View File
@@ -0,0 +1,454 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
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 { ApiErrorNotice, ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { N8N_STATE_META, MCP_STATE_META, N8N_WORKFLOW_SLUGS } from "../data/integrationLabels";
type ViewFilter = "attention" | "recent" | "succeeded" | "all";
function deriveDisplayRef(run: AutomationRun): string {
const digits = run.aggregate_ref.match(/(\d+)$/)?.[1];
if (digits) return `AUT-RET-${digits}`;
return `AUT-${run.event_id.slice(0, 4).toUpperCase()}`;
}
export function Automation() {
const { t } = useTranslation(["integrations", "common"]);
const { formatDateTime } = useLocaleFormat();
const { user } = useAuth();
const [runs, setRuns] = useState<AutomationRun[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("");
const [view, setView] = useState<ViewFilter>("attention");
const [expandSucceeded, setExpandSucceeded] = useState(false);
const [retryError, setRetryError] = useState<ApiErrorInfo | null>(null);
const [retrying, setRetrying] = useState<string | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
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);
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()}`, { signal: controller.signal })
.then(setRuns)
.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", { signal: controller.signal })
.then(setKnowledge)
.catch(() => {
if (!controller.signal.aborted) setKnowledge(null);
})
.finally(() => {
if (!controller.signal.aborted) setKnowledgeSettled(true);
});
return () => controller.abort();
}, []);
useEffect(() => {
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`);
setRunsVersion((version) => version + 1);
setIntegrationVersion((version) => version + 1);
} catch (err) {
setRetryError(describeApiError(t, err, "ledger.retryFailed"));
} finally {
setRetrying(null);
}
}
const visibleRuns = useMemo(() => {
if (!runs) return null;
switch (view) {
case "attention":
return runs.filter((r) => r.status === "failed" || r.status === "pending" || r.status === "delivering");
case "recent":
return runs.slice(0, 5);
case "succeeded":
return runs.filter((r) => r.status === "succeeded");
default:
return runs;
}
}, [runs, view]);
const succeededRuns = useMemo(() => (visibleRuns ?? []).filter((r) => r.status === "succeeded"), [visibleRuns]);
const otherRuns = useMemo(() => (visibleRuns ?? []).filter((r) => r.status !== "succeeded"), [visibleRuns]);
const groupSucceeded = view !== "succeeded" && succeededRuns.length > 3;
if (user?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("ledger.managerOnly")} />
<p>{t("ledger.managerOnly")}</p>
</div>
);
}
function renderRow(r: AutomationRun) {
return (
<tr key={r.event_id}>
<td className="mono" data-label={t("ledger.columns.event")}>
<details>
<summary>{deriveDisplayRef(r)}</summary>
<span className="table-subtext">{r.event_id}</span>
</details>
</td>
<td data-label={t("ledger.columns.type")}>{t(`ledger.eventTypes.${r.event_type}`, { defaultValue: r.event_type })}</td>
<td data-label={t("ledger.columns.booking")}>{r.aggregate_ref}</td>
<td data-label={t("ledger.columns.status")}>
<StatusBadge status={r.status} label={t(`ledger.status${r.status.charAt(0).toUpperCase()}${r.status.slice(1)}`)} />
{r.is_demo_scenario && (
<StatusBadge status="demo_scenario" label={t("ledger.demoScenarioBadge")} />
)}
</td>
<td data-label={t("ledger.columns.attempts")}>{r.attempts}</td>
<td data-label={t("ledger.columns.lastError")}>
{r.last_error_code ? (
<>
<span>{t(`ledger.errorCodes.${r.last_error_code}`, { defaultValue: r.last_error ?? r.last_error_code })}</span>
{r.is_demo_scenario && <p className="table-subtext">{t("ledger.demoScenarioExplanation")}</p>}
{r.last_error && (
<details className="evidence-disclosure">
<summary>{t("common:actions.technicalDetails")}</summary>
<pre className="evidence-block">{r.last_error}</pre>
</details>
)}
</>
) : (
"—"
)}
</td>
<td data-label={t("ledger.columns.when")}>
<time dateTime={r.occurred_at}>{formatDateTime(r.occurred_at)}</time>
</td>
<td data-label={t("ledger.columns.action")}>
{r.status === "failed" ? (
<button type="button" onClick={() => handleRetry(r.event_id)} disabled={retrying === r.event_id}>
{retrying === r.event_id
? t("ledger.retrying")
: t(r.is_demo_scenario ? "ledger.retryDemoScenario" : "ledger.retry")}
</button>
) : (
t("ledger.noAction")
)}
</td>
</tr>
);
}
return (
<div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
<section className="integration-cards" aria-label={t("title")}>
<article>
<IntegrationMark kind="n8n" />
<div>
<span className="integration-kicker">{t("cards.orchestrationKicker")}</span>
<h2>{t("cards.n8nTitle")}</h2>
<p>
{!integrationSettled
? t("cards.checking")
: integrationStatus
? t("cards.n8nSummary", {
succeeded: integrationStatus.n8n.succeeded,
failed: integrationStatus.n8n.unexpected_failed,
pending: integrationStatus.n8n.pending,
delivering: integrationStatus.n8n.delivering,
})
: t("cards.n8nFallback")}
</p>
{/* Prepared demo failures are stated separately and never folded into the
health count above, so the badge and the numbers tell the same story. */}
{integrationStatus && integrationStatus.n8n.demo_scenario_failed > 0 && (
<p className="table-subtext">
{t("cards.n8nDemoScenario", { count: integrationStatus.n8n.demo_scenario_failed })}
</p>
)}
</div>
{(() => {
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
return (
<StatusBadge
status={!integrationSettled ? "loading" : (meta?.statusClass ?? (runs?.[0]?.status ?? "unavailable"))}
label={!integrationSettled ? t("statusLabels.checking") : meta ? t(`statusLabels.${meta.labelKey}`) : t("statusLabels.unavailable")}
/>
);
})()}
</article>
<article>
<IntegrationMark kind="rag" />
<div>
<span className="integration-kicker">{t("cards.knowledgeKicker")}</span>
<h2>{t("cards.knowledgeTitle")}</h2>
<p>
{!knowledgeSettled
? t("cards.checking")
: knowledge
? knowledge.document_count === null
? knowledge.statistics_state === "sync_reported"
? t("cards.knowledgeSummarySyncReported", {
collection: knowledge.collection,
sourceCount: knowledge.source_document_count,
synced: knowledge.reported_synced_document_count,
failed: knowledge.reported_failed_document_count,
when: knowledge.last_sync_at ? formatDateTime(knowledge.last_sync_at) : "—",
})
: t("cards.knowledgeSummaryIndexUnknown", {
collection: knowledge.collection,
sourceCount: knowledge.source_document_count,
})
: t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
count: knowledge.document_count,
collection: knowledge.collection,
})
: t("cards.knowledgeUnavailable")}
</p>
</div>
<StatusBadge
status={!knowledgeSettled ? "loading" : knowledge?.available ? "available" : "unavailable"}
label={
!knowledgeSettled
? t("statusLabels.checking")
: knowledge?.available
? t(`statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`)
: t("statusLabels.unavailable")
}
/>
</article>
<article>
<IntegrationMark kind="mcp" />
<div>
<span className="integration-kicker">{t("cards.gatewayKicker")}</span>
<h2>{t("cards.mcpTitle")}</h2>
<p>
{(() => {
if (!integrationSettled) return t("cards.checking");
const hub = integrationStatus?.mcp_hub;
if (!hub?.registration_enabled) return t("cards.mcpNotConnected");
if (hub.total_calls > 0) {
return t("cards.mcpEvidence", { tool: hub.last_tool, count: hub.total_calls });
}
return t("cards.mcpNoEvidence");
})()}
</p>
{integrationStatus?.mcp_hub.last_called_at && (
<small>{formatDateTime(integrationStatus.mcp_hub.last_called_at)}</small>
)}
{integrationStatus?.mcp_hub.last_client && (
<details className="technical-identity">
<summary>{t("cards.mcpTechnicalIdentity")}</summary>
<code>{integrationStatus.mcp_hub.last_client}</code>
</details>
)}
</div>
<div className="integration-badge-stack">
{(() => {
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
return (
<StatusBadge
status={!integrationSettled ? "loading" : (meta?.statusClass ?? "unavailable")}
label={!integrationSettled ? t("statusLabels.checking") : meta ? t(`statusLabels.${meta.labelKey}`) : t("statusLabels.unavailable")}
/>
);
})()}
{integrationStatus?.mcp_hub.hub_reachable !== null && integrationStatus?.mcp_hub.hub_reachable !== undefined && (
<StatusBadge
status={integrationStatus.mcp_hub.hub_reachable ? "available" : "unavailable"}
label={t(integrationStatus.mcp_hub.hub_reachable ? "cards.mcpHubReachable" : "cards.mcpHubUnreachable")}
/>
)}
</div>
</article>
</section>
<SectionHeading
title={t("workflows.title")}
description={
!integrationSettled
? t("workflows.loadingEvidence")
: integrationStatus
? t("workflows.description", {
known: integrationStatus.n8n.known_workflow_count,
expected: integrationStatus.n8n.expected_workflow_count,
})
: t("workflows.unavailable")
}
/>
{integrationStatus && (
<div className="table-shell">
<table className="data-table">
<caption className="visually-hidden">{t("workflows.title")}</caption>
<thead>
<tr>
<th scope="col">{t("workflows.columns.name")}</th>
<th scope="col">{t("workflows.columns.status")}</th>
<th scope="col">{t("workflows.columns.lastSeen")}</th>
<th scope="col">{t("workflows.columns.technicalId")}</th>
</tr>
</thead>
<tbody>
{integrationStatus.n8n.workflows.map((w) => {
const slug = N8N_WORKFLOW_SLUGS[w.name] ?? "";
const displayName = slug ? t(`workflows.names.${slug}`) : w.name;
const badge = !w.built
? { status: "not_configured", label: t("workflows.notBuilt") }
: w.state === "healthy"
? { status: "available", label: t("workflows.healthy") }
: w.state === "stale"
? { status: "degraded", label: t("workflows.stale") }
: w.state === "failed"
? { status: "failed", label: t("workflows.failed") }
: { status: "no_events", label: t("workflows.noEvidence") };
return (
<tr key={w.name}>
<td data-label={t("workflows.columns.name")}>
{displayName}
{slug && <span className="table-subtext">{t(`workflows.expectations.${slug}`)}</span>}
</td>
<td data-label={t("workflows.columns.status")}>
<StatusBadge status={badge.status} label={badge.label} />
</td>
<td data-label={t("workflows.columns.lastSeen")}>
{w.last_seen_at ? (
<time dateTime={w.last_seen_at}>{formatDateTime(w.last_seen_at)}</time>
) : (
"—"
)}
</td>
<td data-label={t("workflows.columns.technicalId")}>
<details className="evidence-disclosure">
<summary>{t("common:actions.technicalDetails")}</summary>
<span className="table-subtext">{w.name}{w.last_execution_id ? ` · ${t("workflows.executionId")}: ${w.last_execution_id}` : ""}</span>
</details>
</td>
</tr>
);
})}
</tbody>
</table>
<p className="table-meta">
{integrationStatus.n8n.error_handler.total_failures_registered > 0
? t("workflows.errorHandler.summary", {
count: integrationStatus.n8n.error_handler.total_failures_registered,
workflow: integrationStatus.n8n.error_handler.latest_failure_workflow ?? "",
when: integrationStatus.n8n.error_handler.latest_failure_at
? formatDateTime(integrationStatus.n8n.error_handler.latest_failure_at)
: "",
})
: t("workflows.errorHandler.summaryEmpty")}
</p>
</div>
)}
<SectionHeading title={t("ledger.title")} description={t("ledger.description")} />
<form className="filters" aria-label={t("ledger.title")}>
<label>
{t("ledger.filterLabel")}
<select value={view} onChange={(e) => setView(e.target.value as ViewFilter)}>
<option value="attention">{t("ledger.filterNeedsAttention")}</option>
<option value="recent">{t("ledger.filterRecent")}</option>
<option value="succeeded">{t("ledger.filterSucceeded")}</option>
<option value="all">{t("ledger.filterAll")}</option>
</select>
</label>
<label>
{t("ledger.statusFilterLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">{t("ledger.statusAll")}</option>
<option value="pending">{t("ledger.statusPending")}</option>
<option value="delivering">{t("ledger.statusDelivering")}</option>
<option value="succeeded">{t("ledger.statusSucceeded")}</option>
<option value="failed">{t("ledger.statusFailed")}</option>
</select>
</label>
</form>
{error && <ErrorState message={error} />}
<ApiErrorNotice error={retryError} />
{!error && !runs && <LoadingState label={t("ledger.loading")} />}
{visibleRuns && visibleRuns.length === 0 && <p>{t("ledger.empty")}</p>}
{visibleRuns && visibleRuns.length > 0 && (
<div className="table-shell">
<div className="table-meta"><span>{t("ledger.count", { count: visibleRuns.length })}</span><span>{t("ledger.boundedRetries")}</span></div>
<table className="data-table">
<caption className="visually-hidden">{t("ledger.title")}</caption>
<thead>
<tr>
<th scope="col">{t("ledger.columns.event")}</th>
<th scope="col">{t("ledger.columns.type")}</th>
<th scope="col">{t("ledger.columns.booking")}</th>
<th scope="col">{t("ledger.columns.status")}</th>
<th scope="col">{t("ledger.columns.attempts")}</th>
<th scope="col">{t("ledger.columns.lastError")}</th>
<th scope="col">{t("ledger.columns.when")}</th>
<th scope="col">{t("ledger.columns.action")}</th>
</tr>
</thead>
<tbody>
{otherRuns.map(renderRow)}
{groupSucceeded ? (
<>
<tr>
<td colSpan={8}>
<button type="button" className="link-button" onClick={() => setExpandSucceeded((v) => !v)}>
{expandSucceeded
? t("ledger.hideIndividually")
: t("ledger.groupedSucceeded", { count: succeededRuns.length })}
</button>
</td>
</tr>
{expandSucceeded && succeededRuns.map(renderRow)}
</>
) : (
succeededRuns.map(renderRow)
)}
</tbody>
</table>
</div>
)}
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
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, tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
export function BookingCreate() {
const { t } = useTranslation(["bookings", "errors"]);
const navigate = useNavigate();
const [customerQuery, setCustomerQuery] = useState("");
const [customers, setCustomers] = useState<CustomerOption[]>([]);
const [customerRef, setCustomerRef] = useState("");
const [startsAt, setStartsAt] = useState(() => brusselsDateTimeFromNow(2));
const [endsAt, setEndsAt] = useState(() => brusselsDateTimeFromNow(26));
const [vehicles, setVehicles] = useState<AvailableVehicle[]>([]);
const [vehicleRef, setVehicleRef] = useState("");
const [vehicleQuery, setVehicleQuery] = useState("");
const [loadingVehicles, setLoadingVehicles] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
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) {
setCustomers([]);
return;
}
const controller = new AbortController();
const timeout = window.setTimeout(() => {
api.get<CustomerOption[]>(`/api/v1/customers?query=${encodeURIComponent(customerQuery.trim())}`, { signal: controller.signal })
.then((result) => {
setCustomers(result);
if (!result.some((customer) => customer.public_ref === customerRef)) setCustomerRef("");
})
.catch((err) => {
if (!controller.signal.aborted) setError(describeApiError(t, err));
});
}, 250);
return () => {
window.clearTimeout(timeout);
controller.abort();
};
}, [customerQuery, customerRef, t]);
useEffect(() => {
if (!windowValid) {
setVehicles([]);
setVehicleRef("");
return;
}
const controller = new AbortController();
const timeout = window.setTimeout(() => {
setLoadingVehicles(true);
const params = new URLSearchParams({
starts_at: startsAtIso as string,
ends_at: endsAtIso as string,
});
if (vehicleQuery.trim()) params.set("query", vehicleQuery.trim());
params.set("limit", "50");
api.get<AvailableVehicle[]>(`/api/v1/bookings/availability?${params.toString()}`, { signal: controller.signal })
.then((result) => {
setVehicles(result);
setVehicleRef((current) => result.some((vehicle) => vehicle.public_ref === current) ? current : "");
})
.catch((err) => {
if (!controller.signal.aborted) setError(describeApiError(t, err));
})
.finally(() => {
if (!controller.signal.aborted) setLoadingVehicles(false);
});
}, 250);
return () => {
window.clearTimeout(timeout);
controller.abort();
};
}, [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: startsAtIso,
ends_at: endsAtIso,
});
navigate(`/bookings/${booking.public_ref}`);
} catch (err) {
setError(describeApiError(t, err, "bookings:create.failed"));
} finally {
setSubmitting(false);
}
}
return (
<div className="page">
<Link className="back-link" to="/bookings"><Icon name="arrow-left" /> {t("create.backLink")}</Link>
<PageHeader eyebrow={t("create.eyebrow")} title={t("create.title")} description={t("create.description")} />
<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 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>
{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>
);
}
+236
View File
@@ -0,0 +1,236 @@
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";
import type { Booking, CheckoutBookingResult, RegisterReturnResult, VehicleDetail } from "../api/types";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge";
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { PRODUCT_NAME } from "../product";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { ApiErrorNotice } from "../components/PageChrome";
import { CheckoutForm } from "../components/CheckoutForm";
import { toBrusselsDateTimeLocal, tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns", "errors"]);
const { formatDateTime, formatNumber } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>();
const { manifest, loading: manifestLoading } = useDemoManifest();
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null);
const [canonicalOdometerKm, setCanonicalOdometerKm] = useState<number | null>(null);
const [cancelReason, setCancelReason] = useState("");
const [cancelling, setCancelling] = useState(false);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [actionErrorSource, setActionErrorSource] = useState<"cancel" | "requirements" | "reschedule" | null>(null);
const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
const [requirementsConfirmation, setRequirementsConfirmation] = useState("");
const [confirmingRequirements, setConfirmingRequirements] = useState(false);
const [scheduleStart, setScheduleStart] = useState("");
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;
api
.get<Booking>(`/api/v1/bookings/${publicRef}`)
.then(setBooking)
.catch(() => setError(t("detail.notFound")));
}, [publicRef, t]);
useEffect(() => {
setBooking(null);
setError(null);
setReturnResult(null);
load();
}, [load]);
useEffect(() => {
if (!booking) return;
setScheduleStart(toBrusselsDateTimeLocal(new Date(booking.starts_at)));
setScheduleEnd(toBrusselsDateTimeLocal(new Date(booking.ends_at)));
}, [booking]);
// The odometer-regression demo scenario supplies its own suspicious reading (per the
// brief: never ask a demo visitor to invent one) -- only fetched for that one known
// scenario booking, not for every return.
const isReturnAnomalyScenario =
manifest?.scenarios.find((s) => s.id === "return-anomaly")?.start_path === `/bookings/${publicRef}`;
useEffect(() => {
setCanonicalOdometerKm(null);
if (!isReturnAnomalyScenario || !booking) return;
api
.get<VehicleDetail>(`/api/v1/vehicles/${booking.vehicle_ref}`)
.then((vehicle) => setCanonicalOdometerKm(vehicle.odometer_km))
.catch(() => setCanonicalOdometerKm(null));
}, [isReturnAnomalyScenario, booking]);
function handleRegistered(result: RegisterReturnResult) {
setReturnResult(result);
load();
}
async function cancelBooking(event: FormEvent) {
event.preventDefault();
if (!publicRef) return;
setCancelling(true);
setActionError(null);
try {
const updated = await api.post<Booking>(`/api/v1/bookings/${publicRef}/cancel`, { reason: cancelReason });
setBooking(updated);
setCancelReason("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.cancelFailed"));
setActionErrorSource("cancel");
} finally {
setCancelling(false);
}
}
function handleCheckout(result: CheckoutBookingResult) {
setCheckoutResult(result);
load();
}
async function confirmRequirements(event: FormEvent) {
event.preventDefault();
if (!publicRef) return;
setConfirmingRequirements(true);
setActionError(null);
try {
const updated = await api.post<Booking>(`/api/v1/bookings/${publicRef}/complete-requirements`, {
confirmation: requirementsConfirmation,
});
setBooking(updated);
setRequirementsConfirmation("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.requirementsFailed"));
setActionErrorSource("requirements");
} finally {
setConfirmingRequirements(false);
}
}
async function reschedule(event: FormEvent) {
event.preventDefault();
if (!publicRef || !scheduleStartIso || !scheduleEndIso || !scheduleWindowValid) return;
setRescheduling(true);
setActionError(null);
try {
const updated = await api.patch<Booking>(`/api/v1/bookings/${publicRef}/schedule`, {
starts_at: scheduleStartIso,
ends_at: scheduleEndIso,
reason: scheduleReason,
});
setBooking(updated);
setScheduleReason("");
} catch (err) {
setActionError(describeApiError(t, err, "bookings:detail.rescheduleFailed"));
setActionErrorSource("reschedule");
} finally {
setRescheduling(false);
}
}
if (error) return <ErrorState message={error} />;
if (!booking) return <LoadingState label={t("detail.loading")} />;
return (
<div className="page">
<Link className="back-link" to="/bookings"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<PageHeader eyebrow={t("detail.eyebrow")} title={booking.public_ref} description={`${booking.customer_name} · ${booking.vehicle_ref}`} actions={<StatusBadge status={booking.status} label={t(`statuses.${booking.status}`, { defaultValue: booking.status })} />} />
<section className="record-surface" aria-label={t("detail.eyebrow")}><dl className="detail-grid">
<div><dt>{t("detail.customer")}</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div>
<div><dt>{t("detail.vehicle")}</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
<div><dt>{t("detail.starts")}</dt><dd>{formatDateTime(booking.starts_at)}</dd></div>
<div><dt>{t("detail.ends")}</dt><dd>{formatDateTime(booking.ends_at)}</dd></div>
<div><dt>{t("detail.startOdometer")}</dt><dd>{booking.start_odometer_km !== null ? `${formatNumber(booking.start_odometer_km)} km` : t("detail.startOdometerPending")}</dd></div>
<div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : t("detail.endOdometerPending")}</dd></div>
<div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div>
</dl></section>
{booking.status === "reserved" && !booking.requirements_complete ? (
<form className="record-surface requirements-confirmation" onSubmit={confirmRequirements}>
<div>
<h2>{t("detail.requirementsAction")}</h2>
<p>{t("detail.requirementsActionDetail")}</p>
</div>
<ApiErrorNotice error={actionErrorSource === "requirements" ? actionError : null} />
<label>{t("detail.requirementsConfirmation")}<textarea required minLength={3} maxLength={500} value={requirementsConfirmation} onChange={(event) => setRequirementsConfirmation(event.target.value)} placeholder={t("detail.requirementsConfirmationPlaceholder")} /></label>
<div className="form-actions"><button className="button button-primary" type="submit" disabled={confirmingRequirements || requirementsConfirmation.trim().length < 3}>{confirmingRequirements ? t("detail.requirementsSaving") : t("detail.requirementsConfirm")}</button></div>
</form>
) : null}
{booking.status === "reserved" ? (
<details className="record-surface booking-reschedule">
<summary>{t("detail.rescheduleAction")}</summary>
<form onSubmit={reschedule}>
<p>{t("detail.rescheduleDetail")}</p>
<ApiErrorNotice error={actionErrorSource === "reschedule" ? actionError : null} />
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required 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 || !scheduleWindowValid}>{rescheduling ? t("detail.rescheduling") : t("detail.confirmReschedule")}</button></div>
</form>
</details>
) : null}
{(booking.status === "reserved" || booking.status === "blocked") && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
<h2>{t("detail.cancelAction")}</h2>
<ApiErrorNotice error={actionErrorSource === "cancel" ? actionError : null} />
<label>{t("detail.cancelReason")}<textarea required minLength={3} maxLength={500} value={cancelReason} onChange={(event) => setCancelReason(event.target.value)} placeholder={t("detail.cancelReasonPlaceholder")} /></label>
<div className="form-actions"><button className="button button-danger" type="submit" disabled={cancelling || cancelReason.trim().length < 3}>{cancelling ? t("detail.cancelling") : t("detail.confirmCancel")}</button></div>
</form>}
{checkoutResult && <section className={`record-surface checkout-result ${checkoutResult.activated ? "success" : "warning"}`} role="status"><h2>{t(checkoutResult.activated ? "checkout.activatedTitle" : "checkout.blockedTitle")}</h2><p>{t(checkoutResult.activated ? "checkout.activatedDetail" : "checkout.blockedDetail", { inspection: checkoutResult.inspection_ref })}</p></section>}
{!checkoutResult && booking.status === "reserved" && booking.requirements_complete && <CheckoutForm bookingRef={booking.public_ref} vehicleRef={booking.vehicle_ref} onRecorded={handleCheckout} />}
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
<section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}>
<Icon name="spark" />
<div>
<strong>{t("returns:scenario.title")}</strong>
<p>{t("returns:scenario.body", { odometer: formatNumber(canonicalOdometerKm), productName: PRODUCT_NAME })}</p>
</div>
</section>
)}
{returnResult && <ReturnResultPanel result={returnResult} />}
{/* Wait for the scenario's canonical odometer before mounting the form at all when
this is the known demo scenario, so ReturnForm's suggestedOdometerKm is correct
from its very first render -- never updated asynchronously after mount, which
previously raced with anyone already typing into the field. */}
{!returnResult && booking.status === "active" && (manifestLoading || (isReturnAnomalyScenario && canonicalOdometerKm === null)) && (
<LoadingState label={t("returns:scenario.preparing")} />
)}
{!returnResult && booking.status === "active" && !manifestLoading && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
<ReturnForm
bookingRef={booking.public_ref}
onRegistered={handleRegistered}
suggestedOdometerKm={isReturnAnomalyScenario && canonicalOdometerKm !== null ? Math.max(0, canonicalOdometerKm - 50) : undefined}
/>
)}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Booking, Page } from "../api/types";
import { brusselsDayStartIso, brusselsNextDayStartIso, toBrusselsDateTimeLocal } from "../i18n/brusselsDateTime";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
/** Today's Brussels calendar day (YYYY-MM-DD) -- the operational "today", not the browser's. */
function brusselsToday(): string {
return toBrusselsDateTimeLocal(new Date()).slice(0, 10);
}
export function Bookings() {
const { t } = useTranslation("bookings");
const { formatShortDate } = useLocaleFormat();
const [searchParams, setSearchParams] = useSearchParams();
const [bookings, setBookings] = useState<Page<Booking> | null>(null);
const [error, setError] = useState<string | null>(null);
const status = searchParams.get("status") ?? "";
const query = searchParams.get("q") ?? "";
const startsFrom = searchParams.get("from") ?? "";
const startsTo = searchParams.get("to") ?? "";
const location = searchParams.get("location") ?? "";
const sort = searchParams.get("sort") ?? "operational";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
function updateFilters(updates: Record<string, string | number | null>) {
const next = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "") next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next, { replace: true });
}
useEffect(() => {
setBookings(null);
setError(null);
const params = new URLSearchParams({ page: String(page), page_size: "25" });
if (status) params.set("status", status);
if (query) params.set("query", query);
// Date filters are Brussels calendar days regardless of the browser's own time zone.
if (startsFrom) params.set("starts_from", brusselsDayStartIso(startsFrom));
if (startsTo) params.set("starts_to", brusselsNextDayStartIso(startsTo));
if (location) params.set("location", location);
params.set("sort", sort);
const controller = new AbortController();
api
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`, { signal: controller.signal })
.then(setBookings)
.catch(() => {
if (!controller.signal.aborted) setError(t("list.unavailable"));
});
return () => controller.abort();
}, [page, query, status, startsFrom, startsTo, location, sort, t]);
return (
<div className="page">
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("list.description")} actions={<Link className="button button-primary" to="/bookings/new">{t("list.create")}</Link>} />
<form className="filters" aria-label={t("list.title")}>
<label>
{t("list.searchLabel")}
<input type="search" value={query} onChange={(e) => updateFilters({ q: e.target.value, page: 1 })} placeholder={t("list.searchPlaceholder")} />
</label>
<label>
{t("list.statusLabel")}
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
<option value="">{t("list.statusAll")}</option>
{STATUS_OPTIONS.map((value) => <option key={value} value={value}>{t(`statuses.${value}`)}</option>)}
</select>
</label>
<label>{t("list.fromLabel")}<input type="date" value={startsFrom} onChange={(event) => updateFilters({ from: event.target.value, page: 1 })} /></label>
<label>{t("list.toLabel")}<input type="date" value={startsTo} onChange={(event) => updateFilters({ to: event.target.value, page: 1 })} /></label>
<label>{t("list.locationLabel")}<input value={location} onChange={(event) => updateFilters({ location: event.target.value, page: 1 })} placeholder={t("list.locationPlaceholder")} /></label>
<label>{t("list.sortLabel")}<select value={sort} onChange={(event) => updateFilters({ sort: event.target.value, page: 1 })}><option value="operational">{t("list.sortOperational")}</option><option value="starts_asc">{t("list.sortAscending")}</option><option value="starts_desc">{t("list.sortDescending")}</option></select></label>
</form>
<div className="filter-presets" aria-label={t("list.presetsLabel")}>
<button type="button" onClick={() => updateFilters({ from: brusselsToday(), to: brusselsToday(), status: null, page: 1 })}>{t("list.todayPreset")}</button>
<button type="button" onClick={() => updateFilters({ from: brusselsToday(), to: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
<button type="button" onClick={() => setSearchParams(new URLSearchParams())}>{t("list.clearFilters")}</button>
</div>
{error && <ErrorState message={error} />}
{!error && !bookings && <LoadingState label={t("list.loading")} />}
{bookings && bookings.items.length === 0 && <EmptyState icon={query ? "search" : "bookings"} title={query ? t("list.noMatch") : t("list.empty")} detail={query ? t("list.noMatchDetail") : t("list.emptyDetail")} />}
{bookings && bookings.items.length > 0 && <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: bookings.total })}</span><span>{t("list.pageOf", { page: bookings.page, total: bookings.total_pages })}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead><tr><th scope="col">{t("list.columns.reference")}</th><th scope="col">{t("list.columns.customer")}</th><th scope="col">{t("list.columns.vehicle")}</th><th scope="col">{t("list.columns.window")}</th><th scope="col">{t("list.columns.status")}</th></tr></thead>
<tbody>
{bookings.items.map((booking) => (
<tr key={booking.public_ref} className="row-clickable">
<th scope="row" data-label={t("list.columns.reference")}>{booking.public_ref}<Link className="row-link" to={`/bookings/${booking.public_ref}`}><span className="visually-hidden">{booking.public_ref}</span></Link></th>
<td data-label={t("list.columns.customer")}>{booking.customer_name}</td>
<td data-label={t("list.columns.vehicle")}><Link to={`/vehicles/${booking.vehicle_ref}`} className="cell-link">{booking.vehicle_ref}</Link></td>
<td data-label={t("list.columns.window")}>{formatShortDate(booking.starts_at)} → {formatShortDate(booking.ends_at)}</td>
<td data-label={t("list.columns.status")}><StatusBadge status={booking.status} label={t(`statuses.${booking.status}`, { defaultValue: booking.status })} /></td>
</tr>
))}
</tbody>
</table><Pagination page={bookings.page} totalPages={bookings.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} /></div>}
</div>
);
}
+337
View File
@@ -0,0 +1,337 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Dashboard as DashboardData, IntegrationStatus, KnowledgeHealth } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { useGreetingPeriod } from "../i18n/useGreetingPeriod";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
import { describeEvidenceSignal } from "../data/evidenceSignals";
import type { AttentionItem } from "../api/types";
const FLEET_METRIC_KEYS: Array<{ key: keyof DashboardData["metrics"]; labelKey: string; tone: string }> = [
{ key: "available", labelKey: "readiness.available", tone: "ready" },
{ key: "rented", labelKey: "readiness.rented", tone: "neutral" },
{ key: "cleaning", labelKey: "readiness.cleaning", tone: "neutral" },
{ key: "maintenance", labelKey: "readiness.maintenance", tone: "warning" },
{ key: "blocked", labelKey: "readiness.blocked", tone: "critical" },
];
function attentionItemTitle(
t: (key: string, options?: Record<string, unknown>) => string,
item: { rule_type: string; link_ref: string },
): string {
const ruleLabel = t(`quality:ruleTypes.${item.rule_type}`, { defaultValue: item.rule_type.replace(/_/g, " ") });
return `${ruleLabel} — ${item.link_ref}`;
}
// The backend never emits prose here either -- only stable signal codes + params, exactly
// like the data-quality issue detail page. Multiple signals on one item (e.g. the
// duplicate-customer match) are joined into one readable line for this compact row.
function attentionItemDetail(
t: (key: string, options?: Record<string, unknown>) => string,
formatNumber: (value: number) => string,
item: Pick<AttentionItem, "evidence_signals">,
): string {
return item.evidence_signals.map((signal) => describeEvidenceSignal(t, formatNumber, signal)).join(" · ");
}
export function Dashboard() {
const { t } = useTranslation(["dashboard", "common", "integrations", "quality"]);
const { formatTime, formatShortDate, formatNumber } = useLocaleFormat();
const greetingPeriod = useGreetingPeriod();
const { user, demoMode } = useAuth();
const { manifest } = useDemoManifest();
const { openGuide, restart, currentIndex, completed, totalSteps } = useDemoGuide();
const [searchParams, setSearchParams] = useSearchParams();
const canSeeQuality = user?.role === "operations_manager";
const canSeeAutomation = user?.role === "operations_manager";
useEffect(() => {
if (searchParams.get("guide") === "start") {
restart();
openGuide();
setSearchParams({}, { replace: true });
}
// Only ever react to the initial `?guide=start` marker set by the login screen's
// "Start begeleide demo" CTA, so this intentionally runs once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [data, setData] = useState<DashboardData | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
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(() => {
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) {
setIntegrationStatus(null);
return;
}
const controller = new AbortController();
api
.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;
const haystack = `${attentionItemTitle(t, item)} ${attentionItemDetail(t, formatNumber, item)} ${item.link_ref}`.toLowerCase();
return matchesSeverity && haystack.includes(query.toLowerCase());
}) ?? [], [data, query, severity, t, formatNumber]);
const isUnfiltered = severity === "all" && query === "";
const attentionTiers = useMemo(() => {
if (!isUnfiltered) return [{ key: "all", labelKey: "", items: attention.slice(0, 6) }];
// Keep the initial dashboard queue deliberately bounded. The full evidence-backed
// queue remains one click away in Data Quality; this view is for deciding what to
// do next, not for rendering the whole backlog.
const visible = ["high", "medium", "low"].flatMap((level) => attention.filter((item) => item.severity === level)).slice(0, 6);
const bySeverity = (level: string) => visible.filter((item) => item.severity === level);
return [
{ key: "high", labelKey: "attention.tierNow", items: bySeverity("high") },
{ key: "medium", labelKey: "attention.tierToday", items: bySeverity("medium") },
{ key: "low", labelKey: "attention.tierLater", items: bySeverity("low") },
].filter((tier) => tier.items.length > 0);
}, [attention, isUnfiltered]);
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];
const readyCount = manifest?.scenarios.filter((s) => s.ready).length ?? 0;
const greetingTitle = `${t(`greeting.${greetingPeriod}`)}. ${t(`greetingBody.${greetingPeriod}`)}`;
return (
<div className="page dashboard-page">
<PageHeader eyebrow={t("eyebrow")} title={greetingTitle} description={t("description")} actions={<Link className="button button-secondary" to="/vehicles"><Icon name="fleet" /> {t("viewFleet")}</Link>} />
{demoMode && <section className="demo-start-panel" aria-label={t("demoStart.title")}>
<div>
<Icon name="spark" />
<div>
<strong>{t("demoStart.title")}</strong>
<span>
{manifest ? t("demoStart.readyCount", { ready: readyCount, total: manifest.scenarios.length }) : t("demoStart.readyCountFallback")}
</span>
</div>
</div>
<div className="demo-start-actions">
{user?.role === "operations_manager" && (
<button type="button" className="button button-secondary" onClick={openGuide}>
<Icon name="spark" /> {completed.size > 0 ? t("demoStart.resumeGuide", { current: currentIndex + 1, total: totalSteps }) : t("demoStart.startGuide")}
</button>
)}
<Link className="button button-primary" to="/scenarios">{t("demoStart.viewScenarios")} <Icon name="chevron" /></Link>
</div>
</section>}
<section className="readiness-band" aria-labelledby="readiness-heading">
<div className="readiness-label">
<span className="live-indicator" />
<div><h2 id="readiness-heading">{t("readiness.title")}</h2><p>{t("readiness.description")}</p></div>
</div>
<dl className="readiness-metrics">
{FLEET_METRIC_KEYS.map((metric) => (
<div key={metric.key} className={`metric-cell metric-${metric.tone}`}>
<dt>{t(metric.labelKey)}</dt><dd>{data.metrics[metric.key]}</dd>
</div>
))}
</dl>
<Link className="inline-action" to="/vehicles">{t("readiness.openFleet")} <Icon name="chevron" /></Link>
</section>
<div className="operations-grid">
<section className="work-panel attention-panel" aria-labelledby="attention-heading">
<SectionHeading headingId="attention-heading" title={t("attention.title")} description={t("attention.description", { openIssues: data.metrics.open_quality_issues, workflowExceptions: data.metrics.pending_or_failed_workflows })} action={canSeeQuality ? <Link to="/data-quality">{t("attention.reviewQueue")} <Icon name="chevron" /></Link> : undefined} />
<div className="queue-controls">
<label className="compact-search"><Icon name="search" /><span className="visually-hidden">{t("attention.filterAriaLabel")}</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("attention.filterPlaceholder")} /></label>
<label><span className="visually-hidden">{t("attention.severityAriaLabel")}</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">{t("attention.severityAll")}</option><option value="high">{t("attention.severityHigh")}</option><option value="medium">{t("attention.severityMedium")}</option><option value="low">{t("attention.severityLow")}</option></select></label>
</div>
{attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> {t("attention.empty")}</p> : (
<>
{attentionTiers.map((tier) => (
<div key={tier.key} className="attention-tier">
{attentionTiers.length > 1 && <p className="attention-tier-label">{t(tier.labelKey)}</p>}
<ul className="attention-list">
{tier.items.map((item, index) => {
const href = item.issue_ref && canSeeQuality
? `/data-quality/${item.issue_ref}`
: item.link_type === "vehicle"
? `/vehicles/${item.link_ref}`
: null;
const title = attentionItemTitle(t, item);
return (
<li key={`${item.link_ref}-${index}`} className={href ? "row-clickable" : ""}>
<SeverityBadge severity={item.severity} />
<div className="queue-copy">
<p className="attention-title">{title}</p>
<p className="attention-detail">{attentionItemDetail(t, formatNumber, item)}</p>
</div>
<span className="queue-ref">{item.link_ref}</span>
<Icon name="chevron" className="row-chevron" />
{href && (
<Link className="row-link" to={href} aria-label={t("attention.openRecord", { title })}>
<span className="visually-hidden">{title}</span>
</Link>
)}
</li>
);
})}
</ul>
</div>
))}
{isUnfiltered && attention.length > 6 && canSeeQuality && (
<p className="queue-more">
<Link to="/data-quality">
<span className="queue-more-count" aria-hidden="true">{attention.length - 6}</span>
<span className="queue-more-copy">
<strong>{t("attention.remaining", { count: attention.length - 6 })}</strong>
<small>{t("attention.remainingHint")}</small>
</span>
<span className="queue-more-arrow"><Icon name="chevron" /></span>
</Link>
</p>
)}
</>
)}
</section>
<section className="work-panel timeline-panel" aria-labelledby="today-heading">
<SectionHeading headingId="today-heading" title={t("movements.title")} description={t("movements.description")} action={<Link to="/bookings">{t("movements.allBookings")} <Icon name="chevron" /></Link>} />
{data.today.length === 0 ? <p className="quiet-empty"><Icon name="clock" /> {t("movements.empty")}</p> : (
<ol className="movement-timeline">
{data.today.map((item) => (
<li key={`${item.kind}-${item.booking_ref}`} className="row-clickable">
<time dateTime={item.scheduled_at}>{formatTime(item.scheduled_at)}</time>
<span className={`timeline-node timeline-${item.kind}`}><Icon name={item.kind === "return" ? "arrow-left" : "chevron"} /></span>
<div><span className="movement-kind">{item.kind === "return" ? t("movements.return") : t("movements.departure")}</span><span className="movement-ref">{item.booking_ref}</span><small>{item.vehicle_ref}</small></div>
<Link className="row-link" to={`/bookings/${item.booking_ref}`} aria-label={t("movements.openBooking", { ref: item.booking_ref })}>
<span className="visually-hidden">{item.booking_ref}</span>
</Link>
</li>
))}
</ol>
)}
</section>
</div>
<div className="secondary-grid">
<section className="work-panel integration-panel" aria-labelledby="integration-heading">
<SectionHeading headingId="integration-heading" title={t("integrationPulse.title")} description={t("integrationPulse.description")} action={canSeeAutomation ? <Link to="/automation">{t("integrationPulse.systemDetail")} <Icon name="chevron" /></Link> : undefined} />
<ul className="integration-list">
<li>
<IntegrationMark kind="n8n" />
<div>
<strong>{t("integrationPulse.n8nTitle")}</strong>
<span>
{integrationStatus
? t("integrationPulse.n8nSummary", { succeeded: integrationStatus.n8n.succeeded, failed: integrationStatus.n8n.failed })
: latestRun
? t("integrationPulse.n8nLatest", { ref: latestRun.aggregate_ref })
: t("integrationPulse.n8nNoEvidence")}
</span>
</div>
{(() => {
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
return (
<StatusBadge
status={meta?.statusClass ?? "no_events"}
label={meta ? t(`integrations:statusLabels.${meta.labelKey}`) : undefined}
/>
);
})()}
</li>
<li>
<IntegrationMark kind="rag" />
<div>
<strong>{t("integrationPulse.knowledgeTitle")}</strong>
<span>{knowledge ? (knowledge.document_count === null ? t("integrationPulse.knowledgeIndexUnknown", { count: knowledge.source_document_count }) : t("integrationPulse.knowledgeSummary", { count: knowledge.document_count })) : t("integrationPulse.knowledgeUnavailable")}</span>
</div>
<StatusBadge
status={knowledge?.available ? "available" : "unavailable"}
label={
knowledge?.available
? t(`integrations:statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`)
: t("integrations:statusLabels.unavailable")
}
/>
</li>
<li>
<IntegrationMark kind="mcp" />
<div>
<strong>{t("integrationPulse.mcpTitle")}</strong>
<span>{integrationStatus?.mcp_hub.registration_enabled ? t("integrationPulse.mcpEnabled") : t("integrationPulse.mcpNotConnected")}</span>
</div>
{(() => {
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`integrations:statusLabels.${meta.labelKey}`) : undefined} />;
})()}
</li>
</ul>
</section>
<section className="work-panel recent-panel" aria-labelledby="recent-heading">
<SectionHeading headingId="recent-heading" title={t("recent.title")} description={t("recent.description")} />
{data.recent_automation.length === 0 ? <p className="quiet-empty">{t("recent.empty")}</p> : (
<ul className="recent-list">
{data.recent_automation.slice(0, 4).map((run) => (
<li key={run.event_id}><span className="activity-icon"><Icon name="activity" /></span><div><strong>{t(`integrations:ledger.eventTypes.${run.event_type}`, { defaultValue: run.event_type.replace(/_/g, " ") })}</strong><span>{run.aggregate_ref}</span></div><StatusBadge status={run.status} label={t(`integrations:ledger.status${run.status.charAt(0).toUpperCase()}${run.status.slice(1)}`, { defaultValue: run.status })} /><time dateTime={run.occurred_at}>{formatShortDate(run.occurred_at)}</time></li>
))}
</ul>
)}
</section>
</div>
</div>
);
}
+340
View File
@@ -0,0 +1,340 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { DataQualityIssue, Page, ScanResult, UserRecord } from "../api/types";
import { useAuth } from "../context/AuthContext";
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 { tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
const RULE_TYPES = [
"possible_duplicate_customer",
"missing_required_field",
"odometer_regression",
"booking_overlap",
"vehicle_status_conflict",
];
export function DataQuality() {
const { t } = useTranslation("quality");
const { formatDateTime } = useLocaleFormat();
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [issues, setIssues] = useState<Page<DataQualityIssue> | null>(null);
const [error, setError] = useState<string | null>(null);
const status = searchParams.get("status") ?? "open";
const ruleType = searchParams.get("rule_type") ?? "";
const severity = searchParams.get("severity") ?? "";
const assignee = searchParams.get("assignee") ?? "";
const overdueOnly = searchParams.get("overdue") === "true";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const demoScenariosOnly = searchParams.get("demo") === "true";
const [users, setUsers] = useState<UserRecord[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkAssignee, setBulkAssignee] = useState("");
const [bulkDueAt, setBulkDueAt] = useState("");
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);
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next, { replace: true });
}
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.
setError(null);
const params = new URLSearchParams();
if (status) params.set("status", status);
if (ruleType) params.set("rule_type", ruleType);
if (severity) params.set("severity", severity);
if (assignee) params.set("assigned_to_ref", assignee);
if (overdueOnly) params.set("overdue", "true");
if (demoScenariosOnly) params.set("demo_only", "true");
params.set("page", String(page));
params.set("page_size", "25");
const controller = new AbortController();
api
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`, { signal: controller.signal })
.then(setIssues)
.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;
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) || 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: bulkDueAtIso ?? undefined,
});
setSelected(new Set());
setBulkAssignee("");
setBulkDueAt("");
setRefreshVersion((version) => version + 1);
} catch (err) {
setBulkError(describeApiError(t, err, "list.bulkFailed"));
} finally {
setBulkSaving(false);
}
}
function toggleSelected(ref: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(ref)) next.delete(ref); else next.add(ref);
return next;
});
}
function toggleVisibleSelection() {
setSelected((current) => {
const visibleRefs = visibleIssues.map((issue) => issue.public_ref);
const allSelected = visibleRefs.every((ref) => current.has(ref));
const next = new Set(current);
visibleRefs.forEach((ref) => allSelected ? next.delete(ref) : next.add(ref));
return next;
});
}
async function handleScan() {
setScanError(null);
setScanning(true);
try {
const result = await api.post<ScanResult>("/api/v1/data-quality/scan");
setScanResult(result);
setConfirmingScan(false);
setRefreshVersion((version) => version + 1);
} catch (err) {
setScanError(describeApiError(t, err, "list.scanFailed"));
} finally {
setScanning(false);
}
}
if (user?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("detail.managerOnlyDetail")} />
<p>{t("detail.managerOnlyDetail")}</p>
</div>
);
}
const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0;
// The demo-scenario filter is applied server-side (so it spans all pages); the client-side
// guard only bridges the moment between toggling the box and the filtered page arriving.
const visibleIssues = issues
? demoScenariosOnly
? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
: issues.items
: [];
const activeFilterCount = [status !== "open", Boolean(ruleType), Boolean(severity), Boolean(assignee), overdueOnly, demoScenariosOnly].filter(Boolean).length;
const allVisibleSelected = visibleIssues.length > 0 && visibleIssues.every((issue) => selected.has(issue.public_ref));
return (
<div className="page">
<PageHeader
eyebrow={t("list.eyebrow")}
title={t("list.title")}
description={t("list.description")}
actions={
!confirmingScan ? (
<button className="button button-secondary" type="button" onClick={() => setConfirmingScan(true)} disabled={scanning}>
{t("list.runScan")}
</button>
) : (
<div className="confirm-bar" role="alertdialog" aria-label={t("list.confirmScanTitle")}>
<p>{t("list.confirmScanBody")}</p>
<button type="button" onClick={handleScan} disabled={scanning}>
{scanning ? t("list.scanning") : t("list.confirmScanYes")}
</button>
<button type="button" onClick={() => setConfirmingScan(false)} disabled={scanning}>
{t("list.cancel")}
</button>
</div>
)
}
/>
<ApiErrorNotice error={scanError} />
<ApiErrorNotice error={bulkError} />
{scanResult && (
<p className="quiet-empty" role="status">
{t("list.scanComplete", {
summary: scanTotal === 0
? t("list.scanNoNew")
: Object.entries(scanResult.created)
.map(([rule, count]) => `${count} ${t(`ruleTypes.${rule}`, { defaultValue: rule.replace(/_/g, " ") })}`)
.join(", "),
})}
</p>
)}
<button className="button button-secondary filter-toggle" type="button" aria-expanded={filtersOpen} aria-controls="quality-filters" onClick={() => setFiltersOpen((open) => !open)}>
{t("list.filtersToggle", { count: activeFilterCount })}
</button>
<form id="quality-filters" className={`filters quality-filters${filtersOpen ? " is-open" : ""}`} aria-label={t("list.title")}>
<label>
{t("list.statusLabel")}
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
<option value="">{t("list.statusAll")}</option>
<option value="open">{t("list.statusOpen")}</option>
<option value="deferred">{t("list.statusDeferred")}</option>
<option value="resolved">{t("list.statusResolved")}</option>
<option value="rejected">{t("list.statusRejected")}</option>
</select>
</label>
<label>
{t("list.assigneeLabel")}
<select value={assignee} onChange={(e) => updateFilters({ assignee: e.target.value, page: 1 })}>
<option value="">{t("list.assigneeAll")}</option>
<option value="unassigned">{t("list.unassigned")}</option>
{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}
</select>
</label>
<label className="checkbox-label">
<input type="checkbox" checked={overdueOnly} onChange={(e) => updateFilters({ overdue: e.target.checked, page: 1 })} />
{t("list.overdueOnly")}
</label>
<label>
{t("list.ruleTypeLabel")}
<select value={ruleType} onChange={(e) => updateFilters({ rule_type: e.target.value, page: 1 })}>
<option value="">{t("list.ruleTypeAll")}</option>
{RULE_TYPES.map((r) => (
<option key={r} value={r}>
{t(`ruleTypes.${r}`)}
</option>
))}
</select>
</label>
<label>
{t("list.severityLabel")}
<select value={severity} onChange={(e) => updateFilters({ severity: e.target.value, page: 1 })}>
<option value="">{t("list.severityAll")}</option>
<option value="high">{t("severities.high")}</option>
<option value="medium">{t("severities.medium")}</option>
<option value="low">{t("severities.low")}</option>
</select>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={demoScenariosOnly}
onChange={(e) => updateFilters({ demo: e.target.checked, page: 1 })}
/>
{t("list.demoScenariosOnly")}
</label>
</form>
{activeFilterCount > 0 && (
<div className="active-filter-chips" aria-label={t("list.activeFilters")}>
{status !== "open" && <button type="button" onClick={() => updateFilters({ status: "open", page: 1 })}>{t(`list.status${status.charAt(0).toUpperCase()}${status.slice(1)}`)} ×</button>}
{ruleType && <button type="button" onClick={() => updateFilters({ rule_type: null, page: 1 })}>{t(`ruleTypes.${ruleType}`)} ×</button>}
{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={() => updateFilters({ demo: null, page: 1 })}>{t("list.demoScenariosOnly")} ×</button>}
</div>
)}
{error && <ErrorState message={error} />}
{!error && !issues && <LoadingState label={t("list.loading")} />}
{issues && issues.items.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
{issues && issues.items.length > 0 && visibleIssues.length === 0 && (
<EmptyState icon="check" title={t("list.noDemoIssuesMatch")} detail={t("list.noDemoIssuesMatchDetail")} />
)}
{visibleIssues.length > 0 && (
<div className="table-shell">
{selected.size > 0 && (
<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" 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>
)}
<div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><button className="select-visible" type="button" onClick={toggleVisibleSelection}>{t(allVisibleSelected ? "list.deselectVisible" : "list.selectVisible")}</button><span>{t("list.evidenceBacked")}</span></div><table className="data-table quality-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
<th scope="col" className="selection-cell"><input type="checkbox" checked={allVisibleSelected} onChange={toggleVisibleSelection} aria-label={t("list.selectVisible")} /></th>
<th scope="col">{t("list.columns.reference")}</th>
<th scope="col">{t("list.columns.rule")}</th>
<th scope="col">{t("list.columns.entity")}</th>
<th scope="col">{t("list.columns.severity")}</th>
<th scope="col">{t("list.columns.status")}</th>
<th scope="col">{t("list.columns.assignee")}</th>
<th scope="col">{t("list.columns.due")}</th>
</tr>
</thead>
<tbody>
{visibleIssues.map((i) => (
<tr key={i.public_ref} className="row-clickable">
<td className="selection-cell"><input type="checkbox" checked={selected.has(i.public_ref)} onChange={() => toggleSelected(i.public_ref)} aria-label={t("list.selectIssue", { ref: i.public_ref })} /></td>
<th scope="row" data-label={t("list.columns.reference")}>
<span aria-hidden="true">{i.public_ref}</span>
<Link className="row-link" to={`/data-quality/${i.public_ref}`}><span className="visually-hidden">{i.public_ref}</span></Link>
</th>
<td data-label={t("list.columns.rule")}>{t(`ruleTypes.${i.rule_type}`, { defaultValue: i.rule_type.replace(/_/g, " ") })}</td>
<td data-label={t("list.columns.entity")}>{i.entity_ref}</td>
<td data-label={t("list.columns.severity")}>
<SeverityBadge severity={i.severity} />
</td>
<td data-label={t("list.columns.status")}>
<StatusBadge status={i.status} label={t(`list.status${i.status.charAt(0).toUpperCase()}${i.status.slice(1)}`, { defaultValue: i.status })} />
</td>
<td data-label={t("list.columns.assignee")}>{i.assigned_to_name ?? t("list.unassigned")}</td>
<td data-label={t("list.columns.due")} className={i.overdue ? "is-overdue" : ""}>{i.due_at ? <time dateTime={i.due_at}>{formatDateTime(i.due_at)}</time> : "—"}</td>
</tr>
))}
</tbody>
</table>{issues && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
)}
</div>
);
}
@@ -0,0 +1,664 @@
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,
StatusRecommendation,
} from "../api/types";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { describeEvidenceSignal } from "../data/evidenceSignals";
import type { EvidenceSignal } from "../api/types";
import { Icon } from "../components/Icons";
import { ApiErrorNotice, ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { DuplicateCustomerPanel } from "./data-quality/DuplicateCustomerPanel";
function RuleExplainer({ ruleType }: { ruleType: string }) {
const { t } = useTranslation("quality");
if (!t(`detail.explainer.${ruleType}.whatIsWrong`, { defaultValue: "" })) return null;
return (
<section className="rule-explainer" aria-label={t("detail.explainer.whyItMatters")}>
<div>
<strong>{t("detail.explainer.whatIsWrong")}</strong>
<p>{t(`detail.explainer.${ruleType}.whatIsWrong`)}</p>
</div>
<div>
<strong>{t("detail.explainer.whyItMatters")}</strong>
<p>{t(`detail.explainer.${ruleType}.whyItMatters`)}</p>
</div>
</section>
);
}
function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
const { t } = useTranslation("common");
return (
<details className="evidence-disclosure">
<summary>{t("actions.technicalDetails")}</summary>
<pre className="evidence-block">{JSON.stringify(issue.evidence, null, 2)}</pre>
</details>
);
}
// The backend never emits prose for evidence -- only stable signal codes + raw data
// params (see app/services/data_quality.py::_open_issue). describeEvidenceSignal is the
// one place that turns them into the operator's selected language; the legacy
// `evidence.summary` string is a technical fallback only, shown solely inside
// EvidenceDisclosure above.
function EvidenceSignalList({ issue }: { issue: IssueDetail }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const signals = (issue.evidence.signals as EvidenceSignal[] | undefined) ?? [];
if (signals.length === 0) return null;
return (
<ul className="evidence-signal-list">
{signals.map((signal, index) => (
<li key={`${signal.code}-${index}`}>{describeEvidenceSignal(t, formatNumber, signal)}</li>
))}
</ul>
);
}
function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const isCustomer = issue.entity_type === "customer";
const fieldKeys = isCustomer
? ["first_name", "last_name", "email", "phone"]
: ["registration_number", "make", "model", "location"];
const snapshot = issue.entity_snapshot;
const [values, setValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const key of fieldKeys) {
initial[key] = snapshot && snapshot[key] ? String(snapshot[key]) : "";
}
return initial;
});
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const fields = Object.fromEntries(
Object.entries(values).filter(([, value]) => value.trim().length > 0),
);
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/provide-fields`, { fields });
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.missingField.saveFailed"));
} finally {
setSubmitting(false);
}
}
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="missing-field-heading">
<SectionHeading
headingId="missing-field-heading"
title={t("detail.missingField.heading")}
description={t("detail.missingField.description", { ref: snapshot?.public_ref ?? issue.entity_ref })}
/>
<ApiErrorNotice error={error} />
<div className="form-grid">
{fieldKeys.map((field) => (
<label key={field}>
{t(`detail.missingField.fields.${field}`)}
<input
type="text"
value={values[field] ?? ""}
onChange={(e) => setValues((v) => ({ ...v, [field]: e.target.value }))}
/>
</label>
))}
</div>
{isCustomer && (
<p className="table-subtext">{t("detail.missingField.atLeastOne")}</p>
)}
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? t("detail.missingField.saving") : t("detail.missingField.saveAndRecheck")}
</button>
</div>
</form>
);
}
function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
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("");
const [note, setNote] = useState("");
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/resolve-odometer-regression`, {
decision,
booking_ref: decision === "correct_reading" ? bookingRef : undefined,
corrected_odometer_km:
decision === "correct_reading" && correctedValue ? Number(correctedValue) : undefined,
note: note || undefined,
});
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.odometerRegression.resolveFailed"));
} finally {
setSubmitting(false);
}
}
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="odometer-heading">
<SectionHeading
headingId="odometer-heading"
title={t("detail.odometerRegression.heading")}
description={t("detail.odometerRegression.description")}
/>
<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>
<fieldset className="choice-fieldset">
<legend>{t("detail.odometerRegression.decisionLegend")}</legend>
<label className={`choice-card ${decision === "retain_canonical" ? "is-selected" : ""}`}>
<input
type="radio"
name="decision"
checked={decision === "retain_canonical"}
onChange={() => setDecision("retain_canonical")}
/>
<span className="choice-card-body">
<span className="choice-card-title">{t("detail.odometerRegression.retainCanonical")}</span>
<span className="choice-card-detail">{t("detail.odometerRegression.retainCanonicalDetail")}</span>
</span>
</label>
<label className={`choice-card ${decision === "correct_reading" ? "is-selected" : ""} ${bookingSnapshots.length === 0 ? "is-disabled" : ""}`}>
<input
type="radio"
name="decision"
checked={decision === "correct_reading"}
onChange={() => setDecision("correct_reading")}
disabled={bookingSnapshots.length === 0}
/>
<span className="choice-card-body">
<span className="choice-card-title">{t("detail.odometerRegression.correctReading")}</span>
<span className="choice-card-detail">{t("detail.odometerRegression.correctReadingDetail")}</span>
</span>
</label>
{bookingSnapshots.length === 0 && (
<p className="table-subtext">{t("detail.odometerRegression.noBookingAttached")}</p>
)}
</fieldset>
{decision === "correct_reading" && (
<div className="form-grid">
<label>
{t("detail.odometerRegression.booking")}
<select value={bookingRef} onChange={(e) => setBookingRef(e.target.value)}>
{bookingSnapshots.map((snap) => (
<option key={snap.public_ref} value={snap.public_ref}>
{snap.public_ref} ({typeof snap.end_odometer_km === "number" ? formatNumber(snap.end_odometer_km) : "—"} km)
</option>
))}
</select>
</label>
<label>
{t("detail.odometerRegression.correctedOdometer")}
<input
type="number"
min={0}
required
value={correctedValue}
onChange={(e) => setCorrectedValue(e.target.value)}
/>
</label>
</div>
)}
<label>
{t("detail.odometerRegression.note")}
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} />
</label>
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? t("detail.odometerRegression.resolving") : t("detail.odometerRegression.resolveIssue")}
</button>
</div>
</form>
);
}
function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { formatShortDate } = useLocaleFormat();
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<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/resolve-overlap`, {
booking_ref: bookingRef,
note: note || undefined,
});
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.bookingOverlap.resolveFailed"));
} finally {
setSubmitting(false);
}
}
return (
<form className="panel" onSubmit={handleSubmit} aria-labelledby="overlap-heading">
<SectionHeading
headingId="overlap-heading"
title={t("detail.bookingOverlap.heading")}
description={t("detail.bookingOverlap.description")}
/>
<ApiErrorNotice error={error} />
<fieldset className="choice-fieldset choice-fieldset-grid">
<legend className="visually-hidden">{t("detail.bookingOverlap.columns.blockThis")}</legend>
{bookings.map((b) => (
<label key={b.public_ref} className={`choice-card ${bookingRef === b.public_ref ? "is-selected" : ""}`}>
<input
type="radio"
name="overlap-booking"
checked={bookingRef === b.public_ref}
onChange={() => setBookingRef(b.public_ref)}
aria-label={`${t("detail.bookingOverlap.blockLabel", { ref: b.public_ref })}, ${
b.starts_at ? formatShortDate(String(b.starts_at)) : "—"
} → ${b.ends_at ? formatShortDate(String(b.ends_at)) : "—"}, ${b.status}`}
/>
<span className="choice-card-body">
<span className="choice-card-title">{b.public_ref}</span>
<span className="choice-card-detail">
{b.starts_at ? formatShortDate(String(b.starts_at)) : "—"} → {b.ends_at ? formatShortDate(String(b.ends_at)) : "—"}
</span>
<StatusBadge status={String(b.status)} label={t(`bookings:statuses.${b.status}`, { defaultValue: String(b.status) })} />
</span>
</label>
))}
</fieldset>
<label>
{t("detail.bookingOverlap.note")}
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={2} />
</label>
<div className="form-actions">
<button className="button button-primary" type="submit" disabled={submitting || !bookingRef}>
{submitting ? t("detail.bookingOverlap.resolving") : t("detail.bookingOverlap.blockButton", { ref: bookingRef || t("detail.bookingOverlap.blockButtonFallback") })}
</button>
</div>
</form>
);
}
function statusLabel(t: (key: string, opts?: Record<string, unknown>) => string, status: string): string {
const raw = t(`fleet:statuses.${status}`, { defaultValue: status });
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
function StatusRecommendationEvidence({ recommendation }: { recommendation: StatusRecommendation }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const facts = recommendation.facts;
const lines: string[] = [];
if (facts.active_booking_refs.length > 0) {
lines.push(
t("detail.vehicleStatusConflict.evidence.activeBookings", { refs: facts.active_booking_refs.join(", ") }),
);
}
if (facts.overlapping_booking_pairs.length > 0) {
lines.push(
t("detail.vehicleStatusConflict.evidence.overlappingBookings", {
pairs: facts.overlapping_booking_pairs.map((pair) => pair.join(" ↔ ")).join(", "),
}),
);
}
if (facts.service_threshold_reached) {
lines.push(
t("detail.vehicleStatusConflict.evidence.serviceThresholdReached", {
odometer: formatNumber(facts.odometer_km),
threshold: formatNumber(facts.next_service_km),
}),
);
}
if (facts.open_booking_overlap_issue_ref) {
lines.push(
t("detail.vehicleStatusConflict.evidence.openOverlapIssue", { ref: facts.open_booking_overlap_issue_ref }),
);
}
if (lines.length === 0) return null;
return (
<ul className="evidence-list">
{lines.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
);
}
function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const [recommendation, setRecommendation] = useState<StatusRecommendation | null>(null);
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
const [stale, setStale] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
const [submitting, setSubmitting] = useState(false);
function continueDemo() {
completeAndAdvance();
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
}
// Once resolved through this panel, keep showing the "Applied" confirmation even
// after the parent's issue.status flips away from "open" -- reloading on success
// updates the page's own status badge immediately, but this panel controls its own
// visibility via `result` rather than disappearing the instant the reload lands.
if (issue.status !== "open" && !result) return null;
async function loadRecommendation() {
setError(null);
setStale(false);
setLoadingRecommendation(true);
try {
const preview = await api.post<StatusRecommendation>(
`/api/v1/data-quality/issues/${issue.public_ref}/status-recommendation`,
);
setRecommendation(preview);
} catch (err) {
setError(describeApiError(t, err, "detail.vehicleStatusConflict.recommendationFailed"));
} finally {
setLoadingRecommendation(false);
}
}
async function handleApply() {
if (!recommendation) return;
setError(null);
setSubmitting(true);
try {
const applied = await api.post<ApplyRecommendedStatusResult>(
`/api/v1/data-quality/issues/${issue.public_ref}/apply-recommended-status`,
{ recommendation_token: recommendation.recommendation_token },
);
setResult(applied);
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.vehicleStatusConflict.applyFailed"));
if (err instanceof ApiError && err.code === "RECOMMENDATION_STALE") {
setRecommendation(null);
setStale(true);
}
} finally {
setSubmitting(false);
}
}
// safe_to_apply is only ever true alongside a non-null recommended_status, so it must
// not be used to detect "manual review needed" -- a genuine no_conflict recommendation
// also carries safe_to_apply: false (there is nothing to apply), and conflating the two
// would show "manual review required" for a vehicle that needs no attention at all.
const needsManualReview = recommendation !== null && recommendation.manual_review_required;
const noChangeNeeded = recommendation !== null && !needsManualReview && recommendation.recommended_status === null;
const hasSafeRecommendation =
recommendation !== null &&
!needsManualReview &&
recommendation.recommended_status !== null &&
recommendation.safe_to_apply;
return (
<section className="panel" aria-labelledby="status-conflict-heading" aria-live="polite">
<SectionHeading
headingId="status-conflict-heading"
title={t("detail.vehicleStatusConflict.heading")}
description={t("detail.vehicleStatusConflict.description")}
/>
<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>
{result ? (
<>
<p className="quiet-empty">
<Icon name="check" /> {t("detail.vehicleStatusConflict.applied", {
status: statusLabel(t, result.applied_status),
reason: t(`detail.vehicleStatusConflict.reasonCodes.${result.reason_code}`, {
defaultValue: result.reason_code,
}),
})}
</p>
<div className="result-links">
<Link className="button button-secondary" to="/audit">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>{t("detail.resolved.viewVehicle")}<Icon name="chevron" /></Link>
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
{t("detail.resolved.continueDemo")} <Icon name="chevron" />
</button>
)}
</div>
</>
) : !recommendation ? (
<button type="button" className="button button-primary" onClick={loadRecommendation} disabled={loadingRecommendation}>
{loadingRecommendation
? t("detail.vehicleStatusConflict.loadingRecommendation")
: t(stale ? "detail.vehicleStatusConflict.reviewAgain" : "detail.vehicleStatusConflict.reviewRecommendation")}
</button>
) : needsManualReview ? (
<div className="status-decision" role="status">
<h3>{t("detail.vehicleStatusConflict.manualReview.heading")}</h3>
<p>{t("detail.vehicleStatusConflict.manualReview.body")}</p>
<StatusRecommendationEvidence recommendation={recommendation} />
<p className="table-subtext">{t("detail.vehicleStatusConflict.manualReview.hint")}</p>
</div>
) : noChangeNeeded ? (
<div className="status-decision" role="status">
<h3>{t("detail.vehicleStatusConflict.noConflict.heading")}</h3>
<p>{t("detail.vehicleStatusConflict.noConflict.body")}</p>
</div>
) : hasSafeRecommendation && recommendation.recommended_status ? (
<div className="status-decision" role="group" aria-label={t("detail.vehicleStatusConflict.recommendedStatus")}>
<dl className="detail-grid">
<div>
<dt>{t("detail.vehicleStatusConflict.recommendedStatus")}</dt>
<dd>
<StatusBadge
status={recommendation.recommended_status}
label={t(`fleet:statuses.${recommendation.recommended_status}`, {
defaultValue: recommendation.recommended_status,
})}
/>
</dd>
</div>
</dl>
<h3>{t("detail.vehicleStatusConflict.whyHeading")}</h3>
<p>{t(`detail.vehicleStatusConflict.reasonCodes.${recommendation.recommendation_code}`, {
defaultValue: recommendation.recommendation_code,
})}</p>
<h3>{t("detail.vehicleStatusConflict.evidenceHeading")}</h3>
<StatusRecommendationEvidence recommendation={recommendation} />
<h3>{t("detail.vehicleStatusConflict.consequenceHeading")}</h3>
<ul className="evidence-list">
<li>{t("detail.vehicleStatusConflict.consequence.statusWillChange", {
status: statusLabel(t, recommendation.recommended_status),
})}</li>
<li>{t("detail.vehicleStatusConflict.consequence.issueWillBeRechecked")}</li>
<li>{t("detail.vehicleStatusConflict.consequence.changeWillBeAudited")}</li>
<li>{t("detail.vehicleStatusConflict.consequence.bookingsNotDeleted")}</li>
</ul>
<button type="button" className="button button-primary" onClick={handleApply} disabled={submitting}>
{submitting
? t("detail.vehicleStatusConflict.applying")
: t("detail.vehicleStatusConflict.changeStatusTo", {
status: statusLabel(t, recommendation.recommended_status),
})}
</button>
</div>
) : null}
</section>
);
}
export function DataQualityIssueDetail() {
const { t } = useTranslation("quality");
const { user } = useAuth();
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const { publicRef } = useParams<{ publicRef: string }>();
const [issue, setIssue] = useState<IssueDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [justResolved, setJustResolved] = useState(false);
const load = useCallback(() => {
if (!publicRef) return;
api
.get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`)
.then(setIssue)
.catch(() => setError(t("detail.notFound")));
}, [publicRef, t]);
useEffect(() => {
if (user?.role !== "operations_manager") return;
setIssue(null);
setError(null);
setJustResolved(false);
load();
}, [load, user]);
function handleResolved() {
setJustResolved(true);
load();
}
function continueDemo() {
completeAndAdvance();
const nextIndex = Math.min(currentIndex + 1, DEMO_GUIDE_STEPS.length - 1);
navigate(DEMO_GUIDE_STEPS[nextIndex].route(manifest));
}
async function handleAction(action: "defer" | "reject") {
if (!issue) return;
setActionError(null);
try {
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
load();
} catch (err) {
setActionError(describeApiError(t, err, `detail.deferOrReject.${action}Failed`));
}
}
if (user?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("detail.managerOnly")} />
<p>{t("detail.managerOnlyDetail")}</p>
</div>
);
}
if (error) return <ErrorState message={error} />;
if (!issue) return <LoadingState label={t("detail.loading")} />;
return (
<div className="page">
<Link className="back-link" to="/data-quality"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<PageHeader eyebrow={issue.public_ref} title={t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })} description={t("detail.title")} actions={<div className="status-stack"><SeverityBadge severity={issue.severity} /><StatusBadge status={issue.status} label={t(`list.status${issue.status.charAt(0).toUpperCase()}${issue.status.slice(1)}`, { defaultValue: issue.status })} /></div>} />
<section className="record-surface" aria-label={t("detail.summary.rule")}><dl className="detail-grid">
<div><dt>{t("detail.summary.rule")}</dt><dd>{t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })}</dd></div>
<div><dt>{t("detail.summary.entity")}</dt><dd>{issue.entity_type === "vehicle" ? <Link to={`/vehicles/${issue.entity_ref}`}>{issue.entity_ref}</Link> : issue.entity_ref}</dd></div>
</dl>
<div className="record-surface-evidence">
<dt>{t("detail.summary.evidenceSummary")}</dt>
<EvidenceSignalList issue={issue} />
</div>
<EvidenceDisclosure issue={issue} /></section>
<RuleExplainer ruleType={issue.rule_type} />
<ApiErrorNotice error={actionError} />
{justResolved && issue.status !== "open" && issue.rule_type !== "vehicle_status_conflict" && (
<section className="panel success-panel" aria-live="polite">
<div className="result-heading"><span><Icon name="check" /></span><div><p className="page-eyebrow">{t("list.statusResolved")}</p><h2>{t("detail.resolved.title", { ref: issue.public_ref })}</h2></div></div>
<p>{t("detail.resolved.body")}</p>
<div className="result-links">
<Link className="button button-secondary" to="/audit">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
{issue.entity_type === "vehicle" && (
<Link className="button button-secondary" to={`/vehicles/${issue.entity_ref}`}>{t("detail.resolved.viewVehicle")}<Icon name="chevron" /></Link>
)}
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
{t("detail.resolved.continueDemo")} <Icon name="chevron" />
</button>
)}
</div>
</section>
)}
{issue.status === "open" && issue.rule_type === "possible_duplicate_customer" && (
<DuplicateCustomerPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && issue.rule_type === "missing_required_field" && (
<MissingFieldPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && issue.rule_type === "odometer_regression" && (
<OdometerRegressionPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && issue.rule_type === "booking_overlap" && (
<BookingOverlapPanel issue={issue} onResolved={handleResolved} />
)}
{issue.rule_type === "vehicle_status_conflict" && (
<VehicleStatusConflictPanel issue={issue} onResolved={handleResolved} />
)}
{issue.status === "open" && (
<section className="panel defer-reject-panel" aria-labelledby="resolution-heading">
<h2 id="resolution-heading">{t("detail.deferOrReject.heading")}</h2>
<p>{t("detail.deferOrReject.description")}</p>
<div className="resolution-actions">
<button type="button" className="button-tertiary" onClick={() => handleAction("defer")}>
{t("detail.deferOrReject.defer")}
</button>
<button type="button" className="button-tertiary button-tertiary-destructive" onClick={() => handleAction("reject")}>
{t("detail.deferOrReject.reject")}
</button>
</div>
</section>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Icon, type IconName } from "../components/Icons";
import { PageHeader } from "../components/PageChrome";
import { useDemoGuide } from "../context/DemoGuideContext";
import { PRODUCT_NAME } from "../product";
const HIGHLIGHTS: Array<{ id: "return" | "quality" | "knowledge"; to: string; icon: IconName }> = [
{ id: "return", to: "/bookings/BK-DEMO-RETURN", icon: "bookings" },
{ id: "quality", to: "/data-quality/DQ-DEMO-DUPLICATE", icon: "quality" },
{ id: "knowledge", to: "/knowledge", icon: "knowledge" },
];
export function Highlights() {
const { t } = useTranslation("demo");
const { openGuide } = useDemoGuide();
return (
<div className="page recruiter-highlights">
<PageHeader
eyebrow={t("highlights.eyebrow")}
title={t("highlights.title")}
description={t("highlights.description", { productName: PRODUCT_NAME })}
/>
<ol className="highlight-grid">
{HIGHLIGHTS.map((highlight, index) => (
<li key={highlight.id} className="panel highlight-card">
<div className="highlight-card-top">
<span className="highlight-number">0{index + 1}</span>
<span className="highlight-duration"><Icon name="clock" /> {t("highlights.duration")}</span>
</div>
<span className="highlight-icon"><Icon name={highlight.icon} /></span>
<h2>{t(`highlights.${highlight.id}.title`)}</h2>
<p>{t(`highlights.${highlight.id}.body`)}</p>
<div className="highlight-proof">
<strong>{t("highlights.proofLabel")}</strong>
<span>{t(`highlights.${highlight.id}.proof`)}</span>
</div>
<Link className="button button-primary" to={highlight.to}>
{t("highlights.open")} <Icon name="chevron" />
</Link>
</li>
))}
</ol>
<section className="panel highlights-next">
<div>
<p className="page-eyebrow">{t("highlights.proofLabel")}</p>
<h2>{t("highlights.storyTitle")}</h2>
<p>{t("highlights.storyBody")}</p>
</div>
<div className="highlights-next-actions">
<Link className="button button-primary" to="/about">{t("highlights.storyButton")} <Icon name="chevron" /></Link>
<button type="button" className="button button-secondary" onClick={openGuide}><Icon name="spark" /> {t("highlights.fullTourButton")}</button>
</div>
</section>
</div>
);
}
+292
View File
@@ -0,0 +1,292 @@
import { useEffect, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
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 { ApiErrorNotice, PageHeader } from "../components/PageChrome";
import { useLocaleFormat } from "../i18n/format";
import { PRODUCT_NAME } from "../product";
interface Exchange {
question: string;
answer: GroundedAnswer;
durationMs: number;
feedback: boolean | null;
feedbackSaving: boolean;
}
function isOpaqueVersion(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(value);
}
function sourceDisplayTitle(title: string, t: (key: string, options?: Record<string, unknown>) => string): string {
const base = title.replace(/\.md$/i, "");
const fallback = base.split(/[-_]/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
return t(`sourceTitles.${base}`, { defaultValue: fallback });
}
export function Knowledge() {
const { t, i18n } = useTranslation(["knowledge", "errors"]);
const { formatDateTime } = useLocaleFormat();
const [status, setStatus] = useState<KnowledgeHealth | null>(null);
const [statusSettled, setStatusSettled] = useState(false);
const [question, setQuestion] = useState("");
const [submitting, setSubmitting] = useState(false);
const [loadingStage, setLoadingStage] = useState(0);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [exchanges, setExchanges] = useState<Exchange[]>([]);
const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE";
useEffect(() => {
setStatusSettled(false);
const controller = new AbortController();
api
.get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`, { signal: controller.signal })
.then(setStatus)
.catch(() => {
if (!controller.signal.aborted) setStatus(null);
})
.finally(() => {
if (!controller.signal.aborted) setStatusSettled(true);
});
return () => controller.abort();
}, [language]);
async function ask(questionText: string) {
setError(null);
setSubmitting(true);
setLoadingStage(0);
const sourceTimer = window.setTimeout(() => setLoadingStage(1), 900);
const evidenceTimer = window.setTimeout(() => setLoadingStage(2), 2600);
const startedAt = performance.now();
try {
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", {
question: questionText,
language,
});
setExchanges((previous) => [{
question: questionText,
answer,
durationMs: Math.round(performance.now() - startedAt),
feedback: null,
feedbackSaving: false,
}, ...previous]);
setQuestion("");
} catch (err) {
setError(describeApiError(t, err, "askFailed"));
} finally {
window.clearTimeout(sourceTimer);
window.clearTimeout(evidenceTimer);
setSubmitting(false);
}
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!question.trim()) return;
await ask(question);
}
async function sendFeedback(correlationId: string, helpful: boolean) {
setExchanges((previous) => previous.map((exchange) =>
exchange.answer.correlation_id === correlationId
? { ...exchange, feedbackSaving: true }
: exchange,
));
try {
await api.post("/api/v1/knowledge/feedback", { correlation_id: correlationId, helpful });
setExchanges((previous) => previous.map((exchange) =>
exchange.answer.correlation_id === correlationId
? { ...exchange, feedback: helpful, feedbackSaving: false }
: exchange,
));
} catch (err) {
setError(describeApiError(t, err, "feedbackFailed"));
setExchanges((previous) => previous.map((exchange) =>
exchange.answer.correlation_id === correlationId
? { ...exchange, feedbackSaving: false }
: exchange,
));
}
}
const providerLabel = !statusSettled
? t("providerChecking")
: status?.provider === "ragcore"
? t("providerRagcore")
: status?.provider === "demo"
? t("providerDemo")
: t("providerUnavailable");
const suggestedQuestions = t("suggestedQuestions", { returnObjects: true, defaultValue: [] }) as string[];
return (
<div className="page">
<PageHeader
eyebrow={t("eyebrow")}
title={t("title")}
description={t("description", { provider: providerLabel.toLowerCase() })}
/>
{!statusSettled && (
<div className="knowledge-status is-loading" role="status">
<span className="health-orb" />
<div><strong>{t("statusChecking")}</strong><span>{t("statusCheckingDetail")}</span></div>
</div>
)}
{statusSettled && status && (
<div className="knowledge-status">
<span className={`health-orb ${status.available ? "is-healthy" : "is-down"}`} />
<div>
<strong>{providerLabel}</strong>
<span>
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "}
{status.document_count === null
? t("proceduresAvailable", { count: status.source_document_count })
: t("proceduresIndexed", { count: status.document_count })}
</span>
</div>
<details className="knowledge-diagnostics"><summary>{t("technicalDetails")}</summary><small>{status.collection} · {status.detail}</small></details>
</div>
)}
{statusSettled && !status && (
<div className="knowledge-status" role="status">
<span className="health-orb is-down" />
<div><strong>{t("statusUnavailable")}</strong><span>{t("unavailableBody")}</span></div>
</div>
)}
{statusSettled && status?.provider === "demo" && (
<p className="knowledge-provider-note"><Icon name="shield" /> {t("providerNote")}</p>
)}
{statusSettled && status?.provider === "ragcore" && (
<div className="knowledge-index-evidence" role="status">
<p className="knowledge-statistics-scope">{t("statistics.scope")}</p>
<div><strong>{status.document_count ?? "—"}</strong><span>{t("statistics.verifiedIndexed")}</span></div>
<div><strong>{status.source_document_count}</strong><span>{t("statistics.sourceDocuments")}</span></div>
<div>
<strong>{status.reported_synced_document_count ?? "—"}</strong>
<span>{t("statistics.reportedSynced")}</span>
</div>
<div>
<strong>{status.reported_failed_document_count ?? "—"}</strong>
<span>{t("statistics.reportedFailed")}</span>
</div>
<p>
{status.last_sync_at
? t("statistics.lastSync", { when: formatDateTime(status.last_sync_at) })
: t("statistics.noSyncReport")}
</p>
</div>
)}
<form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading">
<div className="ask-heading">
<span><Icon name="spark" /></span>
<div><h2 id="ask-heading">{t("askHeading")}</h2><p>{t("askSubheading")}</p></div>
</div>
<label htmlFor="knowledge-question" className="visually-hidden">{t("questionLabel")}</label>
<div className="knowledge-input-row">
<input
id="knowledge-question"
type="text"
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder={t("questionPlaceholder")}
minLength={3}
maxLength={1000}
required
/>
<button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? t("asking") : t("ask")}{!submitting && <Icon name="chevron" />}
</button>
</div>
<ApiErrorNotice error={error} />
{submitting && <div className="knowledge-progress" role="status" aria-live="polite">
{["retrieving", "checking", "composing"].map((stage, index) => <span className={index <= loadingStage ? "is-active" : ""} key={stage}>{index < loadingStage ? <Icon name="check" /> : <i />}{t(`progress.${stage}`)}</span>)}
</div>}
<div className="knowledge-suggestions">
<span>{t("suggestedLabel")}</span>
{suggestedQuestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="suggestion-chip"
onClick={() => ask(suggestion)}
disabled={submitting}
>{suggestion}</button>
))}
</div>
</form>
{exchanges.length === 0 && !error && (
<div className="knowledge-empty">
<span><Icon name="knowledge" /></span>
<h2>{t("emptyTitle")}</h2>
<p>{t("emptyDescription", { productName: PRODUCT_NAME })}</p>
<div className="retrieval-flow" aria-hidden="true">
<span>{t("retrievalFlow.question")}</span><i />
<span>{providerLabel}</span><i />
<span>{t("retrievalFlow.sources")}</span><i />
<span>{t("retrievalFlow.answer")}</span>
</div>
</div>
)}
<ul className="exchange-list">
{exchanges.map((exchange) => (
<li key={exchange.answer.correlation_id} className="panel exchange">
<p className="exchange-question"><strong>{t("questionLabelExchange")}</strong> {exchange.question}</p>
<p className={`evidence-state evidence-${exchange.answer.evidence_state}`}>
{t(`evidenceStates.${exchange.answer.evidence_state}`)}
</p>
<p className="exchange-timing">
{t("answeredIn", { seconds: (exchange.durationMs / 1000).toFixed(1) })}
</p>
{exchange.answer.evidence_state === "unavailable"
? <p>{t("unavailableBody")}</p>
: <p>{exchange.answer.answer}</p>}
{exchange.answer.sources.length > 0 && (
<ul className="source-cards source-cards-collapsed">
{exchange.answer.sources.map((source, sourceIndex) => (
<li key={`${source.title}-${source.section}-${source.excerpt}`} className="source-card">
<details open={sourceIndex === 0}>
<summary>
<span className="source-number">{sourceIndex + 1}</span>
<span className="source-title">{sourceDisplayTitle(source.title, t)}</span>
{source.section && <span className="source-section">{source.section}</span>}
</summary>
<p className="source-excerpt">{source.excerpt}</p>
<p className="source-version">
{isOpaqueVersion(source.version)
? t("sourceTechnicalVersion")
: t("sourceVersion", { version: source.version })}
</p>
</details>
</li>
))}
</ul>
)}
<div className="knowledge-feedback" aria-label={t("feedbackLabel")}>
<span>{exchange.feedback === null ? t("feedbackPrompt") : t("feedbackThanks")}</span>
<button
type="button"
className={exchange.feedback === true ? "is-selected" : ""}
disabled={exchange.feedbackSaving}
aria-pressed={exchange.feedback === true}
onClick={() => sendFeedback(exchange.answer.correlation_id, true)}
>{t("feedbackHelpful")}</button>
<button
type="button"
className={exchange.feedback === false ? "is-selected" : ""}
disabled={exchange.feedbackSaving}
aria-pressed={exchange.feedback === false}
onClick={() => sendFeedback(exchange.answer.correlation_id, false)}
>{t("feedbackNotHelpful")}</button>
</div>
</li>
))}
</ul>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { LanguageSwitcher } from "../components/LanguageSwitcher";
import type { Role } from "../api/types";
import { BrandMark, Icon } from "../components/Icons";
import { PRODUCT_NAME } from "../product";
export function Login() {
const { t } = useTranslation(["auth", "common"]);
const { loginAs, loginWithPassword, loading, demoMode, oidcEnabled, oidcProviderName } = useAuth();
const {
manifest,
loading: manifestLoading,
error: manifestError,
refresh: refreshManifest,
} = useDemoManifest();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
async function handleLogin(role: Role, destination = "/dashboard") {
setError(null);
try {
await loginAs(role);
navigate(destination);
} catch {
setError(t("loginFailed"));
}
}
async function handlePasswordLogin(event: FormEvent) {
event.preventDefault();
setError(null);
try {
await loginWithPassword(email, password);
navigate("/dashboard");
} catch {
setError(t("passwordLoginFailed"));
}
}
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">
<section className="login-story" aria-labelledby="product-name">
<div className="brand-lockup login-brand">
<BrandMark className="brand-mark" />
<div><strong>{PRODUCT_NAME}</strong><span>{t("brandTagline")}</span></div>
</div>
<div className="login-message">
<p className="eyebrow">{t("orgLine", { orgName })}</p>
<h1 id="product-name">{t("headline1")}<br />{t("headline2")}</h1>
<p>{description}</p>
</div>
<div className="control-illustration" aria-hidden="true">
<div className="illustration-orbit orbit-one"><span /></div>
<div className="illustration-orbit orbit-two"><span /></div>
<div className="illustration-core"><BrandMark /></div>
<span className="illustration-node node-one"><Icon name="fleet" /></span>
<span className="illustration-node node-two"><Icon name="bookings" /></span>
<span className="illustration-node node-three"><Icon name="quality" /></span>
</div>
<p className="login-footnote"><Icon name="shield" /> {t("footnote")}</p>
</section>
<section className="login-access" aria-labelledby="login-heading">
<div className="login-panel">
<div className="login-panel-top">
<p className="page-eyebrow">{t("accessEyebrow")}</p>
<LanguageSwitcher />
</div>
<h2 id="login-heading">{t("accessHeading")}</h2>
<p className="login-intro">{t("accessIntro")}</p>
{error && <p className="error" role="alert">{error}</p>}
{demoMode ? <><button
type="button"
className="button button-primary login-guide-cta"
disabled={loading}
onClick={() => handleLogin("operations_manager", "/highlights")}
>
<Icon name="spark" />
{t("startRecruiterTour")}
</button>
<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>
<span><strong>{t("exploreAsOperationsManager")}</strong><small>{t("exploreAsOperationsManagerDetail")}</small></span>
<Icon name="chevron" />
</button>
<button type="button" aria-label={t("exploreAsRentalEmployee")} disabled={loading} onClick={() => handleLogin("rental_employee")}>
<span className="role-icon"><Icon name="user" /></span>
<span><strong>{t("exploreAsRentalEmployee")}</strong><small>{t("exploreAsRentalEmployeeDetail")}</small></span>
<Icon name="chevron" />
</button>
</div></> : <form className="login-options" onSubmit={handlePasswordLogin}>
<label>{t("emailLabel")}<input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} required /></label>
<label>{t("passwordLabel")}<input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} minLength={8} required /></label>
<button type="submit" className="button button-primary" disabled={loading}>{t("signIn")}</button>
</form>}
{oidcEnabled && <>
<div className="login-divider"><span>{t("or")}</span></div>
<a className="button login-oidc" href="/api/v1/auth/oidc/login">
<Icon name="shield" />
{t("signInWithOrganization", { provider: oidcProviderName })}
</a>
</>}
<div className="access-note"><Icon name="shield" /><p><strong>{t("safeByDesignTitle")}</strong><span>{t("safeByDesignDetail")}</span></p></div>
</div>
</section>
</main>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { CustomerAnonymizeResult, PrivacyRetention } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
export function Privacy() {
const { t } = useTranslation("privacy");
const { user } = useAuth();
const [retention, setRetention] = useState<PrivacyRetention | null>(null);
const [customerRef, setCustomerRef] = useState("");
const [confirmation, setConfirmation] = useState("");
const [reason, setReason] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState(false);
const [working, setWorking] = useState(false);
const loadRetention = useCallback(() => {
setError(false);
api.get<PrivacyRetention>("/api/v1/privacy/retention").then(setRetention).catch(() => setError(true));
}, []);
useEffect(loadRetention, [loadRetention]);
async function anonymize(event: FormEvent) {
event.preventDefault();
setWorking(true);
setMessage(null);
setError(false);
try {
const result = await api.post<CustomerAnonymizeResult>(
`/api/v1/privacy/customers/${encodeURIComponent(customerRef)}/anonymize`,
{ confirmation, reason },
);
setMessage(t(`anonymize.${result.status}`, { ref: result.public_ref }));
setConfirmation("");
setReason("");
loadRetention();
} catch {
setError(true);
} finally {
setWorking(false);
}
}
if (user?.role !== "operations_manager") return <div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} />
</div>;
return <div className="page privacy-page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
{error && <ErrorState message={t("error")} />}
{!retention && !error && <LoadingState label={t("loading")} />}
{retention && <>
<section className="privacy-metrics" aria-label={t("policyTitle")}>
<article><span>{t("totalCustomers")}</span><strong>{retention.customers_total}</strong></article>
<article><span>{t("eligibleCustomers")}</span><strong>{retention.customers_eligible}</strong></article>
<article><span>{t("anonymizedCustomers")}</span><strong>{retention.customers_anonymized}</strong></article>
<article><span>{t("bookingRetention")}</span><strong>{t("days", { count: retention.minimum_booking_retention_days })}</strong></article>
<article><span>{t("auditRetention")}</span><strong>{t("days", { count: retention.audit_retention_days })}</strong></article>
</section>
<div className="privacy-workspaces">
<section className="panel privacy-panel">
<h2>{t("exports.title")}</h2><p>{t("exports.detail")}</p>
<label>{t("customerRef")}<input value={customerRef} onChange={(event) => setCustomerRef(event.target.value.toUpperCase())} placeholder="CUS-0001" /></label>
<div className="form-actions-inline">
<a className={`button ${customerRef ? "" : "is-disabled"}`} aria-disabled={!customerRef} href={customerRef ? `/api/v1/privacy/customers/${encodeURIComponent(customerRef)}/export` : undefined}>{t("exports.customer")}</a>
<a className="button" href="/api/v1/audit/export.csv">{t("exports.audit")}</a>
</div>
</section>
<form className="panel privacy-panel" onSubmit={anonymize}>
<h2>{t("anonymize.title")}</h2><p>{t("anonymize.detail", { days: retention.minimum_booking_retention_days })}</p>
<label>{t("customerRef")}<input value={customerRef} onChange={(event) => setCustomerRef(event.target.value.toUpperCase())} placeholder="CUS-0001" required /></label>
<label>{t("anonymize.reason")}<textarea value={reason} onChange={(event) => setReason(event.target.value)} minLength={8} required /></label>
<label>{t("anonymize.confirmation", { ref: customerRef || "CUS-…" })}<input value={confirmation} onChange={(event) => setConfirmation(event.target.value.toUpperCase())} required /></label>
<button className="button button-danger" disabled={working || confirmation !== customerRef || !customerRef}>{working ? t("anonymize.working") : t("anonymize.submit")}</button>
</form>
</div>
{message && <p className="success" role="status">{message}</p>}
</>}
</div>;
}
+76
View File
@@ -0,0 +1,76 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
export function Scenarios() {
const { t } = useTranslation("demo");
const { manifest, loading, error, refresh } = useDemoManifest();
const { user } = useAuth();
function roleLabel(roles: string[]): string {
const labels = roles.map((r) => t(`scenarios.roles.${r}`, { defaultValue: r }));
return labels.length > 1 ? t("scenarios.roleOr", { a: labels[0], b: labels[1] }) : labels[0];
}
return (
<div className="page">
<PageHeader
eyebrow={t("scenarios.eyebrow")}
title={t("scenarios.title")}
description={t("scenarios.description")}
/>
{loading && <LoadingState label={t("scenarios.loading")} />}
{!loading && error && <ErrorState message={t("scenarios.unavailable")} onRetry={refresh} />}
{manifest && (
<div className="scenario-grid">
{manifest.scenarios.map((scenario) => {
const canRun = !user || scenario.required_roles.includes(user.role);
const blockedText = scenario.blocked_reason_code
? t(`scenarios.blockedReasons.${scenario.blocked_reason_code}`, {
resetHint: t("scenarios.blockedReasons.resetHint"),
...scenario.blocked_reason_params,
})
: null;
return (
<article key={scenario.id} className="scenario-card">
<header>
<h2>{t(`scenarios.items.${scenario.id}.title`)}</h2>
<span className={`badge ${scenario.ready ? "status-available" : "status-unavailable"}`}>
{scenario.ready ? t("scenarios.ready") : t("scenarios.notReady")}
</span>
</header>
<p className="scenario-problem">{t(`scenarios.items.${scenario.id}.problem`)}</p>
<dl className="scenario-meta">
<div><dt>{t("scenarios.duration")}</dt><dd>{t("scenarios.durationValue", { minutes: scenario.estimated_minutes })}</dd></div>
<div><dt>{t("scenarios.role")}</dt><dd>{roleLabel(scenario.required_roles)}</dd></div>
</dl>
<p className="scenario-demonstrates"><strong>{t("scenarios.demonstrates")}</strong> {t(`scenarios.items.${scenario.id}.demonstrates`)}</p>
{!scenario.ready && blockedText && (
<p className="scenario-blocked"><Icon name="alert" /> {blockedText}</p>
)}
{!canRun && (
<p className="scenario-blocked"><Icon name="alert" /> {t("scenarios.requiresRole", { roles: roleLabel(scenario.required_roles) })}</p>
)}
<Link
className={`button ${scenario.ready && canRun ? "button-primary" : "button-secondary"}`}
to={scenario.start_path}
aria-disabled={!scenario.ready || !canRun}
onClick={(event) => {
if (!scenario.ready || !canRun) event.preventDefault();
}}
>
{t("scenarios.startScenario")} <Icon name="chevron" />
</Link>
</article>
);
})}
</div>
)}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { Role, UserRecord } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { ApiErrorNotice, LoadingState, PageHeader } from "../components/PageChrome";
export function Users() {
const { t } = useTranslation(["operations", "errors"]);
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<UserRecord[] | null>(null);
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("rental_employee");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [editingRef, setEditingRef] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editRole, setEditRole] = useState<Role>("rental_employee");
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)));
}, [currentUser?.role, t]);
useEffect(load, [load]);
async function create(event: FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
try {
await api.post<UserRecord>("/api/v1/users", {
email,
display_name: displayName,
password,
role,
});
setDisplayName("");
setEmail("");
setPassword("");
setRole("rental_employee");
load();
} catch (err) {
setError(describeApiError(t, err, "operations:users.createFailed"));
} finally {
setSaving(false);
}
}
function beginEdit(record: UserRecord) {
setEditingRef(record.public_ref);
setEditName(record.display_name);
setEditRole(record.role);
setNewPassword("");
}
async function saveEdit(event: FormEvent) {
event.preventDefault();
if (!editingRef) return;
setSaving(true);
setError(null);
try {
await api.patch(`/api/v1/users/${editingRef}`, {
display_name: editName,
role: editRole,
password: newPassword || undefined,
});
setEditingRef(null);
setNewPassword("");
load();
} catch (err) {
setError(describeApiError(t, err, "operations:users.updateFailed"));
} finally {
setSaving(false);
}
}
async function toggleActive(record: UserRecord) {
setError(null);
try {
await api.patch(`/api/v1/users/${record.public_ref}`, { active: !record.active });
load();
} catch (err) {
setError(describeApiError(t, err, "operations:users.updateFailed"));
}
}
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")} />
<ApiErrorNotice error={error} />
<section className="record-surface user-create">
<h2>{t("users.addTitle")}</h2>
<form onSubmit={create}>
<div className="form-grid">
<label>{t("users.name")}<input required minLength={2} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>
<label>{t("users.email")}<input required type="email" value={email} onChange={(event) => setEmail(event.target.value)} /></label>
<label>{t("users.role")}<select value={role} onChange={(event) => setRole(event.target.value as Role)}><option value="rental_employee">{t("roles.rental_employee")}</option><option value="operations_manager">{t("roles.operations_manager")}</option></select></label>
<label>{t("users.password")}<input required type="password" minLength={8} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
</div>
<div className="form-actions"><button className="button button-primary" disabled={saving}>{saving ? t("users.saving") : t("users.add")}</button></div>
</form>
</section>
{!users && <LoadingState label={t("users.loading")} />}
{users && (
<div className="table-shell">
<table className="data-table">
<caption className="visually-hidden">{t("users.title")}</caption>
<thead><tr><th>{t("users.name")}</th><th>{t("users.email")}</th><th>{t("users.role")}</th><th>{t("users.status")}</th><th>{t("users.action")}</th></tr></thead>
<tbody>
{users.map((record) => (
<tr key={record.public_ref}>
<th>{record.display_name}<span className="table-secondary">{record.public_ref}</span></th>
<td>{record.email ?? "—"}</td>
<td>{t(`roles.${record.role}`)}</td>
<td><span className={`badge ${record.active ? "status-available" : "status-blocked"}`}>{t(record.active ? "users.active" : "users.inactive")}</span></td>
<td><div className="table-actions"><button type="button" className="button button-secondary" onClick={() => beginEdit(record)}>{t("users.edit")}</button><button type="button" className="button button-secondary" disabled={record.public_ref === currentUser?.public_ref} onClick={() => toggleActive(record)}>{t(record.active ? "users.deactivate" : "users.activate")}</button></div></td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editingRef && (
<section className="record-surface user-edit" aria-labelledby="edit-user-title">
<h2 id="edit-user-title">{t("users.editTitle", { ref: editingRef })}</h2>
<form onSubmit={saveEdit}>
<div className="form-grid">
<label>{t("users.name")}<input required minLength={2} value={editName} onChange={(event) => setEditName(event.target.value)} /></label>
<label>{t("users.role")}<select value={editRole} disabled={editingRef === currentUser?.public_ref} onChange={(event) => setEditRole(event.target.value as Role)}><option value="rental_employee">{t("roles.rental_employee")}</option><option value="operations_manager">{t("roles.operations_manager")}</option></select></label>
<label>{t("users.newPassword")}<input type="password" minLength={8} autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} placeholder={t("users.passwordUnchanged")} /></label>
</div>
<div className="form-actions"><button className="button button-primary" disabled={saving}>{saving ? t("users.saving") : t("users.saveChanges")}</button><button type="button" className="button button-secondary" onClick={() => setEditingRef(null)}>{t("users.cancel")}</button></div>
</form>
</section>
)}
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { VehicleDetail as VehicleDetailData } from "../api/types";
import { useLocaleFormat } from "../i18n/format";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { VehicleMaintenanceActions } from "../components/VehicleMaintenanceActions";
import { useAuth } from "../context/AuthContext";
const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const;
type Tab = (typeof TABS)[number];
export function VehicleDetail() {
const { t } = useTranslation(["fleet", "bookings", "quality"]);
const { formatNumber, formatShortDate } = useLocaleFormat();
const { publicRef } = useParams<{ publicRef: string }>();
const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null);
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;
setError(null);
api
.get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`)
.then(setVehicle)
.catch(() => setError(t("detail.notFound")));
}, [publicRef, t]);
useEffect(() => { setVehicle(null); load(); }, [load]);
if (error) return <ErrorState message={error} />;
if (!vehicle) return <LoadingState label={t("detail.loading")} />;
return (
<div className="page">
<Link className="back-link" to="/vehicles"><Icon name="arrow-left" /> {t("detail.backLink")}</Link>
<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">
{visibleTabs.map((tb) => (
<button
key={tb}
role="tab"
type="button"
aria-selected={tab === tb}
className={tab === tb ? "active" : ""}
onClick={() => setTab(tb)}
>
{t(`detail.tabs.${tb}`)}
</button>
))}
</div>
{tab === "overview" && (
<section className="record-surface" aria-label={t("detail.tabs.overview")}><dl className="detail-grid">
<div><dt>{t("detail.overview.registration")}</dt><dd>{vehicle.registration_number}</dd></div>
<div><dt>{t("detail.overview.modelYear")}</dt><dd>{vehicle.model_year}</dd></div>
<div><dt>{t("detail.overview.location")}</dt><dd>{vehicle.location}</dd></div>
<div><dt>{t("detail.overview.odometer")}</dt><dd>{formatNumber(vehicle.odometer_km)} km</dd></div>
<div><dt>{t("detail.overview.nextService")}</dt><dd>{formatNumber(vehicle.next_service_km)} km</dd></div>
<div><dt>{t("detail.overview.active")}</dt><dd>{vehicle.active ? t("detail.yes") : t("detail.no")}</dd></div>
</dl></section>
)}
{tab === "bookings" && (
<ul className="record-list">
{vehicle.bookings.length === 0 && <li>{t("detail.noBookings")}</li>}
{vehicle.bookings.map((b) => (
<li key={b.public_ref}>
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
<StatusBadge status={b.status} label={t(`bookings:statuses.${b.status}`, { defaultValue: b.status })} />
<span>
{formatShortDate(b.starts_at)} → {formatShortDate(b.ends_at)}
</span>
</li>
))}
</ul>
)}
{tab === "inspections" && (
<ul className="record-list">
{vehicle.inspections.length === 0 && <li>{t("detail.noInspections")}</li>}
{vehicle.inspections.map((i) => (
<li key={i.public_ref}>
<span>{t(`fleet:detail.inspectionTypes.${i.type}`, { defaultValue: i.type })}</span>
<span>{formatNumber(i.odometer_km)} km</span>
<span>{t("detail.fuel", { percent: i.fuel_level_percent })}</span>
{i.damage_reported && <span className="badge severity-high">{t("detail.damage")}</span>}
{i.technical_warning && <span className="badge severity-high">{t("detail.technicalWarning")}</span>}
<time dateTime={i.completed_at}>{formatShortDate(i.completed_at)}</time>
</li>
))}
</ul>
)}
{tab === "maintenance" && (
<><ul className="record-list">
{vehicle.maintenance.length === 0 && <li>{t("detail.noMaintenance")}</li>}
{vehicle.maintenance.map((m) => (
<li key={m.public_ref}>
<span>{t(`fleet:detail.maintenanceCategories.${m.category}`, { defaultValue: m.category })}</span>
<span>{formatNumber(m.odometer_km)} km</span>
<time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time>
</li>
))}
</ul>{user?.role === "operations_manager" && <VehicleMaintenanceActions vehicle={vehicle} onSaved={load} />}</>
)}
{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) => (
<li key={q.public_ref}>
<Link to={`/data-quality/${q.public_ref}`}>{q.public_ref}</Link>
<SeverityBadge severity={q.severity} />
<span>{t(`quality:ruleTypes.${q.rule_type}`, { defaultValue: q.rule_type.replace(/_/g, " ") })}</span>
<StatusBadge status={q.status} label={t(`quality:list.status${q.status.charAt(0).toUpperCase()}${q.status.slice(1)}`, { defaultValue: q.status })} />
</li>
))}
</ul>
)}
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import type { Page, Vehicle } from "../api/types";
import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge";
import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
export function Vehicles() {
const { t } = useTranslation("fleet");
const { formatDateTime, formatNumber } = useLocaleFormat();
const [searchParams, setSearchParams] = useSearchParams();
const [vehicles, setVehicles] = useState<Page<Vehicle> | null>(null);
const [error, setError] = useState<string | null>(null);
const status = searchParams.get("status") ?? "";
const attentionOnly = searchParams.get("attention_only") === "true";
const query = searchParams.get("q") ?? "";
const location = searchParams.get("location") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === "" || value === false) next.delete(key);
else next.set(key, String(value));
});
setSearchParams(next, { replace: true });
}
useEffect(() => {
setVehicles(null);
setError(null);
const params = new URLSearchParams();
if (status) params.set("status", status);
if (attentionOnly) params.set("attention_only", "true");
if (query) params.set("query", query);
if (location) params.set("location", location);
params.set("page", String(page));
params.set("page_size", "25");
const controller = new AbortController();
api
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`, { signal: controller.signal })
.then(setVehicles)
.catch(() => {
if (!controller.signal.aborted) setError(t("list.unavailable"));
});
return () => controller.abort();
}, [status, attentionOnly, query, location, page, t]);
return (
<div className="page">
<PageHeader eyebrow={t("list.eyebrow")} title={t("list.title")} description={t("list.description")} />
<form className="filters" aria-label={t("list.title")}>
<label>
{t("list.searchLabel")}
<input type="search" value={query} onChange={(e) => updateFilters({ q: e.target.value, page: 1 })} placeholder={t("list.searchPlaceholder")} />
</label>
<label>
{t("list.statusLabel")}
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
<option value="">{t("list.statusAll")}</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{t(`statuses.${s}`)}
</option>
))}
</select>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={attentionOnly}
onChange={(e) => updateFilters({ attention_only: e.target.checked, page: 1 })}
/>
{t("list.attentionOnly")}
</label>
<label>{t("list.locationFilter")}<input value={location} onChange={(event) => updateFilters({ location: event.target.value, page: 1 })} placeholder={t("list.locationPlaceholder")} /></label>
</form>
{error && <ErrorState message={error} />}
{!error && !vehicles && <LoadingState label={t("list.loading")} />}
{vehicles && vehicles.items.length === 0 && <EmptyState icon={query ? "search" : "fleet"} title={query ? t("list.noMatch") : t("list.empty")} detail={query ? t("list.noMatchDetail") : t("list.emptyDetail")} />}
{vehicles && vehicles.items.length > 0 && <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: vehicles.total })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
<th scope="col">{t("list.columns.reference")}</th>
<th scope="col">{t("list.columns.makeModel")}</th>
<th scope="col">{t("list.columns.location")}</th>
<th scope="col">{t("list.columns.status")}</th>
<th scope="col">{t("list.columns.odometer")}</th>
<th scope="col">{t("list.columns.service")}</th>
<th scope="col">{t("list.columns.nextBooking")}</th>
<th scope="col">{t("list.columns.attention")}</th>
</tr>
</thead>
<tbody>
{vehicles.items.map((v) => (
<tr key={v.public_ref} className={`row-clickable ${v.attention ? "row-attention" : ""}`}>
<th scope="row" data-label={t("list.columns.reference")}>
{v.public_ref}
<Link className="row-link" to={`/vehicles/${v.public_ref}`}><span className="visually-hidden">{v.public_ref}</span></Link>
</th>
<td data-label={t("list.columns.makeModel")}>
{v.make} {v.model} ({v.model_year})
</td>
<td data-label={t("list.columns.location")}>{v.location}</td>
<td data-label={t("list.columns.status")}>
<StatusBadge status={v.operational_status} label={t(`statuses.${v.operational_status}`, { defaultValue: v.operational_status })} />
</td>
<td data-label={t("list.columns.odometer")}>{formatNumber(v.odometer_km)}</td>
<td data-label={t("list.columns.service")} className={v.service_remaining_km <= 0 ? "is-overdue" : ""}>{v.service_remaining_km <= 0 ? t("list.serviceDue") : t("list.serviceRemaining", { count: formatNumber(v.service_remaining_km) })}</td>
<td data-label={t("list.columns.nextBooking")}>{v.next_booking_ref && v.next_booking_at ? <Link className="cell-link" to={`/bookings/${v.next_booking_ref}`}>{v.next_booking_ref}<span className="table-secondary">{formatDateTime(v.next_booking_at)}</span></Link> : "—"}</td>
<td data-label={t("list.columns.attention")}>{v.attention && v.attention_reason ? <span className="attention-flag">{t(`list.attentionReasons.${v.attention_reason}`)}</span> : "—"}</td>
</tr>
))}
</tbody>
</table><Pagination page={vehicles.page} totalPages={vehicles.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} /></div>}
</div>
);
}
@@ -0,0 +1,215 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../api/client";
import { describeApiError, type ApiErrorInfo } from "../../api/errorMessages";
import type { DataQualityIssueDetail as IssueDetail, EntitySnapshot } from "../../api/types";
import { ApiErrorNotice, SectionHeading } from "../../components/PageChrome";
import { useAuth } from "../../context/AuthContext";
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
export function DuplicateCustomerPanel({
issue,
onResolved,
}: {
issue: IssueDetail;
onResolved: () => void;
}) {
const { t } = useTranslation("quality");
const { user } = useAuth();
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
const [fieldChoices, setFieldChoices] = useState<Record<string, "a" | "b">>({});
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [confirming, setConfirming] = useState(false);
const [showMatching, setShowMatching] = useState(false);
if (!issue.entity_snapshot || !issue.related_snapshots[0]) {
return <p className="error">{t("detail.duplicateCustomer.bothMissing")}</p>;
}
const a: EntitySnapshot = issue.entity_snapshot;
const b: EntitySnapshot = issue.related_snapshots[0];
const survivor = survivorRef === a.public_ref ? a : b;
const loser = survivorRef === a.public_ref ? b : a;
const conflictingFields = MERGE_FIELDS.filter(
(field) => String(a[field] ?? "") !== String(b[field] ?? ""),
);
const matchingFields = MERGE_FIELDS.filter((field) => !conflictingFields.includes(field));
const visibleFields = showMatching ? MERGE_FIELDS : conflictingFields;
async function handleMerge() {
setError(null);
setSubmitting(true);
try {
const overrides: Record<string, string> = {};
for (const field of MERGE_FIELDS) {
const choice = fieldChoices[field];
const chosenSide = choice === "a" ? a : choice === "b" ? b : survivor;
if (chosenSide !== survivor && chosenSide[field]) {
overrides[field] = String(chosenSide[field]);
}
}
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/merge-customers`, {
survivor_ref: survivor.public_ref,
field_overrides: Object.keys(overrides).length > 0 ? overrides : undefined,
});
onResolved();
} catch (err) {
setError(describeApiError(t, err, "detail.duplicateCustomer.mergeFailed"));
setConfirming(false);
} finally {
setSubmitting(false);
}
}
if (user?.role !== "operations_manager") {
return <p className="panel">{t("detail.managerOnlyDetail")}</p>;
}
return (
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
<SectionHeading
headingId="compare-heading"
title={t("detail.duplicateCustomer.heading")}
description={t("detail.duplicateCustomer.description")}
/>
<ApiErrorNotice error={error} />
<p className="merge-summary-counts">
{t("detail.duplicateCustomer.summaryCounts", {
matchCount: matchingFields.length,
conflictCount: conflictingFields.length,
})}
</p>
<fieldset className="choice-fieldset">
<legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
{[a, b].map((candidate) => (
<label
key={candidate.public_ref}
className={`choice-card ${survivorRef === candidate.public_ref ? "is-selected" : ""}`}
>
<input
type="radio"
name="survivor"
checked={survivorRef === candidate.public_ref}
onChange={() => setSurvivorRef(candidate.public_ref)}
/>
<span className="choice-card-title">{candidate.public_ref}</span>
</label>
))}
</fieldset>
<table className="data-table compare-table">
<thead>
<tr>
<th scope="col">{t("detail.duplicateCustomer.fieldColumn")}</th>
<th scope="col">{a.public_ref}</th>
<th scope="col">{b.public_ref}</th>
</tr>
</thead>
<tbody>
{visibleFields.map((field) => {
const valueA = a[field] ? String(a[field]) : "—";
const valueB = b[field] ? String(b[field]) : "—";
const differ = valueA !== valueB;
return (
<tr key={field}>
<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>
{([a, b] as const).map((candidate, index) => {
const value = index === 0 ? valueA : valueB;
const choice = index === 0 ? "a" : "b";
return (
<td key={candidate.public_ref} data-label={candidate.public_ref}>
{differ ? (
<label className="checkbox-label">
<input
type="radio"
name={`field-${field}`}
checked={(fieldChoices[field] ?? "a") === choice}
onChange={() =>
setFieldChoices((current) => ({ ...current, [field]: choice }))
}
/>
{value}
</label>
) : (
value
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
{matchingFields.length > 0 && (
<button
type="button"
className="button button-tertiary toggle-matching-fields"
onClick={() => setShowMatching((value) => !value)}
>
{showMatching
? t("detail.duplicateCustomer.hideMatchingFields")
: t("detail.duplicateCustomer.showMatchingFields")}
</button>
)}
<p className="merge-preview">
{t("detail.duplicateCustomer.mergePreview", {
loser: loser.public_ref,
survivor: survivor.public_ref,
})}
</p>
<div className="merge-record-preview">
<p className="merge-record-preview-title">
{t("detail.duplicateCustomer.previewTitle", { ref: survivor.public_ref })}
</p>
<dl>
{MERGE_FIELDS.map((field) => {
const choice = fieldChoices[field];
const chosenSide = choice === "a" ? a : choice === "b" ? b : survivor;
const value = chosenSide[field] || survivor[field] || "—";
return (
<div key={field}>
<dt>{t(`detail.duplicateCustomer.fields.${field}`)}</dt>
<dd>{String(value)}</dd>
</div>
);
})}
</dl>
</div>
{!confirming ? (
<button type="button" className="button button-primary" onClick={() => setConfirming(true)}>
{t("detail.duplicateCustomer.mergeInto", { ref: survivor.public_ref })}
</button>
) : (
<div
className="confirm-bar"
role="alertdialog"
aria-label={t("detail.duplicateCustomer.confirmMergeTitle")}
>
<p>
{t("detail.duplicateCustomer.confirmMergeBody", {
loser: loser.public_ref,
survivor: survivor.public_ref,
})}
</p>
<button type="button" className="button button-primary" onClick={handleMerge} disabled={submitting}>
{submitting
? t("detail.duplicateCustomer.merging")
: t("detail.duplicateCustomer.confirmMergeYes")}
</button>
<button type="button" className="button button-secondary" onClick={() => setConfirming(false)} disabled={submitting}>
{t("list.cancel")}
</button>
</div>
)}
</section>
);
}
+5
View File
@@ -0,0 +1,5 @@
// The visible product name is a fixed brand constant, never a translatable string: it
// must read identically ("Fleet Ops") in every supported locale. Keeping it out of the
// i18n resource files makes that a structural guarantee rather than a convention a
// translator could accidentally break by editing one locale's JSON.
export const PRODUCT_NAME = "Fleet Ops";
+77
View File
@@ -0,0 +1,77 @@
.architecture-story { margin-bottom: 26px; padding: 0; overflow: hidden; }
.architecture-explorer { padding: 18px 22px 22px; background: linear-gradient(180deg, #fff 0%, #fbfcfd 100%); }
.architecture-zones { display: grid; grid-template-columns: 1fr 3fr 1fr; gap: 12px; margin-bottom: 9px; }
.architecture-zones > span { min-height: 31px; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; font-size: .75rem; font-weight: 800; letter-spacing: .055em; text-transform: uppercase; }
.architecture-zones svg { width: 13px; height: 13px; }
.architecture-zones .zone-intent { background: var(--surface-subtle); }
.architecture-zones .zone-transaction { color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; }
.architecture-zones .zone-edge { color: var(--info); background: var(--info-pale); border-color: #cfe3ee; }
.architecture-flow { list-style: none; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin: 0; padding: 0; }
.architecture-step { position: relative; min-width: 0; }
.architecture-step button { position: relative; width: 100%; min-height: 164px; height: 100%; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; padding: 14px; overflow: hidden; color: var(--ink); text-align: left; background: white; border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer; transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease, background .18s ease; }
.architecture-step button::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--line-strong); transition: width .18s ease, background .18s ease; }
.architecture-step button:hover { border-color: #9fd5ce; box-shadow: 0 10px 24px rgba(15, 23, 42, .07); transform: translateY(-2px); }
.architecture-step button:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
.architecture-step.is-active button { background: #fbfffe; border-color: var(--teal); box-shadow: 0 12px 28px rgba(11, 111, 103, .11); transform: translateY(-2px); }
.architecture-step.is-active button::before { width: 4px; background: var(--teal); }
.architecture-step-edge.is-active button { background: #fbfdff; border-color: var(--info); box-shadow: 0 12px 28px rgba(53, 100, 128, .11); }
.architecture-step-edge.is-active button::before { background: var(--info); }
.architecture-step-top { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.architecture-step-number { color: var(--teal-dark); font-size: .75rem; font-weight: 850; letter-spacing: .1em; }
.architecture-step-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }
.architecture-step-icon svg { width: 15px; height: 15px; }
.architecture-step button > strong { font-size: .875rem; line-height: 1.35; }
.architecture-step button > small { color: var(--muted); font-size: .8125rem; line-height: 1.45; }
.architecture-step-meta { width: 100%; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 5px; margin-top: auto; }
.architecture-zone-tag { color: var(--muted); font-size: .75rem; font-weight: 800; letter-spacing: .045em; text-transform: uppercase; }
.architecture-commit-note { padding: 3px 6px; color: var(--info); background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: 999px; font-size: .75rem; font-weight: 800; white-space: nowrap; }
.architecture-connector { position: absolute; z-index: 4; top: calc(50% - 1px); left: 100%; width: 12px; height: 2px; background: var(--line-strong); pointer-events: none; }
.architecture-connector::after { content: ""; position: absolute; top: -3px; right: -1px; width: 7px; height: 7px; border-top: 2px solid var(--line-strong); border-right: 2px solid var(--line-strong); transform: rotate(45deg); }
.architecture-signal { position: absolute; z-index: 2; top: -2px; left: -2px; width: 6px; height: 6px; background: var(--teal); border-radius: 50%; box-shadow: 0 0 0 3px rgba(15, 143, 131, .12); animation: architecture-signal-x 2.4s ease-in-out infinite; }
.architecture-step:nth-child(2) .architecture-signal { animation-delay: .35s; }
.architecture-step:nth-child(3) .architecture-signal { animation-delay: .7s; }
.architecture-step:nth-child(4) .architecture-signal { animation-delay: 1.05s; }
.architecture-connector.is-commit-boundary { background: var(--info); }
.architecture-connector.is-commit-boundary::after { border-color: var(--info); }
.architecture-interaction-hint { margin: 12px 0 8px; color: var(--muted); font-size: .8125rem; text-align: center; }
.architecture-active-detail { position: relative; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr); gap: 22px; align-items: center; min-height: 142px; padding: 20px 22px; overflow: hidden; background: var(--petrol); border-radius: var(--radius); }
.architecture-active-detail::after { content: ""; position: absolute; right: -48px; bottom: -78px; width: 190px; height: 190px; border: 1px solid #2d4157; border-radius: 50%; box-shadow: 0 0 0 24px rgba(45, 65, 87, .22), 0 0 0 48px rgba(45, 65, 87, .12); pointer-events: none; }
.architecture-active-copy, .architecture-active-evidence { position: relative; z-index: 1; }
.architecture-active-copy > span { color: #5eead4; font-size: .75rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.architecture-active-copy h3 { margin: 7px 0 6px; color: white; font-size: 1.06rem; letter-spacing: -.015em; }
.architecture-active-copy p { max-width: 72ch; margin: 0; color: #bcc9d6; font-size: .8125rem; line-height: 1.6; }
.architecture-active-evidence { display: grid; gap: 10px; margin: 0; }
.architecture-active-evidence > div { padding: 10px 12px; background: rgba(255, 255, 255, .055); border: 1px solid #2d4157; border-radius: var(--radius); }
.architecture-active-evidence dt { color: #8fa2b6; font-size: .75rem; font-weight: 800; letter-spacing: .075em; text-transform: uppercase; }
.architecture-active-evidence dd { display: flex; flex-wrap: wrap; gap: 6px; margin: 5px 0 0; color: white; font-size: .8125rem; font-weight: 700; }
.architecture-active-evidence dd span { padding: 4px 7px; color: #d8e5ee; background: rgba(94, 234, 212, .08); border: 1px solid rgba(94, 234, 212, .18); border-radius: 999px; }
@keyframes architecture-signal-x { 0%, 20% { opacity: 0; transform: translateX(0); } 35% { opacity: 1; } 80% { opacity: 1; transform: translateX(10px); } 100% { opacity: 0; transform: translateX(10px); } }
@keyframes architecture-signal-y { 0%, 20% { opacity: 0; transform: translateY(0); } 35% { opacity: 1; } 80% { opacity: 1; transform: translateY(10px); } 100% { opacity: 0; transform: translateY(10px); } }
@media (prefers-reduced-motion: reduce) {
.architecture-signal { animation: none; }
}
@media (max-width: 1180px) {
.architecture-explorer { padding: 14px; }
.architecture-zones { display: none; }
.architecture-flow { grid-template-columns: 1fr; gap: 12px; padding: 0; }
.architecture-step button { min-height: 112px; padding: 14px 16px; }
.architecture-step button > small { max-width: 64ch; }
.architecture-connector { top: 100%; left: 27px; width: 2px; height: 12px; }
.architecture-connector::after { top: auto; right: -3px; bottom: -1px; transform: rotate(135deg); }
.architecture-signal { top: -2px; left: -2px; animation-name: architecture-signal-y; }
.architecture-active-detail { grid-template-columns: 1fr; gap: 16px; }
}
@media (max-width: 700px) {
.architecture-active-detail { min-height: 0; padding: 18px; }
.architecture-active-detail::after { opacity: .6; }
.architecture-active-evidence dd { display: grid; }
}
@media (forced-colors: active) {
.architecture-step.is-active button { outline: 2px solid Highlight; outline-offset: 2px; }
.architecture-signal, .architecture-active-detail::after { display: none; }
}
+49
View File
@@ -0,0 +1,49 @@
.rule-explainer { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 18px; padding: 16px 18px; background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); }
.rule-explainer strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.rule-explainer p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
.scenario-callout { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 18px; padding: 16px 18px; background: var(--teal-pale); border: 1px solid #bfe6df; }
.scenario-callout svg { width: 18px; height: 18px; color: var(--teal-dark); flex-shrink: 0; margin-top: 2px; }
.scenario-callout strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .82rem; }
.scenario-callout p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
.status-decision { margin-top: 16px; padding: 16px 18px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.status-decision h3 { margin: 16px 0 6px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.status-decision h3:first-child { margin-top: 0; }
.status-decision > .detail-grid { margin-bottom: 4px; }
.evidence-list { margin: 0; padding-left: 18px; color: var(--ink-soft); font-size: .78rem; line-height: 1.6; }
.evidence-list li { margin-bottom: 2px; }
.status-decision .button { margin-top: 16px; }
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }.duplicate-compare .merge-record-preview { position: sticky; bottom: 16px; z-index: 2; box-shadow: 0 10px 24px rgba(15,23,42,.08); }
.compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); }
.merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
.merge-summary-counts { margin: 0 0 12px; color: var(--muted); font-size: .72rem; font-weight: 700; }
.toggle-matching-fields { display: inline-block; margin: 10px 0; padding: 6px 10px; color: var(--ink-soft); background: transparent; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 700; cursor: pointer; }
.toggle-matching-fields:hover { background: var(--surface-subtle); }
.merge-record-preview { margin: 14px 0; padding: 13px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.merge-record-preview-title { margin: 0 0 8px; color: var(--ink); font-size: .72rem; font-weight: 700; }
.merge-record-preview dl { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 16px; }
.merge-record-preview dt { margin: 0; color: var(--muted); font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.merge-record-preview dd { margin: 1px 0 0; color: var(--ink); font-size: .78rem; }
.confirm-bar { margin-top: 13px; padding: 15px; background: var(--warning-pale); border: 1px solid #eed4aa; }.confirm-bar p { margin: 0 0 11px; color: #75420c; font-size: .75rem; font-weight: 700; }.confirm-bar button:first-of-type { color: white; background: var(--critical); border-color: var(--critical); }
.resolution-actions { display: flex; gap: 9px; }.evidence-block { max-width: 100%; padding: 13px; overflow: auto; color: #324054; background: var(--surface-subtle); border: 1px solid var(--line); font-size: .68rem; }
.evidence-disclosure { margin-top: 14px; }.evidence-disclosure summary { font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }.evidence-disclosure .evidence-block { margin-top: 8px; }
.choice-fieldset { display: grid; gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.choice-fieldset legend { margin-bottom: 2px; padding: 0; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.choice-fieldset-grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.choice-card { display: flex !important; flex-direction: row; align-items: flex-start; gap: 11px; padding: 13px 15px; background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--radius); cursor: pointer; transition: border-color .15s ease, background .15s ease; }
.choice-card:hover { border-color: var(--teal); }
.choice-card:has(input:checked), .choice-card.is-selected { border-color: var(--teal-dark); background: var(--teal-pale); }
.choice-card:has(input:disabled), .choice-card.is-disabled { cursor: not-allowed; opacity: .55; }
.choice-card input[type="radio"] { flex: 0 0 auto; margin-top: 2px; }
.choice-card-body { display: grid; gap: 3px; }
.choice-card-title { color: var(--ink); font-size: .82rem; font-weight: 700; }
.choice-card-detail { color: var(--muted); font-size: .72rem; line-height: 1.5; }
.defer-reject-panel { padding: 16px 18px; background: var(--surface-subtle); }
.defer-reject-panel h2 { margin: 0 0 4px; color: var(--ink-soft); font-size: .78rem; font-weight: 700; }
.defer-reject-panel > p { margin: 0 0 12px; color: var(--muted); font-size: .72rem; }
.resolution-actions .button-tertiary, .resolution-actions .button-tertiary-destructive { min-height: 36px; padding: 7px 12px; color: var(--muted); background: transparent; border: 1px solid transparent; border-radius: var(--radius); font-size: .7rem; font-weight: 700; cursor: pointer; }
.resolution-actions .button-tertiary:hover { color: var(--ink); background: var(--surface); border-color: var(--line); }
.resolution-actions .button-tertiary-destructive:hover { color: var(--critical); background: var(--surface); border-color: var(--line); }
+551
View File
@@ -0,0 +1,551 @@
@import "./styles-data-quality.css";
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #0f172a;
background: #f5f7fa;
font-synthesis: none;
--ink: #0f172a;
--ink-soft: #344256;
--muted: #5d6a7c;
--muted-light: #94a0af;
--canvas: #f5f7fa;
--surface: #ffffff;
--surface-subtle: #f8fafc;
--line: #dbe2ea;
--line-strong: #c7d1dd;
--petrol: #0f172a;
--petrol-2: #15243a;
--teal: #0f8f83;
--teal-dark: #0b6f67;
--teal-pale: #e7f7f4;
--critical: #c93636;
--critical-pale: #fff0f0;
--warning: #b96812;
--warning-pale: #fff7e8;
--info: #356480;
--info-pale: #eef6fb;
--success: #17724b;
--success-pale: #eaf7f0;
--focus: #14b8a6;
--radius: 4px;
--shadow-float: 0 16px 40px rgba(15, 23, 42, .12);
--type-body: .875rem;
--type-meta: .75rem;
--type-label: .75rem;
--type-badge: .75rem;
}
* { box-sizing: border-box; }
html { background: var(--canvas); scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--canvas); }
button, input, select, textarea { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
a { color: var(--teal-dark); text-underline-offset: 3px; }
a:hover { color: var(--teal); }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
::selection { background: #a7f3d0; color: var(--ink); }
.visually-hidden {
position: absolute !important;
width: 1px !important; height: 1px !important;
padding: 0 !important; margin: -1px !important;
overflow: hidden !important; clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important; border: 0 !important;
}
.skip-link { position: fixed; z-index: 100; top: 10px; left: 10px; transform: translateY(-160%); background: white; color: var(--ink); padding: 10px 16px; border: 2px solid var(--teal); border-radius: var(--radius); }
.skip-link:focus { transform: none; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 224px minmax(0, 1fr); }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 30; width: 224px; display: flex; flex-direction: column; background: var(--petrol); color: #dbe5ef; border-right: 1px solid #24344b; }
.brand-lockup { height: 72px; display: flex; align-items: center; gap: 11px; padding: 0 20px; border-bottom: 1px solid #26364c; }
.brand-mark { width: 31px; height: 31px; color: white; flex: 0 0 auto; }
.brand-lockup div { display: grid; gap: 2px; }
.brand-lockup strong { color: white; font-size: .95rem; letter-spacing: -.02em; }
.brand-lockup span { color: #8291a5; font-size: .68rem; text-transform: uppercase; letter-spacing: .1em; }
.sidebar nav { flex: 1; padding: 22px 12px; overflow: auto; }
.nav-group + .nav-group { margin-top: 26px; }
.nav-group > p { margin: 0 10px 8px; color: #74849a; font-size: .66rem; font-weight: 700; text-transform: uppercase; letter-spacing: .13em; }
.nav-group ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; }
.nav-group a { position: relative; min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 9px 11px; color: #b7c3d1; text-decoration: none; border-radius: var(--radius); font-size: .86rem; font-weight: 600; transition: background .16s ease, color .16s ease, transform .16s ease; }
.nav-group a svg { width: 18px; height: 18px; }
.nav-group a:hover { background: rgba(255,255,255,.06); color: white; }
.nav-group a.active { background: #183b49; color: #5eead4; }
.nav-group a.active::before { content: ""; position: absolute; left: -12px; width: 3px; height: 22px; background: #2dd4bf; }
.sidebar-foot { display: flex; align-items: center; gap: 9px; margin: 12px; padding: 13px 10px; border: 1px solid #2a394e; border-radius: var(--radius); }
.sidebar-foot div { display: grid; gap: 2px; }
.sidebar-foot strong { font-size: .72rem; color: #dbe5ef; }
.sidebar-foot span:not(.environment-dot) { font-size: .66rem; color: #7f8da1; }
.sidebar-reset { margin: 0 12px 12px; display: grid; gap: 8px; }
.sidebar-reset .button { width: 100%; }
.sidebar-reset .confirm-bar { background: var(--surface); border-radius: var(--radius); padding: 10px; display: grid; gap: 8px; }
.sidebar-reset .confirm-bar p { margin: 0; font-size: .7rem; color: var(--ink-soft); }
.environment-dot, .live-indicator { width: 8px; height: 8px; border-radius: 50%; background: #2dd4bf; box-shadow: 0 0 0 4px rgba(45, 212, 191, .12); }
.app-workspace { grid-column: 2; min-width: 0; min-height: 100vh; display: flex; flex-direction: column; }
.topbar { height: 64px; display: flex; align-items: center; gap: 20px; padding: 0 28px; background: rgba(255,255,255,.97); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.global-search { position: relative; width: min(410px, 42vw); min-height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 10px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); color: var(--muted); }
.global-search svg { width: 16px; }
.global-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: .8rem; }
.global-search:focus-within { border-color: var(--teal); box-shadow: 0 0 0 3px rgba(18, 132, 126, .14); }
.global-search kbd { padding: 2px 5px; border: 1px solid var(--line); background: white; color: var(--muted); font-size: .64rem; border-radius: 3px; }
.search-results { position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 30; max-height: 60vh; overflow-y: auto; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); padding: 6px; }
.search-status { margin: 0; padding: 10px 8px; font-size: .78rem; color: var(--muted); }
.search-result { width: 100%; display: flex; align-items: center; gap: 10px; padding: 8px; border: 0; border-radius: var(--radius); background: transparent; color: var(--ink); text-align: left; cursor: pointer; font: inherit; }
.search-result svg { width: 16px; flex-shrink: 0; color: var(--muted); }
.search-result:hover, .search-result.is-active { background: var(--surface-subtle); }
.search-result-copy { display: flex; flex-direction: column; min-width: 0; }
.search-result-copy strong { font-size: .82rem; }
.search-result-copy small { color: var(--muted); font-size: .72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.timezone { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .72rem; white-space: nowrap; }
.timezone svg { width: 15px; }
.operator { display: flex; align-items: center; gap: 9px; padding-left: 17px; border-left: 1px solid var(--line); }
.operator-menu { position: relative; }.operator-menu summary { list-style: none; cursor: pointer; }.operator-menu summary::-webkit-details-marker { display: none; }.operator-menu[open] .operator { color: var(--teal-dark); }.operator-popover { position: absolute; top: calc(100% + 8px); right: 0; z-index: 31; width: 220px; display: grid; gap: 10px; padding: 12px; background: white; border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }.operator-popover .timezone { font-size: var(--type-meta); }.operator-logout { min-height: 40px; display: flex; align-items: center; gap: 8px; padding: 8px 10px; color: var(--ink-soft); background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); font-size: var(--type-meta); font-weight: 700; cursor: pointer; }.operator-logout svg { width: 16px; }
.avatar { width: 32px; height: 32px; display: grid; place-items: center; color: white; background: var(--petrol); border-radius: 50%; font-size: .67rem; font-weight: 700; }
.operator > span:last-child { display: grid; gap: 1px; min-width: 0; }
.operator strong { font-size: .74rem; overflow-wrap: anywhere; }
.operator small { color: var(--muted); font-size: .64rem; overflow-wrap: anywhere; }
.icon-button { width: 40px; height: 40px; display: grid; place-items: center; padding: 0; color: var(--ink-soft); background: transparent; border: 1px solid transparent; border-radius: var(--radius); cursor: pointer; }
.icon-button:hover { background: var(--surface-subtle); border-color: var(--line); }
.icon-button svg { width: 18px; height: 18px; }
.mobile-menu { display: none; }
.language-switcher select { height: 32px; padding: 0 8px; color: var(--ink-soft); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 600; cursor: pointer; }
.language-switcher-compact select { height: 28px; font-size: .64rem; }
.sidebar-language { display: none; padding: 0 20px 14px; }
.login-panel-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.login-panel-top .page-eyebrow { margin: 0; }
.login-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; color: var(--muted); font-size: .68rem; text-transform: uppercase; letter-spacing: .08em; }
.login-divider::before, .login-divider::after { content: ""; height: 1px; flex: 1; background: var(--line); }
.login-oidc { width: 100%; min-height: 44px; justify-content: center; gap: 8px; background: white; border: 1px solid var(--line-strong); }
.login-oidc svg { width: 17px; }
.privacy-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.privacy-metrics article { min-height: 92px; padding: 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.privacy-metrics span { display: block; color: var(--muted); font-size: .68rem; }
.privacy-metrics strong { display: block; margin-top: 10px; font-size: 1.25rem; }
.privacy-workspaces { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.privacy-panel { display: grid; align-content: start; gap: 14px; padding: 20px; }
.privacy-panel h2, .privacy-panel p { margin: 0; }
.privacy-panel label { display: grid; gap: 6px; }
.privacy-panel textarea { min-height: 92px; resize: vertical; }
.form-actions-inline { display: flex; flex-wrap: wrap; gap: 10px; }
.button.is-disabled { pointer-events: none; opacity: .5; }
.demo-badge { position: relative; }
.demo-badge-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: #48566a; background: #eaf0f5; border: 1px solid #d7e0e8; border-radius: 999px; font-size: .68rem; font-weight: 700; letter-spacing: .02em; cursor: pointer; }
.demo-badge-trigger:hover { background: #dfe8ef; }
.demo-badge-trigger svg { width: 13px; height: 13px; }
.demo-badge-popover { position: absolute; z-index: 30; top: calc(100% + 8px); right: 0; width: min(320px, 84vw); padding: 16px; display: grid; gap: 10px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }
.demo-badge-popover p { margin: 0; color: var(--ink-soft); font-size: .74rem; line-height: 1.55; }
.demo-badge-reset { color: var(--muted) !important; font-size: .68rem !important; }
.demo-badge-popover a { display: inline-flex; align-items: center; gap: 4px; color: var(--teal-dark); text-decoration: none; font-size: .74rem; font-weight: 700; }
.demo-badge-popover a svg { width: 13px; }
.demo-badge-close { position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; }
.demo-badge-close svg { width: 14px; height: 14px; }
#main-content { width: min(1320px, calc(100% - 56px)); margin: 0 auto; padding: 38px 0 64px; flex: 1; }
.app-footer { min-height: 52px; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 0 28px; color: #5f6d80; border-top: 1px solid var(--line); font-size: .68rem; }
.mobile-nav, .nav-scrim { display: none; }
.page { animation: page-in .28s ease both; }
@keyframes page-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 26px; }
.page-eyebrow, .eyebrow { margin: 0 0 7px; color: var(--teal-dark); font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .11em; }
.page-header h1 { margin: 0; color: var(--ink); font-size: clamp(1.75rem, 2.5vw, 2.2rem); line-height: 1.12; letter-spacing: -.035em; }
.page-description { max-width: 660px; margin: 8px 0 0; color: var(--muted); font-size: .88rem; line-height: 1.55; }
.page-actions, .status-stack { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.section-heading { min-height: 60px; display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding: 16px 18px 12px; border-bottom: 1px solid var(--line); }
.section-heading h2 { margin: 0; font-size: 1rem; letter-spacing: -.015em; }
.section-heading p { margin: 4px 0 0; color: var(--muted); font-size: .72rem; }
.section-heading > a { min-height: 30px; display: inline-flex; align-items: center; gap: 4px; color: var(--teal-dark); text-decoration: none; font-size: .72rem; font-weight: 700; }
.section-heading > a svg { width: 14px; }
.back-link { width: max-content; min-height: 32px; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 13px; color: var(--muted); text-decoration: none; font-size: .76rem; font-weight: 600; }
.back-link svg { width: 15px; }
.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 8px 14px; border-radius: var(--radius); border: 1px solid transparent; text-decoration: none; font-size: .78rem; font-weight: 700; cursor: pointer; transition: transform .16s ease, background .16s ease; }
.button:hover { transform: translateY(-1px); }
.button svg { width: 16px; height: 16px; }
.button-primary { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
.button-primary:hover { color: white; background: var(--teal); }
.button-secondary { color: var(--ink); background: white; border-color: var(--line-strong); }
.button-secondary:hover { color: var(--ink); background: var(--surface-subtle); }
.button:disabled { opacity: .55; cursor: not-allowed; transform: none; }
.readiness-band { min-height: 92px; display: grid; grid-template-columns: minmax(170px, 1.3fr) minmax(540px, 4fr) auto; align-items: stretch; margin-bottom: 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.readiness-label { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-right: 1px solid var(--line); }
.readiness-label h2 { margin: 0; font-size: .83rem; }
.readiness-label p { margin: 3px 0 0; color: var(--muted); font-size: .65rem; }
.readiness-metrics { min-width: 0; display: grid; grid-template-columns: repeat(5, 1fr); margin: 0; }
.metric-cell { position: relative; display: flex; flex-direction: column-reverse; justify-content: center; gap: 3px; padding: 12px 16px; border-right: 1px solid var(--line); }
.metric-cell::after { content: ""; position: absolute; inset: auto 15px 0; height: 2px; background: #9aa6b4; }
.metric-cell dt { color: var(--muted); font-size: .64rem; text-transform: uppercase; letter-spacing: .07em; }
.metric-cell dd { margin: 0; color: var(--ink); font-size: 1.42rem; font-weight: 700; letter-spacing: -.04em; }
.metric-ready::after { background: var(--teal); }.metric-warning::after { background: var(--warning); }.metric-critical::after { background: var(--critical); }
.metric-warning dd { color: var(--warning); }.metric-critical dd { color: var(--critical); }
.inline-action { display: inline-flex; align-items: center; gap: 5px; padding: 0 18px; color: var(--teal-dark); text-decoration: none; font-size: .72rem; font-weight: 700; white-space: nowrap; }
.inline-action svg { width: 14px; }
.operations-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(320px, .75fr); align-items: start; gap: 16px; margin-bottom: 16px; }
.secondary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.work-panel, .panel, .record-surface, .table-shell { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.panel { padding: 20px; }
.queue-controls { display: flex; gap: 8px; padding: 11px 18px; background: var(--surface-subtle); border-bottom: 1px solid var(--line); }
.queue-controls select, .compact-search { height: 34px; border: 1px solid var(--line-strong); border-radius: var(--radius); background: white; }
.compact-search { min-width: 0; flex: 1; display: flex; align-items: center; gap: 7px; padding: 0 9px; color: var(--muted); }
.compact-search svg { width: 14px; }
.compact-search input { width: 100%; border: 0; outline: 0; background: transparent; font-size: .72rem; }
.queue-controls select { padding: 0 28px 0 9px; color: var(--ink-soft); font-size: .69rem; }
.attention-list, .integration-list, .recent-list, .record-list, .automation-list, .today-list { list-style: none; margin: 0; padding: 0; }
.attention-list li { min-height: 68px; display: grid; grid-template-columns: auto minmax(0,1fr) auto 16px; align-items: center; gap: 11px; padding: 10px 18px; border-bottom: 1px solid #e8edf2; transition: background .14s ease; }
.attention-list li:last-child { border-bottom: 0; }.attention-list li:hover { background: #f9fbfc; }
.queue-more { margin: 0; padding: 10px 12px; border-top: 1px solid var(--line); background: var(--surface-subtle); }
.queue-more a { display: grid; grid-template-columns: 34px minmax(0, 1fr) 30px; align-items: center; gap: 10px; min-height: 52px; padding: 7px 8px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-decoration: none; transition: border-color .14s ease, box-shadow .14s ease, transform .14s ease; }
.queue-more a:hover { border-color: var(--teal); box-shadow: 0 5px 16px rgba(13, 126, 116, .09); transform: translateY(-1px); }
.queue-more-count { width: 34px; height: 34px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; font-size: .78rem; font-weight: 800; }
.queue-more-copy { min-width: 0; display: grid; gap: 2px; }
.queue-more-copy strong { font-size: .75rem; line-height: 1.25; }
.queue-more-copy small { color: var(--muted); font-size: .64rem; font-weight: 500; }
.queue-more-arrow { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }
.queue-more-arrow svg { width: 14px; height: 14px; }
.attention-title { margin: 0; font-size: .78rem; font-weight: 700; }.attention-title a { color: var(--ink); text-decoration: none; }
.attention-detail { max-width: 58ch; margin: 4px 0 0; color: var(--muted); font-size: .69rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.queue-ref { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .65rem; }.row-chevron { width: 14px; color: var(--muted-light); }
.quiet-empty { display: flex; align-items: center; gap: 8px; margin: 0; padding: 28px 18px; color: var(--muted); font-size: .78rem; }.quiet-empty svg { width: 18px; }
.movement-timeline { list-style: none; margin: 0; padding: 8px 18px 15px; }
.movement-timeline li { display: grid; grid-template-columns: 48px 26px 1fr; align-items: center; min-height: 58px; }
.movement-timeline time { color: var(--ink); font-size: .75rem; font-weight: 700; }
.timeline-node { position: relative; z-index: 1; width: 22px; height: 22px; display: grid; place-items: center; color: var(--teal-dark); background: white; border: 1px solid var(--teal); border-radius: 50%; }
.timeline-node::after { content: ""; position: absolute; z-index: -1; top: 21px; height: 37px; border-left: 1px dashed var(--line-strong); }
.movement-timeline li:last-child .timeline-node::after { display: none; }.timeline-node svg { width: 12px; }
.timeline-departure { color: var(--info); border-color: #6a98b3; }
.movement-timeline li > div { min-width: 0; display: grid; grid-template-columns: auto 1fr; gap: 2px 8px; align-items: baseline; }
.movement-kind { color: var(--muted); font-size: .59rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.movement-timeline .movement-ref { color: var(--ink); font-size: .75rem; font-weight: 700; }.movement-timeline small { grid-column: 2; color: var(--muted); font-size: .66rem; }
.row-clickable { position: relative; cursor: pointer; }
.row-link { position: absolute; inset: 0; z-index: 1; border-radius: inherit; }
.row-link:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
.attention-list li.row-clickable:hover .attention-title, .movement-timeline li.row-clickable:hover .movement-ref { color: var(--teal-dark); }
.attention-tier + .attention-tier { border-top: 1px solid var(--line); }
.attention-tier-label { margin: 0; padding: 9px 18px 5px; color: var(--muted); font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.data-table tr.row-clickable:hover { background: var(--surface-subtle); }
.data-table tr.row-clickable .cell-link { position: relative; z-index: 2; }
.integration-list li, .recent-list li { min-height: 60px; display: flex; align-items: center; gap: 11px; padding: 10px 18px; border-bottom: 1px solid #e8edf2; }
.integration-list li:last-child, .recent-list li:last-child { border-bottom: 0; }.integration-list li > div, .recent-list li > div { flex: 1; display: grid; gap: 3px; }
.integration-list strong, .recent-list strong { font-size: .73rem; }.integration-list div span, .recent-list div span { color: var(--muted); font-size: .65rem; }
.integration-mark { width: 31px; height: 31px; display: grid; place-items: center; border-radius: var(--radius); color: white; font-size: .58rem; font-weight: 800; letter-spacing: -.04em; }
.integration-n8n { background: #b84424; }.integration-rag { background: #4a4b95; }.integration-mcp { background: #314358; }
.activity-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.activity-icon svg { width: 15px; }
.recent-list time { color: var(--muted); font-size: .62rem; white-space: nowrap; }
.badge { min-height: 24px; display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border: 1px solid transparent; border-radius: 999px; font-size: var(--type-badge); font-weight: 700; line-height: 1.2; text-transform: capitalize; white-space: nowrap; }
.badge::before, .badge-dot { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.severity-high, .status-failed, .status-blocked, .status-rejected, .status-unavailable { color: #9f2929; background: var(--critical-pale); border-color: #f3c5c5; }
.severity-medium, .status-pending, .status-delivering, .status-needs_attention { color: #93520c; background: var(--warning-pale); border-color: #eed4aa; }
.severity-low, .status-demo_scenario { color: #315f79; background: var(--info-pale); border-color: #c8dfeb; }
.status-succeeded, .status-resolved, .status-available, .status-returned, .status-active { color: #17633f; background: var(--success-pale); border-color: #bfe3d0; }
.status-cancelled, .status-deferred, .status-cleaning, .status-maintenance, .status-rented, .status-reserved, .status-not_configured, .status-no_events, .status-no_delivery_yet { color: #536172; background: #f0f3f6; border-color: #d8dfe6; }
.badge-dot { display: inline-block; }
.filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-bottom: 16px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.filters label, .return-form label, .form-grid label, .panel label:has(> textarea) { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; letter-spacing: .01em; }
.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select, .return-form input[type="number"], .return-form textarea, .knowledge-input-row input, .form-grid input, .form-grid select, .panel label:has(> textarea) textarea { min-height: 44px; padding: 8px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: var(--type-body); }
.filters input[type="text"], .filters input[type="search"] { min-width: 230px; }.filters select { min-width: 160px; }
.checkbox-label { flex-direction: row !important; align-items: center; gap: 8px !important; }
input[type="checkbox"], input[type="radio"] { width: 17px; height: 17px; accent-color: var(--teal-dark); }
.filter-toggle { display: none; margin-bottom: 10px; }
.active-filter-chips { display: flex; flex-wrap: wrap; gap: 7px; margin: -7px 0 16px; }
.active-filter-chips button, .select-visible { min-height: 32px; padding: 5px 10px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: var(--type-meta); font-weight: 700; cursor: pointer; }
.select-visible { margin-left: auto; white-space: nowrap; }
.table-shell { overflow: hidden; }
.table-meta { min-height: 44px; display: flex; justify-content: space-between; align-items: center; padding: 0 14px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: var(--type-meta); }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { min-height: 52px; text-align: left; padding: 9px 14px; border-bottom: 1px solid #e8edf2; font-size: var(--type-body); vertical-align: middle; }
.data-table tbody tr:last-child th, .data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr { transition: background .14s ease; }.data-table tbody tr:hover { background: #f9fbfc; }
.data-table thead th { height: 42px; color: var(--muted); background: #f6f8fa; font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .075em; }
.data-table th[scope="row"] { color: var(--ink); font-weight: 700; }.data-table th[scope="row"] a { color: var(--teal-dark); text-decoration: none; }
.row-attention { box-shadow: inset 2px 0 var(--warning); }.attention-flag { color: var(--warning); font-size: .67rem; font-weight: 700; }
.data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button { min-height: 36px; padding: 7px 12px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .7rem; font-weight: 700; cursor: pointer; }
.data-table td button:hover, .pagination button:hover:not(:disabled) { background: var(--surface-subtle); }.data-table td button:disabled, .pagination button:disabled { opacity: .45; cursor: not-allowed; }
.table-subtext { display: block; margin-top: 3px; color: var(--muted); font-size: var(--type-meta); }.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.pagination { min-height: 56px; display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: var(--type-meta); }
details summary { cursor: pointer; color: var(--teal-dark); }.data-table details pre { max-width: 280px; overflow: auto; color: var(--ink-soft); white-space: pre-wrap; }
.technical-identity { margin-top: 8px; font-size: var(--type-meta); }.technical-identity code { display: block; max-width: 100%; margin-top: 5px; padding: 6px 8px; overflow-wrap: anywhere; color: var(--muted); background: var(--surface-subtle); border-radius: 6px; }
.knowledge-index-evidence .knowledge-statistics-scope { grid-column: 1 / -1; margin: 0; padding: 9px 12px; color: var(--ink-soft); background: var(--info-pale); border-radius: var(--radius); font-size: var(--type-meta); line-height: 1.5; }
.tabs { display: flex; gap: 2px; margin: 0 0 16px; padding: 0 4px; overflow-x: auto; border-bottom: 1px solid var(--line); }
.tabs button { position: relative; min-height: 44px; padding: 8px 13px; border: 0; background: transparent; color: var(--muted); font-size: .72rem; font-weight: 700; text-transform: capitalize; cursor: pointer; }
.tabs button.active { color: var(--teal-dark); }.tabs button.active::after { content: ""; position: absolute; inset: auto 7px -1px; height: 2px; background: var(--teal); }
.record-surface { padding: 18px; margin-bottom: 18px; }
.record-surface-evidence { padding: 0 18px 16px; }
.record-surface-evidence dt { margin: 0 0 6px; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }
.record-surface-evidence .evidence-signal-list { margin: 0; padding-left: 18px; color: var(--ink); font-size: .82rem; font-weight: 700; line-height: 1.6; }
.record-surface-evidence .evidence-signal-list li { margin-bottom: 3px; }
.about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; }
.about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; }
.about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin-bottom: 18px; }
.about-grid .about-card { margin-bottom: 0; }
.about-details summary { cursor: pointer; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; font-weight: 700; }
.about-details[open] summary { margin-bottom: 8px; }
.about-details summary::-webkit-details-marker { display: none; }
.about-details summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); }
.detail-grid div { min-height: 76px; padding: 13px 14px; background: white; }
.detail-grid dt { margin: 0; color: var(--muted); font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: var(--type-body); font-weight: 700; }
.record-list { display: grid; gap: 1px; margin-bottom: 16px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.record-list li { min-height: 52px; display: flex; flex-wrap: wrap; align-items: center; gap: 11px; padding: 10px 14px; background: white; font-size: .75rem; }
.return-form, .return-result { max-width: 880px; margin-top: 22px; padding: 0; overflow: hidden; }
.return-form > .section-heading { padding: 19px 22px; }.return-progress { position: sticky; top: 64px; z-index: 8; min-height: 54px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 10px 22px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: var(--type-meta); font-weight: 700; }
.return-progress span { display: flex; align-items: center; gap: 6px; white-space: nowrap; }.return-progress i { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; font-style: normal; }
.return-progress b { width: 54px; height: 1px; background: var(--line-strong); }.return-progress .is-active, .return-progress .is-complete { color: var(--teal-dark); }.return-progress .is-active i, .return-progress .is-complete i { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
.return-capture, .return-review { padding: 22px; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 17px; }
.booking-create-form, .booking-cancel-form { padding: 22px; }
.booking-create-form .check-card { display: flex; align-items: center; gap: 9px; margin-bottom: 17px; }
.booking-cancel-form { margin-top: 18px; }
.booking-cancel-form h2 { margin: 0 0 14px; font-size: 1rem; }
.booking-cancel-form label { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }
.booking-cancel-form textarea { min-height: 92px; padding: 10px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font: inherit; }
.booking-reschedule > summary { font-size: 1rem; font-weight: 700; }.booking-reschedule form { margin-top: 15px; }.booking-reschedule form > p { color: var(--muted); font-size: var(--type-body); }.booking-reschedule form > label { display: grid; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }.booking-reschedule textarea { min-height: 84px; padding: 10px 11px; border: 1px solid var(--line-strong); border-radius: var(--radius); font: inherit; }
.checkout-result { margin-top: 18px; padding: 18px 22px; }
.checkout-result h2 { margin: 0 0 6px; font-size: 1rem; }
.checkout-result p { margin: 0; color: var(--ink-soft); }
.checkout-result.success { border-left: 4px solid var(--success); }
.checkout-result.warning { border-left: 4px solid var(--warning); }
.maintenance-actions, .user-create { margin-top: 18px; padding: 20px; }
.maintenance-actions > form > label, .release-form label { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }
.maintenance-actions textarea { min-height: 92px; padding: 10px 11px; border: 1px solid var(--line-strong); border-radius: var(--radius); font: inherit; }
.release-form { display: flex; align-items: end; gap: 10px; padding-top: 16px; border-top: 1px solid var(--line); }
.release-form label { flex: 1; }
.release-form input { min-height: 44px; padding: 8px 11px; border: 1px solid var(--line-strong); border-radius: var(--radius); }
.user-create h2 { margin: 0 0 14px; font-size: 1rem; }
.table-secondary { display: block; color: var(--ink-soft); font-size: var(--type-meta); font-weight: 400; }
.condition-fieldset { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.condition-fieldset legend { margin-bottom: 8px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.check-card { min-height: 48px; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.return-form textarea { resize: vertical; min-height: 86px; }.form-actions { display: flex; justify-content: flex-end; gap: 9px; padding: 15px 22px; background: var(--surface-subtle); border-top: 1px solid var(--line); }
.review-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: var(--type-body); font-weight: 700; }
.impact-preview { display: flex; align-items: flex-start; gap: 11px; padding: 15px; border: 1px solid; border-radius: var(--radius); }.impact-preview > svg { width: 20px; flex: 0 0 auto; }.impact-preview strong { font-size: .76rem; }.impact-preview p { margin: 4px 0 0; font-size: .72rem; line-height: 1.45; }.impact-ready { color: #185d41; background: var(--success-pale); border-color: #bfe3d0; }.impact-warning { color: #844909; background: var(--warning-pale); border-color: #eed4aa; }
.commit-list { list-style: none; display: grid; gap: 7px; margin: 16px 0 0; padding: 0; color: var(--muted); font-size: .7rem; }.commit-list li { display: flex; gap: 7px; }.commit-list svg { width: 14px; color: var(--teal-dark); }
.success-panel { padding: 22px; }.result-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }.result-heading > span { width: 40px; height: 40px; display: grid; place-items: center; color: white; background: var(--success); border-radius: 50%; }.result-heading svg { width: 20px; }.result-heading h2 { margin: 0; font-size: 1.2rem; }
.result-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.link-button { padding: 0; color: var(--teal-dark); background: transparent; border: 0; font-size: inherit; font-weight: 700; text-decoration: underline; cursor: pointer; }
.link-button:hover { color: var(--teal); }
.audit-group-list { list-style: none; display: grid; gap: 12px; margin: 0; padding: 16px; }
.audit-group { padding: 16px 18px; }
.audit-group-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
.audit-group-heading strong { color: var(--ink); font-size: .85rem; }
.audit-group-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-top: 6px; font-size: .76rem; }
.change-diff { list-style: none; display: grid; gap: 4px; margin: 10px 0 0; padding: 0; color: var(--ink-soft); font-size: .74rem; }
.change-diff li { padding: 6px 10px; background: var(--surface-subtle); border-radius: 4px; }
.audit-group-actions { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 10px; }
.audit-related-list { list-style: none; display: grid; gap: 10px; margin: 12px 0 0; padding: 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.audit-related-list > li { padding: 10px 12px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }
.audit-technical-grid { margin-top: 8px; }
.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 0; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; }
.integration-cards .integration-badge-stack { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; align-self: end; }
.integration-cards small { display: block; margin-top: 4px; color: var(--muted-light); font-size: var(--type-meta); }.integration-cards h2 { margin: 3px 0 7px; font-size: 1rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: var(--type-body); line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .09em; }
.scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
.scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.scenario-card header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.scenario-card h2 { margin: 0; font-size: 1rem; letter-spacing: -.015em; }
.scenario-problem, .scenario-demonstrates { margin: 0; color: var(--muted); font-size: .78rem; line-height: 1.55; }
.scenario-demonstrates strong { color: var(--ink-soft); }
.scenario-meta { display: flex; gap: 20px; margin: 0; }
.scenario-meta div { display: grid; gap: 2px; }
.scenario-meta dt { color: var(--muted); font-size: .58rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; }
.scenario-meta dd { margin: 0; color: var(--ink-soft); font-size: .76rem; font-weight: 600; }
.scenario-blocked { display: flex; align-items: flex-start; gap: 6px; margin: 0; padding: 8px 10px; color: #8a5a12; background: #fdf3e2; border: 1px solid #f2ddb0; border-radius: 8px; font-size: .68rem; line-height: 1.45; }
.scenario-blocked svg { width: 14px; flex-shrink: 0; margin-top: 1px; }
.scenario-card > .button { align-self: flex-start; margin-top: auto; }
.scenario-card > .button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
.knowledge-status { min-height: 58px; display: flex; align-items: center; gap: 11px; margin-bottom: 14px; padding: 10px 16px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.knowledge-status div { flex: 1; display: grid; gap: 3px; }.knowledge-status strong { font-size: .74rem; }.knowledge-status span:not(.health-orb), .knowledge-status small { color: var(--muted); font-size: .65rem; }.health-orb { width: 9px; height: 9px; border-radius: 50%; }.health-orb.is-healthy { background: var(--success); box-shadow: 0 0 0 4px var(--success-pale); }.health-orb.is-down { background: var(--critical); box-shadow: 0 0 0 4px var(--critical-pale); }
.knowledge-status.is-loading .health-orb { background: var(--line-strong); animation: pulse-soft 1.2s ease-in-out infinite; }
@keyframes pulse-soft {
0%, 100% { opacity: 0.45; }
50% { opacity: 1; }
}
.knowledge-form { margin-bottom: 18px; }.ask-heading { display: flex; gap: 11px; align-items: center; margin-bottom: 15px; }.ask-heading > span { width: 36px; height: 36px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.ask-heading svg { width: 18px; }.ask-heading h2 { margin: 0; font-size: .95rem; }.ask-heading p { margin: 3px 0 0; color: var(--muted); font-size: .65rem; }
.knowledge-input-row { display: flex; gap: 8px; }.knowledge-input-row input { min-width: 0; flex: 1; }.knowledge-empty { min-height: 290px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 30px; color: var(--muted); background: white; border: 1px dashed var(--line-strong); border-radius: var(--radius); }.knowledge-empty > span { width: 50px; height: 50px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.knowledge-empty svg { width: 22px; }.knowledge-empty h2 { margin: 15px 0 7px; color: var(--ink); font-size: 1rem; }.knowledge-empty p { max-width: 540px; margin: 0; font-size: .75rem; line-height: 1.5; }
.retrieval-flow { display: flex; align-items: center; gap: 8px; margin-top: 24px; }.retrieval-flow span { padding: 6px 9px; color: var(--ink-soft); background: var(--surface-subtle); border: 1px solid var(--line); font-size: .61rem; font-weight: 700; }.retrieval-flow i { width: 24px; border-top: 1px dashed var(--teal); }
.exchange-list, .source-cards { list-style: none; margin: 0; padding: 0; }.exchange-list { display: grid; gap: 14px; }.exchange-question { display: grid; gap: 5px; margin: 0 0 10px; font-size: .85rem; }.exchange-question strong { color: var(--muted); font-size: .6rem; text-transform: uppercase; letter-spacing: .08em; }.evidence-state { width: max-content; padding: 4px 8px; border-radius: 999px; font-size: .62rem; font-weight: 700; }.evidence-grounded { color: var(--success); background: var(--success-pale); }.evidence-insufficient { color: var(--warning); background: var(--warning-pale); }.evidence-unavailable { color: var(--critical); background: var(--critical-pale); }
.source-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 9px; margin-top: 13px; }.source-card { padding: 13px; background: var(--surface-subtle); border: 1px solid var(--line); }.source-title { margin: 0; font-size: .73rem; font-weight: 700; }.source-version { color: var(--muted); font-size: .62rem; }.source-section { margin: 5px 0; color: var(--teal-dark); font-size: .65rem; font-weight: 700; }.source-excerpt { margin: 0; color: var(--muted); font-size: .68rem; line-height: 1.45; }
.source-cards-collapsed { grid-template-columns: 1fr; }
.source-cards-collapsed .source-card { padding: 0; border-radius: var(--radius-sm); }
.source-cards-collapsed summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 12px 13px; cursor: pointer; }
.source-cards-collapsed details[open] summary { border-bottom: 1px solid var(--line); }
.source-cards-collapsed .source-number { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; color: var(--teal-dark); background: var(--teal-pale); font-size: .64rem; font-weight: 800; }
.source-cards-collapsed .source-section { margin: 0; text-align: right; }
.source-cards-collapsed .source-excerpt { padding: 13px 13px 5px; }
.source-cards-collapsed .source-version { margin: 0; padding: 0 13px 13px; }
.exchange-timing { margin: -2px 0 12px; color: var(--muted); font-size: .65rem; }
.knowledge-feedback { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: .68rem; }
.knowledge-feedback button { min-height: 34px; padding: 5px 10px; }
.knowledge-feedback button.is-selected { color: var(--teal-dark); border-color: var(--teal); background: var(--teal-pale); }
.bulk-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 10px; padding: 12px 14px; background: var(--teal-pale); border-bottom: 1px solid #bfe6df; }
.bulk-toolbar strong { align-self: center; margin-right: auto; font-size: .74rem; }
.bulk-toolbar label { display: grid; gap: 4px; color: var(--muted); font-size: .62rem; font-weight: 700; }
.bulk-toolbar select, .bulk-toolbar input { min-height: 36px; }
.selection-cell { position: relative; z-index: 2; width: 38px; }
.selection-cell input { width: 16px; height: 16px; }
.is-overdue { color: var(--critical); font-weight: 700; }
.table-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.user-edit { margin-top: 18px; }
.filter-presets { display: flex; flex-wrap: wrap; gap: 7px; margin: -6px 0 18px; }
.filter-presets button { min-height: 34px; padding: 5px 11px; color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; }
.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; }.state-retry { margin-top: 12px; padding-block: 6px; }.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); }
.route-loading { min-height: 40vh; display: grid; place-items: center; }
.route-skeleton { width: min(760px, calc(100% - 40px)); display: grid; gap: 14px; }
.route-skeleton span { display: block; height: 18px; background: linear-gradient(90deg, #edf1f5 25%, #f8fafc 50%, #edf1f5 75%); background-size: 220% 100%; border-radius: var(--radius); animation: skeleton-shift 1.25s ease-in-out infinite; }
.route-skeleton span:first-child { width: 42%; height: 34px; }.route-skeleton span:nth-child(2) { width: 76%; }.route-skeleton span:last-child { height: 180px; }
@keyframes skeleton-shift { to { background-position: -220% 0; } }
/* Recruiter-facing product story */
.engineering-hero { display: flex; justify-content: space-between; align-items: center; gap: 28px; margin-bottom: 26px; padding: 28px; background: linear-gradient(135deg, var(--petrol), #18344a); color: white; overflow: hidden; }
.engineering-hero h2 { max-width: 720px; margin: 0; font-size: clamp(1.6rem, 3vw, 2.35rem); line-height: 1.1; letter-spacing: -.035em; }.engineering-hero p:not(.page-eyebrow) { max-width: 680px; margin: 10px 0 0; color: #c4d0dc; line-height: 1.6; }.engineering-hero .page-eyebrow { color: #5eead4; }
.engineering-hero-actions { min-width: 220px; display: grid; gap: 9px; }.engineering-hero-actions .button { width: 100%; }
.standalone-heading { padding-inline: 0; border: 0; }.proof-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 26px; }.proof-card { padding: 22px; }.proof-card > span, .highlight-icon { width: 42px; height: 42px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.proof-card svg, .highlight-icon svg { width: 21px; }.proof-card h3 { margin: 18px 0 8px; font-size: 1rem; }.proof-card p { margin: 0; color: var(--muted); font-size: var(--type-body); line-height: 1.6; }
.highlight-grid { list-style: none; display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin: 0 0 18px; padding: 0; counter-reset: highlight; }.highlight-card { min-height: 440px; display: flex; flex-direction: column; padding: 24px; }.highlight-card-top { display: flex; justify-content: space-between; align-items: center; }.highlight-number { color: var(--teal-dark); font-size: .72rem; font-weight: 800; letter-spacing: .1em; }.highlight-duration { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: var(--type-meta); }.highlight-duration svg { width: 14px; }.highlight-icon { margin-top: 32px; }.highlight-card h2 { margin: 18px 0 10px; font-size: 1.18rem; letter-spacing: -.02em; }.highlight-card > p { margin: 0; color: var(--muted); line-height: 1.65; }.highlight-proof { display: grid; gap: 4px; margin: auto 0 18px; padding: 13px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }.highlight-proof strong { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; letter-spacing: .06em; }.highlight-proof span { font-size: .77rem; font-weight: 700; }.highlights-next { display: flex; justify-content: space-between; align-items: center; gap: 24px; padding: 24px; }.highlights-next h2 { margin: 0; font-size: 1.15rem; }.highlights-next p:not(.page-eyebrow) { margin: 7px 0 0; color: var(--muted); }.highlights-next-actions { display: flex; flex-wrap: wrap; gap: 9px; }
.login-full-demo { width: 100%; min-height: 44px; margin: -8px 0 18px; }
.login-guide-status { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 28px; margin: -10px 0 16px; color: var(--muted); font-size: .75rem; text-align: center; }
.login-guide-status.is-unavailable { flex-wrap: wrap; color: var(--danger); }
.login-guide-status .link-button { min-height: 32px; padding: 3px 6px; color: inherit; font-size: inherit; }
/* Correlated operation trace */
.operation-trace { margin-bottom: 14px; padding: 22px; }.operation-trace h2 { margin: 0; font-size: 1.15rem; }.operation-trace > div > p:last-child { margin: 7px 0 0; color: var(--muted); }.operation-trace ol { list-style: none; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; margin: 20px 0 0; padding: 0; }.operation-trace li { position: relative; display: grid; grid-template-columns: 34px 1fr; gap: 10px; align-items: start; padding-right: 15px; }.operation-trace li::after { content: ""; position: absolute; top: 16px; left: 34px; right: 0; border-top: 1px solid var(--line-strong); }.operation-trace li:last-child::after { display: none; }.operation-trace li > span { position: relative; z-index: 1; width: 34px; height: 34px; display: grid; place-items: center; color: var(--muted); background: white; border: 1px solid var(--line-strong); border-radius: 50%; font-size: .72rem; font-weight: 800; }.operation-trace li.is-complete > span { color: white; background: var(--success); border-color: var(--success); }.operation-trace li svg { width: 17px; }.operation-trace li div { position: relative; z-index: 1; padding-right: 6px; background: white; }.operation-trace li strong { display: block; font-size: .78rem; }.operation-trace li small { display: block; margin-top: 4px; color: var(--muted); font-size: var(--type-meta); line-height: 1.4; }
/* Knowledge wait and diagnostics */
.knowledge-diagnostics { max-width: 260px; text-align: right; }.knowledge-diagnostics summary { color: var(--teal-dark); font-size: var(--type-meta); font-weight: 700; cursor: pointer; }.knowledge-diagnostics small { display: block; margin-top: 5px; overflow-wrap: anywhere; }.knowledge-progress { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin: 12px 0; padding: 11px; background: var(--surface-subtle); border: 1px solid var(--line); }.knowledge-progress span { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--type-meta); font-weight: 700; }.knowledge-progress span.is-active { color: var(--teal-dark); }.knowledge-progress i, .knowledge-progress svg { width: 15px; height: 15px; flex: 0 0 auto; }.knowledge-progress i { border: 2px solid var(--line-strong); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }
.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); }
.login-brand { height: auto; padding: 0; border: 0; }.login-brand .brand-mark { width: 36px; height: 36px; }.login-brand strong { font-size: 1.05rem; }.login-message { position: relative; z-index: 2; max-width: 620px; margin: auto 0 22px; }.login-message .eyebrow { color: #5eead4; }.login-message h1 { margin: 0; color: white; font-size: clamp(2.8rem, 5.5vw, 5.4rem); line-height: .98; letter-spacing: -.06em; }.login-message > p:last-child { max-width: 520px; margin: 23px 0 0; color: #a9b7c8; font-size: .98rem; line-height: 1.65; }
.control-illustration { position: absolute; width: min(35vw, 500px); aspect-ratio: 1; right: -80px; top: 7vh; opacity: .88; }.illustration-orbit { position: absolute; inset: 10%; border: 1px solid #29435a; border-radius: 50%; }.orbit-two { inset: 28%; border-style: dashed; transform: rotate(35deg); }.illustration-orbit::before, .illustration-orbit::after { content: ""; position: absolute; background: #2dd4bf; border-radius: 50%; }.illustration-orbit::before { width: 7px; height: 7px; top: 13%; left: 16%; }.illustration-orbit::after { width: 5px; height: 5px; right: 2%; bottom: 33%; }.illustration-core { position: absolute; inset: 40%; display: grid; place-items: center; color: white; background: #172a3e; border: 1px solid #2c465e; border-radius: 50%; }.illustration-core svg { width: 45px; }.illustration-node { position: absolute; width: 42px; height: 42px; display: grid; place-items: center; color: #5eead4; background: #152b3f; border: 1px solid #2c465e; border-radius: 50%; }.illustration-node svg { width: 18px; }.node-one { top: 13%; left: 20%; }.node-two { right: 5%; bottom: 27%; }.node-three { left: 15%; bottom: 12%; }
.login-footnote { position: relative; z-index: 2; display: flex; align-items: center; gap: 7px; margin: auto 0 0; color: #7f90a4; font-size: .68rem; }.login-footnote svg { width: 14px; }
.login-access { display: grid; place-items: center; padding: 46px max(38px, 7vw); background: #f8fafb; }.login-panel { width: min(470px, 100%); }.login-panel h2 { margin: 0; color: var(--ink); font-size: 1.7rem; letter-spacing: -.04em; }.login-intro { margin: 9px 0 26px; color: var(--muted); font-size: .8rem; line-height: 1.55; }.login-guide-cta { width: 100%; min-height: 48px; margin-bottom: 18px; font-size: .85rem; }.login-options { display: grid; gap: 10px; }.login-options button { min-height: 76px; display: grid; grid-template-columns: 40px 1fr 18px; align-items: center; gap: 12px; padding: 13px; text-align: left; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: 0 7px 20px rgba(15,23,42,.04); cursor: pointer; transition: border .16s ease, transform .16s ease, box-shadow .16s ease; }.login-options button:hover { transform: translateY(-2px); border-color: var(--teal); box-shadow: var(--shadow-float); }.login-options button > svg { width: 17px; color: var(--teal-dark); }.login-options button > span:nth-child(2) { display: grid; gap: 5px; }.login-options strong { font-size: .8rem; }.login-options small { color: var(--muted); font-size: .66rem; }.role-icon { width: 40px; height: 40px; display: grid !important; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.role-icon svg { width: 18px; }.access-note { display: flex; gap: 10px; margin-top: 22px; padding-top: 20px; color: var(--muted); border-top: 1px solid var(--line); }.access-note > svg { width: 18px; color: var(--teal-dark); }.access-note p { display: grid; gap: 3px; margin: 0; }.access-note strong { color: var(--ink-soft); font-size: .69rem; }.access-note span { font-size: .64rem; }
@media (max-width: 1120px) {
.app-shell { grid-template-columns: 190px minmax(0, 1fr); }.sidebar { width: 190px; }.brand-lockup { padding-inline: 14px; }.readiness-band { grid-template-columns: 160px 1fr; }.readiness-band .inline-action { display: none; }.operations-grid { grid-template-columns: 1fr 1fr; }.timezone { display: none; }.review-facts { grid-template-columns: repeat(3, 1fr); }
}
.knowledge-provider-note { display: flex; align-items: flex-start; gap: 8px; margin: -6px 0 18px; padding: 10px 14px; color: var(--ink-soft); background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); font-size: .72rem; line-height: 1.5; }
.knowledge-provider-note svg { width: 15px; flex-shrink: 0; margin-top: 1px; color: var(--info); }
.knowledge-index-evidence { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: -6px 0 18px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); }
.knowledge-index-evidence > div { display: grid; gap: 3px; padding: 11px 14px; background: white; }
.knowledge-index-evidence strong { font-size: .86rem; }
.knowledge-index-evidence span, .knowledge-index-evidence p { color: var(--muted); font-size: .66rem; line-height: 1.45; }
.knowledge-index-evidence p { grid-column: 1 / -1; margin: 0; padding: 9px 14px; background: var(--surface-subtle); }
@media (max-width: 640px) { .knowledge-index-evidence { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.knowledge-suggestions { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 12px; }
.knowledge-suggestions > span { color: var(--muted); font-size: .68rem; font-weight: 700; }
.suggestion-chip { padding: 6px 11px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: .68rem; font-weight: 600; cursor: pointer; }
.suggestion-chip:hover { background: #d9f0ea; }
.suggestion-chip:disabled { opacity: .6; cursor: not-allowed; }
.demo-start-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 22px; padding: 16px 20px; background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: var(--radius); }
.demo-start-panel > div:first-child { display: flex; align-items: center; gap: 12px; }
.demo-start-panel > div:first-child > svg { width: 22px; height: 22px; color: var(--teal-dark); flex-shrink: 0; }
.demo-start-panel strong { display: block; color: var(--ink); font-size: .85rem; }
.demo-start-panel span { display: block; margin-top: 2px; color: var(--muted); font-size: .72rem; }
.demo-start-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.demo-guide-trigger { display: flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: .68rem; font-weight: 700; cursor: pointer; }
.demo-guide-trigger:hover { background: #d9f0ea; }
.demo-guide-trigger svg { width: 13px; height: 13px; }
.demo-guide-progress-pill { padding: 1px 6px; color: var(--teal-dark); background: white; border-radius: 999px; font-size: .6rem; }
.demo-guide-panel { position: fixed; z-index: 40; top: 0; right: 0; width: min(400px, 92vw); height: 100vh; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: var(--surface); border-left: 1px solid var(--line); box-shadow: var(--shadow-float); overflow-y: auto; }
@media (min-width: 701px) {
/* The guide panel is a fixed right-side overlay at this width -- push page content
aside while it's open so it never sits underneath and blocks actionable buttons. */
.app-workspace.guide-open { padding-right: min(400px, 92vw); transition: padding-right .2s ease; }
.app-workspace.guide-open.guide-collapsed { padding-right: 0; }
}
/* Extra-wide desktop (>=1440px, see useViewportTier): a docked rail rather than a
floating overlay -- guaranteed minimum width, no shadow (it reads as part of the
layout, not something hovering above it), and it never auto-collapses to a chip. */
.demo-guide-panel.is-wide { width: 420px; min-width: 420px; box-shadow: none; }
.demo-guide-chip { position: fixed; z-index: 40; right: 20px; bottom: 20px; display: flex; align-items: stretch; height: 44px; background: white; border: 1px solid #bfe6df; border-radius: 999px; box-shadow: var(--shadow-float); overflow: hidden; }
.demo-guide-chip-expand { display: flex; align-items: center; gap: 8px; padding: 0 14px; color: var(--teal-dark); background: transparent; border: 0; font-size: .72rem; font-weight: 700; cursor: pointer; }
.demo-guide-chip-expand:hover { background: var(--teal-pale); }
.demo-guide-chip-expand svg { width: 14px; }
.demo-guide-chip-close { display: flex; align-items: center; padding: 0 12px; color: var(--muted); background: transparent; border: 0; border-left: 1px solid var(--line); cursor: pointer; }
.demo-guide-chip-close:hover { color: var(--ink); background: var(--surface-subtle); }
.demo-guide-chip-close svg { width: 12px; }
.demo-guide-highlight { animation: demo-guide-pulse 2.2s ease; outline: 3px solid var(--teal); outline-offset: 3px; border-radius: var(--radius); }
@keyframes demo-guide-pulse {
0% { outline-color: var(--teal-dark); }
70% { outline-color: var(--teal-dark); }
100% { outline-color: transparent; }
}
@media (prefers-reduced-motion: reduce) {
.demo-guide-highlight { animation: none; }
}
.demo-guide-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.demo-guide-kicker { margin: 0 0 4px; color: var(--teal-dark); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.demo-guide-header h2 { margin: 0; font-size: 1.05rem; letter-spacing: -.015em; }
.demo-guide-progress-bar { display: flex; gap: 4px; }
.demo-guide-progress-bar span { flex: 1; height: 4px; background: var(--line); border-radius: 2px; }
.demo-guide-progress-bar span.is-done { background: var(--teal); }
.demo-guide-progress-bar span.is-current { background: var(--teal-dark); }
.demo-guide-body { display: grid; gap: 12px; }
.demo-guide-body p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
.demo-guide-body p strong { color: var(--muted); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.demo-guide-steps { display: grid; gap: 3px; padding-top: 12px; border-top: 1px solid var(--line); }
.demo-guide-steps button { display: flex; align-items: center; gap: 7px; padding: 7px 8px; text-align: left; color: var(--muted); background: transparent; border: 0; border-radius: var(--radius); font-size: .68rem; cursor: pointer; }
.demo-guide-steps button svg { width: 12px; color: var(--teal); flex-shrink: 0; }
.demo-guide-steps button:hover { background: var(--surface-subtle); }
.demo-guide-steps button.is-current { color: var(--ink); background: var(--teal-pale); font-weight: 700; }
.demo-guide-footer { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; padding-top: 12px; border-top: 1px solid var(--line); }
.demo-guide-restart { width: 100%; padding: 8px; color: var(--muted); background: transparent; border: 1px dashed var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 600; cursor: pointer; }
.demo-guide-restart:hover { color: var(--ink-soft); border-color: var(--muted-light); }
.demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 960px) {
.privacy-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }.privacy-workspaces { grid-template-columns: 1fr; }
.app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.topbar-meta > .language-switcher-compact { display: none; }.sidebar-language { display: block; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 68px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 44px; min-height: 44px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .69rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 68px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; }
}
@media (max-width: 700px) {
#main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: var(--type-meta); }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; transition: height .2s ease; }.demo-guide-panel.sheet-full { height: min(78vh, 640px); }.demo-guide-panel.sheet-half { height: min(42vh, 340px); }.demo-guide-panel.sheet-collapsed { height: auto; gap: 4px; padding-bottom: 12px; }.demo-guide-sheet-handle { width: 100%; display: flex; justify-content: center; padding: 4px 0 2px; background: transparent; border: 0; cursor: pointer; }.demo-guide-sheet-handle span { width: 36px; height: 4px; background: var(--line-strong); border-radius: 999px; }@media (prefers-reduced-motion: reduce) { .demo-guide-panel { transition: none; } }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { width: 100%; justify-content: flex-start; }.page-description { font-size: var(--type-body); }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: visible; }.metric-cell { min-width: 0; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .65rem; white-space: normal; overflow-wrap: anywhere; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
.filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; }.filter-toggle { display: inline-flex; width: 100%; justify-content: space-between; }.quality-filters { display: none; }.quality-filters.is-open { display: grid; }.active-filter-chips { margin-top: 0; overflow-x: auto; flex-wrap: nowrap; padding-bottom: 3px; }.active-filter-chips button { flex: 0 0 auto; }
.table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { flex-wrap: wrap; gap: 7px; min-height: 52px; border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.table-meta > span:last-child { display: none; }.select-visible { min-height: 38px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { position: relative; display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 40px; height: auto; display: grid; grid-template-columns: minmax(92px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 8px 12px; border: 0; text-align: right; font-size: var(--type-body); }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: var(--type-label); font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.quality-table tr { padding-top: 47px; }.quality-table .selection-cell { position: absolute; z-index: 3; top: 8px; right: 11px; min-height: 32px; display: flex; width: 32px; padding: 7px; }.quality-table .selection-cell::before { display: none; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; }.bulk-toolbar { position: sticky; bottom: 76px; z-index: 8; display: grid; grid-template-columns: 1fr 1fr; border: 1px solid #bfe6df; border-radius: var(--radius); box-shadow: var(--shadow-float); }.bulk-toolbar strong { grid-column: 1 / -1; }.bulk-toolbar label { min-width: 0; }.bulk-toolbar .button { min-width: 0; }
.tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; }
}
@media (max-width: 700px) {
:root { --type-body: .875rem; --type-meta: .75rem; --type-label: .75rem; --type-badge: .75rem; }
.icon-button, .language-switcher select, .demo-badge-trigger, .demo-guide-trigger, .back-link, .section-heading > a, .data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button, .toggle-matching-fields, .button-tertiary, .bulk-toolbar select, .bulk-toolbar input, .state-retry { min-width: 44px; min-height: 44px; }
.demo-guide-trigger { width: 44px; justify-content: center; }.demo-badge-trigger { width: 44px; height: 44px; padding: 0; justify-content: center; font-size: 0; }.demo-badge-trigger svg { width: 16px; height: 16px; color: #48566a; }.language-switcher select { height: 44px; }
.mobile-nav span { max-width: 54px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.engineering-hero, .highlights-next { align-items: stretch; flex-direction: column; padding: 20px; }.engineering-hero-actions { min-width: 0; }.proof-grid, .highlight-grid { grid-template-columns: 1fr; }.highlight-card { min-height: 0; }.highlight-proof { margin-top: 22px; }.highlights-next-actions { display: grid; }.operation-trace ol { grid-template-columns: 1fr; gap: 13px; }.operation-trace li { min-height: 54px; }.operation-trace li::after { top: 34px; bottom: -13px; left: 16px; right: auto; border-top: 0; border-left: 1px solid var(--line-strong); }.operation-trace li div { padding-bottom: 5px; }.knowledge-status { align-items: flex-start; }.knowledge-diagnostics { max-width: none; text-align: left; }.knowledge-progress { grid-template-columns: 1fr; }.retrieval-flow span { font-size: var(--type-meta); }.return-progress span { font-size: var(--type-meta); }.duplicate-compare > button { position: sticky; bottom: 78px; z-index: 6; width: 100%; box-shadow: var(--shadow-float); }
.resolution-actions { position: sticky; bottom: 72px; z-index: 6; padding: 9px; background: rgba(255,255,255,.97); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }.resolution-actions button { flex: 1; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.route-skeleton span, .knowledge-progress i { animation: none; }
}
@media (max-width: 700px) {
.page-header { flex-direction: column; }
.page-actions > .button:only-child { width: 100%; min-height: 44px; }
}
@media (max-width: 440px) {
.global-search { max-width: 144px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 58px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: var(--type-badge); }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; }
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />