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:
@@ -13,3 +13,4 @@ test-results/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.tsbuildinfo
|
||||
|
||||
+21
-2
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
],
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Generated
+59
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user