UX: implement visual product roadmap

This commit is contained in:
NuklearRabbit
2026-08-10 01:05:07 +02:00
parent 13ad2ba6a3
commit f2cdad194c
40 changed files with 1010 additions and 281 deletions
+43 -8
View File
@@ -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,
)
+22 -5
View File
@@ -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
+38 -8
View File
@@ -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)
+24
View File
@@ -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
+5 -1
View File
@@ -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,
},
),
+3 -1
View File
@@ -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):
+3 -3
View File
@@ -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:
+11
View File
@@ -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:
+12
View File
@@ -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
+12
View File
@@ -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
+509
View File
@@ -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 911 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 | 23 days | None |
| R1 | Operational lists and audit log | P0 | 57 days | R0 |
| R2 | Responsive shell and readable hierarchy | P0 | 46 days | R0 |
| R3 | Dashboard and record-detail efficiency | P1 | 57 days | R1, R2 |
| R4 | Guided workflows and decision surfaces | P1 | 57 days | R2, R3 |
| R5 | Knowledge, automation and trust signals | P1 | 35 days | R2 |
| R6 | Accessibility, performance and release evidence | P0 gate | 46 days | R1R5 |
Indicative total: 2841 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 2025 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.
+33
View File
@@ -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);
});
+9 -1
View File
@@ -20,6 +20,14 @@ export interface Vehicle {
attention: boolean;
}
export interface Page<T> {
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 {
+13 -9
View File
@@ -313,19 +313,23 @@ export function Layout() {
)}
</div>
<div className="topbar-meta">
<LanguageSwitcher compact />
<DemoGuideTrigger />
<DemoBadge />
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
{user && (
<div className="operator">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</div>
<details className="operator-menu">
<summary className="operator">
<span className="avatar">{user.display_name.split(" ").map((name) => name[0]).join("").slice(0, 2)}</span>
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
</summary>
<div className="operator-popover">
<LanguageSwitcher compact />
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
<button className="operator-logout" type="button" onClick={handleLogout}>
<Icon name="logout" /> {t("switchRole")}
</button>
</div>
</details>
)}
<button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
<Icon name="logout" />
</button>
</div>
</header>
+25
View File
@@ -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 (
<nav className="pagination" aria-label={t("pagination.label")}>
<button type="button" onClick={() => onPageChange(page - 1)} disabled={page <= 1}>
{t("pagination.previous")}
</button>
<span aria-current="page">{t("pagination.pageOf", { page, total: totalPages })}</span>
<button type="button" onClick={() => onPageChange(page + 1)} disabled={page >= totalPages}>
{t("pagination.next")}
</button>
</nav>
);
}
@@ -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…",
@@ -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."
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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": "Jusquau",
"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…",
@@ -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."
@@ -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) dattention",
"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 dindex inconnue",
"knowledgeUnavailable": "Vérification de santé indisponible",
"mcpTitle": "MCP Hub",
"mcpEnabled": "Enregistrement activé",
@@ -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 dindex indisponible pour {{collection}}.",
"knowledgeUnavailable": "Preuves de santé actuellement indisponibles.",
"gatewayKicker": "Passerelle d'outils",
"mcpTitle": "MCP Hub",
@@ -7,6 +7,7 @@
"statusAvailable": "Disponible",
"statusUnavailable": "Indisponible",
"proceduresIndexed": "{{count}} procédures indexées",
"proceduresIndexUnknown": "La taille de lindex nest 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",
@@ -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",
@@ -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…",
@@ -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."
@@ -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",
@@ -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",
@@ -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",
@@ -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",
+79 -182
View File
@@ -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<string, unknown>) => stri
return t(`actions.${action}`, { defaultValue: action.replace(/_/g, " ") });
}
function ChangeDiff({
before,
after,
t,
}: {
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
function ChangeDiff({ before, after, t }: { before: Record<string, unknown> | null; after: Record<string, unknown> | null; t: (key: string, options?: Record<string, unknown>) => string }) {
if (!before && !after) return <p className="table-subtext">{t("noChangeDetail")}</p>;
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 <p className="table-subtext">{t("noFieldChange")}</p>;
return (
<ul className="change-diff">
{lines.map((line) => (
<li key={line.field}>{line.text}</li>
))}
</ul>
);
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 ? <ul className="change-diff">{lines.map((line) => <li key={line}>{line}</li>)}</ul> : <p className="table-subtext">{t("noFieldChange")}</p>;
}
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<AuditEvent[] | null>(null);
const [events, setEvents] = useState<Page<AuditEvent> | null>(null);
const [error, setError] = useState<string | null>(null);
const [action, setAction] = useState(searchParams.get("action") ?? "");
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
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<string, string | number | null>) {
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<AuditEvent[]>(`/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<Page<AuditEvent>>(`/api/v1/audit?${params.toString()}`).then(setEvents).catch(() => setError(t("unavailable")));
}, [action, actor, correlationId, entityRef, from, page, t, to, user]);
const groups = useMemo<EventGroup[]>(() => {
if (!events) return [];
const order: string[] = [];
const byCorrelation = new Map<string, AuditEvent[]>();
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 <div className="page"><PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} /><p>{t("managerOnlyDetail")}</p></div>;
function clearCorrelationFilter() {
setSearchParams(action ? { action } : {});
}
function toggleExpanded(id: string) {
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
}
if (user?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("managerOnly")} />
<p>{t("managerOnlyDetail")}</p>
</div>
);
}
return (
<div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
<form className="filters" aria-label={t("title")}>
<label>
{t("actionFilterLabel")}
<input
type="text"
value={action}
onChange={(e) => setAction(e.target.value)}
placeholder={t("actionFilterPlaceholder")}
/>
</label>
</form>
{correlationId && (
<p className="quiet-empty" role="status">
{t("relatedFilterActive", { count: events?.length ?? 0 })}{" "}
<button type="button" className="link-button" onClick={clearCorrelationFilter}>
{t("clearFilter")}
</button>
</p>
)}
{error && <ErrorState message={error} />}
{!error && !events && <LoadingState label={t("loading")} />}
{events && events.length === 0 && <EmptyState icon="audit" title={t("empty")} detail={t("emptyDetail")} />}
{groups.length > 0 && (
<div className="table-shell">
<div className="table-meta"><span>{t("count", { count: events!.length })}</span><span>{t("storedRendered")}</span></div>
<ul className="audit-group-list">
{groups.map((group) => {
const e = group.primary;
const isExpanded = expanded[group.correlationId] ?? false;
return (
<li key={group.correlationId} className="audit-group panel">
<div className="audit-group-summary">
<div className="audit-group-heading">
<strong>{actionLabel(t, e.action)}</strong>
<span className="table-subtext">{formatDateTime(e.occurred_at)}</span>
</div>
<div className="audit-group-meta">
<span><strong>{e.actor_label}</strong> <small className="table-subtext">{t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}</small></span>
{e.entity_link ? (
<Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link>
) : (
<span>{e.entity_ref ?? e.entity_type}</span>
)}
</div>
<ChangeDiff before={e.before} after={e.after} t={t} />
<div className="audit-group-actions">
{group.related.length > 0 && (
<button type="button" className="link-button" onClick={() => toggleExpanded(group.correlationId)}>
{isExpanded
? t("hideTechnicalEvents")
: t("showTechnicalEvents", { count: group.related.length })}
</button>
)}
{!correlationId && (
<button type="button" className="link-button" onClick={() => showRelatedEvents(group.correlationId)}>
{t("viewRelatedEvents")}
</button>
)}
</div>
</div>
<details className="evidence-disclosure">
<summary>{t("technicalDetails")}</summary>
<dl className="detail-grid audit-technical-grid">
<div><dt>{t("reference")}</dt><dd>{shortRef(e.id)}</dd></div>
<div><dt>{t("fullReference")}</dt><dd className="mono">{e.id}</dd></div>
<div><dt>{t("correlationId")}</dt><dd className="mono">{e.correlation_id}</dd></div>
</dl>
<pre className="evidence-block">{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre>
</details>
{isExpanded && group.related.length > 0 && (
<ul className="audit-related-list">
{group.related.map((related) => (
<li key={related.id}>
<div className="audit-group-heading">
<strong>{actionLabel(t, related.action)}</strong>
<span className="table-subtext">{formatDateTime(related.occurred_at)}</span>
</div>
<ChangeDiff before={related.before} after={related.after} t={t} />
<details className="evidence-disclosure">
<summary>{t("technicalDetails")}</summary>
<dl className="detail-grid audit-technical-grid">
<div><dt>{t("reference")}</dt><dd>{shortRef(related.id)}</dd></div>
<div><dt>{t("fullReference")}</dt><dd className="mono">{related.id}</dd></div>
</dl>
<pre className="evidence-block">{JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}</pre>
</details>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
);
return <div className="page">
<PageHeader eyebrow={t("eyebrow")} title={t("title")} description={t("description")} />
<form className="filters" aria-label={t("title")} onSubmit={(event) => event.preventDefault()}>
<label>{t("actionFilterLabel")}<input type="search" value={action} onChange={(e) => updateParams({ action: e.target.value, page: 1 })} placeholder={t("actionFilterPlaceholder")} /></label>
<label>{t("actorFilterLabel")}<input type="search" value={actor} onChange={(e) => updateParams({ actor: e.target.value, page: 1 })} placeholder={t("actorFilterPlaceholder")} /></label>
<label>{t("entityFilterLabel")}<input type="search" value={entityRef} onChange={(e) => updateParams({ entity_ref: e.target.value, page: 1 })} placeholder={t("entityFilterPlaceholder")} /></label>
<label>{t("fromFilterLabel")}<input type="date" value={from} onChange={(e) => updateParams({ from: e.target.value, page: 1 })} /></label>
<label>{t("toFilterLabel")}<input type="date" value={to} onChange={(e) => updateParams({ to: e.target.value, page: 1 })} /></label>
</form>
{correlationId && <p className="quiet-empty" role="status">{t("relatedFilterActive", { count: events?.total ?? 0 })} <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: null, page: 1 })}>{t("clearFilter")}</button></p>}
{error && <ErrorState message={error} />}
{!error && !events && <LoadingState label={t("loading")} />}
{events && events.items.length === 0 && <EmptyState icon="audit" title={t("empty")} detail={t("emptyDetail")} />}
{groups.length > 0 && <div className="table-shell audit-shell">
<div className="table-meta"><span>{t("count", { count: events!.total })}</span><span>{t("storedRendered")}</span></div>
<ul className="audit-group-list">
{groups.map((group) => {
const e = group.primary;
const isExpanded = expanded[group.correlationId] ?? false;
return <li key={group.correlationId} className="audit-group panel">
<div className="audit-group-summary">
<div className="audit-group-heading"><strong>{actionLabel(t, e.action)}</strong><span className="table-subtext">{formatDateTime(e.occurred_at)}</span></div>
<div className="audit-group-meta"><span><strong>{e.actor_label}</strong> <small className="table-subtext">{t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}</small></span>{e.entity_link ? <Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link> : <span>{e.entity_ref ?? e.entity_type}</span>}</div>
<ChangeDiff before={e.before} after={e.after} t={t} />
<div className="audit-group-actions">
{group.related.length > 0 && <button type="button" className="link-button" onClick={() => setExpanded((current) => ({ ...current, [group.correlationId]: !current[group.correlationId] }))}>{isExpanded ? t("hideTechnicalEvents") : t("showTechnicalEvents", { count: group.related.length })}</button>}
{!correlationId && <button type="button" className="link-button" onClick={() => updateParams({ correlation_id: group.correlationId, page: 1 })}>{t("viewRelatedEvents")}</button>}
</div>
</div>
<details className="evidence-disclosure"><summary>{t("technicalDetails")}</summary><dl className="detail-grid audit-technical-grid"><div><dt>{t("reference")}</dt><dd>{shortRef(e.id)}</dd></div><div><dt>{t("fullReference")}</dt><dd className="mono">{e.id}</dd></div><div><dt>{t("correlationId")}</dt><dd className="mono">{e.correlation_id}</dd></div></dl><pre className="evidence-block">{JSON.stringify({ before: e.before, after: e.after, metadata: e.metadata }, null, 2)}</pre></details>
{isExpanded && group.related.length > 0 && <ul className="audit-related-list">{group.related.map((related) => <li key={related.id}><div className="audit-group-heading"><strong>{actionLabel(t, related.action)}</strong><span className="table-subtext">{formatDateTime(related.occurred_at)}</span></div><ChangeDiff before={related.before} after={related.after} t={t} /><details className="evidence-disclosure"><summary>{t("technicalDetails")}</summary><pre className="evidence-block">{JSON.stringify({ before: related.before, after: related.after, metadata: related.metadata }, null, 2)}</pre></details></li>)}</ul>}
</li>;
})}
</ul>
<Pagination page={events!.page} totalPages={events!.total_pages} onPageChange={(nextPage) => updateParams({ page: nextPage })} />
</div>}
</div>;
}
+6 -4
View File
@@ -201,10 +201,12 @@ export function Automation() {
<h2>{t("cards.knowledgeTitle")}</h2>
<p>
{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")}
</p>
</div>
+9 -2
View File
@@ -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() {
</ul>
</div>
))}
{isUnfiltered && attention.length > 6 && canSeeQuality && (
<p className="queue-more"><Link to="/data-quality">{t("attention.remaining", { count: attention.length - 6 })} <Icon name="chevron" /></Link></p>
)}
</>
)}
</section>
@@ -238,7 +245,7 @@ export function Dashboard() {
<IntegrationMark kind="rag" />
<div>
<strong>{t("integrationPulse.knowledgeTitle")}</strong>
<span>{knowledge ? t("integrationPulse.knowledgeSummary", { count: knowledge.document_count }) : t("integrationPulse.knowledgeUnavailable")}</span>
<span>{knowledge ? (knowledge.document_count === null ? t("integrationPulse.knowledgeIndexUnknown") : t("integrationPulse.knowledgeSummary", { count: knowledge.document_count })) : t("integrationPulse.knowledgeUnavailable")}</span>
</div>
<StatusBadge
status={knowledge?.available ? "available" : "unavailable"}
+42 -17
View File
@@ -1,12 +1,13 @@
import { useCallback, 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 { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { DataQualityIssue, ScanResult } from "../api/types";
import type { DataQualityIssue, Page, ScanResult } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { SeverityBadge, StatusBadge } from "../components/Badge";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
const RULE_TYPES = [
"possible_duplicate_customer",
@@ -19,15 +20,27 @@ const RULE_TYPES = [
export function DataQuality() {
const { t } = useTranslation("quality");
const { user } = useAuth();
const [issues, setIssues] = useState<DataQualityIssue[] | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [issues, setIssues] = useState<Page<DataQualityIssue> | null>(null);
const [error, setError] = useState<string | null>(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<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
function updateFilters(updates: Record<string, string | boolean | number | null>) {
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<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`)
.get<Page<DataQualityIssue>>(`/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() {
<form className="filters" aria-label={t("list.title")}>
<label>
{t("list.statusLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
<option value="">{t("list.statusAll")}</option>
<option value="open">{t("list.statusOpen")}</option>
<option value="deferred">{t("list.statusDeferred")}</option>
@@ -128,7 +144,7 @@ export function DataQuality() {
</label>
<label>
{t("list.ruleTypeLabel")}
<select value={ruleType} onChange={(e) => setRuleType(e.target.value)}>
<select value={ruleType} onChange={(e) => updateFilters({ rule_type: e.target.value, page: 1 })}>
<option value="">{t("list.ruleTypeAll")}</option>
{RULE_TYPES.map((r) => (
<option key={r} value={r}>
@@ -137,11 +153,20 @@ export function DataQuality() {
))}
</select>
</label>
<label>
{t("list.severityLabel")}
<select value={severity} onChange={(e) => updateFilters({ severity: e.target.value, page: 1 })}>
<option value="">{t("list.severityAll")}</option>
<option value="high">{t("severities.high")}</option>
<option value="medium">{t("severities.medium")}</option>
<option value="low">{t("severities.low")}</option>
</select>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={demoScenariosOnly}
onChange={(e) => setDemoScenariosOnly(e.target.checked)}
onChange={(e) => { setDemoScenariosOnly(e.target.checked); updateFilters({ demo: e.target.checked, page: 1 }); }}
/>
{t("list.demoScenariosOnly")}
</label>
@@ -149,13 +174,13 @@ export function DataQuality() {
{error && <ErrorState message={error} />}
{!error && !issues && <LoadingState label={t("list.loading")} />}
{issues && issues.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
{issues && issues.length > 0 && visibleIssues.length === 0 && (
{issues && issues.items.length === 0 && <EmptyState icon="check" title={t("list.queueClear")} detail={t("list.noIssuesMatch")} />}
{issues && issues.items.length > 0 && visibleIssues.length === 0 && (
<EmptyState icon="check" title={t("list.noDemoIssuesMatch")} detail={t("list.noDemoIssuesMatchDetail")} />
)}
{visibleIssues.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
@@ -184,7 +209,7 @@ export function DataQuality() {
</tr>
))}
</tbody>
</table></div>
</table>{issues && !demoScenariosOnly && <Pagination page={issues.page} totalPages={issues.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} />}</div>
)}
</div>
);
+1 -1
View File
@@ -69,7 +69,7 @@ export function Knowledge() {
<strong>{providerLabel}</strong>
<span>
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "}
{t("proceduresIndexed", { count: status.document_count })}
{status.document_count === null ? t("proceduresIndexUnknown") : t("proceduresIndexed", { count: status.document_count })}
</span>
</div>
<small>{status.collection}</small>
+30 -18
View File
@@ -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<Vehicle[] | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [vehicles, setVehicles] = useState<Page<Vehicle> | null>(null);
const [error, setError] = useState<string | null>(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<string, string | boolean | number | null>) {
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<Vehicle[]>(`/api/v1/vehicles?${params.toString()}`)
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`)
.then(setVehicles)
.catch(() => setError(t("list.unavailable")));
}, [status, attentionOnly]);
}, [status, attentionOnly, query, page, t]);
return (
<div className="page">
@@ -37,11 +52,11 @@ export function Vehicles() {
<form className="filters" aria-label={t("list.title")}>
<label>
{t("list.searchLabel")}
<input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("list.searchPlaceholder")} />
<input type="search" value={query} onChange={(e) => updateFilters({ q: e.target.value, page: 1 })} placeholder={t("list.searchPlaceholder")} />
</label>
<label>
{t("list.statusLabel")}
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<select value={status} onChange={(e) => updateFilters({ status: e.target.value, page: 1 })}>
<option value="">{t("list.statusAll")}</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
@@ -54,7 +69,7 @@ export function Vehicles() {
<input
type="checkbox"
checked={attentionOnly}
onChange={(e) => setAttentionOnly(e.target.checked)}
onChange={(e) => updateFilters({ attention_only: e.target.checked, page: 1 })}
/>
{t("list.attentionOnly")}
</label>
@@ -62,11 +77,9 @@ export function Vehicles() {
{error && <ErrorState message={error} />}
{!error && !vehicles && <LoadingState label={t("list.loading")} />}
{vehicles && vehicles.length === 0 && <EmptyState icon="fleet" title={t("list.empty")} detail={t("list.emptyDetail")} />}
{vehicles && vehicles.items.length === 0 && <EmptyState icon={query ? "search" : "fleet"} title={query ? t("list.noMatch") : t("list.empty")} detail={query ? t("list.noMatchDetail") : t("list.emptyDetail")} />}
{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 ? <EmptyState icon="search" title={t("list.noMatch")} detail={t("list.noMatchDetail")} /> : <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: filtered.length })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
{vehicles && vehicles.items.length > 0 && <div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: vehicles.total })}</span><span>{t("list.persisted")}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption>
<thead>
<tr>
@@ -79,7 +92,7 @@ export function Vehicles() {
</tr>
</thead>
<tbody>
{filtered.map((v) => (
{vehicles.items.map((v) => (
<tr key={v.public_ref} className={`row-clickable ${v.attention ? "row-attention" : ""}`}>
<th scope="row" data-label={t("list.columns.reference")}>
{v.public_ref}
@@ -97,8 +110,7 @@ export function Vehicles() {
</tr>
))}
</tbody>
</table></div>;
})()}
</table><Pagination page={vehicles.page} totalPages={vehicles.total_pages} onPageChange={(nextPage) => updateFilters({ page: nextPage })} /></div>}
</div>
);
}
+27 -21
View File
@@ -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; }
}