M18: implement operational workspaces
This commit is contained in:
@@ -2436,3 +2436,26 @@ evidence yet."
|
||||
feedback.
|
||||
- Exact next action: turn the data-quality queue, booking planning, fleet overview and
|
||||
user administration into complete daily operational workspaces.
|
||||
|
||||
## M18 — daily operational workspaces (2026-08-10)
|
||||
|
||||
- Turned data quality into an owned work queue: every newly detected open issue receives
|
||||
a severity-based SLA deadline (4h high, 1d medium, 3d low), managers can filter by
|
||||
assignee/overdue state and assign or reschedule up to 25 selected issues atomically.
|
||||
Every change is row-locked, validated against an active user and independently audited.
|
||||
- Added the PostgreSQL ownership/deadline migration with indexed nullable assignment,
|
||||
`ON DELETE SET NULL`, live-data backfill and deterministic demo-reset deadlines.
|
||||
- Upgraded booking planning with operational-priority ordering, inclusive date-window,
|
||||
location and explicit sort filters plus Today/Upcoming presets. The default no longer
|
||||
leads with the furthest-future booking.
|
||||
- Upgraded the fleet register with exact location filtering, next-booking context,
|
||||
remaining service distance and explicit attention reasons (blocked, service due or
|
||||
open quality issue) instead of one unexplained warning label.
|
||||
- Completed user administration: managers can now edit names/roles, reset passwords and
|
||||
activate/deactivate accounts from the UI; existing self-demotion/deactivation guards
|
||||
and auditing remain authoritative in the API.
|
||||
- Evidence: frontend TypeScript production build passed; ruff and mypy passed; focused
|
||||
PostgreSQL suites **58 passed**; migration applied in the isolated stack; OpenAPI was
|
||||
regenerated. E2E coverage now includes bulk queue assignment and full user editing.
|
||||
- Exact next action: split the frontend bundle, harden mobile layout and operational
|
||||
backup/deployment controls, then run clean full acceptance and redeploy.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""add ownership and SLA deadlines to data quality issues
|
||||
|
||||
Revision ID: f43d829ab610
|
||||
Revises: d1f83bc64170
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "f43d829ab610"
|
||||
down_revision = "d1f83bc64170"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"data_quality_issues",
|
||||
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"data_quality_issues",
|
||||
sa.Column("assigned_to_user_id", sa.Uuid(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_data_quality_issues_assigned_user",
|
||||
"data_quality_issues",
|
||||
"users",
|
||||
["assigned_to_user_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index("ix_data_quality_issues_due_at", "data_quality_issues", ["due_at"])
|
||||
op.create_index(
|
||||
"ix_data_quality_issues_assigned_to_user_id",
|
||||
"data_quality_issues",
|
||||
["assigned_to_user_id"],
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE data_quality_issues
|
||||
SET due_at = detected_at + CASE severity
|
||||
WHEN 'high' THEN interval '4 hours'
|
||||
WHEN 'low' THEN interval '3 days'
|
||||
ELSE interval '1 day'
|
||||
END
|
||||
WHERE status = 'open' AND due_at IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_data_quality_issues_assigned_to_user_id",
|
||||
table_name="data_quality_issues",
|
||||
)
|
||||
op.drop_index("ix_data_quality_issues_due_at", table_name="data_quality_issues")
|
||||
op.drop_constraint(
|
||||
"fk_data_quality_issues_assigned_user",
|
||||
"data_quality_issues",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_column("data_quality_issues", "assigned_to_user_id")
|
||||
op.drop_column("data_quality_issues", "due_at")
|
||||
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
@@ -52,12 +53,16 @@ def list_bookings(
|
||||
status: str | None = Query(default=None),
|
||||
vehicle_ref: str | None = Query(default=None),
|
||||
query: str | None = Query(default=None, min_length=1, max_length=100),
|
||||
starts_from: datetime | None = Query(default=None),
|
||||
starts_to: datetime | None = Query(default=None),
|
||||
location: str | None = Query(default=None, min_length=1, max_length=120),
|
||||
sort: Literal["operational", "starts_asc", "starts_desc"] = Query(default="operational"),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=25),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[BookingOut] | BookingPageOut:
|
||||
stmt = select(Booking).order_by(Booking.starts_at.desc())
|
||||
stmt = select(Booking)
|
||||
if status:
|
||||
stmt = stmt.where(Booking.status == status)
|
||||
if vehicle_ref:
|
||||
@@ -65,20 +70,42 @@ def list_bookings(
|
||||
if vehicle is None:
|
||||
return []
|
||||
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
|
||||
if starts_from:
|
||||
stmt = stmt.where(Booking.ends_at >= starts_from)
|
||||
if starts_to:
|
||||
stmt = stmt.where(Booking.starts_at < starts_to)
|
||||
if query or location:
|
||||
stmt = stmt.join(Customer, Booking.customer_id == Customer.id).join(
|
||||
Vehicle, Booking.vehicle_id == Vehicle.id
|
||||
)
|
||||
if location:
|
||||
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
|
||||
if query:
|
||||
term = f"%{query.strip()}%"
|
||||
stmt = (
|
||||
stmt.join(Customer, Booking.customer_id == Customer.id)
|
||||
.join(Vehicle, Booking.vehicle_id == Vehicle.id)
|
||||
.where(
|
||||
or_(
|
||||
Booking.public_ref.ilike(term),
|
||||
Customer.first_name.ilike(term),
|
||||
Customer.last_name.ilike(term),
|
||||
Vehicle.public_ref.ilike(term),
|
||||
)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Booking.public_ref.ilike(term),
|
||||
Customer.first_name.ilike(term),
|
||||
Customer.last_name.ilike(term),
|
||||
Vehicle.public_ref.ilike(term),
|
||||
)
|
||||
)
|
||||
if sort == "starts_asc":
|
||||
stmt = stmt.order_by(Booking.starts_at.asc())
|
||||
elif sort == "starts_desc":
|
||||
stmt = stmt.order_by(Booking.starts_at.desc())
|
||||
else:
|
||||
now = datetime.now(UTC)
|
||||
operational_bucket = case(
|
||||
(Booking.status == "active", 0),
|
||||
(Booking.starts_at >= now, 1),
|
||||
else_=2,
|
||||
)
|
||||
stmt = stmt.order_by(
|
||||
operational_bucket,
|
||||
case((Booking.starts_at >= now, Booking.starts_at)).asc().nulls_last(),
|
||||
Booking.starts_at.desc(),
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
bookings = db.scalars(
|
||||
@@ -164,9 +191,7 @@ def checkout_booking(
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CheckoutBookingResult:
|
||||
booking = db.scalar(
|
||||
select(Booking).where(Booking.public_ref == public_ref).with_for_update()
|
||||
)
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||
if booking is None:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
if booking.status != "reserved":
|
||||
@@ -324,9 +349,7 @@ def cancel_booking(
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> BookingOut:
|
||||
booking = db.scalar(
|
||||
select(Booking).where(Booking.public_ref == public_ref).with_for_update()
|
||||
)
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||
if booking is None:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
if booking.status != "reserved":
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
@@ -9,10 +11,13 @@ 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.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
ApplyRecommendedStatusRequest,
|
||||
ApplyRecommendedStatusResult,
|
||||
BulkDataQualityWorkRequest,
|
||||
BulkDataQualityWorkResult,
|
||||
CurrentUser,
|
||||
DataQualityIssueDetailOut,
|
||||
DataQualityIssueOut,
|
||||
@@ -26,6 +31,7 @@ from app.schemas import (
|
||||
StatusRecommendationOut,
|
||||
VehicleStatusFactsOut,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import (
|
||||
apply_recommended_status,
|
||||
defer_issue,
|
||||
@@ -42,6 +48,7 @@ router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
|
||||
|
||||
|
||||
def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
|
||||
assignee = issue.assigned_to_user
|
||||
return DataQualityIssueOut(
|
||||
public_ref=issue.public_ref,
|
||||
rule_type=issue.rule_type,
|
||||
@@ -51,6 +58,12 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
|
||||
status=issue.status,
|
||||
evidence=issue.evidence_json,
|
||||
detected_at=issue.detected_at,
|
||||
due_at=issue.due_at,
|
||||
assigned_to_ref=assignee.public_ref if assignee else None,
|
||||
assigned_to_name=assignee.display_name if assignee else None,
|
||||
overdue=(
|
||||
issue.status == "open" and issue.due_at is not None and issue.due_at < datetime.now(UTC)
|
||||
),
|
||||
resolved_at=issue.resolved_at,
|
||||
)
|
||||
|
||||
@@ -60,18 +73,40 @@ def list_issues(
|
||||
status: str | None = Query(default=None),
|
||||
rule_type: str | None = Query(default=None),
|
||||
severity: str | None = Query(default=None),
|
||||
assigned_to_ref: str | None = Query(default=None),
|
||||
overdue: bool | None = Query(default=None),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=25),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
|
||||
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
||||
severity_order = case(
|
||||
(DataQualityIssue.severity == "high", 0),
|
||||
(DataQualityIssue.severity == "medium", 1),
|
||||
else_=2,
|
||||
)
|
||||
stmt = select(DataQualityIssue).order_by(
|
||||
DataQualityIssue.due_at.asc().nulls_last(),
|
||||
severity_order,
|
||||
DataQualityIssue.detected_at.desc(),
|
||||
)
|
||||
if status:
|
||||
stmt = stmt.where(DataQualityIssue.status == status)
|
||||
if rule_type:
|
||||
stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
|
||||
if severity:
|
||||
stmt = stmt.where(DataQualityIssue.severity == severity)
|
||||
if assigned_to_ref == "unassigned":
|
||||
stmt = stmt.where(DataQualityIssue.assigned_to_user_id.is_(None))
|
||||
elif assigned_to_ref:
|
||||
stmt = stmt.join(DataQualityIssue.assigned_to_user).where(
|
||||
User.public_ref == assigned_to_ref
|
||||
)
|
||||
if overdue is True:
|
||||
stmt = stmt.where(
|
||||
DataQualityIssue.status == "open",
|
||||
DataQualityIssue.due_at < datetime.now(UTC),
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
issues = db.scalars(
|
||||
@@ -90,6 +125,90 @@ def list_issues(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/issues/bulk-work", response_model=BulkDataQualityWorkResult)
|
||||
def update_issue_work_queue(
|
||||
body: BulkDataQualityWorkRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> BulkDataQualityWorkResult:
|
||||
refs = list(dict.fromkeys(body.issue_refs))
|
||||
if (
|
||||
body.assigned_to_ref is None
|
||||
and not body.clear_assignment
|
||||
and body.due_at is None
|
||||
and not body.clear_due_at
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="No work queue change was requested")
|
||||
if body.assigned_to_ref is not None and body.clear_assignment:
|
||||
raise HTTPException(status_code=422, detail="Choose an assignee or clear assignment")
|
||||
if body.due_at is not None and body.clear_due_at:
|
||||
raise HTTPException(status_code=422, detail="Choose a due date or clear the due date")
|
||||
if body.due_at is not None and body.due_at.tzinfo is None:
|
||||
raise HTTPException(status_code=422, detail="Due date must include a timezone")
|
||||
|
||||
assignee = None
|
||||
if body.assigned_to_ref is not None:
|
||||
assignee = db.scalar(
|
||||
select(User).where(
|
||||
User.public_ref == body.assigned_to_ref,
|
||||
User.active.is_(True),
|
||||
)
|
||||
)
|
||||
if assignee is None:
|
||||
raise HTTPException(status_code=422, detail="Active assignee not found")
|
||||
|
||||
issues = list(
|
||||
db.scalars(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref.in_(refs)).with_for_update()
|
||||
).all()
|
||||
)
|
||||
if len(issues) != len(refs):
|
||||
found = {issue.public_ref for issue in issues}
|
||||
missing = next(ref for ref in refs if ref not in found)
|
||||
raise HTTPException(status_code=404, detail=f"Data quality issue {missing} not found")
|
||||
|
||||
for issue in issues:
|
||||
if issue.status != "open":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Data quality issue {issue.public_ref} is not open",
|
||||
)
|
||||
before = {
|
||||
"assigned_to_ref": issue.assigned_to_user.public_ref
|
||||
if issue.assigned_to_user
|
||||
else None,
|
||||
"due_at": issue.due_at.isoformat() if issue.due_at else None,
|
||||
}
|
||||
if body.assigned_to_ref is not None:
|
||||
issue.assigned_to_user = assignee
|
||||
elif body.clear_assignment:
|
||||
issue.assigned_to_user = None
|
||||
if body.due_at is not None:
|
||||
issue.due_at = body.due_at
|
||||
elif body.clear_due_at:
|
||||
issue.due_at = None
|
||||
after = {
|
||||
"assigned_to_ref": assignee.public_ref
|
||||
if body.assigned_to_ref is not None and assignee
|
||||
else (None if body.clear_assignment else before["assigned_to_ref"]),
|
||||
"due_at": issue.due_at.isoformat() if issue.due_at else None,
|
||||
}
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="data_quality_work_updated",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
before=before,
|
||||
after=after,
|
||||
)
|
||||
db.commit()
|
||||
for issue in issues:
|
||||
db.refresh(issue)
|
||||
return BulkDataQualityWorkResult(updated=[_to_out(issue) for issue in issues])
|
||||
|
||||
|
||||
# Every public reference in this system carries its entity type in its own prefix
|
||||
# (CUS-/MO-/BK-/INSP-/DQ-). Related-entity typing is resolved from the reference itself,
|
||||
# not guessed from the issue's rule_type -- a booking_overlap issue's related refs are
|
||||
@@ -239,9 +358,7 @@ def provide_fields(
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut
|
||||
)
|
||||
@router.post("/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut)
|
||||
def resolve_odometer(
|
||||
public_ref: str,
|
||||
body: ResolveOdometerRegressionRequest,
|
||||
@@ -263,9 +380,7 @@ def resolve_overlap(
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut
|
||||
)
|
||||
@router.post("/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut)
|
||||
def status_recommendation(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
@@ -44,6 +45,7 @@ def _attention_vehicle_ids(db: Session) -> set:
|
||||
def list_vehicles(
|
||||
status: str | None = Query(default=None),
|
||||
attention_only: bool = Query(default=False),
|
||||
location: str | None = Query(default=None, min_length=1, max_length=120),
|
||||
query: str | None = Query(default=None, min_length=1, max_length=100),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=25),
|
||||
@@ -53,6 +55,8 @@ def list_vehicles(
|
||||
stmt = select(Vehicle).order_by(Vehicle.public_ref)
|
||||
if status:
|
||||
stmt = stmt.where(Vehicle.operational_status == status)
|
||||
if location:
|
||||
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
|
||||
if query:
|
||||
term = f"%{query.strip()}%"
|
||||
stmt = stmt.where(
|
||||
@@ -67,13 +71,30 @@ def list_vehicles(
|
||||
attention_ids = _attention_vehicle_ids(db)
|
||||
if attention_only:
|
||||
stmt = stmt.where(
|
||||
or_(Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked")
|
||||
or_(
|
||||
Vehicle.id.in_(attention_ids),
|
||||
Vehicle.operational_status == "blocked",
|
||||
Vehicle.next_service_km <= Vehicle.odometer_km,
|
||||
)
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
vehicles = db.scalars(
|
||||
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||
).all()
|
||||
vehicle_ids = [vehicle.id for vehicle in vehicles]
|
||||
next_bookings: dict[uuid.UUID, Booking] = {}
|
||||
if vehicle_ids:
|
||||
for booking in db.scalars(
|
||||
select(Booking)
|
||||
.where(
|
||||
Booking.vehicle_id.in_(vehicle_ids),
|
||||
Booking.status == "reserved",
|
||||
Booking.starts_at >= datetime.now(UTC),
|
||||
)
|
||||
.order_by(Booking.starts_at.asc())
|
||||
).all():
|
||||
next_bookings.setdefault(booking.vehicle_id, booking)
|
||||
items = [
|
||||
VehicleOut(
|
||||
public_ref=v.public_ref,
|
||||
@@ -86,7 +107,23 @@ def list_vehicles(
|
||||
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",
|
||||
attention=(
|
||||
v.id in attention_ids
|
||||
or v.operational_status == "blocked"
|
||||
or v.next_service_km <= v.odometer_km
|
||||
),
|
||||
attention_reason=(
|
||||
"blocked_status"
|
||||
if v.operational_status == "blocked"
|
||||
else "service_due"
|
||||
if v.next_service_km <= v.odometer_km
|
||||
else "data_quality"
|
||||
if v.id in attention_ids
|
||||
else None
|
||||
),
|
||||
service_remaining_km=v.next_service_km - v.odometer_km,
|
||||
next_booking_ref=(next_bookings[v.id].public_ref if v.id in next_bookings else None),
|
||||
next_booking_at=(next_bookings[v.id].starts_at if v.id in next_bookings else None),
|
||||
)
|
||||
for v in vehicles
|
||||
]
|
||||
@@ -133,6 +170,14 @@ def get_vehicle(
|
||||
).all()
|
||||
|
||||
booking_by_id = {b.id: b.public_ref for b in bookings}
|
||||
next_booking = next(
|
||||
(
|
||||
booking
|
||||
for booking in sorted(bookings, key=lambda item: item.starts_at)
|
||||
if booking.status == "reserved" and booking.starts_at >= datetime.now(UTC)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
attention_ids = _attention_vehicle_ids(db)
|
||||
return VehicleDetailOut(
|
||||
@@ -146,7 +191,23 @@ def get_vehicle(
|
||||
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",
|
||||
attention=(
|
||||
vehicle.id in attention_ids
|
||||
or vehicle.operational_status == "blocked"
|
||||
or vehicle.next_service_km <= vehicle.odometer_km
|
||||
),
|
||||
attention_reason=(
|
||||
"blocked_status"
|
||||
if vehicle.operational_status == "blocked"
|
||||
else "service_due"
|
||||
if vehicle.next_service_km <= vehicle.odometer_km
|
||||
else "data_quality"
|
||||
if vehicle.id in attention_ids
|
||||
else None
|
||||
),
|
||||
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
|
||||
next_booking_ref=next_booking.public_ref if next_booking else None,
|
||||
next_booking_at=next_booking.starts_at if next_booking else None,
|
||||
bookings=[
|
||||
BookingSummaryOut(
|
||||
public_ref=b.public_ref,
|
||||
@@ -192,6 +253,12 @@ def get_vehicle(
|
||||
status=q.status,
|
||||
evidence=q.evidence_json,
|
||||
detected_at=q.detected_at,
|
||||
due_at=q.due_at,
|
||||
assigned_to_ref=(q.assigned_to_user.public_ref if q.assigned_to_user else None),
|
||||
assigned_to_name=(q.assigned_to_user.display_name if q.assigned_to_user else None),
|
||||
overdue=(
|
||||
q.status == "open" and q.due_at is not None and q.due_at < datetime.now(UTC)
|
||||
),
|
||||
resolved_at=q.resolved_at,
|
||||
)
|
||||
for q in issues
|
||||
@@ -296,5 +363,11 @@ def release_vehicle(
|
||||
odometer_km=vehicle.odometer_km,
|
||||
next_service_km=vehicle.next_service_km,
|
||||
active=vehicle.active,
|
||||
attention=False,
|
||||
attention=vehicle.next_service_km <= vehicle.odometer_km,
|
||||
attention_reason=(
|
||||
"service_due" if vehicle.next_service_km <= vehicle.odometer_km else None
|
||||
),
|
||||
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
|
||||
next_booking_ref=None,
|
||||
next_booking_at=None,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db import Base
|
||||
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
RULE_TYPES = (
|
||||
"possible_duplicate_customer",
|
||||
"missing_required_field",
|
||||
@@ -31,5 +35,10 @@ class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
proposed_action_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
assigned_to_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
assigned_to_user: Mapped["User | None"] = relationship(lazy="selectin")
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
resolved_by: Mapped[str | None] = mapped_column(String(120))
|
||||
|
||||
@@ -57,6 +57,10 @@ class VehicleOut(BaseModel):
|
||||
next_service_km: int
|
||||
active: bool
|
||||
attention: bool = False
|
||||
attention_reason: str | None = None
|
||||
service_remaining_km: int
|
||||
next_booking_ref: str | None = None
|
||||
next_booking_at: datetime | None = None
|
||||
|
||||
|
||||
class VehiclePageOut(BaseModel):
|
||||
@@ -221,6 +225,10 @@ class DataQualityIssueOut(BaseModel):
|
||||
status: str
|
||||
evidence: dict[str, Any]
|
||||
detected_at: datetime
|
||||
due_at: datetime | None = None
|
||||
assigned_to_ref: str | None = None
|
||||
assigned_to_name: str | None = None
|
||||
overdue: bool = False
|
||||
resolved_at: datetime | None = None
|
||||
|
||||
|
||||
@@ -237,6 +245,18 @@ class DataQualityIssueDetailOut(DataQualityIssueOut):
|
||||
related_snapshots: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BulkDataQualityWorkRequest(BaseModel):
|
||||
issue_refs: list[str] = Field(min_length=1, max_length=25)
|
||||
assigned_to_ref: str | None = Field(default=None, min_length=3, max_length=20)
|
||||
clear_assignment: bool = False
|
||||
due_at: datetime | None = None
|
||||
clear_due_at: bool = False
|
||||
|
||||
|
||||
class BulkDataQualityWorkResult(BaseModel):
|
||||
updated: list[DataQualityIssueOut]
|
||||
|
||||
|
||||
class MergeCustomersRequest(BaseModel):
|
||||
survivor_ref: str
|
||||
field_overrides: dict[str, str] | None = None
|
||||
|
||||
@@ -106,7 +106,9 @@ def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> N
|
||||
):
|
||||
db.execute(delete(model))
|
||||
if preserve_integration_telemetry:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.action.not_in(_PERSISTENT_TELEMETRY_ACTIONS)))
|
||||
db.execute(
|
||||
delete(AuditEvent).where(AuditEvent.action.not_in(_PERSISTENT_TELEMETRY_ACTIONS))
|
||||
)
|
||||
else:
|
||||
db.execute(delete(AuditEvent))
|
||||
|
||||
@@ -116,9 +118,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
today = datetime.now(UTC).date()
|
||||
shift = _seed_anchor_shift(today)
|
||||
|
||||
user_rows = [
|
||||
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
|
||||
]
|
||||
user_rows = [{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS]
|
||||
db.execute(insert(User), user_rows)
|
||||
counts["users"] = len(user_rows)
|
||||
|
||||
@@ -354,6 +354,11 @@ def load_seed(db: Session) -> SeedResult:
|
||||
entity_type, entity_id = resolve_entity(row["entity_ref"])
|
||||
related_ref = row.get("related_ref") or ""
|
||||
related_refs = related_ref.split("|") if related_ref else []
|
||||
severity_due_delta = {
|
||||
"high": timedelta(hours=4),
|
||||
"medium": timedelta(days=1),
|
||||
"low": timedelta(days=3),
|
||||
}.get(row["severity"], timedelta(days=1))
|
||||
dq_rows.append(
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
@@ -371,6 +376,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
},
|
||||
"proposed_action_json": {},
|
||||
"detected_at": now,
|
||||
"due_at": now + severity_due_delta if row["status"] == "open" else None,
|
||||
"resolved_at": now if row["status"] == "resolved" else None,
|
||||
"resolved_by": "USR-OPS" if row["status"] == "resolved" else None,
|
||||
}
|
||||
@@ -443,9 +449,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
|
||||
|
||||
|
||||
def reset_and_seed(
|
||||
db: Session, *, preserve_integration_telemetry: bool = False
|
||||
) -> SeedResult:
|
||||
def reset_and_seed(db: Session, *, preserve_integration_telemetry: bool = False) -> SeedResult:
|
||||
from app.services.data_quality import run_scan
|
||||
|
||||
clear_all(db, preserve_integration_telemetry=preserve_integration_telemetry)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -28,6 +28,15 @@ REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
|
||||
DUPLICATE_THRESHOLD = 70
|
||||
|
||||
|
||||
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
|
||||
"""Return the local operational SLA deadline for a newly detected issue."""
|
||||
return detected_at + {
|
||||
"high": timedelta(hours=4),
|
||||
"medium": timedelta(days=1),
|
||||
"low": timedelta(days=3),
|
||||
}.get(severity, timedelta(days=1))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResult:
|
||||
created: dict[str, int] = field(default_factory=dict)
|
||||
@@ -112,6 +121,7 @@ def _open_issue(
|
||||
evidence_json=evidence,
|
||||
proposed_action_json={},
|
||||
detected_at=now,
|
||||
due_at=issue_due_at(now, severity),
|
||||
)
|
||||
db.add(issue)
|
||||
db.flush()
|
||||
@@ -280,9 +290,7 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
||||
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
|
||||
bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
|
||||
for booking in db.scalars(
|
||||
select(Booking).where(
|
||||
Booking.status == "returned", Booking.end_odometer_km.is_not(None)
|
||||
)
|
||||
select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None))
|
||||
).all():
|
||||
bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
|
||||
|
||||
@@ -346,9 +354,7 @@ def run_scan(
|
||||
|
||||
|
||||
def _load_open_issue(db: Session, public_ref: str) -> DataQualityIssue:
|
||||
issue = db.scalar(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
|
||||
)
|
||||
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
|
||||
if issue is None:
|
||||
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
|
||||
if issue.status != "open":
|
||||
@@ -750,9 +756,7 @@ def apply_recommended_status(
|
||||
# recommendation alone for the post-condition.
|
||||
post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||
post_check = evaluate_vehicle_status(vehicle, post_facts)
|
||||
if post_check.recommendation_code not in (
|
||||
RECOMMENDATION_CODE_NO_CONFLICT,
|
||||
):
|
||||
if post_check.recommendation_code not in (RECOMMENDATION_CODE_NO_CONFLICT,):
|
||||
raise AppError(
|
||||
"CONFLICT_STILL_PRESENT",
|
||||
"Applying the recommended status did not resolve the conflict.",
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -255,6 +255,7 @@ def register_vehicle_return(
|
||||
},
|
||||
proposed_action_json={},
|
||||
detected_at=now,
|
||||
due_at=now + timedelta(days=1),
|
||||
)
|
||||
db.add(issue)
|
||||
db.flush()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -27,6 +28,23 @@ def test_list_bookings_supports_bounded_search_pages(ops_client):
|
||||
assert len(body["items"]) == 25
|
||||
|
||||
|
||||
def test_list_bookings_filters_operational_window_and_location(ops_client):
|
||||
booking = ops_client.get("/api/v1/bookings").json()[0]
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{booking['vehicle_ref']}").json()
|
||||
starts_at = datetime.fromisoformat(booking["starts_at"])
|
||||
response = ops_client.get(
|
||||
"/api/v1/bookings",
|
||||
params={
|
||||
"starts_from": (starts_at - timedelta(minutes=1)).isoformat(),
|
||||
"starts_to": (starts_at + timedelta(minutes=1)).isoformat(),
|
||||
"location": vehicle["location"],
|
||||
"sort": "starts_asc",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert booking["public_ref"] in {item["public_ref"] for item in response.json()}
|
||||
|
||||
|
||||
def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
|
||||
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
|
||||
conflict = ops_client.post(
|
||||
@@ -39,9 +57,9 @@ def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
|
||||
},
|
||||
)
|
||||
assert conflict.status_code == 409
|
||||
available_vehicle = ops_client.get(
|
||||
"/api/v1/vehicles", params={"status": "available"}
|
||||
).json()[0]["public_ref"]
|
||||
available_vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[
|
||||
0
|
||||
]["public_ref"]
|
||||
created = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
@@ -115,11 +133,11 @@ def test_concurrent_bookings_only_reserve_vehicle_once():
|
||||
client.post("/api/v1/demo/login", json={"role": "operations_manager"})
|
||||
response = client.post(
|
||||
"/api/v1/bookings",
|
||||
json={
|
||||
"customer_ref": "CUS-0001",
|
||||
"vehicle_ref": vehicle_ref,
|
||||
**window,
|
||||
},
|
||||
json={
|
||||
"customer_ref": "CUS-0001",
|
||||
"vehicle_ref": vehicle_ref,
|
||||
**window,
|
||||
},
|
||||
)
|
||||
results.append(response.status_code)
|
||||
|
||||
|
||||
@@ -53,6 +53,49 @@ def test_list_issues_requires_operations_manager(employee_client):
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_manager_can_assign_prioritised_quality_work_and_filter_it(ops_client):
|
||||
assignee = next(user for user in ops_client.get("/api/v1/users").json() if user["active"])
|
||||
open_issues = ops_client.get("/api/v1/data-quality/issues", params={"status": "open"}).json()
|
||||
refs = [issue["public_ref"] for issue in open_issues[:2]]
|
||||
due_at = "2030-01-15T12:00:00+00:00"
|
||||
updated = ops_client.post(
|
||||
"/api/v1/data-quality/issues/bulk-work",
|
||||
json={
|
||||
"issue_refs": refs,
|
||||
"assigned_to_ref": assignee["public_ref"],
|
||||
"due_at": due_at,
|
||||
},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert {issue["public_ref"] for issue in updated.json()["updated"]} == set(refs)
|
||||
assert all(
|
||||
issue["assigned_to_ref"] == assignee["public_ref"] for issue in updated.json()["updated"]
|
||||
)
|
||||
|
||||
filtered = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "assigned_to_ref": assignee["public_ref"]},
|
||||
).json()
|
||||
assert set(refs).issubset({issue["public_ref"] for issue in filtered})
|
||||
audits = ops_client.get("/api/v1/audit", params={"action": "data_quality_work_updated"}).json()
|
||||
assert len(audits) >= 2
|
||||
|
||||
cleared = ops_client.post(
|
||||
"/api/v1/data-quality/issues/bulk-work",
|
||||
json={"issue_refs": refs, "clear_assignment": True},
|
||||
)
|
||||
assert cleared.status_code == 200
|
||||
assert all(issue["assigned_to_ref"] is None for issue in cleared.json()["updated"])
|
||||
|
||||
|
||||
def test_employee_cannot_assign_quality_work(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/data-quality/issues/bulk-work",
|
||||
json={"issue_refs": ["DQ-DEMO-OVERLAP"], "clear_assignment": True},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_issue_page_preserves_severity_filter_and_limits_results(ops_client):
|
||||
response = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
|
||||
@@ -12,6 +12,18 @@ def test_attention_only_filters_flagged_vehicles(ops_client):
|
||||
vehicles = response.json()
|
||||
assert len(vehicles) > 0
|
||||
assert all(v["attention"] for v in vehicles)
|
||||
assert all(v["attention_reason"] for v in vehicles)
|
||||
|
||||
|
||||
def test_vehicle_list_exposes_planning_and_service_context(ops_client):
|
||||
vehicles = ops_client.get("/api/v1/vehicles").json()
|
||||
assert vehicles
|
||||
assert all("service_remaining_km" in vehicle for vehicle in vehicles)
|
||||
assert all("next_booking_ref" in vehicle for vehicle in vehicles)
|
||||
location = vehicles[0]["location"]
|
||||
filtered = ops_client.get("/api/v1/vehicles", params={"location": location}).json()
|
||||
assert filtered
|
||||
assert all(vehicle["location"].casefold() == location.casefold() for vehicle in filtered)
|
||||
|
||||
|
||||
def test_vehicle_page_preserves_filters_and_limits_rendered_records(ops_client):
|
||||
@@ -56,8 +68,7 @@ def test_manager_can_record_maintenance_and_release_vehicle(ops_client):
|
||||
detail = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
|
||||
assert detail["operational_status"] == "maintenance"
|
||||
assert any(
|
||||
item["public_ref"] == response.json()["public_ref"]
|
||||
for item in detail["maintenance"]
|
||||
item["public_ref"] == response.json()["public_ref"] for item in detail["maintenance"]
|
||||
)
|
||||
released = ops_client.post(
|
||||
f"/api/v1/vehicles/{vehicle['public_ref']}/release",
|
||||
|
||||
@@ -227,6 +227,16 @@ paths:
|
||||
type: boolean
|
||||
default: false
|
||||
title: Attention Only
|
||||
- name: location
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
- type: 'null'
|
||||
title: Location
|
||||
- name: query
|
||||
in: query
|
||||
required: false
|
||||
@@ -396,6 +406,45 @@ paths:
|
||||
maxLength: 100
|
||||
- type: 'null'
|
||||
title: Query
|
||||
- name: starts_from
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
title: Starts From
|
||||
- name: starts_to
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
title: Starts To
|
||||
- name: location
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
- type: 'null'
|
||||
title: Location
|
||||
- name: sort
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
enum:
|
||||
- operational
|
||||
- starts_asc
|
||||
- starts_desc
|
||||
type: string
|
||||
default: operational
|
||||
title: Sort
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
@@ -847,6 +896,22 @@ paths:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Severity
|
||||
- name: assigned_to_ref
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Assigned To Ref
|
||||
- name: overdue
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: boolean
|
||||
- type: 'null'
|
||||
title: Overdue
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
@@ -883,6 +948,31 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/v1/data-quality/issues/bulk-work:
|
||||
post:
|
||||
tags:
|
||||
- data-quality
|
||||
summary: Update Issue Work Queue
|
||||
operationId: update_issue_work_queue_api_v1_data_quality_issues_bulk_work_post
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BulkDataQualityWorkRequest'
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/BulkDataQualityWorkResult'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/v1/data-quality/issues/{public_ref}:
|
||||
get:
|
||||
tags:
|
||||
@@ -2276,6 +2366,51 @@ components:
|
||||
- ends_at
|
||||
- status
|
||||
title: BookingSummaryOut
|
||||
BulkDataQualityWorkRequest:
|
||||
properties:
|
||||
issue_refs:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
maxItems: 25
|
||||
minItems: 1
|
||||
title: Issue Refs
|
||||
assigned_to_ref:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 20
|
||||
minLength: 3
|
||||
- type: 'null'
|
||||
title: Assigned To Ref
|
||||
clear_assignment:
|
||||
type: boolean
|
||||
title: Clear Assignment
|
||||
default: false
|
||||
due_at:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
title: Due At
|
||||
clear_due_at:
|
||||
type: boolean
|
||||
title: Clear Due At
|
||||
default: false
|
||||
type: object
|
||||
required:
|
||||
- issue_refs
|
||||
title: BulkDataQualityWorkRequest
|
||||
BulkDataQualityWorkResult:
|
||||
properties:
|
||||
updated:
|
||||
items:
|
||||
$ref: '#/components/schemas/DataQualityIssueOut'
|
||||
type: array
|
||||
title: Updated
|
||||
type: object
|
||||
required:
|
||||
- updated
|
||||
title: BulkDataQualityWorkResult
|
||||
CancelBookingRequest:
|
||||
properties:
|
||||
reason:
|
||||
@@ -2584,6 +2719,26 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
title: Detected At
|
||||
due_at:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
title: Due At
|
||||
assigned_to_ref:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Assigned To Ref
|
||||
assigned_to_name:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Assigned To Name
|
||||
overdue:
|
||||
type: boolean
|
||||
title: Overdue
|
||||
default: false
|
||||
resolved_at:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -2641,6 +2796,26 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
title: Detected At
|
||||
due_at:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
title: Due At
|
||||
assigned_to_ref:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Assigned To Ref
|
||||
assigned_to_name:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Assigned To Name
|
||||
overdue:
|
||||
type: boolean
|
||||
title: Overdue
|
||||
default: false
|
||||
resolved_at:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -3968,6 +4143,25 @@ components:
|
||||
type: boolean
|
||||
title: Attention
|
||||
default: false
|
||||
attention_reason:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Attention Reason
|
||||
service_remaining_km:
|
||||
type: integer
|
||||
title: Service Remaining Km
|
||||
next_booking_ref:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Next Booking Ref
|
||||
next_booking_at:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
title: Next Booking At
|
||||
bookings:
|
||||
items:
|
||||
$ref: '#/components/schemas/BookingSummaryOut'
|
||||
@@ -4000,6 +4194,7 @@ components:
|
||||
- odometer_km
|
||||
- next_service_km
|
||||
- active
|
||||
- service_remaining_km
|
||||
title: VehicleDetailOut
|
||||
VehicleOut:
|
||||
properties:
|
||||
@@ -4037,6 +4232,25 @@ components:
|
||||
type: boolean
|
||||
title: Attention
|
||||
default: false
|
||||
attention_reason:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Attention Reason
|
||||
service_remaining_km:
|
||||
type: integer
|
||||
title: Service Remaining Km
|
||||
next_booking_ref:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Next Booking Ref
|
||||
next_booking_at:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
- type: 'null'
|
||||
title: Next Booking At
|
||||
type: object
|
||||
required:
|
||||
- public_ref
|
||||
@@ -4049,6 +4263,7 @@ components:
|
||||
- odometer_km
|
||||
- next_service_km
|
||||
- active
|
||||
- service_remaining_km
|
||||
title: VehicleOut
|
||||
VehiclePageOut:
|
||||
properties:
|
||||
|
||||
@@ -33,7 +33,7 @@ test("operator can create and cancel a booking through the UI", async ({ page, r
|
||||
await expect(page.getByText("geannuleerd", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("manager can create and deactivate an operational user", async ({ page, request }) => {
|
||||
test("manager can create, edit and deactivate an operational user", async ({ page, request }) => {
|
||||
await reset(request);
|
||||
await login(page);
|
||||
await page.goto("/users");
|
||||
@@ -41,12 +41,33 @@ test("manager can create and deactivate an operational user", async ({ page, req
|
||||
await page.getByLabel("E-mail").fill("e2e.planner@example.test");
|
||||
await page.getByLabel("Tijdelijk wachtwoord").fill("secure-e2e-password");
|
||||
await page.getByRole("button", { name: "Gebruiker toevoegen" }).click();
|
||||
const row = page.getByRole("row", { name: /E2E Planner/ });
|
||||
let row = page.getByRole("row", { name: /E2E Planner/ });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole("button", { name: "Bewerken" }).click();
|
||||
const editor = page.locator(".user-edit");
|
||||
await editor.getByLabel("Naam").fill("E2E Senior Planner");
|
||||
await editor.getByLabel("Rol").selectOption("operations_manager");
|
||||
await editor.getByLabel("Nieuw wachtwoord").fill("updated-e2e-password");
|
||||
await editor.getByRole("button", { name: "Wijzigingen opslaan" }).click();
|
||||
row = page.getByRole("row", { name: /E2E Senior Planner/ });
|
||||
await expect(row.getByText("Operationeel beheerder")).toBeVisible();
|
||||
await row.getByRole("button", { name: "Deactiveren" }).click();
|
||||
await expect(row.getByText("inactief", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("manager can assign data-quality work from the queue", async ({ page, request }) => {
|
||||
await reset(request);
|
||||
await login(page);
|
||||
await page.goto("/data-quality?status=open");
|
||||
const firstRow = page.locator(".data-table tbody tr").first();
|
||||
await firstRow.getByRole("checkbox").check();
|
||||
const assignee = page.getByLabel("Toewijzen aan");
|
||||
await assignee.selectOption({ index: 1 });
|
||||
const selectedLabel = await assignee.locator("option:checked").textContent();
|
||||
await page.getByRole("button", { name: "Werkvoorraad bijwerken" }).click();
|
||||
await expect(firstRow).toContainText(selectedLabel ?? "");
|
||||
});
|
||||
|
||||
test("manager can record maintenance and release a safe vehicle", async ({ page, request }) => {
|
||||
await reset(request);
|
||||
const vehicles = await (await request.get("/api/v1/vehicles?status=available")).json();
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface Vehicle {
|
||||
next_service_km: number;
|
||||
active: boolean;
|
||||
attention: boolean;
|
||||
attention_reason: "blocked_status" | "service_due" | "data_quality" | null;
|
||||
service_remaining_km: number;
|
||||
next_booking_ref: string | null;
|
||||
next_booking_at: string | null;
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
@@ -113,6 +117,10 @@ export interface DataQualityIssue {
|
||||
status: "open" | "deferred" | "resolved" | "rejected";
|
||||
evidence: Record<string, unknown>;
|
||||
detected_at: string;
|
||||
due_at: string | null;
|
||||
assigned_to_ref: string | null;
|
||||
assigned_to_name: string | null;
|
||||
overdue: boolean;
|
||||
resolved_at: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
"searchPlaceholder": "Booking, customer or vehicle",
|
||||
"statusLabel": "Status",
|
||||
"statusAll": "All statuses",
|
||||
"fromLabel": "From date",
|
||||
"toLabel": "Through date",
|
||||
"locationLabel": "Location",
|
||||
"locationPlaceholder": "Exact operating location",
|
||||
"sortLabel": "Sort",
|
||||
"sortOperational": "Operational priority",
|
||||
"sortAscending": "Start date ascending",
|
||||
"sortDescending": "Start date descending",
|
||||
"presetsLabel": "Quick filters",
|
||||
"todayPreset": "Today",
|
||||
"upcomingPreset": "Upcoming reservations",
|
||||
"clearFilters": "Clear filters",
|
||||
"loading": "Loading booking ledger…",
|
||||
"empty": "No bookings found",
|
||||
"emptyDetail": "Adjust the booking status filter.",
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
"statusLabel": "Status",
|
||||
"statusAll": "All statuses",
|
||||
"attentionOnly": "Attention only",
|
||||
"locationFilter": "Location",
|
||||
"locationPlaceholder": "Exact operating location",
|
||||
"serviceDue": "Service overdue",
|
||||
"serviceRemaining": "{{count}} km remaining",
|
||||
"attentionReasons": {
|
||||
"blocked_status": "Blocked status",
|
||||
"service_due": "Service overdue",
|
||||
"data_quality": "Open quality issue"
|
||||
},
|
||||
"loading": "Loading fleet registry…",
|
||||
"empty": "No vehicles found",
|
||||
"emptyDetail": "Adjust the current fleet filters.",
|
||||
@@ -22,6 +31,8 @@
|
||||
"location": "Location",
|
||||
"status": "Status",
|
||||
"odometer": "Odometer (km)",
|
||||
"service": "Service",
|
||||
"nextBooking": "Next booking",
|
||||
"attention": "Attention"
|
||||
},
|
||||
"needsAttention": "Needs attention"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"users": { "eyebrow": "Administration / Access", "title": "Users", "description": "Manage operational access and roles. Every change is audited.", "addTitle": "Add user", "name": "Name", "email": "Email", "role": "Role", "password": "Temporary password", "status": "Status", "action": "Action", "add": "Add user", "saving": "Saving…", "loading": "Loading users…", "active": "active", "inactive": "inactive", "activate": "Activate", "deactivate": "Deactivate", "createFailed": "The user could not be created.", "updateFailed": "The user could not be updated." },
|
||||
"users": { "eyebrow": "Administration / Access", "title": "Users", "description": "Manage operational access and roles. Every change is audited.", "addTitle": "Add user", "name": "Name", "email": "Email", "role": "Role", "password": "Temporary password", "status": "Status", "action": "Action", "add": "Add user", "saving": "Saving…", "loading": "Loading users…", "active": "active", "inactive": "inactive", "activate": "Activate", "deactivate": "Deactivate", "edit": "Edit", "editTitle": "Edit user {{ref}}", "newPassword": "New password", "passwordUnchanged": "Leave empty to keep unchanged", "saveChanges": "Save changes", "cancel": "Cancel", "createFailed": "The user could not be created.", "updateFailed": "The user could not be updated." },
|
||||
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Rental employee" }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,19 @@
|
||||
"ruleTypeAll": "All rule types",
|
||||
"severityLabel": "Severity",
|
||||
"severityAll": "All severity levels",
|
||||
"assigneeLabel": "Assigned to",
|
||||
"assigneeAll": "All team members",
|
||||
"unassigned": "Unassigned",
|
||||
"overdueOnly": "Overdue only",
|
||||
"bulkTitle": "Update selected work",
|
||||
"selected": "{{count}} selected",
|
||||
"assignTo": "Assign to",
|
||||
"dueAt": "Due date",
|
||||
"bulkApply": "Update work queue",
|
||||
"bulkSaving": "Updating…",
|
||||
"bulkFailed": "The work queue could not be updated.",
|
||||
"clearSelection": "Clear selection",
|
||||
"selectIssue": "Select issue {{ref}}",
|
||||
"demoScenariosOnly": "Demo scenarios only",
|
||||
"loading": "Loading quality workbench…",
|
||||
"queueClear": "Queue is clear",
|
||||
@@ -33,10 +46,13 @@
|
||||
"evidenceBacked": "Evidence-backed detection",
|
||||
"columns": {
|
||||
"reference": "Reference",
|
||||
"select": "Select",
|
||||
"rule": "Rule",
|
||||
"entity": "Entity",
|
||||
"severity": "Severity",
|
||||
"status": "Status"
|
||||
"status": "Status",
|
||||
"assignee": "Assignee",
|
||||
"due": "Due date"
|
||||
}
|
||||
},
|
||||
"ruleTypes": {
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
"searchPlaceholder": "Réservation, client ou véhicule",
|
||||
"statusLabel": "Statut",
|
||||
"statusAll": "Tous les statuts",
|
||||
"fromLabel": "À partir du",
|
||||
"toLabel": "Jusqu’au",
|
||||
"locationLabel": "Site",
|
||||
"locationPlaceholder": "Site opérationnel exact",
|
||||
"sortLabel": "Tri",
|
||||
"sortOperational": "Priorité opérationnelle",
|
||||
"sortAscending": "Date de début croissante",
|
||||
"sortDescending": "Date de début décroissante",
|
||||
"presetsLabel": "Filtres rapides",
|
||||
"todayPreset": "Aujourd’hui",
|
||||
"upcomingPreset": "Réservations à venir",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"loading": "Chargement du registre des réservations…",
|
||||
"empty": "Aucune réservation trouvée",
|
||||
"emptyDetail": "Ajustez le filtre de statut des réservations.",
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
"statusLabel": "Statut",
|
||||
"statusAll": "Tous les statuts",
|
||||
"attentionOnly": "Attention uniquement",
|
||||
"locationFilter": "Site",
|
||||
"locationPlaceholder": "Site opérationnel exact",
|
||||
"serviceDue": "Entretien en retard",
|
||||
"serviceRemaining": "{{count}} km restants",
|
||||
"attentionReasons": {
|
||||
"blocked_status": "Statut bloqué",
|
||||
"service_due": "Entretien en retard",
|
||||
"data_quality": "Problème de qualité ouvert"
|
||||
},
|
||||
"loading": "Chargement du registre de la flotte…",
|
||||
"empty": "Aucun véhicule trouvé",
|
||||
"emptyDetail": "Ajustez les filtres de flotte actuels.",
|
||||
@@ -22,6 +31,8 @@
|
||||
"location": "Localisation",
|
||||
"status": "Statut",
|
||||
"odometer": "Kilométrage (km)",
|
||||
"service": "Entretien",
|
||||
"nextBooking": "Prochaine réservation",
|
||||
"attention": "Attention"
|
||||
},
|
||||
"needsAttention": "Nécessite de l'attention"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
|
||||
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "edit": "Modifier", "editTitle": "Modifier l’utilisateur {{ref}}", "newPassword": "Nouveau mot de passe", "passwordUnchanged": "Laisser vide pour ne pas modifier", "saveChanges": "Enregistrer les modifications", "cancel": "Annuler", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
|
||||
"roles": { "operations_manager": "Responsable des opérations", "rental_employee": "Employé de location" }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,19 @@
|
||||
"ruleTypeAll": "Tous les types de règles",
|
||||
"severityLabel": "Gravité",
|
||||
"severityAll": "Tous les niveaux de gravité",
|
||||
"assigneeLabel": "Attribué à",
|
||||
"assigneeAll": "Tous les membres",
|
||||
"unassigned": "Non attribué",
|
||||
"overdueOnly": "En retard uniquement",
|
||||
"bulkTitle": "Mettre à jour le travail sélectionné",
|
||||
"selected": "{{count}} sélectionné(s)",
|
||||
"assignTo": "Attribuer à",
|
||||
"dueAt": "Échéance",
|
||||
"bulkApply": "Mettre à jour la file",
|
||||
"bulkSaving": "Mise à jour…",
|
||||
"bulkFailed": "La file de travail n’a pas pu être mise à jour.",
|
||||
"clearSelection": "Effacer la sélection",
|
||||
"selectIssue": "Sélectionner le problème {{ref}}",
|
||||
"demoScenariosOnly": "Scénarios de démo uniquement",
|
||||
"loading": "Chargement de l'atelier qualité…",
|
||||
"queueClear": "La file est vide",
|
||||
@@ -33,10 +46,13 @@
|
||||
"evidenceBacked": "Détection étayée par des preuves",
|
||||
"columns": {
|
||||
"reference": "Référence",
|
||||
"select": "Sélectionner",
|
||||
"rule": "Règle",
|
||||
"entity": "Entité",
|
||||
"severity": "Gravité",
|
||||
"status": "Statut"
|
||||
"status": "Statut",
|
||||
"assignee": "Responsable",
|
||||
"due": "Échéance"
|
||||
}
|
||||
},
|
||||
"ruleTypes": {
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
"searchPlaceholder": "Boeking, klant of voertuig",
|
||||
"statusLabel": "Status",
|
||||
"statusAll": "Alle statussen",
|
||||
"fromLabel": "Vanaf datum",
|
||||
"toLabel": "Tot en met datum",
|
||||
"locationLabel": "Locatie",
|
||||
"locationPlaceholder": "Exacte standplaats",
|
||||
"sortLabel": "Sortering",
|
||||
"sortOperational": "Operationele prioriteit",
|
||||
"sortAscending": "Startdatum oplopend",
|
||||
"sortDescending": "Startdatum aflopend",
|
||||
"presetsLabel": "Snelfilters",
|
||||
"todayPreset": "Vandaag",
|
||||
"upcomingPreset": "Aankomende reservaties",
|
||||
"clearFilters": "Filters wissen",
|
||||
"loading": "Boekingsoverzicht laden…",
|
||||
"empty": "Geen boekingen gevonden",
|
||||
"emptyDetail": "Pas het statusfilter voor boekingen aan.",
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
"statusLabel": "Status",
|
||||
"statusAll": "Alle statussen",
|
||||
"attentionOnly": "Enkel aandachtspunten",
|
||||
"locationFilter": "Locatie",
|
||||
"locationPlaceholder": "Exacte standplaats",
|
||||
"serviceDue": "Onderhoud vervallen",
|
||||
"serviceRemaining": "{{count}} km resterend",
|
||||
"attentionReasons": {
|
||||
"blocked_status": "Geblokkeerde status",
|
||||
"service_due": "Onderhoud vervallen",
|
||||
"data_quality": "Open kwaliteitsprobleem"
|
||||
},
|
||||
"loading": "Wagenparkregister laden…",
|
||||
"empty": "Geen voertuigen gevonden",
|
||||
"emptyDetail": "Pas de huidige wagenparkfilters aan.",
|
||||
@@ -22,6 +31,8 @@
|
||||
"location": "Locatie",
|
||||
"status": "Status",
|
||||
"odometer": "Kilometerstand (km)",
|
||||
"service": "Onderhoud",
|
||||
"nextBooking": "Volgende boeking",
|
||||
"attention": "Aandacht"
|
||||
},
|
||||
"needsAttention": "Vraagt aandacht"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
|
||||
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "edit": "Bewerken", "editTitle": "Gebruiker {{ref}} bewerken", "newPassword": "Nieuw wachtwoord", "passwordUnchanged": "Leeg laten om niet te wijzigen", "saveChanges": "Wijzigingen opslaan", "cancel": "Annuleren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
|
||||
"roles": { "operations_manager": "Operationeel beheerder", "rental_employee": "Verhuurmedewerker" }
|
||||
}
|
||||
|
||||
@@ -22,6 +22,19 @@
|
||||
"ruleTypeAll": "Alle regeltypes",
|
||||
"severityLabel": "Ernst",
|
||||
"severityAll": "Alle ernstniveaus",
|
||||
"assigneeLabel": "Toegewezen aan",
|
||||
"assigneeAll": "Alle medewerkers",
|
||||
"unassigned": "Niet toegewezen",
|
||||
"overdueOnly": "Enkel over tijd",
|
||||
"bulkTitle": "Geselecteerde werkvoorraad bijwerken",
|
||||
"selected": "{{count}} geselecteerd",
|
||||
"assignTo": "Toewijzen aan",
|
||||
"dueAt": "Uiterste datum",
|
||||
"bulkApply": "Werkvoorraad bijwerken",
|
||||
"bulkSaving": "Bijwerken…",
|
||||
"bulkFailed": "De werkvoorraad kon niet bijgewerkt worden.",
|
||||
"clearSelection": "Selectie wissen",
|
||||
"selectIssue": "Probleem {{ref}} selecteren",
|
||||
"demoScenariosOnly": "Enkel demoscenario's",
|
||||
"loading": "Kwaliteitswerkbank laden…",
|
||||
"queueClear": "Wachtrij is leeg",
|
||||
@@ -33,10 +46,13 @@
|
||||
"evidenceBacked": "Evidentie-onderbouwde detectie",
|
||||
"columns": {
|
||||
"reference": "Referentie",
|
||||
"select": "Selecteren",
|
||||
"rule": "Regel",
|
||||
"entity": "Entiteit",
|
||||
"severity": "Ernst",
|
||||
"status": "Status"
|
||||
"status": "Status",
|
||||
"assignee": "Behandelaar",
|
||||
"due": "Uiterste datum"
|
||||
}
|
||||
},
|
||||
"ruleTypes": {
|
||||
|
||||
@@ -10,6 +10,17 @@ import { Pagination } from "../components/Pagination";
|
||||
|
||||
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"];
|
||||
|
||||
function localDateString(date = new Date()): string {
|
||||
const offset = date.getTimezoneOffset() * 60_000;
|
||||
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function nextLocalDay(value: string): string {
|
||||
const date = new Date(`${value}T12:00:00`);
|
||||
date.setDate(date.getDate() + 1);
|
||||
return localDateString(date);
|
||||
}
|
||||
|
||||
export function Bookings() {
|
||||
const { t } = useTranslation("bookings");
|
||||
const { formatShortDate } = useLocaleFormat();
|
||||
@@ -18,6 +29,10 @@ export function Bookings() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const status = searchParams.get("status") ?? "";
|
||||
const query = searchParams.get("q") ?? "";
|
||||
const startsFrom = searchParams.get("from") ?? "";
|
||||
const startsTo = searchParams.get("to") ?? "";
|
||||
const location = searchParams.get("location") ?? "";
|
||||
const sort = searchParams.get("sort") ?? "operational";
|
||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
||||
|
||||
function updateFilters(updates: Record<string, string | number | null>) {
|
||||
@@ -35,11 +50,15 @@ export function Bookings() {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: "25" });
|
||||
if (status) params.set("status", status);
|
||||
if (query) params.set("query", query);
|
||||
if (startsFrom) params.set("starts_from", new Date(`${startsFrom}T00:00:00`).toISOString());
|
||||
if (startsTo) params.set("starts_to", new Date(`${nextLocalDay(startsTo)}T00:00:00`).toISOString());
|
||||
if (location) params.set("location", location);
|
||||
params.set("sort", sort);
|
||||
api
|
||||
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`)
|
||||
.then(setBookings)
|
||||
.catch(() => setError(t("list.unavailable")));
|
||||
}, [page, query, status, t]);
|
||||
}, [page, query, status, startsFrom, startsTo, location, sort, t]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -57,7 +76,16 @@ export function Bookings() {
|
||||
{STATUS_OPTIONS.map((value) => <option key={value} value={value}>{t(`statuses.${value}`)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>{t("list.fromLabel")}<input type="date" value={startsFrom} onChange={(event) => updateFilters({ from: event.target.value, page: 1 })} /></label>
|
||||
<label>{t("list.toLabel")}<input type="date" value={startsTo} onChange={(event) => updateFilters({ to: event.target.value, page: 1 })} /></label>
|
||||
<label>{t("list.locationLabel")}<input value={location} onChange={(event) => updateFilters({ location: event.target.value, page: 1 })} placeholder={t("list.locationPlaceholder")} /></label>
|
||||
<label>{t("list.sortLabel")}<select value={sort} onChange={(event) => updateFilters({ sort: event.target.value, page: 1 })}><option value="operational">{t("list.sortOperational")}</option><option value="starts_asc">{t("list.sortAscending")}</option><option value="starts_desc">{t("list.sortDescending")}</option></select></label>
|
||||
</form>
|
||||
<div className="filter-presets" aria-label={t("list.presetsLabel")}>
|
||||
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: localDateString(), status: null, page: 1 })}>{t("list.todayPreset")}</button>
|
||||
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
|
||||
<button type="button" onClick={() => setSearchParams(new URLSearchParams())}>{t("list.clearFilters")}</button>
|
||||
</div>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
{!error && !bookings && <LoadingState label={t("list.loading")} />}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import type { DataQualityIssue, Page, ScanResult } from "../api/types";
|
||||
import type { DataQualityIssue, Page, ScanResult, UserRecord } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
|
||||
const RULE_TYPES = [
|
||||
"possible_duplicate_customer",
|
||||
@@ -19,6 +20,7 @@ const RULE_TYPES = [
|
||||
|
||||
export function DataQuality() {
|
||||
const { t } = useTranslation("quality");
|
||||
const { formatDateTime } = useLocaleFormat();
|
||||
const { user } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [issues, setIssues] = useState<Page<DataQualityIssue> | null>(null);
|
||||
@@ -26,12 +28,20 @@ export function DataQuality() {
|
||||
const status = searchParams.get("status") ?? "open";
|
||||
const ruleType = searchParams.get("rule_type") ?? "";
|
||||
const severity = searchParams.get("severity") ?? "";
|
||||
const assignee = searchParams.get("assignee") ?? "";
|
||||
const overdueOnly = searchParams.get("overdue") === "true";
|
||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [confirmingScan, setConfirmingScan] = useState(false);
|
||||
const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkAssignee, setBulkAssignee] = useState("");
|
||||
const [bulkDueAt, setBulkDueAt] = useState("");
|
||||
const [bulkSaving, setBulkSaving] = useState(false);
|
||||
const [bulkError, setBulkError] = useState<ApiErrorInfo | null>(null);
|
||||
|
||||
function updateFilters(updates: Record<string, string | boolean | number | null>) {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
@@ -50,18 +60,54 @@ export function DataQuality() {
|
||||
if (status) params.set("status", status);
|
||||
if (ruleType) params.set("rule_type", ruleType);
|
||||
if (severity) params.set("severity", severity);
|
||||
if (assignee) params.set("assigned_to_ref", assignee);
|
||||
if (overdueOnly) params.set("overdue", "true");
|
||||
params.set("page", String(page));
|
||||
params.set("page_size", "25");
|
||||
api
|
||||
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
|
||||
.then(setIssues)
|
||||
.catch(() => setError(t("list.unavailable")));
|
||||
}, [status, ruleType, severity, page, user, t]);
|
||||
}, [status, ruleType, severity, assignee, overdueOnly, page, user, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== "operations_manager") return;
|
||||
api.get<UserRecord[]>("/api/v1/users").then((records) => setUsers(records.filter((record) => record.active))).catch(() => setUsers([]));
|
||||
}, [user]);
|
||||
|
||||
async function applyBulkWork() {
|
||||
if (selected.size === 0 || (!bulkAssignee && !bulkDueAt)) return;
|
||||
setBulkSaving(true);
|
||||
setBulkError(null);
|
||||
try {
|
||||
await api.post("/api/v1/data-quality/issues/bulk-work", {
|
||||
issue_refs: [...selected],
|
||||
assigned_to_ref: bulkAssignee || undefined,
|
||||
due_at: bulkDueAt ? new Date(bulkDueAt).toISOString() : undefined,
|
||||
});
|
||||
setSelected(new Set());
|
||||
setBulkAssignee("");
|
||||
setBulkDueAt("");
|
||||
load();
|
||||
} catch (err) {
|
||||
setBulkError(describeApiError(t, err, "list.bulkFailed"));
|
||||
} finally {
|
||||
setBulkSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelected(ref: string) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(ref)) next.delete(ref); else next.add(ref);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleScan() {
|
||||
setScanError(null);
|
||||
setScanning(true);
|
||||
@@ -119,6 +165,7 @@ export function DataQuality() {
|
||||
/>
|
||||
|
||||
<ApiErrorNotice error={scanError} />
|
||||
<ApiErrorNotice error={bulkError} />
|
||||
{scanResult && (
|
||||
<p className="quiet-empty" role="status">
|
||||
{t("list.scanComplete", {
|
||||
@@ -142,6 +189,18 @@ export function DataQuality() {
|
||||
<option value="rejected">{t("list.statusRejected")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t("list.assigneeLabel")}
|
||||
<select value={assignee} onChange={(e) => updateFilters({ assignee: e.target.value, page: 1 })}>
|
||||
<option value="">{t("list.assigneeAll")}</option>
|
||||
<option value="unassigned">{t("list.unassigned")}</option>
|
||||
{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input type="checkbox" checked={overdueOnly} onChange={(e) => updateFilters({ overdue: e.target.checked, page: 1 })} />
|
||||
{t("list.overdueOnly")}
|
||||
</label>
|
||||
<label>
|
||||
{t("list.ruleTypeLabel")}
|
||||
<select value={ruleType} onChange={(e) => updateFilters({ rule_type: e.target.value, page: 1 })}>
|
||||
@@ -180,20 +239,34 @@ export function DataQuality() {
|
||||
)}
|
||||
|
||||
{visibleIssues.length > 0 && (
|
||||
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
|
||||
<div className="table-shell">
|
||||
{selected.size > 0 && (
|
||||
<div className="bulk-toolbar" role="region" aria-label={t("list.bulkTitle")}>
|
||||
<strong>{t("list.selected", { count: selected.size })}</strong>
|
||||
<label>{t("list.assignTo")}<select value={bulkAssignee} onChange={(event) => setBulkAssignee(event.target.value)}><option value="">—</option>{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}</select></label>
|
||||
<label>{t("list.dueAt")}<input type="datetime-local" value={bulkDueAt} onChange={(event) => setBulkDueAt(event.target.value)} /></label>
|
||||
<button type="button" className="button button-primary" disabled={bulkSaving || (!bulkAssignee && !bulkDueAt)} onClick={applyBulkWork}>{bulkSaving ? t("list.bulkSaving") : t("list.bulkApply")}</button>
|
||||
<button type="button" className="button button-secondary" onClick={() => setSelected(new Set())}>{t("list.clearSelection")}</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
|
||||
<caption className="visually-hidden">{t("list.title")}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"><span className="visually-hidden">{t("list.columns.select")}</span></th>
|
||||
<th scope="col">{t("list.columns.reference")}</th>
|
||||
<th scope="col">{t("list.columns.rule")}</th>
|
||||
<th scope="col">{t("list.columns.entity")}</th>
|
||||
<th scope="col">{t("list.columns.severity")}</th>
|
||||
<th scope="col">{t("list.columns.status")}</th>
|
||||
<th scope="col">{t("list.columns.assignee")}</th>
|
||||
<th scope="col">{t("list.columns.due")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleIssues.map((i) => (
|
||||
<tr key={i.public_ref} className="row-clickable">
|
||||
<td className="selection-cell"><input type="checkbox" checked={selected.has(i.public_ref)} onChange={() => toggleSelected(i.public_ref)} aria-label={t("list.selectIssue", { ref: i.public_ref })} /></td>
|
||||
<th scope="row" data-label={t("list.columns.reference")}>
|
||||
{i.public_ref}
|
||||
<Link className="row-link" to={`/data-quality/${i.public_ref}`}><span className="visually-hidden">{i.public_ref}</span></Link>
|
||||
@@ -206,6 +279,8 @@ export function DataQuality() {
|
||||
<td data-label={t("list.columns.status")}>
|
||||
<StatusBadge status={i.status} label={t(`list.status${i.status.charAt(0).toUpperCase()}${i.status.slice(1)}`, { defaultValue: i.status })} />
|
||||
</td>
|
||||
<td data-label={t("list.columns.assignee")}>{i.assigned_to_name ?? t("list.unassigned")}</td>
|
||||
<td data-label={t("list.columns.due")} className={i.overdue ? "is-overdue" : ""}>{i.due_at ? <time dateTime={i.due_at}>{formatDateTime(i.due_at)}</time> : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
+115
-20
@@ -16,37 +16,132 @@ export function Users() {
|
||||
const [role, setRole] = useState<Role>("rental_employee");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
const [editingRef, setEditingRef] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editRole, setEditRole] = useState<Role>("rental_employee");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.get<UserRecord[]>("/api/v1/users").then(setUsers).catch((err) => setError(describeApiError(t, err)));
|
||||
api
|
||||
.get<UserRecord[]>("/api/v1/users")
|
||||
.then(setUsers)
|
||||
.catch((err) => setError(describeApiError(t, err)));
|
||||
}, [t]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
async function create(event: FormEvent) {
|
||||
event.preventDefault(); setSaving(true); setError(null);
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post<UserRecord>("/api/v1/users", { email, display_name: displayName, password, role });
|
||||
setDisplayName(""); setEmail(""); setPassword(""); setRole("rental_employee"); load();
|
||||
} catch (err) { setError(describeApiError(t, err, "operations:users.createFailed")); }
|
||||
finally { setSaving(false); }
|
||||
await api.post<UserRecord>("/api/v1/users", {
|
||||
email,
|
||||
display_name: displayName,
|
||||
password,
|
||||
role,
|
||||
});
|
||||
setDisplayName("");
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setRole("rental_employee");
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(describeApiError(t, err, "operations:users.createFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function beginEdit(record: UserRecord) {
|
||||
setEditingRef(record.public_ref);
|
||||
setEditName(record.display_name);
|
||||
setEditRole(record.role);
|
||||
setNewPassword("");
|
||||
}
|
||||
|
||||
async function saveEdit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!editingRef) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.patch(`/api/v1/users/${editingRef}`, {
|
||||
display_name: editName,
|
||||
role: editRole,
|
||||
password: newPassword || undefined,
|
||||
});
|
||||
setEditingRef(null);
|
||||
setNewPassword("");
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(describeApiError(t, err, "operations:users.updateFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(record: UserRecord) {
|
||||
setError(null);
|
||||
try { await api.patch(`/api/v1/users/${record.public_ref}`, { active: !record.active }); load(); }
|
||||
catch (err) { setError(describeApiError(t, err, "operations:users.updateFailed")); }
|
||||
try {
|
||||
await api.patch(`/api/v1/users/${record.public_ref}`, { active: !record.active });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(describeApiError(t, err, "operations:users.updateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="page">
|
||||
<PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.description")} />
|
||||
<ApiErrorNotice error={error} />
|
||||
<section className="record-surface user-create"><h2>{t("users.addTitle")}</h2><form onSubmit={create}><div className="form-grid">
|
||||
<label>{t("users.name")}<input required minLength={2} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>
|
||||
<label>{t("users.email")}<input required type="email" value={email} onChange={(event) => setEmail(event.target.value)} /></label>
|
||||
<label>{t("users.role")}<select value={role} onChange={(event) => setRole(event.target.value as Role)}><option value="rental_employee">{t("roles.rental_employee")}</option><option value="operations_manager">{t("roles.operations_manager")}</option></select></label>
|
||||
<label>{t("users.password")}<input required type="password" minLength={8} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
|
||||
</div><div className="form-actions"><button className="button button-primary" disabled={saving}>{saving ? t("users.saving") : t("users.add")}</button></div></form></section>
|
||||
{!users && <LoadingState label={t("users.loading")} />}
|
||||
{users && <div className="table-shell"><table className="data-table"><caption className="visually-hidden">{t("users.title")}</caption><thead><tr><th>{t("users.name")}</th><th>{t("users.email")}</th><th>{t("users.role")}</th><th>{t("users.status")}</th><th>{t("users.action")}</th></tr></thead><tbody>{users.map((record) => <tr key={record.public_ref}><th>{record.display_name}<span className="table-secondary">{record.public_ref}</span></th><td>{record.email ?? "—"}</td><td>{t(`roles.${record.role}`)}</td><td><span className={`badge ${record.active ? "status-available" : "status-blocked"}`}>{t(record.active ? "users.active" : "users.inactive")}</span></td><td><button type="button" className="button button-secondary" disabled={record.public_ref === currentUser?.public_ref} onClick={() => toggleActive(record)}>{t(record.active ? "users.deactivate" : "users.activate")}</button></td></tr>)}</tbody></table></div>}
|
||||
</div>;
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.description")} />
|
||||
<ApiErrorNotice error={error} />
|
||||
<section className="record-surface user-create">
|
||||
<h2>{t("users.addTitle")}</h2>
|
||||
<form onSubmit={create}>
|
||||
<div className="form-grid">
|
||||
<label>{t("users.name")}<input required minLength={2} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>
|
||||
<label>{t("users.email")}<input required type="email" value={email} onChange={(event) => setEmail(event.target.value)} /></label>
|
||||
<label>{t("users.role")}<select value={role} onChange={(event) => setRole(event.target.value as Role)}><option value="rental_employee">{t("roles.rental_employee")}</option><option value="operations_manager">{t("roles.operations_manager")}</option></select></label>
|
||||
<label>{t("users.password")}<input required type="password" minLength={8} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className="form-actions"><button className="button button-primary" disabled={saving}>{saving ? t("users.saving") : t("users.add")}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{!users && <LoadingState label={t("users.loading")} />}
|
||||
{users && (
|
||||
<div className="table-shell">
|
||||
<table className="data-table">
|
||||
<caption className="visually-hidden">{t("users.title")}</caption>
|
||||
<thead><tr><th>{t("users.name")}</th><th>{t("users.email")}</th><th>{t("users.role")}</th><th>{t("users.status")}</th><th>{t("users.action")}</th></tr></thead>
|
||||
<tbody>
|
||||
{users.map((record) => (
|
||||
<tr key={record.public_ref}>
|
||||
<th>{record.display_name}<span className="table-secondary">{record.public_ref}</span></th>
|
||||
<td>{record.email ?? "—"}</td>
|
||||
<td>{t(`roles.${record.role}`)}</td>
|
||||
<td><span className={`badge ${record.active ? "status-available" : "status-blocked"}`}>{t(record.active ? "users.active" : "users.inactive")}</span></td>
|
||||
<td><div className="table-actions"><button type="button" className="button button-secondary" onClick={() => beginEdit(record)}>{t("users.edit")}</button><button type="button" className="button button-secondary" disabled={record.public_ref === currentUser?.public_ref} onClick={() => toggleActive(record)}>{t(record.active ? "users.deactivate" : "users.activate")}</button></div></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingRef && (
|
||||
<section className="record-surface user-edit" aria-labelledby="edit-user-title">
|
||||
<h2 id="edit-user-title">{t("users.editTitle", { ref: editingRef })}</h2>
|
||||
<form onSubmit={saveEdit}>
|
||||
<div className="form-grid">
|
||||
<label>{t("users.name")}<input required minLength={2} value={editName} onChange={(event) => setEditName(event.target.value)} /></label>
|
||||
<label>{t("users.role")}<select value={editRole} disabled={editingRef === currentUser?.public_ref} onChange={(event) => setEditRole(event.target.value as Role)}><option value="rental_employee">{t("roles.rental_employee")}</option><option value="operations_manager">{t("roles.operations_manager")}</option></select></label>
|
||||
<label>{t("users.newPassword")}<input type="password" minLength={8} autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} placeholder={t("users.passwordUnchanged")} /></label>
|
||||
</div>
|
||||
<div className="form-actions"><button className="button button-primary" disabled={saving}>{saving ? t("users.saving") : t("users.saveChanges")}</button><button type="button" className="button button-secondary" onClick={() => setEditingRef(null)}>{t("users.cancel")}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,13 +12,14 @@ const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "block
|
||||
|
||||
export function Vehicles() {
|
||||
const { t } = useTranslation("fleet");
|
||||
const { formatNumber } = useLocaleFormat();
|
||||
const { formatDateTime, formatNumber } = useLocaleFormat();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [vehicles, setVehicles] = useState<Page<Vehicle> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const status = searchParams.get("status") ?? "";
|
||||
const attentionOnly = searchParams.get("attention_only") === "true";
|
||||
const query = searchParams.get("q") ?? "";
|
||||
const location = searchParams.get("location") ?? "";
|
||||
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
|
||||
|
||||
function updateFilters(updates: Record<string, string | boolean | number | null>) {
|
||||
@@ -37,13 +38,14 @@ export function Vehicles() {
|
||||
if (status) params.set("status", status);
|
||||
if (attentionOnly) params.set("attention_only", "true");
|
||||
if (query) params.set("query", query);
|
||||
if (location) params.set("location", location);
|
||||
params.set("page", String(page));
|
||||
params.set("page_size", "25");
|
||||
api
|
||||
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`)
|
||||
.then(setVehicles)
|
||||
.catch(() => setError(t("list.unavailable")));
|
||||
}, [status, attentionOnly, query, page, t]);
|
||||
}, [status, attentionOnly, query, location, page, t]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
@@ -73,6 +75,7 @@ export function Vehicles() {
|
||||
/>
|
||||
{t("list.attentionOnly")}
|
||||
</label>
|
||||
<label>{t("list.locationFilter")}<input value={location} onChange={(event) => updateFilters({ location: event.target.value, page: 1 })} placeholder={t("list.locationPlaceholder")} /></label>
|
||||
</form>
|
||||
|
||||
{error && <ErrorState message={error} />}
|
||||
@@ -88,6 +91,8 @@ export function Vehicles() {
|
||||
<th scope="col">{t("list.columns.location")}</th>
|
||||
<th scope="col">{t("list.columns.status")}</th>
|
||||
<th scope="col">{t("list.columns.odometer")}</th>
|
||||
<th scope="col">{t("list.columns.service")}</th>
|
||||
<th scope="col">{t("list.columns.nextBooking")}</th>
|
||||
<th scope="col">{t("list.columns.attention")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -106,7 +111,9 @@ export function Vehicles() {
|
||||
<StatusBadge status={v.operational_status} label={t(`statuses.${v.operational_status}`, { defaultValue: v.operational_status })} />
|
||||
</td>
|
||||
<td data-label={t("list.columns.odometer")}>{formatNumber(v.odometer_km)}</td>
|
||||
<td data-label={t("list.columns.attention")}>{v.attention ? <span className="attention-flag">{t("list.needsAttention")}</span> : "—"}</td>
|
||||
<td data-label={t("list.columns.service")} className={v.service_remaining_km <= 0 ? "is-overdue" : ""}>{v.service_remaining_km <= 0 ? t("list.serviceDue") : t("list.serviceRemaining", { count: formatNumber(v.service_remaining_km) })}</td>
|
||||
<td data-label={t("list.columns.nextBooking")}>{v.next_booking_ref && v.next_booking_at ? <Link className="cell-link" to={`/bookings/${v.next_booking_ref}`}>{v.next_booking_ref}<span className="table-secondary">{formatDateTime(v.next_booking_at)}</span></Link> : "—"}</td>
|
||||
<td data-label={t("list.columns.attention")}>{v.attention && v.attention_reason ? <span className="attention-flag">{t(`list.attentionReasons.${v.attention_reason}`)}</span> : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -406,6 +406,18 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.knowledge-feedback button { min-height: 34px; padding: 5px 10px; }
|
||||
.knowledge-feedback button.is-selected { color: var(--teal-dark); border-color: var(--teal); background: var(--teal-pale); }
|
||||
|
||||
.bulk-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 10px; padding: 12px 14px; background: var(--teal-pale); border-bottom: 1px solid #bfe6df; }
|
||||
.bulk-toolbar strong { align-self: center; margin-right: auto; font-size: .74rem; }
|
||||
.bulk-toolbar label { display: grid; gap: 4px; color: var(--muted); font-size: .62rem; font-weight: 700; }
|
||||
.bulk-toolbar select, .bulk-toolbar input { min-height: 36px; }
|
||||
.selection-cell { position: relative; z-index: 2; width: 38px; }
|
||||
.selection-cell input { width: 16px; height: 16px; }
|
||||
.is-overdue { color: var(--critical); font-weight: 700; }
|
||||
.table-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.user-edit { margin-top: 18px; }
|
||||
.filter-presets { display: flex; flex-wrap: wrap; gap: 7px; margin: -6px 0 18px; }
|
||||
.filter-presets button { min-height: 34px; padding: 5px 11px; color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; }
|
||||
|
||||
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
|
||||
.error { color: #9f2929; font-size: .74rem; font-weight: 600; }
|
||||
.api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }
|
||||
|
||||
Reference in New Issue
Block a user