UX: implement visual product roadmap
This commit is contained in:
@@ -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,
|
||||
)
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
# MobilityOps visual product roadmap
|
||||
|
||||
## Purpose
|
||||
|
||||
This roadmap turns the visual audit of the deployed MobilityOps PoC into an
|
||||
implementation plan. It improves the existing operational workflows without
|
||||
expanding the locked product scope.
|
||||
|
||||
The intended outcome is a platform that:
|
||||
|
||||
- lets an operator identify and act on urgent work within thirty seconds;
|
||||
- remains efficient with the current synthetic data set and larger realistic data sets;
|
||||
- keeps status, context and primary actions visible on desktop and mobile;
|
||||
- uses a readable, consistent visual hierarchy;
|
||||
- communicates integration and knowledge health without contradictory signals;
|
||||
- preserves the existing domain rules, audit guarantees and graceful degradation.
|
||||
|
||||
## Scope guardrails
|
||||
|
||||
Included:
|
||||
|
||||
- information architecture and visual hierarchy;
|
||||
- list density, pagination, filtering and responsive presentation;
|
||||
- dashboard, detail, return, data-quality, knowledge, automation, audit and demo flows;
|
||||
- accessibility, perceived performance and UI observability;
|
||||
- regression tests, visual evidence and rollout safeguards.
|
||||
|
||||
Excluded:
|
||||
|
||||
- new accounting, payments, CRM, inventory, HR or reservation modules;
|
||||
- maintenance work orders, calendar views or advanced analytics;
|
||||
- changes to RAGcore or ITWorx MCP Hub repositories;
|
||||
- autonomous write actions or write-capable MCP tools;
|
||||
- a redesign of backend domain rules that is not required by the UI work.
|
||||
|
||||
## Audit baseline
|
||||
|
||||
The audit was performed against the deployed application at desktop, tablet and
|
||||
mobile widths. The current strengths to preserve are the professional visual
|
||||
identity, consistent status language, strong login page, semantic page structure,
|
||||
visible keyboard focus, absence of horizontal overflow and clear return and
|
||||
data-quality narratives.
|
||||
|
||||
The principal improvement signals are:
|
||||
|
||||
| Area | Baseline observation | Consequence |
|
||||
|---|---|---|
|
||||
| Audit log | Up to 100 expanded event cards; approximately 18,500 px desktop and 20,500 px mobile | Poor scanability and excessive scrolling |
|
||||
| Vehicle list | All 50 vehicles render at once; approximately 12,000 px on mobile | Operational lookup is slow on small screens |
|
||||
| Mobile page header | `.page-actions` is hidden below 700 px | Important record status disappears |
|
||||
| Typography | Many metadata labels and badges are approximately 9–11 px | Readability is weaker than the visual quality suggests |
|
||||
| Top bar | Search, language, demo controls, disclosure, timezone and operator controls compete for space | High cognitive load and weak prioritisation |
|
||||
| Knowledge | An operational provider can be shown beside “0 procedures indexed” | Trust signal is ambiguous or contradictory |
|
||||
| Integration cards | Technical identifiers and small prose dominate the summary | Operational state is harder to scan |
|
||||
|
||||
These values are baselines, not permanent acceptance thresholds. Each affected
|
||||
phase must record a before-and-after measurement.
|
||||
|
||||
## Delivery principles
|
||||
|
||||
1. Fix operational friction before decorative polish.
|
||||
2. Prefer progressive disclosure over removing useful evidence.
|
||||
3. Keep the primary status and next action visible at every viewport.
|
||||
4. Paginate or virtualise unbounded collections; never solve them only with CSS.
|
||||
5. Derive every displayed count and state from persisted or external evidence.
|
||||
6. Preserve URLs, permissions, audit semantics and keyboard operation.
|
||||
7. Ship each phase as a coherent, independently reversible commit.
|
||||
8. Validate every phase at 390 px, 768 px and 1440 px.
|
||||
|
||||
## Target measures
|
||||
|
||||
The roadmap is complete when the following targets are met:
|
||||
|
||||
| Measure | Target |
|
||||
|---|---|
|
||||
| Initial rows/cards in any operational list | At most 25, unless a documented compact virtualised view is used |
|
||||
| Mobile status visibility | Primary record status visible on every detail page |
|
||||
| Horizontal overflow | None at 360 px and above |
|
||||
| Default text | At least 14 px |
|
||||
| Secondary metadata | At least 12 px; exceptions require an accessibility justification |
|
||||
| Touch target | At least 44 by 44 CSS px for primary mobile controls |
|
||||
| Keyboard access | All actions reachable with visible focus and logical order |
|
||||
| List navigation | Filter, paging and search state represented in the URL where practical |
|
||||
| Dashboard comprehension | Primary operational risks and next movements visible without scrolling at 1440 × 900 |
|
||||
| Browser console | No application errors during the five-minute demo |
|
||||
| Performance | No avoidable rendering of more than one page of list records |
|
||||
| Automated acceptance | Existing backend, frontend and Playwright gates remain green |
|
||||
|
||||
## Roadmap overview
|
||||
|
||||
| Phase | Theme | Priority | Indicative effort | Depends on |
|
||||
|---|---|---:|---:|---|
|
||||
| R0 | Baseline and design-system guardrails | P0 | 2–3 days | None |
|
||||
| R1 | Operational lists and audit log | P0 | 5–7 days | R0 |
|
||||
| R2 | Responsive shell and readable hierarchy | P0 | 4–6 days | R0 |
|
||||
| R3 | Dashboard and record-detail efficiency | P1 | 5–7 days | R1, R2 |
|
||||
| R4 | Guided workflows and decision surfaces | P1 | 5–7 days | R2, R3 |
|
||||
| R5 | Knowledge, automation and trust signals | P1 | 3–5 days | R2 |
|
||||
| R6 | Accessibility, performance and release evidence | P0 gate | 4–6 days | R1–R5 |
|
||||
|
||||
Indicative total: 28–41 focused engineering days. Phases can span multiple
|
||||
calendar sprints, but their exit gates should not be split across releases.
|
||||
|
||||
## R0 — Baseline and design-system guardrails
|
||||
|
||||
### Objective
|
||||
|
||||
Create shared UI rules and reproducible measurements before changing individual
|
||||
pages.
|
||||
|
||||
### Work packages
|
||||
|
||||
#### R0.1 — Visual regression baseline
|
||||
|
||||
- Capture authenticated reference screenshots for the seven principal routes and
|
||||
the login page at 390 × 844, 768 × 1024 and 1440 × 900.
|
||||
- Add stable screenshot fixtures using the deterministic seed.
|
||||
- Mask only genuinely variable timestamps; do not mask operational content.
|
||||
- Record viewport width, page height, visible item count and horizontal overflow.
|
||||
|
||||
#### R0.2 — Typography and spacing tokens
|
||||
|
||||
- Replace scattered sub-12 px declarations with named type tokens.
|
||||
- Establish body, metadata, label, badge, table-header and navigation sizes.
|
||||
- Define compact and comfortable density variants without duplicating page CSS.
|
||||
- Preserve the existing petrol/teal palette and status colours.
|
||||
|
||||
#### R0.3 — Shared responsive patterns
|
||||
|
||||
- Define a mobile page-header contract: title, visible status, primary action and
|
||||
overflow actions.
|
||||
- Define reusable compact-list, filter-toolbar, pagination and empty-state patterns.
|
||||
- Establish 44 px mobile target sizing and a consistent sticky-offset system.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Visual baselines exist for all principal routes and viewports.
|
||||
- No normal operational metadata is smaller than 12 px.
|
||||
- Shared components document their responsive behaviour.
|
||||
- Existing Playwright demo and frontend build pass unchanged.
|
||||
|
||||
## R1 — Operational lists and audit log
|
||||
|
||||
### Objective
|
||||
|
||||
Make high-volume operational information scannable and bounded on every device.
|
||||
|
||||
### Work packages
|
||||
|
||||
#### R1.1 — Audit API pagination and filtering
|
||||
|
||||
- Replace the default fixed `limit=100` response with cursor or page-based pagination.
|
||||
- Add filters for date range, action, actor, entity type, entity reference and
|
||||
correlation ID.
|
||||
- Retain stable newest-first ordering and service-token protections.
|
||||
- Return total or continuation metadata suitable for the selected paging model.
|
||||
|
||||
#### R1.2 — Compact audit timeline
|
||||
|
||||
- Render 20–25 compact events per page.
|
||||
- Group correlated events and repetitive scheduled events without hiding their count.
|
||||
- Move full UUIDs, before/after data and technical metadata into a details drawer.
|
||||
- Keep human-readable action, actor, target, outcome and timestamp in the main row.
|
||||
- Preserve direct navigation to related records.
|
||||
|
||||
#### R1.3 — Vehicle-list pagination
|
||||
|
||||
- Add server-compatible pagination to the vehicle list.
|
||||
- Preserve status, location, attention and search filters across pages.
|
||||
- Store filter and page state in the URL.
|
||||
- Provide a prominent “attention required” view without inventing new metrics.
|
||||
- Use compact mobile rows rather than full table-to-card expansion for every vehicle.
|
||||
|
||||
#### R1.4 — Data-quality queue controls
|
||||
|
||||
- Paginate the open issue list.
|
||||
- Add severity and rule grouping/filtering.
|
||||
- Add “previous issue” and “next issue” navigation to review pages.
|
||||
- Keep scan and resolution actions permission-aware.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- No default list renders more than 25 records.
|
||||
- Audit and vehicle pages remain below 3,500 px at the tested mobile viewport with
|
||||
the deterministic seed.
|
||||
- Paging and filters survive reload, back navigation and direct links.
|
||||
- Empty, loading, error and end-of-results states are explicit.
|
||||
- API tests cover invalid cursors/pages, combined filters and authorization.
|
||||
|
||||
## R2 — Responsive shell and readable hierarchy
|
||||
|
||||
### Objective
|
||||
|
||||
Reduce navigation noise and keep essential context visible on small screens.
|
||||
|
||||
### Work packages
|
||||
|
||||
#### R2.1 — Top-bar simplification
|
||||
|
||||
- Keep global search and current operational context primary.
|
||||
- Move language, timezone and sign-out into the operator menu.
|
||||
- Combine demo disclosure and demo progress into one compact control.
|
||||
- Replace the visually empty mobile search field with an explicit search button or
|
||||
a full-width command surface.
|
||||
|
||||
#### R2.2 — Mobile page actions
|
||||
|
||||
- Stop hiding the complete page-action region.
|
||||
- Always show record status next to or below the title.
|
||||
- Keep one primary action visible and move secondary actions to an accessible menu.
|
||||
- Verify booking, vehicle, data-quality and automation detail headers independently.
|
||||
|
||||
#### R2.3 — Navigation refinement
|
||||
|
||||
- Review whether the desktop sidebar should remain available at wider tablet widths.
|
||||
- Increase mobile navigation label size and touch area.
|
||||
- Keep four or five primary destinations visible and put lower-frequency destinations
|
||||
under “More” if six items cannot meet readability targets.
|
||||
- Preserve current routes and role-based visibility.
|
||||
|
||||
#### R2.4 — Typography rollout
|
||||
|
||||
- Apply the R0 type tokens to badges, tables, integration cards, timelines, forms and
|
||||
mobile navigation.
|
||||
- Rebalance padding where larger text would otherwise increase page height excessively.
|
||||
- Verify Dutch, French and English labels for clipping and wrapping.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Status remains visible on every sampled mobile detail page.
|
||||
- No essential action is hover-only or hidden solely because of viewport width.
|
||||
- Top-bar controls fit without clipping at 390, 768 and 1440 px.
|
||||
- Language changes do not introduce horizontal overflow.
|
||||
- Touch-target and typography targets are met.
|
||||
|
||||
## R3 — Dashboard and record-detail efficiency
|
||||
|
||||
### Objective
|
||||
|
||||
Put operational decisions above supporting detail and make desktop space work harder.
|
||||
|
||||
### Work packages
|
||||
|
||||
#### R3.1 — Dashboard hierarchy
|
||||
|
||||
- Keep readiness and highest-severity attention items visible in the first viewport.
|
||||
- Reduce the vertical weight of the demo-scenario panel after the scenario starts.
|
||||
- Limit the initial attention queue and link to a complete filtered view.
|
||||
- Balance “movements today” against attention content when only a few movements exist.
|
||||
- Keep integrations and recent activity available without duplicating status copy.
|
||||
|
||||
#### R3.2 — Mobile dashboard summary
|
||||
|
||||
- Convert readiness into a compact, horizontally accessible summary or disclosure.
|
||||
- Show the highest-priority attention items first with an explicit remaining count.
|
||||
- Surface the next departure and return before secondary integration information.
|
||||
|
||||
#### R3.3 — Vehicle-detail workspace
|
||||
|
||||
- Replace the sparse overview with a two-column desktop composition.
|
||||
- Surface current status, location, odometer, next booking and active attention items.
|
||||
- Keep bookings, inspections, maintenance, quality issues and audit as focused tabs.
|
||||
- Avoid new maintenance-domain functionality; link only to existing evidence.
|
||||
|
||||
#### R3.4 — Booking-detail summary
|
||||
|
||||
- Rebalance the facts grid so incomplete final rows do not create large empty bands.
|
||||
- On mobile, show status and return readiness before secondary booking facts.
|
||||
- Keep the return form close to the task entry point while retaining validation context.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- At 1440 × 900 the dashboard exposes readiness, urgent work and today's movement
|
||||
context without scrolling.
|
||||
- Sparse states remain intentional and balanced rather than appearing unfinished.
|
||||
- All values remain derived from persisted data.
|
||||
- Existing return and status-domain tests remain unchanged and green.
|
||||
|
||||
## R4 — Guided workflows and decision surfaces
|
||||
|
||||
### Objective
|
||||
|
||||
Reduce the distance between evidence and the decision an operator must make.
|
||||
|
||||
### Work packages
|
||||
|
||||
#### R4.1 — Return flow refinement
|
||||
|
||||
- Keep the step indicator visible while completing or reviewing a return.
|
||||
- Use a compact mobile booking summary above the form.
|
||||
- Present validation errors next to fields and in a short focusable summary.
|
||||
- Preserve the post-submit explanation of inspection, vehicle status, quality issue,
|
||||
queued automation and next-booking risk.
|
||||
|
||||
#### R4.2 — Duplicate-customer decision layout
|
||||
|
||||
- Use evidence/comparison on the left and a sticky survivor/merge preview on desktop.
|
||||
- Keep matching fields collapsed by default and differences expanded.
|
||||
- Keep irreversible-action language and audit preview adjacent to the merge button.
|
||||
- On mobile, use a deliberate sequence: evidence, differences, survivor, preview,
|
||||
confirmation.
|
||||
|
||||
#### R4.3 — Other data-quality resolutions
|
||||
|
||||
- Standardise evidence, proposed resolution, consequence and audit-preview sections.
|
||||
- Keep resolve, reject and defer actions visually distinct and permission-aware.
|
||||
- Provide clear success feedback and navigation to the next issue.
|
||||
|
||||
#### R4.4 — Guided demo integration
|
||||
|
||||
- Reduce competition between the guide and normal navigation.
|
||||
- Persist progress without obscuring page controls.
|
||||
- Make “go to this step” land on the relevant element or focused task state.
|
||||
- Ensure the guide is fully keyboard-operable and usable as a mobile bottom sheet.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Primary workflow action is visible or reachable with one obvious interaction.
|
||||
- A user never needs to scroll past repeated explanatory content to discover the
|
||||
required decision.
|
||||
- Focus moves to validation failures and success results appropriately.
|
||||
- The five-minute demo remains deterministic.
|
||||
|
||||
## R5 — Knowledge, automation and trust signals
|
||||
|
||||
### Objective
|
||||
|
||||
Make external-system health understandable to an operator without exposing irrelevant
|
||||
technical detail.
|
||||
|
||||
### Work packages
|
||||
|
||||
#### R5.1 — Knowledge status semantics
|
||||
|
||||
- Separate provider availability, indexed-source count and last successful sync.
|
||||
- Never show “0 indexed” as a healthy equivalent when the value is unknown or stale.
|
||||
- Label extractive fallback answers honestly while preserving source cards.
|
||||
- Reduce empty-state height and show a compact explanation of what a cited answer contains.
|
||||
- Keep suggested questions concise and prioritised.
|
||||
|
||||
#### R5.2 — Source and freshness presentation
|
||||
|
||||
- Surface source title, version, section and freshness consistently.
|
||||
- Keep document UUIDs and raw retrieval metadata behind technical details.
|
||||
- Provide explicit grounded, insufficient-evidence and unavailable states.
|
||||
|
||||
#### R5.3 — Integration summary redesign
|
||||
|
||||
- Structure each integration around state, last success, affected workflow and next action.
|
||||
- Move client IDs, raw endpoints and UUIDs to a detail disclosure.
|
||||
- Remove unnecessary fixed card height, especially at tablet widths.
|
||||
- Keep genuine mixed states visible without presenting them as contradictions.
|
||||
|
||||
#### R5.4 — Workflow evidence table
|
||||
|
||||
- Improve status and recency scanning.
|
||||
- Distinguish “never triggered”, “no evidence yet”, “failed” and “unavailable”.
|
||||
- Keep retry actions safe, audited and available only where already supported.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Knowledge and integration states cannot make mutually contradictory claims.
|
||||
- An operator can identify the current state and next action within one card scan.
|
||||
- Technical identifiers do not dominate default views.
|
||||
- RAGcore, n8n and MCP Hub timeout/unavailable contract tests stay green.
|
||||
|
||||
## R6 — Accessibility, performance and release evidence
|
||||
|
||||
### Objective
|
||||
|
||||
Turn the improvements into a verified, releasable quality baseline.
|
||||
|
||||
### Work packages
|
||||
|
||||
#### R6.1 — Accessibility review
|
||||
|
||||
- Test complete keyboard traversal of login, navigation, filters, return, merge,
|
||||
knowledge, workflow retry and audit details.
|
||||
- Verify focus order, focus restoration, dialog labelling and error announcements.
|
||||
- Run automated accessibility checks on all principal routes.
|
||||
- Manually verify status is not encoded by colour alone.
|
||||
- Verify zoom at 200% and 360 px responsive reflow.
|
||||
|
||||
#### R6.2 — Rendering and interaction performance
|
||||
|
||||
- Measure list rendering before and after pagination.
|
||||
- Check layout shift and interaction latency on dashboard and long-list routes.
|
||||
- Ensure drawers, menus and guided-demo panels do not mount large hidden subtrees.
|
||||
- Test with reduced motion and a throttled CPU profile.
|
||||
|
||||
#### R6.3 — Cross-browser and localisation pass
|
||||
|
||||
- Verify current Chrome plus one additional Chromium/Firefox-equivalent target where
|
||||
supported by the test environment.
|
||||
- Run Dutch, French and English visual checks at all target widths.
|
||||
- Verify Europe/Brussels date and time rendering.
|
||||
|
||||
#### R6.4 — Final automated journey and evidence
|
||||
|
||||
- Extend the existing Playwright demo with pagination, mobile status and accessible
|
||||
details checks.
|
||||
- Capture final screenshots for all principal pages.
|
||||
- Add before-and-after measurements and known PoC limitations to final evidence.
|
||||
- Execute clean-checkout bootstrap, backend tests, lint, typing and frontend build.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- No critical or serious automated accessibility violations on principal routes.
|
||||
- All target measures in this document pass.
|
||||
- `docs/14-testing-and-acceptance.md` passes from a clean checkout.
|
||||
- `artifacts/evidence/final-summary.md` contains the final commands, counts,
|
||||
screenshots and limitations.
|
||||
|
||||
## Suggested release slices
|
||||
|
||||
### Release A — Operational scale
|
||||
|
||||
Contains R0 and R1. This release eliminates the most severe scroll and list-density
|
||||
problems without materially changing workflow structure.
|
||||
|
||||
Release gate:
|
||||
|
||||
- paginated audit, vehicles and data-quality queues;
|
||||
- URL-backed filters;
|
||||
- unchanged audit and authorization semantics;
|
||||
- visual regression baseline green.
|
||||
|
||||
### Release B — Responsive operations
|
||||
|
||||
Contains R2 and R3. This release improves navigation, typography, dashboard hierarchy
|
||||
and detail-page use of space.
|
||||
|
||||
Release gate:
|
||||
|
||||
- visible mobile statuses and primary actions;
|
||||
- readable typography and touch targets;
|
||||
- dashboard first-viewport target met;
|
||||
- Dutch, French and English responsive checks green.
|
||||
|
||||
### Release C — Guided decisions and trust
|
||||
|
||||
Contains R4 and R5. This release refines the return, merge, knowledge, automation and
|
||||
demo-guide experiences.
|
||||
|
||||
Release gate:
|
||||
|
||||
- decision actions remain near their evidence;
|
||||
- honest and non-contradictory external health states;
|
||||
- five-minute demo passes end to end.
|
||||
|
||||
### Release D — Quality certification
|
||||
|
||||
Contains R6 and only defects found by its gates. It adds no new product scope.
|
||||
|
||||
Release gate:
|
||||
|
||||
- full clean-checkout acceptance;
|
||||
- accessibility and performance targets met;
|
||||
- final evidence updated;
|
||||
- release tag and deployed revision recorded.
|
||||
|
||||
## Implementation sequence and dependencies
|
||||
|
||||
```text
|
||||
R0 shared tokens and baselines
|
||||
├─ R1 pagination and compact lists ─┐
|
||||
└─ R2 responsive shell and type ───┼─ R3 dashboard/details ─ R4 workflows
|
||||
└─ R5 trust surfaces
|
||||
R1 + R2 + R3 + R4 + R5 ──────────────────────────────── R6 release gate
|
||||
```
|
||||
|
||||
R1 and R2 may be developed in parallel after R0. R3 should consume both shared list
|
||||
and responsive patterns. R4 depends on the new page-header and detail hierarchy. R5 can
|
||||
start after R2 but must share its disclosure and status patterns. R6 is a gate, not a
|
||||
cleanup bucket; defects discovered there return to the owning phase.
|
||||
|
||||
## Per-phase engineering checklist
|
||||
|
||||
Every phase must include:
|
||||
|
||||
1. a short before-state measurement;
|
||||
2. API and data-contract impact assessment;
|
||||
3. implementation using shared components where applicable;
|
||||
4. unit/API tests for changed contracts;
|
||||
5. Playwright coverage for the changed user journey;
|
||||
6. manual checks at 390, 768 and 1440 px in all supported languages;
|
||||
7. accessibility and console check;
|
||||
8. updated evidence and `PROJECT_STATE.md` entry;
|
||||
9. one coherent commit after validation passes;
|
||||
10. deployment verification before starting the next release slice.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Pagination changes API consumers | Add pagination metadata without silently changing service-token/MCP response contracts; version only if necessary |
|
||||
| Compact layouts hide evidence | Use drawers and disclosures, preserving direct access and auditability |
|
||||
| Larger type increases page height | Combine type changes with density and hierarchy work, never shrink text again |
|
||||
| Sticky UI covers content on mobile | Use shared top/bottom offsets and test keyboard/zoom states |
|
||||
| Visual snapshots become brittle | Seed deterministic data and mask only true timestamp volatility |
|
||||
| External health data is stale | Show last successful evidence and distinguish unknown from healthy |
|
||||
| Roadmap drifts into new product scope | Check every work package against `docs/01-scope-and-non-goals.md` and `docs/deferred.md` |
|
||||
|
||||
## Definition of roadmap completion
|
||||
|
||||
This roadmap is complete only when Releases A through D are deployed and verified,
|
||||
all target measures pass, the original five-minute demo remains green, and the final
|
||||
evidence records the exact deployed revision. Completing only the visual styling or one
|
||||
high-priority page does not complete the roadmap.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -313,20 +313,24 @@ 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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<button className="icon-button" type="button" onClick={handleLogout} aria-label={t("switchRole")} title={t("switchRoleTitle")}>
|
||||
<Icon name="logout" />
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content" tabIndex={-1}><Outlet /></main>
|
||||
|
||||
@@ -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": "Jusqu’au",
|
||||
"managerOnly": "La piste d'audit est visible uniquement pour les Responsables des opérations.",
|
||||
"managerOnlyDetail": "L'historique d'audit est visible uniquement pour les Responsables des opérations.",
|
||||
"loading": "Chargement de la piste d'audit…",
|
||||
|
||||
@@ -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) d’attention",
|
||||
"filterPlaceholder": "Filtrer les problèmes…",
|
||||
"filterAriaLabel": "Rechercher dans la file d'attention",
|
||||
"severityAll": "Toute gravité",
|
||||
@@ -68,6 +69,7 @@
|
||||
"n8nNoEvidence": "Aucune preuve d'automatisation enregistrée",
|
||||
"knowledgeTitle": "Assistant de connaissances",
|
||||
"knowledgeSummary": "{{count}} procédures indexées",
|
||||
"knowledgeIndexUnknown": "Source de connaissances disponible · taille d’index inconnue",
|
||||
"knowledgeUnavailable": "Vérification de santé indisponible",
|
||||
"mcpTitle": "MCP Hub",
|
||||
"mcpEnabled": "Enregistrement activé",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"knowledgeTitle": "Assistant de connaissances",
|
||||
"knowledgeSummaryDemo": "Base de connaissances de démo · {{count}} procédures indexées dans {{collection}}.",
|
||||
"knowledgeSummaryRagcore": "RAGcore · {{count}} procédures indexées dans {{collection}}.",
|
||||
"knowledgeSummaryIndexUnknown": "Source de connaissances disponible · taille d’index indisponible pour {{collection}}.",
|
||||
"knowledgeUnavailable": "Preuves de santé actuellement indisponibles.",
|
||||
"gatewayKicker": "Passerelle d'outils",
|
||||
"mcpTitle": "MCP Hub",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"statusAvailable": "Disponible",
|
||||
"statusUnavailable": "Indisponible",
|
||||
"proceduresIndexed": "{{count}} procédures indexées",
|
||||
"proceduresIndexUnknown": "La taille de l’index n’est pas disponible via cette connexion",
|
||||
"providerNote": "Cette démo répond à partir d'un petit ensemble fixe de procédures indexées — pas d'une connexion RAGcore en direct. Un backend RAGcore en direct reprendra plus tard la même interface sans changer le fonctionnement de cette page.",
|
||||
"askHeading": "Poser une question de procédure",
|
||||
"askSubheading": "Recherche → vérification des preuves → réponse étayée",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+62
-165
@@ -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 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 (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>
|
||||
);
|
||||
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">
|
||||
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 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?.length ?? 0 })}{" "}
|
||||
<button type="button" className="link-button" onClick={clearCorrelationFilter}>
|
||||
{t("clearFilter")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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.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>
|
||||
{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">
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
<Pagination page={events!.page} totalPages={events!.total_pages} onPageChange={(nextPage) => updateParams({ page: nextPage })} />
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -201,7 +201,9 @@ export function Automation() {
|
||||
<h2>{t("cards.knowledgeTitle")}</h2>
|
||||
<p>
|
||||
{knowledge
|
||||
? t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
|
||||
? 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,
|
||||
})
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user