M12: complete daily operations cycle
This commit is contained in:
@@ -2296,3 +2296,21 @@ evidence yet."
|
||||
the full Unraid Compose suite: **203 passed, 1 warning**.
|
||||
- Exact next action: implement audited checkout/activation, maintenance capture and
|
||||
operations-manager user administration, then repeat the complete validation gate.
|
||||
|
||||
## Complete daily operations cycle (2026-08-10)
|
||||
|
||||
- Reserved bookings now have an audited checkout inspection. A safe inspection atomically
|
||||
activates the booking and marks the vehicle rented; odometer regression, dirt, damage
|
||||
or a technical warning blocks the booking and routes the vehicle to cleaning or
|
||||
maintenance without an unsafe activation.
|
||||
- Operations Managers can register persisted maintenance evidence, advance service and
|
||||
odometer values, explicitly release a vehicle only when no active rental or open
|
||||
high-severity vehicle issue remains, and create/activate/deactivate operational users.
|
||||
Self-deactivation and self-demotion are prevented. Rental employees receive 403 for
|
||||
manager actions.
|
||||
- Added localized web workflows for checkout, maintenance/release and user access
|
||||
administration. All actions use persisted API state and expose actionable errors.
|
||||
- Evidence: frontend lint and production build passed; ruff, mypy and diff check passed;
|
||||
focused Unraid contracts **19 passed** and the full suite **208 passed, 1 warning**.
|
||||
- Exact next action: harden MCP per-client authorization and evidence completeness, then
|
||||
replace inferred n8n status with explicit heartbeat/execution telemetry.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
|
||||
from sqlalchemy import func, or_, select
|
||||
@@ -10,12 +10,15 @@ 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.inspection import Inspection
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
AvailableVehicleOut,
|
||||
BookingOut,
|
||||
BookingPageOut,
|
||||
CancelBookingRequest,
|
||||
CheckoutBookingRequest,
|
||||
CheckoutBookingResult,
|
||||
CreateBookingRequest,
|
||||
CurrentUser,
|
||||
NextBookingRisk,
|
||||
@@ -154,6 +157,100 @@ def create_booking(
|
||||
return _to_out(booking, customer, vehicle)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/checkout", response_model=CheckoutBookingResult)
|
||||
def checkout_booking(
|
||||
public_ref: str,
|
||||
body: CheckoutBookingRequest,
|
||||
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()
|
||||
)
|
||||
if booking is None:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
if booking.status != "reserved":
|
||||
raise HTTPException(status_code=409, detail="Only a reserved booking can be checked out")
|
||||
if not booking.requirements_complete:
|
||||
raise HTTPException(status_code=409, detail="Booking requirements are incomplete")
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=500, detail="Booking references a missing vehicle")
|
||||
if not vehicle.active or vehicle.operational_status in {"maintenance", "blocked", "rented"}:
|
||||
raise HTTPException(status_code=409, detail="Vehicle is not ready for checkout")
|
||||
active_conflict = db.scalar(
|
||||
select(Booking.id).where(
|
||||
Booking.vehicle_id == vehicle.id,
|
||||
Booking.status == "active",
|
||||
Booking.id != booking.id,
|
||||
)
|
||||
)
|
||||
if active_conflict is not None:
|
||||
raise HTTPException(status_code=409, detail="Vehicle already has an active booking")
|
||||
|
||||
attention_reasons: list[str] = []
|
||||
if body.start_odometer_km < vehicle.odometer_km:
|
||||
attention_reasons.append("odometer_regression")
|
||||
if not body.cleanliness_ok:
|
||||
attention_reasons.append("cleanliness")
|
||||
if body.damage_reported:
|
||||
attention_reasons.append("damage")
|
||||
if body.technical_warning:
|
||||
attention_reasons.append("technical_warning")
|
||||
|
||||
inspection = Inspection(
|
||||
public_ref=f"INSP-{uuid.uuid4().hex[:10].upper()}",
|
||||
booking_id=booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="checkout",
|
||||
fuel_level_percent=body.fuel_level_percent,
|
||||
cleanliness_ok=body.cleanliness_ok,
|
||||
damage_reported=body.damage_reported,
|
||||
technical_warning=body.technical_warning,
|
||||
notes=body.notes,
|
||||
odometer_km=body.start_odometer_km,
|
||||
completed_at=datetime.now(UTC),
|
||||
completed_by=user.display_name,
|
||||
)
|
||||
db.add(inspection)
|
||||
if attention_reasons:
|
||||
booking.status = "blocked"
|
||||
vehicle.operational_status = (
|
||||
"maintenance" if body.damage_reported or body.technical_warning else "cleaning"
|
||||
)
|
||||
else:
|
||||
booking.status = "active"
|
||||
booking.start_odometer_km = body.start_odometer_km
|
||||
vehicle.odometer_km = max(vehicle.odometer_km, body.start_odometer_km)
|
||||
vehicle.operational_status = "rented"
|
||||
vehicle.version += 1
|
||||
db.flush()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="booking_checkout_recorded",
|
||||
entity_type="booking",
|
||||
entity_id=booking.id,
|
||||
after={
|
||||
"inspection_ref": inspection.public_ref,
|
||||
"booking_status": booking.status,
|
||||
"vehicle_status": vehicle.operational_status,
|
||||
"attention_reasons": attention_reasons,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return CheckoutBookingResult(
|
||||
booking_ref=booking.public_ref,
|
||||
vehicle_ref=vehicle.public_ref,
|
||||
inspection_ref=inspection.public_ref,
|
||||
booking_status=booking.status,
|
||||
resulting_vehicle_status=vehicle.operational_status,
|
||||
activated=booking.status == "active",
|
||||
attention_reasons=attention_reasons,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/availability", response_model=list[AvailableVehicleOut])
|
||||
def list_available_vehicles(
|
||||
starts_at: datetime,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User
|
||||
from app.schemas import CreateUserRequest, CurrentUser, UpdateUserRequest, UserOut
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||
|
||||
|
||||
def _to_out(user: User) -> UserOut:
|
||||
return UserOut(
|
||||
public_ref=user.public_ref,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
role=user.role, # type: ignore[arg-type]
|
||||
active=user.active,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_manager: CurrentUser = Depends(require_operations_manager),
|
||||
) -> list[UserOut]:
|
||||
return [_to_out(user) for user in db.scalars(select(User).order_by(User.display_name)).all()]
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=201)
|
||||
def create_user(
|
||||
body: CreateUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
manager: CurrentUser = Depends(require_operations_manager),
|
||||
) -> UserOut:
|
||||
email = body.email.strip().lower()
|
||||
if db.scalar(select(User.id).where(User.email == email)) is not None:
|
||||
raise HTTPException(status_code=409, detail="A user with this email already exists")
|
||||
user = User(
|
||||
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
|
||||
email=email,
|
||||
password_hash=hash_password(body.password),
|
||||
display_name=body.display_name.strip(),
|
||||
role=body.role,
|
||||
active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=manager.display_name,
|
||||
action="user_created",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
after={"public_ref": user.public_ref, "role": user.role, "active": user.active},
|
||||
)
|
||||
db.commit()
|
||||
return _to_out(user)
|
||||
|
||||
|
||||
@router.patch("/{public_ref}", response_model=UserOut)
|
||||
def update_user(
|
||||
public_ref: str,
|
||||
body: UpdateUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
manager: CurrentUser = Depends(require_operations_manager),
|
||||
) -> UserOut:
|
||||
user = db.scalar(select(User).where(User.public_ref == public_ref).with_for_update())
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.public_ref == manager.public_ref and body.active is False:
|
||||
raise HTTPException(status_code=409, detail="You cannot deactivate your own account")
|
||||
if user.public_ref == manager.public_ref and body.role not in (None, "operations_manager"):
|
||||
raise HTTPException(status_code=409, detail="You cannot remove your own manager role")
|
||||
before = {"display_name": user.display_name, "role": user.role, "active": user.active}
|
||||
if body.display_name is not None:
|
||||
user.display_name = body.display_name.strip()
|
||||
if body.role is not None:
|
||||
user.role = body.role
|
||||
if body.active is not None:
|
||||
user.active = body.active
|
||||
if body.password is not None:
|
||||
user.password_hash = hash_password(body.password)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=manager.display_name,
|
||||
action="user_updated",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
before=before,
|
||||
after={"display_name": user.display_name, "role": user.role, "active": user.active},
|
||||
)
|
||||
db.commit()
|
||||
return _to_out(user)
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
@@ -13,14 +15,17 @@ from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
BookingSummaryOut,
|
||||
CreateMaintenanceRequest,
|
||||
CurrentUser,
|
||||
DataQualityIssueOut,
|
||||
InspectionOut,
|
||||
MaintenanceOut,
|
||||
ReleaseVehicleRequest,
|
||||
VehicleDetailOut,
|
||||
VehicleOut,
|
||||
VehiclePageOut,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
||||
|
||||
@@ -192,3 +197,104 @@ def get_vehicle(
|
||||
for q in issues
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/maintenance", response_model=MaintenanceOut, status_code=201)
|
||||
def create_maintenance_record(
|
||||
public_ref: str,
|
||||
body: CreateMaintenanceRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> MaintenanceOut:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
record = MaintenanceRecord(
|
||||
public_ref=f"MAINT-{uuid.uuid4().hex[:8].upper()}",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=body.occurred_at,
|
||||
odometer_km=body.odometer_km,
|
||||
category=body.category,
|
||||
summary=body.summary.strip(),
|
||||
)
|
||||
db.add(record)
|
||||
vehicle.odometer_km = max(vehicle.odometer_km, body.odometer_km)
|
||||
if body.next_service_km is not None:
|
||||
if body.next_service_km < vehicle.odometer_km:
|
||||
raise HTTPException(status_code=422, detail="Next service must not be below odometer")
|
||||
vehicle.next_service_km = body.next_service_km
|
||||
if body.mark_maintenance:
|
||||
vehicle.operational_status = "maintenance"
|
||||
vehicle.version += 1
|
||||
db.flush()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="maintenance_record_created",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
after={"maintenance_ref": record.public_ref, "status": vehicle.operational_status},
|
||||
)
|
||||
db.commit()
|
||||
return MaintenanceOut(
|
||||
public_ref=record.public_ref,
|
||||
occurred_at=record.occurred_at,
|
||||
odometer_km=record.odometer_km,
|
||||
category=record.category,
|
||||
summary=record.summary,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/release", response_model=VehicleOut)
|
||||
def release_vehicle(
|
||||
public_ref: str,
|
||||
body: ReleaseVehicleRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> VehicleOut:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
if vehicle.operational_status not in {"cleaning", "maintenance", "blocked"}:
|
||||
raise HTTPException(status_code=409, detail="Vehicle does not require release")
|
||||
active_booking = db.scalar(
|
||||
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
|
||||
)
|
||||
open_high_issue = db.scalar(
|
||||
select(DataQualityIssue.id).where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status == "open",
|
||||
DataQualityIssue.severity == "high",
|
||||
)
|
||||
)
|
||||
if active_booking is not None or open_high_issue is not None:
|
||||
raise HTTPException(status_code=409, detail="Vehicle still has a blocking condition")
|
||||
before = {"status": vehicle.operational_status}
|
||||
vehicle.operational_status = "available"
|
||||
vehicle.version += 1
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="vehicle_released",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
before=before,
|
||||
after={"status": "available", "reason": body.reason.strip()},
|
||||
)
|
||||
db.commit()
|
||||
return VehicleOut(
|
||||
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=False,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.api.routers import (
|
||||
knowledge,
|
||||
mcp_integrations,
|
||||
search,
|
||||
users,
|
||||
vehicles,
|
||||
workflows,
|
||||
)
|
||||
@@ -100,3 +101,4 @@ app.include_router(knowledge.router)
|
||||
app.include_router(mcp_integrations.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(integration_status.router)
|
||||
app.include_router(users.router)
|
||||
|
||||
@@ -17,6 +17,28 @@ class PasswordLoginRequest(BaseModel):
|
||||
password: str = Field(min_length=8, max_length=256)
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
public_ref: str
|
||||
email: str | None
|
||||
display_name: str
|
||||
role: Role
|
||||
active: bool
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
display_name: str = Field(min_length=2, max_length=120)
|
||||
role: Role
|
||||
password: str = Field(min_length=8, max_length=256)
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
display_name: str | None = Field(default=None, min_length=2, max_length=120)
|
||||
role: Role | None = None
|
||||
active: bool | None = None
|
||||
password: str | None = Field(default=None, min_length=8, max_length=256)
|
||||
|
||||
|
||||
class CurrentUser(BaseModel):
|
||||
public_ref: str
|
||||
display_name: str
|
||||
@@ -88,6 +110,25 @@ class CancelBookingRequest(BaseModel):
|
||||
reason: str = Field(min_length=3, max_length=500)
|
||||
|
||||
|
||||
class CheckoutBookingRequest(BaseModel):
|
||||
start_odometer_km: Annotated[int, Field(ge=0)]
|
||||
fuel_level_percent: Annotated[int, Field(ge=0, le=100)]
|
||||
cleanliness_ok: bool
|
||||
damage_reported: bool = False
|
||||
technical_warning: bool = False
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class CheckoutBookingResult(BaseModel):
|
||||
booking_ref: str
|
||||
vehicle_ref: str
|
||||
inspection_ref: str
|
||||
booking_status: str
|
||||
resulting_vehicle_status: str
|
||||
activated: bool
|
||||
attention_reasons: list[str]
|
||||
|
||||
|
||||
class BookingPageOut(BaseModel):
|
||||
items: list[BookingOut]
|
||||
page: int
|
||||
@@ -158,6 +199,19 @@ class MaintenanceOut(BaseModel):
|
||||
summary: str
|
||||
|
||||
|
||||
class CreateMaintenanceRequest(BaseModel):
|
||||
occurred_at: datetime
|
||||
odometer_km: Annotated[int, Field(ge=0)]
|
||||
category: Literal["periodic_service", "repair", "inspection", "tyres", "other"]
|
||||
summary: str = Field(min_length=3, max_length=2000)
|
||||
next_service_km: Annotated[int | None, Field(default=None, ge=0)]
|
||||
mark_maintenance: bool = True
|
||||
|
||||
|
||||
class ReleaseVehicleRequest(BaseModel):
|
||||
reason: str = Field(min_length=3, max_length=500)
|
||||
|
||||
|
||||
class DataQualityIssueOut(BaseModel):
|
||||
public_ref: str
|
||||
rule_type: str
|
||||
|
||||
@@ -133,6 +133,33 @@ def test_concurrent_bookings_only_reserve_vehicle_once():
|
||||
assert results.count(409) == 1
|
||||
|
||||
|
||||
def test_checkout_records_inspection_and_activates_safe_booking(ops_client):
|
||||
window = {"starts_at": "2050-09-01T10:00:00Z", "ends_at": "2050-09-02T12:00:00Z"}
|
||||
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||
vehicle_option = next(item for item in available if item["operational_status"] == "available")
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
|
||||
booking = ops_client.post(
|
||||
"/api/v1/bookings",
|
||||
json={"customer_ref": "CUS-0001", "vehicle_ref": vehicle["public_ref"], **window},
|
||||
).json()
|
||||
response = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||
json={
|
||||
"start_odometer_km": vehicle["odometer_km"],
|
||||
"fuel_level_percent": 95,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": False,
|
||||
"technical_warning": False,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["activated"] is True
|
||||
assert response.json()["booking_status"] == "active"
|
||||
updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
|
||||
assert updated_vehicle["operational_status"] == "rented"
|
||||
assert any(item["type"] == "checkout" for item in updated_vehicle["inspections"])
|
||||
|
||||
|
||||
def test_get_booking_detail(ops_client):
|
||||
response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN")
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
def test_manager_can_create_and_update_user(ops_client):
|
||||
created = ops_client.post(
|
||||
"/api/v1/users",
|
||||
json={
|
||||
"email": "planner@example.test",
|
||||
"display_name": "Fleet Planner",
|
||||
"role": "rental_employee",
|
||||
"password": "a-secure-demo-password",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
public_ref = created.json()["public_ref"]
|
||||
assert created.json()["active"] is True
|
||||
updated = ops_client.patch(
|
||||
f"/api/v1/users/{public_ref}",
|
||||
json={"display_name": "Senior Fleet Planner", "active": False},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["display_name"] == "Senior Fleet Planner"
|
||||
assert updated.json()["active"] is False
|
||||
assert any(user["public_ref"] == public_ref for user in ops_client.get("/api/v1/users").json())
|
||||
|
||||
|
||||
def test_employee_cannot_manage_users(employee_client):
|
||||
assert employee_client.get("/api/v1/users").status_code == 403
|
||||
@@ -37,3 +37,44 @@ def test_vehicle_detail_includes_related_records(ops_client):
|
||||
def test_vehicle_detail_404_for_unknown_ref(ops_client):
|
||||
response = ops_client.get("/api/v1/vehicles/MO-999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_manager_can_record_maintenance_and_release_vehicle(ops_client):
|
||||
vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[0]
|
||||
response = ops_client.post(
|
||||
f"/api/v1/vehicles/{vehicle['public_ref']}/maintenance",
|
||||
json={
|
||||
"occurred_at": "2051-01-15T09:00:00Z",
|
||||
"odometer_km": vehicle["odometer_km"] + 100,
|
||||
"category": "periodic_service",
|
||||
"summary": "Periodic service completed and safety checks passed.",
|
||||
"next_service_km": vehicle["odometer_km"] + 20100,
|
||||
"mark_maintenance": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
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"]
|
||||
)
|
||||
released = ops_client.post(
|
||||
f"/api/v1/vehicles/{vehicle['public_ref']}/release",
|
||||
json={"reason": "Service completed and vehicle inspected"},
|
||||
)
|
||||
assert released.status_code == 200
|
||||
assert released.json()["operational_status"] == "available"
|
||||
|
||||
|
||||
def test_employee_cannot_record_maintenance(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/vehicles/MO-001/maintenance",
|
||||
json={
|
||||
"occurred_at": "2051-01-15T09:00:00Z",
|
||||
"odometer_km": 100,
|
||||
"category": "repair",
|
||||
"summary": "Unauthorised attempt",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Knowledge } from "./pages/Knowledge";
|
||||
import { Audit } from "./pages/Audit";
|
||||
import { AboutDemo } from "./pages/AboutDemo";
|
||||
import { Scenarios } from "./pages/Scenarios";
|
||||
import { Users } from "./pages/Users";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
@@ -44,6 +45,7 @@ export function App() {
|
||||
<Route path="/automation" element={<Automation />} />
|
||||
<Route path="/knowledge" element={<Knowledge />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/about" element={<AboutDemo />} />
|
||||
<Route path="/scenarios" element={<Scenarios />} />
|
||||
</Route>
|
||||
|
||||
@@ -51,4 +51,6 @@ 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 }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
};
|
||||
|
||||
@@ -66,6 +66,24 @@ export interface AvailableVehicle {
|
||||
operational_status: string;
|
||||
}
|
||||
|
||||
export interface CheckoutBookingResult {
|
||||
booking_ref: string;
|
||||
vehicle_ref: string;
|
||||
inspection_ref: string;
|
||||
booking_status: string;
|
||||
resulting_vehicle_status: string;
|
||||
activated: boolean;
|
||||
attention_reasons: string[];
|
||||
}
|
||||
|
||||
export interface UserRecord {
|
||||
public_ref: string;
|
||||
email: string | null;
|
||||
display_name: string;
|
||||
role: Role;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Inspection {
|
||||
public_ref: string;
|
||||
booking_ref: string;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import type { CheckoutBookingResult, VehicleDetail } from "../api/types";
|
||||
import { ApiErrorNotice, LoadingState, SectionHeading } from "./PageChrome";
|
||||
|
||||
export function CheckoutForm({ bookingRef, vehicleRef, onRecorded }: { bookingRef: string; vehicleRef: string; onRecorded: (result: CheckoutBookingResult) => void }) {
|
||||
const { t } = useTranslation(["bookings", "errors"]);
|
||||
const [odometer, setOdometer] = useState<number | null>(null);
|
||||
const [fuel, setFuel] = useState(100);
|
||||
const [clean, setClean] = useState(true);
|
||||
const [damage, setDamage] = useState(false);
|
||||
const [warning, setWarning] = useState(false);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<VehicleDetail>(`/api/v1/vehicles/${vehicleRef}`)
|
||||
.then((vehicle) => setOdometer(vehicle.odometer_km))
|
||||
.catch((err) => setError(describeApiError(t, err)));
|
||||
}, [t, vehicleRef]);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (odometer === null) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.post<CheckoutBookingResult>(`/api/v1/bookings/${bookingRef}/checkout`, {
|
||||
start_odometer_km: odometer,
|
||||
fuel_level_percent: fuel,
|
||||
cleanliness_ok: clean,
|
||||
damage_reported: damage,
|
||||
technical_warning: warning,
|
||||
notes: notes || null,
|
||||
});
|
||||
onRecorded(result);
|
||||
} catch (err) {
|
||||
setError(describeApiError(t, err, "bookings:checkout.failed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (odometer === null && !error) return <LoadingState label={t("checkout.preparing")} />;
|
||||
return <form className="return-form record-surface" onSubmit={submit}>
|
||||
<SectionHeading title={t("checkout.title")} description={t("checkout.description")} />
|
||||
<div className="return-capture">
|
||||
<ApiErrorNotice error={error} />
|
||||
<div className="form-grid">
|
||||
<label>{t("checkout.odometer")}<input type="number" min={0} required value={odometer ?? ""} onChange={(event) => setOdometer(Number(event.target.value))} /></label>
|
||||
<label>{t("checkout.fuel")}<input type="number" min={0} max={100} required value={fuel} onChange={(event) => setFuel(Number(event.target.value))} /></label>
|
||||
</div>
|
||||
<fieldset className="condition-fieldset"><legend>{t("checkout.condition")}</legend>
|
||||
<label className="check-card"><input type="checkbox" checked={clean} onChange={(event) => setClean(event.target.checked)} /> {t("checkout.clean")}</label>
|
||||
<label className="check-card"><input type="checkbox" checked={damage} onChange={(event) => setDamage(event.target.checked)} /> {t("checkout.damage")}</label>
|
||||
<label className="check-card"><input type="checkbox" checked={warning} onChange={(event) => setWarning(event.target.checked)} /> {t("checkout.warning")}</label>
|
||||
</fieldset>
|
||||
<label>{t("checkout.notes")}<textarea maxLength={2000} value={notes} onChange={(event) => setNotes(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className="form-actions"><button type="submit" className="button button-primary" disabled={saving || odometer === null}>{saving ? t("checkout.saving") : t("checkout.submit")}</button></div>
|
||||
</form>;
|
||||
}
|
||||
@@ -69,6 +69,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
|
||||
{ to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" },
|
||||
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
|
||||
{ to: "/audit", labelKey: "items.audit", icon: "audit", roles: ["operations_manager"] },
|
||||
{ to: "/users", labelKey: "items.users", icon: "activity", roles: ["operations_manager"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import type { MaintenanceRecord, VehicleDetail } from "../api/types";
|
||||
import { ApiErrorNotice } from "./PageChrome";
|
||||
|
||||
export function VehicleMaintenanceActions({ vehicle, onSaved }: { vehicle: VehicleDetail; onSaved: () => void }) {
|
||||
const { t } = useTranslation(["fleet", "errors"]);
|
||||
const [showRecord, setShowRecord] = useState(false);
|
||||
const [occurredAt, setOccurredAt] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [odometer, setOdometer] = useState(vehicle.odometer_km);
|
||||
const [category, setCategory] = useState("periodic_service");
|
||||
const [summary, setSummary] = useState("");
|
||||
const [nextService, setNextService] = useState(vehicle.next_service_km);
|
||||
const [releaseReason, setReleaseReason] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
|
||||
async function recordMaintenance(event: FormEvent) {
|
||||
event.preventDefault(); setSaving(true); setError(null);
|
||||
try {
|
||||
await api.post<MaintenanceRecord>(`/api/v1/vehicles/${vehicle.public_ref}/maintenance`, {
|
||||
occurred_at: new Date(`${occurredAt}T12:00:00`).toISOString(), odometer_km: odometer,
|
||||
category, summary, next_service_km: nextService, mark_maintenance: true,
|
||||
});
|
||||
setShowRecord(false); setSummary(""); onSaved();
|
||||
} catch (err) { setError(describeApiError(t, err, "fleet:maintenance.failed")); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
async function release(event: FormEvent) {
|
||||
event.preventDefault(); setSaving(true); setError(null);
|
||||
try {
|
||||
await api.post(`/api/v1/vehicles/${vehicle.public_ref}/release`, { reason: releaseReason });
|
||||
setReleaseReason(""); onSaved();
|
||||
} catch (err) { setError(describeApiError(t, err, "fleet:maintenance.releaseFailed")); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return <section className="record-surface maintenance-actions">
|
||||
<div className="section-heading"><div><h2>{t("maintenance.actionsTitle")}</h2><p>{t("maintenance.actionsDescription")}</p></div>{!showRecord && <button type="button" className="button button-secondary" onClick={() => setShowRecord(true)}>{t("maintenance.add")}</button>}</div>
|
||||
<ApiErrorNotice error={error} />
|
||||
{showRecord && <form onSubmit={recordMaintenance}>
|
||||
<div className="form-grid">
|
||||
<label>{t("maintenance.date")}<input type="date" required value={occurredAt} onChange={(event) => setOccurredAt(event.target.value)} /></label>
|
||||
<label>{t("maintenance.odometer")}<input type="number" min={vehicle.odometer_km} required value={odometer} onChange={(event) => setOdometer(Number(event.target.value))} /></label>
|
||||
<label>{t("maintenance.category")}<select value={category} onChange={(event) => setCategory(event.target.value)}>{["periodic_service", "repair", "inspection", "tyres", "other"].map((value) => <option value={value} key={value}>{t(`detail.maintenanceCategories.${value}`, { defaultValue: value })}</option>)}</select></label>
|
||||
<label>{t("maintenance.nextService")}<input type="number" min={odometer} required value={nextService} onChange={(event) => setNextService(Number(event.target.value))} /></label>
|
||||
</div>
|
||||
<label>{t("maintenance.summary")}<textarea required minLength={3} maxLength={2000} value={summary} onChange={(event) => setSummary(event.target.value)} /></label>
|
||||
<div className="form-actions"><button type="button" className="button button-secondary" onClick={() => setShowRecord(false)}>{t("maintenance.cancel")}</button><button className="button button-primary" disabled={saving}>{saving ? t("maintenance.saving") : t("maintenance.save")}</button></div>
|
||||
</form>}
|
||||
{["cleaning", "maintenance", "blocked"].includes(vehicle.operational_status) && <form className="release-form" onSubmit={release}><label>{t("maintenance.releaseReason")}<input required minLength={3} maxLength={500} value={releaseReason} onChange={(event) => setReleaseReason(event.target.value)} placeholder={t("maintenance.releasePlaceholder")} /></label><button className="button button-primary" disabled={saving || releaseReason.trim().length < 3}>{t("maintenance.release")}</button></form>}
|
||||
</section>;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import auditNl from "./locales/nl-BE/audit.json";
|
||||
import demoNl from "./locales/nl-BE/demo.json";
|
||||
import errorsNl from "./locales/nl-BE/errors.json";
|
||||
import accessibilityNl from "./locales/nl-BE/accessibility.json";
|
||||
import operationsNl from "./locales/nl-BE/operations.json";
|
||||
|
||||
import commonEn from "./locales/en-GB/common.json";
|
||||
import authEn from "./locales/en-GB/auth.json";
|
||||
@@ -30,6 +31,7 @@ import auditEn from "./locales/en-GB/audit.json";
|
||||
import demoEn from "./locales/en-GB/demo.json";
|
||||
import errorsEn from "./locales/en-GB/errors.json";
|
||||
import accessibilityEn from "./locales/en-GB/accessibility.json";
|
||||
import operationsEn from "./locales/en-GB/operations.json";
|
||||
|
||||
import commonFr from "./locales/fr-BE/common.json";
|
||||
import authFr from "./locales/fr-BE/auth.json";
|
||||
@@ -45,6 +47,7 @@ import auditFr from "./locales/fr-BE/audit.json";
|
||||
import demoFr from "./locales/fr-BE/demo.json";
|
||||
import errorsFr from "./locales/fr-BE/errors.json";
|
||||
import accessibilityFr from "./locales/fr-BE/accessibility.json";
|
||||
import operationsFr from "./locales/fr-BE/operations.json";
|
||||
|
||||
export const SUPPORTED_LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
|
||||
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
@@ -66,6 +69,7 @@ export const NAMESPACES = [
|
||||
"demo",
|
||||
"errors",
|
||||
"accessibility",
|
||||
"operations",
|
||||
] as const;
|
||||
|
||||
function readStoredLanguage(): SupportedLanguage {
|
||||
@@ -117,6 +121,7 @@ void i18n
|
||||
demo: demoNl,
|
||||
errors: errorsNl,
|
||||
accessibility: accessibilityNl,
|
||||
operations: operationsNl,
|
||||
},
|
||||
"en-GB": {
|
||||
common: commonEn,
|
||||
@@ -133,6 +138,7 @@ void i18n
|
||||
demo: demoEn,
|
||||
errors: errorsEn,
|
||||
accessibility: accessibilityEn,
|
||||
operations: operationsEn,
|
||||
},
|
||||
"fr-BE": {
|
||||
common: commonFr,
|
||||
@@ -149,6 +155,7 @@ void i18n
|
||||
demo: demoFr,
|
||||
errors: errorsFr,
|
||||
accessibility: accessibilityFr,
|
||||
operations: operationsFr,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -57,6 +57,25 @@
|
||||
"cancelled": "cancelled",
|
||||
"blocked": "blocked"
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Check out vehicle",
|
||||
"description": "Record departure condition. Any exception automatically blocks the rental for follow-up.",
|
||||
"preparing": "Preparing departure inspection…",
|
||||
"odometer": "Start odometer",
|
||||
"fuel": "Fuel level (%)",
|
||||
"condition": "Departure condition",
|
||||
"clean": "Vehicle is clean",
|
||||
"damage": "Damage found",
|
||||
"warning": "Technical warning",
|
||||
"notes": "Notes",
|
||||
"submit": "Record inspection and start rental",
|
||||
"saving": "Recording checkout…",
|
||||
"failed": "The departure inspection could not be recorded.",
|
||||
"activatedTitle": "Rental started",
|
||||
"activatedDetail": "Departure inspection {{inspection}} was saved and the vehicle is now rented.",
|
||||
"blockedTitle": "Rental blocked",
|
||||
"blockedDetail": "Departure inspection {{inspection}} contains an exception. The vehicle was safely removed from service."
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Booking ledger",
|
||||
"eyebrow": "Bookings / Rental record",
|
||||
|
||||
@@ -70,7 +70,11 @@
|
||||
},
|
||||
"maintenanceCategories": {
|
||||
"periodic_service": "Periodic service",
|
||||
"repair": "Repair"
|
||||
}
|
||||
"repair": "Repair",
|
||||
"inspection": "Technical inspection",
|
||||
"tyres": "Tyres",
|
||||
"other": "Other"
|
||||
}
|
||||
},
|
||||
"maintenance": { "actionsTitle": "Maintenance actions", "actionsDescription": "Record completed work or release a safe vehicle back into service.", "add": "Record maintenance", "date": "Date", "odometer": "Odometer", "category": "Category", "nextService": "Next service (km)", "summary": "Work completed", "cancel": "Cancel", "save": "Save maintenance", "saving": "Saving…", "failed": "Maintenance could not be saved.", "releaseReason": "Release reason", "releasePlaceholder": "Which check confirms the vehicle is safe for service?", "release": "Release vehicle", "releaseFailed": "The vehicle cannot be released yet." }
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"quality": "Quality workbench",
|
||||
"knowledge": "Procedure assistant",
|
||||
"integrations": "Automation and integration status",
|
||||
"audit": "Audit history"
|
||||
"audit": "Audit history",
|
||||
"users": "Users"
|
||||
},
|
||||
"switchRole": "Switch role",
|
||||
"switchRoleTitle": "Switch demo role",
|
||||
|
||||
@@ -0,0 +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." },
|
||||
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Rental employee" }
|
||||
}
|
||||
@@ -57,6 +57,25 @@
|
||||
"cancelled": "annulé",
|
||||
"blocked": "bloqué"
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Remettre le véhicule",
|
||||
"description": "Enregistrez l’état de départ. Toute anomalie bloque automatiquement la location pour suivi.",
|
||||
"preparing": "Préparation de l’inspection de départ…",
|
||||
"odometer": "Kilométrage de départ",
|
||||
"fuel": "Niveau de carburant (%)",
|
||||
"condition": "État au départ",
|
||||
"clean": "Le véhicule est propre",
|
||||
"damage": "Dommage constaté",
|
||||
"warning": "Alerte technique",
|
||||
"notes": "Notes",
|
||||
"submit": "Enregistrer l’inspection et démarrer la location",
|
||||
"saving": "Enregistrement du départ…",
|
||||
"failed": "L’inspection de départ n’a pas pu être enregistrée.",
|
||||
"activatedTitle": "Location démarrée",
|
||||
"activatedDetail": "L’inspection {{inspection}} est enregistrée et le véhicule est loué.",
|
||||
"blockedTitle": "Location bloquée",
|
||||
"blockedDetail": "L’inspection {{inspection}} contient une anomalie. Le véhicule a été retiré du service en toute sécurité."
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Registre des réservations",
|
||||
"eyebrow": "Réservations / Fiche de location",
|
||||
|
||||
@@ -70,7 +70,11 @@
|
||||
},
|
||||
"maintenanceCategories": {
|
||||
"periodic_service": "Entretien périodique",
|
||||
"repair": "Réparation"
|
||||
}
|
||||
"repair": "Réparation",
|
||||
"inspection": "Contrôle technique",
|
||||
"tyres": "Pneus",
|
||||
"other": "Autre"
|
||||
}
|
||||
},
|
||||
"maintenance": { "actionsTitle": "Actions d’entretien", "actionsDescription": "Enregistrez les travaux effectués ou remettez un véhicule sûr en service.", "add": "Enregistrer un entretien", "date": "Date", "odometer": "Kilométrage", "category": "Catégorie", "nextService": "Prochain entretien (km)", "summary": "Travaux effectués", "cancel": "Annuler", "save": "Enregistrer", "saving": "Enregistrement…", "failed": "L’entretien n’a pas pu être enregistré.", "releaseReason": "Motif de remise en service", "releasePlaceholder": "Quel contrôle confirme que le véhicule peut être remis en service ?", "release": "Remettre en service", "releaseFailed": "Le véhicule ne peut pas encore être remis en service." }
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"quality": "Atelier qualité",
|
||||
"knowledge": "Assistant de procédures",
|
||||
"integrations": "Statut d'automatisation et d'intégration",
|
||||
"audit": "Historique d'audit"
|
||||
"audit": "Historique d’audit",
|
||||
"users": "Utilisateurs"
|
||||
},
|
||||
"switchRole": "Changer de rôle",
|
||||
"switchRoleTitle": "Changer de rôle de démo",
|
||||
|
||||
@@ -0,0 +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." },
|
||||
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Employé de location" }
|
||||
}
|
||||
@@ -57,6 +57,25 @@
|
||||
"cancelled": "geannuleerd",
|
||||
"blocked": "geblokkeerd"
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Voertuig uitchecken",
|
||||
"description": "Leg de vertrekstaat vast. Een afwijking blokkeert de huur automatisch voor opvolging.",
|
||||
"preparing": "Vertrekinspectie voorbereiden…",
|
||||
"odometer": "Startkilometerstand",
|
||||
"fuel": "Brandstofniveau (%)",
|
||||
"condition": "Vertrekstaat",
|
||||
"clean": "Voertuig is schoon",
|
||||
"damage": "Schade vastgesteld",
|
||||
"warning": "Technische melding",
|
||||
"notes": "Notities",
|
||||
"submit": "Inspectie vastleggen en huur starten",
|
||||
"saving": "Vertrek vastleggen…",
|
||||
"failed": "De vertrekinspectie kon niet worden vastgelegd.",
|
||||
"activatedTitle": "Huur is gestart",
|
||||
"activatedDetail": "Vertrekinspectie {{inspection}} is opgeslagen en het voertuig staat op verhuurd.",
|
||||
"blockedTitle": "Huur is geblokkeerd",
|
||||
"blockedDetail": "Vertrekinspectie {{inspection}} bevat een afwijking. Het voertuig is veilig uit inzet genomen."
|
||||
},
|
||||
"detail": {
|
||||
"backLink": "Boekingsoverzicht",
|
||||
"eyebrow": "Boekingen / Huurrecord",
|
||||
|
||||
@@ -70,7 +70,11 @@
|
||||
},
|
||||
"maintenanceCategories": {
|
||||
"periodic_service": "Periodiek onderhoud",
|
||||
"repair": "Herstelling"
|
||||
}
|
||||
"repair": "Herstelling",
|
||||
"inspection": "Technische inspectie",
|
||||
"tyres": "Banden",
|
||||
"other": "Overig"
|
||||
}
|
||||
},
|
||||
"maintenance": { "actionsTitle": "Onderhoudsacties", "actionsDescription": "Registreer uitgevoerd werk of geef een veilig voertuig opnieuw vrij.", "add": "Onderhoud registreren", "date": "Datum", "odometer": "Kilometerstand", "category": "Categorie", "nextService": "Volgend onderhoud (km)", "summary": "Uitgevoerd werk", "cancel": "Annuleren", "save": "Onderhoud opslaan", "saving": "Opslaan…", "failed": "Het onderhoud kon niet worden opgeslagen.", "releaseReason": "Reden voor vrijgave", "releasePlaceholder": "Welke controle bevestigt dat het voertuig veilig inzetbaar is?", "release": "Voertuig vrijgeven", "releaseFailed": "Het voertuig kan nog niet worden vrijgegeven." }
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"quality": "Datakwaliteit",
|
||||
"knowledge": "Kennis",
|
||||
"integrations": "Integraties",
|
||||
"audit": "Auditgeschiedenis"
|
||||
"audit": "Auditgeschiedenis",
|
||||
"users": "Gebruikers"
|
||||
},
|
||||
"primaryNavLabel": "Hoofdnavigatie",
|
||||
"mobileNavLabel": "Mobiele navigatie",
|
||||
|
||||
@@ -0,0 +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." },
|
||||
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Verhuurmedewerker" }
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import type { Booking, RegisterReturnResult, VehicleDetail } from "../api/types";
|
||||
import type { Booking, CheckoutBookingResult, RegisterReturnResult, VehicleDetail } from "../api/types";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { StatusBadge } from "../components/Badge";
|
||||
@@ -12,6 +12,7 @@ import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import { ApiErrorNotice } from "../components/PageChrome";
|
||||
import { CheckoutForm } from "../components/CheckoutForm";
|
||||
|
||||
export function BookingDetail() {
|
||||
const { t } = useTranslation(["bookings", "returns", "errors"]);
|
||||
@@ -25,6 +26,7 @@ export function BookingDetail() {
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
|
||||
const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!publicRef) return;
|
||||
@@ -77,6 +79,11 @@ export function BookingDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleCheckout(result: CheckoutBookingResult) {
|
||||
setCheckoutResult(result);
|
||||
load();
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />;
|
||||
if (!booking) return <LoadingState label={t("detail.loading")} />;
|
||||
|
||||
@@ -101,6 +108,9 @@ export function BookingDetail() {
|
||||
<div className="form-actions"><button className="button button-danger" type="submit" disabled={cancelling || cancelReason.trim().length < 3}>{cancelling ? t("detail.cancelling") : t("detail.confirmCancel")}</button></div>
|
||||
</form>}
|
||||
|
||||
{checkoutResult && <section className={`record-surface checkout-result ${checkoutResult.activated ? "success" : "warning"}`} role="status"><h2>{t(checkoutResult.activated ? "checkout.activatedTitle" : "checkout.blockedTitle")}</h2><p>{t(checkoutResult.activated ? "checkout.activatedDetail" : "checkout.blockedDetail", { inspection: checkoutResult.inspection_ref })}</p></section>}
|
||||
{!checkoutResult && booking.status === "reserved" && <CheckoutForm bookingRef={booking.public_ref} vehicleRef={booking.vehicle_ref} onRecorded={handleCheckout} />}
|
||||
|
||||
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
|
||||
<section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}>
|
||||
<Icon name="spark" />
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
|
||||
import type { Role, UserRecord } from "../api/types";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { ApiErrorNotice, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
|
||||
export function Users() {
|
||||
const { t } = useTranslation(["operations", "errors"]);
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<UserRecord[] | null>(null);
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<Role>("rental_employee");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<ApiErrorInfo | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
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);
|
||||
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); }
|
||||
}
|
||||
|
||||
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")); }
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api/client";
|
||||
@@ -7,6 +7,8 @@ import { useLocaleFormat } from "../i18n/format";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
import { VehicleMaintenanceActions } from "../components/VehicleMaintenanceActions";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
@@ -18,16 +20,18 @@ export function VehicleDetail() {
|
||||
const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const load = useCallback(() => {
|
||||
if (!publicRef) return;
|
||||
setVehicle(null);
|
||||
setError(null);
|
||||
api
|
||||
.get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`)
|
||||
.then(setVehicle)
|
||||
.catch(() => setError(t("detail.notFound")));
|
||||
}, [publicRef]);
|
||||
}, [publicRef, t]);
|
||||
|
||||
useEffect(() => { setVehicle(null); load(); }, [load]);
|
||||
|
||||
if (error) return <ErrorState message={error} />;
|
||||
if (!vehicle) return <LoadingState label={t("detail.loading")} />;
|
||||
@@ -95,7 +99,7 @@ export function VehicleDetail() {
|
||||
)}
|
||||
|
||||
{tab === "maintenance" && (
|
||||
<ul className="record-list">
|
||||
<><ul className="record-list">
|
||||
{vehicle.maintenance.length === 0 && <li>{t("detail.noMaintenance")}</li>}
|
||||
{vehicle.maintenance.map((m) => (
|
||||
<li key={m.public_ref}>
|
||||
@@ -104,7 +108,7 @@ export function VehicleDetail() {
|
||||
<time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ul>{user?.role === "operations_manager" && <VehicleMaintenanceActions vehicle={vehicle} onSaved={load} />}</>
|
||||
)}
|
||||
|
||||
{tab === "quality" && (
|
||||
|
||||
@@ -280,6 +280,19 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
||||
.booking-cancel-form h2 { margin: 0 0 14px; font-size: 1rem; }
|
||||
.booking-cancel-form label { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }
|
||||
.booking-cancel-form textarea { min-height: 92px; padding: 10px 11px; color: var(--ink); background: white; border: 1px solid var(--line-strong); border-radius: var(--radius); font: inherit; }
|
||||
.checkout-result { margin-top: 18px; padding: 18px 22px; }
|
||||
.checkout-result h2 { margin: 0 0 6px; font-size: 1rem; }
|
||||
.checkout-result p { margin: 0; color: var(--ink-soft); }
|
||||
.checkout-result.success { border-left: 4px solid var(--success); }
|
||||
.checkout-result.warning { border-left: 4px solid var(--warning); }
|
||||
.maintenance-actions, .user-create { margin-top: 18px; padding: 20px; }
|
||||
.maintenance-actions > form > label, .release-form label { display: flex; flex-direction: column; gap: 6px; color: var(--ink-soft); font-size: var(--type-label); font-weight: 700; }
|
||||
.maintenance-actions textarea { min-height: 92px; padding: 10px 11px; border: 1px solid var(--line-strong); border-radius: var(--radius); font: inherit; }
|
||||
.release-form { display: flex; align-items: end; gap: 10px; padding-top: 16px; border-top: 1px solid var(--line); }
|
||||
.release-form label { flex: 1; }
|
||||
.release-form input { min-height: 44px; padding: 8px 11px; border: 1px solid var(--line-strong); border-radius: var(--radius); }
|
||||
.user-create h2 { margin: 0 0 14px; font-size: 1rem; }
|
||||
.table-secondary { display: block; color: var(--ink-soft); font-size: var(--type-meta); font-weight: 400; }
|
||||
.condition-fieldset { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 0 0 17px; padding: 0; border: 0; }.condition-fieldset legend { margin-bottom: 8px; color: var(--ink-soft); font-size: .67rem; font-weight: 700; }.check-card { min-height: 48px; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.return-form textarea { resize: vertical; min-height: 86px; }.form-actions { display: flex; justify-content: flex-end; gap: 9px; padding: 15px 22px; background: var(--surface-subtle); border-top: 1px solid var(--line); }
|
||||
.review-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin: 0 0 16px; background: var(--line); border: 1px solid var(--line); }.review-facts div { padding: 12px; background: white; }.review-facts dt { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; }.review-facts dd { margin: 6px 0 0; font-size: var(--type-body); font-weight: 700; }
|
||||
|
||||
Reference in New Issue
Block a user