M36: deepen operational and mobile UX

This commit is contained in:
NuklearRabbit
2026-08-10 22:53:45 +02:00
parent 809ba0ddcc
commit 9dfbd7c4bf
25 changed files with 341 additions and 60 deletions
+24
View File
@@ -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.
+58 -2
View File
@@ -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,
+6
View File
@@ -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
+16 -2
View File
@@ -133,9 +133,23 @@ 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 :]:
for left, right in sorted(candidate_pairs):
a = customers[left]
b = customers[right]
score = 0
signals: list[dict] = []
summary_parts: list[str] = []
+29
View File
@@ -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)
+22
View File
@@ -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,
+6 -1
View File
@@ -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();
});
+2 -4
View File
@@ -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;
@@ -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",
@@ -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."
@@ -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",
@@ -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",
@@ -112,6 +112,12 @@
"requirementsConfirm": "Confirmer les exigences",
"requirementsSaving": "Confirmation…",
"requirementsFailed": "Les exigences nont pas pu être confirmées.",
"rescheduleAction": "Replanifier la réservation",
"rescheduleDetail": "Modifiez la période de location. La disponibilité est revérifiée avant lenregistrement.",
"rescheduleReason": "Motif de la modification",
"confirmReschedule": "Enregistrer la nouvelle période",
"rescheduling": "Enregistrement…",
"rescheduleFailed": "La réservation na pas pu être replanifiée.",
"yes": "Oui",
"no": "Non",
"cancelAction": "Annuler la réservation",
@@ -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."
@@ -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 dindex 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",
@@ -35,6 +35,11 @@
"bulkFailed": "La file de travail na 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",
@@ -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",
@@ -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."
@@ -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",
@@ -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",
+7 -1
View File
@@ -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 && (
<small>{formatDateTime(integrationStatus.mcp_hub.last_called_at)}</small>
)}
{integrationStatus?.mcp_hub.last_client && (
<details className="technical-identity">
<summary>{t("cards.mcpTechnicalIdentity")}</summary>
<code>{integrationStatus.mcp_hub.last_client}</code>
</details>
)}
</div>
<div className="integration-badge-stack">
{(() => {
+46
View File
@@ -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<CheckoutBookingResult | null>(null);
const [requirementsConfirmation, setRequirementsConfirmation] = useState("");
const [confirmingRequirements, setConfirmingRequirements] = useState(false);
const [scheduleStart, setScheduleStart] = useState("");
const [scheduleEnd, setScheduleEnd] = useState("");
const [scheduleReason, setScheduleReason] = useState("");
const [rescheduling, setRescheduling] = useState(false);
const 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<Booking>(`/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 <ErrorState message={error} />;
if (!booking) return <LoadingState label={t("detail.loading")} />;
@@ -133,6 +164,21 @@ export function BookingDetail() {
</form>
) : null}
{booking.status === "reserved" ? (
<details className="record-surface booking-reschedule">
<summary>{t("detail.rescheduleAction")}</summary>
<form onSubmit={reschedule}>
<p>{t("detail.rescheduleDetail")}</p>
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required value={scheduleStart} onChange={(event) => setScheduleStart(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required min={scheduleStart} value={scheduleEnd} onChange={(event) => setScheduleEnd(event.target.value)} /></label>
</div>
<label>{t("detail.rescheduleReason")}<textarea required minLength={3} maxLength={500} value={scheduleReason} onChange={(event) => setScheduleReason(event.target.value)} /></label>
<div className="form-actions"><button className="button button-secondary" type="submit" disabled={rescheduling || scheduleReason.trim().length < 3 || scheduleEnd <= scheduleStart}>{rescheduling ? t("detail.rescheduling") : t("detail.confirmReschedule")}</button></div>
</form>
</details>
) : null}
{booking.status === "reserved" && <form className="record-surface booking-cancel-form" onSubmit={cancelBooking}>
<h2>{t("detail.cancelAction")}</h2>
<ApiErrorNotice error={actionError} />
+32 -5
View File
@@ -9,6 +9,7 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
import { useLocaleFormat } from "../i18n/format";
import { brusselsLocalToIso } from "../i18n/brusselsDateTime";
const RULE_TYPES = [
"possible_duplicate_customer",
@@ -42,6 +43,7 @@ export function DataQuality() {
const [bulkDueAt, setBulkDueAt] = useState("");
const [bulkSaving, setBulkSaving] = useState(false);
const [bulkError, setBulkError] = useState<ApiErrorInfo | null>(null);
const [filtersOpen, setFiltersOpen] = useState(false);
function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams);
@@ -87,7 +89,7 @@ export function DataQuality() {
await api.post("/api/v1/data-quality/issues/bulk-work", {
issue_refs: [...selected],
assigned_to_ref: bulkAssignee || undefined,
due_at: bulkDueAt ? new Date(bulkDueAt).toISOString() : undefined,
due_at: bulkDueAt ? brusselsLocalToIso(bulkDueAt) : undefined,
});
setSelected(new Set());
setBulkAssignee("");
@@ -108,6 +110,16 @@ export function DataQuality() {
});
}
function toggleVisibleSelection() {
setSelected((current) => {
const visibleRefs = visibleIssues.map((issue) => issue.public_ref);
const allSelected = visibleRefs.every((ref) => current.has(ref));
const next = new Set(current);
visibleRefs.forEach((ref) => allSelected ? next.delete(ref) : next.add(ref));
return next;
});
}
async function handleScan() {
setScanError(null);
setScanning(true);
@@ -138,6 +150,8 @@ export function DataQuality() {
? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-"))
: issues.items
: [];
const activeFilterCount = [status !== "open", Boolean(ruleType), Boolean(severity), Boolean(assignee), overdueOnly, demoScenariosOnly].filter(Boolean).length;
const allVisibleSelected = visibleIssues.length > 0 && visibleIssues.every((issue) => selected.has(issue.public_ref));
return (
<div className="page">
@@ -178,7 +192,10 @@ export function DataQuality() {
</p>
)}
<form className="filters" aria-label={t("list.title")}>
<button className="button button-secondary filter-toggle" type="button" aria-expanded={filtersOpen} aria-controls="quality-filters" onClick={() => setFiltersOpen((open) => !open)}>
{t("list.filtersToggle", { count: activeFilterCount })}
</button>
<form id="quality-filters" className={`filters quality-filters${filtersOpen ? " is-open" : ""}`} aria-label={t("list.title")}>
<label>
{t("list.statusLabel")}
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
@@ -230,6 +247,16 @@ export function DataQuality() {
{t("list.demoScenariosOnly")}
</label>
</form>
{activeFilterCount > 0 && (
<div className="active-filter-chips" aria-label={t("list.activeFilters")}>
{status !== "open" && <button type="button" onClick={() => updateFilters({ status: "open", page: 1 })}>{t(`list.status${status.charAt(0).toUpperCase()}${status.slice(1)}`)} ×</button>}
{ruleType && <button type="button" onClick={() => updateFilters({ rule_type: null, page: 1 })}>{t(`ruleTypes.${ruleType}`)} ×</button>}
{severity && <button type="button" onClick={() => updateFilters({ severity: null, page: 1 })}>{t(`severities.${severity}`)} ×</button>}
{assignee && <button type="button" onClick={() => updateFilters({ assignee: null, page: 1 })}>{assignee === "unassigned" ? t("list.unassigned") : users.find((record) => record.public_ref === assignee)?.display_name ?? assignee} ×</button>}
{overdueOnly && <button type="button" onClick={() => updateFilters({ overdue: null, page: 1 })}>{t("list.overdueOnly")} ×</button>}
{demoScenariosOnly && <button type="button" onClick={() => { setDemoScenariosOnly(false); updateFilters({ demo: null, page: 1 }); }}>{t("list.demoScenariosOnly")} ×</button>}
</div>
)}
{error && <ErrorState message={error} />}
{!error && !issues && <LoadingState label={t("list.loading")} />}
@@ -249,11 +276,11 @@ export function DataQuality() {
<button type="button" className="button button-secondary" onClick={() => setSelected(new Set())}>{t("list.clearSelection")}</button>
</div>
)}
<div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><button className="select-visible" type="button" onClick={toggleVisibleSelection}>{t(allVisibleSelected ? "list.deselectVisible" : "list.selectVisible")}</button><span>{t("list.evidenceBacked")}</span></div><table className="data-table quality-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
<th scope="col"><span className="visually-hidden">{t("list.columns.select")}</span></th>
<th scope="col" className="selection-cell"><input type="checkbox" checked={allVisibleSelected} onChange={toggleVisibleSelection} aria-label={t("list.selectVisible")} /></th>
<th scope="col">{t("list.columns.reference")}</th>
<th scope="col">{t("list.columns.rule")}</th>
<th scope="col">{t("list.columns.entity")}</th>
@@ -268,7 +295,7 @@ export function DataQuality() {
<tr key={i.public_ref} className="row-clickable">
<td className="selection-cell"><input type="checkbox" checked={selected.has(i.public_ref)} onChange={() => toggleSelected(i.public_ref)} aria-label={t("list.selectIssue", { ref: i.public_ref })} /></td>
<th scope="row" data-label={t("list.columns.reference")}>
{i.public_ref}
<span aria-hidden="true">{i.public_ref}</span>
<Link className="row-link" to={`/data-quality/${i.public_ref}`}><span className="visually-hidden">{i.public_ref}</span></Link>
</th>
<td data-label={t("list.columns.rule")}>{t(`ruleTypes.${i.rule_type}`, { defaultValue: i.rule_type.replace(/_/g, " ") })}</td>
+1
View File
@@ -154,6 +154,7 @@ export function Knowledge() {
)}
{statusSettled && status?.provider === "ragcore" && (
<div className="knowledge-index-evidence" role="status">
<p className="knowledge-statistics-scope">{t("statistics.scope")}</p>
<div><strong>{status.document_count ?? "—"}</strong><span>{t("statistics.verifiedIndexed")}</span></div>
<div><strong>{status.source_document_count}</strong><span>{t("statistics.sourceDocuments")}</span></div>
<div>
+10 -2
View File
@@ -250,6 +250,10 @@ a:hover { color: var(--teal); }
.filters input[type="text"], .filters input[type="search"] { min-width: 230px; }.filters select { min-width: 160px; }
.checkbox-label { flex-direction: row !important; align-items: center; gap: 8px !important; }
input[type="checkbox"], input[type="radio"] { width: 17px; height: 17px; accent-color: var(--teal-dark); }
.filter-toggle { display: none; margin-bottom: 10px; }
.active-filter-chips { display: flex; flex-wrap: wrap; gap: 7px; margin: -7px 0 16px; }
.active-filter-chips button, .select-visible { min-height: 32px; padding: 5px 10px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: var(--type-meta); font-weight: 700; cursor: pointer; }
.select-visible { margin-left: auto; white-space: nowrap; }
.table-shell { overflow: hidden; }
.table-meta { min-height: 44px; display: flex; justify-content: space-between; align-items: center; padding: 0 14px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: var(--type-meta); }
@@ -265,6 +269,8 @@ input[type="checkbox"], input[type="radio"] { width: 17px; height: 17px; accent-
.table-subtext { display: block; margin-top: 3px; color: var(--muted); font-size: var(--type-meta); }.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.pagination { min-height: 56px; display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 8px 14px; border-top: 1px solid var(--line); color: var(--muted); font-size: var(--type-meta); }
details summary { cursor: pointer; color: var(--teal-dark); }.data-table details pre { max-width: 280px; overflow: auto; color: var(--ink-soft); white-space: pre-wrap; }
.technical-identity { margin-top: 8px; font-size: var(--type-meta); }.technical-identity code { display: block; max-width: 100%; margin-top: 5px; padding: 6px 8px; overflow-wrap: anywhere; color: var(--muted); background: var(--surface-subtle); border-radius: 6px; }
.knowledge-index-evidence .knowledge-statistics-scope { grid-column: 1 / -1; margin: 0; padding: 9px 12px; color: var(--ink-soft); background: var(--info-pale); border-radius: var(--radius); font-size: var(--type-meta); line-height: 1.5; }
.tabs { display: flex; gap: 2px; margin: 0 0 16px; padding: 0 4px; overflow-x: auto; border-bottom: 1px solid var(--line); }
.tabs button { position: relative; min-height: 44px; padding: 8px 13px; border: 0; background: transparent; color: var(--muted); font-size: .72rem; font-weight: 700; text-transform: capitalize; cursor: pointer; }
@@ -303,6 +309,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.booking-cancel-form h2 { margin: 0 0 14px; font-size: 1rem; }
.booking-cancel-form label { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }
.booking-cancel-form textarea { min-height: 92px; padding: 10px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font: inherit; }
.booking-reschedule > summary { font-size: 1rem; font-weight: 700; }.booking-reschedule form { margin-top: 15px; }.booking-reschedule form > p { color: var(--muted); font-size: var(--type-body); }.booking-reschedule form > label { display: grid; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }.booking-reschedule textarea { min-height: 84px; padding: 10px 11px; border: 1px solid var(--line-strong); border-radius: var(--radius); font: inherit; }
.checkout-result { margin-top: 18px; padding: 18px 22px; }
.checkout-result h2 { margin: 0 0 6px; font-size: 1rem; }
.checkout-result p { margin: 0; color: var(--ink-soft); }
@@ -563,8 +570,8 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
@media (max-width: 700px) {
#main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: var(--type-meta); }.demo-guide-trigger span:not(.demo-guide-progress-pill) { display: none; }.demo-guide-panel { top: auto; right: 0; bottom: 65px; left: 0; width: 100%; height: min(78vh, 640px); border-left: 0; border-top: 1px solid var(--line); border-radius: 14px 14px 0 0; transition: height .2s ease; }.demo-guide-panel.sheet-full { height: min(78vh, 640px); }.demo-guide-panel.sheet-half { height: min(42vh, 340px); }.demo-guide-panel.sheet-collapsed { height: auto; gap: 4px; padding-bottom: 12px; }.demo-guide-sheet-handle { width: 100%; display: flex; justify-content: center; padding: 4px 0 2px; background: transparent; border: 0; cursor: pointer; }.demo-guide-sheet-handle span { width: 36px; height: 4px; background: var(--line-strong); border-radius: 999px; }@media (prefers-reduced-motion: reduce) { .demo-guide-panel { transition: none; } }.demo-start-panel { flex-direction: column; align-items: flex-start; padding: 14px; }.demo-start-actions { width: 100%; }.demo-start-actions .button { flex: 1; min-width: 0; white-space: normal; text-align: center; }.page-header { align-items: flex-start; margin-bottom: 20px; }.page-header h1 { font-size: 1.65rem; }.page-actions { width: 100%; justify-content: flex-start; }.page-description { font-size: var(--type-body); }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: visible; }.metric-cell { min-width: 0; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .65rem; white-space: normal; overflow-wrap: anywhere; }.section-heading { padding: 14px; }.section-heading > a { display: none; }.queue-controls { padding: 9px 14px; }.attention-list li { grid-template-columns: auto minmax(0,1fr) 14px; padding-inline: 14px; }.queue-ref { display: none; }.attention-detail { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }.movement-timeline { padding-inline: 14px; }.integration-list li, .recent-list li { padding-inline: 14px; }.recent-list time { display: none; }
.filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; }
.table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 40px; height: auto; display: grid; grid-template-columns: minmax(92px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 8px 12px; border: 0; text-align: right; font-size: var(--type-body); }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: var(--type-label); font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; }
.filters { display: grid; grid-template-columns: 1fr 1fr; padding: 12px; }.filters label:first-child { grid-column: 1 / -1; }.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select { min-width: 0; width: 100%; }.checkbox-label { align-self: center; }.filter-toggle { display: inline-flex; width: 100%; justify-content: space-between; }.quality-filters { display: none; }.quality-filters.is-open { display: grid; }.active-filter-chips { margin-top: 0; overflow-x: auto; flex-wrap: nowrap; padding-bottom: 3px; }.active-filter-chips button { flex: 0 0 auto; }
.table-shell { overflow: visible; border: 0; background: transparent; }.table-meta { flex-wrap: wrap; gap: 7px; min-height: 52px; border: 1px solid var(--line); border-radius: var(--radius); margin-bottom: 9px; }.table-meta > span:last-child { display: none; }.select-visible { min-height: 38px; }.data-table, .data-table tbody { display: block; }.data-table thead { display: none; }.data-table tr { position: relative; display: block; margin-bottom: 9px; padding: 7px 0; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.data-table th, .data-table td { min-height: 40px; height: auto; display: grid; grid-template-columns: minmax(92px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 8px 12px; border: 0; text-align: right; font-size: var(--type-body); }.data-table th[scope="row"] { text-align: right; }.data-table th::before, .data-table td::before { content: attr(data-label); color: var(--muted); font-size: var(--type-label); font-weight: 700; text-align: left; text-transform: uppercase; letter-spacing: .06em; }.quality-table tr { padding-top: 47px; }.quality-table .selection-cell { position: absolute; z-index: 3; top: 8px; right: 11px; min-height: 32px; display: flex; width: 32px; padding: 7px; }.quality-table .selection-cell::before { display: none; }.compare-table th, .compare-table td { text-align: left; }.pagination { justify-content: space-between; padding-inline: 0; border: 0; }.bulk-toolbar { position: sticky; bottom: 76px; z-index: 8; display: grid; grid-template-columns: 1fr 1fr; border: 1px solid #bfe6df; border-radius: var(--radius); box-shadow: var(--shadow-float); }.bulk-toolbar strong { grid-column: 1 / -1; }.bulk-toolbar label { min-width: 0; }.bulk-toolbar .button { min-width: 0; }
.tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; }
}
@@ -574,6 +581,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.demo-guide-trigger { width: 44px; justify-content: center; }.demo-badge-trigger { width: 44px; height: 44px; padding: 0; justify-content: center; font-size: 0; }.demo-badge-trigger svg { width: 16px; height: 16px; color: #48566a; }.language-switcher select { height: 44px; }
.mobile-nav span { max-width: 54px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.engineering-hero, .highlights-next { align-items: stretch; flex-direction: column; padding: 20px; }.engineering-hero-actions { min-width: 0; }.proof-grid, .highlight-grid { grid-template-columns: 1fr; }.highlight-card { min-height: 0; }.highlight-proof { margin-top: 22px; }.architecture-flow { grid-template-columns: 1fr; gap: 8px; padding: 14px; }.architecture-step { min-height: 72px; }.architecture-step > svg { right: calc(50% - 12px); top: auto; bottom: -16px; transform: rotate(90deg); }.highlights-next-actions { display: grid; }.operation-trace ol { grid-template-columns: 1fr; gap: 13px; }.operation-trace li { min-height: 54px; }.operation-trace li::after { top: 34px; bottom: -13px; left: 16px; right: auto; border-top: 0; border-left: 1px solid var(--line-strong); }.operation-trace li div { padding-bottom: 5px; }.knowledge-status { align-items: flex-start; }.knowledge-diagnostics { max-width: none; text-align: left; }.knowledge-progress { grid-template-columns: 1fr; }.retrieval-flow span { font-size: var(--type-meta); }.return-progress span { font-size: var(--type-meta); }.duplicate-compare > button { position: sticky; bottom: 78px; z-index: 6; width: 100%; box-shadow: var(--shadow-float); }
.resolution-actions { position: sticky; bottom: 72px; z-index: 6; padding: 9px; background: rgba(255,255,255,.97); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }.resolution-actions button { flex: 1; }
}
@media (prefers-reduced-motion: reduce) {