# Feature 05 — Vehicle Management (Dashboard)

## What this feature does

The back office manages the vehicles captains drive: a filterable list, a detail screen,
putting a vehicle on record for a captain who has none, correcting a record, replacing its
photos, tracking the registration and insurance papers, and taking a vehicle off the road.

The data model is unchanged in shape: **one captain, one vehicle**. Registration still
writes the vehicle row, and approving a company-car captain still fills it in. This feature
adds the screens around that row. It does not add a fleet pool of unassigned cars.

## Endpoints (all under `/api/dashboard`, `auth:admin`)

| Method | Path | Permission | Purpose |
| --- | --- | --- | --- |
| GET | `/api/dashboard/vehicles` | `vehicles.view` | List, paginated, newest first, filterable (see below). Switched-off vehicles included. |
| POST | `/api/dashboard/vehicles` | `vehicles.create` | Add a vehicle for a captain who has none. Multipart; photos optional. |
| GET | `/api/dashboard/vehicles/{uuid}` | `vehicles.view` | Detail: captain, photos, document statuses. |
| PUT | `/api/dashboard/vehicles/{uuid}` | `vehicles.update` | Correct the fields sent (JSON). |
| POST | `/api/dashboard/vehicles/{uuid}/images` | `vehicles.update` | Replace `vehicle_image` and/or `mechanics_image` (multipart). |
| PATCH | `/api/dashboard/vehicles/{uuid}/activation` | `vehicles.update` | Toggle the vehicle on/off. |

Route identifiers are UUIDs (`whereUuid`), matching the `Vehicle` model's `HasUuids`.
`driver_uuid` in a body or filter is the captain's **ULID**, like every other captain-facing
endpoint. Photos have their own POST endpoint because PHP only parses multipart uploads on a
POST, so they can't be sent with the JSON PUT.

The three permissions already existed in `AdminPermission` and `RoleSeeder` (the
operations-manager has all three, support has `vehicles.view`), so nothing needs re-seeding.

## Captain app: the captain's own vehicle (under `/api/driver`, `auth:driver`)

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/driver/vehicle` | The signed-in captain's vehicle, with photos and document statuses. |
| POST | `/api/driver/vehicle` | Update it (multipart): vehicle type, plate / brand / model / year / color, registration and insurance, photos. |

Who can change what:

- **Own car (`ownership_type = personal`)**: the captain may change every field above. The
  change is saved immediately and the back office sees it on the vehicle screens at once;
  it is not held for review. The plate stays unique, but re-sending their own plate is
  allowed.
- **Company car (`company_owned`)**: view only. An update gets **403**
  (`company_vehicle_read_only`) and nothing is written, photos included. The back office
  assigns and edits company cars.
- The captain can never change `ownership_type`. It is ignored if sent.
- A captain with no vehicle on record gets **404** (`no_vehicle`).

The captain and the back office go through the same `VehicleService`, so the same rules
apply either way. Tests: `tests/Feature/Vehicle/CaptainVehicleApiTest.php`.

## New vehicle details

| Column | Type | Notes |
| --- | --- | --- |
| `vehicle_type` | enum `motorcycle` / `car` / `van` / `pickup_truck` / `truck` | Nullable: older rows and company cars still awaiting assignment have none. |
| `registration_number` | string | Nullable. |
| `registration_expires_at` | date | Nullable, indexed. |
| `insurance_policy_number` | string | Nullable. |
| `insurance_expires_at` | date | Nullable, indexed. |

Migration: `2026_09_12_090000_add_management_details_to_vehicles_table`. It also adds a
**unique index on `vehicles.driver_id`**, so the database itself refuses a second vehicle for
a captain.

## Document status

Every vehicle response carries `registration_status` and `insurance_status` (plus `_label`).
Both are worked out **on the day of the request** by `VehicleDocumentStatus::fromExpiry()`:

| Status | Rule |
| --- | --- |
| `missing` | No expiry date on record. |
| `expired` | Expiry date is before today. |
| `expiring_soon` | Expires today or within the next 30 days (`EXPIRING_SOON_DAYS`). |
| `valid` | Expires more than 30 days from now. |

The `document_status` list filter uses the same rules
(`VehicleRepository::whereDocumentStatus()`):

- `missing`, `expired`, `expiring_soon` return vehicles with **at least one** document in
  that state.
- `valid` returns vehicles whose registration **and** insurance are both valid.

## List filters

`rows` and `page` (required), plus these optional filters:

- `search`: plate, brand, model, registration number, insurance policy number, or the
  captain's name or phone.
- `ownership_type`, `vehicle_type`, `document_status`: enum values; anything else is a 422.
- `driver_uuid`: one captain's vehicle.
- `is_active`: `1` or `0`. Sent empty, it means no filter.
- `awaiting_assignment`: `1` returns company-owned rows with no plate yet (captains waiting
  for a fleet car); `0` returns rows that have a plate.

## Rules and responses

- **One vehicle per captain.** Adding a vehicle for a captain who already has one returns
  **409** (`captain_has_vehicle`). Unknown `driver_uuid` returns **404**.
- **Plate numbers are unique on every write path**: admin create and update, captain
  registration (`RegisterDriverRequest`) and approval with a fleet car
  (`ApproveDriverRequest`). A duplicate returns **422** on that field. Re-sending a
  vehicle's own plate is allowed.
- **Update sends only what changed.** Fields left out are untouched. Identifying fields
  can't be blanked. Registration and insurance fields may be sent as `null` to clear them.
  An empty body returns the record unchanged.
- **Filling in a waiting fleet car counts for approval.** Once a company car has a plate,
  approving its captain doesn't need a `vehicle` block. This is the same check
  `DriverService` already runs.
- **No delete.** A vehicle is switched off instead, the same way a captain is.
- A view-only admin gets **403** on create, update, images and activation. A missing token
  gets **401**, and so do missing `Accept` / `Accept-Language` headers.

## Deliberately not in this feature

- **Dispatch eligibility is unchanged.** An inactive vehicle, or one with expired papers,
  does not yet stop its captain being suggested or assigned an order
  (`DriverRepository::suggestableCaptains()`). If it should, add that condition to that
  query.
- **No DB unique index on `plate_number` yet.** Uniqueness is enforced by validation, so
  rows written before the rule existed can't block the migration. Once no two vehicles share
  a plate, add the index in a follow-up migration.
- **No fleet pool.** Cars can't exist without a captain, and can't be reassigned to another
  captain.
- **No notification** to the captain when their vehicle record changes.

## Data and seeds

- `VehicleFactory` fills in a category and papers valid for a year. `awaitingAssignment()`
  clears them.
- `VehicleSeeder` gives each seeded vehicle a category and papers. One registration is
  already expired (`XYZ-3003`) and one vehicle has no registration on record (`QRS-5005`),
  so the document filter has demo data. The dates are fixed, so `expiring_soon` is covered by
  the tests only.

## Tests

- `tests/Feature/Vehicle/VehicleManagementApiTest.php`: list and every filter, document
  classification matching the filter, detail, 404, create (with photos), 409, 404 captain,
  duplicate plate, required fields, partial update and null-clearing, plate clash on update,
  filling a waiting fleet car then approving, photo replacement (single file), activation
  toggle, view-only 403s, 401 anonymous, 401 missing headers.
- `DriverRegistrationApiTest`: a plate another vehicle carries is refused.
- `DriverManagementApiTest`: approval can't hand over another vehicle's plate, but
  re-sending the captain's own plate is allowed.
