diff --git a/backend/app/api/routers/audit.py b/backend/app/api/routers/audit.py index de17abb..da76092 100644 --- a/backend/app/api/routers/audit.py +++ b/backend/app/api/routers/audit.py @@ -2,10 +2,11 @@ from __future__ import annotations import uuid from collections.abc import Sequence +from datetime import datetime from typing import Any from fastapi import APIRouter, Depends, Query -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.api.deps import get_db, require_operations_manager @@ -14,7 +15,7 @@ from app.models.booking import Booking from app.models.customer import Customer from app.models.data_quality import DataQualityIssue from app.models.vehicle import Vehicle -from app.schemas import AuditEventOut, CurrentUser +from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser router = APIRouter(prefix="/api/v1/audit", tags=["audit"]) @@ -51,26 +52,51 @@ def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid return refs -@router.get("", response_model=list[AuditEventOut]) +@router.get("", response_model=list[AuditEventOut] | AuditEventPageOut) def list_audit_events( actor_label: str | None = Query(default=None), action: str | None = Query(default=None), entity_type: str | None = Query(default=None), + entity_ref: str | None = Query(default=None, min_length=1, max_length=100), correlation_id: str | None = Query(default=None), - limit: int = Query(default=100, le=500), + occurred_from: datetime | None = Query(default=None), + occurred_to: datetime | None = Query(default=None), + page: int | None = Query(default=None, ge=1), + page_size: int = Query(default=25, ge=1, le=25), db: Session = Depends(get_db), _user: CurrentUser = Depends(require_operations_manager), -) -> list[AuditEventOut]: - stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit) +) -> list[AuditEventOut] | AuditEventPageOut: + stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()) if actor_label: stmt = stmt.where(AuditEvent.actor_label == actor_label) if action: stmt = stmt.where(AuditEvent.action == action) if entity_type: stmt = stmt.where(AuditEvent.entity_type == entity_type) + if entity_ref: + matched_ids: set[uuid.UUID] = set() + for model in _ENTITY_MODELS.values(): + matched_ids.update( + db.scalars(select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))).all() + ) + if not matched_ids: + if page is None: + return [] + return AuditEventPageOut( + items=[], page=1, page_size=page_size, total=0, total_pages=1 + ) + stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids)) if correlation_id: stmt = stmt.where(AuditEvent.correlation_id == correlation_id) - events = db.scalars(stmt).all() + if occurred_from: + stmt = stmt.where(AuditEvent.occurred_at >= occurred_from) + if occurred_to: + stmt = stmt.where(AuditEvent.occurred_at <= occurred_to) + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + page_number = page or 1 + events = db.scalars( + stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) + ).all() entity_refs = _resolve_entity_refs(db, events) out = [] @@ -94,4 +120,13 @@ def list_audit_events( metadata=e.metadata_json, ) ) - return out + if page is None: + return out + total_pages = max(1, (total + page_size - 1) // page_size) + return AuditEventPageOut( + items=out, + page=min(page_number, total_pages), + page_size=page_size, + total=total, + total_pages=total_pages, + ) diff --git a/backend/app/api/routers/data_quality.py b/backend/app/api/routers/data_quality.py index 355f85e..1347ec1 100644 --- a/backend/app/api/routers/data_quality.py +++ b/backend/app/api/routers/data_quality.py @@ -1,7 +1,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.api.deps import get_db, require_operations_manager @@ -16,6 +16,7 @@ from app.schemas import ( CurrentUser, DataQualityIssueDetailOut, DataQualityIssueOut, + DataQualityIssuePageOut, MergeCustomersRequest, MergeCustomersResult, ProvideFieldsRequest, @@ -54,14 +55,16 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut: ) -@router.get("/issues", response_model=list[DataQualityIssueOut]) +@router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut) def list_issues( status: str | None = Query(default=None), rule_type: str | None = Query(default=None), severity: str | None = Query(default=None), + page: int | None = Query(default=None, ge=1), + page_size: int = Query(default=25, ge=1, le=25), db: Session = Depends(get_db), _user: CurrentUser = Depends(require_operations_manager), -) -> list[DataQualityIssueOut]: +) -> list[DataQualityIssueOut] | DataQualityIssuePageOut: stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc()) if status: stmt = stmt.where(DataQualityIssue.status == status) @@ -69,8 +72,22 @@ def list_issues( stmt = stmt.where(DataQualityIssue.rule_type == rule_type) if severity: stmt = stmt.where(DataQualityIssue.severity == severity) - issues = db.scalars(stmt).all() - return [_to_out(i) for i in issues] + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + page_number = page or 1 + issues = db.scalars( + stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) + ).all() + items = [_to_out(i) for i in issues] + if page is None: + return items + total_pages = max(1, (total + page_size - 1) // page_size) + return DataQualityIssuePageOut( + items=items, + page=min(page_number, total_pages), + page_size=page_size, + total=total, + total_pages=total_pages, + ) # Every public reference in this system carries its entity type in its own prefix diff --git a/backend/app/api/routers/vehicles.py b/backend/app/api/routers/vehicles.py index cc783c4..d832852 100644 --- a/backend/app/api/routers/vehicles.py +++ b/backend/app/api/routers/vehicles.py @@ -1,7 +1,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.api.deps import get_current_user, get_db @@ -19,6 +19,7 @@ from app.schemas import ( MaintenanceOut, VehicleDetailOut, VehicleOut, + VehiclePageOut, ) router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"]) @@ -34,19 +35,41 @@ def _attention_vehicle_ids(db: Session) -> set: return set(rows) -@router.get("", response_model=list[VehicleOut]) +@router.get("", response_model=list[VehicleOut] | VehiclePageOut) def list_vehicles( status: str | None = Query(default=None), attention_only: bool = Query(default=False), + query: str | None = Query(default=None, min_length=1, max_length=100), + page: int | None = Query(default=None, ge=1), + page_size: int = Query(default=25, ge=1, le=25), db: Session = Depends(get_db), _user: CurrentUser = Depends(get_current_user), -) -> list[VehicleOut]: +) -> list[VehicleOut] | VehiclePageOut: stmt = select(Vehicle).order_by(Vehicle.public_ref) if status: stmt = stmt.where(Vehicle.operational_status == status) - vehicles = db.scalars(stmt).all() + if query: + term = f"%{query.strip()}%" + stmt = stmt.where( + or_( + Vehicle.public_ref.ilike(term), + Vehicle.make.ilike(term), + Vehicle.model.ilike(term), + Vehicle.location.ilike(term), + Vehicle.registration_number.ilike(term), + ) + ) attention_ids = _attention_vehicle_ids(db) - out = [ + if attention_only: + stmt = stmt.where( + or_(Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked") + ) + total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + page_number = page or 1 + vehicles = db.scalars( + stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) + ).all() + items = [ VehicleOut( public_ref=v.public_ref, make=v.make, @@ -62,9 +85,16 @@ def list_vehicles( ) for v in vehicles ] - if attention_only: - out = [v for v in out if v.attention] - return out + if page is None: + return items + total_pages = max(1, (total + page_size - 1) // page_size) + return VehiclePageOut( + items=items, + page=min(page_number, total_pages), + page_size=page_size, + total=total, + total_pages=total_pages, + ) @router.get("/{public_ref}", response_model=VehicleDetailOut) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 2d0cf1f..f5062a8 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -32,6 +32,14 @@ class VehicleOut(BaseModel): attention: bool = False +class VehiclePageOut(BaseModel): + items: list[VehicleOut] + page: int + page_size: int + total: int + total_pages: int + + class BookingSummaryOut(BaseModel): public_ref: str customer_ref: str @@ -122,6 +130,14 @@ class DataQualityIssueOut(BaseModel): resolved_at: datetime | None = None +class DataQualityIssuePageOut(BaseModel): + items: list[DataQualityIssueOut] + page: int + page_size: int + total: int + total_pages: int + + class DataQualityIssueDetailOut(DataQualityIssueOut): entity_snapshot: dict[str, Any] | None = None related_snapshots: list[dict[str, Any]] = Field(default_factory=list) @@ -440,3 +456,11 @@ class AuditEventOut(BaseModel): before: dict[str, Any] | None = None after: dict[str, Any] | None = None metadata: dict[str, Any] | None = None + + +class AuditEventPageOut(BaseModel): + items: list[AuditEventOut] + page: int + page_size: int + total: int + total_pages: int diff --git a/backend/app/services/demo_manifest.py b/backend/app/services/demo_manifest.py index 3366de5..905cf5c 100644 --- a/backend/app/services/demo_manifest.py +++ b/backend/app/services/demo_manifest.py @@ -142,7 +142,11 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode", detail_code="ragcoreDetail", detail_params={ - "count": knowledge_health.document_count, + "count": ( + knowledge_health.document_count + if knowledge_health.document_count is not None + else "unknown" + ), "collection": knowledge_health.collection, }, ), diff --git a/backend/app/services/knowledge/__init__.py b/backend/app/services/knowledge/__init__.py index 32c4896..2a164a3 100644 --- a/backend/app/services/knowledge/__init__.py +++ b/backend/app/services/knowledge/__init__.py @@ -33,7 +33,9 @@ class KnowledgeHealth(BaseModel): tenant: str workspace: str collection: str - document_count: int + # A provider may be healthy without exposing a corpus-size endpoint. `None` means + # unknown, never "zero procedures". + document_count: int | None class KnowledgeProvider(Protocol): diff --git a/backend/app/services/knowledge/ragcore.py b/backend/app/services/knowledge/ragcore.py index 4d3dbb3..660050c 100644 --- a/backend/app/services/knowledge/ragcore.py +++ b/backend/app/services/knowledge/ragcore.py @@ -86,9 +86,9 @@ class RAGcoreKnowledgeProvider: tenant=self._settings.ragcore_tenant, workspace=self._settings.ragcore_workspace, collection=self._settings.ragcore_collection, - # RAGcore's retrieval API has no corpus-size endpoint to query honestly from - # here; left at 0 rather than approximated from a capped search result count. - document_count=0, + # RAGcore's retrieval API has no corpus-size endpoint. Unknown is explicit + # so the UI never turns this into the misleading claim "0 procedures". + document_count=None, ) def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer: diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 78a9257..72bdc7a 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -23,6 +23,17 @@ def test_audit_requires_operations_manager(employee_client): assert response.status_code == 403 +def test_audit_page_is_bounded_and_exposes_filter_metadata(ops_client): + response = ops_client.get("/api/v1/audit", params={"page": 1, "page_size": 25}) + assert response.status_code == 200 + body = response.json() + assert len(body["items"]) <= 25 + assert body["page"] == 1 + assert body["page_size"] == 25 + assert body["total"] >= len(body["items"]) + assert body["total_pages"] >= 1 + + def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str: db = SessionLocal() try: diff --git a/backend/tests/test_data_quality.py b/backend/tests/test_data_quality.py index d459cdd..4468f53 100644 --- a/backend/tests/test_data_quality.py +++ b/backend/tests/test_data_quality.py @@ -53,6 +53,18 @@ def test_list_issues_requires_operations_manager(employee_client): assert response.status_code == 403 +def test_issue_page_preserves_severity_filter_and_limits_results(ops_client): + response = ops_client.get( + "/api/v1/data-quality/issues", + params={"severity": "high", "page": 1, "page_size": 25}, + ) + assert response.status_code == 200 + body = response.json() + assert len(body["items"]) <= 25 + assert all(issue["severity"] == "high" for issue in body["items"]) + assert body["total"] >= len(body["items"]) + + def test_get_issue_requires_operations_manager(employee_client): response = employee_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE") assert response.status_code == 403 diff --git a/backend/tests/test_vehicles.py b/backend/tests/test_vehicles.py index 12f2f87..bf92105 100644 --- a/backend/tests/test_vehicles.py +++ b/backend/tests/test_vehicles.py @@ -14,6 +14,18 @@ def test_attention_only_filters_flagged_vehicles(ops_client): assert all(v["attention"] for v in vehicles) +def test_vehicle_page_preserves_filters_and_limits_rendered_records(ops_client): + response = ops_client.get( + "/api/v1/vehicles", + params={"status": "maintenance", "page": 1, "page_size": 25}, + ) + assert response.status_code == 200 + body = response.json() + assert len(body["items"]) <= 25 + assert all(v["operational_status"] == "maintenance" for v in body["items"]) + assert body["total"] >= len(body["items"]) + + def test_vehicle_detail_includes_related_records(ops_client): response = ops_client.get("/api/v1/vehicles/MO-016") assert response.status_code == 200 diff --git a/docs/18-visual-product-roadmap.md b/docs/18-visual-product-roadmap.md new file mode 100644 index 0000000..ca1d24e --- /dev/null +++ b/docs/18-visual-product-roadmap.md @@ -0,0 +1,509 @@ +# MobilityOps visual product roadmap + +## Purpose + +This roadmap turns the visual audit of the deployed MobilityOps PoC into an +implementation plan. It improves the existing operational workflows without +expanding the locked product scope. + +The intended outcome is a platform that: + +- lets an operator identify and act on urgent work within thirty seconds; +- remains efficient with the current synthetic data set and larger realistic data sets; +- keeps status, context and primary actions visible on desktop and mobile; +- uses a readable, consistent visual hierarchy; +- communicates integration and knowledge health without contradictory signals; +- preserves the existing domain rules, audit guarantees and graceful degradation. + +## Scope guardrails + +Included: + +- information architecture and visual hierarchy; +- list density, pagination, filtering and responsive presentation; +- dashboard, detail, return, data-quality, knowledge, automation, audit and demo flows; +- accessibility, perceived performance and UI observability; +- regression tests, visual evidence and rollout safeguards. + +Excluded: + +- new accounting, payments, CRM, inventory, HR or reservation modules; +- maintenance work orders, calendar views or advanced analytics; +- changes to RAGcore or ITWorx MCP Hub repositories; +- autonomous write actions or write-capable MCP tools; +- a redesign of backend domain rules that is not required by the UI work. + +## Audit baseline + +The audit was performed against the deployed application at desktop, tablet and +mobile widths. The current strengths to preserve are the professional visual +identity, consistent status language, strong login page, semantic page structure, +visible keyboard focus, absence of horizontal overflow and clear return and +data-quality narratives. + +The principal improvement signals are: + +| Area | Baseline observation | Consequence | +|---|---|---| +| Audit log | Up to 100 expanded event cards; approximately 18,500 px desktop and 20,500 px mobile | Poor scanability and excessive scrolling | +| Vehicle list | All 50 vehicles render at once; approximately 12,000 px on mobile | Operational lookup is slow on small screens | +| Mobile page header | `.page-actions` is hidden below 700 px | Important record status disappears | +| Typography | Many metadata labels and badges are approximately 9–11 px | Readability is weaker than the visual quality suggests | +| Top bar | Search, language, demo controls, disclosure, timezone and operator controls compete for space | High cognitive load and weak prioritisation | +| Knowledge | An operational provider can be shown beside “0 procedures indexed” | Trust signal is ambiguous or contradictory | +| Integration cards | Technical identifiers and small prose dominate the summary | Operational state is harder to scan | + +These values are baselines, not permanent acceptance thresholds. Each affected +phase must record a before-and-after measurement. + +## Delivery principles + +1. Fix operational friction before decorative polish. +2. Prefer progressive disclosure over removing useful evidence. +3. Keep the primary status and next action visible at every viewport. +4. Paginate or virtualise unbounded collections; never solve them only with CSS. +5. Derive every displayed count and state from persisted or external evidence. +6. Preserve URLs, permissions, audit semantics and keyboard operation. +7. Ship each phase as a coherent, independently reversible commit. +8. Validate every phase at 390 px, 768 px and 1440 px. + +## Target measures + +The roadmap is complete when the following targets are met: + +| Measure | Target | +|---|---| +| Initial rows/cards in any operational list | At most 25, unless a documented compact virtualised view is used | +| Mobile status visibility | Primary record status visible on every detail page | +| Horizontal overflow | None at 360 px and above | +| Default text | At least 14 px | +| Secondary metadata | At least 12 px; exceptions require an accessibility justification | +| Touch target | At least 44 by 44 CSS px for primary mobile controls | +| Keyboard access | All actions reachable with visible focus and logical order | +| List navigation | Filter, paging and search state represented in the URL where practical | +| Dashboard comprehension | Primary operational risks and next movements visible without scrolling at 1440 × 900 | +| Browser console | No application errors during the five-minute demo | +| Performance | No avoidable rendering of more than one page of list records | +| Automated acceptance | Existing backend, frontend and Playwright gates remain green | + +## Roadmap overview + +| Phase | Theme | Priority | Indicative effort | Depends on | +|---|---|---:|---:|---| +| R0 | Baseline and design-system guardrails | P0 | 2–3 days | None | +| R1 | Operational lists and audit log | P0 | 5–7 days | R0 | +| R2 | Responsive shell and readable hierarchy | P0 | 4–6 days | R0 | +| R3 | Dashboard and record-detail efficiency | P1 | 5–7 days | R1, R2 | +| R4 | Guided workflows and decision surfaces | P1 | 5–7 days | R2, R3 | +| R5 | Knowledge, automation and trust signals | P1 | 3–5 days | R2 | +| R6 | Accessibility, performance and release evidence | P0 gate | 4–6 days | R1–R5 | + +Indicative total: 28–41 focused engineering days. Phases can span multiple +calendar sprints, but their exit gates should not be split across releases. + +## R0 — Baseline and design-system guardrails + +### Objective + +Create shared UI rules and reproducible measurements before changing individual +pages. + +### Work packages + +#### R0.1 — Visual regression baseline + +- Capture authenticated reference screenshots for the seven principal routes and + the login page at 390 × 844, 768 × 1024 and 1440 × 900. +- Add stable screenshot fixtures using the deterministic seed. +- Mask only genuinely variable timestamps; do not mask operational content. +- Record viewport width, page height, visible item count and horizontal overflow. + +#### R0.2 — Typography and spacing tokens + +- Replace scattered sub-12 px declarations with named type tokens. +- Establish body, metadata, label, badge, table-header and navigation sizes. +- Define compact and comfortable density variants without duplicating page CSS. +- Preserve the existing petrol/teal palette and status colours. + +#### R0.3 — Shared responsive patterns + +- Define a mobile page-header contract: title, visible status, primary action and + overflow actions. +- Define reusable compact-list, filter-toolbar, pagination and empty-state patterns. +- Establish 44 px mobile target sizing and a consistent sticky-offset system. + +### Acceptance criteria + +- Visual baselines exist for all principal routes and viewports. +- No normal operational metadata is smaller than 12 px. +- Shared components document their responsive behaviour. +- Existing Playwright demo and frontend build pass unchanged. + +## R1 — Operational lists and audit log + +### Objective + +Make high-volume operational information scannable and bounded on every device. + +### Work packages + +#### R1.1 — Audit API pagination and filtering + +- Replace the default fixed `limit=100` response with cursor or page-based pagination. +- Add filters for date range, action, actor, entity type, entity reference and + correlation ID. +- Retain stable newest-first ordering and service-token protections. +- Return total or continuation metadata suitable for the selected paging model. + +#### R1.2 — Compact audit timeline + +- Render 20–25 compact events per page. +- Group correlated events and repetitive scheduled events without hiding their count. +- Move full UUIDs, before/after data and technical metadata into a details drawer. +- Keep human-readable action, actor, target, outcome and timestamp in the main row. +- Preserve direct navigation to related records. + +#### R1.3 — Vehicle-list pagination + +- Add server-compatible pagination to the vehicle list. +- Preserve status, location, attention and search filters across pages. +- Store filter and page state in the URL. +- Provide a prominent “attention required” view without inventing new metrics. +- Use compact mobile rows rather than full table-to-card expansion for every vehicle. + +#### R1.4 — Data-quality queue controls + +- Paginate the open issue list. +- Add severity and rule grouping/filtering. +- Add “previous issue” and “next issue” navigation to review pages. +- Keep scan and resolution actions permission-aware. + +### Acceptance criteria + +- No default list renders more than 25 records. +- Audit and vehicle pages remain below 3,500 px at the tested mobile viewport with + the deterministic seed. +- Paging and filters survive reload, back navigation and direct links. +- Empty, loading, error and end-of-results states are explicit. +- API tests cover invalid cursors/pages, combined filters and authorization. + +## R2 — Responsive shell and readable hierarchy + +### Objective + +Reduce navigation noise and keep essential context visible on small screens. + +### Work packages + +#### R2.1 — Top-bar simplification + +- Keep global search and current operational context primary. +- Move language, timezone and sign-out into the operator menu. +- Combine demo disclosure and demo progress into one compact control. +- Replace the visually empty mobile search field with an explicit search button or + a full-width command surface. + +#### R2.2 — Mobile page actions + +- Stop hiding the complete page-action region. +- Always show record status next to or below the title. +- Keep one primary action visible and move secondary actions to an accessible menu. +- Verify booking, vehicle, data-quality and automation detail headers independently. + +#### R2.3 — Navigation refinement + +- Review whether the desktop sidebar should remain available at wider tablet widths. +- Increase mobile navigation label size and touch area. +- Keep four or five primary destinations visible and put lower-frequency destinations + under “More” if six items cannot meet readability targets. +- Preserve current routes and role-based visibility. + +#### R2.4 — Typography rollout + +- Apply the R0 type tokens to badges, tables, integration cards, timelines, forms and + mobile navigation. +- Rebalance padding where larger text would otherwise increase page height excessively. +- Verify Dutch, French and English labels for clipping and wrapping. + +### Acceptance criteria + +- Status remains visible on every sampled mobile detail page. +- No essential action is hover-only or hidden solely because of viewport width. +- Top-bar controls fit without clipping at 390, 768 and 1440 px. +- Language changes do not introduce horizontal overflow. +- Touch-target and typography targets are met. + +## R3 — Dashboard and record-detail efficiency + +### Objective + +Put operational decisions above supporting detail and make desktop space work harder. + +### Work packages + +#### R3.1 — Dashboard hierarchy + +- Keep readiness and highest-severity attention items visible in the first viewport. +- Reduce the vertical weight of the demo-scenario panel after the scenario starts. +- Limit the initial attention queue and link to a complete filtered view. +- Balance “movements today” against attention content when only a few movements exist. +- Keep integrations and recent activity available without duplicating status copy. + +#### R3.2 — Mobile dashboard summary + +- Convert readiness into a compact, horizontally accessible summary or disclosure. +- Show the highest-priority attention items first with an explicit remaining count. +- Surface the next departure and return before secondary integration information. + +#### R3.3 — Vehicle-detail workspace + +- Replace the sparse overview with a two-column desktop composition. +- Surface current status, location, odometer, next booking and active attention items. +- Keep bookings, inspections, maintenance, quality issues and audit as focused tabs. +- Avoid new maintenance-domain functionality; link only to existing evidence. + +#### R3.4 — Booking-detail summary + +- Rebalance the facts grid so incomplete final rows do not create large empty bands. +- On mobile, show status and return readiness before secondary booking facts. +- Keep the return form close to the task entry point while retaining validation context. + +### Acceptance criteria + +- At 1440 × 900 the dashboard exposes readiness, urgent work and today's movement + context without scrolling. +- Sparse states remain intentional and balanced rather than appearing unfinished. +- All values remain derived from persisted data. +- Existing return and status-domain tests remain unchanged and green. + +## R4 — Guided workflows and decision surfaces + +### Objective + +Reduce the distance between evidence and the decision an operator must make. + +### Work packages + +#### R4.1 — Return flow refinement + +- Keep the step indicator visible while completing or reviewing a return. +- Use a compact mobile booking summary above the form. +- Present validation errors next to fields and in a short focusable summary. +- Preserve the post-submit explanation of inspection, vehicle status, quality issue, + queued automation and next-booking risk. + +#### R4.2 — Duplicate-customer decision layout + +- Use evidence/comparison on the left and a sticky survivor/merge preview on desktop. +- Keep matching fields collapsed by default and differences expanded. +- Keep irreversible-action language and audit preview adjacent to the merge button. +- On mobile, use a deliberate sequence: evidence, differences, survivor, preview, + confirmation. + +#### R4.3 — Other data-quality resolutions + +- Standardise evidence, proposed resolution, consequence and audit-preview sections. +- Keep resolve, reject and defer actions visually distinct and permission-aware. +- Provide clear success feedback and navigation to the next issue. + +#### R4.4 — Guided demo integration + +- Reduce competition between the guide and normal navigation. +- Persist progress without obscuring page controls. +- Make “go to this step” land on the relevant element or focused task state. +- Ensure the guide is fully keyboard-operable and usable as a mobile bottom sheet. + +### Acceptance criteria + +- Primary workflow action is visible or reachable with one obvious interaction. +- A user never needs to scroll past repeated explanatory content to discover the + required decision. +- Focus moves to validation failures and success results appropriately. +- The five-minute demo remains deterministic. + +## R5 — Knowledge, automation and trust signals + +### Objective + +Make external-system health understandable to an operator without exposing irrelevant +technical detail. + +### Work packages + +#### R5.1 — Knowledge status semantics + +- Separate provider availability, indexed-source count and last successful sync. +- Never show “0 indexed” as a healthy equivalent when the value is unknown or stale. +- Label extractive fallback answers honestly while preserving source cards. +- Reduce empty-state height and show a compact explanation of what a cited answer contains. +- Keep suggested questions concise and prioritised. + +#### R5.2 — Source and freshness presentation + +- Surface source title, version, section and freshness consistently. +- Keep document UUIDs and raw retrieval metadata behind technical details. +- Provide explicit grounded, insufficient-evidence and unavailable states. + +#### R5.3 — Integration summary redesign + +- Structure each integration around state, last success, affected workflow and next action. +- Move client IDs, raw endpoints and UUIDs to a detail disclosure. +- Remove unnecessary fixed card height, especially at tablet widths. +- Keep genuine mixed states visible without presenting them as contradictions. + +#### R5.4 — Workflow evidence table + +- Improve status and recency scanning. +- Distinguish “never triggered”, “no evidence yet”, “failed” and “unavailable”. +- Keep retry actions safe, audited and available only where already supported. + +### Acceptance criteria + +- Knowledge and integration states cannot make mutually contradictory claims. +- An operator can identify the current state and next action within one card scan. +- Technical identifiers do not dominate default views. +- RAGcore, n8n and MCP Hub timeout/unavailable contract tests stay green. + +## R6 — Accessibility, performance and release evidence + +### Objective + +Turn the improvements into a verified, releasable quality baseline. + +### Work packages + +#### R6.1 — Accessibility review + +- Test complete keyboard traversal of login, navigation, filters, return, merge, + knowledge, workflow retry and audit details. +- Verify focus order, focus restoration, dialog labelling and error announcements. +- Run automated accessibility checks on all principal routes. +- Manually verify status is not encoded by colour alone. +- Verify zoom at 200% and 360 px responsive reflow. + +#### R6.2 — Rendering and interaction performance + +- Measure list rendering before and after pagination. +- Check layout shift and interaction latency on dashboard and long-list routes. +- Ensure drawers, menus and guided-demo panels do not mount large hidden subtrees. +- Test with reduced motion and a throttled CPU profile. + +#### R6.3 — Cross-browser and localisation pass + +- Verify current Chrome plus one additional Chromium/Firefox-equivalent target where + supported by the test environment. +- Run Dutch, French and English visual checks at all target widths. +- Verify Europe/Brussels date and time rendering. + +#### R6.4 — Final automated journey and evidence + +- Extend the existing Playwright demo with pagination, mobile status and accessible + details checks. +- Capture final screenshots for all principal pages. +- Add before-and-after measurements and known PoC limitations to final evidence. +- Execute clean-checkout bootstrap, backend tests, lint, typing and frontend build. + +### Acceptance criteria + +- No critical or serious automated accessibility violations on principal routes. +- All target measures in this document pass. +- `docs/14-testing-and-acceptance.md` passes from a clean checkout. +- `artifacts/evidence/final-summary.md` contains the final commands, counts, + screenshots and limitations. + +## Suggested release slices + +### Release A — Operational scale + +Contains R0 and R1. This release eliminates the most severe scroll and list-density +problems without materially changing workflow structure. + +Release gate: + +- paginated audit, vehicles and data-quality queues; +- URL-backed filters; +- unchanged audit and authorization semantics; +- visual regression baseline green. + +### Release B — Responsive operations + +Contains R2 and R3. This release improves navigation, typography, dashboard hierarchy +and detail-page use of space. + +Release gate: + +- visible mobile statuses and primary actions; +- readable typography and touch targets; +- dashboard first-viewport target met; +- Dutch, French and English responsive checks green. + +### Release C — Guided decisions and trust + +Contains R4 and R5. This release refines the return, merge, knowledge, automation and +demo-guide experiences. + +Release gate: + +- decision actions remain near their evidence; +- honest and non-contradictory external health states; +- five-minute demo passes end to end. + +### Release D — Quality certification + +Contains R6 and only defects found by its gates. It adds no new product scope. + +Release gate: + +- full clean-checkout acceptance; +- accessibility and performance targets met; +- final evidence updated; +- release tag and deployed revision recorded. + +## Implementation sequence and dependencies + +```text +R0 shared tokens and baselines + ├─ R1 pagination and compact lists ─┐ + └─ R2 responsive shell and type ───┼─ R3 dashboard/details ─ R4 workflows + └─ R5 trust surfaces +R1 + R2 + R3 + R4 + R5 ──────────────────────────────── R6 release gate +``` + +R1 and R2 may be developed in parallel after R0. R3 should consume both shared list +and responsive patterns. R4 depends on the new page-header and detail hierarchy. R5 can +start after R2 but must share its disclosure and status patterns. R6 is a gate, not a +cleanup bucket; defects discovered there return to the owning phase. + +## Per-phase engineering checklist + +Every phase must include: + +1. a short before-state measurement; +2. API and data-contract impact assessment; +3. implementation using shared components where applicable; +4. unit/API tests for changed contracts; +5. Playwright coverage for the changed user journey; +6. manual checks at 390, 768 and 1440 px in all supported languages; +7. accessibility and console check; +8. updated evidence and `PROJECT_STATE.md` entry; +9. one coherent commit after validation passes; +10. deployment verification before starting the next release slice. + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Pagination changes API consumers | Add pagination metadata without silently changing service-token/MCP response contracts; version only if necessary | +| Compact layouts hide evidence | Use drawers and disclosures, preserving direct access and auditability | +| Larger type increases page height | Combine type changes with density and hierarchy work, never shrink text again | +| Sticky UI covers content on mobile | Use shared top/bottom offsets and test keyboard/zoom states | +| Visual snapshots become brittle | Seed deterministic data and mask only true timestamp volatility | +| External health data is stale | Show last successful evidence and distinguish unknown from healthy | +| Roadmap drifts into new product scope | Check every work package against `docs/01-scope-and-non-goals.md` and `docs/deferred.md` | + +## Definition of roadmap completion + +This roadmap is complete only when Releases A through D are deployed and verified, +all target measures pass, the original five-minute demo remains green, and the final +evidence records the exact deployed revision. Completing only the visual styling or one +high-priority page does not complete the roadmap. diff --git a/frontend/e2e/roadmap-regression.spec.ts b/frontend/e2e/roadmap-regression.spec.ts new file mode 100644 index 0000000..675fafc --- /dev/null +++ b/frontend/e2e/roadmap-regression.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test"; + +test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => { + await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); + + const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25"); + expect(vehicles.ok()).toBeTruthy(); + const vehiclePage = await vehicles.json(); + expect(vehiclePage.items).toHaveLength(25); + expect(vehiclePage.total).toBeGreaterThanOrEqual(25); + + const audit = await request.get("/api/v1/audit?page=1&page_size=25"); + expect(audit.ok()).toBeTruthy(); + const auditPage = await audit.json(); + expect(auditPage.items.length).toBeLessThanOrEqual(25); + + await page.goto("/vehicles?status=maintenance&page=1"); + await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible(); + await expect(page).toHaveURL(/status=maintenance/); +}); + +test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/login"); + await page.getByRole("button", { name: "Verken als Operationsmanager" }).click(); + await page.goto("/bookings/BK-DEMO-RETURN"); + + await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible(); + await expect(page.locator(".page-actions .badge")).toBeVisible(); + await page.goto("/vehicles"); + const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth })); + expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1); +}); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b03b0ad..ae76242 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -20,6 +20,14 @@ export interface Vehicle { attention: boolean; } +export interface Page { + items: T[]; + page: number; + page_size: number; + total: number; + total_pages: number; +} + export interface BookingSummary { public_ref: string; customer_ref: string; @@ -260,7 +268,7 @@ export interface KnowledgeHealth { tenant: string; workspace: string; collection: string; - document_count: number; + document_count: number | null; } export interface N8nWorkflowEvidence { diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 873b9db..82d5f54 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -313,19 +313,23 @@ export function Layout() { )}
- - {t("common:timezone")} {user && ( -
- {user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)} - {user.display_name}{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")} -
+
+ + {user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)} + {user.display_name}{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")} + +
+ + {t("common:timezone")} + +
+
)} -
diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 0000000..2623032 --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -0,0 +1,25 @@ +import { useTranslation } from "react-i18next"; + +export function Pagination({ + page, + totalPages, + onPageChange, +}: { + page: number; + totalPages: number; + onPageChange: (page: number) => void; +}) { + const { t } = useTranslation("common"); + if (totalPages <= 1) return null; + return ( + + ); +} diff --git a/frontend/src/i18n/locales/en-GB/audit.json b/frontend/src/i18n/locales/en-GB/audit.json index b5422e6..991eab1 100644 --- a/frontend/src/i18n/locales/en-GB/audit.json +++ b/frontend/src/i18n/locales/en-GB/audit.json @@ -4,6 +4,12 @@ "description": "Trace important state changes, actors and correlation references.", "actionFilterLabel": "Action", "actionFilterPlaceholder": "e.g. demo_login", + "actorFilterLabel": "Actor", + "actorFilterPlaceholder": "Name or service", + "entityFilterLabel": "Record", + "entityFilterPlaceholder": "e.g. MO-016", + "fromFilterLabel": "From", + "toFilterLabel": "To", "managerOnly": "The audit trail is visible to Operations Managers only.", "managerOnlyDetail": "Audit history is visible to Operations Managers only.", "loading": "Loading audit trail…", diff --git a/frontend/src/i18n/locales/en-GB/common.json b/frontend/src/i18n/locales/en-GB/common.json index 84a09c7..4171ac2 100644 --- a/frontend/src/i18n/locales/en-GB/common.json +++ b/frontend/src/i18n/locales/en-GB/common.json @@ -22,6 +22,12 @@ "success": "Success", "noResults": "No results" }, + "pagination": { + "label": "Page navigation", + "previous": "Previous", + "next": "Next", + "pageOf": "Page {{page}} of {{total}}" + }, "states": { "loadingDefault": "Loading workspace…", "errorTitle": "We couldn't load this workspace." diff --git a/frontend/src/i18n/locales/en-GB/dashboard.json b/frontend/src/i18n/locales/en-GB/dashboard.json index e0899a0..3e6fe47 100644 --- a/frontend/src/i18n/locales/en-GB/dashboard.json +++ b/frontend/src/i18n/locales/en-GB/dashboard.json @@ -36,6 +36,7 @@ "title": "Attention queue", "description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions", "reviewQueue": "Review queue", + "remaining": "View {{count}} more attention item(s)", "filterPlaceholder": "Filter issues…", "filterAriaLabel": "Search attention queue", "severityAll": "All severity", @@ -68,6 +69,7 @@ "n8nNoEvidence": "No workflow evidence recorded", "knowledgeTitle": "Knowledge assistant", "knowledgeSummary": "{{count}} procedures indexed", + "knowledgeIndexUnknown": "Knowledge source available · index size unknown", "knowledgeUnavailable": "Health check unavailable", "mcpTitle": "MCP Hub", "mcpEnabled": "Registration enabled", diff --git a/frontend/src/i18n/locales/en-GB/integrations.json b/frontend/src/i18n/locales/en-GB/integrations.json index 2f4a4d0..d38a465 100644 --- a/frontend/src/i18n/locales/en-GB/integrations.json +++ b/frontend/src/i18n/locales/en-GB/integrations.json @@ -11,6 +11,7 @@ "knowledgeTitle": "Knowledge assistant", "knowledgeSummaryDemo": "Demo knowledge base · {{count}} procedures indexed in {{collection}}.", "knowledgeSummaryRagcore": "RAGcore · {{count}} procedures indexed in {{collection}}.", + "knowledgeSummaryIndexUnknown": "Knowledge source available · index size is unavailable for {{collection}}.", "knowledgeUnavailable": "Health evidence is currently unavailable.", "gatewayKicker": "Tool gateway", "mcpTitle": "MCP Hub", diff --git a/frontend/src/i18n/locales/en-GB/knowledge.json b/frontend/src/i18n/locales/en-GB/knowledge.json index 87fc924..bc6df36 100644 --- a/frontend/src/i18n/locales/en-GB/knowledge.json +++ b/frontend/src/i18n/locales/en-GB/knowledge.json @@ -7,6 +7,7 @@ "statusAvailable": "Available", "statusUnavailable": "Unavailable", "proceduresIndexed": "{{count}} procedures indexed", + "proceduresIndexUnknown": "Index size is not available through this connection", "providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.", "askHeading": "Ask a procedure question", "askSubheading": "Retrieval → evidence check → grounded answer", diff --git a/frontend/src/i18n/locales/en-GB/quality.json b/frontend/src/i18n/locales/en-GB/quality.json index 2296113..afc4949 100644 --- a/frontend/src/i18n/locales/en-GB/quality.json +++ b/frontend/src/i18n/locales/en-GB/quality.json @@ -20,6 +20,8 @@ "statusRejected": "Rejected", "ruleTypeLabel": "Rule type", "ruleTypeAll": "All rule types", + "severityLabel": "Severity", + "severityAll": "All severity levels", "demoScenariosOnly": "Demo scenarios only", "loading": "Loading quality workbench…", "queueClear": "Queue is clear", diff --git a/frontend/src/i18n/locales/fr-BE/audit.json b/frontend/src/i18n/locales/fr-BE/audit.json index 43eaa06..ca34de1 100644 --- a/frontend/src/i18n/locales/fr-BE/audit.json +++ b/frontend/src/i18n/locales/fr-BE/audit.json @@ -4,6 +4,12 @@ "description": "Suivez les changements d'état importants, les acteurs et les références corrélées.", "actionFilterLabel": "Action", "actionFilterPlaceholder": "p. ex. demo_login", + "actorFilterLabel": "Intervenant", + "actorFilterPlaceholder": "Nom ou service", + "entityFilterLabel": "Dossier", + "entityFilterPlaceholder": "p. ex. MO-016", + "fromFilterLabel": "À partir du", + "toFilterLabel": "Jusqu’au", "managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.", "managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.", "loading": "Chargement de la piste d'audit…", diff --git a/frontend/src/i18n/locales/fr-BE/common.json b/frontend/src/i18n/locales/fr-BE/common.json index f938e76..007bcd5 100644 --- a/frontend/src/i18n/locales/fr-BE/common.json +++ b/frontend/src/i18n/locales/fr-BE/common.json @@ -22,6 +22,12 @@ "success": "Réussi", "noResults": "Aucun résultat" }, + "pagination": { + "label": "Navigation entre les pages", + "previous": "Précédent", + "next": "Suivant", + "pageOf": "Page {{page}} sur {{total}}" + }, "states": { "loadingDefault": "Chargement de l'espace de travail…", "errorTitle": "Impossible de charger cet espace de travail." diff --git a/frontend/src/i18n/locales/fr-BE/dashboard.json b/frontend/src/i18n/locales/fr-BE/dashboard.json index 53b37de..e2ca3ec 100644 --- a/frontend/src/i18n/locales/fr-BE/dashboard.json +++ b/frontend/src/i18n/locales/fr-BE/dashboard.json @@ -36,6 +36,7 @@ "title": "File d'attention", "description": "{{openIssues}} problèmes de qualité ouverts · {{workflowExceptions}} exceptions de workflow", "reviewQueue": "Examiner la file", + "remaining": "Voir encore {{count}} point(s) d’attention", "filterPlaceholder": "Filtrer les problèmes…", "filterAriaLabel": "Rechercher dans la file d'attention", "severityAll": "Toute gravité", @@ -68,6 +69,7 @@ "n8nNoEvidence": "Aucune preuve d'automatisation enregistrée", "knowledgeTitle": "Assistant de connaissances", "knowledgeSummary": "{{count}} procédures indexées", + "knowledgeIndexUnknown": "Source de connaissances disponible · taille d’index inconnue", "knowledgeUnavailable": "Vérification de santé indisponible", "mcpTitle": "MCP Hub", "mcpEnabled": "Enregistrement activé", diff --git a/frontend/src/i18n/locales/fr-BE/integrations.json b/frontend/src/i18n/locales/fr-BE/integrations.json index 6956b8b..cf8005c 100644 --- a/frontend/src/i18n/locales/fr-BE/integrations.json +++ b/frontend/src/i18n/locales/fr-BE/integrations.json @@ -11,6 +11,7 @@ "knowledgeTitle": "Assistant de connaissances", "knowledgeSummaryDemo": "Base de connaissances de démo · {{count}} procédures indexées dans {{collection}}.", "knowledgeSummaryRagcore": "RAGcore · {{count}} procédures indexées dans {{collection}}.", + "knowledgeSummaryIndexUnknown": "Source de connaissances disponible · taille d’index indisponible pour {{collection}}.", "knowledgeUnavailable": "Preuves de santé actuellement indisponibles.", "gatewayKicker": "Passerelle d'outils", "mcpTitle": "MCP Hub", diff --git a/frontend/src/i18n/locales/fr-BE/knowledge.json b/frontend/src/i18n/locales/fr-BE/knowledge.json index 34e0100..17b7533 100644 --- a/frontend/src/i18n/locales/fr-BE/knowledge.json +++ b/frontend/src/i18n/locales/fr-BE/knowledge.json @@ -7,6 +7,7 @@ "statusAvailable": "Disponible", "statusUnavailable": "Indisponible", "proceduresIndexed": "{{count}} procédures indexées", + "proceduresIndexUnknown": "La taille de l’index n’est pas disponible via cette connexion", "providerNote": "Cette démo répond à partir d'un petit ensemble fixe de procédures indexées — pas d'une connexion RAGcore en direct. Un backend RAGcore en direct reprendra plus tard la même interface sans changer le fonctionnement de cette page.", "askHeading": "Poser une question de procédure", "askSubheading": "Recherche → vérification des preuves → réponse étayée", diff --git a/frontend/src/i18n/locales/fr-BE/quality.json b/frontend/src/i18n/locales/fr-BE/quality.json index 8044d39..17de619 100644 --- a/frontend/src/i18n/locales/fr-BE/quality.json +++ b/frontend/src/i18n/locales/fr-BE/quality.json @@ -20,6 +20,8 @@ "statusRejected": "Rejeté", "ruleTypeLabel": "Type de règle", "ruleTypeAll": "Tous les types de règles", + "severityLabel": "Gravité", + "severityAll": "Tous les niveaux de gravité", "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/audit.json b/frontend/src/i18n/locales/nl-BE/audit.json index dd41e31..2c9d984 100644 --- a/frontend/src/i18n/locales/nl-BE/audit.json +++ b/frontend/src/i18n/locales/nl-BE/audit.json @@ -4,6 +4,12 @@ "description": "Volg belangrijke statuswijzigingen, actoren en gekoppelde gebeurtenissen op.", "actionFilterLabel": "Actie", "actionFilterPlaceholder": "bv. demo-login", + "actorFilterLabel": "Uitvoerder", + "actorFilterPlaceholder": "Naam of dienst", + "entityFilterLabel": "Record", + "entityFilterPlaceholder": "bv. MO-016", + "fromFilterLabel": "Vanaf", + "toFilterLabel": "Tot en met", "managerOnly": "De auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.", "managerOnlyDetail": "Auditgeschiedenis is enkel zichtbaar voor Operationsmanagers.", "loading": "Auditgeschiedenis laden…", diff --git a/frontend/src/i18n/locales/nl-BE/common.json b/frontend/src/i18n/locales/nl-BE/common.json index 8818499..c09c7ff 100644 --- a/frontend/src/i18n/locales/nl-BE/common.json +++ b/frontend/src/i18n/locales/nl-BE/common.json @@ -22,6 +22,12 @@ "success": "Gelukt", "noResults": "Geen resultaten" }, + "pagination": { + "label": "Paginanavigatie", + "previous": "Vorige", + "next": "Volgende", + "pageOf": "Pagina {{page}} van {{total}}" + }, "states": { "loadingDefault": "Werkruimte wordt geladen…", "errorTitle": "We konden deze werkruimte niet laden." diff --git a/frontend/src/i18n/locales/nl-BE/dashboard.json b/frontend/src/i18n/locales/nl-BE/dashboard.json index 086152e..11d5cdf 100644 --- a/frontend/src/i18n/locales/nl-BE/dashboard.json +++ b/frontend/src/i18n/locales/nl-BE/dashboard.json @@ -36,6 +36,7 @@ "title": "Aandachtspunten", "description": "{{openIssues}} open datakwaliteitsproblemen · {{workflowExceptions}} automatiseringsuitzonderingen", "reviewQueue": "Wachtrij bekijken", + "remaining": "Nog {{count}} aandachtspunt(en) bekijken", "filterPlaceholder": "Filter aandachtspunten…", "filterAriaLabel": "Zoek in aandachtspunten", "severityAll": "Alle ernst", @@ -68,6 +69,7 @@ "n8nNoEvidence": "Geen automatiseringsevidentie geregistreerd", "knowledgeTitle": "Kennisassistent", "knowledgeSummary": "{{count}} procedures geïndexeerd", + "knowledgeIndexUnknown": "Kennisbron bereikbaar · indexomvang onbekend", "knowledgeUnavailable": "Statuscontrole niet beschikbaar", "mcpTitle": "MCP Hub", "mcpEnabled": "Registratie ingeschakeld", diff --git a/frontend/src/i18n/locales/nl-BE/integrations.json b/frontend/src/i18n/locales/nl-BE/integrations.json index dfc62fc..3c6da07 100644 --- a/frontend/src/i18n/locales/nl-BE/integrations.json +++ b/frontend/src/i18n/locales/nl-BE/integrations.json @@ -11,6 +11,7 @@ "knowledgeTitle": "Kennisassistent", "knowledgeSummaryDemo": "Demokennisbank · {{count}} procedures geïndexeerd in {{collection}}.", "knowledgeSummaryRagcore": "RAGcore · {{count}} procedures geïndexeerd in {{collection}}.", + "knowledgeSummaryIndexUnknown": "Kennisbron bereikbaar · indexomvang niet beschikbaar voor {{collection}}.", "knowledgeUnavailable": "Statusevidentie momenteel niet beschikbaar.", "gatewayKicker": "Tool-gateway", "mcpTitle": "MCP Hub", diff --git a/frontend/src/i18n/locales/nl-BE/knowledge.json b/frontend/src/i18n/locales/nl-BE/knowledge.json index 8f4c6f1..0e9d8fd 100644 --- a/frontend/src/i18n/locales/nl-BE/knowledge.json +++ b/frontend/src/i18n/locales/nl-BE/knowledge.json @@ -7,6 +7,7 @@ "statusAvailable": "Beschikbaar", "statusUnavailable": "Niet beschikbaar", "proceduresIndexed": "{{count}} procedures geïndexeerd", + "proceduresIndexUnknown": "Indexomvang niet beschikbaar via deze koppeling", "providerNote": "Deze demo beantwoordt vanuit een kleine, vaste set geïndexeerde procedures — geen live RAGcore-koppeling. Een live RAGcore-backend zal later dezelfde interface overnemen, zonder dat deze pagina verandert.", "askHeading": "Stel een procedurevraag", "askSubheading": "Ophalen → evidentiecontrole → onderbouwd antwoord", diff --git a/frontend/src/i18n/locales/nl-BE/quality.json b/frontend/src/i18n/locales/nl-BE/quality.json index e1e6ba8..cf1c246 100644 --- a/frontend/src/i18n/locales/nl-BE/quality.json +++ b/frontend/src/i18n/locales/nl-BE/quality.json @@ -20,6 +20,8 @@ "statusRejected": "Verworpen", "ruleTypeLabel": "Regeltype", "ruleTypeAll": "Alle regeltypes", + "severityLabel": "Ernst", + "severityAll": "Alle ernstniveaus", "demoScenariosOnly": "Enkel demoscenario's", "loading": "Kwaliteitswerkbank laden…", "queueClear": "Wachtrij is leeg", diff --git a/frontend/src/pages/Audit.tsx b/frontend/src/pages/Audit.tsx index 8269de9..36c4f4b 100644 --- a/frontend/src/pages/Audit.tsx +++ b/frontend/src/pages/Audit.tsx @@ -2,10 +2,11 @@ import { useEffect, useMemo, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { api } from "../api/client"; -import type { AuditEvent } from "../api/types"; +import type { AuditEvent, Page } from "../api/types"; import { useAuth } from "../context/AuthContext"; import { useLocaleFormat } from "../i18n/format"; import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; +import { Pagination } from "../components/Pagination"; function humanizeField(field: string): string { const spaced = field.replace(/_/g, " "); @@ -20,210 +21,106 @@ function actionLabel(t: (key: string, options?: Record) => stri return t(`actions.${action}`, { defaultValue: action.replace(/_/g, " ") }); } -function ChangeDiff({ - before, - after, - t, -}: { - before: Record | null; - after: Record | null; - t: (key: string, options?: Record) => string; -}) { +function ChangeDiff({ before, after, t }: { before: Record | null; after: Record | null; t: (key: string, options?: Record) => string }) { if (!before && !after) return

{t("noChangeDetail")}

; - const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]); - const lines: { field: string; text: string }[] = []; - for (const key of keys) { - const b = before?.[key]; - const a = after?.[key]; - if (JSON.stringify(b) === JSON.stringify(a)) continue; - const field = t(`fields.${key}`, { defaultValue: humanizeField(key) }); - if (b === undefined) lines.push({ field, text: t("diff.setTo", { field, value: JSON.stringify(a) }) }); - else if (a === undefined) lines.push({ field, text: t("diff.was", { field, value: JSON.stringify(b) }) }); - else lines.push({ field, text: t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) }) }); - } - if (lines.length === 0) return

{t("noFieldChange")}

; - return ( -
    - {lines.map((line) => ( -
  • {line.text}
  • - ))} -
- ); + const lines = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])] + .filter((key) => JSON.stringify(before?.[key]) !== JSON.stringify(after?.[key])) + .slice(0, 3) + .map((key) => { + const field = t(`fields.${key}`, { defaultValue: humanizeField(key) }); + const b = before?.[key]; + const a = after?.[key]; + if (b === undefined) return t("diff.setTo", { field, value: JSON.stringify(a) }); + if (a === undefined) return t("diff.was", { field, value: JSON.stringify(b) }); + return t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) }); + }); + return lines.length ?
    {lines.map((line) =>
  • {line}
  • )}
:

{t("noFieldChange")}

; } -interface EventGroup { - correlationId: string; - primary: AuditEvent; - related: AuditEvent[]; -} +interface EventGroup { correlationId: string; primary: AuditEvent; related: AuditEvent[]; } export function Audit() { const { t } = useTranslation("audit"); const { formatDateTime } = useLocaleFormat(); const { user } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); - const [events, setEvents] = useState(null); + const [events, setEvents] = useState | null>(null); const [error, setError] = useState(null); - const [action, setAction] = useState(searchParams.get("action") ?? ""); const [expanded, setExpanded] = useState>({}); + const action = searchParams.get("action") ?? ""; + const actor = searchParams.get("actor") ?? ""; + const entityRef = searchParams.get("entity_ref") ?? ""; + const from = searchParams.get("from") ?? ""; + const to = searchParams.get("to") ?? ""; const correlationId = searchParams.get("correlation_id") ?? ""; + const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); + + function updateParams(updates: Record) { + const next = new URLSearchParams(searchParams); + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === "") next.delete(key); + else next.set(key, String(value)); + }); + setSearchParams(next); + } useEffect(() => { if (user?.role !== "operations_manager") return; setEvents(null); setError(null); - const params = new URLSearchParams(); + const params = new URLSearchParams({ page: String(page), page_size: "25" }); if (action) params.set("action", action); + if (actor) params.set("actor_label", actor); + if (entityRef) params.set("entity_ref", entityRef); if (correlationId) params.set("correlation_id", correlationId); - api - .get(`/api/v1/audit?${params.toString()}`) - .then(setEvents) - .catch(() => setError(t("unavailable"))); - }, [action, correlationId, user]); + if (from) params.set("occurred_from", `${from}T00:00:00Z`); + if (to) params.set("occurred_to", `${to}T23:59:59Z`); + api.get>(`/api/v1/audit?${params.toString()}`).then(setEvents).catch(() => setError(t("unavailable"))); + }, [action, actor, correlationId, entityRef, from, page, t, to, user]); const groups = useMemo(() => { - if (!events) return []; - const order: string[] = []; const byCorrelation = new Map(); - for (const event of events) { - if (!byCorrelation.has(event.correlation_id)) { - order.push(event.correlation_id); - byCorrelation.set(event.correlation_id, []); - } - byCorrelation.get(event.correlation_id)!.push(event); - } - return order.map((id) => { - const group = byCorrelation.get(id)!; - return { correlationId: id, primary: group[0], related: group.slice(1) }; - }); + for (const event of events?.items ?? []) byCorrelation.set(event.correlation_id, [...(byCorrelation.get(event.correlation_id) ?? []), event]); + return [...byCorrelation.entries()].map(([id, grouped]) => ({ correlationId: id, primary: grouped[0], related: grouped.slice(1) })); }, [events]); - function showRelatedEvents(id: string) { - setSearchParams({ correlation_id: id }); - } + if (user?.role !== "operations_manager") return

{t("managerOnlyDetail")}

; - function clearCorrelationFilter() { - setSearchParams(action ? { action } : {}); - } - - function toggleExpanded(id: string) { - setExpanded((prev) => ({ ...prev, [id]: !prev[id] })); - } - - if (user?.role !== "operations_manager") { - return ( -
- -

{t("managerOnlyDetail")}

-
- ); - } - - return ( -
- - -
- -
- - {correlationId && ( -

- {t("relatedFilterActive", { count: events?.length ?? 0 })}{" "} - -

- )} - - {error && } - {!error && !events && } - {events && events.length === 0 && } - - {groups.length > 0 && ( -
-
{t("count", { count: events!.length })}{t("storedRendered")}
-
    - {groups.map((group) => { - const e = group.primary; - const isExpanded = expanded[group.correlationId] ?? false; - return ( -
  • -
    -
    - {actionLabel(t, e.action)} - {formatDateTime(e.occurred_at)} -
    -
    - {e.actor_label} {t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })} - {e.entity_link ? ( - {e.entity_ref ?? e.entity_type} - ) : ( - {e.entity_ref ?? e.entity_type} - )} -
    - -
    - {group.related.length > 0 && ( - - )} - {!correlationId && ( - - )} -
    -
    - -
    - {t("technicalDetails")} -
    -
    {t("reference")}
    {shortRef(e.id)}
    -
    {t("fullReference")}
    {e.id}
    -
    {t("correlationId")}
    {e.correlation_id}
    -
    -
    {JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}
    -
    - - {isExpanded && group.related.length > 0 && ( -
      - {group.related.map((related) => ( -
    • -
      - {actionLabel(t, related.action)} - {formatDateTime(related.occurred_at)} -
      - -
      - {t("technicalDetails")} -
      -
      {t("reference")}
      {shortRef(related.id)}
      -
      {t("fullReference")}
      {related.id}
      -
      -
      {JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}
      -
      -
    • - ))} -
    - )} -
  • - ); - })} -
-
- )} -
- ); + return
+ +
event.preventDefault()}> + + + + + +
+ {correlationId &&

{t("relatedFilterActive", { count: events?.total ?? 0 })}

} + {error && } + {!error && !events && } + {events && events.items.length === 0 && } + {groups.length > 0 &&
+
{t("count", { count: events!.total })}{t("storedRendered")}
+
    + {groups.map((group) => { + const e = group.primary; + const isExpanded = expanded[group.correlationId] ?? false; + return
  • +
    +
    {actionLabel(t, e.action)}{formatDateTime(e.occurred_at)}
    +
    {e.actor_label} {t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}{e.entity_link ? {e.entity_ref ?? e.entity_type} : {e.entity_ref ?? e.entity_type}}
    + +
    + {group.related.length > 0 && } + {!correlationId && } +
    +
    +
    {t("technicalDetails")}
    {t("reference")}
    {shortRef(e.id)}
    {t("fullReference")}
    {e.id}
    {t("correlationId")}
    {e.correlation_id}
    {JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}
    + {isExpanded && group.related.length > 0 &&
      {group.related.map((related) =>
    • {actionLabel(t, related.action)}{formatDateTime(related.occurred_at)}
      {t("technicalDetails")}
      {JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}
    • )}
    } +
  • ; + })} +
+ updateParams({ page: nextPage })} /> +
} +
; } diff --git a/frontend/src/pages/Automation.tsx b/frontend/src/pages/Automation.tsx index f961897..fce7738 100644 --- a/frontend/src/pages/Automation.tsx +++ b/frontend/src/pages/Automation.tsx @@ -201,10 +201,12 @@ export function Automation() {

{t("cards.knowledgeTitle")}

{knowledge - ? t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", { - count: knowledge.document_count, - collection: knowledge.collection, - }) + ? knowledge.document_count === null + ? t("cards.knowledgeSummaryIndexUnknown", { collection: knowledge.collection }) + : t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", { + count: knowledge.document_count, + collection: knowledge.collection, + }) : t("cards.knowledgeUnavailable")}

diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 45ce925..f62f2d8 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -91,7 +91,11 @@ export function Dashboard() { const isUnfiltered = severity === "all" && query === ""; const attentionTiers = useMemo(() => { if (!isUnfiltered) return [{ key: "all", labelKey: "", items: attention.slice(0, 6) }]; - const bySeverity = (level: string) => attention.filter((item) => item.severity === level); + // Keep the initial dashboard queue deliberately bounded. The full evidence-backed + // queue remains one click away in Data Quality; this view is for deciding what to + // do next, not for rendering the whole backlog. + const visible = ["high", "medium", "low"].flatMap((level) => attention.filter((item) => item.severity === level)).slice(0, 6); + const bySeverity = (level: string) => visible.filter((item) => item.severity === level); return [ { key: "high", labelKey: "attention.tierNow", items: bySeverity("high") }, { key: "medium", labelKey: "attention.tierToday", items: bySeverity("medium") }, @@ -185,6 +189,9 @@ export function Dashboard() { ))} + {isUnfiltered && attention.length > 6 && canSeeQuality && ( +

{t("attention.remaining", { count: attention.length - 6 })}

+ )} )} @@ -238,7 +245,7 @@ export function Dashboard() {
{t("integrationPulse.knowledgeTitle")} - {knowledge ? t("integrationPulse.knowledgeSummary", { count: knowledge.document_count }) : t("integrationPulse.knowledgeUnavailable")} + {knowledge ? (knowledge.document_count === null ? t("integrationPulse.knowledgeIndexUnknown") : t("integrationPulse.knowledgeSummary", { count: knowledge.document_count })) : t("integrationPulse.knowledgeUnavailable")}
(null); + const [searchParams, setSearchParams] = useSearchParams(); + const [issues, setIssues] = useState | null>(null); const [error, setError] = useState(null); - const [status, setStatus] = useState("open"); - const [ruleType, setRuleType] = useState(""); + const status = searchParams.get("status") ?? "open"; + const ruleType = searchParams.get("rule_type") ?? ""; + const severity = searchParams.get("severity") ?? ""; + const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); const [scanning, setScanning] = useState(false); const [scanError, setScanError] = useState(null); const [scanResult, setScanResult] = useState(null); const [confirmingScan, setConfirmingScan] = useState(false); - const [demoScenariosOnly, setDemoScenariosOnly] = useState(false); + const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true"); + + function updateFilters(updates: Record) { + const next = new URLSearchParams(searchParams); + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === "" || value === false) next.delete(key); + else next.set(key, String(value)); + }); + setSearchParams(next); + } const load = useCallback(() => { if (user?.role !== "operations_manager") return; @@ -36,11 +49,14 @@ export function DataQuality() { const params = new URLSearchParams(); if (status) params.set("status", status); if (ruleType) params.set("rule_type", ruleType); + if (severity) params.set("severity", severity); + params.set("page", String(page)); + params.set("page_size", "25"); api - .get(`/api/v1/data-quality/issues?${params.toString()}`) + .get>(`/api/v1/data-quality/issues?${params.toString()}`) .then(setIssues) .catch(() => setError(t("list.unavailable"))); - }, [status, ruleType, user]); + }, [status, ruleType, severity, page, user, t]); useEffect(() => { load(); @@ -73,8 +89,8 @@ export function DataQuality() { const scanTotal = scanResult ? Object.values(scanResult.created).reduce((a, b) => a + b, 0) : 0; const visibleIssues = issues ? demoScenariosOnly - ? issues.filter((i) => i.public_ref.startsWith("DQ-DEMO-")) - : issues + ? issues.items.filter((i) => i.public_ref.startsWith("DQ-DEMO-")) + : issues.items : []; return ( @@ -118,7 +134,7 @@ export function DataQuality() {
+ @@ -149,13 +174,13 @@ export function DataQuality() { {error && } {!error && !issues && } - {issues && issues.length === 0 && } - {issues && issues.length > 0 && visibleIssues.length === 0 && ( + {issues && issues.items.length === 0 && } + {issues && issues.items.length > 0 && visibleIssues.length === 0 && ( )} {visibleIssues.length > 0 && ( -
{t("list.count", { count: visibleIssues.length })}{t("list.evidenceBacked")}
+
{t("list.count", { count: issues?.total ?? visibleIssues.length })}{t("list.evidenceBacked")}
@@ -184,7 +209,7 @@ export function DataQuality() { ))} -
{t("list.title")}
+ {issues && !demoScenariosOnly && updateFilters({ page: nextPage })} />} )} ); diff --git a/frontend/src/pages/Knowledge.tsx b/frontend/src/pages/Knowledge.tsx index d5fc8bc..45bf98a 100644 --- a/frontend/src/pages/Knowledge.tsx +++ b/frontend/src/pages/Knowledge.tsx @@ -69,7 +69,7 @@ export function Knowledge() { {providerLabel} {status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "} - {t("proceduresIndexed", { count: status.document_count })} + {status.document_count === null ? t("proceduresIndexUnknown") : t("proceduresIndexed", { count: status.document_count })} {status.collection} diff --git a/frontend/src/pages/Vehicles.tsx b/frontend/src/pages/Vehicles.tsx index 6a297d4..d2f2c2e 100644 --- a/frontend/src/pages/Vehicles.tsx +++ b/frontend/src/pages/Vehicles.tsx @@ -1,22 +1,34 @@ import { useEffect, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { api } from "../api/client"; -import type { Vehicle } from "../api/types"; +import type { Page, Vehicle } from "../api/types"; import { useLocaleFormat } from "../i18n/format"; import { StatusBadge } from "../components/Badge"; import { EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; +import { Pagination } from "../components/Pagination"; const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"]; export function Vehicles() { const { t } = useTranslation("fleet"); const { formatNumber } = useLocaleFormat(); - const [vehicles, setVehicles] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + const [vehicles, setVehicles] = useState | null>(null); const [error, setError] = useState(null); - const [status, setStatus] = useState(""); - const [attentionOnly, setAttentionOnly] = useState(false); - const [query, setQuery] = useState(""); + const status = searchParams.get("status") ?? ""; + const attentionOnly = searchParams.get("attention_only") === "true"; + const query = searchParams.get("q") ?? ""; + const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); + + function updateFilters(updates: Record) { + const next = new URLSearchParams(searchParams); + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === "" || value === false) next.delete(key); + else next.set(key, String(value)); + }); + setSearchParams(next); + } useEffect(() => { setVehicles(null); @@ -24,11 +36,14 @@ export function Vehicles() { const params = new URLSearchParams(); if (status) params.set("status", status); if (attentionOnly) params.set("attention_only", "true"); + if (query) params.set("query", query); + params.set("page", String(page)); + params.set("page_size", "25"); api - .get(`/api/v1/vehicles?${params.toString()}`) + .get>(`/api/v1/vehicles?${params.toString()}`) .then(setVehicles) .catch(() => setError(t("list.unavailable"))); - }, [status, attentionOnly]); + }, [status, attentionOnly, query, page, t]); return (
@@ -37,11 +52,11 @@ export function Vehicles() { @@ -62,11 +77,9 @@ export function Vehicles() { {error && } {!error && !vehicles && } - {vehicles && vehicles.length === 0 && } + {vehicles && vehicles.items.length === 0 && } - {vehicles && vehicles.length > 0 && (() => { - const filtered = vehicles.filter((v) => `${v.public_ref} ${v.make} ${v.model} ${v.location}`.toLowerCase().includes(query.toLowerCase())); - return filtered.length === 0 ? :
{t("list.count", { count: filtered.length })}{t("list.persisted")}
+ {vehicles && vehicles.items.length > 0 &&
{t("list.count", { count: vehicles.total })}{t("list.persisted")}
@@ -79,7 +92,7 @@ export function Vehicles() { - {filtered.map((v) => ( + {vehicles.items.map((v) => ( ))} -
{t("list.title")}
{v.public_ref} @@ -97,8 +110,7 @@ export function Vehicles() {
; - })()} + updateFilters({ page: nextPage })} />
} ); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7d1585c..8b77fa0 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -28,6 +28,10 @@ --focus: #14b8a6; --radius: 4px; --shadow-float: 0 16px 40px rgba(15, 23, 42, .12); + --type-body: .875rem; + --type-meta: .75rem; + --type-label: .75rem; + --type-badge: .75rem; } * { box-sizing: border-box; } @@ -91,10 +95,11 @@ a:hover { color: var(--teal); } .search-result-copy { display: flex; flex-direction: column; min-width: 0; } .search-result-copy strong { font-size: .82rem; } .search-result-copy small { color: var(--muted); font-size: .72rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 18px; } +.topbar-meta { margin-left: auto; display: flex; align-items: center; gap: 10px; } .timezone { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: .72rem; white-space: nowrap; } .timezone svg { width: 15px; } .operator { display: flex; align-items: center; gap: 9px; padding-left: 17px; border-left: 1px solid var(--line); } +.operator-menu { position: relative; }.operator-menu summary { list-style: none; cursor: pointer; }.operator-menu summary::-webkit-details-marker { display: none; }.operator-menu[open] .operator { color: var(--teal-dark); }.operator-popover { position: absolute; top: calc(100% + 8px); right: 0; z-index: 31; width: 220px; display: grid; gap: 10px; padding: 12px; background: white; border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }.operator-popover .timezone { font-size: var(--type-meta); }.operator-logout { min-height: 40px; display: flex; align-items: center; gap: 8px; padding: 8px 10px; color: var(--ink-soft); background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); font-size: var(--type-meta); font-weight: 700; cursor: pointer; }.operator-logout svg { width: 16px; } .avatar { width: 32px; height: 32px; display: grid; place-items: center; color: white; background: var(--petrol); border-radius: 50%; font-size: .67rem; font-weight: 700; } .operator > span:last-child { display: grid; gap: 1px; min-width: 0; } .operator strong { font-size: .74rem; overflow-wrap: anywhere; } @@ -175,6 +180,7 @@ a:hover { color: var(--teal); } .attention-list, .integration-list, .recent-list, .record-list, .automation-list, .today-list { list-style: none; margin: 0; padding: 0; } .attention-list li { min-height: 68px; display: grid; grid-template-columns: auto minmax(0,1fr) auto 16px; align-items: center; gap: 11px; padding: 10px 18px; border-bottom: 1px solid #e8edf2; transition: background .14s ease; } .attention-list li:last-child { border-bottom: 0; }.attention-list li:hover { background: #f9fbfc; } +.queue-more { margin: 0; padding: 13px 18px; border-top: 1px solid var(--line); font-size: var(--type-meta); font-weight: 700; }.queue-more a { display: inline-flex; align-items: center; gap: 5px; } .attention-title { margin: 0; font-size: .78rem; font-weight: 700; }.attention-title a { color: var(--ink); text-decoration: none; } .attention-detail { max-width: 58ch; margin: 4px 0 0; color: var(--muted); font-size: .69rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .queue-ref { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .65rem; }.row-chevron { width: 14px; color: var(--muted-light); } @@ -206,7 +212,7 @@ a:hover { color: var(--teal); } .activity-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.activity-icon svg { width: 15px; } .recent-list time { color: var(--muted); font-size: .62rem; white-space: nowrap; } -.badge { min-height: 20px; display: inline-flex; align-items: center; gap: 5px; padding: 2px 7px; border: 1px solid transparent; border-radius: 999px; font-size: .59rem; font-weight: 700; line-height: 1.2; text-transform: capitalize; white-space: nowrap; } +.badge { min-height: 24px; display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border: 1px solid transparent; border-radius: 999px; font-size: var(--type-badge); font-weight: 700; line-height: 1.2; text-transform: capitalize; white-space: nowrap; } .badge::before, .badge-dot { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; } .severity-high, .status-failed, .status-blocked, .status-rejected, .status-unavailable { color: #9f2929; background: var(--critical-pale); border-color: #f3c5c5; } .severity-medium, .status-pending, .status-delivering, .status-needs_attention { color: #93520c; background: var(--warning-pale); border-color: #eed4aa; } @@ -216,25 +222,25 @@ a:hover { color: var(--teal); } .badge-dot { display: inline-block; } .filters { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-bottom: 16px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } -.filters label, .return-form label, .form-grid label, .panel label:has(> textarea) { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; letter-spacing: .01em; } -.filters input[type="text"], .filters select, .return-form input[type="number"], .return-form textarea, .knowledge-input-row input, .form-grid input, .form-grid select, .panel label:has(> textarea) textarea { min-height: 40px; padding: 8px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .78rem; } -.filters input[type="text"] { min-width: 230px; }.filters select { min-width: 160px; } +.filters label, .return-form label, .form-grid label, .panel label:has(> textarea) { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; letter-spacing: .01em; } +.filters input[type="text"], .filters input[type="search"], .filters input[type="date"], .filters select, .return-form input[type="number"], .return-form textarea, .knowledge-input-row input, .form-grid input, .form-grid select, .panel label:has(> textarea) textarea { min-height: 44px; padding: 8px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: var(--type-body); } +.filters input[type="text"], .filters input[type="search"] { min-width: 230px; }.filters select { min-width: 160px; } .checkbox-label { flex-direction: row !important; align-items: center; gap: 8px !important; } input[type="checkbox"], input[type="radio"] { width: 17px; height: 17px; accent-color: var(--teal-dark); } .table-shell { overflow: hidden; } -.table-meta { min-height: 40px; 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: .65rem; } +.table-meta { min-height: 44px; display: flex; justify-content: space-between; align-items: center; padding: 0 14px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: var(--type-meta); } .data-table { width: 100%; border-collapse: collapse; } -.data-table th, .data-table td { height: 50px; text-align: left; padding: 9px 14px; border-bottom: 1px solid #e8edf2; font-size: .74rem; vertical-align: middle; } +.data-table th, .data-table td { min-height: 52px; text-align: left; padding: 9px 14px; border-bottom: 1px solid #e8edf2; font-size: var(--type-body); vertical-align: middle; } .data-table tbody tr:last-child th, .data-table tbody tr:last-child td { border-bottom: 0; } .data-table tbody tr { transition: background .14s ease; }.data-table tbody tr:hover { background: #f9fbfc; } -.data-table thead th { height: 38px; color: var(--muted); background: #f6f8fa; font-size: .61rem; font-weight: 700; text-transform: uppercase; letter-spacing: .075em; } +.data-table thead th { height: 42px; color: var(--muted); background: #f6f8fa; font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .075em; } .data-table th[scope="row"] { color: var(--ink); font-weight: 700; }.data-table th[scope="row"] a { color: var(--teal-dark); text-decoration: none; } .row-attention { box-shadow: inset 2px 0 var(--warning); }.attention-flag { color: var(--warning); font-size: .67rem; font-weight: 700; } .data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button { min-height: 36px; padding: 7px 12px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .7rem; font-weight: 700; cursor: pointer; } .data-table td button:hover, .pagination button:hover:not(:disabled) { background: var(--surface-subtle); }.data-table td button:disabled, .pagination button:disabled { opacity: .45; cursor: not-allowed; } -.table-subtext { display: block; margin-top: 3px; color: var(--muted); font-size: .62rem; }.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -.pagination { min-height: 52px; 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: .68rem; } +.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; } .tabs { display: flex; gap: 2px; margin: 0 0 16px; padding: 0 4px; overflow-x: auto; border-bottom: 1px solid var(--line); } @@ -259,18 +265,18 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .about-details summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } .detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); } .detail-grid div { min-height: 76px; padding: 13px 14px; background: white; } -.detail-grid dt { margin: 0; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: .82rem; font-weight: 700; } +.detail-grid dt { margin: 0; color: var(--muted); font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: var(--type-body); font-weight: 700; } .record-list { display: grid; gap: 1px; margin-bottom: 16px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; } .record-list li { min-height: 52px; display: flex; flex-wrap: wrap; align-items: center; gap: 11px; padding: 10px 14px; background: white; font-size: .75rem; } .return-form, .return-result { max-width: 880px; margin-top: 22px; padding: 0; overflow: hidden; } -.return-form > .section-heading { padding: 19px 22px; }.return-progress { min-height: 54px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 10px 22px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: .65rem; font-weight: 700; } +.return-form > .section-heading { padding: 19px 22px; }.return-progress { position: sticky; top: 64px; z-index: 8; min-height: 54px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 10px 22px; color: var(--muted); background: var(--surface-subtle); border-bottom: 1px solid var(--line); font-size: var(--type-meta); font-weight: 700; } .return-progress span { display: flex; align-items: center; gap: 6px; white-space: nowrap; }.return-progress i { width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid var(--line-strong); border-radius: 50%; font-style: normal; } .return-progress b { width: 54px; height: 1px; background: var(--line-strong); }.return-progress .is-active, .return-progress .is-complete { color: var(--teal-dark); }.return-progress .is-active i, .return-progress .is-complete i { color: white; background: var(--teal-dark); border-color: var(--teal-dark); } .return-capture, .return-review { padding: 22px; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 17px; } .condition-fieldset { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.condition-fieldset legend { margin-bottom: 8px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.check-card { min-height: 48px; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); } .return-form textarea { resize: vertical; min-height: 86px; }.form-actions { display: flex; justify-content: flex-end; gap: 9px; padding: 15px 22px; background: var(--surface-subtle); border-top: 1px solid var(--line); } -.review-facts { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: .6rem; text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: .76rem; font-weight: 700; } +.review-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: var(--type-body); font-weight: 700; } .impact-preview { display: flex; align-items: flex-start; gap: 11px; padding: 15px; border: 1px solid; border-radius: var(--radius); }.impact-preview > svg { width: 20px; flex: 0 0 auto; }.impact-preview strong { font-size: .76rem; }.impact-preview p { margin: 4px 0 0; font-size: .72rem; line-height: 1.45; }.impact-ready { color: #185d41; background: var(--success-pale); border-color: #bfe3d0; }.impact-warning { color: #844909; background: var(--warning-pale); border-color: #eed4aa; } .commit-list { list-style: none; display: grid; gap: 7px; margin: 16px 0 0; padding: 0; color: var(--muted); font-size: .7rem; }.commit-list li { display: flex; gap: 7px; }.commit-list svg { width: 14px; color: var(--teal-dark); } .success-panel { padding: 22px; }.result-heading { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }.result-heading > span { width: 40px; height: 40px; display: grid; place-items: center; color: white; background: var(--success); border-radius: 50%; }.result-heading svg { width: 20px; }.result-heading h2 { margin: 0; font-size: 1.2rem; } @@ -293,7 +299,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .evidence-list li { margin-bottom: 2px; } .status-decision .button { margin-top: 16px; } -.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: .69rem; font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; } +.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }.duplicate-compare .merge-record-preview { position: sticky; bottom: 16px; z-index: 2; box-shadow: 0 10px 24px rgba(15,23,42,.08); } .compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); } .merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); } .merge-summary-counts { margin: 0 0 12px; color: var(--muted); font-size: .72rem; font-weight: 700; } @@ -337,9 +343,9 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .resolution-actions .button-tertiary:hover { color: var(--ink); background: var(--surface); border-color: var(--line); } .resolution-actions .button-tertiary-destructive:hover { color: var(--critical); background: var(--surface); border-color: var(--line); } -.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 170px; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; } +.integration-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 28px; }.integration-cards article { min-height: 0; display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 18px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.integration-cards .badge { grid-column: 1 / -1; width: max-content; align-self: end; } .integration-cards .integration-badge-stack { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; align-self: end; } -.integration-cards small { display: block; margin-top: 4px; color: var(--muted-light); font-size: .6rem; }.integration-cards h2 { margin: 3px 0 7px; font-size: .95rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: .7rem; line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: .56rem; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; } +.integration-cards small { display: block; margin-top: 4px; color: var(--muted-light); font-size: var(--type-meta); }.integration-cards h2 { margin: 3px 0 7px; font-size: 1rem; }.integration-cards p { margin: 0; color: var(--muted); font-size: var(--type-body); line-height: 1.48; }.integration-kicker { color: var(--muted); font-size: var(--type-label); font-weight: 700; text-transform: uppercase; letter-spacing: .09em; } .scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; } .scenario-card { display: flex; flex-direction: column; gap: 10px; padding: 18px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } @@ -453,16 +459,16 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .demo-guide-restart:disabled { opacity: .6; cursor: not-allowed; } @media (max-width: 960px) { - .app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.topbar-meta > .language-switcher-compact { display: none; }.sidebar-language { display: block; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 65px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .55rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 65px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; } + .app-shell { display: block; }.app-workspace { min-height: 100vh; }.sidebar { width: min(286px, 86vw); transform: translateX(-102%); transition: transform .22s ease; box-shadow: var(--shadow-float); }.sidebar.is-open { transform: none; }.nav-scrim { display: block; position: fixed; inset: 0; z-index: 25; width: 100%; height: 100%; padding: 0; background: rgba(5, 12, 22, .48); border: 0; }.mobile-menu { display: grid; }.topbar { padding: 0 20px; }.operator > span:last-child, .global-search kbd { display: none; }.topbar-meta > .language-switcher-compact { display: none; }.sidebar-language { display: block; }.global-search { width: min(460px, 55vw); }.mobile-nav { position: fixed; inset: auto 0 0; z-index: 22; height: 68px; display: grid; grid-template-columns: repeat(6, 1fr); padding-bottom: env(safe-area-inset-bottom); background: rgba(255,255,255,.98); border-top: 1px solid var(--line); }.mobile-nav a, .mobile-nav button { min-width: 44px; min-height: 44px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: var(--muted); background: transparent; border: 0; text-decoration: none; font-size: .69rem; font-weight: 700; cursor: pointer; }.mobile-nav svg { width: 18px; height: 18px; }.mobile-nav a.active { color: var(--teal-dark); }.mobile-nav a.active::before { content: ""; position: absolute; top: 0; width: 28px; height: 2px; background: var(--teal); }.app-footer { padding-bottom: 68px; }.operations-grid, .secondary-grid { grid-template-columns: 1fr; }.integration-cards { grid-template-columns: 1fr; }.login-shell { grid-template-columns: 1fr; }.login-story { min-height: 44vh; padding: 28px 8vw; }.login-message { margin: auto 0; }.login-message h1 { font-size: clamp(2.5rem, 9vw, 4rem); }.login-message > p:last-child { margin-top: 15px; }.control-illustration { width: 55vw; opacity: .45; right: -10vw; top: -5vw; }.login-footnote { margin-top: 20px; }.login-access { min-height: 56vh; padding: 42px 8vw 60px; } } @media (max-width: 700px) { - #main-content { width: min(100% - 28px, 620px); padding: 24px 0 90px; }.topbar { height: 58px; padding: 0 14px; gap: 8px; }.global-search { flex: 1; width: auto; }.topbar-meta { gap: 3px; }.operator { padding-left: 7px; border: 0; }.demo-badge-trigger { height: 28px; padding: 0 9px; font-size: .6rem; }.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 { display: none; }.page-description { font-size: .78rem; }.readiness-band { display: block; }.readiness-label { min-height: 62px; border-right: 0; border-bottom: 1px solid var(--line); }.readiness-metrics { grid-template-columns: repeat(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .53rem; }.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 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: 34px; height: auto; display: grid; grid-template-columns: minmax(90px, .8fr) minmax(0, 1.3fr); align-items: center; gap: 10px; padding: 7px 12px; border: 0; text-align: right; font-size: .71rem; }.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: .57rem; 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; } + #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(5, minmax(66px, 1fr)); overflow-x: auto; }.metric-cell { min-width: 68px; padding: 12px 9px; }.metric-cell dd { font-size: 1.18rem; }.metric-cell dt { font-size: .65rem; }.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; } .tabs { margin-inline: -2px; }.record-surface { padding: 10px; }.detail-grid { grid-template-columns: 1fr 1fr; }.detail-grid div { min-height: 70px; padding: 11px; }.return-progress { padding-inline: 12px; gap: 6px; }.return-progress b { width: 20px; }.return-progress span { font-size: .58rem; }.return-form > .section-heading { padding: 16px; }.return-capture, .return-review { padding: 16px; }.form-grid, .condition-fieldset, .review-facts { grid-template-columns: 1fr; }.condition-fieldset { display: grid; }.review-facts div { display: flex; justify-content: space-between; align-items: center; }.review-facts dd { margin: 0; }.form-actions { padding: 12px 16px; }.form-actions .button { flex: 1; }.duplicate-compare { padding: 14px; }.duplicate-compare fieldset label { display: flex !important; margin-bottom: 8px; }.integration-cards article { min-height: 140px; }.knowledge-input-row { align-items: stretch; }.knowledge-input-row .button { min-width: 72px; padding-inline: 10px; }.knowledge-empty { min-height: 250px; padding: 22px 16px; }.retrieval-flow { width: 100%; gap: 4px; }.retrieval-flow span { padding: 5px; font-size: .52rem; }.retrieval-flow i { flex: 1; min-width: 5px; }.login-story { min-height: 38vh; }.login-message > p:last-child { font-size: .8rem; }.control-illustration { display: none; }.login-access { min-height: 62vh; padding: 34px 20px 50px; }.login-options button { min-height: 72px; }.app-footer { display: none; } } @media (max-width: 440px) { - .global-search input::placeholder { color: transparent; }.global-search { max-width: 118px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 56px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: .55rem; }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; } + .global-search { max-width: 144px; }.topbar-meta { margin-left: auto; }.demo-guide-trigger { display: none; }.avatar { width: 29px; height: 29px; }.mobile-nav a span, .mobile-nav button span { max-width: 58px; overflow: hidden; text-overflow: ellipsis; }.filters { grid-template-columns: 1fr; }.filters label:first-child { grid-column: auto; }.detail-grid { grid-template-columns: 1fr; }.readiness-metrics { margin-right: -1px; }.badge { font-size: var(--type-badge); }.page-header h1 { font-size: 1.5rem; }.login-message h1 { font-size: 2.6rem; } }