M18: implement operational workspaces

This commit is contained in:
NuklearRabbit
2026-08-10 12:41:28 +02:00
parent 8030753dbc
commit fe06ff75a1
33 changed files with 1082 additions and 95 deletions
+23
View File
@@ -2436,3 +2436,26 @@ evidence yet."
feedback. feedback.
- Exact next action: turn the data-quality queue, booking planning, fleet overview and - Exact next action: turn the data-quality queue, booking planning, fleet overview and
user administration into complete daily operational workspaces. user administration into complete daily operational workspaces.
## M18 — daily operational workspaces (2026-08-10)
- Turned data quality into an owned work queue: every newly detected open issue receives
a severity-based SLA deadline (4h high, 1d medium, 3d low), managers can filter by
assignee/overdue state and assign or reschedule up to 25 selected issues atomically.
Every change is row-locked, validated against an active user and independently audited.
- Added the PostgreSQL ownership/deadline migration with indexed nullable assignment,
`ON DELETE SET NULL`, live-data backfill and deterministic demo-reset deadlines.
- Upgraded booking planning with operational-priority ordering, inclusive date-window,
location and explicit sort filters plus Today/Upcoming presets. The default no longer
leads with the furthest-future booking.
- Upgraded the fleet register with exact location filtering, next-booking context,
remaining service distance and explicit attention reasons (blocked, service due or
open quality issue) instead of one unexplained warning label.
- Completed user administration: managers can now edit names/roles, reset passwords and
activate/deactivate accounts from the UI; existing self-demotion/deactivation guards
and auditing remain authoritative in the API.
- Evidence: frontend TypeScript production build passed; ruff and mypy passed; focused
PostgreSQL suites **58 passed**; migration applied in the isolated stack; OpenAPI was
regenerated. E2E coverage now includes bulk queue assignment and full user editing.
- Exact next action: split the frontend bundle, harden mobile layout and operational
backup/deployment controls, then run clean full acceptance and redeploy.
@@ -0,0 +1,65 @@
"""add ownership and SLA deadlines to data quality issues
Revision ID: f43d829ab610
Revises: d1f83bc64170
"""
import sqlalchemy as sa
from alembic import op
revision = "f43d829ab610"
down_revision = "d1f83bc64170"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"data_quality_issues",
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"data_quality_issues",
sa.Column("assigned_to_user_id", sa.Uuid(), nullable=True),
)
op.create_foreign_key(
"fk_data_quality_issues_assigned_user",
"data_quality_issues",
"users",
["assigned_to_user_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_data_quality_issues_due_at", "data_quality_issues", ["due_at"])
op.create_index(
"ix_data_quality_issues_assigned_to_user_id",
"data_quality_issues",
["assigned_to_user_id"],
)
op.execute(
"""
UPDATE data_quality_issues
SET due_at = detected_at + CASE severity
WHEN 'high' THEN interval '4 hours'
WHEN 'low' THEN interval '3 days'
ELSE interval '1 day'
END
WHERE status = 'open' AND due_at IS NULL
"""
)
def downgrade() -> None:
op.drop_index(
"ix_data_quality_issues_assigned_to_user_id",
table_name="data_quality_issues",
)
op.drop_index("ix_data_quality_issues_due_at", table_name="data_quality_issues")
op.drop_constraint(
"fk_data_quality_issues_assigned_user",
"data_quality_issues",
type_="foreignkey",
)
op.drop_column("data_quality_issues", "assigned_to_user_id")
op.drop_column("data_quality_issues", "due_at")
+41 -18
View File
@@ -2,9 +2,10 @@ from __future__ import annotations
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Literal
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 case, 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
@@ -52,12 +53,16 @@ def list_bookings(
status: str | None = Query(default=None), status: str | None = Query(default=None),
vehicle_ref: str | None = Query(default=None), vehicle_ref: str | None = Query(default=None),
query: str | None = Query(default=None, min_length=1, max_length=100), 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: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25), page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(get_current_user),
) -> list[BookingOut] | BookingPageOut: ) -> list[BookingOut] | BookingPageOut:
stmt = select(Booking).order_by(Booking.starts_at.desc()) stmt = select(Booking)
if status: if status:
stmt = stmt.where(Booking.status == status) stmt = stmt.where(Booking.status == status)
if vehicle_ref: if vehicle_ref:
@@ -65,20 +70,42 @@ def list_bookings(
if vehicle is None: if vehicle is None:
return [] return []
stmt = stmt.where(Booking.vehicle_id == vehicle.id) 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: if query:
term = f"%{query.strip()}%" term = f"%{query.strip()}%"
stmt = ( stmt = stmt.where(
stmt.join(Customer, Booking.customer_id == Customer.id) or_(
.join(Vehicle, Booking.vehicle_id == Vehicle.id) Booking.public_ref.ilike(term),
.where( Customer.first_name.ilike(term),
or_( Customer.last_name.ilike(term),
Booking.public_ref.ilike(term), Vehicle.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 total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1 page_number = page or 1
bookings = db.scalars( bookings = db.scalars(
@@ -164,9 +191,7 @@ def checkout_booking(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> CheckoutBookingResult: ) -> CheckoutBookingResult:
booking = db.scalar( booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
select(Booking).where(Booking.public_ref == public_ref).with_for_update()
)
if booking is None: if booking is None:
raise HTTPException(status_code=404, detail="Booking not found") raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved": if booking.status != "reserved":
@@ -324,9 +349,7 @@ def cancel_booking(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> BookingOut: ) -> BookingOut:
booking = db.scalar( booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
select(Booking).where(Booking.public_ref == public_ref).with_for_update()
)
if booking is None: if booking is None:
raise HTTPException(status_code=404, detail="Booking not found") raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved": if booking.status != "reserved":
+123 -8
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select from sqlalchemy import case, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager 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.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection from app.models.inspection import Inspection
from app.models.user import User
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
ApplyRecommendedStatusRequest, ApplyRecommendedStatusRequest,
ApplyRecommendedStatusResult, ApplyRecommendedStatusResult,
BulkDataQualityWorkRequest,
BulkDataQualityWorkResult,
CurrentUser, CurrentUser,
DataQualityIssueDetailOut, DataQualityIssueDetailOut,
DataQualityIssueOut, DataQualityIssueOut,
@@ -26,6 +31,7 @@ from app.schemas import (
StatusRecommendationOut, StatusRecommendationOut,
VehicleStatusFactsOut, VehicleStatusFactsOut,
) )
from app.services.audit import record_audit_event
from app.services.data_quality import ( from app.services.data_quality import (
apply_recommended_status, apply_recommended_status,
defer_issue, defer_issue,
@@ -42,6 +48,7 @@ router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut: def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
assignee = issue.assigned_to_user
return DataQualityIssueOut( return DataQualityIssueOut(
public_ref=issue.public_ref, public_ref=issue.public_ref,
rule_type=issue.rule_type, rule_type=issue.rule_type,
@@ -51,6 +58,12 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
status=issue.status, status=issue.status,
evidence=issue.evidence_json, evidence=issue.evidence_json,
detected_at=issue.detected_at, 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, resolved_at=issue.resolved_at,
) )
@@ -60,18 +73,40 @@ def list_issues(
status: str | None = Query(default=None), status: str | None = Query(default=None),
rule_type: str | None = Query(default=None), rule_type: str | None = Query(default=None),
severity: 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: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25), page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager), _user: CurrentUser = Depends(require_operations_manager),
) -> list[DataQualityIssueOut] | DataQualityIssuePageOut: ) -> 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: if status:
stmt = stmt.where(DataQualityIssue.status == status) stmt = stmt.where(DataQualityIssue.status == status)
if rule_type: if rule_type:
stmt = stmt.where(DataQualityIssue.rule_type == rule_type) stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
if severity: if severity:
stmt = stmt.where(DataQualityIssue.severity == 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 total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1 page_number = page or 1
issues = db.scalars( 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 # 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, # (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 # 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) return _to_out(issue)
@router.post( @router.post("/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut)
"/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut
)
def resolve_odometer( def resolve_odometer(
public_ref: str, public_ref: str,
body: ResolveOdometerRegressionRequest, body: ResolveOdometerRegressionRequest,
@@ -263,9 +380,7 @@ def resolve_overlap(
return _to_out(issue) return _to_out(issue)
@router.post( @router.post("/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut)
"/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut
)
def status_recommendation( def status_recommendation(
public_ref: str, public_ref: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
+77 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import UTC, datetime
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
@@ -44,6 +45,7 @@ def _attention_vehicle_ids(db: Session) -> set:
def list_vehicles( def list_vehicles(
status: str | None = Query(default=None), status: str | None = Query(default=None),
attention_only: bool = Query(default=False), 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), query: str | None = Query(default=None, min_length=1, max_length=100),
page: int | None = Query(default=None, ge=1), page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25), 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) stmt = select(Vehicle).order_by(Vehicle.public_ref)
if status: if status:
stmt = stmt.where(Vehicle.operational_status == status) stmt = stmt.where(Vehicle.operational_status == status)
if location:
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
if query: if query:
term = f"%{query.strip()}%" term = f"%{query.strip()}%"
stmt = stmt.where( stmt = stmt.where(
@@ -67,13 +71,30 @@ def list_vehicles(
attention_ids = _attention_vehicle_ids(db) attention_ids = _attention_vehicle_ids(db)
if attention_only: if attention_only:
stmt = stmt.where( 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 total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1 page_number = page or 1
vehicles = db.scalars( vehicles = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size) stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all() ).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 = [ items = [
VehicleOut( VehicleOut(
public_ref=v.public_ref, public_ref=v.public_ref,
@@ -86,7 +107,23 @@ def list_vehicles(
odometer_km=v.odometer_km, odometer_km=v.odometer_km,
next_service_km=v.next_service_km, next_service_km=v.next_service_km,
active=v.active, 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 for v in vehicles
] ]
@@ -133,6 +170,14 @@ def get_vehicle(
).all() ).all()
booking_by_id = {b.id: b.public_ref for b in bookings} 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) attention_ids = _attention_vehicle_ids(db)
return VehicleDetailOut( return VehicleDetailOut(
@@ -146,7 +191,23 @@ def get_vehicle(
odometer_km=vehicle.odometer_km, odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km, next_service_km=vehicle.next_service_km,
active=vehicle.active, 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=[ bookings=[
BookingSummaryOut( BookingSummaryOut(
public_ref=b.public_ref, public_ref=b.public_ref,
@@ -192,6 +253,12 @@ def get_vehicle(
status=q.status, status=q.status,
evidence=q.evidence_json, evidence=q.evidence_json,
detected_at=q.detected_at, 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, resolved_at=q.resolved_at,
) )
for q in issues for q in issues
@@ -296,5 +363,11 @@ def release_vehicle(
odometer_km=vehicle.odometer_km, odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km, next_service_km=vehicle.next_service_km,
active=vehicle.active, 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,
) )
+11 -2
View File
@@ -1,13 +1,17 @@
import uuid import uuid
from datetime import datetime 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.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.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from app.models.user import User
RULE_TYPES = ( RULE_TYPES = (
"possible_duplicate_customer", "possible_duplicate_customer",
"missing_required_field", "missing_required_field",
@@ -31,5 +35,10 @@ class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
proposed_action_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) 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_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_by: Mapped[str | None] = mapped_column(String(120)) resolved_by: Mapped[str | None] = mapped_column(String(120))
+20
View File
@@ -57,6 +57,10 @@ class VehicleOut(BaseModel):
next_service_km: int next_service_km: int
active: bool active: bool
attention: bool = False 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): class VehiclePageOut(BaseModel):
@@ -221,6 +225,10 @@ class DataQualityIssueOut(BaseModel):
status: str status: str
evidence: dict[str, Any] evidence: dict[str, Any]
detected_at: datetime 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 resolved_at: datetime | None = None
@@ -237,6 +245,18 @@ class DataQualityIssueDetailOut(DataQualityIssueOut):
related_snapshots: list[dict[str, Any]] = Field(default_factory=list) 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): class MergeCustomersRequest(BaseModel):
survivor_ref: str survivor_ref: str
field_overrides: dict[str, str] | None = None field_overrides: dict[str, str] | None = None
+11 -7
View File
@@ -106,7 +106,9 @@ def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> N
): ):
db.execute(delete(model)) db.execute(delete(model))
if preserve_integration_telemetry: 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: else:
db.execute(delete(AuditEvent)) db.execute(delete(AuditEvent))
@@ -116,9 +118,7 @@ def load_seed(db: Session) -> SeedResult:
today = datetime.now(UTC).date() today = datetime.now(UTC).date()
shift = _seed_anchor_shift(today) shift = _seed_anchor_shift(today)
user_rows = [ user_rows = [{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS]
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
]
db.execute(insert(User), user_rows) db.execute(insert(User), user_rows)
counts["users"] = len(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"]) entity_type, entity_id = resolve_entity(row["entity_ref"])
related_ref = row.get("related_ref") or "" related_ref = row.get("related_ref") or ""
related_refs = related_ref.split("|") if related_ref else [] 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( dq_rows.append(
{ {
"id": uuid.uuid4(), "id": uuid.uuid4(),
@@ -371,6 +376,7 @@ def load_seed(db: Session) -> SeedResult:
}, },
"proposed_action_json": {}, "proposed_action_json": {},
"detected_at": now, "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_at": now if row["status"] == "resolved" else None,
"resolved_by": "USR-OPS" 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) return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
def reset_and_seed( def reset_and_seed(db: Session, *, preserve_integration_telemetry: bool = False) -> SeedResult:
db: Session, *, preserve_integration_telemetry: bool = False
) -> SeedResult:
from app.services.data_quality import run_scan from app.services.data_quality import run_scan
clear_all(db, preserve_integration_telemetry=preserve_integration_telemetry) clear_all(db, preserve_integration_telemetry=preserve_integration_telemetry)
+14 -10
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from difflib import SequenceMatcher from difflib import SequenceMatcher
from sqlalchemy import select, update from sqlalchemy import select, update
@@ -28,6 +28,15 @@ REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
DUPLICATE_THRESHOLD = 70 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 @dataclass
class ScanResult: class ScanResult:
created: dict[str, int] = field(default_factory=dict) created: dict[str, int] = field(default_factory=dict)
@@ -112,6 +121,7 @@ def _open_issue(
evidence_json=evidence, evidence_json=evidence,
proposed_action_json={}, proposed_action_json={},
detected_at=now, detected_at=now,
due_at=issue_due_at(now, severity),
) )
db.add(issue) db.add(issue)
db.flush() 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()} vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {} bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
for booking in db.scalars( for booking in db.scalars(
select(Booking).where( select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None))
Booking.status == "returned", Booking.end_odometer_km.is_not(None)
)
).all(): ).all():
bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking) 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: def _load_open_issue(db: Session, public_ref: str) -> DataQualityIssue:
issue = db.scalar( issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
)
if issue is None: if issue is None:
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404) raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
if issue.status != "open": if issue.status != "open":
@@ -750,9 +756,7 @@ def apply_recommended_status(
# recommendation alone for the post-condition. # recommendation alone for the post-condition.
post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id) post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
post_check = evaluate_vehicle_status(vehicle, post_facts) post_check = evaluate_vehicle_status(vehicle, post_facts)
if post_check.recommendation_code not in ( if post_check.recommendation_code not in (RECOMMENDATION_CODE_NO_CONFLICT,):
RECOMMENDATION_CODE_NO_CONFLICT,
):
raise AppError( raise AppError(
"CONFLICT_STILL_PRESENT", "CONFLICT_STILL_PRESENT",
"Applying the recommended status did not resolve the conflict.", "Applying the recommended status did not resolve the conflict.",
+2 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -255,6 +255,7 @@ def register_vehicle_return(
}, },
proposed_action_json={}, proposed_action_json={},
detected_at=now, detected_at=now,
due_at=now + timedelta(days=1),
) )
db.add(issue) db.add(issue)
db.flush() db.flush()
+26 -8
View File
@@ -1,4 +1,5 @@
import threading import threading
from datetime import datetime, timedelta
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -27,6 +28,23 @@ def test_list_bookings_supports_bounded_search_pages(ops_client):
assert len(body["items"]) == 25 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): def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json() existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
conflict = ops_client.post( 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 assert conflict.status_code == 409
available_vehicle = ops_client.get( available_vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[
"/api/v1/vehicles", params={"status": "available"} 0
).json()[0]["public_ref"] ]["public_ref"]
created = ops_client.post( created = ops_client.post(
"/api/v1/bookings", "/api/v1/bookings",
json={ json={
@@ -115,11 +133,11 @@ def test_concurrent_bookings_only_reserve_vehicle_once():
client.post("/api/v1/demo/login", json={"role": "operations_manager"}) client.post("/api/v1/demo/login", json={"role": "operations_manager"})
response = client.post( response = client.post(
"/api/v1/bookings", "/api/v1/bookings",
json={ json={
"customer_ref": "CUS-0001", "customer_ref": "CUS-0001",
"vehicle_ref": vehicle_ref, "vehicle_ref": vehicle_ref,
**window, **window,
}, },
) )
results.append(response.status_code) results.append(response.status_code)
+43
View File
@@ -53,6 +53,49 @@ def test_list_issues_requires_operations_manager(employee_client):
assert response.status_code == 403 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): def test_issue_page_preserves_severity_filter_and_limits_results(ops_client):
response = ops_client.get( response = ops_client.get(
"/api/v1/data-quality/issues", "/api/v1/data-quality/issues",
+13 -2
View File
@@ -12,6 +12,18 @@ def test_attention_only_filters_flagged_vehicles(ops_client):
vehicles = response.json() vehicles = response.json()
assert len(vehicles) > 0 assert len(vehicles) > 0
assert all(v["attention"] for v in vehicles) 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): 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() detail = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
assert detail["operational_status"] == "maintenance" assert detail["operational_status"] == "maintenance"
assert any( assert any(
item["public_ref"] == response.json()["public_ref"] item["public_ref"] == response.json()["public_ref"] for item in detail["maintenance"]
for item in detail["maintenance"]
) )
released = ops_client.post( released = ops_client.post(
f"/api/v1/vehicles/{vehicle['public_ref']}/release", f"/api/v1/vehicles/{vehicle['public_ref']}/release",
+215
View File
@@ -227,6 +227,16 @@ paths:
type: boolean type: boolean
default: false default: false
title: Attention Only title: Attention Only
- name: location
in: query
required: false
schema:
anyOf:
- type: string
minLength: 1
maxLength: 120
- type: 'null'
title: Location
- name: query - name: query
in: query in: query
required: false required: false
@@ -396,6 +406,45 @@ paths:
maxLength: 100 maxLength: 100
- type: 'null' - type: 'null'
title: Query 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 - name: page
in: query in: query
required: false required: false
@@ -847,6 +896,22 @@ paths:
- type: string - type: string
- type: 'null' - type: 'null'
title: Severity 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 - name: page
in: query in: query
required: false required: false
@@ -883,6 +948,31 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $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}: /api/v1/data-quality/issues/{public_ref}:
get: get:
tags: tags:
@@ -2276,6 +2366,51 @@ components:
- ends_at - ends_at
- status - status
title: BookingSummaryOut 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: CancelBookingRequest:
properties: properties:
reason: reason:
@@ -2584,6 +2719,26 @@ components:
type: string type: string
format: date-time format: date-time
title: Detected At 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: resolved_at:
anyOf: anyOf:
- type: string - type: string
@@ -2641,6 +2796,26 @@ components:
type: string type: string
format: date-time format: date-time
title: Detected At 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: resolved_at:
anyOf: anyOf:
- type: string - type: string
@@ -3968,6 +4143,25 @@ components:
type: boolean type: boolean
title: Attention title: Attention
default: false 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: bookings:
items: items:
$ref: '#/components/schemas/BookingSummaryOut' $ref: '#/components/schemas/BookingSummaryOut'
@@ -4000,6 +4194,7 @@ components:
- odometer_km - odometer_km
- next_service_km - next_service_km
- active - active
- service_remaining_km
title: VehicleDetailOut title: VehicleDetailOut
VehicleOut: VehicleOut:
properties: properties:
@@ -4037,6 +4232,25 @@ components:
type: boolean type: boolean
title: Attention title: Attention
default: false 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 type: object
required: required:
- public_ref - public_ref
@@ -4049,6 +4263,7 @@ components:
- odometer_km - odometer_km
- next_service_km - next_service_km
- active - active
- service_remaining_km
title: VehicleOut title: VehicleOut
VehiclePageOut: VehiclePageOut:
properties: properties:
+23 -2
View File
@@ -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(); 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 reset(request);
await login(page); await login(page);
await page.goto("/users"); 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("E-mail").fill("e2e.planner@example.test");
await page.getByLabel("Tijdelijk wachtwoord").fill("secure-e2e-password"); await page.getByLabel("Tijdelijk wachtwoord").fill("secure-e2e-password");
await page.getByRole("button", { name: "Gebruiker toevoegen" }).click(); 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 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 row.getByRole("button", { name: "Deactiveren" }).click();
await expect(row.getByText("inactief", { exact: true })).toBeVisible(); 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 }) => { test("manager can record maintenance and release a safe vehicle", async ({ page, request }) => {
await reset(request); await reset(request);
const vehicles = await (await request.get("/api/v1/vehicles?status=available")).json(); const vehicles = await (await request.get("/api/v1/vehicles?status=available")).json();
+8
View File
@@ -25,6 +25,10 @@ export interface Vehicle {
next_service_km: number; next_service_km: number;
active: boolean; active: boolean;
attention: boolean; attention: boolean;
attention_reason: "blocked_status" | "service_due" | "data_quality" | null;
service_remaining_km: number;
next_booking_ref: string | null;
next_booking_at: string | null;
} }
export interface Page<T> { export interface Page<T> {
@@ -113,6 +117,10 @@ export interface DataQualityIssue {
status: "open" | "deferred" | "resolved" | "rejected"; status: "open" | "deferred" | "resolved" | "rejected";
evidence: Record<string, unknown>; evidence: Record<string, unknown>;
detected_at: string; detected_at: string;
due_at: string | null;
assigned_to_ref: string | null;
assigned_to_name: string | null;
overdue: boolean;
resolved_at: string | null; resolved_at: string | null;
} }
@@ -8,6 +8,18 @@
"searchPlaceholder": "Booking, customer or vehicle", "searchPlaceholder": "Booking, customer or vehicle",
"statusLabel": "Status", "statusLabel": "Status",
"statusAll": "All statuses", "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…", "loading": "Loading booking ledger…",
"empty": "No bookings found", "empty": "No bookings found",
"emptyDetail": "Adjust the booking status filter.", "emptyDetail": "Adjust the booking status filter.",
@@ -8,6 +8,15 @@
"statusLabel": "Status", "statusLabel": "Status",
"statusAll": "All statuses", "statusAll": "All statuses",
"attentionOnly": "Attention only", "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…", "loading": "Loading fleet registry…",
"empty": "No vehicles found", "empty": "No vehicles found",
"emptyDetail": "Adjust the current fleet filters.", "emptyDetail": "Adjust the current fleet filters.",
@@ -22,6 +31,8 @@
"location": "Location", "location": "Location",
"status": "Status", "status": "Status",
"odometer": "Odometer (km)", "odometer": "Odometer (km)",
"service": "Service",
"nextBooking": "Next booking",
"attention": "Attention" "attention": "Attention"
}, },
"needsAttention": "Needs attention" "needsAttention": "Needs attention"
@@ -1,4 +1,4 @@
{ {
"users": { "eyebrow": "Administration / Access", "title": "Users", "description": "Manage operational access and roles. Every change is audited.", "addTitle": "Add user", "name": "Name", "email": "Email", "role": "Role", "password": "Temporary password", "status": "Status", "action": "Action", "add": "Add user", "saving": "Saving…", "loading": "Loading users…", "active": "active", "inactive": "inactive", "activate": "Activate", "deactivate": "Deactivate", "createFailed": "The user could not be created.", "updateFailed": "The user could not be updated." }, "users": { "eyebrow": "Administration / Access", "title": "Users", "description": "Manage operational access and roles. Every change is audited.", "addTitle": "Add user", "name": "Name", "email": "Email", "role": "Role", "password": "Temporary password", "status": "Status", "action": "Action", "add": "Add user", "saving": "Saving…", "loading": "Loading users…", "active": "active", "inactive": "inactive", "activate": "Activate", "deactivate": "Deactivate", "edit": "Edit", "editTitle": "Edit user {{ref}}", "newPassword": "New password", "passwordUnchanged": "Leave empty to keep unchanged", "saveChanges": "Save changes", "cancel": "Cancel", "createFailed": "The user could not be created.", "updateFailed": "The user could not be updated." },
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Rental employee" } "roles": { "operations_manager": "Operations Manager", "rental_employee": "Rental employee" }
} }
+17 -1
View File
@@ -22,6 +22,19 @@
"ruleTypeAll": "All rule types", "ruleTypeAll": "All rule types",
"severityLabel": "Severity", "severityLabel": "Severity",
"severityAll": "All severity levels", "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", "demoScenariosOnly": "Demo scenarios only",
"loading": "Loading quality workbench…", "loading": "Loading quality workbench…",
"queueClear": "Queue is clear", "queueClear": "Queue is clear",
@@ -33,10 +46,13 @@
"evidenceBacked": "Evidence-backed detection", "evidenceBacked": "Evidence-backed detection",
"columns": { "columns": {
"reference": "Reference", "reference": "Reference",
"select": "Select",
"rule": "Rule", "rule": "Rule",
"entity": "Entity", "entity": "Entity",
"severity": "Severity", "severity": "Severity",
"status": "Status" "status": "Status",
"assignee": "Assignee",
"due": "Due date"
} }
}, },
"ruleTypes": { "ruleTypes": {
@@ -8,6 +8,18 @@
"searchPlaceholder": "Réservation, client ou véhicule", "searchPlaceholder": "Réservation, client ou véhicule",
"statusLabel": "Statut", "statusLabel": "Statut",
"statusAll": "Tous les statuts", "statusAll": "Tous les statuts",
"fromLabel": "À partir du",
"toLabel": "Jusquau",
"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": "Aujourdhui",
"upcomingPreset": "Réservations à venir",
"clearFilters": "Effacer les filtres",
"loading": "Chargement du registre des réservations…", "loading": "Chargement du registre des réservations…",
"empty": "Aucune réservation trouvée", "empty": "Aucune réservation trouvée",
"emptyDetail": "Ajustez le filtre de statut des réservations.", "emptyDetail": "Ajustez le filtre de statut des réservations.",
@@ -8,6 +8,15 @@
"statusLabel": "Statut", "statusLabel": "Statut",
"statusAll": "Tous les statuts", "statusAll": "Tous les statuts",
"attentionOnly": "Attention uniquement", "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…", "loading": "Chargement du registre de la flotte…",
"empty": "Aucun véhicule trouvé", "empty": "Aucun véhicule trouvé",
"emptyDetail": "Ajustez les filtres de flotte actuels.", "emptyDetail": "Ajustez les filtres de flotte actuels.",
@@ -22,6 +31,8 @@
"location": "Localisation", "location": "Localisation",
"status": "Statut", "status": "Statut",
"odometer": "Kilométrage (km)", "odometer": "Kilométrage (km)",
"service": "Entretien",
"nextBooking": "Prochaine réservation",
"attention": "Attention" "attention": "Attention"
}, },
"needsAttention": "Nécessite de l'attention" "needsAttention": "Nécessite de l'attention"
@@ -1,4 +1,4 @@
{ {
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." }, "users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "edit": "Modifier", "editTitle": "Modifier lutilisateur {{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" } "roles": { "operations_manager": "Responsable des opérations", "rental_employee": "Employé de location" }
} }
+17 -1
View File
@@ -22,6 +22,19 @@
"ruleTypeAll": "Tous les types de règles", "ruleTypeAll": "Tous les types de règles",
"severityLabel": "Gravité", "severityLabel": "Gravité",
"severityAll": "Tous les niveaux de 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 na pas pu être mise à jour.",
"clearSelection": "Effacer la sélection",
"selectIssue": "Sélectionner le problème {{ref}}",
"demoScenariosOnly": "Scénarios de démo uniquement", "demoScenariosOnly": "Scénarios de démo uniquement",
"loading": "Chargement de l'atelier qualité…", "loading": "Chargement de l'atelier qualité…",
"queueClear": "La file est vide", "queueClear": "La file est vide",
@@ -33,10 +46,13 @@
"evidenceBacked": "Détection étayée par des preuves", "evidenceBacked": "Détection étayée par des preuves",
"columns": { "columns": {
"reference": "Référence", "reference": "Référence",
"select": "Sélectionner",
"rule": "Règle", "rule": "Règle",
"entity": "Entité", "entity": "Entité",
"severity": "Gravité", "severity": "Gravité",
"status": "Statut" "status": "Statut",
"assignee": "Responsable",
"due": "Échéance"
} }
}, },
"ruleTypes": { "ruleTypes": {
@@ -8,6 +8,18 @@
"searchPlaceholder": "Boeking, klant of voertuig", "searchPlaceholder": "Boeking, klant of voertuig",
"statusLabel": "Status", "statusLabel": "Status",
"statusAll": "Alle statussen", "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…", "loading": "Boekingsoverzicht laden…",
"empty": "Geen boekingen gevonden", "empty": "Geen boekingen gevonden",
"emptyDetail": "Pas het statusfilter voor boekingen aan.", "emptyDetail": "Pas het statusfilter voor boekingen aan.",
@@ -8,6 +8,15 @@
"statusLabel": "Status", "statusLabel": "Status",
"statusAll": "Alle statussen", "statusAll": "Alle statussen",
"attentionOnly": "Enkel aandachtspunten", "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…", "loading": "Wagenparkregister laden…",
"empty": "Geen voertuigen gevonden", "empty": "Geen voertuigen gevonden",
"emptyDetail": "Pas de huidige wagenparkfilters aan.", "emptyDetail": "Pas de huidige wagenparkfilters aan.",
@@ -22,6 +31,8 @@
"location": "Locatie", "location": "Locatie",
"status": "Status", "status": "Status",
"odometer": "Kilometerstand (km)", "odometer": "Kilometerstand (km)",
"service": "Onderhoud",
"nextBooking": "Volgende boeking",
"attention": "Aandacht" "attention": "Aandacht"
}, },
"needsAttention": "Vraagt aandacht" "needsAttention": "Vraagt aandacht"
@@ -1,4 +1,4 @@
{ {
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." }, "users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "edit": "Bewerken", "editTitle": "Gebruiker {{ref}} bewerken", "newPassword": "Nieuw wachtwoord", "passwordUnchanged": "Leeg laten om niet te wijzigen", "saveChanges": "Wijzigingen opslaan", "cancel": "Annuleren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
"roles": { "operations_manager": "Operationeel beheerder", "rental_employee": "Verhuurmedewerker" } "roles": { "operations_manager": "Operationeel beheerder", "rental_employee": "Verhuurmedewerker" }
} }
+17 -1
View File
@@ -22,6 +22,19 @@
"ruleTypeAll": "Alle regeltypes", "ruleTypeAll": "Alle regeltypes",
"severityLabel": "Ernst", "severityLabel": "Ernst",
"severityAll": "Alle ernstniveaus", "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", "demoScenariosOnly": "Enkel demoscenario's",
"loading": "Kwaliteitswerkbank laden…", "loading": "Kwaliteitswerkbank laden…",
"queueClear": "Wachtrij is leeg", "queueClear": "Wachtrij is leeg",
@@ -33,10 +46,13 @@
"evidenceBacked": "Evidentie-onderbouwde detectie", "evidenceBacked": "Evidentie-onderbouwde detectie",
"columns": { "columns": {
"reference": "Referentie", "reference": "Referentie",
"select": "Selecteren",
"rule": "Regel", "rule": "Regel",
"entity": "Entiteit", "entity": "Entiteit",
"severity": "Ernst", "severity": "Ernst",
"status": "Status" "status": "Status",
"assignee": "Behandelaar",
"due": "Uiterste datum"
} }
}, },
"ruleTypes": { "ruleTypes": {
+29 -1
View File
@@ -10,6 +10,17 @@ import { Pagination } from "../components/Pagination";
const STATUS_OPTIONS = ["reserved", "active", "returned", "cancelled", "blocked"]; 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() { export function Bookings() {
const { t } = useTranslation("bookings"); const { t } = useTranslation("bookings");
const { formatShortDate } = useLocaleFormat(); const { formatShortDate } = useLocaleFormat();
@@ -18,6 +29,10 @@ export function Bookings() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const status = searchParams.get("status") ?? ""; const status = searchParams.get("status") ?? "";
const query = searchParams.get("q") ?? ""; 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); const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
function updateFilters(updates: Record<string, string | number | null>) { function updateFilters(updates: Record<string, string | number | null>) {
@@ -35,11 +50,15 @@ export function Bookings() {
const params = new URLSearchParams({ page: String(page), page_size: "25" }); const params = new URLSearchParams({ page: String(page), page_size: "25" });
if (status) params.set("status", status); if (status) params.set("status", status);
if (query) params.set("query", query); 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 api
.get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`) .get<Page<Booking>>(`/api/v1/bookings?${params.toString()}`)
.then(setBookings) .then(setBookings)
.catch(() => setError(t("list.unavailable"))); .catch(() => setError(t("list.unavailable")));
}, [page, query, status, t]); }, [page, query, status, startsFrom, startsTo, location, sort, t]);
return ( return (
<div className="page"> <div className="page">
@@ -57,7 +76,16 @@ export function Bookings() {
{STATUS_OPTIONS.map((value) => <option key={value} value={value}>{t(`statuses.${value}`)}</option>)} {STATUS_OPTIONS.map((value) => <option key={value} value={value}>{t(`statuses.${value}`)}</option>)}
</select> </select>
</label> </label>
<label>{t("list.fromLabel")}<input type="date" value={startsFrom} onChange={(event) => updateFilters({ from: event.target.value, page: 1 })} /></label>
<label>{t("list.toLabel")}<input type="date" value={startsTo} onChange={(event) => updateFilters({ to: event.target.value, page: 1 })} /></label>
<label>{t("list.locationLabel")}<input value={location} onChange={(event) => updateFilters({ location: event.target.value, page: 1 })} placeholder={t("list.locationPlaceholder")} /></label>
<label>{t("list.sortLabel")}<select value={sort} onChange={(event) => updateFilters({ sort: event.target.value, page: 1 })}><option value="operational">{t("list.sortOperational")}</option><option value="starts_asc">{t("list.sortAscending")}</option><option value="starts_desc">{t("list.sortDescending")}</option></select></label>
</form> </form>
<div className="filter-presets" aria-label={t("list.presetsLabel")}>
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: localDateString(), status: null, page: 1 })}>{t("list.todayPreset")}</button>
<button type="button" onClick={() => updateFilters({ from: localDateString(), to: null, status: "reserved", sort: "starts_asc", page: 1 })}>{t("list.upcomingPreset")}</button>
<button type="button" onClick={() => setSearchParams(new URLSearchParams())}>{t("list.clearFilters")}</button>
</div>
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
{!error && !bookings && <LoadingState label={t("list.loading")} />} {!error && !bookings && <LoadingState label={t("list.loading")} />}
+78 -3
View File
@@ -3,11 +3,12 @@ import { Link, useSearchParams } 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 { describeApiError, type ApiErrorInfo } from "../api/errorMessages"; 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 { useAuth } from "../context/AuthContext";
import { SeverityBadge, StatusBadge } from "../components/Badge"; import { SeverityBadge, StatusBadge } from "../components/Badge";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome"; import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination"; import { Pagination } from "../components/Pagination";
import { useLocaleFormat } from "../i18n/format";
const RULE_TYPES = [ const RULE_TYPES = [
"possible_duplicate_customer", "possible_duplicate_customer",
@@ -19,6 +20,7 @@ const RULE_TYPES = [
export function DataQuality() { export function DataQuality() {
const { t } = useTranslation("quality"); const { t } = useTranslation("quality");
const { formatDateTime } = useLocaleFormat();
const { user } = useAuth(); const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [issues, setIssues] = useState<Page<DataQualityIssue> | null>(null); const [issues, setIssues] = useState<Page<DataQualityIssue> | null>(null);
@@ -26,12 +28,20 @@ export function DataQuality() {
const status = searchParams.get("status") ?? "open"; const status = searchParams.get("status") ?? "open";
const ruleType = searchParams.get("rule_type") ?? ""; const ruleType = searchParams.get("rule_type") ?? "";
const severity = searchParams.get("severity") ?? ""; 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 page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null); const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null); const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false); const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true"); const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
const [users, setUsers] = useState<UserRecord[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkAssignee, setBulkAssignee] = useState("");
const [bulkDueAt, setBulkDueAt] = useState("");
const [bulkSaving, setBulkSaving] = useState(false);
const [bulkError, setBulkError] = useState<ApiErrorInfo | null>(null);
function updateFilters(updates: Record<string, string | boolean | number | null>) { function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams); const next = new URLSearchParams(searchParams);
@@ -50,18 +60,54 @@ export function DataQuality() {
if (status) params.set("status", status); if (status) params.set("status", status);
if (ruleType) params.set("rule_type", ruleType); if (ruleType) params.set("rule_type", ruleType);
if (severity) params.set("severity", severity); 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", String(page));
params.set("page_size", "25"); params.set("page_size", "25");
api api
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`) .get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
.then(setIssues) .then(setIssues)
.catch(() => setError(t("list.unavailable"))); .catch(() => setError(t("list.unavailable")));
}, [status, ruleType, severity, page, user, t]); }, [status, ruleType, severity, assignee, overdueOnly, page, user, t]);
useEffect(() => { useEffect(() => {
load(); load();
}, [load]); }, [load]);
useEffect(() => {
if (user?.role !== "operations_manager") return;
api.get<UserRecord[]>("/api/v1/users").then((records) => setUsers(records.filter((record) => record.active))).catch(() => setUsers([]));
}, [user]);
async function applyBulkWork() {
if (selected.size === 0 || (!bulkAssignee && !bulkDueAt)) return;
setBulkSaving(true);
setBulkError(null);
try {
await api.post("/api/v1/data-quality/issues/bulk-work", {
issue_refs: [...selected],
assigned_to_ref: bulkAssignee || undefined,
due_at: bulkDueAt ? new Date(bulkDueAt).toISOString() : undefined,
});
setSelected(new Set());
setBulkAssignee("");
setBulkDueAt("");
load();
} catch (err) {
setBulkError(describeApiError(t, err, "list.bulkFailed"));
} finally {
setBulkSaving(false);
}
}
function toggleSelected(ref: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(ref)) next.delete(ref); else next.add(ref);
return next;
});
}
async function handleScan() { async function handleScan() {
setScanError(null); setScanError(null);
setScanning(true); setScanning(true);
@@ -119,6 +165,7 @@ export function DataQuality() {
/> />
<ApiErrorNotice error={scanError} /> <ApiErrorNotice error={scanError} />
<ApiErrorNotice error={bulkError} />
{scanResult && ( {scanResult && (
<p className="quiet-empty" role="status"> <p className="quiet-empty" role="status">
{t("list.scanComplete", { {t("list.scanComplete", {
@@ -142,6 +189,18 @@ export function DataQuality() {
<option value="rejected">{t("list.statusRejected")}</option> <option value="rejected">{t("list.statusRejected")}</option>
</select> </select>
</label> </label>
<label>
{t("list.assigneeLabel")}
<select value={assignee} onChange={(e) => updateFilters({ assignee: e.target.value, page: 1 })}>
<option value="">{t("list.assigneeAll")}</option>
<option value="unassigned">{t("list.unassigned")}</option>
{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}
</select>
</label>
<label className="checkbox-label">
<input type="checkbox" checked={overdueOnly} onChange={(e) => updateFilters({ overdue: e.target.checked, page: 1 })} />
{t("list.overdueOnly")}
</label>
<label> <label>
{t("list.ruleTypeLabel")} {t("list.ruleTypeLabel")}
<select value={ruleType} onChange={(e) => updateFilters({ rule_type: e.target.value, page: 1 })}> <select value={ruleType} onChange={(e) => updateFilters({ rule_type: e.target.value, page: 1 })}>
@@ -180,20 +239,34 @@ export function DataQuality() {
)} )}
{visibleIssues.length > 0 && ( {visibleIssues.length > 0 && (
<div className="table-shell"><div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table"> <div className="table-shell">
{selected.size > 0 && (
<div className="bulk-toolbar" role="region" aria-label={t("list.bulkTitle")}>
<strong>{t("list.selected", { count: selected.size })}</strong>
<label>{t("list.assignTo")}<select value={bulkAssignee} onChange={(event) => setBulkAssignee(event.target.value)}><option value=""></option>{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}</select></label>
<label>{t("list.dueAt")}<input type="datetime-local" value={bulkDueAt} onChange={(event) => setBulkDueAt(event.target.value)} /></label>
<button type="button" className="button button-primary" disabled={bulkSaving || (!bulkAssignee && !bulkDueAt)} onClick={applyBulkWork}>{bulkSaving ? t("list.bulkSaving") : t("list.bulkApply")}</button>
<button type="button" className="button button-secondary" onClick={() => setSelected(new Set())}>{t("list.clearSelection")}</button>
</div>
)}
<div className="table-meta"><span>{t("list.count", { count: issues?.total ?? visibleIssues.length })}</span><span>{t("list.evidenceBacked")}</span></div><table className="data-table">
<caption className="visually-hidden">{t("list.title")}</caption> <caption className="visually-hidden">{t("list.title")}</caption>
<thead> <thead>
<tr> <tr>
<th scope="col"><span className="visually-hidden">{t("list.columns.select")}</span></th>
<th scope="col">{t("list.columns.reference")}</th> <th scope="col">{t("list.columns.reference")}</th>
<th scope="col">{t("list.columns.rule")}</th> <th scope="col">{t("list.columns.rule")}</th>
<th scope="col">{t("list.columns.entity")}</th> <th scope="col">{t("list.columns.entity")}</th>
<th scope="col">{t("list.columns.severity")}</th> <th scope="col">{t("list.columns.severity")}</th>
<th scope="col">{t("list.columns.status")}</th> <th scope="col">{t("list.columns.status")}</th>
<th scope="col">{t("list.columns.assignee")}</th>
<th scope="col">{t("list.columns.due")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visibleIssues.map((i) => ( {visibleIssues.map((i) => (
<tr key={i.public_ref} className="row-clickable"> <tr key={i.public_ref} className="row-clickable">
<td className="selection-cell"><input type="checkbox" checked={selected.has(i.public_ref)} onChange={() => toggleSelected(i.public_ref)} aria-label={t("list.selectIssue", { ref: i.public_ref })} /></td>
<th scope="row" data-label={t("list.columns.reference")}> <th scope="row" data-label={t("list.columns.reference")}>
{i.public_ref} {i.public_ref}
<Link className="row-link" to={`/data-quality/${i.public_ref}`}><span className="visually-hidden">{i.public_ref}</span></Link> <Link className="row-link" to={`/data-quality/${i.public_ref}`}><span className="visually-hidden">{i.public_ref}</span></Link>
@@ -206,6 +279,8 @@ export function DataQuality() {
<td data-label={t("list.columns.status")}> <td data-label={t("list.columns.status")}>
<StatusBadge status={i.status} label={t(`list.status${i.status.charAt(0).toUpperCase()}${i.status.slice(1)}`, { defaultValue: i.status })} /> <StatusBadge status={i.status} label={t(`list.status${i.status.charAt(0).toUpperCase()}${i.status.slice(1)}`, { defaultValue: i.status })} />
</td> </td>
<td data-label={t("list.columns.assignee")}>{i.assigned_to_name ?? t("list.unassigned")}</td>
<td data-label={t("list.columns.due")} className={i.overdue ? "is-overdue" : ""}>{i.due_at ? <time dateTime={i.due_at}>{formatDateTime(i.due_at)}</time> : "—"}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+115 -20
View File
@@ -16,37 +16,132 @@ export function Users() {
const [role, setRole] = useState<Role>("rental_employee"); const [role, setRole] = useState<Role>("rental_employee");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null); const [error, setError] = useState<ApiErrorInfo | null>(null);
const [editingRef, setEditingRef] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editRole, setEditRole] = useState<Role>("rental_employee");
const [newPassword, setNewPassword] = useState("");
const load = useCallback(() => { const load = useCallback(() => {
api.get<UserRecord[]>("/api/v1/users").then(setUsers).catch((err) => setError(describeApiError(t, err))); api
.get<UserRecord[]>("/api/v1/users")
.then(setUsers)
.catch((err) => setError(describeApiError(t, err)));
}, [t]); }, [t]);
useEffect(load, [load]); useEffect(load, [load]);
async function create(event: FormEvent) { async function create(event: FormEvent) {
event.preventDefault(); setSaving(true); setError(null); event.preventDefault();
setSaving(true);
setError(null);
try { try {
await api.post<UserRecord>("/api/v1/users", { email, display_name: displayName, password, role }); await api.post<UserRecord>("/api/v1/users", {
setDisplayName(""); setEmail(""); setPassword(""); setRole("rental_employee"); load(); email,
} catch (err) { setError(describeApiError(t, err, "operations:users.createFailed")); } display_name: displayName,
finally { setSaving(false); } 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) { async function toggleActive(record: UserRecord) {
setError(null); setError(null);
try { await api.patch(`/api/v1/users/${record.public_ref}`, { active: !record.active }); load(); } try {
catch (err) { setError(describeApiError(t, err, "operations:users.updateFailed")); } 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"> return (
<PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.description")} /> <div className="page">
<ApiErrorNotice error={error} /> <PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.description")} />
<section className="record-surface user-create"><h2>{t("users.addTitle")}</h2><form onSubmit={create}><div className="form-grid"> <ApiErrorNotice error={error} />
<label>{t("users.name")}<input required minLength={2} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label> <section className="record-surface user-create">
<label>{t("users.email")}<input required type="email" value={email} onChange={(event) => setEmail(event.target.value)} /></label> <h2>{t("users.addTitle")}</h2>
<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> <form onSubmit={create}>
<label>{t("users.password")}<input required type="password" minLength={8} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label> <div className="form-grid">
</div><div className="form-actions"><button className="button button-primary" disabled={saving}>{saving ? t("users.saving") : t("users.add")}</button></div></form></section> <label>{t("users.name")}<input required minLength={2} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>
{!users && <LoadingState label={t("users.loading")} />} <label>{t("users.email")}<input required type="email" value={email} onChange={(event) => setEmail(event.target.value)} /></label>
{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>} <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>
</div>; <label>{t("users.password")}<input required type="password" minLength={8} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
</div>
<div className="form-actions"><button className="button button-primary" disabled={saving}>{saving ? t("users.saving") : t("users.add")}</button></div>
</form>
</section>
{!users && <LoadingState label={t("users.loading")} />}
{users && (
<div className="table-shell">
<table className="data-table">
<caption className="visually-hidden">{t("users.title")}</caption>
<thead><tr><th>{t("users.name")}</th><th>{t("users.email")}</th><th>{t("users.role")}</th><th>{t("users.status")}</th><th>{t("users.action")}</th></tr></thead>
<tbody>
{users.map((record) => (
<tr key={record.public_ref}>
<th>{record.display_name}<span className="table-secondary">{record.public_ref}</span></th>
<td>{record.email ?? "—"}</td>
<td>{t(`roles.${record.role}`)}</td>
<td><span className={`badge ${record.active ? "status-available" : "status-blocked"}`}>{t(record.active ? "users.active" : "users.inactive")}</span></td>
<td><div className="table-actions"><button type="button" className="button button-secondary" onClick={() => beginEdit(record)}>{t("users.edit")}</button><button type="button" className="button button-secondary" disabled={record.public_ref === currentUser?.public_ref} onClick={() => toggleActive(record)}>{t(record.active ? "users.deactivate" : "users.activate")}</button></div></td>
</tr>
))}
</tbody>
</table>
</div>
)}
{editingRef && (
<section className="record-surface user-edit" aria-labelledby="edit-user-title">
<h2 id="edit-user-title">{t("users.editTitle", { ref: editingRef })}</h2>
<form onSubmit={saveEdit}>
<div className="form-grid">
<label>{t("users.name")}<input required minLength={2} value={editName} onChange={(event) => setEditName(event.target.value)} /></label>
<label>{t("users.role")}<select value={editRole} disabled={editingRef === currentUser?.public_ref} onChange={(event) => setEditRole(event.target.value as Role)}><option value="rental_employee">{t("roles.rental_employee")}</option><option value="operations_manager">{t("roles.operations_manager")}</option></select></label>
<label>{t("users.newPassword")}<input type="password" minLength={8} autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} placeholder={t("users.passwordUnchanged")} /></label>
</div>
<div className="form-actions"><button className="button button-primary" disabled={saving}>{saving ? t("users.saving") : t("users.saveChanges")}</button><button type="button" className="button button-secondary" onClick={() => setEditingRef(null)}>{t("users.cancel")}</button></div>
</form>
</section>
)}
</div>
);
} }
+10 -3
View File
@@ -12,13 +12,14 @@ const STATUS_OPTIONS = ["available", "rented", "cleaning", "maintenance", "block
export function Vehicles() { export function Vehicles() {
const { t } = useTranslation("fleet"); const { t } = useTranslation("fleet");
const { formatNumber } = useLocaleFormat(); const { formatDateTime, formatNumber } = useLocaleFormat();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [vehicles, setVehicles] = useState<Page<Vehicle> | null>(null); const [vehicles, setVehicles] = useState<Page<Vehicle> | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const status = searchParams.get("status") ?? ""; const status = searchParams.get("status") ?? "";
const attentionOnly = searchParams.get("attention_only") === "true"; const attentionOnly = searchParams.get("attention_only") === "true";
const query = searchParams.get("q") ?? ""; const query = searchParams.get("q") ?? "";
const location = searchParams.get("location") ?? "";
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1); const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
function updateFilters(updates: Record<string, string | boolean | number | null>) { function updateFilters(updates: Record<string, string | boolean | number | null>) {
@@ -37,13 +38,14 @@ export function Vehicles() {
if (status) params.set("status", status); if (status) params.set("status", status);
if (attentionOnly) params.set("attention_only", "true"); if (attentionOnly) params.set("attention_only", "true");
if (query) params.set("query", query); if (query) params.set("query", query);
if (location) params.set("location", location);
params.set("page", String(page)); params.set("page", String(page));
params.set("page_size", "25"); params.set("page_size", "25");
api api
.get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`) .get<Page<Vehicle>>(`/api/v1/vehicles?${params.toString()}`)
.then(setVehicles) .then(setVehicles)
.catch(() => setError(t("list.unavailable"))); .catch(() => setError(t("list.unavailable")));
}, [status, attentionOnly, query, page, t]); }, [status, attentionOnly, query, location, page, t]);
return ( return (
<div className="page"> <div className="page">
@@ -73,6 +75,7 @@ export function Vehicles() {
/> />
{t("list.attentionOnly")} {t("list.attentionOnly")}
</label> </label>
<label>{t("list.locationFilter")}<input value={location} onChange={(event) => updateFilters({ location: event.target.value, page: 1 })} placeholder={t("list.locationPlaceholder")} /></label>
</form> </form>
{error && <ErrorState message={error} />} {error && <ErrorState message={error} />}
@@ -88,6 +91,8 @@ export function Vehicles() {
<th scope="col">{t("list.columns.location")}</th> <th scope="col">{t("list.columns.location")}</th>
<th scope="col">{t("list.columns.status")}</th> <th scope="col">{t("list.columns.status")}</th>
<th scope="col">{t("list.columns.odometer")}</th> <th scope="col">{t("list.columns.odometer")}</th>
<th scope="col">{t("list.columns.service")}</th>
<th scope="col">{t("list.columns.nextBooking")}</th>
<th scope="col">{t("list.columns.attention")}</th> <th scope="col">{t("list.columns.attention")}</th>
</tr> </tr>
</thead> </thead>
@@ -106,7 +111,9 @@ export function Vehicles() {
<StatusBadge status={v.operational_status} label={t(`statuses.${v.operational_status}`, { defaultValue: v.operational_status })} /> <StatusBadge status={v.operational_status} label={t(`statuses.${v.operational_status}`, { defaultValue: v.operational_status })} />
</td> </td>
<td data-label={t("list.columns.odometer")}>{formatNumber(v.odometer_km)}</td> <td data-label={t("list.columns.odometer")}>{formatNumber(v.odometer_km)}</td>
<td data-label={t("list.columns.attention")}>{v.attention ? <span className="attention-flag">{t("list.needsAttention")}</span> : "—"}</td> <td data-label={t("list.columns.service")} className={v.service_remaining_km <= 0 ? "is-overdue" : ""}>{v.service_remaining_km <= 0 ? t("list.serviceDue") : t("list.serviceRemaining", { count: formatNumber(v.service_remaining_km) })}</td>
<td data-label={t("list.columns.nextBooking")}>{v.next_booking_ref && v.next_booking_at ? <Link className="cell-link" to={`/bookings/${v.next_booking_ref}`}>{v.next_booking_ref}<span className="table-secondary">{formatDateTime(v.next_booking_at)}</span></Link> : "—"}</td>
<td data-label={t("list.columns.attention")}>{v.attention && v.attention_reason ? <span className="attention-flag">{t(`list.attentionReasons.${v.attention_reason}`)}</span> : "—"}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+12
View File
@@ -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 { min-height: 34px; padding: 5px 10px; }
.knowledge-feedback button.is-selected { color: var(--teal-dark); border-color: var(--teal); background: var(--teal-pale); } .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); } .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; } .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); } .api-error-notice { display: block; padding: 12px 14px; background: #fdf1f1; border: 1px solid #f0caca; border-radius: var(--radius); }