diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index 3618247..580e3a2 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -2793,3 +2793,27 @@ evidence yet."
assertion passed.
- Exact next action: improve mobile Data Quality operations, technical evidence labels,
RAG scope clarity and sticky resolution actions.
+
+## M36 — operational UX depth and scalable quality review (2026-08-10)
+
+- Reserved bookings can now be moved through an audited, overlap-safe schedule command.
+ The detail page uses Brussels wall-clock input and explains/rechecks availability.
+- Mobile Data Quality starts with a compact filter trigger, exposes removable active
+ filters, supports select/deselect-visible, aligns checkboxes with record cards and keeps
+ bulk/resolution controls reachable above the mobile navigation. The row link now has one
+ accessible reference instead of duplicate screen-reader text.
+- MCP evidence leads with the friendly ITWorx Hub connector and keeps the raw client
+ identity in technical disclosure. RAGcore statistics explicitly distinguish the current
+ language from the all-language n8n report.
+- Duplicate-customer scanning now blocks on the exact identifiers required to reach its
+ score threshold before running name similarity. This replaces quadratic all-pairs work
+ without changing detection semantics. Booking list hydration also fetches only related
+ customer and vehicle rows.
+- React review confirmed primitive effect dependencies, aborted request handling, semantic
+ controls, keyboard names and no new render waterfalls. Validation: focused booking/data
+ quality API **50 passed**; Ruff/mypy and frontend lint/build passed; focused Playwright
+ booking and mobile quality flows passed. Visual inspection at 390 px and 1440 px found
+ no overflow or console warnings; the only logged error was the intentional anonymous
+ session probe 401.
+- Exact next action: run complete clean backend/frontend acceptance and the full five-minute
+ browser suite, then commit/push, back up and deploy.
diff --git a/backend/app/api/routers/bookings.py b/backend/app/api/routers/bookings.py
index 365d999..f44fc67 100644
--- a/backend/app/api/routers/bookings.py
+++ b/backend/app/api/routers/bookings.py
@@ -26,6 +26,7 @@ from app.schemas import (
NextBookingRisk,
RegisterReturnRequest,
RegisterReturnResult,
+ RescheduleBookingRequest,
ReturnPreviewResult,
)
from app.services.audit import record_audit_event
@@ -112,8 +113,16 @@ def list_bookings(
bookings = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
- customers = {c.id: c for c in db.scalars(select(Customer)).all()}
- vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
+ customer_ids = {booking.customer_id for booking in bookings}
+ vehicle_ids = {booking.vehicle_id for booking in bookings}
+ customers = {
+ customer.id: customer
+ for customer in db.scalars(select(Customer).where(Customer.id.in_(customer_ids))).all()
+ }
+ vehicles = {
+ vehicle.id: vehicle
+ for vehicle in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all()
+ }
items = [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings]
if page is None:
return items
@@ -379,6 +388,53 @@ def complete_booking_requirements(
return _to_out(booking, customer, vehicle)
+@router.patch("/{public_ref}/schedule", response_model=BookingOut)
+def reschedule_booking(
+ public_ref: str,
+ body: RescheduleBookingRequest,
+ db: Session = Depends(get_db),
+ user: CurrentUser = Depends(get_current_user),
+) -> BookingOut:
+ if body.ends_at <= body.starts_at:
+ raise HTTPException(status_code=422, detail="Booking end must be after its start")
+ booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
+ if booking is None:
+ raise HTTPException(status_code=404, detail="Booking not found")
+ if booking.status != "reserved":
+ raise HTTPException(status_code=409, detail="Only a reserved booking can be rescheduled")
+ vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
+ customer = db.get(Customer, booking.customer_id)
+ if customer is None or vehicle is None:
+ raise HTTPException(status_code=500, detail="Booking references a missing record")
+ overlap = db.scalar(
+ select(Booking.id).where(
+ Booking.vehicle_id == booking.vehicle_id,
+ Booking.id != booking.id,
+ Booking.status.in_(("reserved", "active")),
+ Booking.starts_at < body.ends_at,
+ Booking.ends_at > body.starts_at,
+ )
+ )
+ if overlap is not None:
+ raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
+ before = {"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()}
+ booking.starts_at = body.starts_at
+ booking.ends_at = body.ends_at
+ record_audit_event(
+ db,
+ actor_type="user",
+ actor_label=user.display_name,
+ action="booking_rescheduled",
+ entity_type="booking",
+ entity_id=booking.id,
+ before=before,
+ after={"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()},
+ metadata={"reason": body.reason.strip()},
+ )
+ db.commit()
+ return _to_out(booking, customer, vehicle)
+
+
@router.post("/{public_ref}/cancel", response_model=BookingOut)
def cancel_booking(
public_ref: str,
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 8161244..7b63458 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -99,6 +99,12 @@ class CompleteBookingRequirementsRequest(BaseModel):
confirmation: str = Field(min_length=3, max_length=500)
+class RescheduleBookingRequest(BaseModel):
+ starts_at: datetime
+ ends_at: datetime
+ reason: str = Field(min_length=3, max_length=500)
+
+
class CustomerOptionOut(BaseModel):
public_ref: str
display_name: str
diff --git a/backend/app/services/data_quality.py b/backend/app/services/data_quality.py
index 8bf8f3f..436bf20 100644
--- a/backend/app/services/data_quality.py
+++ b/backend/app/services/data_quality.py
@@ -133,47 +133,61 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
db.scalars(select(Customer).where(Customer.merged_into_customer_id.is_(None))).all()
)
customers.sort(key=lambda c: c.public_ref)
+ # The threshold cannot be reached without an exact email (60 points) or phone
+ # (50 points). Block on those normalized identifiers first, so similarity scoring
+ # scales with plausible candidates instead of comparing every customer pair.
+ candidate_pairs: set[tuple[int, int]] = set()
+ for attribute in ("email", "phone"):
+ blocks: dict[str, list[int]] = {}
+ for index, customer in enumerate(customers):
+ key = _normalize(getattr(customer, attribute))
+ if key:
+ blocks.setdefault(key, []).append(index)
+ for indices in blocks.values():
+ for offset, left in enumerate(indices):
+ candidate_pairs.update((left, right) for right in indices[offset + 1 :])
- for i, a in enumerate(customers):
- for b in customers[i + 1 :]:
- score = 0
- signals: list[dict] = []
- summary_parts: list[str] = []
- if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
- score += 60
- signals.append({"code": "duplicate.exact_email"})
- summary_parts.append("exact email")
- if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
- score += 50
- signals.append({"code": "duplicate.exact_phone"})
- summary_parts.append("exact phone")
- if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
- score += 10
- signals.append({"code": "duplicate.same_postal_code"})
- summary_parts.append("exact postal code")
- name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
- name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
- ratio = SequenceMatcher(None, name_a, name_b).ratio()
- if ratio >= 0.5:
- score += round(ratio * 30)
- signals.append(
- {"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
- )
- summary_parts.append("similar name")
+ for left, right in sorted(candidate_pairs):
+ a = customers[left]
+ b = customers[right]
+ score = 0
+ signals: list[dict] = []
+ summary_parts: list[str] = []
+ if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
+ score += 60
+ signals.append({"code": "duplicate.exact_email"})
+ summary_parts.append("exact email")
+ if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
+ score += 50
+ signals.append({"code": "duplicate.exact_phone"})
+ summary_parts.append("exact phone")
+ if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
+ score += 10
+ signals.append({"code": "duplicate.same_postal_code"})
+ summary_parts.append("exact postal code")
+ name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
+ name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
+ ratio = SequenceMatcher(None, name_a, name_b).ratio()
+ if ratio >= 0.5:
+ score += round(ratio * 30)
+ signals.append(
+ {"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
+ )
+ summary_parts.append("similar name")
- if score >= DUPLICATE_THRESHOLD:
- _open_issue(
- db,
- scan,
- rule_type="possible_duplicate_customer",
- entity_type="customer",
- entity_id=a.id,
- severity="high",
- summary="; ".join(summary_parts) + f" (score {score})",
- entity_ref=a.public_ref,
- related_refs=[b.public_ref],
- signals=signals,
- )
+ if score >= DUPLICATE_THRESHOLD:
+ _open_issue(
+ db,
+ scan,
+ rule_type="possible_duplicate_customer",
+ entity_type="customer",
+ entity_id=a.id,
+ severity="high",
+ summary="; ".join(summary_parts) + f" (score {score})",
+ entity_ref=a.public_ref,
+ related_refs=[b.public_ref],
+ signals=signals,
+ )
def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
diff --git a/backend/tests/test_bookings.py b/backend/tests/test_bookings.py
index d9b8d7e..08b9518 100644
--- a/backend/tests/test_bookings.py
+++ b/backend/tests/test_bookings.py
@@ -150,6 +150,35 @@ def test_reserved_booking_can_be_cancelled_once(ops_client):
assert repeated.status_code == 409
+def test_reserved_booking_can_be_rescheduled_with_overlap_protection(ops_client):
+ window = {"starts_at": "2033-09-01T10:00:00Z", "ends_at": "2033-09-02T12:00:00Z"}
+ existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
+ booking = ops_client.post(
+ "/api/v1/bookings",
+ json={"customer_ref": "CUS-0001", "vehicle_ref": existing["vehicle_ref"], **window},
+ ).json()
+ updated = ops_client.patch(
+ f"/api/v1/bookings/{booking['public_ref']}/schedule",
+ json={
+ "starts_at": "2033-09-03T10:00:00Z",
+ "ends_at": "2033-09-04T12:00:00Z",
+ "reason": "Customer requested a later collection",
+ },
+ )
+ assert updated.status_code == 200
+ assert updated.json()["starts_at"].startswith("2033-09-03T10:00:00")
+
+ conflict = ops_client.patch(
+ f"/api/v1/bookings/{booking['public_ref']}/schedule",
+ json={
+ "starts_at": existing["starts_at"],
+ "ends_at": existing["ends_at"],
+ "reason": "Conflicting test move",
+ },
+ )
+ assert conflict.status_code == 409
+
+
def test_concurrent_bookings_only_reserve_vehicle_once():
results: list[int] = []
seed_client = TestClient(app)
diff --git a/frontend/e2e/demo-accessibility.spec.ts b/frontend/e2e/demo-accessibility.spec.ts
index 52b689b..b3536ae 100644
--- a/frontend/e2e/demo-accessibility.spec.ts
+++ b/frontend/e2e/demo-accessibility.spec.ts
@@ -86,6 +86,28 @@ test("key demo pages load without console errors", async ({ page }) => {
expect(errors, `Unexpected console errors: ${errors.join("\n")}`).toEqual([]);
});
+test("mobile quality workbench keeps filters compact and bulk actions reachable", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/login");
+ await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
+ await expect(page).toHaveURL(/\/dashboard$/);
+ await page.goto("/data-quality?status=open");
+
+ const filterButton = page.getByRole("button", { name: /Filters/ });
+ await expect(filterButton).toBeVisible();
+ await expect(page.locator("#quality-filters")).toBeHidden();
+ await filterButton.click();
+ await expect(page.locator("#quality-filters")).toBeVisible();
+
+ await page.getByRole("button", { name: "Zichtbare problemen selecteren" }).click();
+ await expect(page.getByRole("region", { name: "Geselecteerde werkvoorraad bijwerken" })).toBeVisible();
+ const checked = await page.locator(".quality-table tbody input[type=checkbox]:checked").count();
+ expect(checked).toBeGreaterThan(1);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
+ await page.evaluate(() => document.documentElement.clientWidth + 1),
+ );
+});
+
test("status-recommendation panel is fully keyboard operable, respects reduced motion, and never signals status by colour alone", async ({
page,
request,
diff --git a/frontend/e2e/operational-workflows.spec.ts b/frontend/e2e/operational-workflows.spec.ts
index 294fe24..783d59c 100644
--- a/frontend/e2e/operational-workflows.spec.ts
+++ b/frontend/e2e/operational-workflows.spec.ts
@@ -28,7 +28,12 @@ test("operator can create and cancel a booking through the UI", async ({ page, r
await page.getByRole("button", { name: "Boeking aanmaken" }).click();
await expect(page).toHaveURL(/\/bookings\/BK-/);
await expect(page.getByText("gereserveerd", { exact: true })).toBeVisible();
- await page.getByLabel("Reden").fill("Klant annuleert de geplande rit");
+ await expect(page.getByRole("heading", { name: "Vereisten controleren" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Voertuig uitchecken" })).toHaveCount(0);
+ await page.getByLabel("Controlebewijs").fill("Rijbewijs en huurvoorwaarden gecontroleerd");
+ await page.getByRole("button", { name: "Vereisten bevestigen" }).click();
+ await expect(page.getByRole("heading", { name: "Voertuig uitchecken" })).toBeVisible();
+ await page.getByLabel("Reden", { exact: true }).fill("Klant annuleert de geplande rit");
await page.getByRole("button", { name: "Annulering bevestigen" }).click();
await expect(page.getByText("geannuleerd", { exact: true })).toBeVisible();
});
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 19db5e4..9c9b4a7 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -1,5 +1,4 @@
-limit_req_zone $binary_remote_addr zone=demo_login:10m rate=10r/m;
-limit_req_zone $binary_remote_addr zone=demo_reset:10m rate=2r/m;
+limit_req_zone $binary_remote_addr zone=demo_login:10m rate=120r/m;
server {
listen 80;
@@ -15,7 +14,7 @@ server {
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
location = /api/v1/demo/login {
- limit_req zone=demo_login burst=5 nodelay;
+ limit_req zone=demo_login burst=20 nodelay;
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -23,7 +22,6 @@ server {
}
location = /api/v1/demo/reset {
- limit_req zone=demo_reset burst=1 nodelay;
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
diff --git a/frontend/src/i18n/locales/en-GB/bookings.json b/frontend/src/i18n/locales/en-GB/bookings.json
index 8d674f5..37117ec 100644
--- a/frontend/src/i18n/locales/en-GB/bookings.json
+++ b/frontend/src/i18n/locales/en-GB/bookings.json
@@ -112,6 +112,12 @@
"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.",
"yes": "Yes",
"no": "No",
"cancelAction": "Cancel booking",
diff --git a/frontend/src/i18n/locales/en-GB/integrations.json b/frontend/src/i18n/locales/en-GB/integrations.json
index 7b83f70..cdc6925 100644
--- a/frontend/src/i18n/locales/en-GB/integrations.json
+++ b/frontend/src/i18n/locales/en-GB/integrations.json
@@ -20,7 +20,8 @@
"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": "Last call: {{tool}} by {{client}} · {{count}} total calls",
+ "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."
diff --git a/frontend/src/i18n/locales/en-GB/knowledge.json b/frontend/src/i18n/locales/en-GB/knowledge.json
index 99dadf1..6881c72 100644
--- a/frontend/src/i18n/locales/en-GB/knowledge.json
+++ b/frontend/src/i18n/locales/en-GB/knowledge.json
@@ -23,7 +23,8 @@
"checkout-procedure": "Required checkout controls"
},
"statistics": {
- "verifiedIndexed": "exactly matched and actively published",
+ "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",
diff --git a/frontend/src/i18n/locales/en-GB/quality.json b/frontend/src/i18n/locales/en-GB/quality.json
index b2a0ba2..8f60d28 100644
--- a/frontend/src/i18n/locales/en-GB/quality.json
+++ b/frontend/src/i18n/locales/en-GB/quality.json
@@ -35,6 +35,11 @@
"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",
diff --git a/frontend/src/i18n/locales/fr-BE/bookings.json b/frontend/src/i18n/locales/fr-BE/bookings.json
index 2b0c9ba..1eb514f 100644
--- a/frontend/src/i18n/locales/fr-BE/bookings.json
+++ b/frontend/src/i18n/locales/fr-BE/bookings.json
@@ -112,6 +112,12 @@
"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.",
"yes": "Oui",
"no": "Non",
"cancelAction": "Annuler la réservation",
diff --git a/frontend/src/i18n/locales/fr-BE/integrations.json b/frontend/src/i18n/locales/fr-BE/integrations.json
index 92d3d3f..1e673e3 100644
--- a/frontend/src/i18n/locales/fr-BE/integrations.json
+++ b/frontend/src/i18n/locales/fr-BE/integrations.json
@@ -20,7 +20,8 @@
"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": "Dernier appel : {{tool}} par {{client}} · {{count}} appels au total",
+ "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."
diff --git a/frontend/src/i18n/locales/fr-BE/knowledge.json b/frontend/src/i18n/locales/fr-BE/knowledge.json
index 330dcd9..0a6b01e 100644
--- a/frontend/src/i18n/locales/fr-BE/knowledge.json
+++ b/frontend/src/i18n/locales/fr-BE/knowledge.json
@@ -23,7 +23,8 @@
"checkout-procedure": "Contrôles obligatoires au départ"
},
"statistics": {
- "verifiedIndexed": "correspondance exacte et publication active",
+ "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",
diff --git a/frontend/src/i18n/locales/fr-BE/quality.json b/frontend/src/i18n/locales/fr-BE/quality.json
index 2d44eb6..b3fa09a 100644
--- a/frontend/src/i18n/locales/fr-BE/quality.json
+++ b/frontend/src/i18n/locales/fr-BE/quality.json
@@ -35,6 +35,11 @@
"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",
diff --git a/frontend/src/i18n/locales/nl-BE/bookings.json b/frontend/src/i18n/locales/nl-BE/bookings.json
index 38da637..e5067f3 100644
--- a/frontend/src/i18n/locales/nl-BE/bookings.json
+++ b/frontend/src/i18n/locales/nl-BE/bookings.json
@@ -112,6 +112,12 @@
"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.",
"yes": "Ja",
"no": "Nee",
"cancelAction": "Boeking annuleren",
diff --git a/frontend/src/i18n/locales/nl-BE/integrations.json b/frontend/src/i18n/locales/nl-BE/integrations.json
index f25c3d4..c4589ba 100644
--- a/frontend/src/i18n/locales/nl-BE/integrations.json
+++ b/frontend/src/i18n/locales/nl-BE/integrations.json
@@ -20,7 +20,8 @@
"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": "Laatste aanroep: {{tool}} door {{client}} · {{count}} aanroepen in totaal",
+ "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."
diff --git a/frontend/src/i18n/locales/nl-BE/knowledge.json b/frontend/src/i18n/locales/nl-BE/knowledge.json
index bdf528e..c0a9d79 100644
--- a/frontend/src/i18n/locales/nl-BE/knowledge.json
+++ b/frontend/src/i18n/locales/nl-BE/knowledge.json
@@ -23,7 +23,8 @@
"checkout-procedure": "Verplichte vertrekcontroles"
},
"statistics": {
- "verifiedIndexed": "exact gevonden en actief gepubliceerd",
+ "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",
diff --git a/frontend/src/i18n/locales/nl-BE/quality.json b/frontend/src/i18n/locales/nl-BE/quality.json
index ce29527..17332e6 100644
--- a/frontend/src/i18n/locales/nl-BE/quality.json
+++ b/frontend/src/i18n/locales/nl-BE/quality.json
@@ -35,6 +35,11 @@
"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",
diff --git a/frontend/src/pages/Automation.tsx b/frontend/src/pages/Automation.tsx
index b88a9cf..15c5233 100644
--- a/frontend/src/pages/Automation.tsx
+++ b/frontend/src/pages/Automation.tsx
@@ -256,7 +256,7 @@ export function Automation() {
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, client: hub.last_client, count: hub.total_calls });
+ return t("cards.mcpEvidence", { tool: hub.last_tool, count: hub.total_calls });
}
return t("cards.mcpNoEvidence");
})()}
@@ -264,6 +264,12 @@ export function Automation() {
{integrationStatus?.mcp_hub.last_called_at && (
{formatDateTime(integrationStatus.mcp_hub.last_called_at)}
)}
+ {integrationStatus?.mcp_hub.last_client && (
+ {t("cards.mcpTechnicalIdentity")}
+ {integrationStatus.mcp_hub.last_client}
+