M1: implement operational core

Demo auth, seed import/reset, dashboard, vehicle/booking list+detail, audit trail. Backend: 19 tests passing, ruff clean. Frontend: React Router shell, typed API client, responsive pages. Verified end-to-end via curl and browser.
This commit is contained in:
NuklearRabbit
2026-08-01 21:20:53 +02:00
parent 04d26f1f2e
commit 03c5b60235
47 changed files with 2518 additions and 70 deletions
+1
View File
@@ -13,3 +13,4 @@ test-results/
.DS_Store
.idea/
.vscode/
*.tsbuildinfo
+21 -2
View File
@@ -2,7 +2,7 @@
## Current milestone
M0 — complete. Starting M1 next.
M1 — complete. Starting M2 next.
## Locked decisions
@@ -15,6 +15,13 @@ M0 — complete. Starting M1 next.
- SQLAlchemy 2 declarative models cover the full domain model (`backend/app/models/`); enums are plain `String` columns validated at the Pydantic/service layer, not native PG enums (simpler migrations).
- `backend/requirements.lock` is compiled inside a `python:3.12-slim` container (matches the Dockerfile base image) via `pip-compile --extra dev`; regenerate the same way if `pyproject.toml` changes.
- Frontend dependencies pinned (no more `"latest"`); `package-lock.json` committed; Docker build uses `npm ci`.
- Demo auth is a lightweight HMAC-signed cookie (`app/core/security.py`), not a real password/JWT flow — matches "Demo role buttons create an authenticated session; they do not bypass authorization middleware." Two fixed demo users (`USR-OPS` operations_manager, `USR-EMP` rental_employee) are created by the seed loader, not from a CSV (no `users.csv` in `seed/`).
- Seed loader (`backend/app/seed_loader.py`) only supports `seed --reset` (always rebuilds); there is no incremental/idempotent-without-reset mode, since the acceptance criteria only require deterministic reset, not partial import.
- `DataQualityIssue.entity_ref`/`related_ref` from the CSVs are resolved to `entity_type`/`entity_id` (UUID) at load time per the domain model; the original human-readable refs are kept in `evidence_json` (`entity_ref`, `related_refs`) since the API and UI need them and re-resolving UUID→public_ref on every read would be wasteful.
- `backend/app/core/config.py` added `app_secret`, `session_cookie_name`, `session_ttl_seconds`, `seed_dir` (`/app/seed` in-container), `cors_allow_origins` (comma-separated string, not a list — simpler with pydantic-settings env parsing), `demo_today` (drives the dashboard's "Today" section against the deterministic anchor date, default `2026-08-01`).
- `compose.yaml` api build context changed from `./backend` to repo root with `dockerfile: backend/Dockerfile`, so the image can `COPY seed ./seed` (seed CSVs are outside `backend/`).
- Frontend: added `react-router-dom@7.18.2` (bumped from 6.x to clear two real advisories — open redirect + arbitrary constructor injection in v6). One residual `npm audit` finding (RSC-mode CSRF, GHSA-qwww-vcr4-c8h2) does not apply — this SPA never uses React Router's RSC/SSR mode.
- Nav/pages built so far: Dashboard, Vehicles (list+detail with tabs), Bookings (list+detail), Audit. Data Quality, Knowledge and Automation nav items are intentionally omitted until M3/M5/M4 build the pages behind them — CLAUDE.md forbids dead routes/placeholders.
## Completed evidence
@@ -30,10 +37,22 @@ M0 — complete. Starting M1 next.
- `make` is not installed in this Windows/git-bash shell — validated the underlying `docker compose ...` commands directly instead (Makefile targets are thin wrappers around them and are correct as written for a Linux/CI shell or WSL).
- Known accepted gap: `npm audit` reports 1 moderate/1 high transitive `esbuild` advisory (dev-server-only, fixed only by a Vite 8 major bump); left as-is for the PoC, noted here rather than silently upgrading a major version.
### M1 — Operational core
- Backend additions: `app/core/security.py` (HMAC-signed session cookies), `app/api/deps.py` (`get_current_user`, `require_operations_manager`), `app/core/errors.py` (`AppError` + the documented `{"error": {...}}` shape wired as a FastAPI exception handler for both `AppError` and `HTTPException`), `app/seed_loader.py`, `app/cli.py` (`python -m app.cli seed --reset`), `app/services/audit.py`, `app/schemas.py`, routers under `app/api/routers/` (`demo`, `dashboard`, `vehicles`, `bookings`, `audit`).
- Frontend additions: React Router-based app shell (`src/App.tsx`, `src/components/Layout.tsx`, `src/components/RequireAuth.tsx`), `AuthContext`, typed `api` client (`src/api/client.ts`, `src/api/types.ts`), pages `Login`, `Dashboard`, `Vehicles`/`VehicleDetail`, `Bookings`/`BookingDetail`, `Audit`. Full responsive stylesheet (`src/styles.css`) covering nav collapse and table→card layout under 700px, visible focus states, no hover-only actions.
- Commands run and verified from this checkout (container rebuilt each time to pick up code changes):
- `docker compose run --rm api pytest -q`**19 passed** (new: `test_seed.py`, `test_auth.py`, `test_dashboard.py`, `test_vehicles.py`, `test_bookings.py`, `test_audit.py`; tests seed the real Postgres via `reset_and_seed` in a session fixture, then exercise the FastAPI app through `TestClient`, not mocks).
- `docker compose run --rm api ruff check .` — All checks passed (added `ignore = ["B008"]` — FastAPI's `Depends()`-as-default is idiomatic, not a real bug).
- `npm run build` (local, Node 24) — clean `tsc -b && vite build`.
- `docker compose up -d --build` then `docker compose exec api python -m app.cli seed --reset` — counts: `users:2 customers:180 vehicles:50 bookings:246 inspections:75 maintenance:40 data_quality_issues:15 workflow_runs:20`.
- `curl` end-to-end: `POST /api/v1/demo/login` sets cookie and returns the user; unauthenticated `GET /api/v1/dashboard` → 401 with the documented error shape; authenticated dashboard/vehicle-detail return real seeded data (verified metrics `available:21 rented:11 cleaning:6 maintenance:5 blocked:7`, matching the 50 seeded vehicles).
- Browser smoke test (Chrome via MCP) at desktop width: login page → Operations Manager login → Dashboard (metrics + attention items + today + recent automation all populated) → Vehicle detail `MO-016` (tabs render, "Needs attention" badge correct — it's `DQ-DEMO-OVERLAP`/`DQ-DEMO-STATUS`) → Booking detail `BK-DEMO-RETURN` (matches S1 scenario: vehicle `MO-024`, status `active`, start odometer `53610`). Responsive CSS (`@media max-width:700px`) was written and code-reviewed but the automated resize during this session didn't visibly reflect in the captured screenshot (likely a screenshot-timing quirk of the browser tool, not necessarily a real bug) — **treat the ≤360px layout as visually unverified** and re-check with a real device/DevTools emulation before final acceptance (M7).
- Known accepted gap carried over from M0: `npm audit` residual `esbuild`/Vite-8 dev-server-only advisory.
## Known blockers
None. External service credentials may be absent; use the documented demo/degraded providers.
## Exact next action
Start M1 (operational core): read `docs/02-user-stories.md`, `docs/05-api-contract.md`, `docs/06-ui-ux.md`, `docs/13-seed-and-demo-scenarios.md` (already read this session). Implement: `app/cli.py` seed import/reset from `seed/*.csv`, demo auth/session + role middleware, dashboard/vehicles/bookings read APIs, audit-event writer, and the corresponding React router + pages (Dashboard, Vehicles, Bookings) with the persistent demo-disclosure banner.
Start M2 (vehicle return vertical slice): read `docs/08-return-workflow.md`, `contracts/events.schema.json`. Implement `POST /api/v1/bookings/{public_ref}/return` per the transaction steps in that doc (lock booking+vehicle rows, idempotency by header key, odometer-regression handling, status derivation, quality-issue creation, outbox insert, audit, one commit), a result-summary UI on the booking page, and tests for success/regression/replay/concurrent-submission/rollback. The seeded `BK-DEMO-RETURN` (`MO-024`, currently `active`, start odometer 53610) is the scripted demo scenario (S1) — a return below 53610 should trigger the regression path.
+7 -6
View File
@@ -1,13 +1,14 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.lock ./
COPY backend/requirements.lock ./
RUN pip install --no-cache-dir -r requirements.lock
COPY pyproject.toml ./
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./
COPY tests ./tests
COPY backend/pyproject.toml ./
COPY backend/app ./app
COPY backend/alembic ./alembic
COPY backend/alembic.ini ./
COPY backend/tests ./tests
COPY seed ./seed
RUN pip install --no-cache-dir --no-deps -e .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from collections.abc import Generator
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.db import SessionLocal
from app.core.security import SessionPayload, read_session_token
from app.schemas import CurrentUser
settings = get_settings()
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(request: Request) -> CurrentUser:
token = request.cookies.get(settings.session_cookie_name)
payload: SessionPayload | None = read_session_token(token) if token else None
if payload is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return CurrentUser(
public_ref=payload.public_ref, display_name=payload.display_name, role=payload.role
)
def require_operations_manager(
user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if user.role != "operations_manager":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Operations Manager role required"
)
return user
View File
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.audit import AuditEvent
from app.schemas import AuditEventOut, CurrentUser
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
@router.get("", response_model=list[AuditEventOut])
def list_audit_events(
actor_label: str | None = Query(default=None),
action: str | None = Query(default=None),
entity_type: str | None = Query(default=None),
correlation_id: str | None = Query(default=None),
limit: int = Query(default=100, le=500),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[AuditEventOut]:
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
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 correlation_id:
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
events = db.scalars(stmt).all()
return [
AuditEventOut(
id=str(e.id),
actor_type=e.actor_type,
actor_label=e.actor_label,
action=e.action,
entity_type=e.entity_type,
entity_id=str(e.entity_id) if e.entity_id else None,
correlation_id=str(e.correlation_id),
occurred_at=e.occurred_at,
metadata=e.metadata_json,
)
for e in events
]
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.vehicle import Vehicle
from app.schemas import BookingOut, CurrentUser
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
def _to_out(booking: Booking, customer: Customer, vehicle: Vehicle) -> BookingOut:
return BookingOut(
public_ref=booking.public_ref,
customer_ref=customer.public_ref,
vehicle_ref=vehicle.public_ref,
starts_at=booking.starts_at,
ends_at=booking.ends_at,
status=booking.status,
start_odometer_km=booking.start_odometer_km,
end_odometer_km=booking.end_odometer_km,
requirements_complete=booking.requirements_complete,
customer_name=f"{customer.first_name} {customer.last_name}",
)
@router.get("", response_model=list[BookingOut])
def list_bookings(
status: str | None = Query(default=None),
vehicle_ref: str | None = Query(default=None),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[BookingOut]:
stmt = select(Booking).order_by(Booking.starts_at.desc())
if status:
stmt = stmt.where(Booking.status == status)
if vehicle_ref:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None:
return []
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
bookings = db.scalars(stmt).all()
customers = {c.id: c for c in db.scalars(select(Customer)).all()}
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
return [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings]
@router.get("/{public_ref}", response_model=BookingOut)
def get_booking(
public_ref: str,
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref))
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
customer = db.get(Customer, booking.customer_id)
vehicle = db.get(Vehicle, booking.vehicle_id)
return _to_out(booking, customer, vehicle)
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
from datetime import date, datetime
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent
from app.models.vehicle import Vehicle
from app.schemas import (
AttentionItem,
AutomationRunOut,
CurrentUser,
DashboardMetrics,
DashboardOut,
TodayItem,
)
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"])
settings = get_settings()
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def _today() -> date:
return datetime.fromisoformat(settings.demo_today).date()
@router.get("", response_model=DashboardOut)
def get_dashboard(
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> DashboardOut:
status_counts = dict(
db.execute(
select(Vehicle.operational_status, func.count()).group_by(Vehicle.operational_status)
).all()
)
open_issues = db.scalar(
select(func.count()).select_from(DataQualityIssue).where(DataQualityIssue.status == "open")
)
pending_or_failed = db.scalar(
select(func.count())
.select_from(OutboxEvent)
.where(OutboxEvent.delivery_status.in_(["pending", "failed"]))
)
metrics = DashboardMetrics(
available=status_counts.get("available", 0),
rented=status_counts.get("rented", 0),
cleaning=status_counts.get("cleaning", 0),
maintenance=status_counts.get("maintenance", 0),
blocked=status_counts.get("blocked", 0),
open_quality_issues=open_issues or 0,
pending_or_failed_workflows=pending_or_failed or 0,
)
vehicles_by_id = {v.id: v for v in db.scalars(select(Vehicle)).all()}
customers_by_id = {c.id: c for c in db.scalars(select(Customer)).all()}
issues = db.scalars(
select(DataQualityIssue)
.where(DataQualityIssue.status == "open")
.order_by(DataQualityIssue.detected_at.asc())
).all()
attention_items = []
for issue in issues:
if issue.entity_type == "vehicle":
entity = vehicles_by_id.get(issue.entity_id)
link_type = "vehicle"
else:
entity = customers_by_id.get(issue.entity_id)
link_type = "customer"
link_ref = entity.public_ref if entity else ""
title = f"{issue.rule_type.replace('_', ' ').title()}{link_ref}"
attention_items.append(
AttentionItem(
kind="quality_issue",
severity=issue.severity,
title=title,
detail=issue.evidence_json.get("summary", ""),
link_type=link_type,
link_ref=link_ref,
)
)
attention_items.sort(key=lambda item: _SEVERITY_ORDER.get(item.severity, 3))
today = _today()
bookings = db.scalars(select(Booking)).all()
today_items: list[TodayItem] = []
for b in bookings:
vehicle = vehicles_by_id.get(b.vehicle_id)
vehicle_ref = vehicle.public_ref if vehicle else ""
if b.starts_at.date() == today and b.status in ("reserved", "active"):
today_items.append(
TodayItem(
kind="departure", booking_ref=b.public_ref, vehicle_ref=vehicle_ref,
scheduled_at=b.starts_at,
)
)
if b.ends_at.date() == today and b.status in ("active", "returned"):
today_items.append(
TodayItem(
kind="return", booking_ref=b.public_ref, vehicle_ref=vehicle_ref,
scheduled_at=b.ends_at,
)
)
today_items.sort(key=lambda item: item.scheduled_at)
recent = db.scalars(
select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)
).all()
recent_automation = [
AutomationRunOut(
event_id=str(r.event_id),
event_type=r.event_type,
aggregate_ref=r.payload_json.get("aggregate_ref", ""),
status=r.delivery_status,
attempts=r.attempts,
last_error=r.last_error,
occurred_at=r.occurred_at,
)
for r in recent
]
return DashboardOut(
metrics=metrics,
attention_items=attention_items,
today=today_items,
recent_automation=recent_automation,
)
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, Response
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.core.config import get_settings
from app.core.security import SessionPayload, create_session_token
from app.models.user import User
from app.schemas import CurrentUser, DemoLoginRequest
from app.seed_loader import reset_and_seed
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
settings = get_settings()
@router.post("/login", response_model=CurrentUser)
def demo_login(
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
) -> CurrentUser:
public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP"
user = db.scalar(select(User).where(User.public_ref == public_ref))
if user is None:
raise LookupError("Demo users are missing; run the seed loader first.")
token = create_session_token(
SessionPayload(
user_id=str(user.id),
public_ref=user.public_ref,
role=user.role,
display_name=user.display_name,
issued_at=int(time.time()),
)
)
response.set_cookie(
settings.session_cookie_name,
token,
httponly=True,
samesite="lax",
max_age=settings.session_ttl_seconds,
)
record_audit_event(
db,
actor_type="user",
actor_id=user.id,
actor_label=user.display_name,
action="demo_login",
entity_type="user",
entity_id=user.id,
)
db.commit()
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=user.role)
@router.post("/reset")
def demo_reset(
response: Response,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> dict:
result = reset_and_seed(db)
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="demo_reset",
entity_type="system",
metadata={"counts": result.counts},
)
db.commit()
response.delete_cookie(settings.session_cookie_name)
return {"status": "reset", "counts": result.counts}
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.vehicle import Vehicle
from app.schemas import (
BookingSummaryOut,
CurrentUser,
DataQualityIssueOut,
InspectionOut,
MaintenanceOut,
VehicleDetailOut,
VehicleOut,
)
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
def _attention_vehicle_ids(db: Session) -> set:
rows = db.scalars(
select(DataQualityIssue.entity_id).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.status == "open",
)
).all()
return set(rows)
@router.get("", response_model=list[VehicleOut])
def list_vehicles(
status: str | None = Query(default=None),
attention_only: bool = Query(default=False),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[VehicleOut]:
stmt = select(Vehicle).order_by(Vehicle.public_ref)
if status:
stmt = stmt.where(Vehicle.operational_status == status)
vehicles = db.scalars(stmt).all()
attention_ids = _attention_vehicle_ids(db)
out = [
VehicleOut(
public_ref=v.public_ref,
make=v.make,
model=v.model,
model_year=v.model_year,
registration_number=v.registration_number,
location=v.location,
operational_status=v.operational_status,
odometer_km=v.odometer_km,
next_service_km=v.next_service_km,
active=v.active,
attention=v.id in attention_ids or v.operational_status == "blocked",
)
for v in vehicles
]
if attention_only:
out = [v for v in out if v.attention]
return out
@router.get("/{public_ref}", response_model=VehicleDetailOut)
def get_vehicle(
public_ref: str,
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> VehicleDetailOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref))
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
bookings = db.scalars(
select(Booking).where(Booking.vehicle_id == vehicle.id).order_by(Booking.starts_at.desc())
).all()
customer_ref_by_id = {c.id: c.public_ref for c in db.scalars(select(Customer)).all()}
inspections = db.scalars(
select(Inspection)
.where(Inspection.vehicle_id == vehicle.id)
.order_by(Inspection.completed_at.desc())
).all()
maintenance = db.scalars(
select(MaintenanceRecord)
.where(MaintenanceRecord.vehicle_id == vehicle.id)
.order_by(MaintenanceRecord.occurred_at.desc())
).all()
issues = db.scalars(
select(DataQualityIssue)
.where(DataQualityIssue.entity_type == "vehicle", DataQualityIssue.entity_id == vehicle.id)
.order_by(DataQualityIssue.detected_at.desc())
).all()
booking_by_id = {b.id: b.public_ref for b in bookings}
attention_ids = _attention_vehicle_ids(db)
return VehicleDetailOut(
public_ref=vehicle.public_ref,
make=vehicle.make,
model=vehicle.model,
model_year=vehicle.model_year,
registration_number=vehicle.registration_number,
location=vehicle.location,
operational_status=vehicle.operational_status,
odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km,
active=vehicle.active,
attention=vehicle.id in attention_ids or vehicle.operational_status == "blocked",
bookings=[
BookingSummaryOut(
public_ref=b.public_ref,
customer_ref=customer_ref_by_id.get(b.customer_id, ""),
vehicle_ref=vehicle.public_ref,
starts_at=b.starts_at,
ends_at=b.ends_at,
status=b.status,
)
for b in bookings
],
inspections=[
InspectionOut(
public_ref=i.public_ref,
booking_ref=booking_by_id.get(i.booking_id, ""),
type=i.type,
fuel_level_percent=i.fuel_level_percent,
cleanliness_ok=i.cleanliness_ok,
damage_reported=i.damage_reported,
technical_warning=i.technical_warning,
odometer_km=i.odometer_km,
completed_at=i.completed_at,
)
for i in inspections
],
maintenance=[
MaintenanceOut(
public_ref=m.public_ref,
occurred_at=m.occurred_at,
odometer_km=m.odometer_km,
category=m.category,
summary=m.summary,
)
for m in maintenance
],
quality_issues=[
DataQualityIssueOut(
public_ref=q.public_ref,
rule_type=q.rule_type,
entity_type=q.entity_type,
entity_ref=vehicle.public_ref,
severity=q.severity,
status=q.status,
evidence=q.evidence_json,
detected_at=q.detected_at,
resolved_at=q.resolved_at,
)
for q in issues
],
)
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import argparse
from app.core.db import SessionLocal
from app.seed_loader import reset_and_seed
def main() -> None:
parser = argparse.ArgumentParser(prog="app.cli")
subparsers = parser.add_subparsers(dest="command", required=True)
seed_parser = subparsers.add_parser("seed", help="Load the deterministic demo dataset")
seed_parser.add_argument(
"--reset", action="store_true", help="Clear existing data before loading"
)
args = parser.parse_args()
if args.command == "seed":
if not args.reset:
raise SystemExit(
"Only 'seed --reset' is supported: seeding always rebuilds the demo dataset."
)
db = SessionLocal()
try:
result = reset_and_seed(db)
for name, count in result.counts.items():
print(f"{name}: {count}")
finally:
db.close()
if __name__ == "__main__":
main()
+6
View File
@@ -15,6 +15,12 @@ class Settings(BaseSettings):
ragcore_workspace: str = "mobilityops"
ragcore_collection: str = "internal-procedures"
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
app_secret: str = "replace-in-production"
session_cookie_name: str = "mobilityops_session"
session_ttl_seconds: int = 60 * 60 * 8
seed_dir: str = "/app/seed"
cors_allow_origins: str = "http://localhost:1228"
demo_today: str = "2026-08-01"
@lru_cache
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import uuid
from typing import Any
class AppError(Exception):
def __init__(
self,
code: str,
message: str,
status_code: int = 400,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.details = details or {}
self.correlation_id = str(uuid.uuid4())
def error_body(code: str, message: str, correlation_id: str, details: dict[str, Any]) -> dict:
return {
"error": {
"code": code,
"message": message,
"correlation_id": correlation_id,
"details": details,
}
}
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from app.core.config import get_settings
settings = get_settings()
@dataclass(frozen=True)
class SessionPayload:
user_id: str
public_ref: str
role: str
display_name: str
issued_at: int
def _sign(data: bytes) -> str:
digest = hmac.new(settings.app_secret.encode(), data, hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def create_session_token(payload: SessionPayload) -> str:
body = json.dumps(payload.__dict__, separators=(",", ":")).encode()
encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=")
signature = _sign(encoded_body.encode())
return f"{encoded_body}.{signature}"
def read_session_token(token: str) -> SessionPayload | None:
try:
encoded_body, signature = token.split(".", 1)
except ValueError:
return None
expected = _sign(encoded_body.encode())
if not hmac.compare_digest(expected, signature):
return None
padding = "=" * (-len(encoded_body) % 4)
try:
body = json.loads(base64.urlsafe_b64decode(encoded_body + padding))
except (ValueError, json.JSONDecodeError):
return None
payload = SessionPayload(**body)
if time.time() - payload.issued_at > settings.session_ttl_seconds:
return None
return payload
+44 -3
View File
@@ -1,9 +1,44 @@
from fastapi import FastAPI
import uuid
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api.routers import audit, bookings, dashboard, demo, vehicles
from app.core.config import get_settings
from app.core.errors import AppError, error_body
settings = get_settings()
app = FastAPI(title="MobilityOps API", version="0.0.1")
app = FastAPI(title="MobilityOps API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(AppError)
def handle_app_error(_request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=error_body(exc.code, exc.message, exc.correlation_id, exc.details),
)
@app.exception_handler(HTTPException)
def handle_http_exception(_request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=error_body(
code=str(exc.status_code),
message=str(exc.detail),
correlation_id=str(uuid.uuid4()),
details={},
),
)
@app.get("/health")
@@ -18,5 +53,11 @@ def system_status() -> dict[str, object]:
"environment": settings.mobilityops_env,
"demo_mode": settings.mobilityops_demo_mode,
"knowledge_provider": settings.knowledge_provider,
"scaffold": True,
}
app.include_router(demo.router)
app.include_router(dashboard.router)
app.include_router(vehicles.router)
app.include_router(bookings.router)
app.include_router(audit.router)
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
Role = Literal["operations_manager", "rental_employee"]
class DemoLoginRequest(BaseModel):
role: Role
class CurrentUser(BaseModel):
public_ref: str
display_name: str
role: Role
class VehicleOut(BaseModel):
public_ref: str
make: str
model: str
model_year: int
registration_number: str
location: str
operational_status: str
odometer_km: int
next_service_km: int
active: bool
attention: bool = False
class BookingSummaryOut(BaseModel):
public_ref: str
customer_ref: str
vehicle_ref: str
starts_at: datetime
ends_at: datetime
status: str
class BookingOut(BookingSummaryOut):
start_odometer_km: int | None
end_odometer_km: int | None
requirements_complete: bool
customer_name: str
class InspectionOut(BaseModel):
public_ref: str
booking_ref: str
type: str
fuel_level_percent: int
cleanliness_ok: bool
damage_reported: bool
technical_warning: bool
odometer_km: int
completed_at: datetime
class MaintenanceOut(BaseModel):
public_ref: str
occurred_at: datetime
odometer_km: int
category: str
summary: str
class DataQualityIssueOut(BaseModel):
public_ref: str
rule_type: str
entity_type: str
entity_ref: str
severity: str
status: str
evidence: dict[str, Any]
detected_at: datetime
resolved_at: datetime | None = None
class VehicleDetailOut(VehicleOut):
bookings: list[BookingSummaryOut] = Field(default_factory=list)
inspections: list[InspectionOut] = Field(default_factory=list)
maintenance: list[MaintenanceOut] = Field(default_factory=list)
quality_issues: list[DataQualityIssueOut] = Field(default_factory=list)
class DashboardMetrics(BaseModel):
available: int
rented: int
cleaning: int
maintenance: int
blocked: int
open_quality_issues: int
pending_or_failed_workflows: int
class AttentionItem(BaseModel):
kind: Literal["quality_issue", "vehicle"]
severity: str
title: str
detail: str
link_type: Literal["vehicle", "booking", "customer"]
link_ref: str
class TodayItem(BaseModel):
kind: Literal["departure", "return"]
booking_ref: str
vehicle_ref: str
scheduled_at: datetime
class AutomationRunOut(BaseModel):
event_id: str
event_type: str
aggregate_ref: str
status: str
attempts: int
last_error: str | None
occurred_at: datetime
class DashboardOut(BaseModel):
metrics: DashboardMetrics
attention_items: list[AttentionItem]
today: list[TodayItem]
recent_automation: list[AutomationRunOut]
class AuditEventOut(BaseModel):
id: str
actor_type: str
actor_label: str
action: str
entity_type: str
entity_id: str | None
correlation_id: str
occurred_at: datetime
metadata: dict[str, Any] | None = None
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
import csv
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import delete, insert
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent
from app.models.user import User
from app.models.vehicle import Vehicle
settings = get_settings()
DEMO_USERS = [
{
"public_ref": "USR-OPS",
"display_name": "Amelie De Ridder",
"role": "operations_manager",
},
{
"public_ref": "USR-EMP",
"display_name": "Karim Boujaddaine",
"role": "rental_employee",
},
]
def _parse_dt(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _parse_bool(value: str) -> bool:
return value.strip().lower() == "true"
def _parse_optional_int(value: str) -> int | None:
value = value.strip()
return int(value) if value else None
@dataclass
class SeedResult:
counts: dict[str, int]
def _seed_dir() -> Path:
return Path(settings.seed_dir)
def _read_csv(name: str) -> list[dict[str, str]]:
path = _seed_dir() / name
with path.open(newline="", encoding="utf-8") as handle:
return list(csv.DictReader(handle))
def clear_all(db: Session) -> None:
for model in (
AuditEvent,
OutboxEvent,
DataQualityIssue,
Inspection,
MaintenanceRecord,
Booking,
Vehicle,
Customer,
User,
):
db.execute(delete(model))
def load_seed(db: Session) -> SeedResult:
counts: dict[str, int] = {}
user_rows = [
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
]
db.execute(insert(User), user_rows)
counts["users"] = len(user_rows)
customer_id_by_ref: dict[str, uuid.UUID] = {}
customer_rows = []
for row in _read_csv("customers.csv"):
cid = uuid.uuid4()
customer_id_by_ref[row["public_ref"]] = cid
customer_rows.append(
{
"id": cid,
"public_ref": row["public_ref"],
"first_name": row["first_name"],
"last_name": row["last_name"],
"email": row["email"] or None,
"phone": row["phone"] or None,
"postal_code": row["postal_code"] or None,
"city": row["city"] or None,
}
)
db.execute(insert(Customer), customer_rows)
counts["customers"] = len(customer_rows)
# Second pass for merged_into (self-referencing FK) since target must exist first.
for row in _read_csv("customers.csv"):
merged_ref = row.get("merged_into") or ""
if merged_ref:
db.execute(
Customer.__table__.update()
.where(Customer.id == customer_id_by_ref[row["public_ref"]])
.values(merged_into_customer_id=customer_id_by_ref[merged_ref])
)
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
vehicle_rows = []
for row in _read_csv("vehicles.csv"):
vid = uuid.uuid4()
vehicle_id_by_ref[row["public_ref"]] = vid
vehicle_rows.append(
{
"id": vid,
"public_ref": row["public_ref"],
"make": row["make"],
"model": row["model"],
"model_year": int(row["model_year"]),
"registration_number": row["registration_number"],
"location": row["location"],
"operational_status": row["operational_status"],
"odometer_km": int(row["odometer_km"]),
"next_service_km": int(row["next_service_km"]),
"active": _parse_bool(row["active"]),
"version": 1,
}
)
db.execute(insert(Vehicle), vehicle_rows)
counts["vehicles"] = len(vehicle_rows)
booking_id_by_ref: dict[str, uuid.UUID] = {}
booking_rows = []
for row in _read_csv("bookings.csv"):
bid = uuid.uuid4()
booking_id_by_ref[row["public_ref"]] = bid
booking_rows.append(
{
"id": bid,
"public_ref": row["public_ref"],
"customer_id": customer_id_by_ref[row["customer_ref"]],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"starts_at": _parse_dt(row["starts_at"]),
"ends_at": _parse_dt(row["ends_at"]),
"status": row["status"],
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
"requirements_complete": _parse_bool(row["requirements_complete"]),
}
)
db.execute(insert(Booking), booking_rows)
counts["bookings"] = len(booking_rows)
inspection_rows = []
for row in _read_csv("inspections.csv"):
inspection_rows.append(
{
"id": uuid.uuid4(),
"public_ref": row["public_ref"],
"booking_id": booking_id_by_ref[row["booking_ref"]],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"type": row["type"],
"fuel_level_percent": int(row["fuel_level_percent"]),
"cleanliness_ok": _parse_bool(row["cleanliness_ok"]),
"damage_reported": _parse_bool(row["damage_reported"]),
"technical_warning": _parse_bool(row["technical_warning"]),
"odometer_km": int(row["odometer_km"]),
"completed_at": _parse_dt(row["completed_at"]),
"completed_by": None,
}
)
db.execute(insert(Inspection), inspection_rows)
counts["inspections"] = len(inspection_rows)
maintenance_rows = []
for row in _read_csv("maintenance.csv"):
maintenance_rows.append(
{
"id": uuid.uuid4(),
"public_ref": row["public_ref"],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"occurred_at": _parse_dt(row["occurred_at"]),
"odometer_km": int(row["odometer_km"]),
"category": row["category"],
"summary": row["summary"],
}
)
db.execute(insert(MaintenanceRecord), maintenance_rows)
counts["maintenance"] = len(maintenance_rows)
def resolve_entity(entity_ref: str) -> tuple[str, uuid.UUID]:
if entity_ref.startswith("CUS-"):
return "customer", customer_id_by_ref[entity_ref]
return "vehicle", vehicle_id_by_ref[entity_ref]
dq_rows = []
now = datetime.now(UTC)
for row in _read_csv("data_quality_issues.csv"):
entity_type, entity_id = resolve_entity(row["entity_ref"])
related_ref = row.get("related_ref") or ""
dq_rows.append(
{
"id": uuid.uuid4(),
"public_ref": row["public_ref"],
"rule_type": row["rule_type"],
"entity_type": entity_type,
"entity_id": entity_id,
"severity": row["severity"],
"status": row["status"],
"evidence_json": {
"summary": row["evidence"],
"entity_ref": row["entity_ref"],
"related_refs": related_ref.split("|") if related_ref else [],
},
"proposed_action_json": {},
"detected_at": now,
"resolved_at": now if row["status"] == "resolved" else None,
"resolved_by": "USR-OPS" if row["status"] == "resolved" else None,
}
)
db.execute(insert(DataQualityIssue), dq_rows)
counts["data_quality_issues"] = len(dq_rows)
outbox_rows = []
for row in _read_csv("workflow_runs.csv"):
booking_id = booking_id_by_ref.get(row["aggregate_ref"])
outbox_rows.append(
{
"event_id": uuid.UUID(row["event_id"]),
"event_type": row["event_type"],
"aggregate_type": "booking",
"aggregate_id": booking_id or uuid.uuid4(),
"payload_json": {"aggregate_ref": row["aggregate_ref"]},
"occurred_at": _parse_dt(row["occurred_at"]),
"delivery_status": row["status"],
"attempts": int(row["attempts"]),
"next_attempt_at": None,
"last_error": row["last_error"] or None,
"external_run_id": None,
}
)
db.execute(insert(OutboxEvent), outbox_rows)
counts["workflow_runs"] = len(outbox_rows)
return SeedResult(counts=counts)
def reset_and_seed(db: Session) -> SeedResult:
clear_all(db)
result = load_seed(db)
db.commit()
return result
View File
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.models.audit import AuditEvent
def record_audit_event(
db: Session,
*,
actor_type: str,
actor_label: str,
action: str,
entity_type: str,
actor_id: uuid.UUID | None = None,
entity_id: uuid.UUID | None = None,
correlation_id: uuid.UUID | None = None,
before: dict[str, Any] | None = None,
after: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> AuditEvent:
event = AuditEvent(
actor_type=actor_type,
actor_id=actor_id,
actor_label=actor_label,
action=action,
entity_type=entity_type,
entity_id=entity_id,
correlation_id=correlation_id or uuid.uuid4(),
before_json=before,
after_json=after,
metadata_json=metadata,
occurred_at=datetime.now(UTC),
)
db.add(event)
return event
+2 -1
View File
@@ -34,7 +34,8 @@ asyncio_mode = "auto"
[tool.ruff]
line-length = 100
extend-exclude = ["alembic/versions"]
extend-exclude = ["alembic/versions", "seed"]
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
ignore = ["B008"]
+38
View File
@@ -0,0 +1,38 @@
import pytest
from fastapi.testclient import TestClient
from app.core.db import Base, SessionLocal, engine
from app.main import app
from app.seed_loader import reset_and_seed
@pytest.fixture(scope="session", autouse=True)
def _seeded_database():
Base.metadata.create_all(engine)
db = SessionLocal()
try:
reset_and_seed(db)
finally:
db.close()
yield
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def login(client: TestClient, role: str) -> TestClient:
response = client.post("/api/v1/demo/login", json={"role": role})
assert response.status_code == 200
return client
@pytest.fixture
def ops_client(client: TestClient) -> TestClient:
return login(client, "operations_manager")
@pytest.fixture
def employee_client(client: TestClient) -> TestClient:
return login(client, "rental_employee")
+11
View File
@@ -0,0 +1,11 @@
def test_demo_login_is_audited(ops_client):
response = ops_client.get("/api/v1/audit", params={"action": "demo_login"})
assert response.status_code == 200
events = response.json()
assert len(events) >= 1
assert events[0]["action"] == "demo_login"
def test_audit_requires_authentication(client):
response = client.get("/api/v1/audit")
assert response.status_code == 401
+20
View File
@@ -0,0 +1,20 @@
def test_unauthenticated_dashboard_is_rejected(client):
response = client.get("/api/v1/dashboard")
assert response.status_code == 401
assert response.json()["error"]["code"] == "401"
def test_demo_login_grants_access(ops_client):
response = ops_client.get("/api/v1/dashboard")
assert response.status_code == 200
def test_rental_employee_cannot_reset_demo(employee_client):
response = employee_client.post("/api/v1/demo/reset")
assert response.status_code == 403
def test_operations_manager_can_reset_demo(ops_client):
response = ops_client.post("/api/v1/demo/reset")
assert response.status_code == 200
assert response.json()["counts"]["vehicles"] == 50
+19
View File
@@ -0,0 +1,19 @@
def test_list_bookings_filters_by_vehicle(ops_client):
response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-024"})
assert response.status_code == 200
bookings = response.json()
assert len(bookings) >= 1
assert all(b["vehicle_ref"] == "MO-024" for b in bookings)
def test_get_booking_detail(ops_client):
response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN")
assert response.status_code == 200
body = response.json()
assert body["status"] == "active"
assert body["vehicle_ref"] == "MO-024"
def test_get_booking_404_for_unknown_ref(ops_client):
response = ops_client.get("/api/v1/bookings/BK-UNKNOWN")
assert response.status_code == 404
+30
View File
@@ -0,0 +1,30 @@
def test_dashboard_metrics_are_persisted_counts(ops_client):
response = ops_client.get("/api/v1/dashboard")
assert response.status_code == 200
body = response.json()
metrics = body["metrics"]
total = (
metrics["available"]
+ metrics["rented"]
+ metrics["cleaning"]
+ metrics["maintenance"]
+ metrics["blocked"]
)
assert total == 50
assert metrics["open_quality_issues"] >= 1
assert metrics["pending_or_failed_workflows"] >= 1
def test_dashboard_attention_items_link_to_records(ops_client):
response = ops_client.get("/api/v1/dashboard")
body = response.json()
assert len(body["attention_items"]) > 0
for item in body["attention_items"]:
assert item["link_ref"]
assert item["severity"] in ("low", "medium", "high")
def test_dashboard_recent_automation_capped_at_five(ops_client):
response = ops_client.get("/api/v1/dashboard")
body = response.json()
assert len(body["recent_automation"]) == 5
+46
View File
@@ -0,0 +1,46 @@
from sqlalchemy import func, select
from app.core.db import SessionLocal
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent
from app.models.user import User
from app.models.vehicle import Vehicle
def test_seed_counts_match_deterministic_dataset():
db = SessionLocal()
try:
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
assert db.scalar(select(func.count()).select_from(Customer)) == 180
assert db.scalar(select(func.count()).select_from(Booking)) == 246
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 15
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
assert db.scalar(select(func.count()).select_from(User)) == 2
finally:
db.close()
def test_seed_demo_scenarios_present():
db = SessionLocal()
try:
booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN"))
assert booking is not None
assert booking.status == "active"
duplicate_customer = db.scalar(select(Customer).where(Customer.public_ref == "CUS-0178"))
assert duplicate_customer is not None
duplicate_issue = db.scalar(
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE")
)
assert duplicate_issue is not None
assert duplicate_issue.rule_type == "possible_duplicate_customer"
failed_run = db.scalar(
select(OutboxEvent).where(OutboxEvent.delivery_status == "failed")
)
assert failed_run is not None
finally:
db.close()
+27
View File
@@ -0,0 +1,27 @@
def test_list_vehicles_filters_by_status(ops_client):
response = ops_client.get("/api/v1/vehicles", params={"status": "maintenance"})
assert response.status_code == 200
vehicles = response.json()
assert len(vehicles) > 0
assert all(v["operational_status"] == "maintenance" for v in vehicles)
def test_attention_only_filters_flagged_vehicles(ops_client):
response = ops_client.get("/api/v1/vehicles", params={"attention_only": True})
assert response.status_code == 200
vehicles = response.json()
assert len(vehicles) > 0
assert all(v["attention"] for v in vehicles)
def test_vehicle_detail_includes_related_records(ops_client):
response = ops_client.get("/api/v1/vehicles/MO-016")
assert response.status_code == 200
body = response.json()
assert body["public_ref"] == "MO-016"
assert len(body["quality_issues"]) >= 1
def test_vehicle_detail_404_for_unknown_ref(ops_client):
response = ops_client.get("/api/v1/vehicles/MO-999")
assert response.status_code == 404
+6 -1
View File
@@ -15,12 +15,17 @@ services:
networks: [mobilityops]
api:
build: ./backend
build:
context: .
dockerfile: backend/Dockerfile
environment:
MOBILITYOPS_ENV: ${MOBILITYOPS_ENV:-development}
MOBILITYOPS_DEMO_MODE: ${MOBILITYOPS_DEMO_MODE:-true}
DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops}
TZ: ${TZ:-Europe/Brussels}
APP_SECRET: ${APP_SECRET:-replace-in-production}
DEMO_TODAY: ${DEMO_TODAY:-2026-08-01}
CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228}
KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo}
RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000}
RAGCORE_TENANT: ${RAGCORE_TENANT:-northstar-mobility-demo}
+59 -1
View File
@@ -9,7 +9,8 @@
"version": "0.0.1",
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
"react-dom": "18.3.1",
"react-router-dom": "7.18.2"
},
"devDependencies": {
"@types/react": "18.3.12",
@@ -1326,6 +1327,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1593,6 +1607,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rollup": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
@@ -1658,6 +1710,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+2 -1
View File
@@ -11,7 +11,8 @@
},
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
"react-dom": "18.3.1",
"react-router-dom": "7.18.2"
},
"devDependencies": {
"@types/react": "18.3.12",
+32 -44
View File
@@ -1,49 +1,37 @@
import { useEffect, useState } from "react";
const API = import.meta.env.VITE_API_BASE_URL ?? "";
type Status = {
service: string;
environment: string;
demo_mode: boolean;
knowledge_provider: string;
scaffold: boolean;
};
import { Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./context/AuthContext";
import { Layout } from "./components/Layout";
import { RequireAuth } from "./components/RequireAuth";
import { Login } from "./pages/Login";
import { Dashboard } from "./pages/Dashboard";
import { Vehicles } from "./pages/Vehicles";
import { VehicleDetail } from "./pages/VehicleDetail";
import { Bookings } from "./pages/Bookings";
import { BookingDetail } from "./pages/BookingDetail";
import { Audit } from "./pages/Audit";
export function App() {
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`${API}/api/v1/system/status`)
.then((response) => {
if (!response.ok) throw new Error(`API returned ${response.status}`);
return response.json();
})
.then(setStatus)
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "Unknown error"));
}, []);
return (
<main className="shell">
<section className="hero">
<p className="eyebrow">Synthetic proof of concept</p>
<h1>MobilityOps</h1>
<p>Connected operations for vehicle rental and service teams.</p>
</section>
<section className="panel" aria-live="polite">
<h2>Scaffold status</h2>
{error && <p className="error">API unavailable: {error}</p>}
{!error && !status && <p>Connecting to the MobilityOps API</p>}
{status && (
<dl>
<div><dt>Service</dt><dd>{status.service}</dd></div>
<div><dt>Environment</dt><dd>{status.environment}</dd></div>
<div><dt>Knowledge provider</dt><dd>{status.knowledge_provider}</dd></div>
</dl>
)}
<p className="note">This is the bootable project scaffold. Claude must replace it with the complete scoped application described in the build pack.</p>
</section>
</main>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/vehicles" element={<Vehicles />} />
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
<Route path="/bookings" element={<Bookings />} />
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
<Route path="/audit" element={<Audit />} />
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</AuthProvider>
);
}
+52
View File
@@ -0,0 +1,52 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
export class ApiError extends Error {
status: number;
code: string;
correlationId: string;
constructor(status: number, code: string, message: string, correlationId: string) {
super(message);
this.status = status;
this.code = code;
this.correlationId = correlationId;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!response.ok) {
let body: { error?: { code: string; message: string; correlation_id: string } } | undefined;
try {
body = await response.json();
} catch {
body = undefined;
}
const error = body?.error;
throw new ApiError(
response.status,
error?.code ?? String(response.status),
error?.message ?? response.statusText,
error?.correlation_id ?? "",
);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
request<T>(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }),
};
+131
View File
@@ -0,0 +1,131 @@
export type Role = "operations_manager" | "rental_employee";
export interface CurrentUser {
public_ref: string;
display_name: string;
role: Role;
}
export interface Vehicle {
public_ref: string;
make: string;
model: string;
model_year: number;
registration_number: string;
location: string;
operational_status: string;
odometer_km: number;
next_service_km: number;
active: boolean;
attention: boolean;
}
export interface BookingSummary {
public_ref: string;
customer_ref: string;
vehicle_ref: string;
starts_at: string;
ends_at: string;
status: string;
}
export interface Booking extends BookingSummary {
start_odometer_km: number | null;
end_odometer_km: number | null;
requirements_complete: boolean;
customer_name: string;
}
export interface Inspection {
public_ref: string;
booking_ref: string;
type: string;
fuel_level_percent: number;
cleanliness_ok: boolean;
damage_reported: boolean;
technical_warning: boolean;
odometer_km: number;
completed_at: string;
}
export interface MaintenanceRecord {
public_ref: string;
occurred_at: string;
odometer_km: number;
category: string;
summary: string;
}
export interface DataQualityIssue {
public_ref: string;
rule_type: string;
entity_type: string;
entity_ref: string;
severity: "low" | "medium" | "high";
status: "open" | "deferred" | "resolved" | "rejected";
evidence: Record<string, unknown>;
detected_at: string;
resolved_at: string | null;
}
export interface VehicleDetail extends Vehicle {
bookings: BookingSummary[];
inspections: Inspection[];
maintenance: MaintenanceRecord[];
quality_issues: DataQualityIssue[];
}
export interface DashboardMetrics {
available: number;
rented: number;
cleaning: number;
maintenance: number;
blocked: number;
open_quality_issues: number;
pending_or_failed_workflows: number;
}
export interface AttentionItem {
kind: string;
severity: "low" | "medium" | "high";
title: string;
detail: string;
link_type: "vehicle" | "booking" | "customer";
link_ref: string;
}
export interface TodayItem {
kind: "departure" | "return";
booking_ref: string;
vehicle_ref: string;
scheduled_at: string;
}
export interface AutomationRun {
event_id: string;
event_type: string;
aggregate_ref: string;
status: string;
attempts: number;
last_error: string | null;
occurred_at: string;
}
export interface Dashboard {
metrics: DashboardMetrics;
attention_items: AttentionItem[];
today: TodayItem[];
recent_automation: AutomationRun[];
}
export interface AuditEvent {
id: string;
actor_type: string;
actor_label: string;
action: string;
entity_type: string;
entity_id: string | null;
correlation_id: string;
occurred_at: string;
metadata: Record<string, unknown> | null;
}
+8
View File
@@ -0,0 +1,8 @@
export function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
const label = severity === "high" ? "High" : severity === "medium" ? "Medium" : "Low";
return <span className={`badge severity-${severity}`}>{label} severity</span>;
}
export function StatusBadge({ status }: { status: string }) {
return <span className={`badge status-${status}`}>{status.replace(/_/g, " ")}</span>;
}
+56
View File
@@ -0,0 +1,56 @@
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
const NAV_ITEMS = [
{ to: "/dashboard", label: "Dashboard" },
{ to: "/vehicles", label: "Vehicles" },
{ to: "/bookings", label: "Bookings" },
{ to: "/audit", label: "Audit" },
];
export function Layout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
function handleLogout() {
logout();
navigate("/login");
}
return (
<div className="app-shell">
<p className="demo-banner">
Synthetic demo environment no real customer or vehicle data.
</p>
<header className="app-header">
<span className="brand">MobilityOps</span>
<nav aria-label="Primary">
<ul>
{NAV_ITEMS.map((item) => (
<li key={item.to}>
<NavLink to={item.to} className={({ isActive }) => (isActive ? "active" : "")}>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="user-badge">
{user && (
<>
<span>
{user.display_name} · {user.role === "operations_manager" ? "Operations Manager" : "Rental Employee"}
</span>
<button type="button" onClick={handleLogout}>
Switch role
</button>
</>
)}
</div>
</header>
<main id="main-content">
<Outlet />
</main>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
export function RequireAuth({ children }: { children: ReactNode }) {
const { user } = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
+54
View File
@@ -0,0 +1,54 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { api, ApiError } from "../api/client";
import type { CurrentUser, Role } from "../api/types";
interface AuthState {
user: CurrentUser | null;
loading: boolean;
loginAs: (role: Role) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
const STORAGE_KEY = "mobilityops.demo-user";
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(() => {
const stored = sessionStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as CurrentUser) : null;
});
const [loading, setLoading] = useState(false);
const loginAs = useCallback(async (role: Role) => {
setLoading(true);
try {
const loggedIn = await api.post<CurrentUser>("/api/v1/demo/login", { role });
setUser(loggedIn);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(loggedIn));
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
setUser(null);
sessionStorage.removeItem(STORAGE_KEY);
}, []);
return (
<AuthContext.Provider value={{ user, loading, loginAs, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
export function isSessionExpired(error: unknown): boolean {
return error instanceof ApiError && error.status === 401;
}
+3
View File
@@ -1,10 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { AuditEvent } from "../api/types";
export function Audit() {
const [events, setEvents] = useState<AuditEvent[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [action, setAction] = useState("");
useEffect(() => {
const params = new URLSearchParams();
if (action) params.set("action", action);
api
.get<AuditEvent[]>(`/api/v1/audit?${params.toString()}`)
.then(setEvents)
.catch(() => setError("Audit trail is unavailable right now."));
}, [action]);
return (
<div className="page">
<h1>Audit</h1>
<form className="filters" aria-label="Filter audit events">
<label>
Action
<input
type="text"
value={action}
onChange={(e) => setAction(e.target.value)}
placeholder="e.g. demo_login"
/>
</label>
</form>
{error && <p className="error" role="alert">{error}</p>}
{!error && !events && <p>Loading audit trail</p>}
{events && events.length === 0 && <p>No audit events match this filter.</p>}
{events && events.length > 0 && (
<table className="data-table">
<caption className="visually-hidden">Audit events</caption>
<thead>
<tr>
<th scope="col">When</th>
<th scope="col">Actor</th>
<th scope="col">Action</th>
<th scope="col">Entity</th>
<th scope="col">Correlation</th>
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id}>
<td>
<time dateTime={e.occurred_at}>
{new Date(e.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
</time>
</td>
<td>{e.actor_label} ({e.actor_type})</td>
<td>{e.action}</td>
<td>{e.entity_type}</td>
<td className="mono">{e.correlation_id.slice(0, 8)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api } from "../api/client";
import type { Booking } from "../api/types";
import { StatusBadge } from "../components/Badge";
export function BookingDetail() {
const { publicRef } = useParams<{ publicRef: string }>();
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!publicRef) return;
setBooking(null);
setError(null);
api
.get<Booking>(`/api/v1/bookings/${publicRef}`)
.then(setBooking)
.catch(() => setError("This booking could not be found."));
}, [publicRef]);
if (error) return <p className="error" role="alert">{error}</p>;
if (!booking) return <p>Loading booking</p>;
return (
<div className="page">
<p><Link to="/bookings"> Back to bookings</Link></p>
<h1>{booking.public_ref}</h1>
<p><StatusBadge status={booking.status} /></p>
<dl className="detail-grid">
<div><dt>Customer</dt><dd>{booking.customer_name} ({booking.customer_ref})</dd></div>
<div><dt>Vehicle</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
<div><dt>Starts</dt><dd>{new Date(booking.starts_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div>
<div><dt>Ends</dt><dd>{new Date(booking.ends_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}</dd></div>
<div><dt>Start odometer</dt><dd>{booking.start_odometer_km ?? "—"} km</dd></div>
<div><dt>End odometer</dt><dd>{booking.end_odometer_km ?? "—"} km</dd></div>
<div><dt>Requirements complete</dt><dd>{booking.requirements_complete ? "Yes" : "No"}</dd></div>
</dl>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api/client";
import type { Booking } from "../api/types";
import { StatusBadge } from "../components/Badge";
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
export function Bookings() {
const [bookings, setBookings] = useState<Booking[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("");
useEffect(() => {
const params = new URLSearchParams();
if (status) params.set("status", status);
api
.get<Booking[]>(`/api/v1/bookings?${params.toString()}`)
.then(setBookings)
.catch(() => setError("Booking list is unavailable right now."));
}, [status]);
return (
<div className="page">
<h1>Bookings</h1>
<form className="filters" aria-label="Filter bookings">
<label>
Status
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
</form>
{error && <p className="error" role="alert">{error}</p>}
{!error && !bookings && <p>Loading bookings</p>}
{bookings && bookings.length === 0 && <p>No bookings match these filters.</p>}
{bookings && bookings.length > 0 && (
<table className="data-table">
<caption className="visually-hidden">Bookings</caption>
<thead>
<tr>
<th scope="col">Reference</th>
<th scope="col">Customer</th>
<th scope="col">Vehicle</th>
<th scope="col">Window</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
{bookings.map((b) => (
<tr key={b.public_ref}>
<th scope="row">
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
</th>
<td>{b.customer_name}</td>
<td>
<Link to={`/vehicles/${b.vehicle_ref}`}>{b.vehicle_ref}</Link>
</td>
<td>
{new Date(b.starts_at).toLocaleDateString("en-GB")} {new Date(b.ends_at).toLocaleDateString("en-GB")}
</td>
<td>
<StatusBadge status={b.status} />
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api/client";
import type { Dashboard as DashboardData } from "../api/types";
import { SeverityBadge, StatusBadge } from "../components/Badge";
const METRIC_LABELS: Record<keyof DashboardData["metrics"], string> = {
available: "Available",
rented: "Rented",
cleaning: "Cleaning",
maintenance: "Maintenance",
blocked: "Blocked",
open_quality_issues: "Open quality issues",
pending_or_failed_workflows: "Pending/failed workflows",
};
export function Dashboard() {
const [data, setData] = useState<DashboardData | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.get<DashboardData>("/api/v1/dashboard")
.then(setData)
.catch(() => setError("Dashboard data is unavailable right now."));
}, []);
if (error) return <p className="error" role="alert">{error}</p>;
if (!data) return <p>Loading dashboard</p>;
return (
<div className="page">
<h1>Dashboard</h1>
<section aria-labelledby="metrics-heading">
<h2 id="metrics-heading">Operational metrics</h2>
<ul className="metric-grid">
{(Object.keys(METRIC_LABELS) as (keyof DashboardData["metrics"])[]).map((key) => (
<li key={key} className="metric-tile">
<span className="metric-value">{data.metrics[key]}</span>
<span className="metric-label">{METRIC_LABELS[key]}</span>
</li>
))}
</ul>
</section>
<section aria-labelledby="attention-heading" className="panel">
<h2 id="attention-heading">Attention required</h2>
{data.attention_items.length === 0 && <p>Nothing needs attention right now.</p>}
<ul className="attention-list">
{data.attention_items.map((item, index) => (
<li key={`${item.link_ref}-${index}`}>
<SeverityBadge severity={item.severity} />
<div>
<p className="attention-title">
{item.link_type === "vehicle" ? (
<Link to={`/vehicles/${item.link_ref}`}>{item.title}</Link>
) : (
item.title
)}
</p>
<p className="attention-detail">{item.detail}</p>
</div>
</li>
))}
</ul>
</section>
<section aria-labelledby="today-heading" className="panel">
<h2 id="today-heading">Today</h2>
{data.today.length === 0 && <p>No departures or returns scheduled today.</p>}
<ul className="today-list">
{data.today.map((item) => (
<li key={`${item.kind}-${item.booking_ref}`}>
<span className="today-kind">{item.kind === "departure" ? "Departure" : "Return"}</span>
<Link to={`/bookings/${item.booking_ref}`}>{item.booking_ref}</Link>
<span>{item.vehicle_ref}</span>
<time dateTime={item.scheduled_at}>
{new Date(item.scheduled_at).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Brussels",
})}
</time>
</li>
))}
</ul>
</section>
<section aria-labelledby="automation-heading" className="panel">
<h2 id="automation-heading">Recent automation</h2>
{data.recent_automation.length === 0 && <p>No automation runs recorded yet.</p>}
<ul className="automation-list">
{data.recent_automation.map((run) => (
<li key={run.event_id}>
<StatusBadge status={run.status} />
<span>{run.event_type}</span>
<span>{run.aggregate_ref}</span>
<time dateTime={run.occurred_at}>
{new Date(run.occurred_at).toLocaleString("en-GB", { timeZone: "Europe/Brussels" })}
</time>
</li>
))}
</ul>
</section>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import type { Role } from "../api/types";
export function Login() {
const { loginAs, loading } = useAuth();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
async function handleLogin(role: Role) {
setError(null);
try {
await loginAs(role);
navigate("/dashboard");
} catch {
setError("Could not start a demo session. The API may be unavailable.");
}
}
return (
<main className="login-shell">
<p className="demo-banner">
Synthetic demo environment no real customer or vehicle data.
</p>
<section className="login-hero">
<p className="eyebrow">Synthetic proof of concept</p>
<h1>MobilityOps</h1>
<p>Connected operations for vehicle rental and service teams.</p>
</section>
<section className="login-panel panel" aria-labelledby="login-heading">
<h2 id="login-heading">Choose a demo role</h2>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<div className="login-options">
<button type="button" disabled={loading} onClick={() => handleLogin("operations_manager")}>
Open as Operations Manager
</button>
<p>See the dashboard, resolve data-quality issues, retry automation and reset the demo.</p>
<button type="button" disabled={loading} onClick={() => handleLogin("rental_employee")}>
Open as Rental Employee
</button>
<p>Register vehicle returns and look up bookings, vehicles and procedures.</p>
</div>
</section>
</main>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { api } from "../api/client";
import type { VehicleDetail as VehicleDetailData } from "../api/types";
import { SeverityBadge, StatusBadge } from "../components/Badge";
const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const;
type Tab = (typeof TABS)[number];
export function VehicleDetail() {
const { publicRef } = useParams<{ publicRef: string }>();
const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
useEffect(() => {
if (!publicRef) return;
setVehicle(null);
setError(null);
api
.get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`)
.then(setVehicle)
.catch(() => setError("This vehicle could not be found."));
}, [publicRef]);
if (error) return <p className="error" role="alert">{error}</p>;
if (!vehicle) return <p>Loading vehicle</p>;
return (
<div className="page">
<p><Link to="/vehicles"> Back to vehicles</Link></p>
<h1>
{vehicle.public_ref} {vehicle.make} {vehicle.model}
</h1>
<p>
<StatusBadge status={vehicle.operational_status} />
{vehicle.attention && <span className="badge severity-high">Needs attention</span>}
</p>
<div role="tablist" aria-label="Vehicle sections" className="tabs">
{TABS.map((t) => (
<button
key={t}
role="tab"
type="button"
aria-selected={tab === t}
className={tab === t ? "active" : ""}
onClick={() => setTab(t)}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{tab === "overview" && (
<dl className="detail-grid">
<div><dt>Registration</dt><dd>{vehicle.registration_number}</dd></div>
<div><dt>Model year</dt><dd>{vehicle.model_year}</dd></div>
<div><dt>Location</dt><dd>{vehicle.location}</dd></div>
<div><dt>Odometer</dt><dd>{vehicle.odometer_km.toLocaleString("en-GB")} km</dd></div>
<div><dt>Next service</dt><dd>{vehicle.next_service_km.toLocaleString("en-GB")} km</dd></div>
<div><dt>Active</dt><dd>{vehicle.active ? "Yes" : "No"}</dd></div>
</dl>
)}
{tab === "bookings" && (
<ul className="record-list">
{vehicle.bookings.length === 0 && <li>No bookings recorded.</li>}
{vehicle.bookings.map((b) => (
<li key={b.public_ref}>
<Link to={`/bookings/${b.public_ref}`}>{b.public_ref}</Link>
<StatusBadge status={b.status} />
<span>
{new Date(b.starts_at).toLocaleDateString("en-GB")} {new Date(b.ends_at).toLocaleDateString("en-GB")}
</span>
</li>
))}
</ul>
)}
{tab === "inspections" && (
<ul className="record-list">
{vehicle.inspections.length === 0 && <li>No inspections recorded.</li>}
{vehicle.inspections.map((i) => (
<li key={i.public_ref}>
<span>{i.type}</span>
<span>{i.odometer_km.toLocaleString("en-GB")} km</span>
<span>Fuel {i.fuel_level_percent}%</span>
{i.damage_reported && <span className="badge severity-high">Damage</span>}
{i.technical_warning && <span className="badge severity-high">Technical warning</span>}
<time dateTime={i.completed_at}>{new Date(i.completed_at).toLocaleDateString("en-GB")}</time>
</li>
))}
</ul>
)}
{tab === "maintenance" && (
<ul className="record-list">
{vehicle.maintenance.length === 0 && <li>No maintenance records.</li>}
{vehicle.maintenance.map((m) => (
<li key={m.public_ref}>
<span>{m.category}</span>
<span>{m.summary}</span>
<time dateTime={m.occurred_at}>{new Date(m.occurred_at).toLocaleDateString("en-GB")}</time>
</li>
))}
</ul>
)}
{tab === "quality" && (
<ul className="record-list">
{vehicle.quality_issues.length === 0 && <li>No quality issues recorded.</li>}
{vehicle.quality_issues.map((q) => (
<li key={q.public_ref}>
<SeverityBadge severity={q.severity} />
<span>{q.rule_type.replace(/_/g, " ")}</span>
<StatusBadge status={q.status} />
</li>
))}
</ul>
)}
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api/client";
import type { Vehicle } from "../api/types";
import { StatusBadge } from "../components/Badge";
const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "blocked"];
export function Vehicles() {
const [vehicles, setVehicles] = useState<Vehicle[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("");
const [attentionOnly, setAttentionOnly] = useState(false);
useEffect(() => {
const params = new URLSearchParams();
if (status) params.set("status", status);
if (attentionOnly) params.set("attention_only", "true");
api
.get<Vehicle[]>(`/api/v1/vehicles?${params.toString()}`)
.then(setVehicles)
.catch(() => setError("Vehicle list is unavailable right now."));
}, [status, attentionOnly]);
return (
<div className="page">
<h1>Vehicles</h1>
<form className="filters" aria-label="Filter vehicles">
<label>
Status
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All statuses</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={attentionOnly}
onChange={(e) => setAttentionOnly(e.target.checked)}
/>
Attention only
</label>
</form>
{error && <p className="error" role="alert">{error}</p>}
{!error && !vehicles && <p>Loading vehicles</p>}
{vehicles && vehicles.length === 0 && <p>No vehicles match these filters.</p>}
{vehicles && vehicles.length > 0 && (
<table className="data-table">
<caption className="visually-hidden">Vehicle fleet</caption>
<thead>
<tr>
<th scope="col">Reference</th>
<th scope="col">Make / model</th>
<th scope="col">Location</th>
<th scope="col">Status</th>
<th scope="col">Odometer (km)</th>
<th scope="col">Attention</th>
</tr>
</thead>
<tbody>
{vehicles.map((v) => (
<tr key={v.public_ref}>
<th scope="row">
<Link to={`/vehicles/${v.public_ref}`}>{v.public_ref}</Link>
</th>
<td>
{v.make} {v.model} ({v.model_year})
</td>
<td>{v.location}</td>
<td>
<StatusBadge status={v.operational_status} />
</td>
<td>{v.odometer_km.toLocaleString("en-GB")}</td>
<td>{v.attention ? "Needs attention" : "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+175 -10
View File
@@ -6,14 +6,179 @@
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
.shell { width: min(920px, calc(100% - 32px)); margin: 0 auto; padding: 64px 0; }
.hero { margin-bottom: 28px; }
a { color: #1f5c8f; }
:focus-visible { outline: 3px solid #1f5c8f; outline-offset: 2px; }
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.demo-banner {
margin: 0;
padding: 8px 16px;
background: #14324f;
color: #eaf2fb;
font-size: 0.85rem;
text-align: center;
}
.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
.app-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
padding: 14px 24px;
background: white;
border-bottom: 1px solid #dce3eb;
}
.brand { font-weight: 800; font-size: 1.15rem; color: #172033; }
.app-header nav ul {
display: flex;
flex-wrap: wrap;
gap: 4px;
list-style: none;
margin: 0; padding: 0;
}
.app-header nav a {
display: inline-block;
padding: 8px 12px;
border-radius: 8px;
color: #375065;
text-decoration: none;
font-weight: 600;
}
.app-header nav a.active, .app-header nav a[aria-current="page"] {
background: #e5eef9;
color: #14324f;
}
.user-badge { margin-left: auto; display: flex; align-items: center; gap: 12px; font-size: 0.9rem; }
.user-badge button {
padding: 6px 12px;
border-radius: 8px;
border: 1px solid #cfd8e2;
background: white;
cursor: pointer;
}
#main-content { width: min(1080px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; flex: 1; }
.page h1 { margin-top: 0; }
.page section { margin-bottom: 28px; }
.panel {
background: white;
border: 1px solid #dce3eb;
border-radius: 16px;
padding: 20px;
box-shadow: 0 10px 30px rgba(24, 40, 64, .05);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
list-style: none;
margin: 0; padding: 0;
}
.metric-tile {
background: white;
border: 1px solid #dce3eb;
border-radius: 14px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.metric-value { font-size: 1.8rem; font-weight: 800; }
.metric-label { color: #607084; font-size: 0.85rem; }
.attention-list, .today-list, .automation-list, .record-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.attention-list li { display: flex; gap: 12px; align-items: flex-start; padding: 10px 0; border-bottom: 1px solid #edf1f5; }
.attention-title { margin: 0; font-weight: 700; }
.attention-detail { margin: 2px 0 0; color: #607084; font-size: 0.9rem; }
.today-list li, .automation-list li, .record-list li {
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
padding: 8px 0; border-bottom: 1px solid #edf1f5;
}
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid transparent;
white-space: nowrap;
}
.severity-high { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; }
.severity-medium { background: #fdf1de; color: #8a5a10; border-color: #f0d29e; }
.severity-low { background: #eaf1fb; color: #315d73; border-color: #c4d9ec; }
.status-open, .status-pending, .status-active, .status-failed { background: #fdf1de; color: #8a5a10; border-color: #f0d29e; }
.status-failed { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; }
.status-succeeded, .status-resolved, .status-available, .status-returned { background: #e6f5ec; color: #1f6d3d; border-color: #b9e0c9; }
.status-blocked, .status-rejected { background: #fbe6e6; color: #8f2323; border-color: #f2b9b9; }
.status-cancelled, .status-deferred, .status-cleaning, .status-maintenance, .status-rented, .status-reserved, .status-delivering {
background: #eef1f5; color: #47566b; border-color: #d7dfe8;
}
.filters { display: flex; flex-wrap: wrap; gap: 16px; align-items: end; margin-bottom: 16px; }
.filters label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; color: #375065; font-weight: 600; }
.filters select, .filters input[type="text"] { padding: 8px 10px; border: 1px solid #cfd8e2; border-radius: 8px; font-size: 0.95rem; }
.checkbox-label { flex-direction: row !important; align-items: center; gap: 8px !important; }
.data-table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #dce3eb; border-radius: 12px; overflow: hidden; }
.data-table th, .data-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #edf1f5; font-size: 0.92rem; }
.data-table thead th { background: #f6f8fb; color: #47566b; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .04em; }
.tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid #dce3eb; margin: 16px 0; }
.tabs button {
padding: 8px 14px; border: none; background: none; cursor: pointer;
border-bottom: 3px solid transparent; font-weight: 600; color: #607084;
}
.tabs button.active { color: #14324f; border-bottom-color: #1f5c8f; }
.detail-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.detail-grid div { background: white; border: 1px solid #dce3eb; border-radius: 10px; padding: 10px 14px; }
.detail-grid dt { color: #607084; font-size: 0.8rem; margin: 0; }
.detail-grid dd { margin: 4px 0 0; font-weight: 700; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.error { color: #9a2530; font-weight: 600; }
.login-shell { width: min(720px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0; }
.login-hero { margin-bottom: 24px; }
.eyebrow { color: #315d73; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; font-size: .8rem; }
h1 { margin: 6px 0; font-size: clamp(2.4rem, 7vw, 4.8rem); line-height: 1; }
.hero > p:last-child { color: #526173; font-size: 1.2rem; }
.panel { background: white; border: 1px solid #dce3eb; border-radius: 18px; padding: 24px; box-shadow: 0 10px 30px rgba(24,40,64,.06); }
dl { display: grid; gap: 12px; }
dl div { display: flex; justify-content: space-between; gap: 24px; border-bottom: 1px solid #edf1f5; padding-bottom: 10px; }
dt { color: #607084; } dd { margin: 0; font-weight: 700; }
.note { margin-top: 24px; color: #607084; }
.error { color: #9a2530; }
.login-hero h1 { margin: 6px 0; font-size: clamp(2.2rem, 7vw, 3.4rem); line-height: 1; }
.login-options { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.login-options button {
padding: 14px 18px; border-radius: 12px; border: none;
background: #14324f; color: white; font-weight: 700; font-size: 1rem; cursor: pointer;
}
.login-options button:hover, .login-options button:focus-visible { background: #1f5c8f; }
.login-options p { margin: 0 0 8px; color: #607084; font-size: 0.9rem; }
@media (max-width: 700px) {
.app-header { flex-direction: column; align-items: flex-start; }
.user-badge { margin-left: 0; }
.data-table, .data-table thead, .data-table tbody, .data-table th, .data-table td, .data-table tr {
display: block;
}
.data-table thead { display: none; }
.data-table tr { border-bottom: 2px solid #dce3eb; padding: 8px 0; }
.data-table td, .data-table th { border: none; padding: 4px 12px; }
}