fix: localize dashboard evidence, explain blocked vehicles, clarify pending odometers

Three content defects found by a live reviewer:

- Dashboard attention subtext was raw, untranslated evidence.summary text, and for
  11 of 15 seeded issues that text was literally "Synthetic deterministic seed
  issue". AttentionItem now exposes evidence_signals (stable code + params, same
  shape as the issue detail page) instead of a detail string; the frontend renders
  them through a shared describeEvidenceSignal() used by both the dashboard and the
  issue detail page. Every previously-placeholder seed row now cites a real,
  per-rule-type fact (a genuinely crossed service threshold, a genuinely blank
  field, or a real pair of booking odometer readings) instead of invented prose.

- 5 of 7 blocked vehicles had no quality issue at all and one had only a resolved
  one, so "needs attention" led nowhere. Each now has a real open
  missing_required_field issue backed by a genuinely blank field (no schema change,
  no migration -- reuses the existing data-quality pipeline).

- Booking odometer fields showing a bare "-" for 25 reserved + 1 active booking now
  show a localized explanation ("trip hasn't started yet" / "not yet closed").
  MO-024's rented-but-service-overdue contradiction was already caught by the
  vehicle-status evaluator (DQ-SCAN, vehicle.manual_review_required) -- added a
  regression test rather than new logic.

Also fixed a related bug the above exposed: the vehicle entity_snapshot omitted
registration_number entirely, so the "provide missing fields" form always showed
it blank regardless of the real value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-05 17:44:32 +02:00
co-authored by Claude Sonnet 5
parent 3808bbe132
commit 4faac24b5a
21 changed files with 349 additions and 132 deletions
+11 -1
View File
@@ -19,6 +19,7 @@ from app.schemas import (
AutomationRunOut,
CurrentUser,
DashboardOut,
EvidenceSignalOut,
TodayItem,
)
from app.services.operations import compute_metrics
@@ -61,12 +62,21 @@ def get_dashboard(
entity = customers_by_id.get(issue.entity_id)
link_type = "customer"
link_ref = entity.public_ref if entity else ""
# The backend never emits prose for the attention queue -- only stable signal
# codes + raw data params, exactly like the issue detail page's evidence list
# (see app/services/data_quality.py::_open_issue). The frontend is the one place
# that turns these into the operator's selected language; `evidence_json["summary"]`
# is a technical fallback only, never rendered here.
signals = [
EvidenceSignalOut(code=s["code"], params=s.get("params", {}))
for s in issue.evidence_json.get("signals", [])
]
attention_items.append(
AttentionItem(
kind="quality_issue",
severity=issue.severity,
rule_type=issue.rule_type,
detail=issue.evidence_json.get("summary", ""),
evidence_signals=signals,
link_type=link_type,
link_ref=link_ref,
issue_ref=issue.public_ref,
+1
View File
@@ -115,6 +115,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
return {
"entity_type": "vehicle",
"public_ref": vehicle.public_ref,
"registration_number": vehicle.registration_number,
"make": vehicle.make,
"model": vehicle.model,
"location": vehicle.location,
+6 -1
View File
@@ -350,11 +350,16 @@ class DashboardMetrics(BaseModel):
pending_or_failed_workflows: int
class EvidenceSignalOut(BaseModel):
code: str
params: dict[str, Any] = Field(default_factory=dict)
class AttentionItem(BaseModel):
kind: Literal["quality_issue", "vehicle"]
severity: str
rule_type: str
detail: str
evidence_signals: list[EvidenceSignalOut] = Field(default_factory=list)
link_type: Literal["vehicle", "booking", "customer"]
link_ref: str
issue_ref: str | None = None
+109 -31
View File
@@ -139,47 +139,49 @@ def load_seed(db: Session) -> SeedResult:
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
vehicle_rows = []
vehicle_row_by_ref: dict[str, dict] = {}
for row in _read_csv("vehicles.csv"):
vid = uuid.uuid4()
vehicle_id_by_ref[row["public_ref"]] = vid
vehicle_rows.append(
{
"id": vid,
"public_ref": row["public_ref"],
"make": row["make"],
"model": row["model"],
"model_year": int(row["model_year"]),
"registration_number": row["registration_number"],
"location": row["location"],
"operational_status": row["operational_status"],
"odometer_km": int(row["odometer_km"]),
"next_service_km": int(row["next_service_km"]),
"active": _parse_bool(row["active"]),
"version": 1,
}
)
vehicle_row = {
"id": vid,
"public_ref": row["public_ref"],
"make": row["make"],
"model": row["model"],
"model_year": int(row["model_year"]),
"registration_number": row["registration_number"],
"location": row["location"],
"operational_status": row["operational_status"],
"odometer_km": int(row["odometer_km"]),
"next_service_km": int(row["next_service_km"]),
"active": _parse_bool(row["active"]),
"version": 1,
}
vehicle_rows.append(vehicle_row)
vehicle_row_by_ref[row["public_ref"]] = vehicle_row
db.execute(insert(Vehicle), vehicle_rows)
counts["vehicles"] = len(vehicle_rows)
booking_id_by_ref: dict[str, uuid.UUID] = {}
booking_rows = []
booking_row_by_ref: dict[str, dict] = {}
for row in _read_csv("bookings.csv"):
bid = uuid.uuid4()
booking_id_by_ref[row["public_ref"]] = bid
booking_rows.append(
{
"id": bid,
"public_ref": row["public_ref"],
"customer_id": customer_id_by_ref[row["customer_ref"]],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"starts_at": _parse_dt(row["starts_at"]) + shift,
"ends_at": _parse_dt(row["ends_at"]) + shift,
"status": row["status"],
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
"requirements_complete": _parse_bool(row["requirements_complete"]),
}
)
booking_row = {
"id": bid,
"public_ref": row["public_ref"],
"customer_id": customer_id_by_ref[row["customer_ref"]],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"starts_at": _parse_dt(row["starts_at"]) + shift,
"ends_at": _parse_dt(row["ends_at"]) + shift,
"status": row["status"],
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
"requirements_complete": _parse_bool(row["requirements_complete"]),
}
booking_rows.append(booking_row)
booking_row_by_ref[row["public_ref"]] = booking_row
db.execute(insert(Booking), booking_rows)
counts["bookings"] = len(booking_rows)
@@ -225,6 +227,82 @@ def load_seed(db: Session) -> SeedResult:
return "customer", customer_id_by_ref[entity_ref]
return "vehicle", vehicle_id_by_ref[entity_ref]
def _vehicle_conflict_facts(vehicle_ref: str, *, service_threshold_reached: bool) -> dict:
# Mirrors app.services.vehicle_status.VehicleStatusFacts.as_dict() for the
# handful of seed-only rows below -- none of them carry an active rental or a
# real booking conflict (verified against the fixed seed dataset), only a
# genuinely-crossed service threshold or none at all, so those two fields are
# the only ones that vary per vehicle.
vehicle = vehicle_row_by_ref[vehicle_ref]
return {
"active_booking_refs": [],
"overlapping_booking_pairs": [],
"service_threshold_reached": service_threshold_reached,
"odometer_km": vehicle["odometer_km"],
"next_service_km": vehicle["next_service_km"],
"open_booking_overlap_issue_ref": None,
}
def _odometer_regression_signal(later_ref: str, earlier_ref: str) -> list[dict]:
later = booking_row_by_ref[later_ref]
earlier = booking_row_by_ref[earlier_ref]
return [
{
"code": "odometer.regression",
"params": {
"later_ref": later_ref,
"later_km": later["end_odometer_km"],
"earlier_ref": earlier_ref,
"earlier_km": earlier["end_odometer_km"],
},
}
]
def _missing_field_signal(field: str) -> list[dict]:
return [{"code": "missing_field", "params": {"field": field}}]
# Every seed-only row below (i.e. not one of the four named DQ-DEMO-* scenarios)
# used to carry no structured signal at all -- just the placeholder summary
# "Synthetic deterministic seed issue". Each now cites a real fact about its actual
# entity (a genuinely-crossed service threshold, a genuinely-blank field, or a real
# pair of booking odometer readings engineered into seed/bookings.csv), using the
# exact same signal vocabulary the live scan (app.services.data_quality) already
# renders through -- see docs/fleet-ops-correction/current-gap-audit.md §6.
_SEED_SIGNALS_BY_REF: dict[str, list[dict]] = {
"DQ-0005": [
{
"code": "vehicle.service_threshold_reached",
"params": _vehicle_conflict_facts("MO-036", service_threshold_reached=True),
}
],
"DQ-0006": _missing_field_signal("location"),
"DQ-0007": _odometer_regression_signal("BK-H-0007", "BK-H-0057"),
"DQ-0008": [
{
"code": "vehicle.rental_ended",
"params": _vehicle_conflict_facts("MO-007", service_threshold_reached=False),
}
],
"DQ-0009": _missing_field_signal("location"),
"DQ-0010": _odometer_regression_signal("BK-H-0010", "BK-H-0060"),
"DQ-0011": [
{
"code": "vehicle.service_threshold_reached",
"params": _vehicle_conflict_facts("MO-028", service_threshold_reached=True),
}
],
"DQ-0012": _missing_field_signal("registration_number"),
"DQ-0013": _missing_field_signal("location"),
"DQ-0014": _missing_field_signal("registration_number"),
"DQ-0015": _missing_field_signal("location"),
"DQ-0016": _missing_field_signal("location"),
"DQ-0017": _missing_field_signal("location"),
"DQ-0018": _missing_field_signal("registration_number"),
"DQ-0019": _missing_field_signal("location"),
"DQ-0020": _missing_field_signal("location"),
"DQ-0021": _missing_field_signal("location"),
}
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
# they carry real, accurate structured signals (not just a legacy English
@@ -253,7 +331,7 @@ def load_seed(db: Session) -> SeedResult:
"params": {"booking_ref": related_refs[0] if related_refs else ""},
}
]
return []
return _SEED_SIGNALS_BY_REF.get(public_ref, [])
dq_rows = []
now = datetime.now(UTC)
+16
View File
@@ -24,6 +24,22 @@ def test_dashboard_attention_items_link_to_records(ops_client):
assert item["severity"] in ("low", "medium", "high")
def test_dashboard_attention_items_expose_localizable_signals_not_raw_text(ops_client):
"""The dashboard subtext used to be raw, untranslated evidence text (and for most
seeded issues, the meaningless placeholder 'Synthetic deterministic seed issue').
The API must never emit prose here -- only stable signal codes + params, exactly
like the data-quality issue detail page, for the frontend to localize."""
response = ops_client.get("/api/v1/dashboard")
body = response.json()
assert len(body["attention_items"]) > 0
for item in body["attention_items"]:
assert "detail" not in item
assert len(item["evidence_signals"]) > 0
for signal in item["evidence_signals"]:
assert signal["code"]
assert signal["code"] != "Synthetic deterministic seed issue"
def test_dashboard_recent_automation_capped_at_five(ops_client):
response = ops_client.get("/api/v1/dashboard")
body = response.json()
+15
View File
@@ -220,6 +220,21 @@ def test_provide_fields_resolves_a_vehicle_missing_field_issue(ops_client):
assert vehicle["registration_number"] == "TST-999"
def test_vehicle_entity_snapshot_includes_registration_number(ops_client):
"""The snapshot used to omit registration_number entirely, so the 'provide missing
fields' form always showed it blank -- even for a vehicle whose plate was actually
on file, and even when a *different* field was the genuinely missing one."""
issues = ops_client.get(
"/api/v1/data-quality/issues",
params={"rule_type": "missing_required_field", "status": "open"},
).json()
target = next(i for i in issues if i["entity_type"] == "vehicle")
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
assert detail["entity_snapshot"]["registration_number"] == vehicle["registration_number"]
def test_resolve_overlap_requires_operations_manager(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
+77 -3
View File
@@ -23,12 +23,13 @@ def test_seed_counts_match_deterministic_dataset():
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
assert db.scalar(select(func.count()).select_from(Customer)) == 180
assert db.scalar(select(func.count()).select_from(Booking)) == 246
# 15 from the CSV plus a deterministic set discovered by the post-seed scan. The
# shared vehicle-status evaluator (app.services.vehicle_status) now also catches
# 21 from the CSV (15 original + 6 giving every unexplained blocked vehicle a
# real open issue) plus a deterministic set discovered by the post-seed scan. The
# shared vehicle-status evaluator (app.services.vehicle_status) also catches
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
# already crossed its service-due odometer threshold -- a genuine conflict the
# previous hand-rolled scanner never checked for.
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 27
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 33
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
assert db.scalar(select(func.count()).select_from(User)) == 2
finally:
@@ -200,3 +201,76 @@ def test_seed_today_movements_are_a_credible_mix():
assert len(returns) >= 2
finally:
db.close()
def test_every_blocked_vehicle_has_a_real_open_issue():
"""A live reviewer found blocked vehicles with no explanation anywhere in the UI --
5 with zero quality issues at all, one (MO-049) with only a resolved one. Every
vehicle seeded as 'blocked' must now have at least one real, currently open
DataQualityIssue an operator can click through to."""
db = SessionLocal()
try:
reset_and_seed(db)
blocked = db.scalars(select(Vehicle).where(Vehicle.operational_status == "blocked")).all()
assert len(blocked) > 0
for vehicle in blocked:
open_issue = db.scalar(
select(DataQualityIssue).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
)
)
assert open_issue is not None, f"{vehicle.public_ref} is blocked with no open issue"
finally:
db.close()
def test_seed_scenario_mo024_service_conflict_is_flagged():
"""Regression lock-in for the live-reported MO-024 defect: 'rented' with a real
active booking (BK-DEMO-RETURN) yet already 14,820 km past its service threshold,
with nothing surfacing the contradiction. The shared vehicle-status evaluator
already catches this (a real conflict, not a fabricated third status) -- this test
exists so a future change can't silently regress it back to unexplained."""
db = SessionLocal()
try:
reset_and_seed(db)
vehicle = _by_ref(db, Vehicle, "MO-024")
assert vehicle is not None
assert vehicle.operational_status == "rented"
assert vehicle.odometer_km >= vehicle.next_service_km
issue = db.scalar(
select(DataQualityIssue).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.rule_type == "vehicle_status_conflict",
)
)
assert issue is not None
signals = issue.evidence_json.get("signals", [])
assert any(s["code"] == "vehicle.manual_review_required" for s in signals)
finally:
db.close()
def test_seed_evidence_has_no_placeholder_summary():
"""Every seed-only data-quality issue used to carry the vacuous evidence
'Synthetic deterministic seed issue' with no structured signal at all -- a visitor
had no way to understand why it needed attention. Every issue must now carry a real
summary and at least one localizable signal (code + params)."""
db = SessionLocal()
try:
reset_and_seed(db)
issues = db.scalars(select(DataQualityIssue)).all()
assert len(issues) > 0
for issue in issues:
summary = issue.evidence_json.get("summary", "")
assert summary != "Synthetic deterministic seed issue", (
f"{issue.public_ref} still has the meaningless placeholder summary"
)
signals = issue.evidence_json.get("signals", [])
assert len(signals) > 0, f"{issue.public_ref} has no structured evidence signal"
finally:
db.close()
+6 -1
View File
@@ -85,11 +85,16 @@ export interface DashboardMetrics {
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;
detail: string;
evidence_signals: EvidenceSignal[];
link_type: "vehicle" | "booking" | "customer";
link_ref: string;
issue_ref: string | null;
+42
View File
@@ -0,0 +1,42 @@
import type { EvidenceSignal } from "../api/types";
type TFunction = (key: string, options?: Record<string, unknown>) => string;
// The backend never emits prose for data-quality evidence -- only stable signal codes
// plus raw data params (see backend/app/services/data_quality.py::_open_issue). This is
// the one place that turns them into the operator's selected language, shared by the
// issue detail page and the dashboard attention queue so the same code always reads the
// same way everywhere it appears.
export function describeEvidenceSignal(
t: TFunction,
formatNumber: (value: number) => string,
signal: EvidenceSignal,
): string {
const { code, params = {} } = signal;
if (code.startsWith("vehicle.")) {
return t(`quality:detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
}
switch (code) {
case "missing_field":
return t("quality:detail.evidence.missingField", {
field: t(`quality:detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
});
case "overlap.reserved_bookings":
return t("quality:detail.evidence.overlapReservedBookings", {
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
});
case "odometer.regression":
return t("quality:detail.evidence.odometerRegression", {
laterRef: params.later_ref,
laterKm: formatNumber(Number(params.later_km ?? 0)),
earlierRef: params.earlier_ref,
earlierKm: formatNumber(Number(params.earlier_km ?? 0)),
});
case "duplicate.similar_name":
return t("quality:detail.evidence.duplicateSimilarName", { score: params.score });
case "attention.upcoming_booking_missing_inspection":
return t("quality:detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
default:
return t(`quality:detail.evidence.${code}`, { defaultValue: code });
}
}
@@ -45,6 +45,8 @@
"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",
"yes": "Yes",
"no": "No"
+1 -2
View File
@@ -109,8 +109,7 @@
"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.",
"seedPlaceholder": "Synthetic seed data with no further detail."
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing."
},
"duplicateCustomer": {
"heading": "Compare and merge",
@@ -45,6 +45,8 @@
"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",
"yes": "Oui",
"no": "Non"
+1 -2
View File
@@ -109,8 +109,7 @@
"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.",
"seedPlaceholder": "Donnée de démonstration synthétique sans autre détail."
"upcomingBookingMissingInspection": "La réservation {{bookingRef}} commence bientôt mais l'inspection opérationnelle requise est encore manquante."
},
"duplicateCustomer": {
"heading": "Comparer et fusionner",
@@ -45,6 +45,8 @@
"ends": "Einde",
"startOdometer": "Startkilometerstand",
"endOdometer": "Eindkilometerstand",
"startOdometerPending": "Nog niet vastgelegd — rit nog niet gestart",
"endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten",
"requirementsComplete": "Vereisten volledig",
"yes": "Ja",
"no": "Nee"
+1 -2
View File
@@ -109,8 +109,7 @@
"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.",
"seedPlaceholder": "Synthetisch demogegeven zonder verder detail."
"upcomingBookingMissingInspection": "Boeking {{bookingRef}} start binnenkort maar de vereiste operationele inspectie ontbreekt nog."
},
"duplicateCustomer": {
"heading": "Vergelijken en samenvoegen",
+2 -2
View File
@@ -68,8 +68,8 @@ export function BookingDetail() {
<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` : "—"}</dd></div>
<div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : "—"}</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>
+18 -5
View File
@@ -12,6 +12,8 @@ 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" },
@@ -29,9 +31,20 @@ function attentionItemTitle(
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"]);
const { formatTime, formatShortDate } = useLocaleFormat();
const { t } = useTranslation(["dashboard", "common", "integrations", "quality"]);
const { formatTime, formatShortDate, formatNumber } = useLocaleFormat();
const greetingPeriod = useGreetingPeriod();
const { user } = useAuth();
const { manifest } = useDemoManifest();
@@ -71,9 +84,9 @@ export function Dashboard() {
const attention = useMemo(() => data?.attention_items.filter((item) => {
const matchesSeverity = severity === "all" || item.severity === severity;
const haystack = `${attentionItemTitle(t, item)} ${item.detail} ${item.link_ref}`.toLowerCase();
const haystack = `${attentionItemTitle(t, item)} ${attentionItemDetail(t, formatNumber, item)} ${item.link_ref}`.toLowerCase();
return matchesSeverity && haystack.includes(query.toLowerCase());
}) ?? [], [data, query, severity, t]);
}) ?? [], [data, query, severity, t, formatNumber]);
const isUnfiltered = severity === "all" && query === "";
const attentionTiers = useMemo(() => {
@@ -157,7 +170,7 @@ export function Dashboard() {
<SeverityBadge severity={item.severity} />
<div className="queue-copy">
<p className="attention-title">{title}</p>
<p className="attention-detail">{item.detail}</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" />
+8 -59
View File
@@ -15,6 +15,8 @@ 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";
@@ -47,74 +49,21 @@ function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
);
}
interface EvidenceSignal {
code: string;
params?: Record<string, unknown>;
}
// The backend never emits prose for evidence -- only stable signal codes + raw data
// params (see app/services/data_quality.py::_open_issue). This 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.
// A handful of seed-only rows (generic filler, not tied to a scripted demo scenario)
// carry no structured signals -- rather than show nothing, this known placeholder
// summary gets a localized rendering. Any other un-signalled legacy text still falls
// back to the raw string (better than a blank primary evidence area), but only
// "Technical details" is meant to guarantee raw-text visibility.
const SEED_PLACEHOLDER_SUMMARY = "Synthetic deterministic seed issue";
// 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) {
const rawSummary = String(issue.evidence.summary ?? "");
if (!rawSummary) return null;
const text =
rawSummary === SEED_PLACEHOLDER_SUMMARY
? t("detail.evidence.seedPlaceholder")
: rawSummary;
return (
<ul className="evidence-signal-list">
<li>{text}</li>
</ul>
);
}
function describe(signal: EvidenceSignal): string {
const { code, params = {} } = signal;
if (code.startsWith("vehicle.")) {
return t(`detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
}
switch (code) {
case "missing_field":
return t("detail.evidence.missingField", {
field: t(`detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
});
case "overlap.reserved_bookings":
return t("detail.evidence.overlapReservedBookings", {
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
});
case "odometer.regression":
return t("detail.evidence.odometerRegression", {
laterRef: params.later_ref,
laterKm: formatNumber(Number(params.later_km ?? 0)),
earlierRef: params.earlier_ref,
earlierKm: formatNumber(Number(params.earlier_km ?? 0)),
});
case "duplicate.similar_name":
return t("detail.evidence.duplicateSimilarName", { score: params.score });
case "attention.upcoming_booking_missing_inspection":
return t("detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
default:
return t(`detail.evidence.${code}`, { defaultValue: code });
}
}
if (signals.length === 0) return null;
return (
<ul className="evidence-signal-list">
{signals.map((signal, index) => (
<li key={`${signal.code}-${index}`}>{describe(signal)}</li>
<li key={`${signal.code}-${index}`}>{describeEvidenceSignal(t, formatNumber, signal)}</li>
))}
</ul>
);
+2 -2
View File
@@ -5,10 +5,10 @@ BK-H-0003,CUS-0034,MO-022,2026-07-22T06:00:00Z,2026-07-27T06:00:00Z,returned,314
BK-H-0004,CUS-0045,MO-029,2026-07-20T05:00:00Z,2026-07-26T05:00:00Z,returned,36708,36912,true
BK-H-0005,CUS-0056,MO-036,2026-07-18T04:00:00Z,2026-07-25T04:00:00Z,returned,41655,41860,true
BK-H-0006,CUS-0067,MO-043,2026-07-16T03:00:00Z,2026-07-24T03:00:00Z,returned,46513,46719,true
BK-H-0007,CUS-0078,MO-050,2026-07-14T02:00:00Z,2026-07-23T02:00:00Z,returned,51685,51892,true
BK-H-0007,CUS-0078,MO-050,2026-07-14T02:00:00Z,2026-07-23T02:00:00Z,returned,51685,51700,true
BK-H-0008,CUS-0089,MO-007,2026-07-12T09:00:00Z,2026-07-22T09:00:00Z,returned,20681,20889,true
BK-H-0009,CUS-0100,MO-014,2026-07-10T08:00:00Z,2026-07-21T08:00:00Z,returned,25419,25628,true
BK-H-0010,CUS-0111,MO-021,2026-07-18T07:00:00Z,2026-07-20T07:00:00Z,returned,31088,31298,true
BK-H-0010,CUS-0111,MO-021,2026-07-18T07:00:00Z,2026-07-20T07:00:00Z,returned,31088,31150,true
BK-H-0011,CUS-0122,MO-028,2026-07-16T06:00:00Z,2026-07-19T06:00:00Z,returned,35948,36159,true
BK-H-0012,CUS-0133,MO-035,2026-07-14T05:00:00Z,2026-07-18T05:00:00Z,returned,41022,41234,true
BK-H-0013,CUS-0144,MO-042,2026-07-12T04:00:00Z,2026-07-17T04:00:00Z,returned,46535,46748,true
1 public_ref customer_ref vehicle_ref starts_at ends_at status start_odometer_km end_odometer_km requirements_complete
5 BK-H-0004 CUS-0045 MO-029 2026-07-20T05:00:00Z 2026-07-26T05:00:00Z returned 36708 36912 true
6 BK-H-0005 CUS-0056 MO-036 2026-07-18T04:00:00Z 2026-07-25T04:00:00Z returned 41655 41860 true
7 BK-H-0006 CUS-0067 MO-043 2026-07-16T03:00:00Z 2026-07-24T03:00:00Z returned 46513 46719 true
8 BK-H-0007 CUS-0078 MO-050 2026-07-14T02:00:00Z 2026-07-23T02:00:00Z returned 51685 51892 51700 true
9 BK-H-0008 CUS-0089 MO-007 2026-07-12T09:00:00Z 2026-07-22T09:00:00Z returned 20681 20889 true
10 BK-H-0009 CUS-0100 MO-014 2026-07-10T08:00:00Z 2026-07-21T08:00:00Z returned 25419 25628 true
11 BK-H-0010 CUS-0111 MO-021 2026-07-18T07:00:00Z 2026-07-20T07:00:00Z returned 31088 31298 31150 true
12 BK-H-0011 CUS-0122 MO-028 2026-07-16T06:00:00Z 2026-07-19T06:00:00Z returned 35948 36159 true
13 BK-H-0012 CUS-0133 MO-035 2026-07-14T05:00:00Z 2026-07-18T05:00:00Z returned 41022 41234 true
14 BK-H-0013 CUS-0144 MO-042 2026-07-12T04:00:00Z 2026-07-17T04:00:00Z returned 46535 46748 true
+17 -11
View File
@@ -3,14 +3,20 @@ DQ-DEMO-DUPLICATE,possible_duplicate_customer,CUS-0012,CUS-0178,high,open,exact
DQ-DEMO-OVERLAP,booking_overlap,MO-016,BK-DEMO-OVERLAP-A|BK-DEMO-OVERLAP-B,high,open,controlled legacy import overlap
DQ-DEMO-STATUS,vehicle_status_conflict,MO-016,,high,open,vehicle marked available while reserved bookings conflict
DQ-DEMO-ATTENTION,missing_required_field,MO-031,BK-DEMO-NEXT,high,open,near-future booking; required operational inspection missing
DQ-0005,vehicle_status_conflict,MO-036,,high,open,Synthetic deterministic seed issue
DQ-0006,missing_required_field,MO-043,,low,open,Synthetic deterministic seed issue
DQ-0007,odometer_regression,MO-050,,medium,open,Synthetic deterministic seed issue
DQ-0008,vehicle_status_conflict,MO-007,,high,open,Synthetic deterministic seed issue
DQ-0009,missing_required_field,MO-014,,low,open,Synthetic deterministic seed issue
DQ-0010,odometer_regression,MO-021,,medium,open,Synthetic deterministic seed issue
DQ-0011,vehicle_status_conflict,MO-028,,high,open,Synthetic deterministic seed issue
DQ-0012,missing_required_field,MO-035,,low,resolved,Synthetic deterministic seed issue
DQ-0013,odometer_regression,MO-042,,medium,resolved,Synthetic deterministic seed issue
DQ-0014,vehicle_status_conflict,MO-049,,high,resolved,Synthetic deterministic seed issue
DQ-0015,missing_required_field,MO-006,,low,resolved,Synthetic deterministic seed issue
DQ-0005,vehicle_status_conflict,MO-036,,high,open,Recommended status: maintenance (44660 km reported against a 44000 km service threshold)
DQ-0006,missing_required_field,MO-043,,low,open,Missing: location
DQ-0007,odometer_regression,MO-050,,medium,open,Booking BK-H-0007 recorded 51700 km, below the 51892 km recorded by earlier booking BK-H-0057.
DQ-0008,vehicle_status_conflict,MO-007,,high,open,Recommended status: available (marked rented with no active booking)
DQ-0009,missing_required_field,MO-014,,low,open,Missing: location
DQ-0010,odometer_regression,MO-021,,medium,open,Booking BK-H-0010 recorded 31150 km, below the 31298 km recorded by earlier booking BK-H-0060.
DQ-0011,vehicle_status_conflict,MO-028,,high,open,Recommended status: maintenance (38959 km reported against a 38000 km service threshold)
DQ-0012,missing_required_field,MO-035,,low,resolved,Missing: registration_number
DQ-0013,missing_required_field,MO-042,,low,resolved,Missing: location
DQ-0014,missing_required_field,MO-049,,low,resolved,Missing: registration_number
DQ-0015,missing_required_field,MO-006,,low,resolved,Missing: location
DQ-0016,missing_required_field,MO-009,,medium,open,Missing: location
DQ-0017,missing_required_field,MO-025,,medium,open,Missing: location
DQ-0018,missing_required_field,MO-026,,medium,open,Missing: registration_number
DQ-0019,missing_required_field,MO-041,,medium,open,Missing: location
DQ-0020,missing_required_field,MO-045,,medium,open,Missing: location
DQ-0021,missing_required_field,MO-049,,medium,open,Missing: location
1 public_ref public_ref,rule_type,entity_ref,related_ref,severity,status,evidence rule_type entity_ref related_ref severity status evidence
3 DQ-DEMO-OVERLAP DQ-DEMO-OVERLAP,booking_overlap,MO-016,BK-DEMO-OVERLAP-A|BK-DEMO-OVERLAP-B,high,open,controlled legacy import overlap booking_overlap MO-016 BK-DEMO-OVERLAP-A|BK-DEMO-OVERLAP-B high open controlled legacy import overlap
4 DQ-DEMO-STATUS DQ-DEMO-STATUS,vehicle_status_conflict,MO-016,,high,open,vehicle marked available while reserved bookings conflict vehicle_status_conflict MO-016 high open vehicle marked available while reserved bookings conflict
5 DQ-DEMO-ATTENTION DQ-DEMO-ATTENTION,missing_required_field,MO-031,BK-DEMO-NEXT,high,open,near-future booking; required operational inspection missing missing_required_field MO-031 BK-DEMO-NEXT high open near-future booking; required operational inspection missing
6 DQ-0005 DQ-0005,vehicle_status_conflict,MO-036,,high,open,Recommended status: maintenance (44660 km reported against a 44000 km service threshold) vehicle_status_conflict MO-036 high open Synthetic deterministic seed issue
7 DQ-0006 DQ-0006,missing_required_field,MO-043,,low,open,Missing: location missing_required_field MO-043 low open Synthetic deterministic seed issue
8 DQ-0007 DQ-0007,odometer_regression,MO-050,,medium,open,Booking BK-H-0007 recorded 51700 km, below the 51892 km recorded by earlier booking BK-H-0057. odometer_regression MO-050 medium open Synthetic deterministic seed issue
9 DQ-0008 DQ-0008,vehicle_status_conflict,MO-007,,high,open,Recommended status: available (marked rented with no active booking) vehicle_status_conflict MO-007 high open Synthetic deterministic seed issue
10 DQ-0009 DQ-0009,missing_required_field,MO-014,,low,open,Missing: location missing_required_field MO-014 low open Synthetic deterministic seed issue
11 DQ-0010 DQ-0010,odometer_regression,MO-021,,medium,open,Booking BK-H-0010 recorded 31150 km, below the 31298 km recorded by earlier booking BK-H-0060. odometer_regression MO-021 medium open Synthetic deterministic seed issue
12 DQ-0011 DQ-0011,vehicle_status_conflict,MO-028,,high,open,Recommended status: maintenance (38959 km reported against a 38000 km service threshold) vehicle_status_conflict MO-028 high open Synthetic deterministic seed issue
13 DQ-0012 DQ-0012,missing_required_field,MO-035,,low,resolved,Missing: registration_number missing_required_field MO-035 low resolved Synthetic deterministic seed issue
14 DQ-0013 DQ-0013,missing_required_field,MO-042,,low,resolved,Missing: location odometer_regression MO-042 medium resolved Synthetic deterministic seed issue
15 DQ-0014 DQ-0014,missing_required_field,MO-049,,low,resolved,Missing: registration_number vehicle_status_conflict MO-049 high resolved Synthetic deterministic seed issue
16 DQ-0015 DQ-0015,missing_required_field,MO-006,,low,resolved,Missing: location missing_required_field MO-006 low resolved Synthetic deterministic seed issue
17 DQ-0016,missing_required_field,MO-009,,medium,open,Missing: location
18 DQ-0017,missing_required_field,MO-025,,medium,open,Missing: location
19 DQ-0018,missing_required_field,MO-026,,medium,open,Missing: registration_number
20 DQ-0019,missing_required_field,MO-041,,medium,open,Missing: location
21 DQ-0020,missing_required_field,MO-045,,medium,open,Missing: location
22 DQ-0021,missing_required_field,MO-049,,medium,open,Missing: location
+10 -10
View File
@@ -7,12 +7,12 @@ MO-005,Bürstner,Lyseo,2024,2-MOB-005,Geel,available,22213,30000,true
MO-006,Sunlight,Cliff,2025,2-MOB-006,Geel,cleaning,23163,30000,true
MO-007,Adria,Matrix,2019,2-MOB-007,Geel,rented,23689,30000,true
MO-008,Dethleffs,Trend,2020,2-MOB-008,Geel,available,23933,30000,true
MO-009,Carado,T-Series,2021,2-MOB-009,Geel,blocked,25203,30000,true
MO-009,Carado,T-Series,2021,2-MOB-009,,blocked,25203,30000,true
MO-010,Hymer,Exsis,2022,2-MOB-010,Geel,available,25333,30000,true
MO-011,Bürstner,Lyseo,2023,2-MOB-011,Geel,available,26160,30000,true
MO-012,Sunlight,Cliff,2024,2-MOB-012,Geel,available,26859,30000,true
MO-013,Adria,Matrix,2025,2-MOB-013,Geel,rented,27750,30000,true
MO-014,Dethleffs,Trend,2019,2-MOB-014,Geel,rented,28428,30000,true
MO-014,Dethleffs,Trend,2019,2-MOB-014,,rented,28428,30000,true
MO-015,Carado,T-Series,2020,2-MOB-015,Geel,available,29661,30000,true
MO-016,Hymer,Exsis,2021,2-MOB-016,Geel,available,30497,40000,true
MO-017,Bürstner,Lyseo,2022,2-MOB-017,Geel,available,30596,40000,true
@@ -23,10 +23,10 @@ MO-021,Carado,T-Series,2019,2-MOB-021,Geel,cleaning,34098,40000,true
MO-022,Hymer,Exsis,2020,2-MOB-022,Geel,available,34410,40000,true
MO-023,Bürstner,Lyseo,2021,2-MOB-023,Geel,rented,35624,40000,true
MO-024,Sunlight,Cliff,2022,2-MOB-024,Geel,rented,54820,40000,true
MO-025,Adria,Matrix,2023,2-MOB-025,Geel,blocked,36429,40000,true
MO-026,Dethleffs,Trend,2024,2-MOB-026,Geel,blocked,37273,40000,true
MO-025,Adria,Matrix,2023,2-MOB-025,,blocked,36429,40000,true
MO-026,Dethleffs,Trend,2024,,Geel,blocked,37273,40000,true
MO-027,Carado,T-Series,2025,2-MOB-027,Geel,available,38502,40000,true
MO-028,Hymer,Exsis,2019,2-MOB-028,Geel,cleaning,38959,40000,true
MO-028,Hymer,Exsis,2019,2-MOB-028,Geel,cleaning,38959,38000,true
MO-029,Bürstner,Lyseo,2020,2-MOB-029,Geel,rented,39712,40000,true
MO-030,Sunlight,Cliff,2021,2-MOB-030,Geel,maintenance,40213,50000,true
MO-031,Adria,Matrix,2022,2-MOB-031,Geel,blocked,41149,50000,true
@@ -34,18 +34,18 @@ MO-032,Dethleffs,Trend,2023,2-MOB-032,Geel,rented,41791,50000,true
MO-033,Carado,T-Series,2024,2-MOB-033,Geel,available,42561,50000,true
MO-034,Hymer,Exsis,2025,2-MOB-034,Geel,available,43057,50000,true
MO-035,Bürstner,Lyseo,2019,2-MOB-035,Geel,rented,44034,50000,true
MO-036,Sunlight,Cliff,2020,2-MOB-036,Geel,available,44660,50000,true
MO-036,Sunlight,Cliff,2020,2-MOB-036,Geel,available,44660,44000,true
MO-037,Adria,Matrix,2021,2-MOB-037,Geel,available,45654,50000,true
MO-038,Dethleffs,Trend,2022,2-MOB-038,Geel,available,46630,50000,true
MO-039,Carado,T-Series,2023,2-MOB-039,Geel,rented,46657,50000,true
MO-040,Hymer,Exsis,2024,2-MOB-040,Geel,available,47909,50000,true
MO-041,Bürstner,Lyseo,2025,2-MOB-041,Geel,blocked,48443,50000,true
MO-041,Bürstner,Lyseo,2025,2-MOB-041,,blocked,48443,50000,true
MO-042,Sunlight,Cliff,2019,2-MOB-042,Geel,available,49548,50000,true
MO-043,Adria,Matrix,2020,2-MOB-043,Geel,available,49519,50000,true
MO-043,Adria,Matrix,2020,2-MOB-043,,available,49519,50000,true
MO-044,Dethleffs,Trend,2021,2-MOB-044,Geel,available,50611,60000,true
MO-045,Carado,T-Series,2022,2-MOB-045,Geel,blocked,51260,60000,true
MO-045,Carado,T-Series,2022,2-MOB-045,,blocked,51260,60000,true
MO-046,Hymer,Exsis,2023,2-MOB-046,Geel,cleaning,51957,60000,true
MO-047,Bürstner,Lyseo,2024,2-MOB-047,Geel,available,52452,60000,true
MO-048,Sunlight,Cliff,2025,2-MOB-048,Geel,maintenance,53693,60000,true
MO-049,Adria,Matrix,2019,2-MOB-049,Geel,blocked,53935,60000,true
MO-049,Adria,Matrix,2019,2-MOB-049,,blocked,53935,60000,true
MO-050,Dethleffs,Trend,2020,2-MOB-050,Geel,cleaning,54692,60000,true
1 public_ref make model model_year registration_number location operational_status odometer_km next_service_km active
7 MO-006 Sunlight Cliff 2025 2-MOB-006 Geel cleaning 23163 30000 true
8 MO-007 Adria Matrix 2019 2-MOB-007 Geel rented 23689 30000 true
9 MO-008 Dethleffs Trend 2020 2-MOB-008 Geel available 23933 30000 true
10 MO-009 Carado T-Series 2021 2-MOB-009 Geel blocked 25203 30000 true
11 MO-010 Hymer Exsis 2022 2-MOB-010 Geel available 25333 30000 true
12 MO-011 Bürstner Lyseo 2023 2-MOB-011 Geel available 26160 30000 true
13 MO-012 Sunlight Cliff 2024 2-MOB-012 Geel available 26859 30000 true
14 MO-013 Adria Matrix 2025 2-MOB-013 Geel rented 27750 30000 true
15 MO-014 Dethleffs Trend 2019 2-MOB-014 Geel rented 28428 30000 true
16 MO-015 Carado T-Series 2020 2-MOB-015 Geel available 29661 30000 true
17 MO-016 Hymer Exsis 2021 2-MOB-016 Geel available 30497 40000 true
18 MO-017 Bürstner Lyseo 2022 2-MOB-017 Geel available 30596 40000 true
23 MO-022 Hymer Exsis 2020 2-MOB-022 Geel available 34410 40000 true
24 MO-023 Bürstner Lyseo 2021 2-MOB-023 Geel rented 35624 40000 true
25 MO-024 Sunlight Cliff 2022 2-MOB-024 Geel rented 54820 40000 true
26 MO-025 Adria Matrix 2023 2-MOB-025 Geel blocked 36429 40000 true
27 MO-026 Dethleffs Trend 2024 2-MOB-026 Geel blocked 37273 40000 true
28 MO-027 Carado T-Series 2025 2-MOB-027 Geel available 38502 40000 true
29 MO-028 Hymer Exsis 2019 2-MOB-028 Geel cleaning 38959 40000 38000 true
30 MO-029 Bürstner Lyseo 2020 2-MOB-029 Geel rented 39712 40000 true
31 MO-030 Sunlight Cliff 2021 2-MOB-030 Geel maintenance 40213 50000 true
32 MO-031 Adria Matrix 2022 2-MOB-031 Geel blocked 41149 50000 true
34 MO-033 Carado T-Series 2024 2-MOB-033 Geel available 42561 50000 true
35 MO-034 Hymer Exsis 2025 2-MOB-034 Geel available 43057 50000 true
36 MO-035 Bürstner Lyseo 2019 2-MOB-035 Geel rented 44034 50000 true
37 MO-036 Sunlight Cliff 2020 2-MOB-036 Geel available 44660 50000 44000 true
38 MO-037 Adria Matrix 2021 2-MOB-037 Geel available 45654 50000 true
39 MO-038 Dethleffs Trend 2022 2-MOB-038 Geel available 46630 50000 true
40 MO-039 Carado T-Series 2023 2-MOB-039 Geel rented 46657 50000 true
41 MO-040 Hymer Exsis 2024 2-MOB-040 Geel available 47909 50000 true
42 MO-041 Bürstner Lyseo 2025 2-MOB-041 Geel blocked 48443 50000 true
43 MO-042 Sunlight Cliff 2019 2-MOB-042 Geel available 49548 50000 true
44 MO-043 Adria Matrix 2020 2-MOB-043 Geel available 49519 50000 true
45 MO-044 Dethleffs Trend 2021 2-MOB-044 Geel available 50611 60000 true
46 MO-045 Carado T-Series 2022 2-MOB-045 Geel blocked 51260 60000 true
47 MO-046 Hymer Exsis 2023 2-MOB-046 Geel cleaning 51957 60000 true
48 MO-047 Bürstner Lyseo 2024 2-MOB-047 Geel available 52452 60000 true
49 MO-048 Sunlight Cliff 2025 2-MOB-048 Geel maintenance 53693 60000 true
50 MO-049 Adria Matrix 2019 2-MOB-049 Geel blocked 53935 60000 true
51 MO-050 Dethleffs Trend 2020 2-MOB-050 Geel cleaning 54692 60000 true