From 9dfbd7c4bf9eb3aedf88b0df52b104fe70935b32 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:53:45 +0200 Subject: [PATCH] M36: deepen operational and mobile UX --- PROJECT_STATE.md | 24 +++++ backend/app/api/routers/bookings.py | 60 +++++++++++- backend/app/schemas.py | 6 ++ backend/app/services/data_quality.py | 92 +++++++++++-------- backend/tests/test_bookings.py | 29 ++++++ frontend/e2e/demo-accessibility.spec.ts | 22 +++++ frontend/e2e/operational-workflows.spec.ts | 7 +- frontend/nginx.conf | 6 +- frontend/src/i18n/locales/en-GB/bookings.json | 6 ++ .../src/i18n/locales/en-GB/integrations.json | 3 +- .../src/i18n/locales/en-GB/knowledge.json | 3 +- frontend/src/i18n/locales/en-GB/quality.json | 5 + frontend/src/i18n/locales/fr-BE/bookings.json | 6 ++ .../src/i18n/locales/fr-BE/integrations.json | 3 +- .../src/i18n/locales/fr-BE/knowledge.json | 3 +- frontend/src/i18n/locales/fr-BE/quality.json | 5 + frontend/src/i18n/locales/nl-BE/bookings.json | 6 ++ .../src/i18n/locales/nl-BE/integrations.json | 3 +- .../src/i18n/locales/nl-BE/knowledge.json | 3 +- frontend/src/i18n/locales/nl-BE/quality.json | 5 + frontend/src/pages/Automation.tsx | 8 +- frontend/src/pages/BookingDetail.tsx | 46 ++++++++++ frontend/src/pages/DataQuality.tsx | 37 +++++++- frontend/src/pages/Knowledge.tsx | 1 + frontend/src/styles.css | 12 ++- 25 files changed, 341 insertions(+), 60 deletions(-) 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} +
+ )}
{(() => { diff --git a/frontend/src/pages/BookingDetail.tsx b/frontend/src/pages/BookingDetail.tsx index 4d851bf..f7a029e 100644 --- a/frontend/src/pages/BookingDetail.tsx +++ b/frontend/src/pages/BookingDetail.tsx @@ -13,6 +13,7 @@ import { PRODUCT_NAME } from "../product"; import { describeApiError, type ApiErrorInfo } from "../api/errorMessages"; import { ApiErrorNotice } from "../components/PageChrome"; import { CheckoutForm } from "../components/CheckoutForm"; +import { brusselsLocalToIso, toBrusselsDateTimeLocal } from "../i18n/brusselsDateTime"; export function BookingDetail() { const { t } = useTranslation(["bookings", "returns", "errors"]); @@ -29,6 +30,10 @@ export function BookingDetail() { const [checkoutResult, setCheckoutResult] = useState(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 load = useCallback(() => { if (!publicRef) return; @@ -45,6 +50,12 @@ export function BookingDetail() { load(); }, [load]); + useEffect(() => { + if (!booking) return; + setScheduleStart(toBrusselsDateTimeLocal(new Date(booking.starts_at))); + setScheduleEnd(toBrusselsDateTimeLocal(new Date(booking.ends_at))); + }, [booking?.public_ref]); + // 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. @@ -104,6 +115,26 @@ export function BookingDetail() { } } + async function reschedule(event: FormEvent) { + event.preventDefault(); + if (!publicRef) return; + setRescheduling(true); + setActionError(null); + try { + const updated = await api.patch(`/api/v1/bookings/${publicRef}/schedule`, { + starts_at: brusselsLocalToIso(scheduleStart), + ends_at: brusselsLocalToIso(scheduleEnd), + reason: scheduleReason, + }); + setBooking(updated); + setScheduleReason(""); + } catch (err) { + setActionError(describeApiError(t, err, "bookings:detail.rescheduleFailed")); + } finally { + setRescheduling(false); + } + } + if (error) return ; if (!booking) return ; @@ -133,6 +164,21 @@ export function BookingDetail() { ) : null} + {booking.status === "reserved" ? ( +
+ {t("detail.rescheduleAction")} +
+

{t("detail.rescheduleDetail")}

+
+ + +
+