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

{t("list.scanComplete", { @@ -142,6 +189,18 @@ export function DataQuality() { + + + + + +

+ )} +
{t("list.count", { count: issues?.total ?? visibleIssues.length })}{t("list.evidenceBacked")}
+ + + {visibleIssues.map((i) => ( + + + ))} diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index 0712597..96bb20e 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -16,37 +16,132 @@ export function Users() { const [role, setRole] = useState("rental_employee"); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [editingRef, setEditingRef] = useState(null); + const [editName, setEditName] = useState(""); + const [editRole, setEditRole] = useState("rental_employee"); + const [newPassword, setNewPassword] = useState(""); const load = useCallback(() => { - api.get("/api/v1/users").then(setUsers).catch((err) => setError(describeApiError(t, err))); + api + .get("/api/v1/users") + .then(setUsers) + .catch((err) => setError(describeApiError(t, err))); }, [t]); + useEffect(load, [load]); async function create(event: FormEvent) { - event.preventDefault(); setSaving(true); setError(null); + event.preventDefault(); + setSaving(true); + setError(null); try { - await api.post("/api/v1/users", { email, display_name: displayName, password, role }); - setDisplayName(""); setEmail(""); setPassword(""); setRole("rental_employee"); load(); - } catch (err) { setError(describeApiError(t, err, "operations:users.createFailed")); } - finally { setSaving(false); } + await api.post("/api/v1/users", { + email, + display_name: displayName, + password, + role, + }); + setDisplayName(""); + setEmail(""); + setPassword(""); + setRole("rental_employee"); + load(); + } catch (err) { + setError(describeApiError(t, err, "operations:users.createFailed")); + } finally { + setSaving(false); + } + } + + function beginEdit(record: UserRecord) { + setEditingRef(record.public_ref); + setEditName(record.display_name); + setEditRole(record.role); + setNewPassword(""); + } + + async function saveEdit(event: FormEvent) { + event.preventDefault(); + if (!editingRef) return; + setSaving(true); + setError(null); + try { + await api.patch(`/api/v1/users/${editingRef}`, { + display_name: editName, + role: editRole, + password: newPassword || undefined, + }); + setEditingRef(null); + setNewPassword(""); + load(); + } catch (err) { + setError(describeApiError(t, err, "operations:users.updateFailed")); + } finally { + setSaving(false); + } } async function toggleActive(record: UserRecord) { setError(null); - try { await api.patch(`/api/v1/users/${record.public_ref}`, { active: !record.active }); load(); } - catch (err) { setError(describeApiError(t, err, "operations:users.updateFailed")); } + try { + await api.patch(`/api/v1/users/${record.public_ref}`, { active: !record.active }); + load(); + } catch (err) { + setError(describeApiError(t, err, "operations:users.updateFailed")); + } } - return
- - -

{t("users.addTitle")}

- - - - -
- {!users && } - {users &&
{t("list.title")}
{t("list.columns.select")} {t("list.columns.reference")} {t("list.columns.rule")} {t("list.columns.entity")} {t("list.columns.severity")} {t("list.columns.status")}{t("list.columns.assignee")}{t("list.columns.due")}
toggleSelected(i.public_ref)} aria-label={t("list.selectIssue", { ref: i.public_ref })} /> {i.public_ref} {i.public_ref} @@ -206,6 +279,8 @@ export function DataQuality() { {i.assigned_to_name ?? t("list.unassigned")}{i.due_at ? : "—"}
{users.map((record) => )}
{t("users.title")}
{t("users.name")}{t("users.email")}{t("users.role")}{t("users.status")}{t("users.action")}
{record.display_name}{record.public_ref}{record.email ?? "—"}{t(`roles.${record.role}`)}{t(record.active ? "users.active" : "users.inactive")}
} - ; + return ( +
+ + +
+

{t("users.addTitle")}

+
+
+ + + + +
+
+
+
+ + {!users && } + {users && ( +
+ + + + + {users.map((record) => ( + + + + + + + + ))} + +
{t("users.title")}
{t("users.name")}{t("users.email")}{t("users.role")}{t("users.status")}{t("users.action")}
{record.display_name}{record.public_ref}{record.email ?? "—"}{t(`roles.${record.role}`)}{t(record.active ? "users.active" : "users.inactive")}
+
+ )} + + {editingRef && ( +
+

{t("users.editTitle", { ref: editingRef })}

+
+
+ + + +
+
+
+
+ )} +
+ ); } diff --git a/frontend/src/pages/Vehicles.tsx b/frontend/src/pages/Vehicles.tsx index d2f2c2e..e4c3537 100644 --- a/frontend/src/pages/Vehicles.tsx +++ b/frontend/src/pages/Vehicles.tsx @@ -12,13 +12,14 @@ const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "block export function Vehicles() { const { t } = useTranslation("fleet"); - const { formatNumber } = useLocaleFormat(); + const { formatDateTime, formatNumber } = useLocaleFormat(); const [searchParams, setSearchParams] = useSearchParams(); const [vehicles, setVehicles] = useState | null>(null); const [error, setError] = useState(null); const status = searchParams.get("status") ?? ""; const attentionOnly = searchParams.get("attention_only") === "true"; const query = searchParams.get("q") ?? ""; + const location = searchParams.get("location") ?? ""; const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); function updateFilters(updates: Record) { @@ -37,13 +38,14 @@ export function Vehicles() { if (status) params.set("status", status); if (attentionOnly) params.set("attention_only", "true"); if (query) params.set("query", query); + if (location) params.set("location", location); params.set("page", String(page)); params.set("page_size", "25"); api .get>(`/api/v1/vehicles?${params.toString()}`) .then(setVehicles) .catch(() => setError(t("list.unavailable"))); - }, [status, attentionOnly, query, page, t]); + }, [status, attentionOnly, query, location, page, t]); return (
@@ -73,6 +75,7 @@ export function Vehicles() { /> {t("list.attentionOnly")} + {error && } @@ -88,6 +91,8 @@ export function Vehicles() { {t("list.columns.location")} {t("list.columns.status")} {t("list.columns.odometer")} + {t("list.columns.service")} + {t("list.columns.nextBooking")} {t("list.columns.attention")} @@ -106,7 +111,9 @@ export function Vehicles() { {formatNumber(v.odometer_km)} - {v.attention ? {t("list.needsAttention")} : "—"} + {v.service_remaining_km <= 0 ? t("list.serviceDue") : t("list.serviceRemaining", { count: formatNumber(v.service_remaining_km) })} + {v.next_booking_ref && v.next_booking_at ? {v.next_booking_ref}{formatDateTime(v.next_booking_at)} : "—"} + {v.attention && v.attention_reason ? {t(`list.attentionReasons.${v.attention_reason}`)} : "—"} ))} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 752c87a..5f15949 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -406,6 +406,18 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .knowledge-feedback button { min-height: 34px; padding: 5px 10px; } .knowledge-feedback button.is-selected { color: var(--teal-dark); border-color: var(--teal); background: var(--teal-pale); } +.bulk-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 10px; padding: 12px 14px; background: var(--teal-pale); border-bottom: 1px solid #bfe6df; } +.bulk-toolbar strong { align-self: center; margin-right: auto; font-size: .74rem; } +.bulk-toolbar label { display: grid; gap: 4px; color: var(--muted); font-size: .62rem; font-weight: 700; } +.bulk-toolbar select, .bulk-toolbar input { min-height: 36px; } +.selection-cell { position: relative; z-index: 2; width: 38px; } +.selection-cell input { width: 16px; height: 16px; } +.is-overdue { color: var(--critical); font-weight: 700; } +.table-actions { display: flex; flex-wrap: wrap; gap: 6px; } +.user-edit { margin-top: 18px; } +.filter-presets { display: flex; flex-wrap: wrap; gap: 7px; margin: -6px 0 18px; } +.filter-presets button { min-height: 34px; padding: 5px 11px; color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; } + .state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); } .error { color: #9f2929; font-size: .74rem; font-weight: 600; } .api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }