From 188a9577bf7a2529ad664e02218ace21131c9639 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 17:36:38 +0800 Subject: [PATCH 01/12] Compute the Radar triage list and keep its state on alerts Radar replaces the Resources Hub with the gaps a fleet manager has to decide about, one item per record: maintenance and inspection schedules due, overdue or blocked work orders, open issues, failed inspections with no follow-up, unresolved inspection follow-ups, stale drafts, inspection links expiring unused, vehicles flagged inspection_failed, drivers late for a shift, on shift without a vehicle, or ending a shift with orders still assigned, licences and leases expiring, unmatched fuel, low stock, and the notices people write themselves. RadarRules is pure: it takes plain rows and a clock and answers with items, pill counts and a summary, so every rule is tested against a fixture and a fixed morning. RadarItemState is the one piece that touches the database: acknowledge, snooze, assign and plan live on the core alerts table, a row created only when someone acts, found again by the item key, and resolved automatically when the record's gap closes. RadarController loads each source in its own try/catch so one failing table degrades the page rather than blanking it. Three fixes the rules exposed: a driver's current shift filtered on a status the schedule_items enum does not have; a part had three different definitions of "low stock"; an inspection schedule produced a preventive maintenance work order. --- .../Commands/ProcessMaintenanceTriggers.php | 5 +- .../Internal/v1/RadarController.php | 889 +++++++++++ server/src/Models/Driver.php | 10 +- server/src/Models/Part.php | 11 +- server/src/Support/Radar/RadarItemState.php | 291 ++++ server/src/Support/Radar/RadarRules.php | 1396 +++++++++++++++++ server/src/routes.php | 20 + .../Http/Internal/RadarControllerTest.php | 499 ++++++ server/tests/RadarRoutesTest.php | 31 + .../tests/Unit/Support/RadarItemStateTest.php | 238 +++ server/tests/Unit/Support/RadarRulesTest.php | 475 ++++++ 11 files changed, 3860 insertions(+), 5 deletions(-) create mode 100644 server/src/Http/Controllers/Internal/v1/RadarController.php create mode 100644 server/src/Support/Radar/RadarItemState.php create mode 100644 server/src/Support/Radar/RadarRules.php create mode 100644 server/tests/Feature/Http/Internal/RadarControllerTest.php create mode 100644 server/tests/RadarRoutesTest.php create mode 100644 server/tests/Unit/Support/RadarItemStateTest.php create mode 100644 server/tests/Unit/Support/RadarRulesTest.php diff --git a/server/src/Console/Commands/ProcessMaintenanceTriggers.php b/server/src/Console/Commands/ProcessMaintenanceTriggers.php index 9816a49b4..e0c0e8b3c 100644 --- a/server/src/Console/Commands/ProcessMaintenanceTriggers.php +++ b/server/src/Console/Commands/ProcessMaintenanceTriggers.php @@ -93,7 +93,10 @@ public function handle(): void 'company_uuid' => $schedule->company_uuid, 'schedule_uuid' => $schedule->uuid, 'subject' => $schedule->name, - 'category' => 'preventive_maintenance', + // An inspection schedule produces an inspection request, + // not preventive maintenance, so the work order reads as + // what it is. + 'category' => $schedule->type === 'inspection' ? 'inspection_request' : 'preventive_maintenance', 'code' => $woCode, 'status' => 'open', 'priority' => $schedule->default_priority ?? 'normal', diff --git a/server/src/Http/Controllers/Internal/v1/RadarController.php b/server/src/Http/Controllers/Internal/v1/RadarController.php new file mode 100644 index 000000000..fe56dc370 --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/RadarController.php @@ -0,0 +1,889 @@ +companyUuid($request); + $now = $this->now(); + $tab = in_array($request->input('status'), ['open', 'snoozed', 'resolved'], true) ? $request->input('status') : 'open'; + $pills = array_values(array_filter(explode(',', (string) $request->input('filters', '')))); + + $built = $this->buildItems($company, $now, true); + + if ($tab === 'resolved') { + $items = $this->resolvedSince($company, $now->copy()->subDays(self::RESOLVED_WINDOW_DAYS), $now); + } else { + $items = RadarRules::forTab($built['items'], $tab, $now); + } + + $items = RadarRules::forPills($items, $pills); + $items = RadarRules::forFleet($items, $request->input('fleet')); + $items = RadarRules::search($items, $request->input('query')); + $page = RadarRules::paginate($items, (int) $request->input('page', 1), (int) $request->input('limit', 50)); + + return response()->json([ + 'items' => $page['items'], + 'groups' => RadarRules::group($page['items']), + 'meta' => $page['meta'], + 'counts' => $built['counts'], + 'summary' => $built['summary'] + ['resolved' => $tab === 'resolved' ? count($items) : null], + 'snooze_schedule' => $this->snoozeSchedule($company, $now), + 'generated_at' => $now->toIso8601String(), + 'sources' => $built['errors'], + ]); + } + + /** + * The numbers the header and the dashboard widget show. + */ + public function summary(Request $request): JsonResponse + { + $company = $this->companyUuid($request); + $now = $this->now(); + $built = $this->buildItems($company, $now, false); + + return response()->json([ + 'summary' => $built['summary'], + 'counts' => $built['counts'], + 'generated_at' => $now->toIso8601String(), + ]); + } + + public function acknowledge(Request $request, string $key): JsonResponse + { + return $this->act($request, $key, function (Alert $row, array $item) use ($request) { + $row->acknowledge($this->actor($request)); + }); + } + + public function snooze(Request $request, string $key): JsonResponse + { + $minutes = $this->snoozeMinutes($request); + if ($minutes === null) { + return response()->json(['error' => 'Pass minutes (1 to ' . (self::SNOOZE_MAX_DAYS * 1440) . ') or a future until date.'], 422); + } + + return $this->act($request, $key, function (Alert $row) use ($request, $minutes) { + $row->snooze($minutes, $request->input('reason'), $this->actor($request)); + }); + } + + public function wake(Request $request, string $key): JsonResponse + { + return $this->act($request, $key, function (Alert $row) { + $row->unsnooze(); + }, false); + } + + public function assign(Request $request, string $key): JsonResponse + { + $company = $this->companyUuid($request); + $assignee = null; + + if ($request->filled('user')) { + $assignee = $this->findCompanyUser($company, (string) $request->input('user')); + if (!$assignee) { + return response()->json(['error' => 'That user is not a member of this company.'], 422); + } + } + + return $this->act($request, $key, function (Alert $row) use ($assignee) { + $row->assignTo($assignee); + }); + } + + public function plan(Request $request, string $key): JsonResponse + { + $plannedAt = null; + if ($request->filled('planned_at')) { + $plannedAt = RadarRules::carbon($request->input('planned_at')); + if (!$plannedAt) { + return response()->json(['error' => 'planned_at must be a date.'], 422); + } + } + + return $this->act($request, $key, function (Alert $row) use ($plannedAt) { + $row->update(['planned_at' => $plannedAt]); + }); + } + + /** + * Only a notice can be resolved by hand: every other item resolves when + * the record it describes changes. + */ + public function resolve(Request $request, string $key): JsonResponse + { + $parsed = RadarRules::parseKey($key); + if (!$parsed || $parsed[0] !== 'notice') { + return response()->json(['error' => 'Only notices resolve by hand; other items close when their record changes.'], 422); + } + + return $this->act($request, $key, function (Alert $row) use ($request) { + $row->resolve($this->actor($request), $request->input('resolution')); + }, false); + } + + /** + * One state action over many keys. Record-level actions stay one at a time. + */ + public function bulk(Request $request): JsonResponse + { + $keys = array_values(array_filter((array) $request->input('keys', []), 'is_string')); + $action = (string) $request->input('action'); + + if (!$keys) { + return response()->json(['error' => 'Pass at least one key.'], 422); + } + if (!in_array($action, ['acknowledge', 'snooze', 'wake', 'assign', 'plan'], true)) { + return response()->json(['error' => 'action must be acknowledge, snooze, wake, assign or plan.'], 422); + } + + $company = $this->companyUuid($request); + $now = $this->now(); + $built = $this->buildItems($company, $now, false); + $byKey = array_column($built['items'], null, 'key'); + $actor = $this->actor($request); + $minutes = $action === 'snooze' ? $this->snoozeMinutes($request) : null; + $assignee = null; + $planned = null; + + if ($action === 'snooze' && $minutes === null) { + return response()->json(['error' => 'Pass minutes or a future until date.'], 422); + } + if ($action === 'assign' && $request->filled('user')) { + $assignee = $this->findCompanyUser($company, (string) $request->input('user')); + if (!$assignee) { + return response()->json(['error' => 'That user is not a member of this company.'], 422); + } + } + if ($action === 'plan' && $request->filled('planned_at')) { + $planned = RadarRules::carbon($request->input('planned_at')); + } + + $results = []; + foreach ($keys as $key) { + $item = $byKey[$key] ?? null; + $row = $item ? $this->rowFor($company, $item) : $this->findRow($company, $key); + + if (!$row) { + $results[] = ['key' => $key, 'ok' => false, 'error' => 'not found']; + continue; + } + + match ($action) { + 'acknowledge' => $row->acknowledge($actor), + 'snooze' => $row->snooze($minutes, $request->input('reason'), $actor), + 'wake' => $row->unsnooze(), + 'assign' => $row->assignTo($assignee), + 'plan' => $row->update(['planned_at' => $planned]), + }; + + $results[] = ['key' => $key, 'ok' => true, 'state' => $this->stateOf($row, $now)]; + } + + return response()->json(['results' => $results, 'generated_at' => $now->toIso8601String()]); + } + + /** + * A notice is an item a person writes: a yard closure, a deadline for + * the whole fleet. + */ + public function storeNotice(Request $request): JsonResponse + { + $message = trim((string) $request->input('message')); + $severity = in_array($request->input('severity'), [RadarRules::SEVERITY_CRITICAL, RadarRules::SEVERITY_WARNING, RadarRules::SEVERITY_INFO], true) ? $request->input('severity') : RadarRules::SEVERITY_INFO; + $dueAt = $request->filled('due_at') ? RadarRules::carbon($request->input('due_at')) : null; + + if ($message === '') { + return response()->json(['error' => 'A notice needs a message.'], 422); + } + if ($request->filled('due_at') && !$dueAt) { + return response()->json(['error' => 'due_at must be a date.'], 422); + } + + $company = $this->companyUuid($request); + $now = $this->now(); + $actor = $this->actor($request); + + $alert = $this->createNotice([ + 'company_uuid' => $company, + 'type' => RadarRules::NOTICE_TYPE, + 'severity' => $severity, + 'status' => 'open', + 'message' => $message, + 'triggered_at' => $now, + 'meta' => [ + 'due_at' => $dueAt?->toIso8601String(), + 'scope' => $request->input('scope') ? trim((string) $request->input('scope')) : null, + 'created_by_uuid' => $actor?->uuid, + 'created_by_name' => $actor?->name, + ], + ]); + + $alert->context = ['key' => RadarRules::keyFor('notice', $alert->public_id ?? $alert->uuid), 'rule' => 'notice', 'category' => RadarRules::CATEGORY_NOTICES, 'chip' => 'Notice', 'due_at' => $dueAt?->toIso8601String()]; + $alert->save(); + + $items = RadarRules::noticeItems($this->notices($company), $now); + $items = RadarRules::mergeStates($items, [], $now); + $item = collect($items)->firstWhere('source.uuid', $alert->uuid); + + return response()->json(['item' => $item, 'generated_at' => $now->toIso8601String()], 201); + } + + public function destroyNotice(Request $request, string $id): JsonResponse + { + $company = $this->companyUuid($request); + $alert = $this->findNotice($company, $id); + + if (!$alert) { + return response()->json(['error' => 'Notice not found.'], 404); + } + + $alert->delete(); + + return response()->json(['deleted' => true]); + } + + // ------------------------------------------------------------------ + // Building + // ------------------------------------------------------------------ + + /** + * Load every source, run the rules, attach state, and (for the list) + * close the rows whose gap is gone. + * + * @return array{items: array, counts: array, summary: array, errors: array} + */ + protected function buildItems(?string $company, Carbon $now, bool $reconcile): array + { + $errors = []; + $sources = []; + + foreach ($this->sourceLoaders() as $name => $loader) { + try { + $sources[$name] = $loader($company, $now, $sources); + } catch (\Throwable $e) { + $sources[$name] = []; + $errors[$name] = $e->getMessage(); + if (function_exists('report')) { + report($e); + } + } + } + + try { + $sources['states'] = $this->statesFor($company); + } catch (\Throwable $e) { + $sources['states'] = []; + $errors['states'] = $e->getMessage(); + } + + $built = RadarRules::build($sources, $now); + + if ($reconcile && empty($errors)) { + try { + $this->reconcile($company, array_column($built['items'], 'key')); + } catch (\Throwable $e) { + $errors['reconcile'] = $e->getMessage(); + } + } + + $built['errors'] = $errors; + + return $built; + } + + /** + * Source name => loader. Order matters where one loader reads another's + * rows (shifts read drivers, fuel reads vehicles). + * + * @return array + */ + protected function sourceLoaders(): array + { + return [ + 'schedules' => fn ($company, $now) => $this->loadSchedules($company, $now), + 'workOrders' => fn ($company, $now) => $this->loadWorkOrders($company, $now), + 'issues' => fn ($company, $now) => $this->loadIssues($company, $now), + 'drivers' => fn ($company, $now) => $this->loadDrivers($company, $now), + 'shifts' => fn ($company, $now, $sources) => $this->loadShifts($company, $now, $sources['drivers'] ?? []), + 'vehicles' => fn ($company, $now) => $this->loadVehicles($company, $now), + 'trailers' => fn ($company, $now) => $this->loadTrailers($company, $now), + 'devices' => fn ($company, $now) => $this->loadDevices($company, $now), + 'parts' => fn ($company, $now) => $this->loadParts($company, $now), + 'fuelTransactions' => fn ($company, $now, $sources) => $this->loadFuelTransactions($company, $now, $sources['vehicles'] ?? []), + 'inspectionSubmissions' => fn ($company, $now) => $this->loadInspectionSubmissions($company, $now), + 'inspectionLinks' => fn ($company, $now) => $this->loadInspectionLinks($company, $now), + 'notices' => fn ($company) => $this->notices($company), + ]; + } + + protected function loadSchedules(?string $company, Carbon $now): array + { + return $this->scoped(MaintenanceSchedule::query(), $company) + ->where('status', 'active') + ->whereNotNull('next_due_date') + ->where('next_due_date', '<=', $now->copy()->addDays(RadarRules::DUE_SOON_DAYS)) + ->with('subject') + ->withCount(['workOrders as open_work_orders_count' => fn ($query) => $query->whereNotIn('status', RadarRules::WORK_ORDER_CLOSED)]) + ->get() + ->map(fn (MaintenanceSchedule $schedule) => [ + 'uuid' => $schedule->uuid, + 'public_id' => $schedule->public_id, + 'name' => $schedule->name, + 'type' => $schedule->type, + 'status' => $schedule->status, + 'next_due_date' => $schedule->next_due_date, + 'next_due_odometer' => $schedule->next_due_odometer, + 'has_open_work_order' => (int) ($schedule->open_work_orders_count ?? 0) > 0, + 'subject' => $this->subjectFor($schedule->subject), + ]) + ->all(); + } + + protected function loadWorkOrders(?string $company, Carbon $now): array + { + return $this->scoped(WorkOrder::query(), $company) + ->whereNotIn('status', RadarRules::WORK_ORDER_CLOSED) + ->where(function ($query) use ($now) { + $query->where(fn ($due) => $due->whereNotNull('due_at')->where('due_at', '<', $now)) + ->orWhereIn('status', RadarRules::WORK_ORDER_BLOCKED); + }) + ->with('target') + ->get() + ->map(fn (WorkOrder $workOrder) => [ + 'uuid' => $workOrder->uuid, + 'public_id' => $workOrder->public_id, + 'code' => $workOrder->code, + 'subject' => $workOrder->subject, + 'status' => $workOrder->status, + 'priority' => $workOrder->priority, + 'due_at' => $workOrder->due_at, + 'checklist' => $workOrder->checklist, + 'target' => $this->subjectFor($workOrder->target), + ]) + ->all(); + } + + protected function loadIssues(?string $company, Carbon $now): array + { + return $this->scoped(Issue::query(), $company) + ->whereNotIn('status', ['resolved', 'closed']) + ->with(['vehicle', 'driver.user']) + ->get() + ->map(fn (Issue $issue) => [ + 'uuid' => $issue->uuid, + 'public_id' => $issue->public_id, + 'title' => $issue->title, + 'report' => $issue->report, + 'priority' => $issue->priority, + 'status' => $issue->status, + 'meta' => $issue->meta ?? [], + 'reporter_name' => $issue->reporter_name, + 'vehicle' => $this->subjectFor($issue->vehicle), + 'driver' => $this->subjectFor($issue->driver), + ]) + ->all(); + } + + protected function loadDrivers(?string $company, Carbon $now): array + { + return $this->scoped(Driver::query(), $company) + ->with(['user']) + ->withCount(['orders as active_orders_count' => fn ($query) => $query->whereNotIn('status', LiveOrderQuery::$baseExcludedStatuses)]) + ->get() + ->map(fn (Driver $driver) => [ + 'class' => Driver::class, + 'uuid' => $driver->uuid, + 'public_id' => $driver->public_id, + 'name' => $driver->name, + 'phone' => $driver->phone, + 'photo_url' => $driver->photo_url, + 'online' => (bool) $driver->online, + 'status' => $driver->status, + 'vehicle_uuid' => $driver->vehicle_uuid, + 'license_expiry' => $driver->license_expiry, + 'drivers_license_number' => $driver->drivers_license_number, + 'active_orders' => (int) ($driver->active_orders_count ?? 0), + 'location' => $this->pointFor($driver->location), + ]) + ->all(); + } + + protected function loadShifts(?string $company, Carbon $now, array $drivers): array + { + $byUuid = array_column($drivers, null, 'uuid'); + if (!$byUuid) { + return []; + } + + return ScheduleItem::query() + ->whereIn('assignee_type', ['fleet-ops:driver', Driver::class]) + ->whereIn('assignee_uuid', array_keys($byUuid)) + ->whereIn('status', Driver::LIVE_SHIFT_STATUSES) + ->where('end_at', '>', $now) + ->where('start_at', '<=', $now->copy()->addHours(24)) + ->orderBy('start_at') + ->get() + ->map(function (ScheduleItem $shift) use ($byUuid) { + $driver = $byUuid[$shift->assignee_uuid] ?? null; + + return [ + 'uuid' => $shift->uuid, + 'public_id' => $shift->public_id, + 'start_at' => $shift->start_at, + 'end_at' => $shift->end_at, + 'status' => $shift->status, + 'driver' => $driver ? ['type' => 'driver', 'class' => Driver::class, 'uuid' => $driver['uuid'], 'public_id' => $driver['public_id'], 'label' => $driver['name'], 'photo_url' => $driver['photo_url'], 'phone' => $driver['phone']] : null, + 'driver_online' => (bool) ($driver['online'] ?? false), + 'driver_vehicle_uuid' => $driver['vehicle_uuid'] ?? null, + 'active_orders' => (int) ($driver['active_orders'] ?? 0), + ]; + }) + ->all(); + } + + protected function loadVehicles(?string $company, Carbon $now): array + { + return $this->scoped(Vehicle::query(), $company) + ->with(['driver']) + ->withCount('devices') + ->get() + ->map(fn (Vehicle $vehicle) => [ + 'class' => Vehicle::class, + 'uuid' => $vehicle->uuid, + 'public_id' => $vehicle->public_id, + 'label' => $vehicle->display_name, + 'photo_url' => $vehicle->photo_url, + 'status' => $vehicle->status, + 'driver_uuid' => $vehicle->driver?->uuid, + 'device_count' => (int) ($vehicle->devices_count ?? 0), + 'lease_expires_at' => $vehicle->lease_expires_at, + 'plate_number' => $vehicle->plate_number, + 'vin' => $vehicle->vin, + 'fuel_card_number' => $vehicle->fuel_card_number, + ]) + ->all(); + } + + protected function loadTrailers(?string $company, Carbon $now): array + { + return $this->scoped(Trailer::query(), $company) + ->whereNotNull('lease_expires_at') + ->where('lease_expires_at', '<=', $now->copy()->addDays(RadarRules::EXPIRING_DAYS)) + ->get() + ->map(fn (Trailer $trailer) => [ + 'class' => Trailer::class, + 'uuid' => $trailer->uuid, + 'public_id' => $trailer->public_id, + 'label' => $trailer->display_name, + 'photo_url' => $trailer->photo_url, + 'status' => $trailer->status, + 'lease_expires_at' => $trailer->lease_expires_at, + ]) + ->all(); + } + + protected function loadDevices(?string $company, Carbon $now): array + { + return $this->scoped(Device::query(), $company) + ->whereNull('attachable_uuid') + ->get() + ->map(fn (Device $device) => [ + 'class' => Device::class, + 'uuid' => $device->uuid, + 'public_id' => $device->public_id, + 'name' => $device->name, + 'device_id' => $device->device_id, + 'attachable_uuid' => $device->attachable_uuid, + 'online' => (bool) $device->is_online, + ]) + ->all(); + } + + protected function loadParts(?string $company, Carbon $now): array + { + return $this->scoped(Part::query(), $company) + ->get() + ->map(fn (Part $part) => [ + 'class' => Part::class, + 'uuid' => $part->uuid, + 'public_id' => $part->public_id, + 'name' => $part->name, + 'sku' => $part->sku, + 'quantity_on_hand' => (int) $part->quantity_on_hand, + 'reorder_point' => $part->reorder_point, + 'specs' => $part->specs, + 'photo_url' => $part->photo_url ?? null, + ]) + ->all(); + } + + protected function loadFuelTransactions(?string $company, Carbon $now, array $vehicles): array + { + return $this->scoped(FuelProviderTransaction::query(), $company) + ->where('sync_status', 'unmatched') + ->get() + ->map(function (FuelProviderTransaction $transaction) use ($vehicles) { + $row = [ + 'class' => FuelProviderTransaction::class, + 'uuid' => $transaction->uuid, + 'public_id' => $transaction->public_id, + 'amount' => $transaction->amount, + 'currency' => $transaction->currency, + 'station_name' => $transaction->station_name, + 'provider' => $transaction->provider, + 'transaction_at' => $transaction->transaction_at, + 'vehicle_card_id' => $transaction->vehicle_card_id, + 'plate_number' => $transaction->plate_number, + 'vin' => $transaction->vin, + 'sync_status' => $transaction->sync_status, + ]; + $row['suggested_vehicle'] = RadarRules::suggestVehicleForFuel($row, $vehicles); + + return $row; + }) + ->all(); + } + + protected function loadInspectionSubmissions(?string $company, Carbon $now): array + { + return $this->scoped(InspectionSubmission::query(), $company) + ->where(function ($query) use ($now) { + $query->where(function ($failed) { + $failed->where('result', 'failed')->whereNull('resolved_at')->where('status', '!=', 'resolved'); + })->orWhere(function ($draft) use ($now) { + $draft->where('status', 'draft')->where('started_at', '<', $now->copy()->subHours(RadarRules::STALE_DRAFT_HOURS)); + }); + }) + ->with(['form', 'vehicle', 'driver.user', 'failedItemResults']) + ->get() + ->map(function (InspectionSubmission $submission) { + $severities = $submission->failedItemResults->pluck('severity')->map(fn ($severity) => strtolower((string) $severity))->all(); + $rank = ['critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1]; + $highest = null; + foreach ($severities as $severity) { + if (($rank[$severity] ?? 0) > ($rank[$highest] ?? 0)) { + $highest = $severity; + } + } + + return [ + 'uuid' => $submission->uuid, + 'public_id' => $submission->public_id, + 'status' => $submission->status, + 'result' => $submission->result, + 'failed_items' => (int) $submission->failed_items, + 'total_items' => (int) $submission->total_items, + 'issue_uuid' => $submission->issue_uuid, + 'work_order_uuid' => $submission->work_order_uuid, + 'resolved_at' => $submission->resolved_at, + 'started_at' => $submission->started_at, + 'submitted_at' => $submission->submitted_at, + 'created_at' => $submission->created_at, + 'form_name' => $submission->form?->name, + 'driver_name' => $submission->driver_name, + 'highest_severity' => $highest ?? ($submission->failed_items > 0 ? 'high' : 'low'), + 'failed_item_labels' => $submission->failedItemResults->map(fn ($result) => ['label' => $result->label, 'severity' => $result->severity, 'category' => $result->category])->values()->all(), + 'vehicle' => $this->subjectFor($submission->vehicle), + 'driver' => $this->subjectFor($submission->driver), + ]; + }) + ->all(); + } + + protected function loadInspectionLinks(?string $company, Carbon $now): array + { + return $this->scoped(InspectionLink::query(), $company) + ->where('status', 'active') + ->whereNull('used_at') + ->whereNotNull('expires_at') + ->where('expires_at', '<=', $now->copy()->addHours(RadarRules::LINK_EXPIRY_HOURS)) + ->with(['form', 'driver.user', 'vehicle']) + ->get() + ->map(fn (InspectionLink $link) => [ + 'class' => InspectionLink::class, + 'uuid' => $link->uuid, + 'public_id' => $link->public_id, + 'status' => $link->status, + 'used_at' => $link->used_at, + 'expires_at' => $link->expires_at, + 'last_viewed_at' => $link->last_viewed_at, + 'form_uuid' => $link->inspection_form_uuid, + 'form_public_id' => $link->form?->public_id, + 'form_name' => $link->form?->name, + 'driver' => $this->subjectFor($link->driver), + 'vehicle' => $this->subjectFor($link->vehicle), + ]) + ->all(); + } + + // ------------------------------------------------------------------ + // Acting + // ------------------------------------------------------------------ + + /** + * Run a state mutation for one key and answer with the item as it now + * reads. When `$needsItem` is false the row must already exist (wake, + * resolve); otherwise the row is created from the live item. + */ + protected function act(Request $request, string $key, callable $mutate, bool $needsItem = true): JsonResponse + { + if (!RadarRules::parseKey($key)) { + return response()->json(['error' => 'Unknown item key.'], 404); + } + + $company = $this->companyUuid($request); + $now = $this->now(); + $built = $this->buildItems($company, $now, false); + $item = collect($built['items'])->firstWhere('key', $key); + $row = null; + + if ($item) { + $row = $needsItem ? $this->rowFor($company, $item) : $this->findRow($company, $key); + } elseif (!$needsItem) { + $row = $this->findRow($company, $key); + } + + if (!$row) { + return response()->json(['error' => $item ? 'This item has no state yet.' : 'Item not found.'], 404); + } + + $mutate($row, $item ?? []); + + $state = $this->stateOf($row, $now); + if ($item) { + $item['state'] = $state; + } + + return response()->json([ + 'item' => $item, + 'state' => $state, + 'generated_at' => $now->toIso8601String(), + ]); + } + + /** + * Minutes from either `minutes` or a future `until`, or null when neither is usable. + */ + protected function snoozeMinutes(Request $request): ?int + { + $max = self::SNOOZE_MAX_DAYS * 1440; + + if ($request->filled('minutes')) { + $minutes = (int) $request->input('minutes'); + + return $minutes >= 1 && $minutes <= $max ? $minutes : null; + } + + if ($request->filled('until')) { + $until = RadarRules::carbon($request->input('until')); + if (!$until || $until->lte($this->now())) { + return null; + } + $minutes = (int) ceil($this->now()->diffInMinutes($until)); + + return $minutes >= 1 && $minutes <= $max ? $minutes : null; + } + + return null; + } + + /** + * A user of this company by uuid or public id. + */ + protected function findCompanyUser(?string $company, string $id): ?User + { + $user = User::query() + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + + if (!$user) { + return null; + } + + $isMember = $user->company_uuid === $company + || CompanyUser::query()->where('company_uuid', $company)->where('user_uuid', $user->uuid)->exists(); + + return $isMember ? $user : null; + } + + // ------------------------------------------------------------------ + // State (thin wrappers so a test can stand in for the alerts table) + // ------------------------------------------------------------------ + + protected function statesFor(?string $company): array + { + return RadarItemState::statesFor($company); + } + + protected function rowFor(?string $company, array $item): ?Alert + { + return RadarItemState::rowFor($company, $item); + } + + protected function findRow(?string $company, string $key): ?Alert + { + return RadarItemState::findRow($company, $key); + } + + protected function reconcile(?string $company, array $liveKeys): int + { + return RadarItemState::reconcile($company, $liveKeys); + } + + protected function resolvedSince(?string $company, Carbon $since, Carbon $now): array + { + return RadarItemState::resolvedSince($company, $since, $now); + } + + protected function snoozeSchedule(?string $company, Carbon $now): array + { + return RadarItemState::snoozeSchedule($company, $now); + } + + protected function notices(?string $company): array + { + return RadarItemState::notices($company); + } + + protected function createNotice(array $attributes): Alert + { + return Alert::create($attributes); + } + + protected function findNotice(?string $company, string $id): ?Alert + { + return Alert::query() + ->where('company_uuid', $company) + ->where('type', RadarRules::NOTICE_TYPE) + ->where(function ($query) use ($id) { + $query->where('public_id', $id)->orWhere('uuid', $id); + }) + ->first(); + } + + /** + * A row's state as the console reads it, after a mutation. + */ + protected function stateOf(Alert $row, Carbon $now): array + { + $fresh = $row->exists ? $row->fresh(['acknowledgedBy', 'assignedTo', 'snoozedBy']) : null; + + return RadarRules::normalizeState(RadarItemState::toState($fresh ?? $row), $now); + } + + // ------------------------------------------------------------------ + // Plumbing + // ------------------------------------------------------------------ + + protected function now(): Carbon + { + return Carbon::now(); + } + + protected function actor(Request $request): ?User + { + $user = $request->user(); + + return $user instanceof User ? $user : null; + } + + protected function scoped(Builder $query, ?string $companyUuid): Builder + { + return $query->when($companyUuid, fn ($query) => $query->where($query->qualifyColumn('company_uuid'), $companyUuid)); + } + + protected function companyUuid(Request $request): ?string + { + return session('company') ?? $request->user()?->company_uuid ?? $request->user()?->company?->uuid; + } + + /** + * A related model as the subject block an item carries, or null. + */ + protected function subjectFor(?Model $model): ?array + { + if (!$model) { + return null; + } + + $type = match (true) { + $model instanceof Vehicle => 'vehicle', + $model instanceof Trailer => 'trailer', + $model instanceof Driver => 'driver', + $model instanceof Device => 'device', + $model instanceof Part => 'part', + default => strtolower(class_basename($model)), + }; + + $label = match ($type) { + 'driver' => $model->name, + default => $model->name ?? ($model->display_name ?? null), + }; + + return [ + 'type' => $type, + 'class' => get_class($model), + 'uuid' => $model->uuid, + 'public_id' => $model->public_id ?? null, + 'label' => $label ?: ($model->public_id ?? $type), + 'photo_url' => $model->photo_url ?? null, + ]; + } + + protected function pointFor(mixed $point): ?array + { + if (!$point || !method_exists($point, 'getLat')) { + return null; + } + + return ['lat' => $point->getLat(), 'lng' => $point->getLng()]; + } +} diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php index 142b0d45b..7eef1ac1c 100644 --- a/server/src/Models/Driver.php +++ b/server/src/Models/Driver.php @@ -57,6 +57,12 @@ class Driver extends Model use LogsActivity; use CausesActivity; use HasCustomFields; + /** + * Schedule item statuses in which a driver is expected on shift. The + * `schedule_items.status` enum has no `active`; a shift a driver is + * working is `in_progress`. + */ + public const LIVE_SHIFT_STATUSES = ['pending', 'scheduled', 'confirmed', 'in_progress']; /** * The database table used by the model. @@ -375,7 +381,7 @@ public function scheduleItems(): MorphMany public function currentShift(): MorphOne { return $this->morphOne(ScheduleItem::class, 'assignee', 'assignee_type', 'assignee_uuid') - ->whereIn('status', ['scheduled', 'active']) + ->whereIn('status', static::LIVE_SHIFT_STATUSES) ->where('start_at', '<=', now()) ->where('end_at', '>=', now()) ->latest('start_at'); @@ -391,7 +397,7 @@ public function activeShiftFor(?\DateTimeInterface $date = null): ?ScheduleItem return $this->scheduleItems() ->whereDate('start_at', $date) - ->whereIn('status', ['pending', 'active']) + ->whereIn('status', static::LIVE_SHIFT_STATUSES) ->orderBy('start_at') ->first(); } diff --git a/server/src/Models/Part.php b/server/src/Models/Part.php index ef85365b3..fcf52d29e 100644 --- a/server/src/Models/Part.php +++ b/server/src/Models/Part.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\Money; use Fleetbase\Casts\PolymorphicType; +use Fleetbase\FleetOps\Support\Radar\RadarRules; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\FleetOps\Traits\Maintainable; use Fleetbase\Models\Alert; @@ -88,6 +89,8 @@ class Part extends Model 'barcode', 'description', 'quantity_on_hand', + 'reorder_point', + 'reorder_quantity', 'unit_cost', 'msrp', 'currency', @@ -252,8 +255,12 @@ public function getIsInStockAttribute(): bool */ public function getIsLowStockAttribute(): bool { - $specs = $this->specs ?? []; - $lowStockThreshold = $specs['low_stock_threshold'] ?? 5; + // One definition of "low", shared with the Radar low-stock rule: the + // reorder point when set, else the spec's threshold, else 5. + $lowStockThreshold = RadarRules::lowStockThreshold([ + 'reorder_point' => $this->reorder_point, + 'specs' => $this->specs, + ]); return $this->quantity_on_hand <= $lowStockThreshold; } diff --git a/server/src/Support/Radar/RadarItemState.php b/server/src/Support/Radar/RadarItemState.php new file mode 100644 index 000000000..e42decc2e --- /dev/null +++ b/server/src/Support/Radar/RadarItemState.php @@ -0,0 +1,291 @@ + + */ + public static function statesFor(?string $companyUuid): array + { + $states = []; + + foreach (self::rows($companyUuid)->get() as $alert) { + $key = self::keyOf($alert); + if ($key) { + $states[$key] = self::toState($alert); + } + } + + return $states; + } + + /** + * The row for an item, creating an open one when none exists. + */ + public static function rowFor(?string $companyUuid, array $item): Alert + { + $existing = self::findRow($companyUuid, $item['key']); + if ($existing) { + return $existing; + } + + $subject = $item['subject'] ?? []; + + return Alert::create([ + 'company_uuid' => $companyUuid, + 'type' => $item['rule'], + 'severity' => $item['severity'] ?? 'info', + 'status' => 'open', + 'subject_type' => $subject['class'] ?? null, + 'subject_uuid' => $subject['uuid'] ?? null, + 'message' => $item['title'] ?? '', + 'triggered_at' => now(), + 'context' => [ + 'key' => $item['key'], + 'rule' => $item['rule'], + 'category' => $item['category'] ?? null, + 'chip' => $item['chip'] ?? null, + 'due_at' => $item['due_at'] ?? null, + 'record' => $item['record'] ?? null, + 'subject' => $subject ? [ + 'type' => $subject['type'] ?? null, + 'public_id' => $subject['public_id'] ?? null, + 'label' => $subject['label'] ?? null, + 'photo_url' => $subject['photo_url'] ?? null, + ] : null, + ], + ]); + } + + /** + * The live (unresolved) row for an item key, or null. + */ + public static function findRow(?string $companyUuid, string $key): ?Alert + { + $parsed = RadarRules::parseKey($key); + if (!$parsed) { + return null; + } + + [$rule, $publicId] = $parsed; + + if ($rule === 'notice') { + return self::rows($companyUuid, [RadarRules::NOTICE_TYPE]) + ->where(function ($query) use ($publicId) { + $query->where('public_id', $publicId)->orWhere('uuid', $publicId); + }) + ->first(); + } + + foreach (self::rows($companyUuid, [$rule])->get() as $alert) { + if (self::keyOf($alert) === $key) { + return $alert; + } + } + + return null; + } + + /** + * Resolve the rows whose item no longer exists — the gap closed on the + * record itself. Returns how many rows were closed. + */ + public static function reconcile(?string $companyUuid, array $liveKeys): int + { + $live = array_fill_keys($liveKeys, true); + $closed = 0; + + foreach (self::rows($companyUuid)->get() as $alert) { + $key = self::keyOf($alert); + if (!$key || isset($live[$key])) { + continue; + } + + $alert->update([ + 'status' => 'resolved', + 'resolved_at' => now(), + 'meta' => array_merge($alert->meta ?? [], ['resolution' => 'auto']), + ]); + $closed++; + } + + return $closed; + } + + /** + * Items resolved since a moment, newest first, shaped like live items so + * the Resolved tab can render them with the same row. + */ + public static function resolvedSince(?string $companyUuid, Carbon $since, Carbon $now): array + { + $items = []; + + $rows = Alert::query() + ->where('company_uuid', $companyUuid) + ->whereIn('type', array_merge(RadarRules::stateTypes(), [RadarRules::NOTICE_TYPE])) + ->where('status', 'resolved') + ->where('resolved_at', '>=', $since) + ->with(['resolvedBy', 'acknowledgedBy', 'assignedTo']) + ->orderByDesc('resolved_at') + ->get(); + + foreach ($rows as $alert) { + $context = $alert->context ?? []; + $rule = $alert->type === RadarRules::NOTICE_TYPE ? 'notice' : $alert->type; + $meta = RadarRules::RULES[$rule] ?? ['category' => 'notices', 'lane' => 'anytime', 'chip' => 'Notice']; + $due = RadarRules::carbon($context['due_at'] ?? null); + $state = self::toState($alert); + $state['status'] = 'resolved'; + + $items[] = [ + 'key' => self::keyOf($alert) ?? RadarRules::keyFor($rule, $alert->public_id ?? $alert->uuid), + 'rule' => $rule, + 'category' => $context['category'] ?? $meta['category'], + 'lane' => $meta['lane'], + 'chip' => $context['chip'] ?? $meta['chip'], + 'severity' => $alert->severity ?? 'info', + 'title' => $alert->message ?? '', + 'subject' => $context['subject'] ?? null, + 'meta_line' => ($alert->meta['resolution'] ?? null) === 'auto' ? 'closed on the record' : ('resolved by ' . ($alert->resolved_by_name ?? 'you')), + 'due_at' => $due?->toIso8601String(), + 'due_bucket' => 'none', + 'due_label' => null, + 'window' => null, + 'planned_at' => null, + 'record' => $context['record'] ?? null, + 'source' => null, + 'details' => null, + 'actions' => $rule === 'notice' ? [] : ['open_record'], + 'state' => $state, + 'pills' => [], + ]; + } + + return $items; + } + + /** + * The snoozed items waking within a week, soonest first. + */ + public static function snoozeSchedule(?string $companyUuid, Carbon $now): array + { + return self::rows($companyUuid) + ->whereNotNull('snoozed_until') + ->where('snoozed_until', '>', $now) + ->where('snoozed_until', '<=', $now->copy()->addDays(7)) + ->orderBy('snoozed_until') + ->get() + ->map(fn (Alert $alert) => [ + 'key' => self::keyOf($alert), + 'title' => $alert->message, + 'snoozed_until' => $alert->snoozed_until?->toIso8601String(), + ]) + ->values() + ->all(); + } + + /** + * Notices as source rows for RadarRules::noticeItems(). + */ + public static function notices(?string $companyUuid): array + { + return self::rows($companyUuid, [RadarRules::NOTICE_TYPE]) + ->orderByDesc('created_at') + ->get() + ->map(function (Alert $alert) { + $context = $alert->context ?? []; + + return [ + 'uuid' => $alert->uuid, + 'public_id' => $alert->public_id, + 'message' => $alert->message, + 'severity' => $alert->severity, + 'status' => $alert->status, + 'meta' => $alert->meta ?? [], + 'subject' => $context['subject'] ?? null, + 'state' => self::toState($alert), + ]; + }) + ->all(); + } + + /** + * A row as the console reads it. + */ + public static function toState(Alert $alert): array + { + $assignee = $alert->assignedTo; + + return [ + 'status' => $alert->status ?? 'open', + 'alert_id' => $alert->public_id ?? $alert->uuid, + 'acknowledged_at' => $alert->acknowledged_at?->toIso8601String(), + 'acknowledged_by_name' => $alert->acknowledged_by_name, + 'snoozed_until' => $alert->snoozed_until?->toIso8601String(), + 'snoozed_by_name' => $alert->snoozedBy?->name, + 'assigned_to' => $assignee ? ['uuid' => $assignee->uuid, 'public_id' => $assignee->public_id ?? null, 'name' => $assignee->name, 'initials' => self::initials($assignee->name)] : null, + 'planned_at' => $alert->planned_at?->toIso8601String(), + 'resolved_at' => $alert->resolved_at?->toIso8601String(), + 'resolved_by_name' => $alert->resolved_by_name, + 'resolution' => $alert->meta['resolution'] ?? null, + ]; + } + + /** + * The item key a row was created for. + */ + public static function keyOf(Alert $alert): ?string + { + $key = $alert->context['key'] ?? null; + if (is_string($key) && $key !== '') { + return $key; + } + + if ($alert->type === RadarRules::NOTICE_TYPE && ($alert->public_id || $alert->uuid)) { + return RadarRules::keyFor('notice', $alert->public_id ?? $alert->uuid); + } + + return null; + } + + public static function initials(?string $name): string + { + $parts = array_values(array_filter(explode(' ', trim((string) $name)))); + if (!$parts) { + return ''; + } + + $first = mb_substr($parts[0], 0, 1); + $last = count($parts) > 1 ? mb_substr($parts[count($parts) - 1], 0, 1) : ''; + + return mb_strtoupper($first . $last); + } + + /** + * Live rows for the company: every Radar type, not resolved. + */ + protected static function rows(?string $companyUuid, ?array $types = null) + { + return Alert::query() + ->where('company_uuid', $companyUuid) + ->whereIn('type', $types ?? array_merge(RadarRules::stateTypes(), [RadarRules::NOTICE_TYPE])) + ->where('status', '!=', 'resolved') + ->with(['acknowledgedBy', 'assignedTo', 'snoozedBy']); + } +} diff --git a/server/src/Support/Radar/RadarRules.php b/server/src/Support/Radar/RadarRules.php new file mode 100644 index 000000000..cdc58da94 --- /dev/null +++ b/server/src/Support/Radar/RadarRules.php @@ -0,0 +1,1396 @@ + ['category' => self::CATEGORY_MAINTENANCE, 'lane' => self::LANE_MAINTENANCE, 'chip' => 'Maint'], + 'maintenance_due_soon' => ['category' => self::CATEGORY_MAINTENANCE, 'lane' => self::LANE_MAINTENANCE, 'chip' => 'Maint'], + 'inspection_due' => ['category' => self::CATEGORY_INSPECTIONS, 'lane' => self::LANE_MAINTENANCE, 'chip' => 'Inspection'], + 'inspection_failed' => ['category' => self::CATEGORY_INSPECTIONS, 'lane' => self::LANE_ANYTIME, 'chip' => 'Inspection'], + 'inspection_unresolved' => ['category' => self::CATEGORY_INSPECTIONS, 'lane' => self::LANE_ANYTIME, 'chip' => 'Inspection'], + 'inspection_draft' => ['category' => self::CATEGORY_INSPECTIONS, 'lane' => self::LANE_ANYTIME, 'chip' => 'Inspection'], + 'inspection_link_pending' => ['category' => self::CATEGORY_INSPECTIONS, 'lane' => self::LANE_EXPIRIES, 'chip' => 'Inspection'], + 'vehicle_inspection_failed' => ['category' => self::CATEGORY_INSPECTIONS, 'lane' => self::LANE_ANYTIME, 'chip' => 'Inspection'], + 'work_order_overdue' => ['category' => self::CATEGORY_MAINTENANCE, 'lane' => self::LANE_MAINTENANCE, 'chip' => 'Work order'], + 'work_order_blocked' => ['category' => self::CATEGORY_MAINTENANCE, 'lane' => self::LANE_MAINTENANCE, 'chip' => 'Work order'], + 'issue_open' => ['category' => self::CATEGORY_ISSUES, 'lane' => self::LANE_ANYTIME, 'chip' => 'Issue'], + 'shift_late_start' => ['category' => self::CATEGORY_STAFFING, 'lane' => self::LANE_SHIFTS, 'chip' => 'Shift'], + 'shift_no_vehicle' => ['category' => self::CATEGORY_STAFFING, 'lane' => self::LANE_SHIFTS, 'chip' => 'Shift'], + 'shift_handover' => ['category' => self::CATEGORY_STAFFING, 'lane' => self::LANE_SHIFTS, 'chip' => 'Handover'], + 'driver_without_vehicle' => ['category' => self::CATEGORY_STAFFING, 'lane' => self::LANE_ANYTIME, 'chip' => 'Unassigned'], + 'vehicle_without_driver' => ['category' => self::CATEGORY_STAFFING, 'lane' => self::LANE_ANYTIME, 'chip' => 'Idle'], + 'vehicle_without_device' => ['category' => self::CATEGORY_CONNECTIVITY, 'lane' => self::LANE_ANYTIME, 'chip' => 'Device'], + 'device_unattached' => ['category' => self::CATEGORY_CONNECTIVITY, 'lane' => self::LANE_ANYTIME, 'chip' => 'Device'], + 'license_expiring' => ['category' => self::CATEGORY_COMPLIANCE, 'lane' => self::LANE_EXPIRIES, 'chip' => 'Licence'], + 'lease_expiring' => ['category' => self::CATEGORY_COMPLIANCE, 'lane' => self::LANE_EXPIRIES, 'chip' => 'Lease'], + 'fuel_unmatched' => ['category' => self::CATEGORY_FUEL, 'lane' => self::LANE_ANYTIME, 'chip' => 'Fuel'], + 'part_low_stock' => ['category' => self::CATEGORY_PARTS, 'lane' => self::LANE_ANYTIME, 'chip' => 'Parts'], + 'notice' => ['category' => self::CATEGORY_NOTICES, 'lane' => self::LANE_NOTICES, 'chip' => 'Notice'], + ]; + + /** + * The count pills, each the set of rules (or the due bucket) it counts. + */ + public const PILLS = [ + 'overdue' => ['buckets' => ['overdue']], + 'due_week' => ['buckets' => ['today', 'week']], + 'unassigned' => ['rules' => ['driver_without_vehicle', 'shift_no_vehicle', 'vehicle_without_driver']], + 'issues' => ['rules' => ['issue_open']], + 'inspections' => ['rules' => ['inspection_due', 'inspection_failed', 'inspection_unresolved', 'inspection_draft', 'inspection_link_pending', 'vehicle_inspection_failed']], + 'shifts' => ['rules' => ['shift_late_start', 'shift_no_vehicle', 'shift_handover']], + 'expiring' => ['rules' => ['license_expiring', 'lease_expiring', 'inspection_link_pending']], + 'low_stock' => ['rules' => ['part_low_stock']], + 'fuel' => ['rules' => ['fuel_unmatched']], + 'notices' => ['rules' => ['notice']], + ]; + + /** Work order statuses that mean the work is done with. */ + public const WORK_ORDER_CLOSED = ['closed', 'completed', 'done', 'canceled', 'cancelled']; + + /** Work order statuses that mean the work is waiting on something. */ + public const WORK_ORDER_BLOCKED = ['blocked', 'awaiting_parts', 'awaiting_vendor', 'on_hold']; + + /** Issue priorities that make an open issue critical. */ + public const ISSUE_CRITICAL_PRIORITIES = ['high', 'critical', 'urgent']; + + /** Shift statuses that count as a shift the driver is expected to work. */ + public const SHIFT_LIVE_STATUSES = ['pending', 'scheduled', 'confirmed', 'in_progress']; + + /** Shift statuses in which a driver should have shown up by the start time. */ + public const SHIFT_NOT_STARTED_STATUSES = ['pending', 'scheduled', 'confirmed']; + + /** + * Build every item from the given source rows. + * + * @param array $sources keyed by source name; each a list of plain arrays (see the load* methods on RadarController) + * + * @return array{items: array, counts: array, summary: array} + */ + public static function build(array $sources, Carbon $now): array + { + $items = []; + + foreach (self::scheduleItems($sources['schedules'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::workOrderItems($sources['workOrders'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::issueItems($sources['issues'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::inspectionItems($sources['inspectionSubmissions'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::inspectionLinkItems($sources['inspectionLinks'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::shiftItems($sources['shifts'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::driverItems($sources['drivers'] ?? [], $sources['shifts'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::vehicleItems($sources['vehicles'] ?? [], $sources['devices'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::trailerItems($sources['trailers'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::deviceItems($sources['devices'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::partItems($sources['parts'] ?? [], $sources['workOrders'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::fuelItems($sources['fuelTransactions'] ?? [], $now) as $item) { + $items[] = $item; + } + foreach (self::noticeItems($sources['notices'] ?? [], $now) as $item) { + $items[] = $item; + } + + $items = self::mergeStates($items, $sources['states'] ?? [], $now); + $items = self::sort($items); + + $open = array_values(array_filter($items, fn ($item) => self::isOpen($item, $now))); + + return [ + 'items' => $items, + 'counts' => self::counts($open), + 'summary' => self::summary($items, $now), + ]; + } + + // ------------------------------------------------------------------ + // Rules + // ------------------------------------------------------------------ + + /** + * Maintenance schedules: overdue, due within a week, and inspection-type + * schedules as their own rule so the chip says what the job is. + */ + public static function scheduleItems(array $schedules, Carbon $now): array + { + $items = []; + + foreach ($schedules as $schedule) { + $due = self::carbon($schedule['next_due_date'] ?? null); + if (!$due || ($schedule['status'] ?? 'active') !== 'active') { + continue; + } + + $isInspection = ($schedule['type'] ?? null) === 'inspection'; + $isOverdue = $due->lt($now); + $isDueSoon = !$isOverdue && $due->lte($now->copy()->addDays(self::DUE_SOON_DAYS)); + + if (!$isOverdue && !$isDueSoon) { + continue; + } + + $rule = $isInspection ? 'inspection_due' : ($isOverdue ? 'maintenance_overdue' : 'maintenance_due_soon'); + $name = $schedule['name'] ?: ($isInspection ? 'Inspection' : 'Service'); + + if ($isOverdue) { + $title = sprintf('%s %s', $name, self::overdueWords($due, $now)); + } else { + $title = sprintf('%s due %s', $name, self::dueInWords($due, $now)); + } + + $meta = []; + if (!empty($schedule['next_due_odometer'])) { + $meta[] = 'due at ' . number_format((int) $schedule['next_due_odometer']) . ' odometer'; + } + if (!empty($schedule['has_open_work_order'])) { + $meta[] = 'work order open'; + } + + $items[] = self::item($rule, $schedule['subject'] ?? null, $title, [ + 'severity' => $isOverdue ? self::SEVERITY_CRITICAL : self::SEVERITY_WARNING, + 'due_at' => $due, + 'meta_line' => implode(' · ', $meta) ?: null, + 'record' => self::record('maintenance.schedules.index.details', $schedule['public_id'] ?? null), + 'source' => ['type' => 'schedule', 'uuid' => $schedule['uuid'] ?? null, 'public_id' => $schedule['public_id'] ?? null, 'has_open_work_order' => (bool) ($schedule['has_open_work_order'] ?? false)], + 'actions' => array_keys(array_filter(['create_work_order' => empty($schedule['has_open_work_order']), 'acknowledge' => true, 'snooze' => true, 'assign' => true, 'open_record' => true])), + ], $now, $schedule['public_id'] ?? ($schedule['uuid'] ?? '')); + } + + return $items; + } + + /** + * Work orders past due, or waiting on parts, a vendor, or a hold. + */ + public static function workOrderItems(array $workOrders, Carbon $now): array + { + $items = []; + + foreach ($workOrders as $workOrder) { + $status = $workOrder['status'] ?? 'open'; + if (in_array($status, self::WORK_ORDER_CLOSED, true)) { + continue; + } + + $due = self::carbon($workOrder['due_at'] ?? null); + $isOverdue = $due && $due->lt($now); + $isBlocked = in_array($status, self::WORK_ORDER_BLOCKED, true); + + if (!$isOverdue && !$isBlocked) { + continue; + } + + $code = $workOrder['code'] ?? $workOrder['public_id'] ?? 'Work order'; + $title = trim($code . ' ' . ($workOrder['subject'] ?? '')); + $meta = []; + + if ($isBlocked) { + $meta[] = Str::of($status)->replace('_', ' ')->toString(); + } + if (!empty($workOrder['priority']) && $workOrder['priority'] !== 'normal') { + $meta[] = $workOrder['priority'] . ' priority'; + } + + $items[] = self::item($isOverdue ? 'work_order_overdue' : 'work_order_blocked', $workOrder['target'] ?? null, $title, [ + 'severity' => $isOverdue ? self::SEVERITY_CRITICAL : self::SEVERITY_WARNING, + 'due_at' => $due, + 'meta_line' => implode(' · ', $meta) ?: null, + 'record' => self::record('maintenance.work-orders.index.details', $workOrder['public_id'] ?? null), + 'source' => ['type' => 'work_order', 'uuid' => $workOrder['uuid'] ?? null, 'public_id' => $workOrder['public_id'] ?? null, 'status' => $status], + 'actions' => ['acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $workOrder['public_id'] ?? ($workOrder['uuid'] ?? '')); + } + + return $items; + } + + /** + * Open issues, critical when their priority says so. An issue an + * inspection raised says so in its meta line. + */ + public static function issueItems(array $issues, Carbon $now): array + { + $items = []; + + foreach ($issues as $issue) { + if (in_array($issue['status'] ?? 'pending', ['resolved', 'closed'], true)) { + continue; + } + + $priority = strtolower((string) ($issue['priority'] ?? '')); + $subject = $issue['vehicle'] ?? $issue['driver'] ?? null; + $title = $issue['title'] ?: Str::limit((string) ($issue['report'] ?? 'Open issue'), 80); + $meta = []; + + if ($priority) { + $meta[] = $priority . ' priority'; + } + if (!empty($issue['meta']['inspection_submission_id'])) { + $meta[] = 'raised by inspection ' . $issue['meta']['inspection_submission_id']; + } elseif (!empty($issue['meta']['inspection_submission_uuid'])) { + $meta[] = 'raised by an inspection'; + } + if (!empty($issue['reporter_name'])) { + $meta[] = 'reported by ' . $issue['reporter_name']; + } + + $items[] = self::item('issue_open', $subject, $title, [ + 'severity' => in_array($priority, self::ISSUE_CRITICAL_PRIORITIES, true) ? self::SEVERITY_CRITICAL : self::SEVERITY_WARNING, + 'meta_line' => implode(' · ', $meta) ?: null, + 'record' => self::record('management.issues.index.details', $issue['public_id'] ?? null), + 'source' => ['type' => 'issue', 'uuid' => $issue['uuid'] ?? null, 'public_id' => $issue['public_id'] ?? null, 'status' => $issue['status'] ?? null], + 'actions' => ['resolve_issue', 'acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $issue['public_id'] ?? ($issue['uuid'] ?? '')); + } + + return $items; + } + + /** + * Inspection submissions: failed ones with follow-up still to raise, + * failed ones waiting on their follow-up, and drafts nobody finished. + */ + public static function inspectionItems(array $submissions, Carbon $now): array + { + $items = []; + + foreach ($submissions as $submission) { + $status = $submission['status'] ?? 'draft'; + $result = $submission['result'] ?? null; + $failedItems = (int) ($submission['failed_items'] ?? 0); + $hasFailures = $failedItems > 0 || $result === 'failed'; + $subject = $submission['vehicle'] ?? $submission['driver'] ?? null; + $formName = $submission['form_name'] ?? 'Inspection'; + $publicId = $submission['public_id'] ?? ($submission['uuid'] ?? ''); + $record = self::record('maintenance.inspection-submissions.index.details', $submission['public_id'] ?? null); + $source = ['type' => 'inspection_submission', 'uuid' => $submission['uuid'] ?? null, 'public_id' => $submission['public_id'] ?? null, 'status' => $status, 'result' => $result, 'issue_uuid' => $submission['issue_uuid'] ?? null, 'work_order_uuid' => $submission['work_order_uuid'] ?? null]; + + if ($status === 'draft') { + $started = self::carbon($submission['started_at'] ?? $submission['created_at'] ?? null); + if (!$started || $started->gt($now->copy()->subHours(self::STALE_DRAFT_HOURS))) { + continue; + } + + $items[] = self::item('inspection_draft', $subject, sprintf('%s started %s, never filed', $formName, self::agoWords($started, $now)), [ + 'severity' => self::SEVERITY_INFO, + 'meta_line' => !empty($submission['driver_name']) ? 'by ' . $submission['driver_name'] : null, + 'record' => $record, + 'source' => $source, + 'actions' => ['open_record', 'acknowledge', 'snooze', 'assign'], + ], $now, $publicId); + continue; + } + + if ($status === 'resolved' || !$hasFailures) { + continue; + } + + $needsIssue = empty($submission['issue_uuid']); + $needsWorkOrder = empty($submission['work_order_uuid']); + $severity = strtolower((string) ($submission['highest_severity'] ?? 'high')); + $criticalWords = $severity === 'critical' ? 'critical ' : ''; + + if ($needsIssue || $needsWorkOrder) { + $missing = []; + if ($needsIssue) { + $missing[] = 'no issue'; + } + if ($needsWorkOrder) { + $missing[] = 'no work order'; + } + + $actions = []; + if ($needsWorkOrder) { + $actions[] = 'create_work_order_from_inspection'; + } + if ($needsIssue) { + $actions[] = 'create_issue_from_inspection'; + } + + $items[] = self::item('inspection_failed', $subject, sprintf('%s failed %d %sitem%s', $formName, $failedItems, $criticalWords, $failedItems === 1 ? '' : 's'), [ + 'severity' => $severity === 'critical' ? self::SEVERITY_CRITICAL : self::SEVERITY_WARNING, + 'meta_line' => implode(' · ', array_merge([$failedItems . ' failed'], $missing)), + 'record' => $record, + 'source' => $source, + 'actions' => array_merge($actions, ['acknowledge', 'snooze', 'assign', 'open_record']), + 'details' => ['failed_items' => $submission['failed_item_labels'] ?? []], + ], $now, $publicId); + continue; + } + + if (empty($submission['resolved_at'])) { + $items[] = self::item('inspection_unresolved', $subject, sprintf('%s follow-up still open', $formName), [ + 'severity' => self::SEVERITY_INFO, + 'meta_line' => $failedItems . ' failed · issue and work order raised', + 'record' => $record, + 'source' => $source, + 'actions' => ['resolve_inspection', 'acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $publicId); + } + } + + return $items; + } + + /** + * Inspection links that were sent but never used, once they are close to + * expiring or already have. + */ + public static function inspectionLinkItems(array $links, Carbon $now): array + { + $items = []; + + foreach ($links as $link) { + if (($link['status'] ?? 'active') !== 'active' || !empty($link['used_at'])) { + continue; + } + + $expires = self::carbon($link['expires_at'] ?? null); + if (!$expires || $expires->gt($now->copy()->addHours(self::LINK_EXPIRY_HOURS))) { + continue; + } + + $expired = $expires->lte($now); + $subject = $link['driver'] ?? $link['vehicle'] ?? null; + $formName = $link['form_name'] ?? 'Inspection'; + $title = $expired + ? sprintf('%s link expired unused', $formName) + : sprintf('%s link expires %s, not yet used', $formName, self::dueInWords($expires, $now)); + + $items[] = self::item('inspection_link_pending', $subject, $title, [ + 'severity' => $expired ? self::SEVERITY_WARNING : self::SEVERITY_INFO, + 'due_at' => $expires, + 'meta_line' => !empty($link['last_viewed_at']) ? 'opened, not filed' : 'never opened', + 'record' => self::record('maintenance.inspection-forms.index.details', $link['form_public_id'] ?? null), + 'source' => ['type' => 'inspection_link', 'uuid' => $link['uuid'] ?? null, 'public_id' => $link['public_id'] ?? null, 'form_uuid' => $link['form_uuid'] ?? null, 'form_public_id' => $link['form_public_id'] ?? null, 'state' => $expired ? 'expired' : 'active'], + 'actions' => ['send_pin', 'revoke_link', 'acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $link['public_id'] ?? ($link['uuid'] ?? '')); + } + + return $items; + } + + /** + * Shifts happening now: a driver who should have started but is not + * online, a driver on shift with no vehicle, and a shift ending soon with + * orders still on the driver. + */ + public static function shiftItems(array $shifts, Carbon $now): array + { + $items = []; + + foreach ($shifts as $shift) { + $start = self::carbon($shift['start_at'] ?? null); + $end = self::carbon($shift['end_at'] ?? null); + $status = $shift['status'] ?? 'scheduled'; + $driver = $shift['driver'] ?? null; + + if (!$start || !$end || !$driver || !in_array($status, self::SHIFT_LIVE_STATUSES, true)) { + continue; + } + + if ($end->lte($now) || $start->gt($now)) { + continue; + } + + $window = ['start_at' => $start->toIso8601String(), 'end_at' => $end->toIso8601String(), 'status' => $status]; + $name = $driver['label'] ?? 'Driver'; + $publicId = $driver['public_id'] ?? ($driver['uuid'] ?? ''); + $source = ['type' => 'shift', 'uuid' => $shift['uuid'] ?? null, 'public_id' => $shift['public_id'] ?? null, 'driver_uuid' => $driver['uuid'] ?? null]; + $record = self::record('management.drivers.index.details', $driver['public_id'] ?? null); + + $minutesLate = $start->diffInMinutes($now, false); + if (empty($shift['driver_online']) && in_array($status, self::SHIFT_NOT_STARTED_STATUSES, true) && $minutesLate >= self::LATE_START_MINUTES) { + $items[] = self::item('shift_late_start', $driver, sprintf('%s scheduled %s, not online', $name, $start->format('H:i')), [ + 'severity' => $minutesLate >= self::LATE_START_CRITICAL ? self::SEVERITY_CRITICAL : self::SEVERITY_WARNING, + 'meta_line' => self::minutesWords($minutesLate) . ' late', + 'window' => $window, + 'record' => $record, + 'source' => $source, + 'actions' => ['call', 'cover_shift', 'acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $publicId); + } + + if (empty($shift['driver_vehicle_uuid'])) { + $items[] = self::item('shift_no_vehicle', $driver, sprintf('%s on shift since %s, no vehicle assigned', $name, $start->format('H:i')), [ + 'severity' => self::SEVERITY_WARNING, + 'meta_line' => self::minutesWords($start->diffInMinutes($now)) . ' on shift', + 'window' => $window, + 'record' => $record, + 'source' => $source, + 'actions' => ['assign_vehicle', 'acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $publicId); + } + + $activeOrders = (int) ($shift['active_orders'] ?? 0); + $minutesToEnd = $now->diffInMinutes($end, false); + if ($activeOrders > 0 && $minutesToEnd <= self::HANDOVER_MINUTES) { + $items[] = self::item('shift_handover', $driver, sprintf('%s shift ends in %s, %d active order%s still assigned', $name, self::minutesWords($minutesToEnd), $activeOrders, $activeOrders === 1 ? '' : 's'), [ + 'severity' => self::SEVERITY_WARNING, + 'due_at' => $end, + 'window' => $window, + 'record' => $record, + 'source' => $source + ['active_orders' => $activeOrders], + 'actions' => ['handover', 'extend_shift', 'acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $publicId); + } + } + + return $items; + } + + /** + * Drivers with no vehicle (when not on shift — the shift rule covers + * that) and driver licences expiring. + */ + public static function driverItems(array $drivers, array $shifts, Carbon $now): array + { + $items = []; + $onShift = []; + + foreach ($shifts as $shift) { + $start = self::carbon($shift['start_at'] ?? null); + $end = self::carbon($shift['end_at'] ?? null); + if ($start && $end && $start->lte($now) && $end->gt($now) && in_array($shift['status'] ?? 'scheduled', self::SHIFT_LIVE_STATUSES, true)) { + $onShift[$shift['driver']['uuid'] ?? ''] = true; + } + } + + foreach ($drivers as $driver) { + $subject = self::driverSubject($driver); + $publicId = $driver['public_id'] ?? ($driver['uuid'] ?? ''); + $record = self::record('management.drivers.index.details', $driver['public_id'] ?? null); + + if (empty($driver['vehicle_uuid']) && empty($onShift[$driver['uuid'] ?? ''])) { + $items[] = self::item('driver_without_vehicle', $subject, sprintf('%s has no vehicle assigned', $subject['label']), [ + 'severity' => !empty($driver['online']) ? self::SEVERITY_WARNING : self::SEVERITY_INFO, + 'meta_line' => !empty($driver['online']) ? 'online now' : null, + 'record' => $record, + 'source' => ['type' => 'driver', 'uuid' => $driver['uuid'] ?? null, 'public_id' => $driver['public_id'] ?? null], + 'actions' => ['assign_vehicle', 'acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $publicId); + } + + $expiry = self::carbon($driver['license_expiry'] ?? null); + if ($expiry && $expiry->lte($now->copy()->addDays(self::EXPIRING_DAYS))) { + $expired = $expiry->lt($now->copy()->startOfDay()); + $items[] = self::item('license_expiring', $subject, $expired + ? sprintf('%s licence expired %s', $subject['label'], $expiry->format('j M')) + : sprintf('%s licence expires %s', $subject['label'], self::dueInWords($expiry, $now)), [ + 'severity' => $expired ? self::SEVERITY_CRITICAL : self::SEVERITY_WARNING, + 'due_at' => $expiry, + 'meta_line' => !empty($driver['drivers_license_number']) ? 'licence ' . $driver['drivers_license_number'] : null, + 'record' => $record, + 'source' => ['type' => 'driver', 'uuid' => $driver['uuid'] ?? null, 'public_id' => $driver['public_id'] ?? null], + 'actions' => ['acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $publicId); + } + } + + return $items; + } + + /** + * Vehicles with no driver, no device (while spare devices exist), a + * failed-inspection status, or a lease running out. + */ + public static function vehicleItems(array $vehicles, array $devices, Carbon $now): array + { + $items = []; + $spareDevices = count(array_filter($devices, fn ($device) => empty($device['attachable_uuid']))); + + foreach ($vehicles as $vehicle) { + $subject = self::vehicleSubject($vehicle); + $publicId = $vehicle['public_id'] ?? ($vehicle['uuid'] ?? ''); + $record = self::record('management.vehicles.index.details', $vehicle['public_id'] ?? null); + $source = ['type' => 'vehicle', 'uuid' => $vehicle['uuid'] ?? null, 'public_id' => $vehicle['public_id'] ?? null, 'status' => $vehicle['status'] ?? null]; + + if (($vehicle['status'] ?? null) === 'inspection_failed') { + $items[] = self::item('vehicle_inspection_failed', $subject, sprintf('%s failed inspection, cannot dispatch', $subject['label']), [ + 'severity' => self::SEVERITY_CRITICAL, + 'meta_line' => 'status inspection_failed', + 'record' => self::record('management.vehicles.index.details.inspections', $vehicle['public_id'] ?? null), + 'source' => $source, + 'actions' => ['acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $publicId); + } + + if (empty($vehicle['driver_uuid']) && in_array($vehicle['status'] ?? 'available', ['available', 'active', 'idle', 'operational'], true)) { + $meta = []; + if (!empty($vehicle['lease_expires_at'])) { + $lease = self::carbon($vehicle['lease_expires_at']); + if ($lease) { + $meta[] = 'lease ends ' . $lease->format('j M'); + } + } + + $items[] = self::item('vehicle_without_driver', $subject, sprintf('%s has no driver', $subject['label']), [ + 'severity' => self::SEVERITY_INFO, + 'meta_line' => implode(' · ', $meta) ?: null, + 'record' => $record, + 'source' => $source, + 'actions' => ['assign_driver', 'acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $publicId); + } + + if ((int) ($vehicle['device_count'] ?? 0) === 0 && $spareDevices > 0) { + $items[] = self::item('vehicle_without_device', $subject, sprintf('%s has no tracking device', $subject['label']), [ + 'severity' => self::SEVERITY_INFO, + 'meta_line' => sprintf('%d spare device%s', $spareDevices, $spareDevices === 1 ? '' : 's'), + 'record' => self::record('management.vehicles.index.details.devices', $vehicle['public_id'] ?? null), + 'source' => $source, + 'actions' => ['acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $publicId); + } + + $lease = self::carbon($vehicle['lease_expires_at'] ?? null); + if ($lease && $lease->lte($now->copy()->addDays(self::EXPIRING_DAYS))) { + $items[] = self::leaseItem($subject, $lease, $record, $source, $now, $publicId); + } + } + + return $items; + } + + /** + * Trailers whose lease is running out. + */ + public static function trailerItems(array $trailers, Carbon $now): array + { + $items = []; + + foreach ($trailers as $trailer) { + $lease = self::carbon($trailer['lease_expires_at'] ?? null); + if (!$lease || $lease->gt($now->copy()->addDays(self::EXPIRING_DAYS))) { + continue; + } + + $subject = ['type' => 'trailer', 'class' => $trailer['class'] ?? null, 'uuid' => $trailer['uuid'] ?? null, 'public_id' => $trailer['public_id'] ?? null, 'label' => $trailer['label'] ?? ($trailer['public_id'] ?? 'Trailer'), 'photo_url' => $trailer['photo_url'] ?? null]; + $publicId = $trailer['public_id'] ?? ($trailer['uuid'] ?? ''); + $items[] = self::leaseItem($subject, $lease, self::record('management.trailers.index.details', $trailer['public_id'] ?? null), ['type' => 'trailer', 'uuid' => $trailer['uuid'] ?? null, 'public_id' => $trailer['public_id'] ?? null], $now, $publicId); + } + + return $items; + } + + /** + * Devices not attached to anything. + */ + public static function deviceItems(array $devices, Carbon $now): array + { + $items = []; + + foreach ($devices as $device) { + if (!empty($device['attachable_uuid'])) { + continue; + } + + $subject = ['type' => 'device', 'class' => $device['class'] ?? null, 'uuid' => $device['uuid'] ?? null, 'public_id' => $device['public_id'] ?? null, 'label' => $device['name'] ?? ($device['device_id'] ?? ($device['public_id'] ?? 'Device')), 'photo_url' => null]; + $items[] = self::item('device_unattached', $subject, sprintf('%s not attached to a vehicle', $subject['label']), [ + 'severity' => self::SEVERITY_INFO, + 'meta_line' => !empty($device['online']) ? 'online' : 'offline', + 'record' => self::record('connectivity.devices.index.details', $device['public_id'] ?? null), + 'source' => ['type' => 'device', 'uuid' => $device['uuid'] ?? null, 'public_id' => $device['public_id'] ?? null], + 'actions' => ['attach_device', 'acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $device['public_id'] ?? ($device['uuid'] ?? '')); + } + + return $items; + } + + /** + * Parts at or below their reorder point. Critical when there are none + * left and an open work order's checklist names the part. + */ + public static function partItems(array $parts, array $workOrders, Carbon $now): array + { + $items = []; + + foreach ($parts as $part) { + $onHand = (int) ($part['quantity_on_hand'] ?? 0); + $threshold = self::lowStockThreshold($part); + if ($onHand > $threshold) { + continue; + } + + $name = $part['name'] ?? ($part['sku'] ?? 'Part'); + $blocks = self::workOrderNaming($workOrders, [$part['name'] ?? null, $part['sku'] ?? null]); + $meta = [sprintf('reorder point %d', $threshold)]; + if ($blocks) { + $meta[] = 'blocks ' . $blocks; + } + + $subject = ['type' => 'part', 'class' => $part['class'] ?? null, 'uuid' => $part['uuid'] ?? null, 'public_id' => $part['public_id'] ?? null, 'label' => $name, 'photo_url' => $part['photo_url'] ?? null]; + $items[] = self::item('part_low_stock', $subject, $onHand > 0 + ? sprintf('%s — %d left, reorder point %d', $name, $onHand, $threshold) + : sprintf('%s — out of stock', $name), [ + 'severity' => ($onHand <= 0 && $blocks) ? self::SEVERITY_CRITICAL : self::SEVERITY_WARNING, + 'meta_line' => implode(' · ', $meta), + 'record' => self::record('maintenance.parts.index.details', $part['public_id'] ?? null), + 'source' => ['type' => 'part', 'uuid' => $part['uuid'] ?? null, 'public_id' => $part['public_id'] ?? null, 'quantity_on_hand' => $onHand, 'threshold' => $threshold], + 'actions' => ['acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $part['public_id'] ?? ($part['uuid'] ?? '')); + } + + return $items; + } + + /** + * Fuel transactions the provider sync could not match to a vehicle. + */ + public static function fuelItems(array $transactions, Carbon $now): array + { + $items = []; + + foreach ($transactions as $transaction) { + if (($transaction['sync_status'] ?? 'unmatched') !== 'unmatched') { + continue; + } + + $amount = self::money($transaction['amount'] ?? null, $transaction['currency'] ?? null); + $station = $transaction['station_name'] ?? ($transaction['provider'] ?? 'fuel provider'); + $meta = []; + if (!empty($transaction['vehicle_card_id'])) { + $meta[] = 'card ' . $transaction['vehicle_card_id']; + } + if (!empty($transaction['plate_number'])) { + $meta[] = 'plate ' . $transaction['plate_number']; + } + if (!empty($transaction['suggested_vehicle']['label'])) { + $meta[] = 'suggest: ' . $transaction['suggested_vehicle']['label']; + } + $when = self::carbon($transaction['transaction_at'] ?? null); + if ($when) { + $meta[] = self::agoWords($when, $now); + } + + $subject = ['type' => 'fuel_transaction', 'class' => $transaction['class'] ?? null, 'uuid' => $transaction['uuid'] ?? null, 'public_id' => $transaction['public_id'] ?? null, 'label' => $station, 'photo_url' => null]; + $items[] = self::item('fuel_unmatched', $subject, sprintf('%s at %s — no vehicle matched', $amount, $station), [ + 'severity' => self::SEVERITY_WARNING, + 'meta_line' => implode(' · ', $meta) ?: null, + 'record' => self::record('management.fuel-transactions.index.details', $transaction['public_id'] ?? null), + 'source' => ['type' => 'fuel_transaction', 'uuid' => $transaction['uuid'] ?? null, 'public_id' => $transaction['public_id'] ?? null, 'suggested_vehicle' => $transaction['suggested_vehicle'] ?? null], + 'actions' => ['match_vehicle', 'ignore_transaction', 'acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $transaction['public_id'] ?? ($transaction['uuid'] ?? '')); + } + + return $items; + } + + /** + * Notices are the one kind of item a person writes rather than a rule + * finds. They arrive as alert rows. + */ + public static function noticeItems(array $notices, Carbon $now): array + { + $items = []; + + foreach ($notices as $notice) { + if (($notice['status'] ?? 'open') === 'resolved') { + continue; + } + + $due = self::carbon($notice['meta']['due_at'] ?? null); + $subject = $notice['subject'] ?? null; + $items[] = self::item('notice', $subject, (string) ($notice['message'] ?? 'Notice'), [ + 'severity' => in_array($notice['severity'] ?? 'info', [self::SEVERITY_CRITICAL, self::SEVERITY_WARNING, self::SEVERITY_INFO], true) ? $notice['severity'] : self::SEVERITY_INFO, + 'due_at' => $due, + 'meta_line' => $notice['meta']['scope'] ?? null, + 'record' => null, + 'source' => ['type' => 'notice', 'uuid' => $notice['uuid'] ?? null, 'public_id' => $notice['public_id'] ?? null], + 'actions' => ['resolve', 'acknowledge', 'snooze', 'assign'], + 'state' => $notice['state'] ?? null, + ], $now, $notice['public_id'] ?? ($notice['uuid'] ?? '')); + } + + return $items; + } + + // ------------------------------------------------------------------ + // State, filtering, sorting, counting + // ------------------------------------------------------------------ + + /** + * Attach each item's alert state, keyed by item key. Items with no row + * get the open default. + */ + public static function mergeStates(array $items, array $states, Carbon $now): array + { + foreach ($items as &$item) { + $state = $item['state'] ?? ($states[$item['key']] ?? null); + $item['state'] = self::normalizeState($state, $now); + } + unset($item); + + return $items; + } + + /** + * A state row as the console reads it: the stored status, refined by + * whether the snooze has ended. + */ + public static function normalizeState(?array $state, Carbon $now): array + { + $state = $state ?? []; + $until = self::carbon($state['snoozed_until'] ?? null); + $status = $state['status'] ?? 'open'; + + if ($until && $until->gt($now)) { + $status = 'snoozed'; + } elseif ($status === 'snoozed') { + $status = !empty($state['acknowledged_at']) ? 'acknowledged' : 'open'; + } + + return [ + 'status' => $status, + 'alert_id' => $state['alert_id'] ?? null, + 'acknowledged_at' => $state['acknowledged_at'] ?? null, + 'acknowledged_by_name' => $state['acknowledged_by_name'] ?? null, + 'snoozed_until' => $until && $until->gt($now) ? $until->toIso8601String() : null, + 'snoozed_by_name' => $until && $until->gt($now) ? ($state['snoozed_by_name'] ?? null) : null, + 'assigned_to' => $state['assigned_to'] ?? null, + 'planned_at' => $state['planned_at'] ?? null, + 'resolved_at' => $state['resolved_at'] ?? null, + 'resolved_by_name' => $state['resolved_by_name'] ?? null, + 'resolution' => $state['resolution'] ?? null, + ]; + } + + public static function isOpen(array $item, Carbon $now): bool + { + return in_array($item['state']['status'] ?? 'open', ['open', 'acknowledged'], true); + } + + public static function isSnoozed(array $item, Carbon $now): bool + { + return ($item['state']['status'] ?? 'open') === 'snoozed'; + } + + /** + * Keep the items for a status tab. + */ + public static function forTab(array $items, string $tab, Carbon $now): array + { + return array_values(array_filter($items, function ($item) use ($tab, $now) { + return match ($tab) { + 'snoozed' => self::isSnoozed($item, $now), + 'resolved' => ($item['state']['status'] ?? null) === 'resolved', + default => self::isOpen($item, $now), + }; + })); + } + + /** + * Keep the items every one of the given pills matches (pills AND). + */ + public static function forPills(array $items, array $pills): array + { + $pills = array_values(array_filter(array_map('trim', $pills), fn ($pill) => isset(self::PILLS[$pill]))); + if (!$pills) { + return array_values($items); + } + + return array_values(array_filter($items, function ($item) use ($pills) { + foreach ($pills as $pill) { + if (!in_array($pill, $item['pills'] ?? [], true)) { + return false; + } + } + + return true; + })); + } + + /** + * Keep the items whose title, subject, chip or meta line mentions the query. + */ + public static function search(array $items, ?string $query): array + { + $query = trim((string) $query); + if ($query === '') { + return array_values($items); + } + + $needle = Str::lower($query); + + return array_values(array_filter($items, function ($item) use ($needle) { + $haystack = Str::lower(implode(' ', array_filter([ + $item['title'] ?? '', + $item['subject']['label'] ?? '', + $item['subject']['public_id'] ?? '', + $item['chip'] ?? '', + $item['meta_line'] ?? '', + $item['rule'] ?? '', + ]))); + + return str_contains($haystack, $needle); + })); + } + + /** + * Keep the items whose subject belongs to a fleet. + */ + public static function forFleet(array $items, ?string $fleet): array + { + $fleet = trim((string) $fleet); + if ($fleet === '' || $fleet === 'all') { + return array_values($items); + } + + return array_values(array_filter($items, fn ($item) => in_array($fleet, $item['subject']['fleets'] ?? [], true))); + } + + /** + * Count the pills over a set of items. + */ + public static function counts(array $items): array + { + $counts = array_fill_keys(array_keys(self::PILLS), 0); + + foreach ($items as $item) { + foreach ($item['pills'] ?? [] as $pill) { + $counts[$pill]++; + } + } + + return $counts; + } + + /** + * The numbers the header and widget show. + */ + public static function summary(array $items, Carbon $now): array + { + $open = array_filter($items, fn ($item) => self::isOpen($item, $now)); + $snoozed = array_filter($items, fn ($item) => self::isSnoozed($item, $now)); + + return [ + 'open' => count($open), + 'acknowledged' => count(array_filter($open, fn ($item) => $item['state']['status'] === 'acknowledged')), + 'snoozed' => count($snoozed), + 'overdue' => count(array_filter($open, fn ($item) => $item['due_bucket'] === 'overdue')), + 'critical' => count(array_filter($open, fn ($item) => $item['severity'] === self::SEVERITY_CRITICAL)), + 'undated' => count(array_filter($open, fn ($item) => $item['due_bucket'] === 'none')), + ]; + } + + /** + * Overdue first (oldest first), then today, this week and later by due + * date, then undated by severity. + */ + public static function sort(array $items): array + { + $bucketOrder = ['overdue' => 0, 'today' => 1, 'week' => 2, 'later' => 3, 'none' => 4]; + $severityOrder = [self::SEVERITY_CRITICAL => 0, self::SEVERITY_WARNING => 1, self::SEVERITY_INFO => 2]; + + usort($items, function ($a, $b) use ($bucketOrder, $severityOrder) { + $bucket = ($bucketOrder[$a['due_bucket'] ?? 'none'] ?? 9) <=> ($bucketOrder[$b['due_bucket'] ?? 'none'] ?? 9); + if ($bucket !== 0) { + return $bucket; + } + + if (($a['due_bucket'] ?? 'none') !== 'none') { + $due = strcmp((string) $a['due_at'], (string) $b['due_at']); + if ($due !== 0) { + return $due; + } + } + + $severity = ($severityOrder[$a['severity'] ?? ''] ?? 9) <=> ($severityOrder[$b['severity'] ?? ''] ?? 9); + if ($severity !== 0) { + return $severity; + } + + return strcmp((string) ($a['title'] ?? ''), (string) ($b['title'] ?? '')); + }); + + return array_values($items); + } + + /** + * Group sorted items by due bucket, in display order, dropping empty groups. + */ + public static function group(array $items): array + { + $groups = []; + foreach (['overdue', 'today', 'week', 'later', 'none'] as $bucket) { + $members = array_values(array_filter($items, fn ($item) => ($item['due_bucket'] ?? 'none') === $bucket)); + if ($members) { + $groups[] = ['key' => $bucket, 'count' => count($members), 'items' => $members]; + } + } + + return $groups; + } + + /** + * A page of items. + */ + public static function paginate(array $items, int $page, int $limit): array + { + $page = max(1, $page); + $limit = max(1, min(200, $limit)); + $total = count($items); + + return [ + 'items' => array_slice(array_values($items), ($page - 1) * $limit, $limit), + 'meta' => ['total' => $total, 'page' => $page, 'limit' => $limit, 'pages' => (int) ceil($total / $limit)], + ]; + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** + * The stable key for a rule applied to a record. + */ + public static function keyFor(string $rule, string $publicId): string + { + return $rule . ':' . $publicId; + } + + /** + * Split a key back into its rule and public id. + * + * @return array{0: string, 1: string}|null + */ + public static function parseKey(?string $key): ?array + { + $key = (string) $key; + $at = strpos($key, ':'); + if ($at === false) { + return null; + } + + $rule = substr($key, 0, $at); + $publicId = substr($key, $at + 1); + + if (!isset(self::RULES[$rule]) || $publicId === '') { + return null; + } + + return [$rule, $publicId]; + } + + /** + * The rules a state row can carry: every rule but the manual notice, + * which is a row already. + */ + public static function stateTypes(): array + { + return array_values(array_filter(array_keys(self::RULES), fn ($rule) => $rule !== 'notice')); + } + + /** + * The threshold a part is low at: the reorder point when set, else the + * spec's low-stock threshold, else the default. + */ + public static function lowStockThreshold(array $part): int + { + $reorderPoint = (int) ($part['reorder_point'] ?? 0); + if ($reorderPoint > 0) { + return $reorderPoint; + } + + $specs = $part['specs'] ?? []; + if (is_string($specs)) { + $specs = json_decode($specs, true) ?: []; + } + $specThreshold = (int) ($specs['low_stock_threshold'] ?? 0); + + return $specThreshold > 0 ? $specThreshold : self::DEFAULT_LOW_STOCK; + } + + /** + * Which due bucket a date falls in. + */ + public static function bucket(?Carbon $due, Carbon $now): string + { + if (!$due) { + return 'none'; + } + if ($due->lt($now)) { + return 'overdue'; + } + if ($due->isSameDay($now)) { + return 'today'; + } + if ($due->lte($now->copy()->addDays(self::DUE_SOON_DAYS))) { + return 'week'; + } + + return 'later'; + } + + /** + * The short due text a row shows. + */ + public static function dueLabel(?Carbon $due, Carbon $now): ?string + { + if (!$due) { + return null; + } + + return match (self::bucket($due, $now)) { + 'overdue' => self::overdueWords($due, $now), + 'today' => 'Today ' . $due->format('H:i'), + 'week' => $due->format('D j M'), + default => $due->format('j M'), + }; + } + + public static function overdueWords(Carbon $due, Carbon $now): string + { + $minutes = $due->diffInMinutes($now); + if ($minutes < 60) { + return max(1, $minutes) . 'm overdue'; + } + $hours = $due->diffInHours($now); + if ($hours < 24) { + return $hours . 'h overdue'; + } + + return $due->diffInDays($now) . 'd overdue'; + } + + public static function dueInWords(Carbon $due, Carbon $now): string + { + if ($due->isSameDay($now)) { + return 'today'; + } + if ($due->isSameDay($now->copy()->addDay())) { + return 'tomorrow'; + } + + $days = $now->copy()->startOfDay()->diffInDays($due->copy()->startOfDay()); + if ($days <= self::DUE_SOON_DAYS) { + return sprintf('in %d day%s', $days, $days === 1 ? '' : 's'); + } + + return $due->format('j M'); + } + + /** + * "35m ago", "5h ago", "2d ago". + */ + public static function agoWords(Carbon $then, Carbon $now): string + { + $minutes = max(0, $then->diffInMinutes($now, false)); + if ($minutes < 60) { + return max(1, $minutes) . 'm ago'; + } + if ($minutes < 1440) { + return intdiv($minutes, 60) . 'h ago'; + } + + return intdiv($minutes, 1440) . 'd ago'; + } + + public static function minutesWords(int $minutes): string + { + $minutes = max(0, $minutes); + if ($minutes < 60) { + return $minutes . 'm'; + } + + $hours = intdiv($minutes, 60); + $rest = $minutes % 60; + + return $rest ? sprintf('%dh %dm', $hours, $rest) : $hours . 'h'; + } + + /** + * Accepts a Carbon, a DateTime, or a string; anything else is null. + */ + public static function carbon(mixed $value): ?Carbon + { + if ($value instanceof Carbon) { + return $value->copy(); + } + if ($value instanceof \DateTimeInterface) { + return Carbon::instance($value); + } + if (is_string($value) && trim($value) !== '') { + try { + return Carbon::parse($value); + } catch (\Throwable) { + return null; + } + } + + return null; + } + + /** + * The route the "open record" link goes to. + */ + public static function record(?string $route, ?string $model): ?array + { + if (!$route || !$model) { + return null; + } + + return ['route' => $route, 'model' => $model]; + } + + protected static function item(string $rule, ?array $subject, string $title, array $extra, Carbon $now, string $publicId): array + { + $meta = self::RULES[$rule]; + $due = self::carbon($extra['due_at'] ?? null); + $bucket = self::bucket($due, $now); + $subject = $subject ? self::subject($subject) : null; + $item = [ + 'key' => self::keyFor($rule, $publicId), + 'rule' => $rule, + 'category' => $meta['category'], + 'lane' => $due || !empty($extra['window']) ? $meta['lane'] : self::LANE_ANYTIME, + 'chip' => $meta['chip'], + 'severity' => $extra['severity'] ?? self::SEVERITY_INFO, + 'title' => $title, + 'subject' => $subject, + 'meta_line' => $extra['meta_line'] ?? null, + 'due_at' => $due?->toIso8601String(), + 'due_bucket' => $bucket, + 'due_label' => self::dueLabel($due, $now), + 'window' => $extra['window'] ?? null, + 'planned_at' => null, + 'record' => $extra['record'] ?? null, + 'source' => $extra['source'] ?? null, + 'details' => $extra['details'] ?? null, + 'actions' => array_values($extra['actions'] ?? ['acknowledge', 'snooze', 'assign']), + 'state' => $extra['state'] ?? null, + ]; + + $item['pills'] = self::pillsFor($item); + + return $item; + } + + protected static function pillsFor(array $item): array + { + $pills = []; + foreach (self::PILLS as $pill => $definition) { + if (in_array($item['rule'], $definition['rules'] ?? [], true) || in_array($item['due_bucket'], $definition['buckets'] ?? [], true)) { + $pills[] = $pill; + } + } + + return $pills; + } + + protected static function subject(array $subject): array + { + return [ + 'type' => $subject['type'] ?? null, + 'class' => $subject['class'] ?? null, + 'uuid' => $subject['uuid'] ?? null, + 'public_id' => $subject['public_id'] ?? null, + 'label' => $subject['label'] ?? ($subject['public_id'] ?? ''), + 'photo_url' => $subject['photo_url'] ?? null, + 'fleets' => array_values($subject['fleets'] ?? []), + ]; + } + + protected static function driverSubject(array $driver): array + { + return [ + 'type' => 'driver', + 'class' => $driver['class'] ?? null, + 'uuid' => $driver['uuid'] ?? null, + 'public_id' => $driver['public_id'] ?? null, + 'label' => $driver['name'] ?? ($driver['label'] ?? ($driver['public_id'] ?? 'Driver')), + 'photo_url' => $driver['photo_url'] ?? null, + 'fleets' => $driver['fleets'] ?? [], + ]; + } + + protected static function vehicleSubject(array $vehicle): array + { + return [ + 'type' => 'vehicle', + 'class' => $vehicle['class'] ?? null, + 'uuid' => $vehicle['uuid'] ?? null, + 'public_id' => $vehicle['public_id'] ?? null, + 'label' => $vehicle['label'] ?? ($vehicle['display_name'] ?? ($vehicle['public_id'] ?? 'Vehicle')), + 'photo_url' => $vehicle['photo_url'] ?? null, + 'fleets' => $vehicle['fleets'] ?? [], + ]; + } + + protected static function leaseItem(array $subject, Carbon $lease, ?array $record, array $source, Carbon $now, string $publicId): array + { + $expired = $lease->lt($now->copy()->startOfDay()); + + return self::item('lease_expiring', $subject, $expired + ? sprintf('%s lease ended %s', $subject['label'], $lease->format('j M')) + : sprintf('%s lease ends %s', $subject['label'], self::dueInWords($lease, $now)), [ + 'severity' => $expired ? self::SEVERITY_CRITICAL : self::SEVERITY_WARNING, + 'due_at' => $lease, + 'record' => $record, + 'source' => $source, + 'actions' => ['acknowledge', 'snooze', 'assign', 'open_record'], + ], $now, $publicId); + } + + /** + * The code of the first open work order whose checklist names any of the + * given words, or null. + */ + protected static function workOrderNaming(array $workOrders, array $words): ?string + { + $words = array_values(array_filter(array_map(fn ($word) => Str::lower(trim((string) $word)), $words))); + if (!$words) { + return null; + } + + foreach ($workOrders as $workOrder) { + if (in_array($workOrder['status'] ?? 'open', self::WORK_ORDER_CLOSED, true)) { + continue; + } + + $checklist = $workOrder['checklist'] ?? []; + if (is_string($checklist)) { + $checklist = json_decode($checklist, true) ?: []; + } + + $text = Str::lower(implode(' ', array_map(fn ($entry) => is_array($entry) ? ($entry['title'] ?? '') : (string) $entry, $checklist)) . ' ' . ($workOrder['subject'] ?? '')); + + foreach ($words as $word) { + if ($word !== '' && str_contains($text, $word)) { + return $workOrder['code'] ?? ($workOrder['public_id'] ?? null); + } + } + } + + return null; + } + + /** + * The company vehicle a fuel transaction's identity columns point at, or + * null. Reports which field matched so the console can say why. + * + * @param array $vehicles rows with uuid, public_id, label, plate_number, vin, fuel_card_number + */ + public static function suggestVehicleForFuel(array $transaction, array $vehicles): ?array + { + $candidates = [ + 'fuel_card_number' => $transaction['vehicle_card_id'] ?? null, + 'plate_number' => $transaction['plate_number'] ?? null, + 'vin' => $transaction['vin'] ?? null, + ]; + + foreach ($candidates as $field => $value) { + $value = Str::lower(trim((string) $value)); + if ($value === '') { + continue; + } + + foreach ($vehicles as $vehicle) { + if (Str::lower(trim((string) ($vehicle[$field] ?? ''))) === $value) { + return [ + 'uuid' => $vehicle['uuid'] ?? null, + 'public_id' => $vehicle['public_id'] ?? null, + 'label' => $vehicle['label'] ?? ($vehicle['public_id'] ?? ''), + 'matched' => $field, + ]; + } + } + } + + return null; + } + + protected static function money(mixed $amount, ?string $currency): string + { + if ($amount === null || $amount === '') { + return 'Fuel purchase'; + } + + $value = (float) $amount; + // Provider amounts are stored in minor units when they are integers. + if (is_int($amount) || (is_string($amount) && ctype_digit($amount))) { + $value = $value / 100; + } + + return trim(($currency ? strtoupper($currency) . ' ' : '') . number_format($value, 2)); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index ddf0efae3..96d5e86ff 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -837,6 +837,26 @@ function ($router) { $router->get('maintenance', 'HubController@maintenance'); } ); + + // radar — the per-record gaps a fleet manager triages each + // morning, computed live, with acknowledge / snooze / assign + // state kept on the core alerts table. + $router->group( + ['prefix' => 'radar'], + function ($router) { + $router->get('items', 'RadarController@items'); + $router->get('summary', 'RadarController@summary'); + $router->post('items/bulk', 'RadarController@bulk'); + $router->post('items/{key}/acknowledge', 'RadarController@acknowledge'); + $router->post('items/{key}/snooze', 'RadarController@snooze'); + $router->post('items/{key}/wake', 'RadarController@wake'); + $router->post('items/{key}/assign', 'RadarController@assign'); + $router->post('items/{key}/plan', 'RadarController@plan'); + $router->post('items/{key}/resolve', 'RadarController@resolve'); + $router->post('notices', 'RadarController@storeNotice'); + $router->delete('notices/{id}', 'RadarController@destroyNotice'); + } + ); $router->group( ['prefix' => 'getting-started'], function ($router) { diff --git a/server/tests/Feature/Http/Internal/RadarControllerTest.php b/server/tests/Feature/Http/Internal/RadarControllerTest.php new file mode 100644 index 000000000..f5ab58a85 --- /dev/null +++ b/server/tests/Feature/Http/Internal/RadarControllerTest.php @@ -0,0 +1,499 @@ +setRawAttributes($attributes, true); + $this->exists = false; + foreach (['acknowledgedBy', 'assignedTo', 'snoozedBy', 'resolvedBy'] as $relation) { + $this->setRelation($relation, null); + } + } + + // Date casts ask the connection for its format; there is no connection. + public function getDateFormat(): string + { + return 'Y-m-d H:i:s'; + } + + public function save(array $options = []): bool + { + $this->calls[] = ['save']; + $this->syncOriginal(); + + return true; + } + + public function update(array $attributes = [], array $options = []): bool + { + $this->calls[] = ['update', $attributes]; + $this->forceFill($attributes); + + return true; + } + + public function acknowledge(?User $user = null): bool + { + $this->calls[] = ['acknowledge', $user?->uuid]; + $this->forceFill(['acknowledged_at' => now(), 'acknowledged_by_uuid' => $user?->uuid, 'status' => 'acknowledged']); + $this->setRelation('acknowledgedBy', $user); + + return true; + } + + public function snooze(int $minutes, ?string $reason = null, ?User $user = null): bool + { + $this->calls[] = ['snooze', $minutes, $reason, $user?->uuid]; + $this->forceFill(['snoozed_until' => now()->addMinutes($minutes), 'snoozed_by_uuid' => $user?->uuid]); + $this->setRelation('snoozedBy', $user); + + return true; + } + + public function unsnooze(): bool + { + $this->calls[] = ['unsnooze']; + if (!$this->snoozed_until) { + return false; + } + $this->forceFill(['snoozed_until' => null, 'snoozed_by_uuid' => null]); + $this->setRelation('snoozedBy', null); + + return true; + } + + public function assignTo(?User $user): bool + { + $this->calls[] = ['assignTo', $user?->uuid]; + $this->forceFill(['assigned_to_uuid' => $user?->uuid]); + $this->setRelation('assignedTo', $user); + + return true; + } + + public function resolve(?User $user = null, ?string $resolution = null): bool + { + $this->calls[] = ['resolve', $user?->uuid, $resolution]; + $this->forceFill(['status' => 'resolved', 'resolved_at' => now(), 'resolved_by_uuid' => $user?->uuid, 'meta' => array_merge($this->meta ?? [], ['resolution' => $resolution])]); + $this->setRelation('resolvedBy', $user); + + return true; + } + + public function delete(): ?bool + { + $this->calls[] = ['delete']; + + return true; + } +} + +/** + * The controller with its sources and its state store replaced: the rows + * come from fixtures, the rows' state lives in an array of spies. + */ +class FleetOpsRadarControllerProbe extends RadarController +{ + /** @var array */ + public array $store = []; + public array $reconciled = []; + public array $resolved = []; + public array $schedule = []; + public ?Alert $lastNotice = null; + + public function __construct(public array $fixtures = [], public array $seededStates = [], public array $failing = []) + { + } + + protected function sourceLoaders(): array + { + $loaders = []; + foreach (['schedules', 'workOrders', 'issues', 'drivers', 'shifts', 'vehicles', 'trailers', 'devices', 'parts', 'fuelTransactions', 'inspectionSubmissions', 'inspectionLinks', 'notices'] as $name) { + $loaders[$name] = function () use ($name) { + if (in_array($name, $this->failing, true)) { + throw new RuntimeException($name . ' table is unavailable'); + } + + return $this->fixtures[$name] ?? []; + }; + } + + return $loaders; + } + + protected function statesFor(?string $company): array + { + $states = []; + foreach ($this->store as $key => $spy) { + $states[$key] = RadarItemState::toState($spy); + } + + return $states + $this->seededStates; + } + + protected function rowFor(?string $company, array $item): ?Alert + { + return $this->store[$item['key']] ??= new FleetOpsRadarAlertSpy([ + 'uuid' => 'alert-' . (count($this->store) + 1), + 'public_id' => 'alert_' . (count($this->store) + 1), + 'type' => $item['rule'], + 'status' => 'open', + 'context' => ['key' => $item['key']], + ]); + } + + protected function findRow(?string $company, string $key): ?Alert + { + return $this->store[$key] ?? null; + } + + protected function reconcile(?string $company, array $liveKeys): int + { + $this->reconciled = $liveKeys; + + return 0; + } + + protected function resolvedSince(?string $company, Carbon $since, Carbon $now): array + { + return $this->resolved; + } + + protected function snoozeSchedule(?string $company, Carbon $now): array + { + return $this->schedule; + } + + protected function notices(?string $company): array + { + return $this->fixtures['notices'] ?? []; + } + + protected function createNotice(array $attributes): Alert + { + $this->lastNotice = new FleetOpsRadarAlertSpy(['uuid' => 'alert-notice', 'public_id' => 'alert_notice'] + $attributes); + $this->fixtures['notices'][] = [ + 'uuid' => 'alert-notice', + 'public_id' => 'alert_notice', + 'message' => $attributes['message'], + 'severity' => $attributes['severity'], + 'status' => 'open', + 'meta' => $attributes['meta'], + ]; + + return $this->lastNotice; + } + + protected function findNotice(?string $company, string $id): ?Alert + { + return $id === 'alert_notice' ? ($this->lastNotice ?? new FleetOpsRadarAlertSpy(['uuid' => 'alert-notice', 'public_id' => 'alert_notice', 'type' => 'radar_notice'])) : null; + } + + protected function now(): Carbon + { + return Carbon::parse('2026-09-15 08:35:00', 'UTC'); + } + + protected function companyUuid(Request $request): ?string + { + return 'company-radar'; + } + + protected function actor(Request $request): ?User + { + return fleetOpsRadarUser('user-1', 'Ada Ops'); + } + + protected function findCompanyUser(?string $company, string $id): ?User + { + return $id === 'user_bo' ? fleetOpsRadarUser('user-2', 'Bo Tran') : null; + } +} + +function fleetOpsRadarUser(string $uuid, string $name): User +{ + $user = new User(); + $user->setRawAttributes(['uuid' => $uuid, 'public_id' => str_replace('-', '_', $uuid), 'name' => $name], true); + + return $user; +} + +function fleetOpsRadarFixtures(): array +{ + $vehicle = fn (string $id) => ['type' => 'vehicle', 'class' => 'Fleetbase\FleetOps\Models\Vehicle', 'uuid' => 'vehicle-' . $id, 'public_id' => 'vehicle_' . $id, 'label' => strtoupper($id), 'photo_url' => null]; + + return [ + 'schedules' => [ + ['uuid' => 's1', 'public_id' => 'schedule_oil', 'name' => 'Oil change', 'type' => 'oil_change', 'status' => 'active', 'next_due_date' => '2026-09-12 08:00:00', 'subject' => $vehicle('trk118')], + ['uuid' => 's2', 'public_id' => 'schedule_tires', 'name' => 'Tire rotation', 'type' => 'tire_rotation', 'status' => 'active', 'next_due_date' => '2026-09-17 12:00:00', 'subject' => $vehicle('trk204')], + ], + 'issues' => [ + ['uuid' => 'i1', 'public_id' => 'issue_1', 'title' => 'Check engine light', 'priority' => 'high', 'status' => 'pending', 'vehicle' => $vehicle('trk311'), 'driver' => null, 'meta' => []], + ], + 'parts' => [ + ['uuid' => 'p1', 'public_id' => 'part_pads', 'name' => 'Brake pads', 'quantity_on_hand' => 2, 'reorder_point' => 6], + ], + 'notices' => [ + ['uuid' => 'n1', 'public_id' => 'alert_yard', 'message' => 'Yard closed Saturday', 'severity' => 'warning', 'status' => 'open', 'meta' => ['due_at' => '2026-09-18 18:00:00'], 'state' => ['status' => 'open']], + ], + ]; +} + +function fleetOpsRadarRequest(string $uri, string $method = 'GET', array $parameters = []): Request +{ + return Request::create('/int/v1/fleet-ops/radar/' . ltrim($uri, '/'), $method, $parameters); +} + +afterEach(fn () => Carbon::setTestNow()); + +test('items lists the open tab grouped by due, with pill counts and the summary', function () { + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures()); + $payload = $controller->items(fleetOpsRadarRequest('items'))->getData(true); + + expect(array_column($payload['items'], 'key'))->toBe([ + 'maintenance_overdue:schedule_oil', + 'maintenance_due_soon:schedule_tires', + 'notice:alert_yard', + 'issue_open:issue_1', + 'part_low_stock:part_pads', + ]) + ->and(array_column($payload['groups'], 'key'))->toBe(['overdue', 'week', 'none']) + ->and($payload['meta'])->toBe(['total' => 5, 'page' => 1, 'limit' => 50, 'pages' => 1]) + ->and($payload['counts']['overdue'])->toBe(1) + ->and($payload['counts']['due_week'])->toBe(2) + ->and($payload['counts']['notices'])->toBe(1) + ->and($payload['summary']['open'])->toBe(5) + ->and($payload['summary']['critical'])->toBe(2) + ->and($payload['summary']['resolved'])->toBeNull() + ->and($payload['sources'])->toBe([]) + ->and($payload['generated_at'])->toBe('2026-09-15T08:35:00+00:00') + ->and($controller->reconciled)->toHaveCount(5) + ->and($payload['items'][0]['state'])->toMatchArray(['status' => 'open', 'alert_id' => null]); +}); + +test('items narrows by pills, query, fleet and page, and serves the snoozed and resolved tabs', function () { + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures(), [ + 'maintenance_due_soon:schedule_tires' => ['status' => 'snoozed', 'snoozed_until' => '2026-09-18T08:00:00+00:00'], + ]); + $controller->resolved = [['key' => 'issue_open:issue_9', 'rule' => 'issue_open', 'severity' => 'warning', 'title' => 'Old issue', 'due_bucket' => 'none', 'pills' => [], 'subject' => null, 'state' => ['status' => 'resolved']]]; + $controller->schedule = [['key' => 'maintenance_due_soon:schedule_tires', 'title' => 'Tire rotation', 'snoozed_until' => '2026-09-18T08:00:00+00:00']]; + + $open = $controller->items(fleetOpsRadarRequest('items'))->getData(true); + expect(array_column($open['items'], 'key'))->not->toContain('maintenance_due_soon:schedule_tires') + ->and($open['summary']['snoozed'])->toBe(1) + ->and($open['snooze_schedule'][0]['key'])->toBe('maintenance_due_soon:schedule_tires'); + + $pills = $controller->items(fleetOpsRadarRequest('items', 'GET', ['filters' => 'issues,due_week']))->getData(true); + expect($pills['items'])->toBe([]) + ->and($pills['counts']['issues'])->toBe(1); + + $overdue = $controller->items(fleetOpsRadarRequest('items', 'GET', ['filters' => 'overdue']))->getData(true); + expect(array_column($overdue['items'], 'key'))->toBe(['maintenance_overdue:schedule_oil']); + + $query = $controller->items(fleetOpsRadarRequest('items', 'GET', ['query' => 'brake']))->getData(true); + expect(array_column($query['items'], 'key'))->toBe(['part_low_stock:part_pads']); + + $paged = $controller->items(fleetOpsRadarRequest('items', 'GET', ['page' => 2, 'limit' => 3]))->getData(true); + expect($paged['items'])->toHaveCount(1) + ->and($paged['meta'])->toBe(['total' => 4, 'page' => 2, 'limit' => 3, 'pages' => 2]); + + $snoozed = $controller->items(fleetOpsRadarRequest('items', 'GET', ['status' => 'snoozed']))->getData(true); + expect(array_column($snoozed['items'], 'key'))->toBe(['maintenance_due_soon:schedule_tires']) + ->and($snoozed['items'][0]['state']['snoozed_until'])->toBe('2026-09-18T08:00:00+00:00'); + + $resolved = $controller->items(fleetOpsRadarRequest('items', 'GET', ['status' => 'resolved']))->getData(true); + expect(array_column($resolved['items'], 'key'))->toBe(['issue_open:issue_9']) + ->and($resolved['summary']['resolved'])->toBe(1); + + $fleet = $controller->items(fleetOpsRadarRequest('items', 'GET', ['fleet' => 'fleet-north']))->getData(true); + expect($fleet['items'])->toBe([]); +}); + +test('a failing source is reported without blanking the list, and reconciliation is skipped', function () { + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures(), [], ['issues']); + $payload = $controller->items(fleetOpsRadarRequest('items'))->getData(true); + + expect(array_column($payload['items'], 'key'))->not->toContain('issue_open:issue_1') + ->and($payload['items'])->toHaveCount(4) + ->and($payload['sources'])->toBe(['issues' => 'issues table is unavailable']) + ->and($controller->reconciled)->toBe([]); +}); + +test('summary answers with counts only', function () { + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures()); + $payload = $controller->summary(fleetOpsRadarRequest('summary'))->getData(true); + + expect($payload)->toHaveKeys(['summary', 'counts', 'generated_at']) + ->and($payload['summary']['open'])->toBe(5) + ->and($controller->reconciled)->toBe([]); +}); + +test('acknowledge creates the row from the live item and answers with the new state', function () { + Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC')); + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures()); + + $response = $controller->acknowledge(fleetOpsRadarRequest('items/issue_open:issue_1/acknowledge', 'POST'), 'issue_open:issue_1'); + $payload = $response->getData(true); + + expect($response->getStatusCode())->toBe(200) + ->and($payload['item']['key'])->toBe('issue_open:issue_1') + ->and($payload['state']['status'])->toBe('acknowledged') + ->and($payload['state']['acknowledged_by_name'])->toBe('Ada Ops') + ->and($payload['state']['alert_id'])->toBe('alert_1') + ->and($controller->store['issue_open:issue_1']->calls[0])->toBe(['acknowledge', 'user-1']); + + // The row is reused on the next action instead of created again. + $controller->acknowledge(fleetOpsRadarRequest('items/issue_open:issue_1/acknowledge', 'POST'), 'issue_open:issue_1'); + expect($controller->store)->toHaveCount(1); + + $missing = $controller->acknowledge(fleetOpsRadarRequest('items/issue_open:issue_404/acknowledge', 'POST'), 'issue_open:issue_404'); + expect($missing->getStatusCode())->toBe(404); + + $unknownRule = $controller->acknowledge(fleetOpsRadarRequest('items/nope:issue_1/acknowledge', 'POST'), 'nope:issue_1'); + expect($unknownRule->getStatusCode())->toBe(404); +}); + +test('snooze takes minutes or an until date, and wake ends it', function () { + Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC')); + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures()); + $key = 'part_low_stock:part_pads'; + + expect($controller->snooze(fleetOpsRadarRequest("items/{$key}/snooze", 'POST'), $key)->getStatusCode())->toBe(422) + ->and($controller->snooze(fleetOpsRadarRequest("items/{$key}/snooze", 'POST', ['minutes' => 0]), $key)->getStatusCode())->toBe(422) + ->and($controller->snooze(fleetOpsRadarRequest("items/{$key}/snooze", 'POST', ['until' => '2026-09-14 08:00:00']), $key)->getStatusCode())->toBe(422) + ->and($controller->snooze(fleetOpsRadarRequest("items/{$key}/snooze", 'POST', ['until' => 'garbage']), $key)->getStatusCode())->toBe(422); + + $wakeBefore = $controller->wake(fleetOpsRadarRequest("items/{$key}/wake", 'POST'), $key); + expect($wakeBefore->getStatusCode())->toBe(404); + + $snoozed = $controller->snooze(fleetOpsRadarRequest("items/{$key}/snooze", 'POST', ['minutes' => 90, 'reason' => 'Order placed']), $key)->getData(true); + expect($snoozed['state']['status'])->toBe('snoozed') + ->and($snoozed['state']['snoozed_until'])->toBe('2026-09-15T10:05:00+00:00') + ->and($snoozed['state']['snoozed_by_name'])->toBe('Ada Ops') + ->and($controller->store[$key]->calls[0])->toBe(['snooze', 90, 'Order placed', 'user-1']); + + $untilTomorrow = $controller->snooze(fleetOpsRadarRequest("items/{$key}/snooze", 'POST', ['until' => '2026-09-16 08:35:00']), $key)->getData(true); + expect($controller->store[$key]->calls[1][1])->toBe(1440) + ->and($untilTomorrow['state']['snoozed_until'])->toBe('2026-09-16T08:35:00+00:00'); + + $woken = $controller->wake(fleetOpsRadarRequest("items/{$key}/wake", 'POST'), $key)->getData(true); + expect($woken['state']['status'])->toBe('open') + ->and($woken['state']['snoozed_until'])->toBeNull(); +}); + +test('assign takes a company user or clears the owner, and plan takes a date or null', function () { + Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC')); + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures()); + $key = 'maintenance_overdue:schedule_oil'; + + expect($controller->assign(fleetOpsRadarRequest("items/{$key}/assign", 'POST', ['user' => 'user_stranger']), $key)->getStatusCode())->toBe(422); + + $assigned = $controller->assign(fleetOpsRadarRequest("items/{$key}/assign", 'POST', ['user' => 'user_bo']), $key)->getData(true); + expect($assigned['state']['assigned_to'])->toBe(['uuid' => 'user-2', 'public_id' => 'user_2', 'name' => 'Bo Tran', 'initials' => 'BT']); + + $cleared = $controller->assign(fleetOpsRadarRequest("items/{$key}/assign", 'POST'), $key)->getData(true); + expect($cleared['state']['assigned_to'])->toBeNull() + ->and($controller->store[$key]->calls[1])->toBe(['assignTo', null]); + + expect($controller->plan(fleetOpsRadarRequest("items/{$key}/plan", 'POST', ['planned_at' => 'garbage']), $key)->getStatusCode())->toBe(422); + + $planned = $controller->plan(fleetOpsRadarRequest("items/{$key}/plan", 'POST', ['planned_at' => '2026-09-15 14:00:00']), $key)->getData(true); + expect($planned['state']['planned_at'])->toBe('2026-09-15T14:00:00+00:00'); + + $unplanned = $controller->plan(fleetOpsRadarRequest("items/{$key}/plan", 'POST'), $key)->getData(true); + expect($unplanned['state']['planned_at'])->toBeNull(); +}); + +test('only notices resolve by hand', function () { + Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC')); + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures()); + + expect($controller->resolve(fleetOpsRadarRequest('items/issue_open:issue_1/resolve', 'POST'), 'issue_open:issue_1')->getStatusCode())->toBe(422); + + // A notice with no row yet is not resolvable either; it needs the row a + // notice is created with. + expect($controller->resolve(fleetOpsRadarRequest('items/notice:alert_yard/resolve', 'POST'), 'notice:alert_yard')->getStatusCode())->toBe(404); + + $controller->store['notice:alert_yard'] = new FleetOpsRadarAlertSpy(['uuid' => 'n1', 'public_id' => 'alert_yard', 'type' => 'radar_notice', 'status' => 'open', 'context' => ['key' => 'notice:alert_yard']]); + $resolved = $controller->resolve(fleetOpsRadarRequest('items/notice:alert_yard/resolve', 'POST', ['resolution' => 'Trailers moved']), 'notice:alert_yard')->getData(true); + + expect($resolved['state']['status'])->toBe('resolved') + ->and($resolved['state']['resolution'])->toBe('Trailers moved') + ->and($controller->store['notice:alert_yard']->calls[0])->toBe(['resolve', 'user-1', 'Trailers moved']); +}); + +test('bulk applies one state action to many keys and reports the ones it could not find', function () { + Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC')); + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures()); + + expect($controller->bulk(fleetOpsRadarRequest('items/bulk', 'POST', ['keys' => [], 'action' => 'acknowledge']))->getStatusCode())->toBe(422) + ->and($controller->bulk(fleetOpsRadarRequest('items/bulk', 'POST', ['keys' => ['issue_open:issue_1'], 'action' => 'delete']))->getStatusCode())->toBe(422) + ->and($controller->bulk(fleetOpsRadarRequest('items/bulk', 'POST', ['keys' => ['issue_open:issue_1'], 'action' => 'snooze']))->getStatusCode())->toBe(422) + ->and($controller->bulk(fleetOpsRadarRequest('items/bulk', 'POST', ['keys' => ['issue_open:issue_1'], 'action' => 'assign', 'user' => 'user_stranger']))->getStatusCode())->toBe(422); + + $payload = $controller->bulk(fleetOpsRadarRequest('items/bulk', 'POST', [ + 'keys' => ['issue_open:issue_1', 'part_low_stock:part_pads', 'issue_open:issue_404'], + 'action' => 'snooze', + 'minutes'=> 60, + ]))->getData(true); + + expect(array_column($payload['results'], 'ok'))->toBe([true, true, false]) + ->and($payload['results'][0]['state']['status'])->toBe('snoozed') + ->and($payload['results'][2]['error'])->toBe('not found') + ->and($controller->store)->toHaveCount(2); + + $woken = $controller->bulk(fleetOpsRadarRequest('items/bulk', 'POST', ['keys' => ['issue_open:issue_1'], 'action' => 'wake']))->getData(true); + expect($woken['results'][0]['state']['status'])->toBe('open'); + + $assigned = $controller->bulk(fleetOpsRadarRequest('items/bulk', 'POST', ['keys' => ['issue_open:issue_1'], 'action' => 'assign', 'user' => 'user_bo']))->getData(true); + expect($assigned['results'][0]['state']['assigned_to']['name'])->toBe('Bo Tran'); +}); + +test('a notice is written with its key, due date and author, and can be deleted', function () { + Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC')); + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures()); + + expect($controller->storeNotice(fleetOpsRadarRequest('notices', 'POST', ['message' => ' ']))->getStatusCode())->toBe(422) + ->and($controller->storeNotice(fleetOpsRadarRequest('notices', 'POST', ['message' => 'x', 'due_at' => 'garbage']))->getStatusCode())->toBe(422); + + $response = $controller->storeNotice(fleetOpsRadarRequest('notices', 'POST', [ + 'message' => 'Yard closed Saturday for repaving — move trailers by Fri 18:00', + 'severity' => 'warning', + 'due_at' => '2026-09-18 18:00:00', + 'scope' => 'Yard 3 · whole fleet', + ])); + $payload = $response->getData(true); + + expect($response->getStatusCode())->toBe(201) + ->and($payload['item']['key'])->toBe('notice:alert_notice') + ->and($payload['item']['severity'])->toBe('warning') + ->and($payload['item']['due_bucket'])->toBe('week') + ->and($payload['item']['meta_line'])->toBe('Yard 3 · whole fleet') + ->and($controller->lastNotice->context['key'])->toBe('notice:alert_notice') + ->and($controller->lastNotice->meta['created_by_name'])->toBe('Ada Ops'); + + expect($controller->destroyNotice(fleetOpsRadarRequest('notices/alert_other', 'DELETE'), 'alert_other')->getStatusCode())->toBe(404) + ->and($controller->destroyNotice(fleetOpsRadarRequest('notices/alert_notice', 'DELETE'), 'alert_notice')->getData(true))->toBe(['deleted' => true]) + ->and($controller->lastNotice->calls)->toContain(['delete']); +}); diff --git a/server/tests/RadarRoutesTest.php b/server/tests/RadarRoutesTest.php new file mode 100644 index 000000000..8f4b041be --- /dev/null +++ b/server/tests/RadarRoutesTest.php @@ -0,0 +1,31 @@ +toContain("['prefix' => 'radar']") + ->toContain("\$router->get('items', 'RadarController@items');") + ->toContain("\$router->get('summary', 'RadarController@summary');") + ->toContain("\$router->post('items/bulk', 'RadarController@bulk');") + ->toContain("\$router->post('items/{key}/acknowledge', 'RadarController@acknowledge');") + ->toContain("\$router->post('items/{key}/snooze', 'RadarController@snooze');") + ->toContain("\$router->post('items/{key}/wake', 'RadarController@wake');") + ->toContain("\$router->post('items/{key}/assign', 'RadarController@assign');") + ->toContain("\$router->post('items/{key}/plan', 'RadarController@plan');") + ->toContain("\$router->post('items/{key}/resolve', 'RadarController@resolve');") + ->toContain("\$router->post('notices', 'RadarController@storeNotice');") + ->toContain("\$router->delete('notices/{id}', 'RadarController@destroyNotice');"); + + // The bulk route is declared before the keyed routes so `items/bulk` + // is never read as a key called "bulk". + expect(strpos($routes, 'items/bulk'))->toBeLessThan(strpos($routes, 'items/{key}/acknowledge')); +}); + +test('radar controller exposes every routed action', function () { + $controller = new ReflectionClass(Fleetbase\FleetOps\Http\Controllers\Internal\v1\RadarController::class); + + foreach (['items', 'summary', 'bulk', 'acknowledge', 'snooze', 'wake', 'assign', 'plan', 'resolve', 'storeNotice', 'destroyNotice'] as $method) { + expect($controller->hasMethod($method))->toBeTrue("RadarController@{$method} is routed but missing"); + } +}); diff --git a/server/tests/Unit/Support/RadarItemStateTest.php b/server/tests/Unit/Support/RadarItemStateTest.php new file mode 100644 index 000000000..9131cd903 --- /dev/null +++ b/server/tests/Unit/Support/RadarItemStateTest.php @@ -0,0 +1,238 @@ + $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + EloquentModel::setEventDispatcher(new Dispatcher()); + EloquentModel::clearBootedModels(); + + $config = new Repository([ + 'activitylog' => ['enabled' => false, 'default_auth_driver' => null, 'default_log_name' => 'default'], + 'api' => ['cache' => ['enabled' => false]], + ]); + app()->instance('config', $config); + app()->instance(Illuminate\Contracts\Config\Repository::class, $config); + app()->instance(Spatie\Activitylog\CauserResolver::class, new class extends Spatie\Activitylog\CauserResolver { + public function __construct() + { + } + + public function resolve(EloquentModel|int|string|null $subject = null): ?EloquentModel + { + return null; + } + }); + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $connection) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->connection; + } + + public function __call($method, $arguments) + { + return $this->connection->{$method}(...$arguments); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('db.schema', $connection->getSchemaBuilder()); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + app()->instance('request', Request::create('/')); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'alerts' => ['uuid', 'public_id', '_key', 'company_uuid', 'category_uuid', 'acknowledged_by_uuid', 'resolved_by_uuid', 'snoozed_by_uuid', 'assigned_to_uuid', 'type', 'severity', 'status', 'subject_type', 'subject_uuid', 'message', 'rule', 'context', 'meta', 'triggered_at', 'acknowledged_at', 'resolved_at', 'snoozed_until', 'planned_at'], + 'users' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'email', 'phone', 'type', 'status'], + 'companies' => ['uuid', 'public_id', '_key', 'name', 'owner_uuid'], + 'activity_log' => ['uuid', 'company_uuid', 'log_name', 'description', 'subject_type', 'subject_id', 'causer_type', 'causer_id', 'properties', 'event', 'batch_uuid'], + 'settings' => ['key', 'value'], + ]; + + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $connection->table('companies')->insert(['uuid' => 'company-radar', 'public_id' => 'company_radar', 'name' => 'Radar Co']); + $connection->table('users')->insert(['uuid' => 'user-ada', 'public_id' => 'user_ada', 'company_uuid' => 'company-radar', 'name' => 'Ada Ops', 'type' => 'user']); + $connection->table('users')->insert(['uuid' => 'user-bo', 'public_id' => 'user_bo', 'company_uuid' => 'company-radar', 'name' => 'Bo Tran', 'type' => 'user']); + + session(['company' => 'company-radar', 'user' => 'user-ada']); + + return $connection; +} + +function fleetOpsRadarStateItem(string $key = 'issue_open:issue_1', array $extra = []): array +{ + [$rule] = explode(':', $key); + + return array_merge([ + 'key' => $key, + 'rule' => $rule, + 'category' => 'issues', + 'chip' => 'Issue', + 'severity' => 'critical', + 'title' => 'Check engine light', + 'due_at' => null, + 'record' => ['route' => 'management.issues.index.details', 'model' => 'issue_1'], + 'subject' => ['type' => 'vehicle', 'class' => 'Fleetbase\FleetOps\Models\Vehicle', 'uuid' => 'vehicle-311', 'public_id' => 'vehicle_311', 'label' => 'TRK-311', 'photo_url' => null], + ], $extra); +} + +afterEach(fn () => Carbon::setTestNow()); + +test('rowFor creates one open row per item key and reuses it', function () { + fleetOpsRadarStateDatabase(); + Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC')); + + $row = RadarItemState::rowFor('company-radar', fleetOpsRadarStateItem()); + + expect($row->exists)->toBeTrue() + ->and($row->type)->toBe('issue_open') + ->and($row->status)->toBe('open') + ->and($row->severity)->toBe('critical') + ->and($row->subject_type)->toBe('Fleetbase\FleetOps\Models\Vehicle') + ->and($row->subject_uuid)->toBe('vehicle-311') + ->and($row->message)->toBe('Check engine light') + ->and($row->context['key'])->toBe('issue_open:issue_1') + ->and($row->context['subject']['label'])->toBe('TRK-311') + ->and($row->context['record']['model'])->toBe('issue_1') + ->and(RadarItemState::keyOf($row))->toBe('issue_open:issue_1'); + + $again = RadarItemState::rowFor('company-radar', fleetOpsRadarStateItem()); + expect($again->uuid)->toBe($row->uuid) + ->and(Alert::query()->count())->toBe(1); + + // A resolved row is history: the next action opens a fresh one. + $row->update(['status' => 'resolved', 'resolved_at' => now()]); + $fresh = RadarItemState::rowFor('company-radar', fleetOpsRadarStateItem()); + expect($fresh->uuid)->not->toBe($row->uuid) + ->and(Alert::query()->count())->toBe(2); +}); + +test('statesFor and findRow read live rows by key, including notices by public id', function () { + fleetOpsRadarStateDatabase(); + Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC')); + + $issue = RadarItemState::rowFor('company-radar', fleetOpsRadarStateItem()); + $issue->update(['acknowledged_at' => now(), 'acknowledged_by_uuid' => 'user-ada', 'status' => 'acknowledged', 'assigned_to_uuid' => 'user-bo', 'snoozed_until' => now()->addHour(), 'snoozed_by_uuid' => 'user-bo', 'planned_at' => now()->addHours(3)]); + + $notice = Alert::create(['company_uuid' => 'company-radar', 'type' => 'radar_notice', 'severity' => 'info', 'status' => 'open', 'message' => 'Yard closed']); + $notice->forceFill(['public_id' => 'alert_yard'])->save(); + + // A row for another company, and one that is resolved, are not live. + Alert::create(['company_uuid' => 'company-other', 'type' => 'issue_open', 'severity' => 'info', 'status' => 'open', 'message' => 'x', 'context' => ['key' => 'issue_open:issue_other']]); + Alert::create(['company_uuid' => 'company-radar', 'type' => 'issue_open', 'severity' => 'info', 'status' => 'resolved', 'message' => 'x', 'context' => ['key' => 'issue_open:issue_done']]); + + $states = RadarItemState::statesFor('company-radar'); + + expect(array_keys($states))->toBe(['issue_open:issue_1', 'notice:alert_yard']) + ->and($states['issue_open:issue_1'])->toMatchArray([ + 'status' => 'acknowledged', + 'alert_id' => $issue->public_id, + 'acknowledged_at' => '2026-09-15T08:35:00+00:00', + 'acknowledged_by_name' => 'Ada Ops', + 'snoozed_until' => '2026-09-15T09:35:00+00:00', + 'snoozed_by_name' => 'Bo Tran', + 'planned_at' => '2026-09-15T11:35:00+00:00', + ]) + ->and($states['issue_open:issue_1']['assigned_to'])->toBe(['uuid' => 'user-bo', 'public_id' => 'user_bo', 'name' => 'Bo Tran', 'initials' => 'BT']) + ->and(RadarItemState::findRow('company-radar', 'issue_open:issue_1')?->uuid)->toBe($issue->uuid) + ->and(RadarItemState::findRow('company-radar', 'notice:alert_yard')?->uuid)->toBe($notice->uuid) + ->and(RadarItemState::findRow('company-radar', 'notice:' . $notice->uuid)?->uuid)->toBe($notice->uuid) + ->and(RadarItemState::findRow('company-radar', 'issue_open:issue_other'))->toBeNull() + ->and(RadarItemState::findRow('company-radar', 'issue_open:issue_done'))->toBeNull() + ->and(RadarItemState::findRow('company-radar', 'garbage'))->toBeNull(); + + $notices = RadarItemState::notices('company-radar'); + expect($notices)->toHaveCount(1) + ->and($notices[0]['public_id'])->toBe('alert_yard') + ->and($notices[0]['state']['status'])->toBe('open'); +}); + +test('reconcile resolves rows whose gap is gone and leaves the live ones', function () { + fleetOpsRadarStateDatabase(); + Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC')); + + $live = RadarItemState::rowFor('company-radar', fleetOpsRadarStateItem('issue_open:issue_1')); + $gone = RadarItemState::rowFor('company-radar', fleetOpsRadarStateItem('part_low_stock:part_pads', ['category' => 'parts', 'chip' => 'Parts', 'title' => 'Brake pads — 2 left'])); + $notice = Alert::create(['company_uuid' => 'company-radar', 'type' => 'radar_notice', 'severity' => 'info', 'status' => 'open', 'message' => 'Yard closed', 'context' => ['key' => 'notice:alert_yard']]); + + expect(RadarItemState::reconcile('company-radar', ['issue_open:issue_1', 'notice:alert_yard']))->toBe(1) + ->and($live->fresh()->status)->toBe('open') + ->and($notice->fresh()->status)->toBe('open') + ->and($gone->fresh()->status)->toBe('resolved') + ->and($gone->fresh()->resolved_at?->toIso8601String())->toBe('2026-09-15T08:35:00+00:00') + ->and($gone->fresh()->meta['resolution'])->toBe('auto'); + + $resolved = RadarItemState::resolvedSince('company-radar', Carbon::parse('2026-09-14 00:00:00', 'UTC'), now()); + expect($resolved)->toHaveCount(1) + ->and($resolved[0]['key'])->toBe('part_low_stock:part_pads') + ->and($resolved[0]['title'])->toBe('Brake pads — 2 left') + ->and($resolved[0]['chip'])->toBe('Parts') + ->and($resolved[0]['category'])->toBe('parts') + ->and($resolved[0]['meta_line'])->toBe('closed on the record') + ->and($resolved[0]['subject']['label'])->toBe('TRK-311') + ->and($resolved[0]['state']['status'])->toBe('resolved') + ->and($resolved[0]['state']['resolution'])->toBe('auto') + ->and($resolved[0]['actions'])->toBe(['open_record']); +}); + +test('snoozeSchedule lists the snoozes waking within a week, soonest first', function () { + fleetOpsRadarStateDatabase(); + Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC')); + + $soon = RadarItemState::rowFor('company-radar', fleetOpsRadarStateItem('issue_open:issue_1', ['title' => 'Soon'])); + $later = RadarItemState::rowFor('company-radar', fleetOpsRadarStateItem('issue_open:issue_2', ['title' => 'Later'])); + $far = RadarItemState::rowFor('company-radar', fleetOpsRadarStateItem('issue_open:issue_3', ['title' => 'Far'])); + $past = RadarItemState::rowFor('company-radar', fleetOpsRadarStateItem('issue_open:issue_4', ['title' => 'Past'])); + + $later->update(['snoozed_until' => now()->addDays(3)]); + $soon->update(['snoozed_until' => now()->addHours(2)]); + $far->update(['snoozed_until' => now()->addDays(20)]); + $past->update(['snoozed_until' => now()->subHour()]); + + $schedule = RadarItemState::snoozeSchedule('company-radar', now()); + + expect(array_column($schedule, 'title'))->toBe(['Soon', 'Later']) + ->and($schedule[0]['key'])->toBe('issue_open:issue_1') + ->and($schedule[0]['snoozed_until'])->toBe('2026-09-15T10:35:00+00:00'); +}); + +test('initials come from the first and last name', function () { + expect(RadarItemState::initials('Ada Ops'))->toBe('AO') + ->and(RadarItemState::initials('Cher'))->toBe('C') + ->and(RadarItemState::initials(' mary jane watson '))->toBe('MW') + ->and(RadarItemState::initials(null))->toBe(''); +}); diff --git a/server/tests/Unit/Support/RadarRulesTest.php b/server/tests/Unit/Support/RadarRulesTest.php new file mode 100644 index 000000000..d1eb4c706 --- /dev/null +++ b/server/tests/Unit/Support/RadarRulesTest.php @@ -0,0 +1,475 @@ + 'vehicle', + 'class' => 'Fleetbase\FleetOps\Models\Vehicle', + 'uuid' => 'vehicle-' . strtolower($id), + 'public_id' => 'vehicle_' . strtolower(str_replace('-', '', $id)), + 'label' => $id . ' Freightliner Cascadia', + 'photo_url' => null, + ], $extra); +} + +function fleetOpsRadarDriver(string $name = 'Amara Diallo', array $extra = []): array +{ + $slug = strtolower(str_replace(' ', '', $name)); + + return array_merge([ + 'type' => 'driver', + 'class' => 'Fleetbase\FleetOps\Models\Driver', + 'uuid' => 'driver-' . $slug, + 'public_id' => 'driver_' . $slug, + 'name' => $name, + 'label' => $name, + 'photo_url' => null, + 'online' => false, + 'vehicle_uuid' => 'vehicle-trk-118', + ], $extra); +} + +afterEach(fn () => Carbon::setTestNow()); + +test('maintenance schedules become overdue, due-soon or inspection items with the right due bucket', function () { + $now = fleetOpsRadarNow(); + $items = RadarRules::scheduleItems([ + ['uuid' => 's1', 'public_id' => 'schedule_oil', 'name' => 'Oil change', 'type' => 'oil_change', 'status' => 'active', 'next_due_date' => '2026-09-12 08:00:00', 'next_due_odometer' => 214880, 'has_open_work_order' => false, 'subject' => fleetOpsRadarVehicle()], + ['uuid' => 's2', 'public_id' => 'schedule_tires', 'name' => 'Tire rotation', 'type' => 'tire_rotation', 'status' => 'active', 'next_due_date' => '2026-09-17 12:00:00', 'has_open_work_order' => true, 'subject' => fleetOpsRadarVehicle('TRK-204')], + ['uuid' => 's3', 'public_id' => 'schedule_dot', 'name' => 'DOT inspection', 'type' => 'inspection', 'status' => 'active', 'next_due_date' => '2026-09-15 16:00:00', 'subject' => fleetOpsRadarVehicle('VAN-042')], + ['uuid' => 's4', 'public_id' => 'schedule_far', 'name' => 'Brake service', 'type' => 'brake_service', 'status' => 'active', 'next_due_date' => '2026-10-30 08:00:00', 'subject' => fleetOpsRadarVehicle('TRK-101')], + ['uuid' => 's5', 'public_id' => 'schedule_paused', 'name' => 'Paused', 'type' => 'oil_change', 'status' => 'paused', 'next_due_date' => '2026-09-01 08:00:00', 'subject' => fleetOpsRadarVehicle('TRK-102')], + ], $now); + + expect($items)->toHaveCount(3) + ->and($items[0]['key'])->toBe('maintenance_overdue:schedule_oil') + ->and($items[0]['severity'])->toBe('critical') + ->and($items[0]['due_bucket'])->toBe('overdue') + ->and($items[0]['due_label'])->toBe('3d overdue') + ->and($items[0]['title'])->toBe('Oil change 3d overdue') + ->and($items[0]['meta_line'])->toBe('due at 214,880 odometer') + ->and($items[0]['lane'])->toBe('maintenance') + ->and($items[0]['chip'])->toBe('Maint') + ->and($items[0]['record'])->toBe(['route' => 'maintenance.schedules.index.details', 'model' => 'schedule_oil']) + ->and($items[0]['actions'])->toContain('create_work_order') + ->and($items[0]['pills'])->toBe(['overdue']) + ->and($items[1]['rule'])->toBe('maintenance_due_soon') + ->and($items[1]['severity'])->toBe('warning') + ->and($items[1]['due_bucket'])->toBe('week') + ->and($items[1]['due_label'])->toBe('Thu 17 Sep') + ->and($items[1]['title'])->toBe('Tire rotation due in 2 days') + ->and($items[1]['meta_line'])->toBe('work order open') + ->and($items[1]['actions'])->not->toContain('create_work_order') + ->and($items[1]['pills'])->toBe(['due_week']) + ->and($items[2]['rule'])->toBe('inspection_due') + ->and($items[2]['category'])->toBe('inspections') + ->and($items[2]['chip'])->toBe('Inspection') + ->and($items[2]['due_bucket'])->toBe('today') + ->and($items[2]['due_label'])->toBe('Today 16:00') + ->and($items[2]['title'])->toBe('DOT inspection due today') + ->and($items[2]['pills'])->toBe(['due_week', 'inspections']); +}); + +test('work orders past due or waiting on something become items', function () { + $now = fleetOpsRadarNow(); + $items = RadarRules::workOrderItems([ + ['uuid' => 'w1', 'public_id' => 'work_order_2041', 'code' => 'WO-2041', 'subject' => 'brake pad replacement', 'status' => 'in_progress', 'priority' => 'high', 'due_at' => '2026-09-13 09:00:00', 'target' => fleetOpsRadarVehicle('TRK-207')], + ['uuid' => 'w2', 'public_id' => 'work_order_2044', 'code' => 'WO-2044', 'subject' => 'DOT inspection', 'status' => 'awaiting_parts', 'priority' => 'normal', 'due_at' => '2026-09-20 09:00:00', 'target' => fleetOpsRadarVehicle('VAN-042')], + ['uuid' => 'w3', 'public_id' => 'work_order_done', 'code' => 'WO-1', 'subject' => 'done', 'status' => 'completed', 'due_at' => '2026-09-01 09:00:00', 'target' => null], + ['uuid' => 'w4', 'public_id' => 'work_order_fine', 'code' => 'WO-2', 'subject' => 'on time', 'status' => 'open', 'due_at' => '2026-09-25 09:00:00', 'target' => null], + ], $now); + + expect($items)->toHaveCount(2) + ->and($items[0]['key'])->toBe('work_order_overdue:work_order_2041') + ->and($items[0]['title'])->toBe('WO-2041 brake pad replacement') + ->and($items[0]['severity'])->toBe('critical') + ->and($items[0]['due_label'])->toBe('1d overdue') + ->and($items[0]['meta_line'])->toBe('high priority') + ->and($items[1]['rule'])->toBe('work_order_blocked') + ->and($items[1]['severity'])->toBe('warning') + ->and($items[1]['meta_line'])->toBe('awaiting parts') + ->and($items[1]['due_bucket'])->toBe('week'); +}); + +test('open issues are critical by priority and name the inspection that raised them', function () { + $now = fleetOpsRadarNow(); + $items = RadarRules::issueItems([ + ['uuid' => 'i1', 'public_id' => 'issue_1', 'title' => 'Check engine light', 'report' => 'power loss above 55 mph', 'priority' => 'high', 'status' => 'pending', 'vehicle' => fleetOpsRadarVehicle('TRK-311'), 'driver' => null, 'reporter_name' => 'M. Okafor', 'meta' => []], + ['uuid' => 'i2', 'public_id' => 'issue_2', 'title' => null, 'report' => 'Failed inspection: Truck 7', 'priority' => 'medium', 'status' => 'pending', 'vehicle' => null, 'driver' => fleetOpsRadarDriver(), 'meta' => ['inspection_submission_id' => 'inspection_submission_abc']], + ['uuid' => 'i3', 'public_id' => 'issue_3', 'title' => 'Closed', 'report' => '', 'priority' => 'low', 'status' => 'resolved', 'vehicle' => null, 'driver' => null, 'meta' => []], + ], $now); + + expect($items)->toHaveCount(2) + ->and($items[0]['severity'])->toBe('critical') + ->and($items[0]['subject']['label'])->toBe('TRK-311 Freightliner Cascadia') + ->and($items[0]['meta_line'])->toBe('high priority · reported by M. Okafor') + ->and($items[0]['due_bucket'])->toBe('none') + ->and($items[0]['lane'])->toBe('anytime') + ->and($items[0]['actions'][0])->toBe('resolve_issue') + ->and($items[1]['severity'])->toBe('warning') + ->and($items[1]['title'])->toBe('Failed inspection: Truck 7') + ->and($items[1]['subject']['type'])->toBe('driver') + ->and($items[1]['meta_line'])->toBe('medium priority · raised by inspection inspection_submission_abc') + ->and($items[1]['pills'])->toBe(['issues']); +}); + +test('inspection submissions become follow-up, unresolved or stale-draft items', function () { + $now = fleetOpsRadarNow(); + $items = RadarRules::inspectionItems([ + ['uuid' => 'a', 'public_id' => 'inspection_submission_a', 'status' => 'submitted', 'result' => 'failed', 'failed_items' => 2, 'issue_uuid' => null, 'work_order_uuid' => null, 'resolved_at' => null, 'form_name' => 'Pre-trip DVIR', 'highest_severity' => 'critical', 'vehicle' => fleetOpsRadarVehicle('TRK-207'), 'driver' => null, 'failed_item_labels' => [['label' => 'Brakes', 'severity' => 'critical']]], + ['uuid' => 'b', 'public_id' => 'inspection_submission_b', 'status' => 'needs_review', 'result' => 'failed', 'failed_items' => 1, 'issue_uuid' => 'issue-1', 'work_order_uuid' => null, 'resolved_at' => null, 'form_name' => 'Pre-trip DVIR', 'highest_severity' => 'medium', 'vehicle' => fleetOpsRadarVehicle('VAN-011'), 'driver' => null], + ['uuid' => 'c', 'public_id' => 'inspection_submission_c', 'status' => 'submitted', 'result' => 'failed', 'failed_items' => 1, 'issue_uuid' => 'issue-2', 'work_order_uuid' => 'wo-2', 'resolved_at' => null, 'form_name' => 'Post-trip', 'highest_severity' => 'low', 'vehicle' => null, 'driver' => fleetOpsRadarDriver('Priya Nair')], + ['uuid' => 'd', 'public_id' => 'inspection_submission_d', 'status' => 'draft', 'result' => null, 'failed_items' => 0, 'started_at' => '2026-09-13 07:00:00', 'form_name' => 'Pre-trip DVIR', 'driver_name' => 'Luis Ortega', 'vehicle' => fleetOpsRadarVehicle('TRK-101'), 'driver' => null], + ['uuid' => 'e', 'public_id' => 'inspection_submission_e', 'status' => 'draft', 'result' => null, 'failed_items' => 0, 'started_at' => '2026-09-15 08:00:00', 'form_name' => 'Pre-trip DVIR', 'vehicle' => null, 'driver' => null], + ['uuid' => 'f', 'public_id' => 'inspection_submission_f', 'status' => 'submitted', 'result' => 'passed', 'failed_items' => 0, 'issue_uuid' => null, 'work_order_uuid' => null, 'resolved_at' => null, 'form_name' => 'Pre-trip DVIR', 'vehicle' => null, 'driver' => null], + ['uuid' => 'g', 'public_id' => 'inspection_submission_g', 'status' => 'resolved', 'result' => 'failed', 'failed_items' => 3, 'issue_uuid' => 'i', 'work_order_uuid' => 'w', 'resolved_at' => '2026-09-14', 'form_name' => 'Pre-trip DVIR', 'vehicle' => null, 'driver' => null], + ], $now); + + expect(array_column($items, 'key'))->toBe([ + 'inspection_failed:inspection_submission_a', + 'inspection_failed:inspection_submission_b', + 'inspection_unresolved:inspection_submission_c', + 'inspection_draft:inspection_submission_d', + ]) + ->and($items[0]['severity'])->toBe('critical') + ->and($items[0]['title'])->toBe('Pre-trip DVIR failed 2 critical items') + ->and($items[0]['meta_line'])->toBe('2 failed · no issue · no work order') + ->and($items[0]['actions'])->toBe(['create_work_order_from_inspection', 'create_issue_from_inspection', 'acknowledge', 'snooze', 'assign', 'open_record']) + ->and($items[0]['details']['failed_items'][0]['label'])->toBe('Brakes') + ->and($items[0]['record'])->toBe(['route' => 'maintenance.inspection-submissions.index.details', 'model' => 'inspection_submission_a']) + ->and($items[1]['severity'])->toBe('warning') + ->and($items[1]['meta_line'])->toBe('1 failed · no work order') + ->and($items[1]['actions'][0])->toBe('create_work_order_from_inspection') + ->and($items[2]['severity'])->toBe('info') + ->and($items[2]['actions'][0])->toBe('resolve_inspection') + ->and($items[2]['subject']['label'])->toBe('Priya Nair') + ->and($items[3]['severity'])->toBe('info') + ->and($items[3]['title'])->toBe('Pre-trip DVIR started 2d ago, never filed') + ->and($items[3]['meta_line'])->toBe('by Luis Ortega') + ->and($items[3]['pills'])->toBe(['inspections']); +}); + +test('inspection links expiring unused become items, expired ones a warning', function () { + $now = fleetOpsRadarNow(); + $items = RadarRules::inspectionLinkItems([ + ['uuid' => 'l1', 'public_id' => 'inspection_link_1', 'status' => 'active', 'used_at' => null, 'expires_at' => '2026-09-15 20:00:00', 'last_viewed_at' => '2026-09-15 07:00:00', 'form_name' => 'Pre-trip DVIR', 'form_public_id' => 'inspection_form_1', 'form_uuid' => 'form-1', 'driver' => fleetOpsRadarDriver(), 'vehicle' => null], + ['uuid' => 'l2', 'public_id' => 'inspection_link_2', 'status' => 'active', 'used_at' => null, 'expires_at' => '2026-09-14 20:00:00', 'last_viewed_at' => null, 'form_name' => 'Pre-trip DVIR', 'form_public_id' => 'inspection_form_1', 'form_uuid' => 'form-1', 'driver' => null, 'vehicle' => fleetOpsRadarVehicle()], + ['uuid' => 'l3', 'public_id' => 'inspection_link_3', 'status' => 'active', 'used_at' => '2026-09-15 07:30:00', 'expires_at' => '2026-09-15 20:00:00', 'form_name' => 'Pre-trip DVIR', 'form_public_id' => 'inspection_form_1', 'driver' => null, 'vehicle' => null], + ['uuid' => 'l4', 'public_id' => 'inspection_link_4', 'status' => 'active', 'used_at' => null, 'expires_at' => '2026-09-18 20:00:00', 'form_name' => 'Pre-trip DVIR', 'form_public_id' => 'inspection_form_1', 'driver' => null, 'vehicle' => null], + ['uuid' => 'l5', 'public_id' => 'inspection_link_5', 'status' => 'revoked', 'used_at' => null, 'expires_at' => '2026-09-15 09:00:00', 'form_name' => 'Pre-trip DVIR', 'form_public_id' => 'inspection_form_1', 'driver' => null, 'vehicle' => null], + ], $now); + + expect(array_column($items, 'key'))->toBe(['inspection_link_pending:inspection_link_1', 'inspection_link_pending:inspection_link_2']) + ->and($items[0]['severity'])->toBe('info') + ->and($items[0]['title'])->toBe('Pre-trip DVIR link expires today, not yet used') + ->and($items[0]['meta_line'])->toBe('opened, not filed') + ->and($items[0]['lane'])->toBe('expiries') + ->and($items[0]['pills'])->toBe(['due_week', 'inspections', 'expiring']) + ->and($items[0]['record'])->toBe(['route' => 'maintenance.inspection-forms.index.details', 'model' => 'inspection_form_1']) + ->and($items[1]['severity'])->toBe('warning') + ->and($items[1]['title'])->toBe('Pre-trip DVIR link expired unused') + ->and($items[1]['meta_line'])->toBe('never opened') + ->and($items[1]['due_bucket'])->toBe('overdue'); +}); + +test('shifts produce late-start, no-vehicle and handover items only while the shift is live', function () { + $now = fleetOpsRadarNow(); + $diallo = fleetOpsRadarDriver('Amara Diallo'); + $nair = fleetOpsRadarDriver('Priya Nair'); + $ortega = fleetOpsRadarDriver('Luis Ortega'); + $items = RadarRules::shiftItems([ + ['uuid' => 'sh1', 'public_id' => 'shift_1', 'start_at' => '2026-09-15 08:00:00', 'end_at' => '2026-09-15 16:00:00', 'status' => 'scheduled', 'driver' => $diallo, 'driver_online' => false, 'driver_vehicle_uuid' => 'vehicle-1', 'active_orders' => 0], + ['uuid' => 'sh2', 'public_id' => 'shift_2', 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 14:00:00', 'status' => 'in_progress', 'driver' => $nair, 'driver_online' => true, 'driver_vehicle_uuid' => null, 'active_orders' => 0], + ['uuid' => 'sh3', 'public_id' => 'shift_3', 'start_at' => '2026-09-15 01:15:00', 'end_at' => '2026-09-15 09:15:00', 'status' => 'in_progress', 'driver' => $ortega, 'driver_online' => true, 'driver_vehicle_uuid' => 'vehicle-2', 'active_orders' => 2], + ['uuid' => 'sh4', 'public_id' => 'shift_4', 'start_at' => '2026-09-15 08:25:00', 'end_at' => '2026-09-15 16:00:00', 'status' => 'scheduled', 'driver' => fleetOpsRadarDriver('Just Started'), 'driver_online' => false, 'driver_vehicle_uuid' => 'v', 'active_orders' => 0], + ['uuid' => 'sh5', 'public_id' => 'shift_5', 'start_at' => '2026-09-15 14:00:00', 'end_at' => '2026-09-15 21:00:00', 'status' => 'scheduled', 'driver' => fleetOpsRadarDriver('Later Today'), 'driver_online' => false, 'driver_vehicle_uuid' => null, 'active_orders' => 3], + ['uuid' => 'sh6', 'public_id' => 'shift_6', 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 16:00:00', 'status' => 'cancelled', 'driver' => fleetOpsRadarDriver('Cancelled Shift'), 'driver_online' => false, 'driver_vehicle_uuid' => null, 'active_orders' => 0], + ], $now); + + expect(array_column($items, 'key'))->toBe([ + 'shift_late_start:driver_amaradiallo', + 'shift_no_vehicle:driver_priyanair', + 'shift_handover:driver_luisortega', + ]) + ->and($items[0]['title'])->toBe('Amara Diallo scheduled 08:00, not online') + ->and($items[0]['meta_line'])->toBe('35m late') + ->and($items[0]['severity'])->toBe('warning') + ->and($items[0]['lane'])->toBe('shifts') + ->and($items[0]['window'])->toBe(['start_at' => '2026-09-15T08:00:00+00:00', 'end_at' => '2026-09-15T16:00:00+00:00', 'status' => 'scheduled']) + ->and($items[0]['actions'][0])->toBe('call') + ->and($items[0]['pills'])->toBe(['shifts']) + ->and($items[1]['title'])->toBe('Priya Nair on shift since 06:00, no vehicle assigned') + ->and($items[1]['meta_line'])->toBe('2h 35m on shift') + ->and($items[1]['actions'][0])->toBe('assign_vehicle') + ->and($items[1]['pills'])->toBe(['unassigned', 'shifts']) + ->and($items[2]['title'])->toBe('Luis Ortega shift ends in 40m, 2 active orders still assigned') + ->and($items[2]['due_bucket'])->toBe('today') + ->and($items[2]['source']['active_orders'])->toBe(2) + ->and($items[2]['actions'][0])->toBe('handover') + ->and($items[2]['chip'])->toBe('Handover'); + + $lateByAnHour = RadarRules::shiftItems([ + ['uuid' => 'sh7', 'public_id' => 'shift_7', 'start_at' => '2026-09-15 07:30:00', 'end_at' => '2026-09-15 16:00:00', 'status' => 'confirmed', 'driver' => $diallo, 'driver_online' => false, 'driver_vehicle_uuid' => 'v', 'active_orders' => 0], + ], $now); + + expect($lateByAnHour[0]['severity'])->toBe('critical') + ->and($lateByAnHour[0]['meta_line'])->toBe('1h 5m late'); +}); + +test('drivers without a vehicle are skipped while on shift and licences expiring are flagged', function () { + $now = fleetOpsRadarNow(); + $shifts = [ + ['uuid' => 'sh', 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 14:00:00', 'status' => 'in_progress', 'driver' => ['uuid' => 'driver-priyanair']], + ]; + $items = RadarRules::driverItems([ + fleetOpsRadarDriver('Priya Nair', ['vehicle_uuid' => null, 'online' => true]), + fleetOpsRadarDriver('Sam Bauer', ['vehicle_uuid' => null, 'online' => false, 'license_expiry' => '2026-10-06', 'drivers_license_number' => 'D-4471']), + fleetOpsRadarDriver('Ben Novak', ['vehicle_uuid' => 'v', 'online' => true, 'license_expiry' => '2026-09-10']), + fleetOpsRadarDriver('Fine Driver', ['vehicle_uuid' => 'v', 'license_expiry' => '2027-01-01']), + ], $shifts, $now); + + expect(array_column($items, 'key'))->toBe([ + 'driver_without_vehicle:driver_sambauer', + 'license_expiring:driver_sambauer', + 'license_expiring:driver_bennovak', + ]) + ->and($items[0]['severity'])->toBe('info') + ->and($items[0]['title'])->toBe('Sam Bauer has no vehicle assigned') + ->and($items[0]['pills'])->toBe(['unassigned']) + ->and($items[1]['title'])->toBe('Sam Bauer licence expires 6 Oct') + ->and($items[1]['severity'])->toBe('warning') + ->and($items[1]['meta_line'])->toBe('licence D-4471') + ->and($items[1]['due_bucket'])->toBe('later') + ->and($items[1]['lane'])->toBe('expiries') + ->and($items[1]['pills'])->toBe(['expiring']) + ->and($items[2]['title'])->toBe('Ben Novak licence expired 10 Sep') + ->and($items[2]['severity'])->toBe('critical') + ->and($items[2]['due_bucket'])->toBe('overdue'); +}); + +test('vehicles report failed inspections, no driver, no device and lease expiry', function () { + $now = fleetOpsRadarNow(); + $devices = [['uuid' => 'd1', 'attachable_uuid' => null]]; + $items = RadarRules::vehicleItems([ + ['uuid' => 'vehicle-1', 'public_id' => 'vehicle_1', 'label' => 'TRK-311', 'status' => 'inspection_failed', 'driver_uuid' => 'driver-1', 'device_count' => 1], + ['uuid' => 'vehicle-2', 'public_id' => 'vehicle_2', 'label' => 'VAN-042', 'status' => 'available', 'driver_uuid' => null, 'device_count' => 0, 'lease_expires_at' => '2026-09-30'], + ['uuid' => 'vehicle-3', 'public_id' => 'vehicle_3', 'label' => 'TRK-101', 'status' => 'maintenance', 'driver_uuid' => null, 'device_count' => 2], + ], $devices, $now); + + expect(array_column($items, 'key'))->toBe([ + 'vehicle_inspection_failed:vehicle_1', + 'vehicle_without_driver:vehicle_2', + 'vehicle_without_device:vehicle_2', + 'lease_expiring:vehicle_2', + ]) + ->and($items[0]['severity'])->toBe('critical') + ->and($items[0]['record']['route'])->toBe('management.vehicles.index.details.inspections') + ->and($items[0]['pills'])->toBe(['inspections']) + ->and($items[1]['title'])->toBe('VAN-042 has no driver') + ->and($items[1]['meta_line'])->toBe('lease ends 30 Sep') + ->and($items[1]['chip'])->toBe('Idle') + ->and($items[2]['meta_line'])->toBe('1 spare device') + ->and($items[2]['category'])->toBe('connectivity') + ->and($items[3]['title'])->toBe('VAN-042 lease ends 30 Sep') + ->and($items[3]['due_bucket'])->toBe('later'); + + $noSpares = RadarRules::vehicleItems([ + ['uuid' => 'vehicle-2', 'public_id' => 'vehicle_2', 'label' => 'VAN-042', 'status' => 'available', 'driver_uuid' => 'd', 'device_count' => 0], + ], [], $now); + + expect($noSpares)->toBe([]); +}); + +test('parts use the reorder point, then the spec threshold, then five, and go critical when a work order needs them', function () { + expect(RadarRules::lowStockThreshold(['reorder_point' => 6, 'specs' => ['low_stock_threshold' => 9]]))->toBe(6) + ->and(RadarRules::lowStockThreshold(['reorder_point' => 0, 'specs' => ['low_stock_threshold' => 9]]))->toBe(9) + ->and(RadarRules::lowStockThreshold(['reorder_point' => null, 'specs' => '{"low_stock_threshold":3}']))->toBe(3) + ->and(RadarRules::lowStockThreshold(['reorder_point' => null, 'specs' => null]))->toBe(5); + + $now = fleetOpsRadarNow(); + $workOrders = [ + ['code' => 'WO-2041', 'status' => 'open', 'subject' => 'brake pad replacement', 'checklist' => [['title' => 'Fit brake pads (PN 8842)']]], + ['code' => 'WO-9', 'status' => 'completed', 'subject' => 'wipers', 'checklist' => [['title' => 'wiper blades']]], + ]; + $items = RadarRules::partItems([ + ['uuid' => 'p1', 'public_id' => 'part_pads', 'name' => 'Brake pads', 'sku' => 'PN 8842', 'quantity_on_hand' => 2, 'reorder_point' => 6], + ['uuid' => 'p2', 'public_id' => 'part_wipers', 'name' => 'Wiper blades', 'sku' => 'WB-1', 'quantity_on_hand' => 0, 'reorder_point' => 4], + ['uuid' => 'p3', 'public_id' => 'part_fine', 'name' => 'Filters', 'sku' => 'F-1', 'quantity_on_hand' => 20, 'reorder_point' => 6], + ], $workOrders, $now); + + expect(array_column($items, 'key'))->toBe(['part_low_stock:part_pads', 'part_low_stock:part_wipers']) + ->and($items[0]['title'])->toBe('Brake pads — 2 left, reorder point 6') + ->and($items[0]['meta_line'])->toBe('reorder point 6 · blocks WO-2041') + ->and($items[0]['severity'])->toBe('warning') + ->and($items[1]['title'])->toBe('Wiper blades — out of stock') + ->and($items[1]['severity'])->toBe('warning') + ->and($items[1]['pills'])->toBe(['low_stock']); + + $critical = RadarRules::partItems([ + ['uuid' => 'p1', 'public_id' => 'part_pads', 'name' => 'Brake pads', 'sku' => 'PN 8842', 'quantity_on_hand' => 0, 'reorder_point' => 6], + ], $workOrders, $now); + + expect($critical[0]['severity'])->toBe('critical'); +}); + +test('unmatched fuel transactions carry the suggested vehicle when an identity column matches', function () { + $now = fleetOpsRadarNow(); + $vehicles = [ + ['uuid' => 'vehicle-207', 'public_id' => 'vehicle_207', 'label' => 'TRK-207 Isuzu NPR', 'plate_number' => 'ABC-207', 'vin' => 'VIN207', 'fuel_card_number' => '4471'], + ]; + + expect(RadarRules::suggestVehicleForFuel(['vehicle_card_id' => '4471'], $vehicles))->toBe(['uuid' => 'vehicle-207', 'public_id' => 'vehicle_207', 'label' => 'TRK-207 Isuzu NPR', 'matched' => 'fuel_card_number']) + ->and(RadarRules::suggestVehicleForFuel(['plate_number' => 'abc-207'], $vehicles)['matched'])->toBe('plate_number') + ->and(RadarRules::suggestVehicleForFuel(['vin' => 'nope'], $vehicles))->toBeNull(); + + $items = RadarRules::fuelItems([ + ['uuid' => 'f1', 'public_id' => 'fuel_provider_transaction_1', 'amount' => 21240, 'currency' => 'USD', 'station_name' => 'Pilot #331', 'transaction_at' => '2026-09-14 08:04:00', 'vehicle_card_id' => '4471', 'sync_status' => 'unmatched', 'suggested_vehicle' => ['label' => 'TRK-207 Isuzu NPR']], + ['uuid' => 'f2', 'public_id' => 'fuel_provider_transaction_2', 'amount' => 5000, 'currency' => 'USD', 'station_name' => 'Shell', 'sync_status' => 'matched'], + ], $now); + + expect($items)->toHaveCount(1) + ->and($items[0]['title'])->toBe('USD 212.40 at Pilot #331 — no vehicle matched') + ->and($items[0]['meta_line'])->toBe('card 4471 · suggest: TRK-207 Isuzu NPR · 1d ago') + ->and($items[0]['actions'][0])->toBe('match_vehicle') + ->and($items[0]['pills'])->toBe(['fuel']); +}); + +test('notices, devices and trailers become items', function () { + $now = fleetOpsRadarNow(); + + $notices = RadarRules::noticeItems([ + ['uuid' => 'n1', 'public_id' => 'alert_notice', 'message' => 'Yard closed Saturday for repaving', 'severity' => 'warning', 'status' => 'open', 'meta' => ['due_at' => '2026-09-18 18:00:00', 'scope' => 'Yard 3 · whole fleet'], 'state' => ['status' => 'acknowledged', 'acknowledged_at' => '2026-09-15T08:12:00+00:00']], + ['uuid' => 'n2', 'public_id' => 'alert_done', 'message' => 'Old', 'severity' => 'info', 'status' => 'resolved', 'meta' => []], + ], $now); + + expect($notices)->toHaveCount(1) + ->and($notices[0]['key'])->toBe('notice:alert_notice') + ->and($notices[0]['due_bucket'])->toBe('week') + ->and($notices[0]['lane'])->toBe('notices') + ->and($notices[0]['meta_line'])->toBe('Yard 3 · whole fleet') + ->and($notices[0]['actions'])->toBe(['resolve', 'acknowledge', 'snooze', 'assign']) + ->and($notices[0]['pills'])->toBe(['due_week', 'notices']); + + $devices = RadarRules::deviceItems([ + ['uuid' => 'd1', 'public_id' => 'device_1', 'name' => 'GPS-477', 'attachable_uuid' => null, 'online' => true], + ['uuid' => 'd2', 'public_id' => 'device_2', 'name' => 'GPS-478', 'attachable_uuid' => 'vehicle-1', 'online' => true], + ], $now); + + expect($devices)->toHaveCount(1) + ->and($devices[0]['title'])->toBe('GPS-477 not attached to a vehicle') + ->and($devices[0]['meta_line'])->toBe('online') + ->and($devices[0]['actions'][0])->toBe('attach_device'); + + $trailers = RadarRules::trailerItems([ + ['uuid' => 't1', 'public_id' => 'trailer_1', 'label' => 'TRL-019', 'lease_expires_at' => '2026-09-20'], + ['uuid' => 't2', 'public_id' => 'trailer_2', 'label' => 'TRL-004', 'lease_expires_at' => '2027-09-20'], + ], $now); + + expect($trailers)->toHaveCount(1) + ->and($trailers[0]['key'])->toBe('lease_expiring:trailer_1') + ->and($trailers[0]['subject']['type'])->toBe('trailer') + ->and($trailers[0]['record']['route'])->toBe('management.trailers.index.details'); +}); + +test('build merges state, sorts overdue first, counts pills over open items and summarises', function () { + $now = fleetOpsRadarNow(); + $built = RadarRules::build([ + 'schedules' => [ + ['uuid' => 's1', 'public_id' => 'schedule_oil', 'name' => 'Oil change', 'type' => 'oil_change', 'status' => 'active', 'next_due_date' => '2026-09-12 08:00:00', 'subject' => fleetOpsRadarVehicle()], + ['uuid' => 's2', 'public_id' => 'schedule_tires', 'name' => 'Tire rotation', 'type' => 'tire_rotation', 'status' => 'active', 'next_due_date' => '2026-09-17 12:00:00', 'subject' => fleetOpsRadarVehicle('TRK-204')], + ], + 'issues' => [ + ['uuid' => 'i1', 'public_id' => 'issue_1', 'title' => 'Check engine light', 'priority' => 'high', 'status' => 'pending', 'vehicle' => fleetOpsRadarVehicle('TRK-311'), 'driver' => null, 'meta' => []], + ['uuid' => 'i2', 'public_id' => 'issue_2', 'title' => 'Scratch', 'priority' => 'low', 'status' => 'pending', 'vehicle' => null, 'driver' => null, 'meta' => []], + ], + 'parts' => [ + ['uuid' => 'p1', 'public_id' => 'part_pads', 'name' => 'Brake pads', 'quantity_on_hand' => 2, 'reorder_point' => 6], + ], + 'states' => [ + 'maintenance_due_soon:schedule_tires' => ['status' => 'snoozed', 'snoozed_until' => '2026-09-18T08:00:00+00:00', 'snoozed_by_name' => 'M. Reyes'], + 'issue_open:issue_1' => ['status' => 'acknowledged', 'acknowledged_at' => '2026-09-15T08:12:00+00:00', 'acknowledged_by_name' => 'You'], + 'part_low_stock:part_pads' => ['status' => 'snoozed', 'snoozed_until' => '2026-09-14T08:00:00+00:00'], + ], + ], $now); + + // Dated first by due date; undated by severity, then title — so the + // warning-level part sorts before the low-priority issue. + expect(array_column($built['items'], 'key'))->toBe([ + 'maintenance_overdue:schedule_oil', + 'maintenance_due_soon:schedule_tires', + 'issue_open:issue_1', + 'part_low_stock:part_pads', + 'issue_open:issue_2', + ]) + ->and($built['items'][1]['state']['status'])->toBe('snoozed') + ->and($built['items'][1]['state']['snoozed_until'])->toBe('2026-09-18T08:00:00+00:00') + ->and($built['items'][1]['state']['snoozed_by_name'])->toBe('M. Reyes') + ->and($built['items'][2]['state']['status'])->toBe('acknowledged') + ->and($built['items'][3]['state']['status'])->toBe('open') + ->and($built['items'][3]['state']['snoozed_until'])->toBeNull() + ->and($built['counts']['overdue'])->toBe(1) + ->and($built['counts']['due_week'])->toBe(0) + ->and($built['counts']['issues'])->toBe(2) + ->and($built['counts']['low_stock'])->toBe(1) + ->and($built['summary'])->toBe(['open' => 4, 'acknowledged' => 1, 'snoozed' => 1, 'overdue' => 1, 'critical' => 2, 'undated' => 3]); + + $open = RadarRules::forTab($built['items'], 'open', $now); + expect(array_column($open, 'key'))->not->toContain('maintenance_due_soon:schedule_tires') + ->and(array_column(RadarRules::forTab($built['items'], 'snoozed', $now), 'key'))->toBe(['maintenance_due_soon:schedule_tires']) + ->and(array_column(RadarRules::forPills($open, ['issues']), 'key'))->toBe(['issue_open:issue_1', 'issue_open:issue_2']) + ->and(RadarRules::forPills($open, ['issues', 'overdue']))->toBe([]) + ->and(RadarRules::forPills($open, ['not-a-pill']))->toHaveCount(4) + ->and(array_column(RadarRules::search($open, 'trk-311'), 'key'))->toBe(['issue_open:issue_1']) + ->and(array_column(RadarRules::search($open, 'brake'), 'key'))->toBe(['part_low_stock:part_pads']) + ->and(array_column(RadarRules::group($open), 'key'))->toBe(['overdue', 'none']) + ->and(RadarRules::group($open)[1]['count'])->toBe(3) + ->and(RadarRules::paginate($open, 2, 3)['items'])->toHaveCount(1) + ->and(RadarRules::paginate($open, 2, 3)['meta'])->toBe(['total' => 4, 'page' => 2, 'limit' => 3, 'pages' => 2]); +}); + +test('keys parse only for known rules and buckets follow the calendar', function () { + $now = fleetOpsRadarNow(); + + expect(RadarRules::parseKey('issue_open:issue_1'))->toBe(['issue_open', 'issue_1']) + ->and(RadarRules::parseKey('notice:alert_x'))->toBe(['notice', 'alert_x']) + ->and(RadarRules::parseKey('nope:issue_1'))->toBeNull() + ->and(RadarRules::parseKey('issue_open:'))->toBeNull() + ->and(RadarRules::parseKey('issue_open'))->toBeNull() + ->and(RadarRules::parseKey(null))->toBeNull() + ->and(RadarRules::stateTypes())->not->toContain('notice') + ->and(RadarRules::bucket(null, $now))->toBe('none') + ->and(RadarRules::bucket(Carbon::parse('2026-09-15 08:34:59', 'UTC'), $now))->toBe('overdue') + ->and(RadarRules::bucket(Carbon::parse('2026-09-15 23:00:00', 'UTC'), $now))->toBe('today') + ->and(RadarRules::bucket(Carbon::parse('2026-09-22 08:35:00', 'UTC'), $now))->toBe('week') + ->and(RadarRules::bucket(Carbon::parse('2026-09-22 08:35:01', 'UTC'), $now))->toBe('later') + ->and(RadarRules::dueLabel(Carbon::parse('2026-09-15 08:00:00', 'UTC'), $now))->toBe('35m overdue') + ->and(RadarRules::dueLabel(Carbon::parse('2026-09-15 03:00:00', 'UTC'), $now))->toBe('5h overdue') + ->and(RadarRules::dueLabel(Carbon::parse('2026-10-01 03:00:00', 'UTC'), $now))->toBe('1 Oct') + ->and(RadarRules::dueInWords(Carbon::parse('2026-09-16 03:00:00', 'UTC'), $now))->toBe('tomorrow') + ->and(RadarRules::minutesWords(125))->toBe('2h 5m') + ->and(RadarRules::minutesWords(120))->toBe('2h') + ->and(RadarRules::carbon('not a date'))->toBeNull() + ->and(RadarRules::carbon(new DateTimeImmutable('2026-09-15 00:00:00'))->toDateString())->toBe('2026-09-15'); +}); + +test('normalizeState turns an ended snooze back into open or acknowledged', function () { + $now = fleetOpsRadarNow(); + + expect(RadarRules::normalizeState(null, $now)['status'])->toBe('open') + ->and(RadarRules::normalizeState(['status' => 'snoozed', 'snoozed_until' => '2026-09-15T08:00:00+00:00'], $now)['status'])->toBe('open') + ->and(RadarRules::normalizeState(['status' => 'snoozed', 'snoozed_until' => '2026-09-15T08:00:00+00:00', 'acknowledged_at' => 'x'], $now)['status'])->toBe('acknowledged') + ->and(RadarRules::normalizeState(['status' => 'open', 'snoozed_until' => '2026-09-15T09:00:00+00:00'], $now)['status'])->toBe('snoozed') + ->and(RadarRules::normalizeState(['status' => 'resolved'], $now)['status'])->toBe('resolved'); +}); From 1ab213dc9bbd9f4489f3b2d10b15c77015254904 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 06:36:56 +0800 Subject: [PATCH 02/12] Write the morning brief and lay Radar out on a time scale The brief is arithmetic, not a black box: each category row scores its open gaps by severity (capped per rule), the overall score is the mean, and yesterday's score comes from a per-company setting so the delta is real. Three or four sentences name the records behind the morning with links, and the decision cards each describe one call the console can make in a click (raise the work order for a failed inspection, open a work order for an overdue schedule, put an idle vehicle on a driver without one, match a fuel transaction to the vehicle its card belongs to), with the undo where there is one. The agenda answers the same items placed on the next 24 hours or 7 days: an overdue band left of now, four lanes, a later rail, and an anytime tray for undated work, which a drop gives a time via `planned_at`. Every live shift becomes a bar on the shifts lane; a shift ending with orders still assigned gets a handover card naming the nearest on-shift driver with capacity, and an endpoint extends a shift by the hour. Also: items can be narrowed to one category or to the caller's own assignments, and driver subjects carry a phone for the Call action. --- .../Internal/v1/RadarController.php | 222 +++++++- server/src/Support/Radar/RadarAgenda.php | 375 +++++++++++++ server/src/Support/Radar/RadarBriefing.php | 504 ++++++++++++++++++ server/src/Support/Radar/RadarItemState.php | 1 + server/src/Support/Radar/RadarRules.php | 28 + server/src/routes.php | 4 + .../Http/Internal/RadarControllerTest.php | 111 ++++ server/tests/RadarRoutesTest.php | 6 +- server/tests/Unit/Support/RadarAgendaTest.php | 165 ++++++ .../tests/Unit/Support/RadarBriefingTest.php | 183 +++++++ server/tests/Unit/Support/RadarRulesTest.php | 2 + 11 files changed, 1599 insertions(+), 2 deletions(-) create mode 100644 server/src/Support/Radar/RadarAgenda.php create mode 100644 server/src/Support/Radar/RadarBriefing.php create mode 100644 server/tests/Unit/Support/RadarAgendaTest.php create mode 100644 server/tests/Unit/Support/RadarBriefingTest.php diff --git a/server/src/Http/Controllers/Internal/v1/RadarController.php b/server/src/Http/Controllers/Internal/v1/RadarController.php index fe56dc370..dbf3d08a9 100644 --- a/server/src/Http/Controllers/Internal/v1/RadarController.php +++ b/server/src/Http/Controllers/Internal/v1/RadarController.php @@ -9,17 +9,21 @@ use Fleetbase\FleetOps\Models\InspectionSubmission; use Fleetbase\FleetOps\Models\Issue; use Fleetbase\FleetOps\Models\MaintenanceSchedule; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Part; use Fleetbase\FleetOps\Models\Trailer; use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Models\WorkOrder; use Fleetbase\FleetOps\Support\LiveOrderQuery; +use Fleetbase\FleetOps\Support\Radar\RadarAgenda; +use Fleetbase\FleetOps\Support\Radar\RadarBriefing; use Fleetbase\FleetOps\Support\Radar\RadarItemState; use Fleetbase\FleetOps\Support\Radar\RadarRules; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Models\Alert; use Fleetbase\Models\CompanyUser; use Fleetbase\Models\ScheduleItem; +use Fleetbase\Models\Setting; use Fleetbase\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -61,8 +65,12 @@ public function items(Request $request): JsonResponse } $items = RadarRules::forPills($items, $pills); + $items = RadarRules::forCategory($items, $request->input('category')); $items = RadarRules::forFleet($items, $request->input('fleet')); $items = RadarRules::search($items, $request->input('query')); + if ($request->input('assigned') === 'me') { + $items = RadarRules::forAssignee($items, $this->actor($request)?->uuid); + } $page = RadarRules::paginate($items, (int) $request->input('page', 1), (int) $request->input('limit', 50)); return response()->json([ @@ -93,6 +101,114 @@ public function summary(Request $request): JsonResponse ]); } + /** + * The morning brief: score, category rows, a few sentences and the + * decisions that can be made in one click. + */ + public function briefing(Request $request): JsonResponse + { + $company = $this->companyUuid($request); + $now = $this->now(); + $built = $this->buildItems($company, $now, false); + $history = $this->scoreHistory($company); + $brief = RadarBriefing::build($built['items'], $now, $history); + + $this->rememberScore($company, RadarBriefing::pushHistory($history, $brief['score']['value'], $now)); + + $since = $now->copy()->subDay(); + $resolved = $this->resolvedSince($company, $since, $now); + $rolled = array_filter($built['items'], function ($item) use ($since, $now) { + $triggered = RadarRules::carbon($item['state']['triggered_at'] ?? null); + + return RadarRules::isOpen($item, $now) && $triggered && $triggered->lt($since); + }); + + return response()->json($brief + [ + 'yesterday' => ['closed' => count($resolved), 'rolled_over' => count($rolled)], + 'generated_at' => $now->toIso8601String(), + 'sources' => $built['errors'], + ]); + } + + /** + * The Agenda view: items on a time scale, with the roster's shifts and + * the handover cards. + */ + public function agenda(Request $request): JsonResponse + { + $company = $this->companyUuid($request); + $now = $this->now(); + $window = (string) $request->input('window', '24h'); + $built = $this->buildItems($company, $now, false); + $items = RadarRules::forFleet($built['items'], $request->input('fleet')); + $shifts = $built['sources']['shifts'] ?? []; + $drivers = $built['sources']['drivers'] ?? []; + $orders = $this->loadOrdersForHandovers($company, $items); + + return response()->json(RadarAgenda::build($items, $shifts, $window, $now, $drivers, $orders) + [ + 'summary' => $built['summary'], + 'generated_at' => $now->toIso8601String(), + 'sources' => $built['errors'], + ]); + } + + /** + * One handover card, for the list view's Cover / Reassign actions. + */ + public function handoverSuggest(Request $request, string $key): JsonResponse + { + $company = $this->companyUuid($request); + $now = $this->now(); + $built = $this->buildItems($company, $now, false); + $orders = $this->loadOrdersForHandovers($company, $built['items']); + $cards = RadarAgenda::handovers( + array_values(array_filter($built['items'], fn ($item) => $item['key'] === $key && RadarRules::isOpen($item, $now))), + $built['sources']['shifts'] ?? [], + $built['sources']['drivers'] ?? [], + $orders, + $now, + ['shift_handover', 'shift_late_start'] + ); + + if (!$cards) { + return response()->json(['error' => 'No handover is open for that item.'], 404); + } + + return response()->json(['handover' => $cards[0], 'generated_at' => $now->toIso8601String()]); + } + + /** + * Push a shift's end out by some minutes, for "Extend shift 1h". + */ + public function extendShift(Request $request, string $id): JsonResponse + { + $minutes = (int) $request->input('minutes', 60); + if ($minutes < 1 || $minutes > 24 * 60) { + return response()->json(['error' => 'minutes must be between 1 and 1440.'], 422); + } + + $company = $this->companyUuid($request); + $shift = $this->findShift($company, $id); + if (!$shift) { + return response()->json(['error' => 'Shift not found.'], 404); + } + + $end = RadarRules::carbon($shift->end_at) ?? $this->now(); + $shift->end_at = $end->copy()->addMinutes($minutes); + $this->saveShift($shift); + + return response()->json([ + 'shift' => [ + 'uuid' => $shift->uuid, + 'public_id' => $shift->public_id, + 'start_at' => RadarRules::carbon($shift->start_at)?->toIso8601String(), + 'end_at' => RadarRules::carbon($shift->end_at)?->toIso8601String(), + 'status' => $shift->status, + ], + 'generated_at' => $this->now()->toIso8601String(), + ]); + } + public function acknowledge(Request $request, string $key): JsonResponse { return $this->act($request, $key, function (Alert $row, array $item) use ($request) { @@ -322,7 +438,8 @@ protected function buildItems(?string $company, Carbon $now, bool $reconcile): a $errors['states'] = $e->getMessage(); } - $built = RadarRules::build($sources, $now); + $built = RadarRules::build($sources, $now); + $built['sources'] = $sources; if ($reconcile && empty($errors)) { try { @@ -754,6 +871,75 @@ protected function findCompanyUser(?string $company, string $id): ?User return $isMember ? $user : null; } + /** + * Active orders for the drivers with a handover open, keyed by driver + * uuid: what the cover driver would take over. + * + * @return array + */ + protected function loadOrdersForHandovers(?string $company, array $items): array + { + $driverUuids = []; + foreach ($items as $item) { + if (in_array($item['rule'] ?? null, ['shift_handover', 'shift_late_start'], true) && !empty($item['subject']['uuid'])) { + $driverUuids[] = $item['subject']['uuid']; + } + } + if (!$driverUuids) { + return []; + } + + try { + $orders = $this->scoped(Order::query(), $company) + ->whereIn('driver_assigned_uuid', array_unique($driverUuids)) + ->whereNotIn('status', LiveOrderQuery::$baseExcludedStatuses) + ->with(['payload.dropoff']) + ->orderBy('scheduled_at') + ->get(); + } catch (\Throwable $e) { + if (function_exists('report')) { + report($e); + } + + return []; + } + + $byDriver = []; + foreach ($orders as $order) { + $dropoff = $order->payload?->dropoff; + $byDriver[$order->driver_assigned_uuid][] = [ + 'uuid' => $order->uuid, + 'public_id' => $order->public_id, + 'status' => $order->status, + 'destination' => $dropoff?->name ?: ($dropoff?->street1 ?: null), + 'ends_at' => RadarRules::carbon($order->time_window_end ?? $order->scheduled_at)?->toIso8601String(), + ]; + } + + return $byDriver; + } + + protected function findShift(?string $company, string $id): ?ScheduleItem + { + $driverUuids = $this->scoped(Driver::query(), $company)->pluck('uuid')->all(); + if (!$driverUuids) { + return null; + } + + return ScheduleItem::query() + ->whereIn('assignee_type', ['fleet-ops:driver', Driver::class]) + ->whereIn('assignee_uuid', $driverUuids) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + } + + protected function saveShift(ScheduleItem $shift): void + { + $shift->save(); + } + // ------------------------------------------------------------------ // State (thin wrappers so a test can stand in for the alerts table) // ------------------------------------------------------------------ @@ -793,6 +979,39 @@ protected function notices(?string $company): array return RadarItemState::notices($company); } + /** + * Past days' scores, kept per company so the brief can say "vs yesterday". + * + * @return array + */ + protected function scoreHistory(?string $company): array + { + if (!$company) { + return []; + } + + try { + $history = Setting::lookup('company.' . $company . '.fleet-ops.radar.score_history', []); + } catch (\Throwable) { + return []; + } + + return is_array($history) ? array_values($history) : []; + } + + protected function rememberScore(?string $company, array $history): void + { + if (!$company) { + return; + } + + try { + Setting::configure('company.' . $company . '.fleet-ops.radar.score_history', $history); + } catch (\Throwable) { + // The brief still renders without a delta. + } + } + protected function createNotice(array $attributes): Alert { return Alert::create($attributes); @@ -875,6 +1094,7 @@ protected function subjectFor(?Model $model): ?array 'public_id' => $model->public_id ?? null, 'label' => $label ?: ($model->public_id ?? $type), 'photo_url' => $model->photo_url ?? null, + 'phone' => $type === 'driver' ? ($model->phone ?? null) : null, ]; } diff --git a/server/src/Support/Radar/RadarAgenda.php b/server/src/Support/Radar/RadarAgenda.php new file mode 100644 index 000000000..c81cc25b5 --- /dev/null +++ b/server/src/Support/Radar/RadarAgenda.php @@ -0,0 +1,375 @@ + 24, '7d' => 168]; + + public const LANES = [RadarRules::LANE_SHIFTS, RadarRules::LANE_MAINTENANCE, RadarRules::LANE_EXPIRIES, RadarRules::LANE_NOTICES]; + + /** Where an undated item goes once someone gives it a time. */ + public const LANE_BY_CATEGORY = [ + RadarRules::CATEGORY_STAFFING => RadarRules::LANE_SHIFTS, + RadarRules::CATEGORY_MAINTENANCE => RadarRules::LANE_MAINTENANCE, + RadarRules::CATEGORY_INSPECTIONS => RadarRules::LANE_MAINTENANCE, + RadarRules::CATEGORY_ISSUES => RadarRules::LANE_MAINTENANCE, + RadarRules::CATEGORY_PARTS => RadarRules::LANE_MAINTENANCE, + RadarRules::CATEGORY_CONNECTIVITY => RadarRules::LANE_MAINTENANCE, + RadarRules::CATEGORY_COMPLIANCE => RadarRules::LANE_EXPIRIES, + RadarRules::CATEGORY_FUEL => RadarRules::LANE_EXPIRIES, + RadarRules::CATEGORY_NOTICES => RadarRules::LANE_NOTICES, + ]; + + /** A cover driver must still be on shift this long after the handover. */ + public const COVER_MARGIN_HOURS = 2; + + /** Orders a driver can carry in a day when the driver record does not say. */ + public const DEFAULT_CAPACITY = 6; + + /** + * @param array $items RadarRules items with state merged + * @param array $shifts shift rows (see RadarController::loadShifts) + * @param array $drivers driver rows (uuid, public_id, name, online, location, active_orders, max_daily_orders) + * @param array $orders active orders keyed by driver uuid: [{public_id, uuid, destination, ends_at}] + */ + public static function build(array $items, array $shifts, string $window, Carbon $now, array $drivers = [], array $orders = []): array + { + $hours = self::WINDOWS[$window] ?? self::WINDOWS['24h']; + $start = $now->copy()->startOfHour(); + $end = $start->copy()->addHours($hours); + $open = array_values(array_filter($items, fn ($item) => RadarRules::isOpen($item, $now))); + + $lanes = array_fill_keys(self::LANES, []); + $overdue = []; + $later = []; + $anytime = []; + + foreach ($open as $item) { + $placed = self::place($item, $start, $end, $now); + + switch ($placed['zone']) { + case 'overdue': + $overdue[] = $placed['entry']; + break; + case 'lane': + $lanes[$placed['entry']['lane']][] = $placed['entry']; + break; + case 'later': + $later[] = $placed['entry']; + break; + default: + $anytime[] = $placed['entry']; + } + } + + // Every live shift is a bar on the shifts lane, gap or not, so the + // lane reads as the day's roster. + $byDriver = []; + foreach ($open as $item) { + if (($item['category'] ?? null) === RadarRules::CATEGORY_STAFFING && !empty($item['subject']['uuid'])) { + $byDriver[$item['subject']['uuid']][] = $item; + } + } + foreach ($shifts as $shift) { + $bar = self::shiftBar($shift, $byDriver, $start, $end, $now); + if ($bar) { + $lanes[RadarRules::LANE_SHIFTS][] = $bar; + } + } + + usort($overdue, fn ($a, $b) => strcmp((string) $a['at'], (string) $b['at'])); + usort($later, fn ($a, $b) => strcmp((string) $a['at'], (string) $b['at'])); + foreach ($lanes as &$entries) { + usort($entries, fn ($a, $b) => strcmp((string) $a['at'], (string) $b['at'])); + } + unset($entries); + + return [ + 'now' => $now->toIso8601String(), + 'window' => [ + 'key' => isset(self::WINDOWS[$window]) ? $window : '24h', + 'hours' => $hours, + 'start_at' => $start->toIso8601String(), + 'end_at' => $end->toIso8601String(), + 'ticks' => self::ticks($start, $hours), + 'now_pct' => self::pct($now, $start, $hours), + ], + 'lanes' => $lanes, + 'overdue' => $overdue, + 'later' => $later, + 'anytime' => $anytime, + 'handovers' => self::handovers($open, $shifts, $drivers, $orders, $now), + 'counts' => [ + 'overdue' => count($overdue), + 'later' => count($later), + 'anytime' => count($anytime), + 'shifts' => count($lanes[RadarRules::LANE_SHIFTS]), + ], + ]; + } + + /** + * Which zone an item lands in, and the entry the lane draws. + * + * @return array{zone: string, entry: array} + */ + public static function place(array $item, Carbon $start, Carbon $end, Carbon $now): array + { + $planned = RadarRules::carbon($item['state']['planned_at'] ?? null); + $due = RadarRules::carbon($item['due_at'] ?? null); + $at = $planned ?? $due; + + // A shift gap with no due date is happening now: it sits on the + // shifts lane from the moment the window opened. + if ($at === null && !empty($item['window']['start_at'])) { + $windowStart = RadarRules::carbon($item['window']['start_at']); + $at = $windowStart ? $windowStart->max($now) : null; + } + + $entry = self::entry($item, $at, $planned !== null, $start, $end); + + if ($at === null) { + return ['zone' => 'anytime', 'entry' => $entry]; + } + if ($at->lt($now) && !$planned) { + return ['zone' => 'overdue', 'entry' => $entry]; + } + if ($at->gt($end)) { + return ['zone' => 'later', 'entry' => $entry]; + } + + return ['zone' => 'lane', 'entry' => $entry]; + } + + /** + * The handover cards: shifts ending soon with orders still assigned, + * each with the best cover driver we can find. + */ + public static function handovers(array $open, array $shifts, array $drivers, array $orders, Carbon $now, array $rules = ['shift_handover']): array + { + $cards = []; + + foreach ($open as $item) { + if (!in_array($item['rule'] ?? null, $rules, true)) { + continue; + } + + $driverUuid = $item['subject']['uuid'] ?? null; + $end = RadarRules::carbon($item['window']['end_at'] ?? null); + $driverRows = array_column($drivers, null, 'uuid'); + $driver = $driverRows[$driverUuid] ?? null; + $driverOrders = array_values($orders[$driverUuid] ?? []); + $suggested = $end ? self::suggestCover($driverUuid, $driver, $end, $shifts, $driverRows, $now) : null; + + $cards[] = [ + 'key' => $item['key'], + 'driver' => $item['subject'], + 'rule' => $item['rule'], + 'shift' => ($item['window'] ?? []) + ['uuid' => $item['source']['uuid'] ?? null, 'public_id' => $item['source']['public_id'] ?? null, 'minutes_left' => $end ? max(0, $now->diffInMinutes($end, false)) : null], + 'orders' => array_map(fn ($order) => $order + ['finishes_after_shift' => self::finishesAfter($order, $end)], $driverOrders), + 'active_orders' => (int) ($item['source']['active_orders'] ?? count($driverOrders)), + 'suggested' => $suggested, + 'record' => $item['record'] ?? null, + ]; + } + + return $cards; + } + + /** + * The on-shift driver best placed to take the orders: still on shift + * past the handover, online, nearest, with capacity to spare. + */ + public static function suggestCover(?string $driverUuid, ?array $driver, Carbon $shiftEnd, array $shifts, array $driverRows, Carbon $now): ?array + { + $needUntil = $shiftEnd->copy()->addHours(self::COVER_MARGIN_HOURS); + $candidates = []; + + foreach ($shifts as $shift) { + $uuid = $shift['driver']['uuid'] ?? null; + if (!$uuid || $uuid === $driverUuid || !in_array($shift['status'] ?? 'scheduled', RadarRules::SHIFT_LIVE_STATUSES, true)) { + continue; + } + + $start = RadarRules::carbon($shift['start_at'] ?? null); + $end = RadarRules::carbon($shift['end_at'] ?? null); + if (!$start || !$end || $start->gt($shiftEnd) || $end->lt($needUntil)) { + continue; + } + + $row = $driverRows[$uuid] ?? []; + $capacity = (int) ($row['max_daily_orders'] ?? self::DEFAULT_CAPACITY) ?: self::DEFAULT_CAPACITY; + $active = (int) ($row['active_orders'] ?? $shift['active_orders'] ?? 0); + if ($active >= $capacity) { + continue; + } + + $distance = self::distanceKm($driver['location'] ?? null, $row['location'] ?? null); + + $candidates[] = [ + 'driver' => $shift['driver'], + 'on_shift_until' => $end->toIso8601String(), + 'online' => (bool) ($shift['driver_online'] ?? $row['online'] ?? false), + 'distance_km' => $distance, + 'active_orders' => $active, + 'capacity' => $capacity, + 'capacity_label' => sprintf('%d of %d orders', $active, $capacity), + ]; + } + + if (!$candidates) { + return null; + } + + usort($candidates, function ($a, $b) { + $online = ($b['online'] ? 1 : 0) <=> ($a['online'] ? 1 : 0); + if ($online !== 0) { + return $online; + } + $distance = ($a['distance_km'] ?? PHP_FLOAT_MAX) <=> ($b['distance_km'] ?? PHP_FLOAT_MAX); + if ($distance !== 0) { + return $distance; + } + + return $a['active_orders'] <=> $b['active_orders']; + }); + + return $candidates[0]; + } + + /** + * Great-circle distance in km between two {lat, lng} points, or null. + */ + public static function distanceKm(?array $from, ?array $to): ?float + { + if (!isset($from['lat'], $from['lng'], $to['lat'], $to['lng'])) { + return null; + } + + $earth = 6371.0; + $dLat = deg2rad((float) $to['lat'] - (float) $from['lat']); + $dLng = deg2rad((float) $to['lng'] - (float) $from['lng']); + $a = sin($dLat / 2) ** 2 + cos(deg2rad((float) $from['lat'])) * cos(deg2rad((float) $to['lat'])) * sin($dLng / 2) ** 2; + + return round($earth * 2 * atan2(sqrt($a), sqrt(1 - $a)), 1); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + protected static function entry(array $item, ?Carbon $at, bool $planned, Carbon $start, Carbon $end): array + { + $lane = $item['lane'] ?? RadarRules::LANE_ANYTIME; + if ($lane === RadarRules::LANE_ANYTIME) { + $lane = self::LANE_BY_CATEGORY[$item['category'] ?? ''] ?? RadarRules::LANE_MAINTENANCE; + } + $hours = max(1, $start->diffInHours($end)); + + return [ + 'kind' => 'item', + 'key' => $item['key'], + 'rule' => $item['rule'], + 'chip' => $item['chip'] ?? null, + 'category' => $item['category'] ?? null, + 'severity' => $item['severity'] ?? 'info', + 'title' => $item['title'], + 'subject' => $item['subject'] ?? null, + 'meta_line' => $item['meta_line'] ?? null, + 'lane' => $lane, + 'at' => $at?->toIso8601String(), + 'label' => $at ? $at->format($start->diffInHours($end) > 24 ? 'D H:i' : 'H:i') : null, + 'planned' => $planned, + 'pct' => $at ? self::pct($at, $start, $hours) : null, + 'state' => $item['state'] ?? null, + 'actions' => $item['actions'] ?? [], + 'record' => $item['record'] ?? null, + ]; + } + + protected static function shiftBar(array $shift, array $byDriver, Carbon $start, Carbon $end, Carbon $now): ?array + { + $from = RadarRules::carbon($shift['start_at'] ?? null); + $to = RadarRules::carbon($shift['end_at'] ?? null); + $driver = $shift['driver'] ?? null; + if (!$from || !$to || !$driver || $to->lt($start) || $from->gt($end)) { + return null; + } + + $hours = max(1, $start->diffInHours($end)); + $gaps = $byDriver[$driver['uuid'] ?? ''] ?? []; + $handover = null; + $worst = null; + foreach ($gaps as $gap) { + if ($gap['rule'] === 'shift_handover') { + $handover = $gap['key']; + } + if ($worst === null || self::severityRank($gap['severity']) > self::severityRank($worst)) { + $worst = $gap['severity']; + } + } + + $status = $shift['status'] ?? 'scheduled'; + $state = $to->lte($now) ? 'ended' : ($from->gt($now) ? 'upcoming' : (!empty($shift['driver_online']) || $status === 'in_progress' ? 'on_shift' : 'not_online')); + + return [ + 'kind' => 'shift', + 'key' => 'shift:' . ($shift['public_id'] ?? $shift['uuid'] ?? ($driver['public_id'] ?? '')), + 'driver' => $driver, + 'status' => $status, + 'state' => $state, + 'at' => $from->toIso8601String(), + 'end_at' => $to->toIso8601String(), + 'label' => $from->format('H:i') . '–' . $to->format('H:i'), + 'pct' => self::pct($from->max($start), $start, $hours), + 'width_pct' => max(1.0, round(($from->max($start)->diffInMinutes($to->min($end)) / 60) / $hours * 100, 2)), + 'lane' => RadarRules::LANE_SHIFTS, + 'severity' => $worst, + 'gap_keys' => array_column($gaps, 'key'), + 'handover_key' => $handover, + 'active_orders' => (int) ($shift['active_orders'] ?? 0), + 'vehicle_uuid' => $shift['driver_vehicle_uuid'] ?? null, + ]; + } + + protected static function finishesAfter(array $order, ?Carbon $shiftEnd): bool + { + $ends = RadarRules::carbon($order['ends_at'] ?? null); + + return (bool) ($shiftEnd && $ends && $ends->gt($shiftEnd)); + } + + protected static function ticks(Carbon $start, int $hours): array + { + $ticks = []; + $step = $hours > 24 ? 24 : 2; + for ($offset = 0; $offset <= $hours; $offset += $step) { + $at = $start->copy()->addHours($offset); + $ticks[] = ['at' => $at->toIso8601String(), 'label' => $hours > 24 ? $at->format('D j') : $at->format('H'), 'pct' => round($offset / $hours * 100, 2)]; + } + + return $ticks; + } + + protected static function pct(Carbon $at, Carbon $start, int $hours): float + { + return round(max(0, min(100, $start->diffInMinutes($at, false) / 60 / $hours * 100)), 2); + } + + protected static function severityRank(?string $severity): int + { + return [RadarRules::SEVERITY_CRITICAL => 3, RadarRules::SEVERITY_WARNING => 2, RadarRules::SEVERITY_INFO => 1][$severity] ?? 0; + } +} diff --git a/server/src/Support/Radar/RadarBriefing.php b/server/src/Support/Radar/RadarBriefing.php new file mode 100644 index 000000000..db88b0a63 --- /dev/null +++ b/server/src/Support/Radar/RadarBriefing.php @@ -0,0 +1,504 @@ + 'Maintenance', + RadarRules::CATEGORY_INSPECTIONS => 'Inspections', + RadarRules::CATEGORY_STAFFING => 'Staffing', + RadarRules::CATEGORY_COMPLIANCE => 'Compliance', + RadarRules::CATEGORY_FUEL => 'Fuel', + RadarRules::CATEGORY_PARTS => 'Parts', + RadarRules::CATEGORY_CONNECTIVITY => 'Connectivity', + RadarRules::CATEGORY_ISSUES => 'Issues', + ]; + + /** Points a gap takes off its category, by severity. */ + public const WEIGHTS = [ + RadarRules::SEVERITY_CRITICAL => 10, + RadarRules::SEVERITY_WARNING => 4, + RadarRules::SEVERITY_INFO => 1, + ]; + + /** The most one rule can take off a category, so one runaway rule cannot zero it. */ + public const RULE_CAP = 40; + + /** How the score rows describe a rule's count. */ + public const GAP_LABELS = [ + 'maintenance_overdue' => ['overdue', 'overdue'], + 'maintenance_due_soon' => ['due this week', 'due this week'], + 'inspection_due' => ['inspection due', 'inspections due'], + 'inspection_failed' => ['failed, no follow-up', 'failed, no follow-up'], + 'inspection_unresolved' => ['follow-up open', 'follow-ups open'], + 'inspection_draft' => ['draft never filed', 'drafts never filed'], + 'inspection_link_pending' => ['link expiring unused', 'links expiring unused'], + 'vehicle_inspection_failed' => ['vehicle failed inspection', 'vehicles failed inspection'], + 'work_order_overdue' => ['work order overdue', 'work orders overdue'], + 'work_order_blocked' => ['work order blocked', 'work orders blocked'], + 'issue_open' => ['open', 'open'], + 'shift_late_start' => ['late start', 'late starts'], + 'shift_no_vehicle' => ['on shift, no vehicle', 'on shift, no vehicle'], + 'shift_handover' => ['handover', 'handovers'], + 'driver_without_vehicle' => ['driver unassigned', 'drivers unassigned'], + 'vehicle_without_driver' => ['vehicle idle', 'vehicles idle'], + 'vehicle_without_device' => ['vehicle untracked', 'vehicles untracked'], + 'device_unattached' => ['device spare', 'devices spare'], + 'license_expiring' => ['licence expiring', 'licences expiring'], + 'lease_expiring' => ['lease expiring', 'leases expiring'], + 'fuel_unmatched' => ['unmatched transaction', 'unmatched transactions'], + 'part_low_stock' => ['below reorder point', 'below reorder point'], + 'notice' => ['notice', 'notices'], + ]; + + /** How many decision cards the brief offers at most. */ + public const MAX_DECISIONS = 9; + + /** + * @param array $items every item RadarRules built, states merged + * @param array $history [{date: 'Y-m-d', score: int}] previous days + * + * @return array{score: array, categories: array, brief: array, decisions: array, open: int} + */ + public static function build(array $items, Carbon $now, array $history = []): array + { + $open = array_values(array_filter($items, fn ($item) => RadarRules::isOpen($item, $now))); + $categories = self::categories($open); + $score = self::score($categories); + + return [ + 'score' => [ + 'value' => $score, + 'delta' => self::delta($score, $history, $now), + 'summary' => self::summaryLine($open), + ], + 'categories' => $categories, + 'brief' => self::brief($open, $now), + 'decisions' => self::decisions($open, $now), + 'open' => count($open), + ]; + } + + /** + * One row per category: its score, the counts driving it, and the + * filter that shows exactly those items. + */ + public static function categories(array $open): array + { + $rows = []; + + foreach (self::CATEGORIES as $category => $label) { + $members = array_values(array_filter($open, fn ($item) => ($item['category'] ?? null) === $category)); + $byRule = []; + foreach ($members as $item) { + $byRule[$item['rule']][] = $item; + } + + $penalty = 0; + $gaps = []; + foreach ($byRule as $rule => $ruleItems) { + $rulePenalty = 0; + foreach ($ruleItems as $item) { + $rulePenalty += self::WEIGHTS[$item['severity']] ?? 1; + } + $penalty += min(self::RULE_CAP, $rulePenalty); + + $count = count($ruleItems); + $labels = self::GAP_LABELS[$rule] ?? [$rule, $rule]; + $gaps[] = ['rule' => $rule, 'count' => $count, 'label' => $count . ' ' . ($count === 1 ? $labels[0] : $labels[1])]; + } + + usort($gaps, fn ($a, $b) => $b['count'] <=> $a['count']); + + $rows[] = [ + 'key' => $category, + 'label' => $label, + 'score' => max(0, 100 - $penalty), + 'count' => count($members), + 'critical' => count(array_filter($members, fn ($item) => $item['severity'] === RadarRules::SEVERITY_CRITICAL)), + 'gaps' => $gaps, + 'filter' => ['category' => $category], + ]; + } + + return $rows; + } + + /** + * The mean of the category scores, rounded. + */ + public static function score(array $categories): int + { + if (!$categories) { + return 100; + } + + $total = array_sum(array_column($categories, 'score')); + + return (int) round($total / count($categories)); + } + + /** + * Today's score against the most recent earlier day in the history. + */ + public static function delta(int $score, array $history, Carbon $now): ?int + { + $today = $now->toDateString(); + $previous = null; + + foreach ($history as $entry) { + $date = $entry['date'] ?? null; + if (!$date || $date >= $today || !isset($entry['score'])) { + continue; + } + if ($previous === null || $date > $previous['date']) { + $previous = $entry; + } + } + + return $previous === null ? null : $score - (int) $previous['score']; + } + + /** + * Append today's score to the history, replacing today's earlier entry + * and keeping the last 30 days. + */ + public static function pushHistory(array $history, int $score, Carbon $now): array + { + $today = $now->toDateString(); + $history = array_values(array_filter($history, fn ($entry) => ($entry['date'] ?? null) !== $today && isset($entry['date'], $entry['score']))); + $history[] = ['date' => $today, 'score' => $score]; + usort($history, fn ($a, $b) => strcmp($a['date'], $b['date'])); + + return array_slice($history, -30); + } + + /** + * "3 overdue · 12 due this week · 5 unassigned · 8 open issues". + */ + public static function summaryLine(array $open): string + { + $counts = RadarRules::counts($open); + $parts = []; + foreach (['overdue' => 'overdue', 'due_week' => 'due this week', 'unassigned' => 'unassigned', 'issues' => 'open issues', 'inspections' => 'inspection items'] as $pill => $label) { + if (($counts[$pill] ?? 0) > 0) { + $parts[] = $counts[$pill] . ' ' . $label; + } + } + + return implode(' · ', $parts) ?: 'nothing open'; + } + + /** + * Up to four sentences, each a list of segments: plain text, or text + * with the route of the record it names. + */ + public static function brief(array $open, Carbon $now): array + { + if (!$open) { + return [[self::text('All clear: nothing needs a decision this morning.')]]; + } + + $sentences = array_values(array_filter([ + self::maintenanceSentence($open), + self::staffingSentence($open), + self::housekeepingSentence($open), + ])); + + $sentences[] = [self::text(count($sentences) ? 'Everything else is routine.' : sprintf('%d item%s open, none critical.', count($open), count($open) === 1 ? '' : 's'))]; + + return $sentences; + } + + /** + * The decisions that can be made in one click, most urgent first. + */ + public static function decisions(array $open, Carbon $now): array + { + $decisions = array_merge( + self::inspectionDecisions($open), + self::workOrderDecisions($open), + self::vehicleDecisions($open), + self::fuelDecisions($open), + ); + + $order = [RadarRules::SEVERITY_CRITICAL => 0, RadarRules::SEVERITY_WARNING => 1, RadarRules::SEVERITY_INFO => 2]; + usort($decisions, fn ($a, $b) => ($order[$a['severity']] ?? 9) <=> ($order[$b['severity']] ?? 9)); + + return array_slice($decisions, 0, self::MAX_DECISIONS); + } + + // ------------------------------------------------------------------ + // Sentences + // ------------------------------------------------------------------ + + protected static function maintenanceSentence(array $open): ?array + { + $overdue = self::ofRule($open, ['maintenance_overdue', 'work_order_overdue', 'inspection_due'], fn ($item) => $item['due_bucket'] === 'overdue'); + $failed = self::ofRule($open, ['inspection_failed']); + $segments = []; + + if ($overdue) { + $named = array_slice($overdue, 0, 2); + $segments[] = self::text(sprintf('%d %s past due: ', count($overdue), count($overdue) === 1 ? 'job is' : 'jobs are')); + foreach ($named as $index => $item) { + if ($index > 0) { + $segments[] = self::text(count($named) === 2 && $index === 1 ? ' and ' : ', '); + } + $segments[] = self::link($item['subject']['label'] ?? 'a record', $item); + $segments[] = self::text(' (' . ($item['due_label'] ?? $item['title']) . ')'); + } + if (count($overdue) > 2) { + $segments[] = self::text(sprintf(' and %d more', count($overdue) - 2)); + } + $segments[] = self::text('.'); + } + + if ($failed) { + $first = $failed[0]; + $segments[] = self::text(($segments ? ' ' : '') . sprintf('%d failed inspection%s %s no follow-up yet', count($failed), count($failed) === 1 ? '' : 's', count($failed) === 1 ? 'has' : 'have')); + $segments[] = self::text(': '); + $segments[] = self::link($first['subject']['label'] ?? 'a vehicle', $first); + $segments[] = self::text(' ' . ($first['meta_line'] ?? '') . '.'); + } + + return $segments ?: null; + } + + protected static function staffingSentence(array $open): ?array + { + $segments = []; + $clauses = []; + + foreach (self::ofRule($open, ['shift_late_start']) as $item) { + $clauses[] = [self::link($item['subject']['label'] ?? 'a driver', $item), self::text(' is ' . ($item['meta_line'] ?? 'late') . ' for a shift that started ' . self::hourFromWindow($item))]; + } + foreach (self::ofRule($open, ['shift_no_vehicle']) as $item) { + $clauses[] = [self::link($item['subject']['label'] ?? 'a driver', $item), self::text(' has been on shift ' . ($item['meta_line'] ?? '') . ' without a vehicle')]; + } + foreach (self::ofRule($open, ['shift_handover']) as $item) { + $orders = (int) ($item['source']['active_orders'] ?? 0); + $clauses[] = [self::link($item['subject']['label'] ?? 'a driver', $item), self::text(sprintf(' ends at %s still holding %d order%s', self::hourFromWindow($item, 'end_at'), $orders, $orders === 1 ? '' : 's'))]; + } + + $idle = self::ofRule($open, ['vehicle_without_driver']); + if ($idle && $clauses) { + $first = $idle[0]; + $clauses[] = [self::link($first['subject']['label'] ?? 'a vehicle', $first), self::text(sprintf(' is idle%s', count($idle) > 1 ? sprintf(' with %d other vehicle%s', count($idle) - 1, count($idle) === 2 ? '' : 's') : ''))]; + } + + if (!$clauses) { + return null; + } + + $clauses = array_slice($clauses, 0, 3); + foreach ($clauses as $index => $clause) { + if ($index > 0) { + $segments[] = self::text($index === count($clauses) - 1 ? ', and ' : ', '); + } + foreach ($clause as $segment) { + $segments[] = $segment; + } + } + $segments[] = self::text('.'); + + return $segments; + } + + protected static function housekeepingSentence(array $open): ?array + { + $parts = []; + + $expiring = self::ofRule($open, ['license_expiring', 'lease_expiring']); + if ($expiring) { + $parts[] = sprintf('%d licence%s or lease%s expire%s within 30 days', count($expiring), count($expiring) === 1 ? '' : 's', count($expiring) === 1 ? '' : 's', count($expiring) === 1 ? 's' : ''); + } + + $fuel = self::ofRule($open, ['fuel_unmatched']); + if ($fuel) { + $suggested = count(array_filter($fuel, fn ($item) => !empty($item['source']['suggested_vehicle']))); + $parts[] = sprintf('%d fuel transaction%s %s unmatched%s', count($fuel), count($fuel) === 1 ? '' : 's', count($fuel) === 1 ? 'is' : 'are', $suggested ? sprintf(' (%d with a suggested vehicle)', $suggested) : ''); + } + + $parts_ = self::ofRule($open, ['part_low_stock']); + if ($parts_) { + $blocking = array_filter($parts_, fn ($item) => str_contains((string) ($item['meta_line'] ?? ''), 'blocks')); + $parts[] = sprintf('%d part%s %s below reorder point%s', count($parts_), count($parts_) === 1 ? '' : 's', count($parts_) === 1 ? 'is' : 'are', $blocking ? sprintf(', %d blocking a work order', count($blocking)) : ''); + } + + if (!$parts) { + return null; + } + + return [self::text(ucfirst(implode('; ', $parts)) . '.')]; + } + + // ------------------------------------------------------------------ + // Decisions + // ------------------------------------------------------------------ + + protected static function inspectionDecisions(array $open): array + { + $decisions = []; + + foreach (self::ofRule($open, ['inspection_failed']) as $item) { + $needsWorkOrder = in_array('create_work_order_from_inspection', $item['actions'] ?? [], true); + $needsIssue = in_array('create_issue_from_inspection', $item['actions'] ?? [], true); + if (!$needsWorkOrder && !$needsIssue) { + continue; + } + + $submission = $item['source']['public_id'] ?? $item['source']['uuid'] ?? null; + $label = $item['subject']['label'] ?? 'this vehicle'; + + $decisions[] = self::decision('inspection_follow_up:' . $submission, $item, [ + 'title' => $needsWorkOrder ? sprintf('Raise a work order for %s', $label) : sprintf('Raise an issue for %s', $label), + 'subtitle' => $item['meta_line'] ?? null, + 'reasoning' => [self::link($label, $item), self::text(' ' . $item['title'] . '. ' . ($item['severity'] === RadarRules::SEVERITY_CRITICAL ? 'A critical item blocks dispatch until the repair is booked.' : 'Booking the repair now keeps the vehicle on the road.'))], + 'confirm' => $needsWorkOrder + ? self::call('Raise work order', 'create_work_order_from_inspection', 'POST', sprintf('inspection-submissions/%s/create-work-order', $submission)) + : self::call('Raise issue', 'create_issue_from_inspection', 'POST', sprintf('inspection-submissions/%s/create-issue', $submission)), + 'alternatives' => $needsWorkOrder && $needsIssue + ? [self::call('Issue only', 'create_issue_from_inspection', 'POST', sprintf('inspection-submissions/%s/create-issue', $submission))] + : [], + ]); + } + + return $decisions; + } + + protected static function workOrderDecisions(array $open): array + { + $decisions = []; + + foreach (self::ofRule($open, ['maintenance_overdue', 'inspection_due'], fn ($item) => in_array('create_work_order', $item['actions'] ?? [], true)) as $item) { + $schedule = $item['source']['public_id'] ?? $item['source']['uuid'] ?? null; + $label = $item['subject']['label'] ?? 'this vehicle'; + + $decisions[] = self::decision('open_work_order:' . $schedule, $item, [ + 'title' => sprintf('Open a work order for %s', $label), + 'subtitle' => $item['title'], + 'reasoning' => [self::link($label, $item), self::text(' is ' . ($item['due_label'] ?? 'due') . ' on this schedule and no work order is open for it.')], + 'confirm' => self::call('Open work order', 'create_work_order', 'POST', sprintf('maintenance-schedules/%s/trigger', $schedule)), + 'alternatives' => [], + ]); + } + + return $decisions; + } + + protected static function vehicleDecisions(array $open): array + { + $decisions = []; + $drivers = self::ofRule($open, ['shift_no_vehicle', 'driver_without_vehicle'], fn ($item) => $item['rule'] === 'shift_no_vehicle' || $item['severity'] === RadarRules::SEVERITY_WARNING); + $vehicles = self::ofRule($open, ['vehicle_without_driver']); + + foreach ($drivers as $index => $driverItem) { + $vehicleItem = $vehicles[$index] ?? null; + if (!$vehicleItem) { + break; + } + + $driverId = $driverItem['subject']['uuid'] ?? $driverItem['subject']['public_id'] ?? null; + $vehicleId = $vehicleItem['subject']['uuid'] ?? $vehicleItem['subject']['public_id'] ?? null; + + $decisions[] = self::decision('assign_vehicle:' . ($driverItem['subject']['public_id'] ?? $driverId), $driverItem, [ + 'title' => sprintf('Assign %s to %s', $vehicleItem['subject']['label'] ?? 'a vehicle', $driverItem['subject']['label'] ?? 'the driver'), + 'subtitle' => 'clears 2 gaps', + 'reasoning' => [self::link($driverItem['subject']['label'] ?? 'Driver', $driverItem), self::text(' ' . lcfirst(preg_replace('/^\S+\s+\S+\s+/', '', $driverItem['title'])) . '; '), self::link($vehicleItem['subject']['label'] ?? 'the vehicle', $vehicleItem), self::text(' has no driver' . (!empty($vehicleItem['meta_line']) ? ' (' . $vehicleItem['meta_line'] . ')' : '') . '. Assigning it ends both gaps.')], + 'confirm' => self::call('Confirm assignment', 'assign_vehicle', 'POST', sprintf('drivers/%s/assign-vehicle', $driverId), ['vehicle' => $vehicleId], self::call('Undo', 'unassign_vehicle', 'POST', sprintf('drivers/%s/unassign-vehicle', $driverId))), + 'alternatives' => [['label' => 'Pick another vehicle', 'action' => 'pick_vehicle']], + 'keys' => [$driverItem['key'], $vehicleItem['key']], + ]); + } + + return $decisions; + } + + protected static function fuelDecisions(array $open): array + { + $decisions = []; + + foreach (self::ofRule($open, ['fuel_unmatched'], fn ($item) => !empty($item['source']['suggested_vehicle'])) as $item) { + $suggested = $item['source']['suggested_vehicle']; + $transaction = $item['source']['uuid'] ?? $item['source']['public_id'] ?? null; + + $decisions[] = self::decision('match_fuel:' . ($item['source']['public_id'] ?? $transaction), $item, [ + 'title' => sprintf('Match %s to %s', preg_replace('/ — .*$/', '', $item['title']), $suggested['label'] ?? 'the suggested vehicle'), + 'subtitle' => 'matched by ' . str_replace('_', ' ', (string) ($suggested['matched'] ?? 'identity')), + 'reasoning' => [self::text(sprintf('The transaction\'s %s equals the %s recorded on ', str_replace('_', ' ', (string) ($suggested['matched'] ?? 'identity')), str_replace('_', ' ', (string) ($suggested['matched'] ?? 'identity')))), self::link($suggested['label'] ?? 'the vehicle', $item), self::text('.')], + 'confirm' => self::call('Confirm match', 'match_vehicle', 'POST', sprintf('fuel-provider-transactions/%s/match-vehicle', $transaction), ['vehicle' => $suggested['uuid'] ?? $suggested['public_id'] ?? null]), + 'alternatives' => [self::call('Ignore', 'ignore_transaction', 'POST', sprintf('fuel-provider-transactions/%s/review', $transaction), ['status' => 'ignored'])], + 'severity' => RadarRules::SEVERITY_INFO, + ]); + } + + return $decisions; + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + protected static function decision(string $key, array $item, array $extra): array + { + return [ + 'key' => $key, + 'severity' => $extra['severity'] ?? $item['severity'], + 'category' => $item['category'], + 'title' => $extra['title'], + 'subtitle' => $extra['subtitle'] ?? null, + 'reasoning' => $extra['reasoning'] ?? [], + 'confirm' => $extra['confirm'], + 'alternatives' => $extra['alternatives'] ?? [], + 'keys' => $extra['keys'] ?? [$item['key']], + 'subject' => $item['subject'] ?? null, + 'record' => $item['record'] ?? null, + ]; + } + + /** + * A call the console makes when a card is confirmed, with the call + * that undoes it when there is one. + */ + protected static function call(string $label, string $action, string $method, string $endpoint, array $body = [], ?array $undo = null): array + { + return array_filter(['label' => $label, 'action' => $action, 'method' => $method, 'endpoint' => $endpoint, 'body' => $body, 'undo' => $undo], fn ($value) => $value !== null); + } + + protected static function ofRule(array $items, array $rules, ?callable $where = null): array + { + return array_values(array_filter($items, fn ($item) => in_array($item['rule'], $rules, true) && (!$where || $where($item)))); + } + + protected static function text(string $text): array + { + return ['text' => $text]; + } + + protected static function link(string $text, array $item): array + { + $record = $item['record'] ?? null; + + return $record ? ['text' => $text, 'route' => $record['route'], 'model' => $record['model']] : ['text' => $text]; + } + + protected static function hourFromWindow(array $item, string $edge = 'start_at'): string + { + $at = RadarRules::carbon($item['window'][$edge] ?? null); + + return $at ? $at->format('H:i') : 'earlier'; + } +} diff --git a/server/src/Support/Radar/RadarItemState.php b/server/src/Support/Radar/RadarItemState.php index e42decc2e..0aa9d1c62 100644 --- a/server/src/Support/Radar/RadarItemState.php +++ b/server/src/Support/Radar/RadarItemState.php @@ -244,6 +244,7 @@ public static function toState(Alert $alert): array 'resolved_at' => $alert->resolved_at?->toIso8601String(), 'resolved_by_name' => $alert->resolved_by_name, 'resolution' => $alert->meta['resolution'] ?? null, + 'triggered_at' => $alert->triggered_at?->toIso8601String(), ]; } diff --git a/server/src/Support/Radar/RadarRules.php b/server/src/Support/Radar/RadarRules.php index cdc58da94..e0bd123c2 100644 --- a/server/src/Support/Radar/RadarRules.php +++ b/server/src/Support/Radar/RadarRules.php @@ -831,6 +831,7 @@ public static function normalizeState(?array $state, Carbon $now): array 'resolved_at' => $state['resolved_at'] ?? null, 'resolved_by_name' => $state['resolved_by_name'] ?? null, 'resolution' => $state['resolution'] ?? null, + 'triggered_at' => $state['triggered_at'] ?? null, ]; } @@ -918,6 +919,31 @@ public static function forFleet(array $items, ?string $fleet): array return array_values(array_filter($items, fn ($item) => in_array($fleet, $item['subject']['fleets'] ?? [], true))); } + /** + * Keep the items of one category. + */ + public static function forCategory(array $items, ?string $category): array + { + $category = trim((string) $category); + if ($category === '') { + return array_values($items); + } + + return array_values(array_filter($items, fn ($item) => ($item['category'] ?? null) === $category)); + } + + /** + * Keep the items assigned to a user. + */ + public static function forAssignee(array $items, ?string $userUuid): array + { + if (!$userUuid) { + return []; + } + + return array_values(array_filter($items, fn ($item) => ($item['state']['assigned_to']['uuid'] ?? null) === $userUuid)); + } + /** * Count the pills over a set of items. */ @@ -1266,6 +1292,7 @@ protected static function subject(array $subject): array 'public_id' => $subject['public_id'] ?? null, 'label' => $subject['label'] ?? ($subject['public_id'] ?? ''), 'photo_url' => $subject['photo_url'] ?? null, + 'phone' => $subject['phone'] ?? null, 'fleets' => array_values($subject['fleets'] ?? []), ]; } @@ -1279,6 +1306,7 @@ protected static function driverSubject(array $driver): array 'public_id' => $driver['public_id'] ?? null, 'label' => $driver['name'] ?? ($driver['label'] ?? ($driver['public_id'] ?? 'Driver')), 'photo_url' => $driver['photo_url'] ?? null, + 'phone' => $driver['phone'] ?? null, 'fleets' => $driver['fleets'] ?? [], ]; } diff --git a/server/src/routes.php b/server/src/routes.php index 96d5e86ff..2b41e8b4e 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -846,6 +846,10 @@ function ($router) { function ($router) { $router->get('items', 'RadarController@items'); $router->get('summary', 'RadarController@summary'); + $router->get('briefing', 'RadarController@briefing'); + $router->get('agenda', 'RadarController@agenda'); + $router->get('handovers/{key}', 'RadarController@handoverSuggest'); + $router->post('shifts/{id}/extend', 'RadarController@extendShift'); $router->post('items/bulk', 'RadarController@bulk'); $router->post('items/{key}/acknowledge', 'RadarController@acknowledge'); $router->post('items/{key}/snooze', 'RadarController@snooze'); diff --git a/server/tests/Feature/Http/Internal/RadarControllerTest.php b/server/tests/Feature/Http/Internal/RadarControllerTest.php index f5ab58a85..9ad4ea7c3 100644 --- a/server/tests/Feature/Http/Internal/RadarControllerTest.php +++ b/server/tests/Feature/Http/Internal/RadarControllerTest.php @@ -190,6 +190,36 @@ protected function notices(?string $company): array return $this->fixtures['notices'] ?? []; } + public array $history = []; + public array $orders = []; + public ?object $shift = null; + public bool $shiftSaved = false; + + protected function loadOrdersForHandovers(?string $company, array $items): array + { + return $this->orders; + } + + protected function findShift(?string $company, string $id): ?Fleetbase\Models\ScheduleItem + { + return $id === 'shift_1' ? $this->shift : null; + } + + protected function saveShift(Fleetbase\Models\ScheduleItem $shift): void + { + $this->shiftSaved = true; + } + + protected function scoreHistory(?string $company): array + { + return $this->history; + } + + protected function rememberScore(?string $company, array $history): void + { + $this->history = $history; + } + protected function createNotice(array $attributes): Alert { $this->lastNotice = new FleetOpsRadarAlertSpy(['uuid' => 'alert-notice', 'public_id' => 'alert_notice'] + $attributes); @@ -328,6 +358,12 @@ function fleetOpsRadarRequest(string $uri, string $method = 'GET', array $parame $fleet = $controller->items(fleetOpsRadarRequest('items', 'GET', ['fleet' => 'fleet-north']))->getData(true); expect($fleet['items'])->toBe([]); + + // "My assignments" keeps only the items whose owner is the caller. + $controller->store['issue_open:issue_1'] = new FleetOpsRadarAlertSpy(['uuid' => 'a', 'public_id' => 'alert_a', 'type' => 'issue_open', 'status' => 'open', 'assigned_to_uuid' => 'user-1', 'context' => ['key' => 'issue_open:issue_1']]); + $controller->store['issue_open:issue_1']->setRelation('assignedTo', fleetOpsRadarUser('user-1', 'Ada Ops')); + $mine = $controller->items(fleetOpsRadarRequest('items', 'GET', ['assigned' => 'me']))->getData(true); + expect(array_column($mine['items'], 'key'))->toBe(['issue_open:issue_1']); }); test('a failing source is reported without blanking the list, and reconciliation is skipped', function () { @@ -340,6 +376,81 @@ function fleetOpsRadarRequest(string $uri, string $method = 'GET', array $parame ->and($controller->reconciled)->toBe([]); }); +test('briefing scores the morning, remembers the score and counts what closed since yesterday', function () { + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures(), [ + 'issue_open:issue_1' => ['status' => 'open', 'triggered_at' => '2026-09-13T08:00:00+00:00'], + ]); + $controller->history = [['date' => '2026-09-14', 'score' => 90]]; + $controller->resolved = [['key' => 'issue_open:issue_9', 'rule' => 'issue_open', 'severity' => 'warning', 'title' => 'Old issue', 'due_bucket' => 'none', 'pills' => [], 'subject' => null, 'state' => ['status' => 'resolved']]]; + + $payload = $controller->briefing(fleetOpsRadarRequest('briefing'))->getData(true); + + expect($payload)->toHaveKeys(['score', 'categories', 'brief', 'decisions', 'open', 'yesterday', 'generated_at', 'sources']) + ->and($payload['score']['value'])->toBeLessThan(100) + ->and($payload['score']['delta'])->toBe($payload['score']['value'] - 90) + ->and($payload['categories'][0]['key'])->toBe('maintenance') + ->and($payload['yesterday'])->toBe(['closed' => 1, 'rolled_over' => 1]) + ->and($controller->history)->toHaveCount(2) + ->and($controller->history[1])->toBe(['date' => '2026-09-15', 'score' => $payload['score']['value']]) + ->and(array_column($payload['decisions'], 'key'))->toBe(['open_work_order:schedule_oil']) + ->and($controller->reconciled)->toBe([]); + + $byCategory = $controller->items(fleetOpsRadarRequest('items', 'GET', ['category' => 'parts']))->getData(true); + expect(array_column($byCategory['items'], 'key'))->toBe(['part_low_stock:part_pads']); +}); + +test('agenda lays the items out on the window, with the roster and handover cards', function () { + $fixtures = fleetOpsRadarFixtures(); + $ortega = ['type' => 'driver', 'class' => 'Fleetbase\FleetOps\Models\Driver', 'uuid' => 'driver-ortega', 'public_id' => 'driver_ortega', 'label' => 'Luis Ortega', 'photo_url' => null, 'phone' => null]; + $alves = ['type' => 'driver', 'class' => 'Fleetbase\FleetOps\Models\Driver', 'uuid' => 'driver-alves', 'public_id' => 'driver_alves', 'label' => 'Tomas Alves', 'photo_url' => null, 'phone' => null]; + $fixtures['shifts'] = [ + ['uuid' => 'sh1', 'public_id' => 'shift_1', 'start_at' => '2026-09-15 01:15:00', 'end_at' => '2026-09-15 09:15:00', 'status' => 'in_progress', 'driver' => $ortega, 'driver_online' => true, 'driver_vehicle_uuid' => 'v', 'active_orders' => 2], + ['uuid' => 'sh2', 'public_id' => 'shift_2', 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 21:00:00', 'status' => 'in_progress', 'driver' => $alves, 'driver_online' => true, 'driver_vehicle_uuid' => 'v2', 'active_orders' => 1], + ]; + $fixtures['drivers'] = [ + ['uuid' => 'driver-ortega', 'public_id' => 'driver_ortega', 'name' => 'Luis Ortega', 'online' => true, 'vehicle_uuid' => 'v', 'active_orders' => 2, 'location' => ['lat' => 40.7, 'lng' => -74.0]], + ['uuid' => 'driver-alves', 'public_id' => 'driver_alves', 'name' => 'Tomas Alves', 'online' => true, 'vehicle_uuid' => 'v2', 'active_orders' => 1, 'location' => ['lat' => 40.71, 'lng' => -74.0]], + ]; + $controller = new FleetOpsRadarControllerProbe($fixtures); + $controller->orders = ['driver-ortega' => [['uuid' => 'o1', 'public_id' => 'order_1', 'status' => 'dispatched', 'destination' => 'Bay Ridge', 'ends_at' => '2026-09-15T09:40:00+00:00']]]; + + $payload = $controller->agenda(fleetOpsRadarRequest('agenda', 'GET', ['window' => '24h']))->getData(true); + + expect($payload['window']['key'])->toBe('24h') + ->and(array_column($payload['overdue'], 'key'))->toBe(['maintenance_overdue:schedule_oil']) + ->and(array_column($payload['lanes']['maintenance'], 'key'))->toBe([]) + ->and(array_column($payload['later'], 'key'))->toBe(['maintenance_due_soon:schedule_tires', 'notice:alert_yard']) + ->and(array_column($payload['anytime'], 'key'))->toBe(['issue_open:issue_1', 'part_low_stock:part_pads']) + ->and(collect($payload['lanes']['shifts'])->where('kind', 'shift')->count())->toBe(2) + ->and($payload['handovers'][0]['key'])->toBe('shift_handover:driver_ortega') + ->and($payload['handovers'][0]['orders'][0]['destination'])->toBe('Bay Ridge') + ->and($payload['handovers'][0]['suggested']['driver']['label'])->toBe('Tomas Alves') + ->and($payload['summary']['open'])->toBe(6); + + $card = $controller->handoverSuggest(fleetOpsRadarRequest('handovers/shift_handover:driver_ortega'), 'shift_handover:driver_ortega')->getData(true); + expect($card['handover']['suggested']['capacity_label'])->toBe('1 of 6 orders'); + expect($controller->handoverSuggest(fleetOpsRadarRequest('handovers/x'), 'shift_handover:driver_nobody')->getStatusCode())->toBe(404); +}); + +test('extend shift pushes the end out and validates the minutes', function () { + $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures()); + $shift = new class extends Fleetbase\Models\ScheduleItem { + public function getDateFormat(): string + { + return 'Y-m-d H:i:s'; + } + }; + $shift->setRawAttributes(['uuid' => 'sh1', 'public_id' => 'shift_1', 'start_at' => '2026-09-15 01:15:00', 'end_at' => '2026-09-15 09:15:00', 'status' => 'in_progress'], true); + $controller->shift = $shift; + + expect($controller->extendShift(fleetOpsRadarRequest('shifts/shift_1/extend', 'POST', ['minutes' => 0]), 'shift_1')->getStatusCode())->toBe(422) + ->and($controller->extendShift(fleetOpsRadarRequest('shifts/shift_9/extend', 'POST', ['minutes' => 60]), 'shift_9')->getStatusCode())->toBe(404); + + $payload = $controller->extendShift(fleetOpsRadarRequest('shifts/shift_1/extend', 'POST', ['minutes' => 60]), 'shift_1')->getData(true); + expect($payload['shift']['end_at'])->toBe('2026-09-15T10:15:00+00:00') + ->and($controller->shiftSaved)->toBeTrue(); +}); + test('summary answers with counts only', function () { $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures()); $payload = $controller->summary(fleetOpsRadarRequest('summary'))->getData(true); diff --git a/server/tests/RadarRoutesTest.php b/server/tests/RadarRoutesTest.php index 8f4b041be..1994036b2 100644 --- a/server/tests/RadarRoutesTest.php +++ b/server/tests/RadarRoutesTest.php @@ -7,6 +7,10 @@ ->toContain("['prefix' => 'radar']") ->toContain("\$router->get('items', 'RadarController@items');") ->toContain("\$router->get('summary', 'RadarController@summary');") + ->toContain("\$router->get('briefing', 'RadarController@briefing');") + ->toContain("\$router->get('agenda', 'RadarController@agenda');") + ->toContain("\$router->get('handovers/{key}', 'RadarController@handoverSuggest');") + ->toContain("\$router->post('shifts/{id}/extend', 'RadarController@extendShift');") ->toContain("\$router->post('items/bulk', 'RadarController@bulk');") ->toContain("\$router->post('items/{key}/acknowledge', 'RadarController@acknowledge');") ->toContain("\$router->post('items/{key}/snooze', 'RadarController@snooze');") @@ -25,7 +29,7 @@ test('radar controller exposes every routed action', function () { $controller = new ReflectionClass(Fleetbase\FleetOps\Http\Controllers\Internal\v1\RadarController::class); - foreach (['items', 'summary', 'bulk', 'acknowledge', 'snooze', 'wake', 'assign', 'plan', 'resolve', 'storeNotice', 'destroyNotice'] as $method) { + foreach (['items', 'summary', 'briefing', 'agenda', 'handoverSuggest', 'extendShift', 'bulk', 'acknowledge', 'snooze', 'wake', 'assign', 'plan', 'resolve', 'storeNotice', 'destroyNotice'] as $method) { expect($controller->hasMethod($method))->toBeTrue("RadarController@{$method} is routed but missing"); } }); diff --git a/server/tests/Unit/Support/RadarAgendaTest.php b/server/tests/Unit/Support/RadarAgendaTest.php new file mode 100644 index 000000000..40f100c5a --- /dev/null +++ b/server/tests/Unit/Support/RadarAgendaTest.php @@ -0,0 +1,165 @@ + $key, + 'rule' => $rule, + 'category' => 'maintenance', + 'lane' => 'maintenance', + 'chip' => 'Maint', + 'severity' => 'warning', + 'title' => $key, + 'subject' => null, + 'due_at' => null, + 'due_bucket' => 'none', + 'window' => null, + 'state' => ['status' => 'open'], + 'actions' => [], + 'record' => null, + ], $extra); +} + +function fleetOpsAgendaDriver(string $name, array $extra = []): array +{ + $slug = strtolower(str_replace(' ', '', $name)); + + return array_merge(['type' => 'driver', 'uuid' => 'driver-' . $slug, 'public_id' => 'driver_' . $slug, 'label' => $name, 'photo_url' => null], $extra); +} + +afterEach(fn () => Carbon::setTestNow()); + +test('items land in the overdue band, a lane, the later rail or the anytime tray', function () { + $now = fleetOpsAgendaNow(); + $items = [ + fleetOpsAgendaItem('maintenance_overdue:a', ['due_at' => '2026-09-12T08:00:00+00:00', 'due_bucket' => 'overdue']), + fleetOpsAgendaItem('maintenance_due_soon:b', ['due_at' => '2026-09-15T14:00:00+00:00', 'due_bucket' => 'today']), + fleetOpsAgendaItem('license_expiring:c', ['category' => 'compliance', 'lane' => 'expiries', 'due_at' => '2026-09-21T00:00:00+00:00', 'due_bucket' => 'week']), + fleetOpsAgendaItem('notice:d', ['category' => 'notices', 'lane' => 'notices', 'due_at' => '2026-09-16T06:00:00+00:00', 'due_bucket' => 'later']), + fleetOpsAgendaItem('issue_open:e', ['category' => 'issues', 'lane' => 'anytime']), + fleetOpsAgendaItem('part_low_stock:f', ['category' => 'parts', 'lane' => 'anytime', 'state' => ['status' => 'open', 'planned_at' => '2026-09-15T16:00:00+00:00']]), + fleetOpsAgendaItem('fuel_unmatched:g', ['category' => 'fuel', 'lane' => 'anytime', 'state' => ['status' => 'snoozed', 'snoozed_until' => '2026-09-18T08:00:00+00:00']]), + ]; + + $day = RadarAgenda::build($items, [], '24h', $now); + + expect($day['window']['key'])->toBe('24h') + ->and($day['window']['start_at'])->toBe('2026-09-15T08:00:00+00:00') + ->and($day['window']['end_at'])->toBe('2026-09-16T08:00:00+00:00') + ->and($day['window']['now_pct'])->toBe(2.43) + ->and(array_column($day['window']['ticks'], 'label'))->toBe(['08', '10', '12', '14', '16', '18', '20', '22', '00', '02', '04', '06', '08']) + ->and(array_column($day['overdue'], 'key'))->toBe(['maintenance_overdue:a']) + ->and(array_column($day['lanes']['maintenance'], 'key'))->toBe(['maintenance_due_soon:b', 'part_low_stock:f']) + ->and($day['lanes']['maintenance'][0]['pct'])->toBe(25.0) + ->and($day['lanes']['maintenance'][0]['label'])->toBe('14:00') + ->and($day['lanes']['maintenance'][1]['planned'])->toBeTrue() + ->and(array_column($day['lanes']['notices'], 'key'))->toBe(['notice:d']) + ->and(array_column($day['later'], 'key'))->toBe(['license_expiring:c']) + ->and(array_column($day['anytime'], 'key'))->toBe(['issue_open:e'], 'snoozed items are not on the agenda') + ->and($day['counts'])->toBe(['overdue' => 1, 'later' => 1, 'anytime' => 1, 'shifts' => 0]); + + $week = RadarAgenda::build($items, [], '7d', $now); + expect($week['window']['hours'])->toBe(168) + ->and(array_column($week['lanes']['expiries'], 'key'))->toBe(['license_expiring:c'], 'the 7-day window absorbs the later rail') + ->and($week['later'])->toBe([]) + ->and($week['window']['ticks'][1]['label'])->toBe('Wed 16'); + + expect(RadarAgenda::build($items, [], 'bogus', $now)['window']['key'])->toBe('24h'); +}); + +test('every live shift is a bar on the shifts lane carrying its gaps and handover', function () { + $now = fleetOpsAgendaNow(); + $ortega = fleetOpsAgendaDriver('Luis Ortega'); + $diallo = fleetOpsAgendaDriver('Amara Diallo'); + $items = [ + fleetOpsAgendaItem('shift_handover:driver_luisortega', ['category' => 'staffing', 'lane' => 'shifts', 'subject' => $ortega, 'window' => ['start_at' => '2026-09-15T01:15:00+00:00', 'end_at' => '2026-09-15T09:15:00+00:00'], 'due_at' => '2026-09-15T09:15:00+00:00', 'due_bucket' => 'today', 'source' => ['active_orders' => 2]]), + fleetOpsAgendaItem('shift_late_start:driver_amaradiallo', ['category' => 'staffing', 'lane' => 'shifts', 'severity' => 'critical', 'subject' => $diallo, 'window' => ['start_at' => '2026-09-15T08:00:00+00:00', 'end_at' => '2026-09-15T16:00:00+00:00']]), + ]; + $shifts = [ + ['uuid' => 'sh1', 'public_id' => 'shift_1', 'start_at' => '2026-09-15 01:15:00', 'end_at' => '2026-09-15 09:15:00', 'status' => 'in_progress', 'driver' => $ortega, 'driver_online' => true, 'active_orders' => 2], + ['uuid' => 'sh2', 'public_id' => 'shift_2', 'start_at' => '2026-09-15 08:00:00', 'end_at' => '2026-09-15 16:00:00', 'status' => 'scheduled', 'driver' => $diallo, 'driver_online' => false, 'active_orders' => 0], + ['uuid' => 'sh3', 'public_id' => 'shift_3', 'start_at' => '2026-09-15 14:00:00', 'end_at' => '2026-09-15 21:00:00', 'status' => 'scheduled', 'driver' => fleetOpsAgendaDriver('Tomas Alves'), 'driver_online' => false, 'active_orders' => 0], + ['uuid' => 'sh4', 'public_id' => 'shift_4', 'start_at' => '2026-09-17 06:00:00', 'end_at' => '2026-09-17 14:00:00', 'status' => 'scheduled', 'driver' => fleetOpsAgendaDriver('Far Away'), 'driver_online' => false, 'active_orders' => 0], + ['uuid' => 'sh5', 'public_id' => 'shift_5', 'start_at' => '2026-09-14 20:00:00', 'end_at' => '2026-09-15 04:00:00', 'status' => 'completed', 'driver' => fleetOpsAgendaDriver('Night Done'), 'driver_online' => false, 'active_orders' => 0], + ]; + + $day = RadarAgenda::build($items, $shifts, '24h', $now); + $lane = collect($day['lanes']['shifts']); + $bars = $lane->where('kind', 'shift')->keyBy('key'); + + expect($bars->keys()->all())->toBe(['shift:shift_1', 'shift:shift_2', 'shift:shift_3']) + ->and($bars['shift:shift_1']['state'])->toBe('on_shift') + ->and($bars['shift:shift_1']['pct'])->toBe(0.0) + ->and($bars['shift:shift_1']['width_pct'])->toBe(5.21) + ->and($bars['shift:shift_1']['handover_key'])->toBe('shift_handover:driver_luisortega') + ->and($bars['shift:shift_1']['gap_keys'])->toBe(['shift_handover:driver_luisortega']) + ->and($bars['shift:shift_2']['state'])->toBe('not_online') + ->and($bars['shift:shift_2']['severity'])->toBe('critical') + ->and($bars['shift:shift_3']['state'])->toBe('upcoming') + ->and($bars['shift:shift_3']['pct'])->toBe(25.0) + ->and($bars['shift:shift_3']['label'])->toBe('14:00–21:00') + ->and($lane->where('kind', 'item')->pluck('key')->all())->toBe(['shift_late_start:driver_amaradiallo', 'shift_handover:driver_luisortega'], 'a live gap sits at the now line, ahead of the 09:15 handover') + ->and($day['counts']['shifts'])->toBe(5); +}); + +test('a handover card lists the orders and suggests the nearest on-shift driver with capacity', function () { + $now = fleetOpsAgendaNow(); + $ortega = fleetOpsAgendaDriver('Luis Ortega'); + $alves = fleetOpsAgendaDriver('Tomas Alves'); + $kim = fleetOpsAgendaDriver('Rin Kim'); + $full = fleetOpsAgendaDriver('Full Driver'); + $early = fleetOpsAgendaDriver('Ends Early'); + $items = [ + fleetOpsAgendaItem('shift_handover:driver_luisortega', ['category' => 'staffing', 'lane' => 'shifts', 'subject' => $ortega, 'window' => ['start_at' => '2026-09-15T01:15:00+00:00', 'end_at' => '2026-09-15T09:15:00+00:00'], 'due_at' => '2026-09-15T09:15:00+00:00', 'due_bucket' => 'today', 'source' => ['active_orders' => 2], 'record' => ['route' => 'management.drivers.index.details', 'model' => 'driver_luisortega']]), + ]; + $shifts = [ + ['uuid' => 'sh1', 'start_at' => '2026-09-15 01:15:00', 'end_at' => '2026-09-15 09:15:00', 'status' => 'in_progress', 'driver' => $ortega, 'driver_online' => true], + ['uuid' => 'sh2', 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 21:00:00', 'status' => 'in_progress', 'driver' => $alves, 'driver_online' => true], + ['uuid' => 'sh3', 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 18:00:00', 'status' => 'in_progress', 'driver' => $kim, 'driver_online' => true], + ['uuid' => 'sh4', 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 18:00:00', 'status' => 'in_progress', 'driver' => $full, 'driver_online' => true], + ['uuid' => 'sh5', 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 10:00:00', 'status' => 'in_progress', 'driver' => $early, 'driver_online' => true], + ]; + $drivers = [ + ['uuid' => 'driver-luisortega', 'location' => ['lat' => 40.70, 'lng' => -73.99], 'active_orders' => 2], + ['uuid' => 'driver-tomasalves', 'location' => ['lat' => 40.71, 'lng' => -73.98], 'active_orders' => 2, 'max_daily_orders' => 6], + ['uuid' => 'driver-rinkim', 'location' => ['lat' => 40.80, 'lng' => -73.90], 'active_orders' => 0], + ['uuid' => 'driver-fulldriver', 'location' => ['lat' => 40.70, 'lng' => -73.99], 'active_orders' => 6], + ['uuid' => 'driver-endsearly', 'location' => ['lat' => 40.70, 'lng' => -73.99], 'active_orders' => 0], + ]; + $orders = [ + 'driver-luisortega' => [ + ['uuid' => 'o1', 'public_id' => 'order_88412', 'status' => 'dispatched', 'destination' => 'Bay Ridge', 'ends_at' => '2026-09-15T09:40:00+00:00'], + ['uuid' => 'o2', 'public_id' => 'order_88431', 'status' => 'dispatched', 'destination' => 'Sunset Park', 'ends_at' => '2026-09-15T08:50:00+00:00'], + ], + ]; + + $cards = RadarAgenda::handovers($items, $shifts, $drivers, $orders, $now); + + expect($cards)->toHaveCount(1) + ->and($cards[0]['key'])->toBe('shift_handover:driver_luisortega') + ->and($cards[0]['shift']['minutes_left'])->toBe(40) + ->and($cards[0]['rule'])->toBe('shift_handover') + ->and(array_column($cards[0]['orders'], 'finishes_after_shift'))->toBe([true, false]) + ->and($cards[0]['orders'][0]['destination'])->toBe('Bay Ridge') + ->and($cards[0]['suggested']['driver']['label'])->toBe('Tomas Alves') + ->and($cards[0]['suggested']['distance_km'])->toBe(1.4) + ->and($cards[0]['suggested']['capacity_label'])->toBe('2 of 6 orders') + ->and($cards[0]['suggested']['on_shift_until'])->toBe('2026-09-15T21:00:00+00:00'); + + // Without anyone on shift long enough, the card still exists but has no suggestion. + $none = RadarAgenda::handovers($items, [$shifts[0], $shifts[4]], $drivers, $orders, $now); + expect($none[0]['suggested'])->toBeNull(); + + expect(RadarAgenda::distanceKm(['lat' => 0, 'lng' => 0], ['lat' => 0, 'lng' => 1]))->toBe(111.2) + ->and(RadarAgenda::distanceKm(null, ['lat' => 0, 'lng' => 1]))->toBeNull(); +}); diff --git a/server/tests/Unit/Support/RadarBriefingTest.php b/server/tests/Unit/Support/RadarBriefingTest.php new file mode 100644 index 000000000..57e79afa8 --- /dev/null +++ b/server/tests/Unit/Support/RadarBriefingTest.php @@ -0,0 +1,183 @@ + 'vehicle', 'class' => 'Fleetbase\FleetOps\Models\Vehicle', 'uuid' => 'vehicle-' . strtolower($id), 'public_id' => 'vehicle_' . strtolower($id), 'label' => $id, 'photo_url' => null]; +} + +function fleetOpsBriefDriver(string $name, array $extra = []): array +{ + $slug = strtolower(str_replace(' ', '', $name)); + + return array_merge(['type' => 'driver', 'class' => 'Fleetbase\FleetOps\Models\Driver', 'uuid' => 'driver-' . $slug, 'public_id' => 'driver_' . $slug, 'name' => $name, 'label' => $name, 'photo_url' => null, 'online' => true, 'vehicle_uuid' => null], $extra); +} + +/** + * The morning from the design concept, as RadarRules sees it. + */ +function fleetOpsBriefItems(): array +{ + $now = fleetOpsBriefNow(); + + return RadarRules::build([ + 'schedules' => [ + ['uuid' => 's1', 'public_id' => 'schedule_oil', 'name' => 'Oil change', 'type' => 'oil_change', 'status' => 'active', 'next_due_date' => '2026-09-12 08:00:00', 'has_open_work_order' => false, 'subject' => fleetOpsBriefVehicle('TRK-118')], + ['uuid' => 's2', 'public_id' => 'schedule_tires', 'name' => 'Tire rotation', 'type' => 'tire_rotation', 'status' => 'active', 'next_due_date' => '2026-09-17 12:00:00', 'has_open_work_order' => true, 'subject' => fleetOpsBriefVehicle('TRK-204')], + ], + 'workOrders' => [ + ['uuid' => 'w1', 'public_id' => 'work_order_2041', 'code' => 'WO-2041', 'subject' => 'brake pad replacement', 'status' => 'in_progress', 'priority' => 'high', 'due_at' => '2026-09-13 09:00:00', 'target' => fleetOpsBriefVehicle('TRK-207'), 'checklist' => [['title' => 'Fit brake pads (PN 8842)']]], + ], + 'inspectionSubmissions' => [ + ['uuid' => 'a', 'public_id' => 'inspection_submission_a', 'status' => 'submitted', 'result' => 'failed', 'failed_items' => 2, 'issue_uuid' => null, 'work_order_uuid' => null, 'resolved_at' => null, 'form_name' => 'Pre-trip DVIR', 'highest_severity' => 'critical', 'vehicle' => fleetOpsBriefVehicle('VAN-042'), 'driver' => null], + ], + 'shifts' => [ + ['uuid' => 'sh1', 'public_id' => 'shift_1', 'start_at' => '2026-09-15 08:00:00', 'end_at' => '2026-09-15 16:00:00', 'status' => 'scheduled', 'driver' => fleetOpsBriefDriver('Amara Diallo'), 'driver_online' => false, 'driver_vehicle_uuid' => 'v', 'active_orders' => 0], + ['uuid' => 'sh2', 'public_id' => 'shift_2', 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 14:00:00', 'status' => 'in_progress', 'driver' => fleetOpsBriefDriver('Priya Nair'), 'driver_online' => true, 'driver_vehicle_uuid' => null, 'active_orders' => 0], + ['uuid' => 'sh3', 'public_id' => 'shift_3', 'start_at' => '2026-09-15 01:15:00', 'end_at' => '2026-09-15 09:15:00', 'status' => 'in_progress', 'driver' => fleetOpsBriefDriver('Luis Ortega'), 'driver_online' => true, 'driver_vehicle_uuid' => 'v2', 'active_orders' => 2], + ], + 'vehicles' => [ + ['uuid' => 'vehicle-van042', 'public_id' => 'vehicle_van042', 'label' => 'VAN-042', 'status' => 'available', 'driver_uuid' => null, 'device_count' => 1, 'lease_expires_at' => '2026-09-30'], + ], + 'drivers' => [ + fleetOpsBriefDriver('Sam Bauer', ['online' => false, 'license_expiry' => '2026-10-06']), + ], + 'parts' => [ + ['uuid' => 'p1', 'public_id' => 'part_pads', 'name' => 'Brake pads', 'sku' => 'PN 8842', 'quantity_on_hand' => 2, 'reorder_point' => 6], + ], + 'fuelTransactions' => [ + ['uuid' => 'f1', 'public_id' => 'fuel_provider_transaction_1', 'amount' => 21240, 'currency' => 'USD', 'station_name' => 'Pilot #331', 'transaction_at' => '2026-09-14 08:04:00', 'vehicle_card_id' => '4471', 'sync_status' => 'unmatched', 'suggested_vehicle' => ['uuid' => 'vehicle-trk207', 'public_id' => 'vehicle_trk207', 'label' => 'TRK-207', 'matched' => 'fuel_card_number']], + ], + 'states' => [ + 'lease_expiring:vehicle_van042' => ['status' => 'snoozed', 'snoozed_until' => '2026-09-18T08:00:00+00:00'], + ], + ], $now)['items']; +} + +afterEach(fn () => Carbon::setTestNow()); + +test('category rows score the open gaps with capped, visible arithmetic', function () { + $brief = RadarBriefing::build(fleetOpsBriefItems(), fleetOpsBriefNow()); + $rows = collect($brief['categories'])->keyBy('key'); + + // Maintenance: oil overdue (10) + tires due soon (4) + WO-2041 overdue (10) = 24 off. + expect($rows['maintenance']['score'])->toBe(76) + ->and($rows['maintenance']['count'])->toBe(3) + ->and($rows['maintenance']['critical'])->toBe(2) + ->and(array_column($rows['maintenance']['gaps'], 'label'))->toBe(['1 overdue', '1 work order overdue', '1 due this week']) + ->and($rows['maintenance']['filter'])->toBe(['category' => 'maintenance']) + // Inspections: one critical failed inspection. + ->and($rows['inspections']['score'])->toBe(90) + ->and($rows['inspections']['gaps'][0]['label'])->toBe('1 failed, no follow-up') + // Staffing: late start (4) + no vehicle (4) + handover (4) + idle vehicle (1) + offline driver without vehicle (1) = 14. + ->and($rows['staffing']['score'])->toBe(86) + ->and(array_column($rows['staffing']['gaps'], 'rule'))->toContain('shift_late_start', 'shift_no_vehicle', 'shift_handover', 'vehicle_without_driver') + // Compliance: the snoozed lease is not open; only the licence counts. + ->and($rows['compliance']['score'])->toBe(96) + ->and($rows['compliance']['gaps'])->toBe([['rule' => 'license_expiring', 'count' => 1, 'label' => '1 licence expiring']]) + ->and($rows['fuel']['score'])->toBe(96) + ->and($rows['parts']['score'])->toBe(96) + ->and($rows['connectivity']['score'])->toBe(100) + ->and($rows['issues']['score'])->toBe(100) + ->and($brief['score']['value'])->toBe((int) round((76 + 90 + 86 + 96 + 96 + 96 + 100 + 100) / 8)) + ->and($brief['score']['delta'])->toBeNull() + ->and($brief['score']['summary'])->toBe('2 overdue · 2 due this week · 3 unassigned · 1 inspection items') + ->and($brief['open'])->toBe(12); + + // One rule cannot zero a category on its own. + $many = []; + for ($i = 0; $i < 20; $i++) { + $many[] = ['key' => "issue_open:i{$i}", 'rule' => 'issue_open', 'category' => 'issues', 'severity' => 'critical', 'state' => ['status' => 'open'], 'title' => 'x', 'due_bucket' => 'none', 'pills' => ['issues']]; + } + $capped = collect(RadarBriefing::categories($many))->firstWhere('key', 'issues'); + expect($capped['score'])->toBe(100 - RadarBriefing::RULE_CAP); +}); + +test('the delta compares with the most recent earlier day and the history keeps 30 days', function () { + $now = fleetOpsBriefNow(); + $history = [ + ['date' => '2026-09-13', 'score' => 70], + ['date' => '2026-09-14', 'score' => 82], + ['date' => '2026-09-15', 'score' => 50], + ['date' => '2026-09-16', 'score' => 99], + ]; + + expect(RadarBriefing::delta(78, $history, $now))->toBe(-4) + ->and(RadarBriefing::delta(78, [], $now))->toBeNull() + ->and(RadarBriefing::delta(78, [['date' => '2026-09-15', 'score' => 10]], $now))->toBeNull(); + + $pushed = RadarBriefing::pushHistory($history, 78, $now); + expect(array_column($pushed, 'score'))->toBe([70, 82, 78, 99]) + ->and($pushed[2])->toBe(['date' => '2026-09-15', 'score' => 78]); + + $long = []; + for ($day = 1; $day <= 40; $day++) { + $long[] = ['date' => sprintf('2026-08-%02d', $day), 'score' => $day]; + } + expect(RadarBriefing::pushHistory($long, 1, $now))->toHaveCount(30); +}); + +test('the brief names the records behind the morning, with links, and closes with routine', function () { + $brief = RadarBriefing::build(fleetOpsBriefItems(), fleetOpsBriefNow()); + $sentences = array_map(fn ($sentence) => implode('', array_column($sentence, 'text')), $brief['brief']); + + expect($sentences)->toHaveCount(4) + ->and($sentences[0])->toBe('2 jobs are past due: TRK-118 (3d overdue) and TRK-207 (1d overdue). 1 failed inspection has no follow-up yet: VAN-042 2 failed · no issue · no work order.') + ->and($sentences[1])->toBe('Amara Diallo is 35m late for a shift that started 08:00, Priya Nair has been on shift 2h 35m on shift without a vehicle, and Luis Ortega ends at 09:15 still holding 2 orders.') + ->and($sentences[2])->toBe('1 licence or lease expires within 30 days; 1 fuel transaction is unmatched (1 with a suggested vehicle); 1 part is below reorder point, 1 blocking a work order.') + ->and($sentences[3])->toBe('Everything else is routine.'); + + $links = array_filter($brief['brief'][0], fn ($segment) => isset($segment['route'])); + expect(array_values($links)[0])->toBe(['text' => 'TRK-118', 'route' => 'maintenance.schedules.index.details', 'model' => 'schedule_oil']); + + expect(RadarBriefing::brief([], fleetOpsBriefNow()))->toBe([[['text' => 'All clear: nothing needs a decision this morning.']]]); +}); + +test('decisions are one click each, most urgent first, and say what the click calls', function () { + $brief = RadarBriefing::build(fleetOpsBriefItems(), fleetOpsBriefNow()); + $decisions = collect($brief['decisions'])->keyBy('key'); + + expect(array_column($brief['decisions'], 'key'))->toBe([ + 'inspection_follow_up:inspection_submission_a', + 'open_work_order:schedule_oil', + 'assign_vehicle:driver_priyanair', + 'match_fuel:fuel_provider_transaction_1', + ]); + + $followUp = $decisions['inspection_follow_up:inspection_submission_a']; + expect($followUp['severity'])->toBe('critical') + ->and($followUp['title'])->toBe('Raise a work order for VAN-042') + ->and($followUp['confirm'])->toBe(['label' => 'Raise work order', 'action' => 'create_work_order_from_inspection', 'method' => 'POST', 'endpoint' => 'inspection-submissions/inspection_submission_a/create-work-order', 'body' => []]) + ->and($followUp['alternatives'][0]['endpoint'])->toBe('inspection-submissions/inspection_submission_a/create-issue') + ->and($followUp['keys'])->toBe(['inspection_failed:inspection_submission_a']) + ->and($followUp['record']['route'])->toBe('maintenance.inspection-submissions.index.details'); + + $workOrder = $decisions['open_work_order:schedule_oil']; + expect($workOrder['title'])->toBe('Open a work order for TRK-118') + ->and($workOrder['confirm']['endpoint'])->toBe('maintenance-schedules/schedule_oil/trigger'); + + $assign = $decisions['assign_vehicle:driver_priyanair']; + expect($assign['severity'])->toBe('warning') + ->and($assign['title'])->toBe('Assign VAN-042 to Priya Nair') + ->and($assign['subtitle'])->toBe('clears 2 gaps') + ->and($assign['confirm']['endpoint'])->toBe('drivers/driver-priyanair/assign-vehicle') + ->and($assign['confirm']['body'])->toBe(['vehicle' => 'vehicle-van042']) + ->and($assign['confirm']['undo']['endpoint'])->toBe('drivers/driver-priyanair/unassign-vehicle') + ->and($assign['alternatives'][0]['action'])->toBe('pick_vehicle') + ->and($assign['keys'])->toBe(['shift_no_vehicle:driver_priyanair', 'vehicle_without_driver:vehicle_van042']); + + $fuel = $decisions['match_fuel:fuel_provider_transaction_1']; + expect($fuel['severity'])->toBe('info') + ->and($fuel['title'])->toBe('Match USD 212.40 at Pilot #331 to TRK-207') + ->and($fuel['subtitle'])->toBe('matched by fuel card number') + ->and($fuel['confirm']['body'])->toBe(['vehicle' => 'vehicle-trk207']) + ->and($fuel['alternatives'][0]['body'])->toBe(['status' => 'ignored']); +}); diff --git a/server/tests/Unit/Support/RadarRulesTest.php b/server/tests/Unit/Support/RadarRulesTest.php index d1eb4c706..7456ba277 100644 --- a/server/tests/Unit/Support/RadarRulesTest.php +++ b/server/tests/Unit/Support/RadarRulesTest.php @@ -432,6 +432,8 @@ function fleetOpsRadarDriver(string $name = 'Amara Diallo', array $extra = []): ->and(RadarRules::forPills($open, ['issues', 'overdue']))->toBe([]) ->and(RadarRules::forPills($open, ['not-a-pill']))->toHaveCount(4) ->and(array_column(RadarRules::search($open, 'trk-311'), 'key'))->toBe(['issue_open:issue_1']) + ->and(RadarRules::forAssignee($open, null))->toBe([]) + ->and(array_column(RadarRules::forAssignee(array_map(fn ($item) => $item['key'] === 'issue_open:issue_2' ? array_merge($item, ['state' => ['status' => 'open', 'assigned_to' => ['uuid' => 'user-1']]]) : $item, $open), 'user-1'), 'key'))->toBe(['issue_open:issue_2']) ->and(array_column(RadarRules::search($open, 'brake'), 'key'))->toBe(['part_low_stock:part_pads']) ->and(array_column(RadarRules::group($open), 'key'))->toBe(['overdue', 'none']) ->and(RadarRules::group($open)[1]['count'])->toBe(3) From a7bf823b1d8e4b198c88758671474ea81e1c54c4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 06:53:01 +0800 Subject: [PATCH 03/12] Replace the Resources Hub with Radar The hub was a landing page of counts and links. Radar is the triage list behind them: one row per gap, grouped by due date, with count pills as the only filter, Open / Snoozed / Resolved tabs, and saved views kept per user. A row's hover rail carries the record's own action (raise the work order, assign the vehicle, match the transaction, send the PIN) beside acknowledge, snooze and assign; a drawer shows the failed inspection items or the shift behind a row. Selection turns the rail into a bulk bar, and J/K/X/E/S/A/Enter work the list from the keyboard. Above the list the morning brief scores each category with visible arithmetic, writes a few linked sentences about the day, and offers one decision per card, each a single call with an undo strip. The Agenda toggle lays the same items on the next 24 hours or 7 days: an overdue band, four lanes, a later rail and an anytime tray a drop can schedule from, with every live shift as a bar and a handover card naming the nearest driver with capacity to take a leaving driver's orders. Styling stays on the console's palette: sky-500 for the active pill and primary actions, the badge palette for chips and severities, the hub's rose and amber tones for overdue and today, and dark mode through the same body[data-theme] overrides as the rest of the engine. --- addon/components/layout/fleet-ops-sidebar.js | 9 +- addon/components/modals/radar-notice.hbs | 24 + addon/components/modals/radar-notice.js | 8 + addon/components/modals/radar-save-view.hbs | 7 + .../modals/radar-select-resource.hbs | 24 + addon/components/radar/agenda.hbs | 69 + addon/components/radar/agenda.js | 29 + addon/components/radar/agenda/entry.hbs | 17 + addon/components/radar/agenda/entry.js | 26 + addon/components/radar/agenda/lane.hbs | 33 + addon/components/radar/agenda/lane.js | 80 + addon/components/radar/briefing.hbs | 108 ++ addon/components/radar/briefing.js | 43 + addon/components/radar/bulk-bar.hbs | 30 + addon/components/radar/bulk-bar.js | 27 + addon/components/radar/decision-card.hbs | 28 + addon/components/radar/decision-card.js | 7 + addon/components/radar/empty-state.hbs | 30 + addon/components/radar/empty-state.js | 27 + addon/components/radar/filter-pills.hbs | 22 + addon/components/radar/filter-pills.js | 3 + addon/components/radar/handover-card.hbs | 62 + addon/components/radar/handover-card.js | 14 + addon/components/radar/header.hbs | 28 + addon/components/radar/header.js | 21 + addon/components/radar/item-drawer.hbs | 92 + addon/components/radar/item-drawer.js | 37 + addon/components/radar/item-list.hbs | 20 + addon/components/radar/item-list.js | 3 + addon/components/radar/item-row.hbs | 90 + addon/components/radar/item-row.js | 29 + addon/components/radar/keyboard-hints.hbs | 8 + addon/components/radar/keyboard-hints.js | 3 + addon/components/radar/subject.hbs | 14 + addon/components/radar/subject.js | 26 + addon/components/radar/view-bar.hbs | 27 + addon/components/radar/view-bar.js | 16 + addon/controllers/management/index.js | 1271 ++++++++++++- addon/helpers/initials.js | 6 + addon/modifiers/radar-keyboard.js | 78 + addon/routes/management/index.js | 18 +- addon/styles/fleetops-engine.css | 1596 +++++++++++++++++ addon/templates/management/index.hbs | 207 +-- addon/utils/radar.js | 156 ++ app/components/modals/radar-notice.js | 1 + app/components/modals/radar-save-view.js | 1 + .../modals/radar-select-resource.js | 1 + app/components/radar/agenda.js | 1 + app/components/radar/agenda/entry.js | 1 + app/components/radar/agenda/lane.js | 1 + app/components/radar/briefing.js | 1 + app/components/radar/bulk-bar.js | 1 + app/components/radar/decision-card.js | 1 + app/components/radar/empty-state.js | 1 + app/components/radar/filter-pills.js | 1 + app/components/radar/handover-card.js | 1 + app/components/radar/header.js | 1 + app/components/radar/item-drawer.js | 1 + app/components/radar/item-list.js | 1 + app/components/radar/item-row.js | 1 + app/components/radar/keyboard-hints.js | 1 + app/components/radar/subject.js | 1 + app/components/radar/view-bar.js | 1 + app/helpers/initials.js | 1 + app/modifiers/radar-keyboard.js | 1 + app/utils/radar.js | 1 + .../layout/fleet-ops-sidebar-test.js | 17 +- .../components/radar/agenda-test.js | 204 +++ .../components/radar/briefing-test.js | 129 ++ .../components/radar/bulk-bar-test.js | 42 + .../components/radar/empty-state-test.js | 41 + .../components/radar/filter-pills-test.js | 46 + .../components/radar/handover-card-test.js | 55 + .../components/radar/item-row-test.js | 115 ++ .../components/radar/view-bar-test.js | 52 + .../modifiers/radar-keyboard-test.js | 77 + .../unit/controllers/management/index-test.js | 361 ++++ tests/unit/routes/hub-index-routes-test.js | 26 +- tests/unit/utils/radar-test.js | 83 + translations/en-us.yaml | 265 +++ 80 files changed, 5832 insertions(+), 176 deletions(-) create mode 100644 addon/components/modals/radar-notice.hbs create mode 100644 addon/components/modals/radar-notice.js create mode 100644 addon/components/modals/radar-save-view.hbs create mode 100644 addon/components/modals/radar-select-resource.hbs create mode 100644 addon/components/radar/agenda.hbs create mode 100644 addon/components/radar/agenda.js create mode 100644 addon/components/radar/agenda/entry.hbs create mode 100644 addon/components/radar/agenda/entry.js create mode 100644 addon/components/radar/agenda/lane.hbs create mode 100644 addon/components/radar/agenda/lane.js create mode 100644 addon/components/radar/briefing.hbs create mode 100644 addon/components/radar/briefing.js create mode 100644 addon/components/radar/bulk-bar.hbs create mode 100644 addon/components/radar/bulk-bar.js create mode 100644 addon/components/radar/decision-card.hbs create mode 100644 addon/components/radar/decision-card.js create mode 100644 addon/components/radar/empty-state.hbs create mode 100644 addon/components/radar/empty-state.js create mode 100644 addon/components/radar/filter-pills.hbs create mode 100644 addon/components/radar/filter-pills.js create mode 100644 addon/components/radar/handover-card.hbs create mode 100644 addon/components/radar/handover-card.js create mode 100644 addon/components/radar/header.hbs create mode 100644 addon/components/radar/header.js create mode 100644 addon/components/radar/item-drawer.hbs create mode 100644 addon/components/radar/item-drawer.js create mode 100644 addon/components/radar/item-list.hbs create mode 100644 addon/components/radar/item-list.js create mode 100644 addon/components/radar/item-row.hbs create mode 100644 addon/components/radar/item-row.js create mode 100644 addon/components/radar/keyboard-hints.hbs create mode 100644 addon/components/radar/keyboard-hints.js create mode 100644 addon/components/radar/subject.hbs create mode 100644 addon/components/radar/subject.js create mode 100644 addon/components/radar/view-bar.hbs create mode 100644 addon/components/radar/view-bar.js create mode 100644 addon/helpers/initials.js create mode 100644 addon/modifiers/radar-keyboard.js create mode 100644 addon/utils/radar.js create mode 100644 app/components/modals/radar-notice.js create mode 100644 app/components/modals/radar-save-view.js create mode 100644 app/components/modals/radar-select-resource.js create mode 100644 app/components/radar/agenda.js create mode 100644 app/components/radar/agenda/entry.js create mode 100644 app/components/radar/agenda/lane.js create mode 100644 app/components/radar/briefing.js create mode 100644 app/components/radar/bulk-bar.js create mode 100644 app/components/radar/decision-card.js create mode 100644 app/components/radar/empty-state.js create mode 100644 app/components/radar/filter-pills.js create mode 100644 app/components/radar/handover-card.js create mode 100644 app/components/radar/header.js create mode 100644 app/components/radar/item-drawer.js create mode 100644 app/components/radar/item-list.js create mode 100644 app/components/radar/item-row.js create mode 100644 app/components/radar/keyboard-hints.js create mode 100644 app/components/radar/subject.js create mode 100644 app/components/radar/view-bar.js create mode 100644 app/helpers/initials.js create mode 100644 app/modifiers/radar-keyboard.js create mode 100644 app/utils/radar.js create mode 100644 tests/integration/components/radar/agenda-test.js create mode 100644 tests/integration/components/radar/briefing-test.js create mode 100644 tests/integration/components/radar/bulk-bar-test.js create mode 100644 tests/integration/components/radar/empty-state-test.js create mode 100644 tests/integration/components/radar/filter-pills-test.js create mode 100644 tests/integration/components/radar/handover-card-test.js create mode 100644 tests/integration/components/radar/item-row-test.js create mode 100644 tests/integration/components/radar/view-bar-test.js create mode 100644 tests/integration/modifiers/radar-keyboard-test.js create mode 100644 tests/unit/controllers/management/index-test.js create mode 100644 tests/unit/utils/radar-test.js diff --git a/addon/components/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js index 442955818..b65b7ebb7 100644 --- a/addon/components/layout/fleet-ops-sidebar.js +++ b/addon/components/layout/fleet-ops-sidebar.js @@ -135,10 +135,13 @@ export default class LayoutFleetOpsSidebarComponent extends Component { get resourcesItems() { return this.withRegistryItems('management', [ - this.createHubItem('Resources Hub', 'layer-group', 'management.index', 'fleet-ops list driver', 'fleet-ops see driver', [ + this.createHubItem(this.intl.t('menu.radar'), 'crosshairs', 'management.index', 'fleet-ops list driver', 'fleet-ops see driver', [ + 'radar', + 'needs attention', 'resources hub', - 'resource dashboard', - 'resource readiness', + 'triage', + 'handover', + 'shift changes', ]), this.createItem('menu.drivers', 'id-card', 'management.drivers', 'fleet-ops list driver', 'fleet-ops see driver', ['driver', 'online drivers']), this.createItem('menu.vehicles', 'truck', 'management.vehicles', 'fleet-ops list vehicle', 'fleet-ops see vehicle', ['vehicle', 'track vehicles', 'online vehicles']), diff --git a/addon/components/modals/radar-notice.hbs b/addon/components/modals/radar-notice.hbs new file mode 100644 index 000000000..49333e0f3 --- /dev/null +++ b/addon/components/modals/radar-notice.hbs @@ -0,0 +1,24 @@ + +