M12: complete daily operations cycle

This commit is contained in:
NuklearRabbit
2026-08-10 03:15:46 +02:00
parent 4a3c3bd0a9
commit 15bdbe40ac
32 changed files with 802 additions and 18 deletions
+18
View File
@@ -2296,3 +2296,21 @@ evidence yet."
the full Unraid Compose suite: **203 passed, 1 warning**. the full Unraid Compose suite: **203 passed, 1 warning**.
- Exact next action: implement audited checkout/activation, maintenance capture and - Exact next action: implement audited checkout/activation, maintenance capture and
operations-manager user administration, then repeat the complete validation gate. 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.
+98 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
from sqlalchemy import func, or_, select 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.api.deps import get_current_user, get_db
from app.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.inspection import Inspection
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
AvailableVehicleOut, AvailableVehicleOut,
BookingOut, BookingOut,
BookingPageOut, BookingPageOut,
CancelBookingRequest, CancelBookingRequest,
CheckoutBookingRequest,
CheckoutBookingResult,
CreateBookingRequest, CreateBookingRequest,
CurrentUser, CurrentUser,
NextBookingRisk, NextBookingRisk,
@@ -154,6 +157,100 @@ def create_booking(
return _to_out(booking, customer, vehicle) 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]) @router.get("/availability", response_model=list[AvailableVehicleOut])
def list_available_vehicles( def list_available_vehicles(
starts_at: datetime, starts_at: datetime,
+100
View File
@@ -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)
+107 -1
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session 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.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue 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.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
BookingSummaryOut, BookingSummaryOut,
CreateMaintenanceRequest,
CurrentUser, CurrentUser,
DataQualityIssueOut, DataQualityIssueOut,
InspectionOut, InspectionOut,
MaintenanceOut, MaintenanceOut,
ReleaseVehicleRequest,
VehicleDetailOut, VehicleDetailOut,
VehicleOut, VehicleOut,
VehiclePageOut, VehiclePageOut,
) )
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"]) router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
@@ -192,3 +197,104 @@ def get_vehicle(
for q in issues 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,
)
+2
View File
@@ -18,6 +18,7 @@ from app.api.routers import (
knowledge, knowledge,
mcp_integrations, mcp_integrations,
search, search,
users,
vehicles, vehicles,
workflows, workflows,
) )
@@ -100,3 +101,4 @@ app.include_router(knowledge.router)
app.include_router(mcp_integrations.router) app.include_router(mcp_integrations.router)
app.include_router(search.router) app.include_router(search.router)
app.include_router(integration_status.router) app.include_router(integration_status.router)
app.include_router(users.router)
+54
View File
@@ -17,6 +17,28 @@ class PasswordLoginRequest(BaseModel):
password: str = Field(min_length=8, max_length=256) 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): class CurrentUser(BaseModel):
public_ref: str public_ref: str
display_name: str display_name: str
@@ -88,6 +110,25 @@ class CancelBookingRequest(BaseModel):
reason: str = Field(min_length=3, max_length=500) 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): class BookingPageOut(BaseModel):
items: list[BookingOut] items: list[BookingOut]
page: int page: int
@@ -158,6 +199,19 @@ class MaintenanceOut(BaseModel):
summary: str 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): class DataQualityIssueOut(BaseModel):
public_ref: str public_ref: str
rule_type: str rule_type: str
+27
View File
@@ -133,6 +133,33 @@ def test_concurrent_bookings_only_reserve_vehicle_once():
assert results.count(409) == 1 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): def test_get_booking_detail(ops_client):
response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN") response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN")
assert response.status_code == 200 assert response.status_code == 200
+25
View File
@@ -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
+41
View File
@@ -37,3 +37,44 @@ def test_vehicle_detail_includes_related_records(ops_client):
def test_vehicle_detail_404_for_unknown_ref(ops_client): def test_vehicle_detail_404_for_unknown_ref(ops_client):
response = ops_client.get("/api/v1/vehicles/MO-999") response = ops_client.get("/api/v1/vehicles/MO-999")
assert response.status_code == 404 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
+2
View File
@@ -18,6 +18,7 @@ import { Knowledge } from "./pages/Knowledge";
import { Audit } from "./pages/Audit"; import { Audit } from "./pages/Audit";
import { AboutDemo } from "./pages/AboutDemo"; import { AboutDemo } from "./pages/AboutDemo";
import { Scenarios } from "./pages/Scenarios"; import { Scenarios } from "./pages/Scenarios";
import { Users } from "./pages/Users";
export function App() { export function App() {
return ( return (
@@ -44,6 +45,7 @@ export function App() {
<Route path="/automation" element={<Automation />} /> <Route path="/automation" element={<Automation />} />
<Route path="/knowledge" element={<Knowledge />} /> <Route path="/knowledge" element={<Knowledge />} />
<Route path="/audit" element={<Audit />} /> <Route path="/audit" element={<Audit />} />
<Route path="/users" element={<Users />} />
<Route path="/about" element={<AboutDemo />} /> <Route path="/about" element={<AboutDemo />} />
<Route path="/scenarios" element={<Scenarios />} /> <Route path="/scenarios" element={<Scenarios />} />
</Route> </Route>
+2
View File
@@ -51,4 +51,6 @@ export const api = {
get: <T>(path: string) => request<T>(path), get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) => post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
request<T>(path, { method: "POST", body: body ? JSON.stringify(body) : undefined, headers }), 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) }),
}; };
+18
View File
@@ -66,6 +66,24 @@ export interface AvailableVehicle {
operational_status: string; 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 { export interface Inspection {
public_ref: string; public_ref: string;
booking_ref: string; booking_ref: string;
+65
View File
@@ -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>;
}
+1
View File
@@ -69,6 +69,7 @@ const NAV_GROUPS: Array<{ labelKey: string; items: NavItem[] }> = [
{ to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" }, { to: "/knowledge", labelKey: "items.knowledge", icon: "knowledge" },
{ to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] }, { to: "/automation", labelKey: "items.integrations", icon: "integrations", roles: ["operations_manager"] },
{ to: "/audit", labelKey: "items.audit", icon: "audit", 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>;
}
+7
View File
@@ -15,6 +15,7 @@ import auditNl from "./locales/nl-BE/audit.json";
import demoNl from "./locales/nl-BE/demo.json"; import demoNl from "./locales/nl-BE/demo.json";
import errorsNl from "./locales/nl-BE/errors.json"; import errorsNl from "./locales/nl-BE/errors.json";
import accessibilityNl from "./locales/nl-BE/accessibility.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 commonEn from "./locales/en-GB/common.json";
import authEn from "./locales/en-GB/auth.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 demoEn from "./locales/en-GB/demo.json";
import errorsEn from "./locales/en-GB/errors.json"; import errorsEn from "./locales/en-GB/errors.json";
import accessibilityEn from "./locales/en-GB/accessibility.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 commonFr from "./locales/fr-BE/common.json";
import authFr from "./locales/fr-BE/auth.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 demoFr from "./locales/fr-BE/demo.json";
import errorsFr from "./locales/fr-BE/errors.json"; import errorsFr from "./locales/fr-BE/errors.json";
import accessibilityFr from "./locales/fr-BE/accessibility.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 const SUPPORTED_LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
@@ -66,6 +69,7 @@ export const NAMESPACES = [
"demo", "demo",
"errors", "errors",
"accessibility", "accessibility",
"operations",
] as const; ] as const;
function readStoredLanguage(): SupportedLanguage { function readStoredLanguage(): SupportedLanguage {
@@ -117,6 +121,7 @@ void i18n
demo: demoNl, demo: demoNl,
errors: errorsNl, errors: errorsNl,
accessibility: accessibilityNl, accessibility: accessibilityNl,
operations: operationsNl,
}, },
"en-GB": { "en-GB": {
common: commonEn, common: commonEn,
@@ -133,6 +138,7 @@ void i18n
demo: demoEn, demo: demoEn,
errors: errorsEn, errors: errorsEn,
accessibility: accessibilityEn, accessibility: accessibilityEn,
operations: operationsEn,
}, },
"fr-BE": { "fr-BE": {
common: commonFr, common: commonFr,
@@ -149,6 +155,7 @@ void i18n
demo: demoFr, demo: demoFr,
errors: errorsFr, errors: errorsFr,
accessibility: accessibilityFr, accessibility: accessibilityFr,
operations: operationsFr,
}, },
}, },
}); });
@@ -57,6 +57,25 @@
"cancelled": "cancelled", "cancelled": "cancelled",
"blocked": "blocked" "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": { "detail": {
"backLink": "Booking ledger", "backLink": "Booking ledger",
"eyebrow": "Bookings / Rental record", "eyebrow": "Bookings / Rental record",
+6 -2
View File
@@ -70,7 +70,11 @@
}, },
"maintenanceCategories": { "maintenanceCategories": {
"periodic_service": "Periodic service", "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", "quality": "Quality workbench",
"knowledge": "Procedure assistant", "knowledge": "Procedure assistant",
"integrations": "Automation and integration status", "integrations": "Automation and integration status",
"audit": "Audit history" "audit": "Audit history",
"users": "Users"
}, },
"switchRole": "Switch role", "switchRole": "Switch role",
"switchRoleTitle": "Switch demo 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é", "cancelled": "annulé",
"blocked": "bloqué" "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 linspection 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 linspection et démarrer la location",
"saving": "Enregistrement du départ…",
"failed": "Linspection de départ na pas pu être enregistrée.",
"activatedTitle": "Location démarrée",
"activatedDetail": "Linspection {{inspection}} est enregistrée et le véhicule est loué.",
"blockedTitle": "Location bloquée",
"blockedDetail": "Linspection {{inspection}} contient une anomalie. Le véhicule a été retiré du service en toute sécurité."
},
"detail": { "detail": {
"backLink": "Registre des réservations", "backLink": "Registre des réservations",
"eyebrow": "Réservations / Fiche de location", "eyebrow": "Réservations / Fiche de location",
+6 -2
View File
@@ -70,7 +70,11 @@
}, },
"maintenanceCategories": { "maintenanceCategories": {
"periodic_service": "Entretien périodique", "periodic_service": "Entretien périodique",
"repair": "Réparation" "repair": "Réparation",
"inspection": "Contrôle technique",
"tyres": "Pneus",
"other": "Autre"
} }
} },
"maintenance": { "actionsTitle": "Actions dentretien", "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": "Lentretien na 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é", "quality": "Atelier qualité",
"knowledge": "Assistant de procédures", "knowledge": "Assistant de procédures",
"integrations": "Statut d'automatisation et d'intégration", "integrations": "Statut d'automatisation et d'intégration",
"audit": "Historique d'audit" "audit": "Historique daudit",
"users": "Utilisateurs"
}, },
"switchRole": "Changer de rôle", "switchRole": "Changer de rôle",
"switchRoleTitle": "Changer de rôle de démo", "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", "cancelled": "geannuleerd",
"blocked": "geblokkeerd" "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": { "detail": {
"backLink": "Boekingsoverzicht", "backLink": "Boekingsoverzicht",
"eyebrow": "Boekingen / Huurrecord", "eyebrow": "Boekingen / Huurrecord",
+6 -2
View File
@@ -70,7 +70,11 @@
}, },
"maintenanceCategories": { "maintenanceCategories": {
"periodic_service": "Periodiek onderhoud", "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", "quality": "Datakwaliteit",
"knowledge": "Kennis", "knowledge": "Kennis",
"integrations": "Integraties", "integrations": "Integraties",
"audit": "Auditgeschiedenis" "audit": "Auditgeschiedenis",
"users": "Gebruikers"
}, },
"primaryNavLabel": "Hoofdnavigatie", "primaryNavLabel": "Hoofdnavigatie",
"mobileNavLabel": "Mobiele navigatie", "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" }
}
+11 -1
View File
@@ -2,7 +2,7 @@ import { FormEvent, useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../api/client"; 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 { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format"; import { useLocaleFormat } from "../i18n/format";
import { StatusBadge } from "../components/Badge"; import { StatusBadge } from "../components/Badge";
@@ -12,6 +12,7 @@ import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { PRODUCT_NAME } from "../product"; import { PRODUCT_NAME } from "../product";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages"; import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { ApiErrorNotice } from "../components/PageChrome"; import { ApiErrorNotice } from "../components/PageChrome";
import { CheckoutForm } from "../components/CheckoutForm";
export function BookingDetail() { export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns", "errors"]); const { t } = useTranslation(["bookings", "returns", "errors"]);
@@ -25,6 +26,7 @@ export function BookingDetail() {
const [cancelReason, setCancelReason] = useState(""); const [cancelReason, setCancelReason] = useState("");
const [cancelling, setCancelling] = useState(false); const [cancelling, setCancelling] = useState(false);
const [actionError, setActionError] = useState<ApiErrorInfo | null>(null); const [actionError, setActionError] = useState<ApiErrorInfo | null>(null);
const [checkoutResult, setCheckoutResult] = useState<CheckoutBookingResult | null>(null);
const load = useCallback(() => { const load = useCallback(() => {
if (!publicRef) return; if (!publicRef) return;
@@ -77,6 +79,11 @@ export function BookingDetail() {
} }
} }
function handleCheckout(result: CheckoutBookingResult) {
setCheckoutResult(result);
load();
}
if (error) return <ErrorState message={error} />; if (error) return <ErrorState message={error} />;
if (!booking) return <LoadingState label={t("detail.loading")} />; 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> <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>} </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 && ( {isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
<section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}> <section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}>
<Icon name="spark" /> <Icon name="spark" />
+52
View File
@@ -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>;
}
+10 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { api } from "../api/client"; import { api } from "../api/client";
@@ -7,6 +7,8 @@ import { useLocaleFormat } from "../i18n/format";
import { SeverityBadge, StatusBadge } from "../components/Badge"; import { SeverityBadge, StatusBadge } from "../components/Badge";
import { Icon } from "../components/Icons"; import { Icon } from "../components/Icons";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; 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; const TABS = ["overview", "bookings", "inspections", "maintenance", "quality"] as const;
type Tab = (typeof TABS)[number]; type Tab = (typeof TABS)[number];
@@ -18,16 +20,18 @@ export function VehicleDetail() {
const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null); const [vehicle, setVehicle] = useState<VehicleDetailData | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview"); const [tab, setTab] = useState<Tab>("overview");
const { user } = useAuth();
useEffect(() => { const load = useCallback(() => {
if (!publicRef) return; if (!publicRef) return;
setVehicle(null);
setError(null); setError(null);
api api
.get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`) .get<VehicleDetailData>(`/api/v1/vehicles/${publicRef}`)
.then(setVehicle) .then(setVehicle)
.catch(() => setError(t("detail.notFound"))); .catch(() => setError(t("detail.notFound")));
}, [publicRef]); }, [publicRef, t]);
useEffect(() => { setVehicle(null); load(); }, [load]);
if (error) return <ErrorState message={error} />; if (error) return <ErrorState message={error} />;
if (!vehicle) return <LoadingState label={t("detail.loading")} />; if (!vehicle) return <LoadingState label={t("detail.loading")} />;
@@ -95,7 +99,7 @@ export function VehicleDetail() {
)} )}
{tab === "maintenance" && ( {tab === "maintenance" && (
<ul className="record-list"> <><ul className="record-list">
{vehicle.maintenance.length === 0 && <li>{t("detail.noMaintenance")}</li>} {vehicle.maintenance.length === 0 && <li>{t("detail.noMaintenance")}</li>}
{vehicle.maintenance.map((m) => ( {vehicle.maintenance.map((m) => (
<li key={m.public_ref}> <li key={m.public_ref}>
@@ -104,7 +108,7 @@ export function VehicleDetail() {
<time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time> <time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time>
</li> </li>
))} ))}
</ul> </ul>{user?.role === "operations_manager" && <VehicleMaintenanceActions vehicle={vehicle} onSaved={load} />}</>
)} )}
{tab === "quality" && ( {tab === "quality" && (
+13
View File
@@ -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 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 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; } .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); } .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); } .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; } .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; }