+
+
+
{{or @resource.code @resource.subject}}
+ {{smart-humanize @resource.status}}
-
-
{{smart-humanize @resource.type}}
+
+ {{#if @resource.type}}
+
{{smart-humanize @resource.type}}
+ {{/if}}
{{#if @resource.priority}}
-
{{t "column.priority"}}: {{smart-humanize @resource.priority}}
+
{{t "column.priority"}}: {{smart-humanize @resource.priority}}
{{/if}}
- {{#if (or @resource.assignee @resource.assignee_name)}}
-
- {{t "column.assignee"}}:
- {{or @resource.assignee_name @resource.assignee.name}}
-
+ {{#if (or @resource.assignee_name @resource.assignee.name)}}
+
{{t "column.assignee"}}: {{or @resource.assignee_name @resource.assignee.name}}
{{/if}}
-
-
-
-
- {{@resource.name}} {{t "resource.zone"}}
-
-
+
\ No newline at end of file
diff --git a/addon/controllers/connectivity/devices/index/details.js b/addon/controllers/connectivity/devices/index/details.js
index 9e4835d45..6068e2804 100644
--- a/addon/controllers/connectivity/devices/index/details.js
+++ b/addon/controllers/connectivity/devices/index/details.js
@@ -42,24 +42,7 @@ export default class ConnectivityDevicesIndexDetailsController extends Controlle
icon: 'ellipsis-h',
iconPrefix: 'fas',
renderInPlace: true,
- items: [
- {
- text: this.intl.t('device.actions.attach-to-asset'),
- icon: 'link',
- fn: () => this.deviceActions.attachToVehicle(this.model),
- permission: 'fleet-ops update device',
- },
- ...(this.model.attachable_uuid || this.model.attached_to_name || this.model.attachable
- ? [
- {
- text: this.intl.t('device.attachment.detach'),
- icon: 'unlink',
- fn: () => this.deviceActions.detachFromVehicle(this.model),
- permission: 'fleet-ops update device',
- },
- ]
- : []),
- ],
+ items: this.deviceActions.attachmentItems(this.model),
},
];
}
diff --git a/addon/controllers/maintenance/inspection-forms/index/details.js b/addon/controllers/maintenance/inspection-forms/index/details.js
index 17b134cd9..ae1948df1 100644
--- a/addon/controllers/maintenance/inspection-forms/index/details.js
+++ b/addon/controllers/maintenance/inspection-forms/index/details.js
@@ -1,7 +1,6 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
-import { action } from '@ember/object';
export default class MaintenanceInspectionFormsIndexDetailsController extends Controller {
@service inspectionFormActions;
@@ -22,31 +21,9 @@ export default class MaintenanceInspectionFormsIndexDetailsController extends Co
* The public link needs a published form, so it appears at the same moment.
*/
get actionButtons() {
- const isPublished = this.model?.is_published === true || this.model?.status === 'published';
-
- return [
- ...(isPublished ? [] : [{ icon: 'check', fn: this.publish, text: 'Publish', type: 'success', permission: 'fleet-ops publish inspection-form' }]),
- ...(isPublished ? [{ icon: 'link', fn: this.generateLink, text: 'Generate Link', permission: 'fleet-ops view inspection-form' }] : []),
- { icon: 'edit', fn: this.edit, permission: 'fleet-ops update inspection-form' },
- { icon: 'trash', fn: this.delete, type: 'danger', permission: 'fleet-ops delete inspection-form' },
- ];
- }
-
- @action publish() {
- return this.inspectionFormActions.publish(this.model);
- }
-
- @action generateLink() {
- return this.inspectionFormActions.generateLink(this.model);
- }
-
- @action edit() {
- return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.edit', this.model);
- }
-
- @action delete() {
- return this.inspectionFormActions.delete(this.model, {
- onConfirm: () => this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index'),
+ return this.inspectionFormActions.headerActionButtons(this.model, {
+ onEdit: () => this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.edit', this.model),
+ onDeleted: () => this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index'),
});
}
}
diff --git a/addon/controllers/maintenance/inspection-submissions/index/details.js b/addon/controllers/maintenance/inspection-submissions/index/details.js
index f425cdb07..7b1480ede 100644
--- a/addon/controllers/maintenance/inspection-submissions/index/details.js
+++ b/addon/controllers/maintenance/inspection-submissions/index/details.js
@@ -42,59 +42,12 @@ export default class MaintenanceInspectionSubmissionsIndexDetailsController exte
* from the Follow Up panel instead of offered again here.
*/
get followUpItems() {
- const record = this.model;
- const items = [];
-
- if (record?.has_failures && !record?.issue_uuid) {
- items.push({
- text: this.intl.t('inspection.record.create-issue'),
- icon: 'triangle-exclamation',
- fn: this.createIssue,
- permission: 'fleet-ops create-issue inspection-submission',
- });
- }
-
- if (record?.has_failures && !record?.work_order_uuid) {
- items.push({
- text: this.intl.t('inspection.record.create-work-order'),
- icon: 'clipboard-list',
- fn: this.createWorkOrder,
- permission: 'fleet-ops create-work-order inspection-submission',
- });
- }
-
- if (record?.status !== 'resolved') {
- items.push({ text: this.intl.t('inspection.record.resolve'), icon: 'check', fn: this.resolve, permission: 'fleet-ops resolve inspection-submission' });
- }
-
- if (items.length) {
- items.push({ separator: true });
- }
-
- items.push({ text: this.intl.t('common.delete'), icon: 'trash', class: 'text-red-500', fn: this.delete, permission: 'fleet-ops delete inspection-submission' });
-
- return items;
- }
-
- @action createIssue() {
- return this.inspectionSubmissionActions.createIssue(this.model);
- }
-
- @action createWorkOrder() {
- return this.inspectionSubmissionActions.createWorkOrder(this.model);
- }
-
- @action resolve() {
- return this.inspectionSubmissionActions.resolve(this.model);
+ return this.inspectionSubmissionActions.followUpItems(this.model, {
+ onDeleted: () => this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index'),
+ });
}
@action edit() {
return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.edit', this.model);
}
-
- @action delete() {
- return this.inspectionSubmissionActions.delete(this.model, {
- onConfirm: () => this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index'),
- });
- }
}
diff --git a/addon/controllers/maintenance/schedules/index/details.js b/addon/controllers/maintenance/schedules/index/details.js
index 9ed8b3c30..bcd1e27f4 100644
--- a/addon/controllers/maintenance/schedules/index/details.js
+++ b/addon/controllers/maintenance/schedules/index/details.js
@@ -6,10 +6,8 @@ import { isArray } from '@ember/array';
export default class MaintenanceSchedulesIndexDetailsController extends Controller {
@service maintenanceScheduleActions;
- @service fetch;
@service hostRouter;
@service intl;
- @service abilities;
@service('universe/menu-service') menuService;
@tracked overlay;
@@ -23,93 +21,13 @@ export default class MaintenanceSchedulesIndexDetailsController extends Controll
}
get actionButtons() {
- return [
- { icon: 'edit', fn: this.edit, permission: 'fleet-ops update maintenance-schedule' },
- { icon: 'play', helpText: 'Trigger Work Order Now', fn: this.triggerNow, permission: 'fleet-ops update maintenance-schedule' },
- {
- // Calendar export dropdown — matches the dropdown-button pattern
- icon: 'ellipsis-h',
- iconPrefix: 'fas',
- renderInPlace: true,
- items: [
- {
- text: 'Download .ics',
- icon: 'download',
- iconPrefix: 'far',
- fn: () => this.downloadIcal(this.model),
- },
- {
- text: 'Add to Google Calendar',
- icon: 'calendar-plus',
- iconPrefix: 'fab',
- fn: () => this.addToGoogleCalendar(this.model),
- },
- ],
- },
- { icon: 'trash', fn: this.delete, permission: 'fleet-ops delete maintenance-schedule', type: 'danger' },
- ];
+ return this.maintenanceScheduleActions.panelActionButtons(this.model, {
+ onEdit: this.edit,
+ onDeleted: () => this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index'),
+ });
}
@action edit() {
return this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index.edit', this.model);
}
-
- @action triggerNow() {
- return this.maintenanceScheduleActions.triggerNow(this.model);
- }
-
- @action delete() {
- return this.maintenanceScheduleActions.delete(this.model, {
- onConfirm: () => {
- this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index');
- },
- });
- }
-
- /**
- * Trigger a browser download of the .ics file for this schedule.
- * Uses fetch.download() which automatically attaches auth headers.
- */
- @action downloadIcal(schedule) {
- const id = schedule.public_id ?? schedule.id;
- this.fetch
- .download(
- `maintenance-schedules/${id}/ical`,
- {},
- {
- fileName: `maintenance-schedule-${id}.ics`,
- mimeType: 'text/calendar',
- }
- )
- .catch((error) => {
- // eslint-disable-next-line no-console
- console.error('Failed to download iCal:', error);
- });
- }
-
- /**
- * Open the Google Calendar "add event" URL for this schedule.
- * Includes RRULE recurrence when the schedule has a time-based interval.
- */
- @action addToGoogleCalendar(schedule) {
- const title = encodeURIComponent(schedule.name ?? 'Maintenance Schedule');
- const dueDate = schedule.next_due_date ? new Date(schedule.next_due_date) : new Date();
- const pad = (n) => String(n).padStart(2, '0');
- const dateStr = `${dueDate.getFullYear()}${pad(dueDate.getMonth() + 1)}${pad(dueDate.getDate())}`;
- const details = encodeURIComponent(schedule.description ?? schedule.instructions ?? '');
-
- // Build RRULE if the schedule has a time-based interval
- let recur = '';
- const intervalValue = parseInt(schedule.interval_value, 10);
- const intervalUnit = schedule.interval_unit;
- if (intervalValue > 0 && intervalUnit) {
- const unitMap = { days: 'DAILY', weeks: 'WEEKLY', months: 'MONTHLY', years: 'YEARLY' };
- const freq = unitMap[intervalUnit] ?? 'DAILY';
- const interval = intervalUnit === 'weeks' ? intervalValue : intervalValue;
- recur = `&recur=RRULE:FREQ=${freq};INTERVAL=${interval}`;
- }
-
- const url = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${title}&dates=${dateStr}/${dateStr}&details=${details}${recur}`;
- window.open(url, '_blank', 'noopener,noreferrer');
- }
}
diff --git a/addon/controllers/management/drivers/index/details.js b/addon/controllers/management/drivers/index/details.js
index 2f6e13ee8..2f9689fac 100644
--- a/addon/controllers/management/drivers/index/details.js
+++ b/addon/controllers/management/drivers/index/details.js
@@ -1,12 +1,10 @@
import Controller from '@ember/controller';
-import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { isArray } from '@ember/array';
export default class ManagementDriverIndexDetailsController extends Controller {
@service('universe/menu-service') menuService;
@service driverActions;
- @service issueActions;
@service hostRouter;
@service intl;
@@ -33,10 +31,6 @@ export default class ManagementDriverIndexDetailsController extends Controller {
];
}
- hasAssignedVehicle(driver) {
- return Boolean(driver?.vehicle_uuid || driver?.vehicle?.id || driver?.vehicle?.uuid || driver?.vehicle_name);
- }
-
get actionButtons() {
return [
{
@@ -48,67 +42,8 @@ export default class ManagementDriverIndexDetailsController extends Controller {
icon: 'ellipsis-h',
iconPrefix: 'fas',
renderInPlace: true,
- items: [
- {
- text: this.intl.t('driver.actions.assign-order'),
- icon: 'clipboard-list',
- fn: () => this.driverActions.assignOrder(this.model),
- permission: 'fleet-ops assign-order-for driver',
- },
- ...(Number(this.model.assigned_orders_count) > 0
- ? [
- {
- text: this.intl.t('driver.actions.unassign-orders'),
- icon: 'user-minus',
- fn: () => this.driverActions.unassignOrders(this.model),
- permission: 'fleet-ops assign-order-for driver',
- },
- ]
- : []),
- {
- separator: true,
- },
- {
- text: this.intl.t('driver.actions.assign-vehicle'),
- icon: 'car',
- fn: () => this.driverActions.assignVehicle(this.model),
- permission: 'fleet-ops assign-vehicle-for driver',
- },
- ...(this.hasAssignedVehicle(this.model)
- ? [
- {
- text: this.intl.t('driver.actions.unassign-vehicle'),
- icon: 'link-slash',
- fn: () => this.driverActions.unassignVehicle(this.model),
- permission: 'fleet-ops assign-vehicle-for driver',
- },
- ]
- : []),
- {
- separator: true,
- },
- {
- text: this.intl.t('driver.actions.locate-driver'),
- icon: 'location-dot',
- fn: () => this.driverActions.locate(this.model),
- permission: 'fleet-ops view driver',
- },
- {
- text: this.intl.t('driver.actions.create-issue'),
- icon: 'triangle-exclamation',
- fn: () => this.createIssue(this.model),
- permission: 'fleet-ops create issue',
- },
- ],
+ items: this.driverActions.detailsMenuItems(this.model),
},
];
}
-
- @action createIssue(driver) {
- this.issueActions.modal.create({
- driver,
- driver_uuid: driver.id,
- title: this.intl.t('driver.prompts.issue-title', { driverName: driver.name }),
- });
- }
}
diff --git a/addon/controllers/management/fleets/index/details.js b/addon/controllers/management/fleets/index/details.js
index 3ec568dde..56807d64c 100644
--- a/addon/controllers/management/fleets/index/details.js
+++ b/addon/controllers/management/fleets/index/details.js
@@ -9,7 +9,7 @@ export default class ManagementFleetsIndexDetailsController extends Controller {
@service intl;
get tabs() {
- const registeredTabs = this.menuService.getMenuItems('fleet-ops:component:place:details');
+ const registeredTabs = this.menuService.getMenuItems('fleet-ops:component:fleet:details');
return [
{
route: 'management.fleets.index.details.index',
@@ -38,20 +38,7 @@ export default class ManagementFleetsIndexDetailsController extends Controller {
icon: 'ellipsis-h',
iconPrefix: 'fas',
renderInPlace: true,
- items: [
- {
- text: this.intl.t('fleet.actions.assign-driver'),
- icon: 'user-plus',
- fn: () => this.fleetActions.assignDriver(this.model),
- permission: 'fleet-ops assign-driver-for fleet',
- },
- {
- text: this.intl.t('fleet.actions.assign-vehicle'),
- icon: 'car',
- fn: () => this.fleetActions.assignVehicle(this.model),
- permission: 'fleet-ops assign-vehicle-for fleet',
- },
- ],
+ items: this.fleetActions.detailsMenuItems(this.model),
},
];
}
diff --git a/addon/controllers/management/index.js b/addon/controllers/management/index.js
index e7ef930bd..a5b8f2933 100644
--- a/addon/controllers/management/index.js
+++ b/addon/controllers/management/index.js
@@ -2,87 +2,1405 @@ import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
-import { task } from 'ember-concurrency';
+import { getOwner } from '@ember/application';
+import { task, timeout } from 'ember-concurrency';
+import { format } from 'date-fns';
+import { PANEL_DEFAULTS } from '../../utils/context-panel';
+import { RADAR_PILLS, RADAR_DEFAULT_VIEWS, SNOOZE_PRESETS, snoozePayloadFor, patchPayload, recordPanelFor, recordOf } from '../../utils/radar';
+const VIEWS_CACHE_KEY = 'fleetops:radar:views';
+const VIEW_MODE_CACHE_KEY = 'fleetops:radar:view';
+const BRIEFING_CACHE_KEY = 'fleetops:radar:briefing-collapsed';
+const PAGE_SIZE = 50;
+
+/** A record key Radar holds as a uuid rather than a public id. */
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/**
+ * Radar: the triage list that replaced the Resources Hub.
+ *
+ * The list, its pills, tabs and saved views live in query params so a view
+ * is shareable. Items come from `fleet-ops/radar/items`; every action on an
+ * item posts to the same prefix and patches the row in place, so the list
+ * never reflows under the cursor.
+ */
export default class ManagementIndexController extends Controller {
@service fetch;
- @service docsPanel;
+ @service notifications;
+ @service intl;
+ @service appCache;
+ @service hostRouter;
+ @service store;
+ @service modalsManager;
+ @service currentUser;
+ @service inspectionSubmissionActions;
+ @service inspectionFormActions;
+ @service issueActions;
+ @service resourceContextPanel;
+
+ queryParams = ['view', 'status', 'filters', 'category', 'fleet', 'q', 'saved', 'assigned', 'window'];
- @tracked hub = null;
+ @tracked view = 'list';
+ @tracked status = 'open';
+ @tracked filters = '';
+ @tracked category = '';
+ @tracked fleet = '';
+ @tracked q = '';
+ @tracked saved = '';
+ @tracked assigned = '';
+ @tracked window = '24h';
+ @tracked page = 1;
+
+ @tracked payload = null;
+ @tracked summary = null;
+ @tracked briefing = null;
+ @tracked agenda = null;
+ @tracked handover = null;
+ @tracked strips = [];
+ @tracked briefingCollapsed = false;
+ @tracked busyDecisionKey = null;
+ @tracked selection = [];
+ @tracked selectionAnchor = null;
+ @tracked focusedKey = null;
+ @tracked drawerItem = null;
+ @tracked savedViews = [];
+ @tracked lastLoadedAt = null;
+
+ snoozePresets = SNOOZE_PRESETS;
constructor() {
super(...arguments);
- this.loadHub.perform();
+ this.view = this.appCache.get(VIEW_MODE_CACHE_KEY, 'list') === 'agenda' ? 'agenda' : 'list';
+ this.briefingCollapsed = this.appCache.get(BRIEFING_CACHE_KEY, false) === true;
+ this.savedViews = this.readSavedViews();
}
- get kpis() {
- return (this.hub?.kpis ?? this.loadingKpis).map((kpi) => ({
- ...kpi,
- actionLabel: kpi.actionLabel ?? this.kpiActionLabels[kpi.key] ?? `Open ${kpi.label}`,
- }));
+ // ------------------------------------------------------------------
+ // Derived state
+ // ------------------------------------------------------------------
+
+ get items() {
+ return this.payload?.items ?? [];
+ }
+
+ get groups() {
+ return this.payload?.groups ?? [];
+ }
+
+ get counts() {
+ return this.payload?.counts ?? this.summary?.counts ?? {};
+ }
+
+ get stats() {
+ return this.summary?.summary ?? this.payload?.summary ?? {};
+ }
+
+ get meta() {
+ return this.payload?.meta ?? { total: 0, page: 1, pages: 1, limit: PAGE_SIZE };
+ }
+
+ get snoozeSchedule() {
+ return this.payload?.snooze_schedule ?? [];
+ }
+
+ get sourceErrors() {
+ return Object.keys(this.payload?.sources ?? {});
+ }
+
+ get sourceErrorsText() {
+ return this.sourceErrors.join(', ');
+ }
+
+ get previousPage() {
+ return Math.max(1, (this.meta.page ?? 1) - 1);
}
- get actions() {
- return (this.hub?.actions ?? []).map((action) => this.normalizeAction(action));
+ get nextPage() {
+ return Math.min(this.meta.pages ?? 1, (this.meta.page ?? 1) + 1);
}
- get sections() {
- return this.hub?.sections ?? [];
+ get activeFilters() {
+ return this.filters ? this.filters.split(',').filter(Boolean) : [];
}
- get docs() {
- return (this.hub?.docs ?? []).map((doc) => ({
- ...doc,
- description: doc.description ?? this.docDescriptions[doc.slug] ?? '',
+ get pills() {
+ return RADAR_PILLS.map((key) => ({
+ key,
+ label: this.intl.t(`radar.pills.${key}`),
+ count: this.counts[key] ?? 0,
+ active: this.activeFilters.includes(key),
+ dot: key === 'overdue',
}));
}
- docDescriptions = {
- 'fleet-ops/resources/drivers/overview': 'Create driver profiles, app access, assignment context, and operating status.',
- 'fleet-ops/resources/vehicles/overview': 'Track fleet assets, assignment readiness, and vehicle operating details.',
- 'fleet-ops/resources/fleets/overview': 'Group drivers and vehicles by team, region, or service coverage.',
- 'fleet-ops/resources/contacts/overview': 'Manage people and organizations used across orders and operational records.',
- 'fleet-ops/resources/places/overview': 'Manage reusable pickup, dropoff, hub, and facility locations.',
- 'fleet-ops/resources/issues/overview': 'Track incidents, service exceptions, and operational follow-up.',
- };
+ get isFiltered() {
+ return this.activeFilters.length > 0 || Boolean(this.q) || Boolean(this.category) || this.assigned === 'me';
+ }
+
+ get isLoading() {
+ return this.loadItems.isRunning && !this.payload;
+ }
+
+ get isEmpty() {
+ return !this.loadItems.isRunning && Boolean(this.payload) && this.items.length === 0;
+ }
+
+ get allViews() {
+ return [...RADAR_DEFAULT_VIEWS.map((view) => ({ ...view, label: this.intl.t(view.intl) })), ...this.savedViews];
+ }
+
+ get activeView() {
+ return this.allViews.find((view) => view.id === this.saved) ?? null;
+ }
+
+ get hasSelection() {
+ return this.selection.length > 0;
+ }
+
+ get selectedItems() {
+ return this.items.filter((item) => this.selection.includes(item.key));
+ }
- kpiActionLabels = {
- drivers: 'Manage Drivers',
- vehicles: 'Track Vehicles',
- issues: 'Review Issues',
- fuel_records: 'Review Fuel Records',
- };
+ get isAllSelected() {
+ return this.items.length > 0 && this.items.every((item) => this.selection.includes(item.key));
+ }
- normalizeAction(action = {}) {
- const query = action.query && typeof action.query === 'object' && !Array.isArray(action.query) ? action.query : {};
+ get focusedItem() {
+ return this.items.find((item) => item.key === this.focusedKey) ?? null;
+ }
+ /**
+ * With a selection, the action keys run over the selection; without one
+ * they act on the focused row.
+ */
+ get keyboardHandlers() {
return {
- ...action,
- query,
+ next: this.focusNext,
+ previous: this.focusPrevious,
+ select: this.toggleFocusedSelection,
+ acknowledge: () => this.actOnSelectionOrFocused('acknowledge'),
+ snooze: () => this.actOnSelectionOrFocused('snooze', snoozePayloadFor('1h')),
+ assign: () => this.actOnSelectionOrFocused('assign'),
+ open: this.openFocused,
+ close: this.closeOpen,
+ search: this.focusSearch,
+ };
+ }
+
+ // ------------------------------------------------------------------
+ // Loading
+ // ------------------------------------------------------------------
+
+ @task *reload() {
+ const loads = [this.loadSummary.perform(), this.loadItems.perform(), this.loadBriefing.perform()];
+ if (this.view === 'agenda') {
+ loads.push(this.loadAgenda.perform());
+ }
+ yield Promise.all(loads);
+ }
+
+ @task({ restartable: true }) *loadAgenda() {
+ try {
+ this.agenda = yield this.fetch.get('fleet-ops/radar/agenda', { window: this.window, fleet: this.fleet });
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @task({ restartable: true }) *loadBriefing() {
+ try {
+ this.briefing = yield this.fetch.get('fleet-ops/radar/briefing');
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @task({ restartable: true }) *loadItems(debounce = false) {
+ if (debounce) {
+ yield timeout(250);
+ }
+
+ const params = {
+ status: this.status,
+ filters: this.filters,
+ category: this.category,
+ fleet: this.fleet,
+ query: this.q,
+ assigned: this.assigned,
+ page: this.page,
+ limit: PAGE_SIZE,
+ };
+
+ try {
+ this.payload = yield this.fetch.get('fleet-ops/radar/items', params);
+ this.lastLoadedAt = new Date();
+ this.pruneSelection();
+ if (this.focusedKey && !this.items.some((item) => item.key === this.focusedKey)) {
+ this.focusedKey = null;
+ }
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @task({ restartable: true }) *loadSummary() {
+ try {
+ this.summary = yield this.fetch.get('fleet-ops/radar/summary');
+ } catch {
+ // The header falls back to the list's own summary.
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Filters, tabs, views
+ // ------------------------------------------------------------------
+
+ @action togglePill(key) {
+ const active = new Set(this.activeFilters);
+ if (active.has(key)) {
+ active.delete(key);
+ } else {
+ active.add(key);
+ }
+ this.filters = [...active].join(',');
+ this.saved = '';
+ this.page = 1;
+ this.loadItems.perform();
+ }
+
+ @action clearFilters() {
+ this.filters = '';
+ this.category = '';
+ this.q = '';
+ this.assigned = '';
+ this.saved = '';
+ this.page = 1;
+ this.loadItems.perform();
+ }
+
+ @action setStatus(status) {
+ if (status === this.status) {
+ return;
+ }
+ this.status = status;
+ this.page = 1;
+ this.selection = [];
+ this.loadItems.perform();
+ }
+
+ @action search(query) {
+ this.q = query ?? '';
+ this.page = 1;
+ this.loadItems.perform(true);
+ }
+
+ @action setView(view) {
+ this.view = view === 'agenda' ? 'agenda' : 'list';
+ this.appCache.set(VIEW_MODE_CACHE_KEY, this.view);
+ if (this.view === 'agenda') {
+ this.loadAgenda.perform();
+ }
+ }
+
+ @action setWindow(window) {
+ this.window = window === '7d' ? '7d' : '24h';
+ this.loadAgenda.perform();
+ }
+
+ /** An agenda entry is an item in another coat: open the same drawer. */
+ @action openAgendaEntry(entry) {
+ if (!entry?.key) {
+ return;
+ }
+ const item = this.items.find((row) => row.key === entry.key) ?? {
+ key: entry.key,
+ rule: entry.rule,
+ category: entry.category,
+ severity: entry.severity,
+ title: entry.title,
+ subject: entry.subject,
+ meta_line: entry.meta_line,
+ due_at: entry.at,
+ due_bucket: 'none',
+ due_label: entry.label,
+ window: null,
+ record: entry.record,
+ actions: entry.actions ?? [],
+ state: entry.state ?? { status: 'open' },
+ details: null,
};
+ this.openDrawer(item);
+ }
+
+ @action showAnytimeInList() {
+ this.setView('list');
+ this.clearFilters();
+ }
+
+ /** Dropping a tray item on a lane gives it a time. */
+ @task *planItem(key, plannedAt) {
+ try {
+ yield this.fetch.post(`fleet-ops/radar/items/${encodeURIComponent(key)}/plan`, { planned_at: plannedAt });
+ this.notifications.success(this.intl.t('radar.toasts.planned'));
+ yield Promise.all([this.loadAgenda.perform(), this.loadItems.perform()]);
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Handover
+ // ------------------------------------------------------------------
+
+ @task({ restartable: true }) *openHandover(key) {
+ this.handover = null;
+ try {
+ const response = yield this.fetch.get(`fleet-ops/radar/handovers/${encodeURIComponent(key)}`);
+ this.handover = response?.handover ?? null;
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @action closeHandover() {
+ this.handover = null;
}
- get loadingKpis() {
- return [
- { key: 'drivers', label: 'Drivers', value: '...', caption: 'Loading resource readiness.', tone: 'blue', icon: 'id-card', route: 'management.drivers' },
- { key: 'vehicles', label: 'Vehicles', value: '...', caption: 'Loading fleet asset coverage.', tone: 'green', icon: 'truck', route: 'management.vehicles' },
- { key: 'issues', label: 'Open Issues', value: '...', caption: 'Loading exception health.', tone: 'rose', icon: 'triangle-exclamation', route: 'management.issues' },
- { key: 'fuel_records', label: 'Fuel Records', value: '...', caption: 'Loading fuel activity.', tone: 'amber', icon: 'gas-pump', route: 'management.fuel-reports' },
- ];
+ @task({ drop: true }) *reassignOrders(handover) {
+ const ids = (handover?.orders ?? []).map((order) => order.uuid).filter(Boolean);
+ const driver = handover?.suggested?.driver;
+ if (!ids.length || !driver?.uuid) {
+ return;
+ }
+
+ try {
+ yield this.fetch.patch('orders/bulk-assign-driver', { ids, driver: driver.uuid });
+ this.notifications.success(this.intl.t('radar.handover.reassigned', { name: driver.label }));
+ this.handover = null;
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @task({ drop: true }) *extendShift(handover, minutes = 60) {
+ const shiftId = handover?.shift?.uuid ?? handover?.shift?.public_id;
+ if (!shiftId) {
+ return;
+ }
+
+ try {
+ yield this.fetch.post(`fleet-ops/radar/shifts/${shiftId}/extend`, { minutes });
+ this.notifications.success(this.intl.t('radar.handover.extended'));
+ this.handover = null;
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
}
- @action openDocs(link) {
- if (!link?.slug) {
+ @task({ drop: true }) *snoozeHandover(handover) {
+ if (!handover?.key) {
return;
}
+ yield this.stateAction({ key: handover.key }, 'snooze', { minutes: 60 });
+ this.handover = null;
+ this.loadAgenda.perform();
+ }
- return this.docsPanel.open(link.slug, {
- title: link.title ?? link.label,
- source: 'fleet-ops-resources-hub',
+ @action setPage(page) {
+ this.page = Math.max(1, page);
+ this.loadItems.perform();
+ }
+
+ @action applyView(view) {
+ this.saved = view.id;
+ this.status = view.status ?? 'open';
+ this.filters = view.filters ?? '';
+ this.category = view.category ?? '';
+ this.q = view.q ?? '';
+ this.assigned = view.assigned ?? '';
+ this.page = 1;
+ this.loadItems.perform();
+ }
+
+ @action saveCurrentView() {
+ return this.modalsManager.show('modals/radar-save-view', {
+ title: this.intl.t('radar.saved-views.save-current'),
+ acceptButtonText: this.intl.t('common.save'),
+ acceptButtonIcon: 'save',
+ name: '',
+ confirm: (modal) => {
+ const name = (modal.getOption('name') ?? '').trim();
+ if (!name) {
+ return this.notifications.warning(this.intl.t('radar.saved-views.name-prompt'));
+ }
+
+ const view = {
+ id: `view-${Date.now()}`,
+ label: name,
+ status: this.status,
+ filters: this.filters,
+ category: this.category,
+ q: this.q,
+ assigned: this.assigned,
+ };
+ this.savedViews = [...this.savedViews, view];
+ this.writeSavedViews();
+ this.saved = view.id;
+ this.notifications.success(this.intl.t('radar.saved-views.saved'));
+ modal.done();
+ },
});
}
- @task *loadHub() {
- this.hub = yield this.fetch.get('fleet-ops/hubs/resources');
+ @action deleteView(view) {
+ this.savedViews = this.savedViews.filter((saved) => saved.id !== view.id);
+ this.writeSavedViews();
+ if (this.saved === view.id) {
+ this.saved = '';
+ }
+ this.notifications.info(this.intl.t('radar.saved-views.deleted'));
+ }
+
+ readSavedViews() {
+ const views = this.appCache.get(VIEWS_CACHE_KEY, []);
+
+ return Array.isArray(views) ? views.filter((view) => view && view.id && view.label) : [];
+ }
+
+ writeSavedViews() {
+ this.appCache.set(VIEWS_CACHE_KEY, this.savedViews);
+ }
+
+ // ------------------------------------------------------------------
+ // Selection and focus
+ // ------------------------------------------------------------------
+
+ /**
+ * Toggle one row, or with Shift held set every row between the last row
+ * toggled and this one to this row's new state, the way a mail inbox does.
+ */
+ @action toggleSelect(item, { range = false } = {}) {
+ const key = item.key;
+ const select = !this.selection.includes(key);
+ const keys = this.items.map((row) => row.key);
+ const from = keys.indexOf(this.selectionAnchor);
+ const to = keys.indexOf(key);
+
+ let affected = [key];
+ if (range && from !== -1 && to !== -1) {
+ affected = keys.slice(Math.min(from, to), Math.max(from, to) + 1);
+ }
+
+ const selection = new Set(this.selection);
+ for (const affectedKey of affected) {
+ if (select) {
+ selection.add(affectedKey);
+ } else {
+ selection.delete(affectedKey);
+ }
+ }
+
+ this.selection = keys.filter((rowKey) => selection.has(rowKey));
+ this.selectionAnchor = key;
+ }
+
+ @action selectAll() {
+ this.selection = this.isAllSelected ? [] : this.items.map((item) => item.key);
+ }
+
+ @action clearSelection() {
+ this.selectionAnchor = null;
+ this.selection = [];
+ }
+
+ pruneSelection() {
+ const keys = new Set(this.items.map((item) => item.key));
+ this.selection = this.selection.filter((key) => keys.has(key));
+ }
+
+ @action focusItem(item) {
+ this.focusedKey = item?.key ?? null;
+ }
+
+ @action focusNext() {
+ this.moveFocus(1);
+ }
+
+ @action focusPrevious() {
+ this.moveFocus(-1);
+ }
+
+ moveFocus(step) {
+ const items = this.items;
+ if (!items.length) {
+ return;
+ }
+
+ const index = items.findIndex((item) => item.key === this.focusedKey);
+ const next = index === -1 ? (step > 0 ? 0 : items.length - 1) : Math.min(items.length - 1, Math.max(0, index + step));
+ this.focusedKey = items[next].key;
+ document.querySelector(`[data-radar-key="${CSS.escape(this.focusedKey)}"]`)?.scrollIntoView({ block: 'nearest' });
+ }
+
+ @action toggleFocusedSelection() {
+ if (this.focusedItem) {
+ this.toggleSelect(this.focusedItem);
+ }
+ }
+
+ @action openFocused() {
+ if (this.focusedItem) {
+ this.openDrawer(this.focusedItem);
+ }
+ }
+
+ actOnFocused(actionName, payload = {}) {
+ if (this.focusedItem) {
+ this.act.perform(this.focusedItem, actionName, payload);
+ }
+ }
+
+ actOnSelectionOrFocused(actionName, payload = {}) {
+ if (this.hasSelection) {
+ return this.bulkAct.perform(actionName, payload);
+ }
+
+ return this.actOnFocused(actionName, payload);
+ }
+
+ @action closeOpen() {
+ if (this.drawerItem) {
+ return this.closeDrawer();
+ }
+ if (this.hasSelection) {
+ return this.clearSelection();
+ }
+ }
+
+ @action focusSearch() {
+ document.querySelector('.fleet-ops-radar-search input')?.focus();
+ }
+
+ // ------------------------------------------------------------------
+ // Drawer
+ // ------------------------------------------------------------------
+
+ @action openDrawer(item) {
+ this.drawerItem = item;
+ this.focusedKey = item?.key ?? this.focusedKey;
+ }
+
+ @action closeDrawer() {
+ this.drawerItem = null;
+ }
+
+ // ------------------------------------------------------------------
+ // Acting on items
+ // ------------------------------------------------------------------
+
+ /**
+ * Every action a row, the drawer or a key can fire, by name. State
+ * actions post to Radar; record actions use the record's own endpoint.
+ */
+ @task *act(item, actionName, payload = {}) {
+ switch (actionName) {
+ case 'acknowledge':
+ return yield this.stateAction(item, 'acknowledge', {}, 'radar.toasts.acknowledged');
+ case 'snooze':
+ return yield this.snoozeItem(item, payload);
+ case 'wake':
+ return yield this.stateAction(item, 'wake', {}, 'radar.toasts.woken');
+ case 'assign':
+ return yield this.assignItem(item);
+ case 'unassign':
+ return yield this.stateAction(item, 'assign', {}, 'radar.toasts.unassigned');
+ case 'plan':
+ return yield this.stateAction(item, 'plan', { planned_at: payload.planned_at ?? null }, 'radar.toasts.planned');
+ case 'unplan':
+ return yield this.stateAction(item, 'plan', { planned_at: null }, 'radar.toasts.planned');
+ case 'resolve':
+ return yield this.stateAction(item, 'resolve', { resolution: payload.resolution ?? null }, 'radar.toasts.resolved');
+ case 'open_record':
+ return this.openRecord(item);
+ case 'assign_vehicle':
+ return yield this.assignVehicle(item);
+ case 'assign_driver':
+ return yield this.assignDriver(item);
+ case 'create_work_order':
+ return yield this.createWorkOrderFromSchedule(item);
+ case 'create_work_order_from_inspection':
+ return yield this.inspectionFollowUp(item, 'createWorkOrder');
+ case 'create_issue_from_inspection':
+ return yield this.inspectionFollowUp(item, 'createIssue');
+ case 'resolve_inspection':
+ return yield this.inspectionFollowUp(item, 'resolve');
+ case 'resolve_issue':
+ return yield this.resolveIssue(item);
+ case 'match_vehicle':
+ return yield this.matchVehicle(item);
+ case 'ignore_transaction':
+ return yield this.ignoreTransaction(item);
+ case 'attach_device':
+ return yield this.attachDevice(item);
+ case 'send_pin':
+ return yield this.sendPin(item);
+ case 'revoke_link':
+ return yield this.revokeLink(item);
+ case 'call':
+ return this.callDriver(item);
+ case 'delete_notice':
+ return yield this.deleteNotice(item);
+ case 'cover_shift':
+ case 'handover':
+ return yield this.openHandover.perform(item.key);
+ case 'extend_shift': {
+ const shiftId = item.source?.uuid ?? item.source?.public_id;
+ return shiftId ? yield this.extendShift.perform({ shift: { uuid: shiftId } }, 60) : null;
+ }
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * One state action over the selection: acknowledge, snooze, wake or
+ * assign. Assign asks who first, then posts once for every key.
+ */
+ @task({ drop: true }) *bulkAct(actionName, payload = {}) {
+ const keys = [...this.selection];
+ if (!keys.length) {
+ return;
+ }
+
+ if (actionName === 'assign') {
+ return yield this.bulkAssign(keys);
+ }
+
+ if (actionName === 'revoke_link') {
+ return yield this.bulkRevokeLinks(this.selectedItems.filter((item) => item.actions?.includes('revoke_link')));
+ }
+
+ const body = { keys, action: actionName };
+ if (actionName === 'snooze') {
+ Object.assign(body, payload.minutes || payload.until ? { minutes: payload.minutes, until: payload.until } : snoozePayloadFor(payload.preset ?? '1h'));
+ }
+
+ yield this.postBulk(body);
+ }
+
+ async bulkAssign(keys) {
+ return this.modalsManager.show('modals/radar-select-resource', {
+ title: this.intl.t('radar.prompts.assign-user-title'),
+ helpText: this.intl.t('radar.prompts.assign-user-help'),
+ inputLabel: this.intl.t('radar.prompts.select-user'),
+ placeholder: this.intl.t('radar.prompts.select-user'),
+ modelName: 'user',
+ query: { is_not_customer: true },
+ allowClear: true,
+ selected: null,
+ acceptButtonText: this.intl.t('radar.actions.assign'),
+ acceptButtonIcon: 'user-plus',
+ confirm: async (modal) => {
+ const user = modal.getOption('selected');
+ modal.startLoading();
+ const ok = await this.postBulk({ keys, action: 'assign', user: user?.id ?? null });
+ modal.stopLoading();
+ if (ok) {
+ modal.done();
+ }
+ },
+ });
+ }
+
+ /**
+ * Revoke every selected inspection link at once. Each link is its own
+ * delete, so one failure does not stop the rest; the toast counts both.
+ */
+ async bulkRevokeLinks(items) {
+ if (!items.length) {
+ return;
+ }
+
+ return this.modalsManager.confirm({
+ title: this.intl.t('radar.prompts.bulk-revoke-link-title', { count: items.length }),
+ body: this.intl.t('radar.prompts.bulk-revoke-link-body'),
+ acceptButtonText: this.intl.t('radar.actions.revoke-links', { count: items.length }),
+ acceptButtonIcon: 'link-slash',
+ acceptButtonType: 'danger',
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ const results = await Promise.allSettled(
+ items.map((item) => {
+ const formId = item.source?.form_uuid ?? item.source?.form_public_id;
+ const linkId = item.source?.uuid ?? item.source?.public_id;
+
+ return this.fetch.delete(`inspection-forms/${formId}/links/${linkId}`);
+ })
+ );
+ const revoked = results.filter((result) => result.status === 'fulfilled').length;
+ const failed = results.length - revoked;
+
+ if (revoked) {
+ this.notifications.success(this.intl.t('radar.toasts.links-revoked', { count: revoked }));
+ }
+ if (failed) {
+ this.notifications.error(this.intl.t('radar.toasts.links-revoke-failed', { count: failed }));
+ }
+
+ this.selection = [];
+ modal.done();
+ this.reload.perform();
+ },
+ });
+ }
+
+ async postBulk(body) {
+ try {
+ const response = await this.fetch.post('fleet-ops/radar/items/bulk', body);
+ const results = response?.results ?? [];
+ for (const result of results) {
+ if (result.ok && result.state) {
+ this.applyState({ key: result.key }, result.state);
+ }
+ }
+ const updated = results.filter((result) => result.ok).length;
+ this.selection = [];
+ this.notifications.success(this.intl.t('radar.toasts.bulk-done', { count: updated }));
+ this.loadSummary.perform();
+
+ return true;
+ } catch (err) {
+ this.notifications.serverError(err);
+
+ return false;
+ }
+ }
+
+ async stateAction(item, endpoint, body = {}, toastKey = null, toastParams = {}) {
+ try {
+ const response = await this.fetch.post(`fleet-ops/radar/items/${encodeURIComponent(item.key)}/${endpoint}`, body);
+ this.applyState(item, response?.state ?? response?.item?.state);
+ if (toastKey) {
+ this.notifications.success(this.intl.t(toastKey, toastParams));
+ }
+ this.loadSummary.perform();
+
+ return response;
+ } catch (err) {
+ this.notifications.serverError(err);
+
+ return null;
+ }
+ }
+
+ /**
+ * Put a fresh state on the row, or take the row off this tab when the
+ * state moved it to another one.
+ */
+ applyState(item, state) {
+ if (!state) {
+ return;
+ }
+
+ const leavesTab = (this.status === 'open' && ['snoozed', 'resolved'].includes(state.status)) || (this.status === 'snoozed' && state.status !== 'snoozed');
+ this.payload = patchPayload(this.payload, item.key, (row) => (leavesTab ? null : { ...row, state }));
+
+ if (this.drawerItem?.key === item.key) {
+ this.drawerItem = leavesTab ? null : { ...this.drawerItem, state };
+ }
+ if (leavesTab) {
+ this.selection = this.selection.filter((key) => key !== item.key);
+ if (this.focusedKey === item.key) {
+ this.focusedKey = null;
+ }
+ }
+ }
+
+ async snoozeItem(item, payload = {}) {
+ const body = payload.minutes || payload.until ? payload : snoozePayloadFor(payload.preset ?? '1h');
+ const response = await this.stateAction(item, 'snooze', body);
+ if (response?.state?.snoozed_until) {
+ this.notifications.success(this.intl.t('radar.toasts.snoozed', { time: this.formatTime(response.state.snoozed_until) }));
+ }
+
+ return response;
+ }
+
+ async assignItem(item) {
+ return this.modalsManager.show('modals/radar-select-resource', {
+ title: this.intl.t('radar.prompts.assign-user-title'),
+ helpText: this.intl.t('radar.prompts.assign-user-help'),
+ inputLabel: this.intl.t('radar.prompts.select-user'),
+ placeholder: this.intl.t('radar.prompts.select-user'),
+ modelName: 'user',
+ query: { is_not_customer: true },
+ allowClear: true,
+ selected: null,
+ acceptButtonText: this.intl.t('radar.actions.assign'),
+ acceptButtonIcon: 'user-plus',
+ confirm: async (modal) => {
+ const user = modal.getOption('selected');
+ modal.startLoading();
+ const response = await this.stateAction(item, 'assign', { user: user?.id ?? null }, user ? 'radar.toasts.assigned' : 'radar.toasts.unassigned', { name: user?.name });
+ modal.stopLoading();
+ if (response) {
+ modal.done();
+ }
+ },
+ });
+ }
+
+ async assignVehicle(item) {
+ const driver = await this.findRecord('driver', item.subject?.public_id);
+ if (!driver) {
+ return;
+ }
+
+ return this.modalsManager.show('modals/driver-assign-vehicle', {
+ title: this.intl.t('radar.prompts.assign-vehicle-title', { name: driver.name }),
+ acceptButtonText: this.intl.t('radar.actions.assign-vehicle'),
+ acceptButtonIcon: 'check',
+ hideDeclineButton: true,
+ driver,
+ confirm: async (modal) => {
+ const vehicleId = driver.vehicle_uuid ?? driver.vehicle?.id;
+ if (!vehicleId) {
+ return this.notifications.warning(this.intl.t('radar.prompts.no-selection'));
+ }
+
+ modal.startLoading();
+ try {
+ await this.fetch.post(`drivers/${driver.id}/assign-vehicle`, { vehicle: vehicleId });
+ this.notifications.success(this.intl.t('radar.toasts.vehicle-assigned'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async assignDriver(item) {
+ const vehicleId = item.subject?.uuid ?? item.subject?.public_id;
+
+ return this.modalsManager.show('modals/radar-select-resource', {
+ title: this.intl.t('radar.prompts.assign-driver-title', { name: item.subject?.label }),
+ inputLabel: this.intl.t('radar.prompts.select-user'),
+ placeholder: this.intl.t('radar.actions.assign-driver'),
+ modelName: 'driver',
+ selected: null,
+ acceptButtonText: this.intl.t('radar.actions.assign-driver'),
+ acceptButtonIcon: 'check',
+ confirm: async (modal) => {
+ const driver = modal.getOption('selected');
+ if (!driver) {
+ return this.notifications.warning(this.intl.t('radar.prompts.no-selection'));
+ }
+
+ modal.startLoading();
+ try {
+ await this.fetch.post(`vehicles/${vehicleId}/assign-driver`, { driver: driver.id });
+ this.notifications.success(this.intl.t('radar.toasts.driver-assigned'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async createWorkOrderFromSchedule(item) {
+ const scheduleId = item.source?.uuid ?? item.source?.public_id;
+
+ return this.modalsManager.confirm({
+ title: this.intl.t('radar.prompts.create-work-order-title'),
+ body: this.intl.t('radar.prompts.create-work-order-body'),
+ icon: 'clipboard-list',
+ iconClass: 'text-blue-500',
+ acceptButtonText: this.intl.t('radar.actions.create-work-order'),
+ acceptButtonScheme: 'primary',
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ await this.fetch.post(`maintenance-schedules/${scheduleId}/trigger`);
+ this.notifications.success(this.intl.t('radar.toasts.work-order-created'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async inspectionFollowUp(item, kind) {
+ const submission = await this.findRecord('inspection-submission', item.source?.public_id ?? item.source?.uuid);
+ if (!submission) {
+ return;
+ }
+
+ await this.inspectionSubmissionActions[kind](submission);
+ this.reload.perform();
+ }
+
+ /**
+ * Close an issue the way its details panel does: the close-issue modal,
+ * which asks for the resolution note and records who closed it.
+ */
+ async resolveIssue(item) {
+ const issue = await this.findRecord('issue', item.source?.public_id ?? item.source?.uuid, recordPanelFor('management.issues')?.include);
+ if (!issue) {
+ return;
+ }
+
+ return this.issueActions.openCloseIssueModal(issue, { onSaved: () => this.reload.perform() });
+ }
+
+ async matchVehicle(item) {
+ const transactionId = item.source?.uuid ?? item.source?.public_id;
+ const suggested = item.source?.suggested_vehicle;
+
+ return this.modalsManager.show('modals/radar-select-resource', {
+ title: this.intl.t('radar.prompts.match-vehicle-title'),
+ body: suggested ? `${this.intl.t('radar.actions.match-vehicle')}: ${suggested.label}` : null,
+ inputLabel: this.intl.t('radar.prompts.select-vehicle'),
+ placeholder: this.intl.t('radar.prompts.select-vehicle'),
+ modelName: 'vehicle',
+ selected: null,
+ selectedId: suggested?.uuid ?? null,
+ acceptButtonText: this.intl.t('radar.actions.match-vehicle'),
+ acceptButtonIcon: 'link',
+ confirm: async (modal) => {
+ const vehicle = modal.getOption('selected');
+ const vehicleId = vehicle?.id ?? modal.getOption('selectedId');
+ if (!vehicleId) {
+ return this.notifications.warning(this.intl.t('radar.prompts.no-selection'));
+ }
+
+ modal.startLoading();
+ try {
+ await this.fetch.post(`fuel-provider-transactions/${transactionId}/match-vehicle`, { vehicle: vehicleId });
+ this.notifications.success(this.intl.t('radar.toasts.matched'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async ignoreTransaction(item) {
+ const transactionId = item.source?.uuid ?? item.source?.public_id;
+
+ return this.modalsManager.confirm({
+ title: this.intl.t('radar.prompts.ignore-title'),
+ body: this.intl.t('radar.prompts.ignore-body'),
+ acceptButtonText: this.intl.t('radar.actions.ignore'),
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ await this.fetch.post(`fuel-provider-transactions/${transactionId}/review`, { status: 'ignored' });
+ this.notifications.success(this.intl.t('radar.toasts.ignored'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async attachDevice(item) {
+ const deviceId = item.source?.uuid ?? item.source?.public_id;
+
+ return this.modalsManager.show('modals/radar-select-resource', {
+ title: this.intl.t('radar.prompts.attach-device-title'),
+ inputLabel: this.intl.t('radar.prompts.select-vehicle'),
+ placeholder: this.intl.t('radar.prompts.select-vehicle'),
+ modelName: 'vehicle',
+ selected: null,
+ acceptButtonText: this.intl.t('radar.actions.attach'),
+ acceptButtonIcon: 'link',
+ confirm: async (modal) => {
+ const vehicle = modal.getOption('selected');
+ if (!vehicle) {
+ return this.notifications.warning(this.intl.t('radar.prompts.no-selection'));
+ }
+
+ modal.startLoading();
+ try {
+ await this.fetch.post(`devices/${deviceId}/attach`, { vehicle: vehicle.id });
+ this.notifications.success(this.intl.t('radar.toasts.device-attached'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Send an inspection link's PIN, asking how first: by email or by text,
+ * offering only the channels the link's recipient can receive, the same
+ * choice the inspection form's link list gives.
+ */
+ async sendPin(item) {
+ const formId = item.source?.form_uuid ?? item.source?.form_public_id;
+ const linkId = item.source?.uuid ?? item.source?.public_id;
+
+ let link;
+ try {
+ const response = await this.fetch.get(`inspection-forms/${formId}/links`);
+ link = (response?.links ?? []).find((candidate) => [candidate.id, candidate.uuid, candidate.public_id].includes(linkId));
+ } catch (err) {
+ return this.notifications.serverError(err);
+ }
+
+ if (!link?.has_pin) {
+ return this.notifications.warning(this.intl.t('inspection.link.no-pin'));
+ }
+
+ const channels = ['email', 'sms'].filter((via) => link.can_send_pin?.[via]);
+ if (!channels.length) {
+ return this.notifications.warning(this.intl.t('radar.prompts.send-pin-unavailable'));
+ }
+
+ return this.modalsManager.show('modals/radar-send-pin', {
+ title: this.intl.t('radar.prompts.send-pin-title'),
+ recipient: link.recipient?.name ?? null,
+ channels,
+ via: channels[0],
+ acceptButtonText: this.intl.t('radar.actions.send-pin'),
+ acceptButtonIcon: 'paper-plane',
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ const response = await this.fetch.post(`inspection-forms/${formId}/links/${link.id ?? linkId}/send-pin`, { via: modal.getOption('via') });
+ this.inspectionFormActions.notifyPinDelivery(response?.pin_delivery);
+ modal.done();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async revokeLink(item) {
+ const formId = item.source?.form_uuid ?? item.source?.form_public_id;
+ const linkId = item.source?.uuid ?? item.source?.public_id;
+
+ return this.modalsManager.confirm({
+ title: this.intl.t('radar.prompts.revoke-link-title'),
+ body: this.intl.t('radar.prompts.revoke-link-body'),
+ acceptButtonText: this.intl.t('radar.actions.revoke-link'),
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ await this.fetch.delete(`inspection-forms/${formId}/links/${linkId}`);
+ this.notifications.success(this.intl.t('radar.toasts.link-revoked'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ callDriver(item) {
+ const phone = item.subject?.phone;
+ if (!phone) {
+ return this.openRecord(item);
+ }
+
+ window.open(`tel:${phone}`, '_self');
+ }
+
+ /**
+ * Open the record behind a row, a decision card, a handover or a brief
+ * link in a context panel, so Radar stays on screen. Resources with an
+ * action service use its `panel.view`; the rest open their details
+ * component in a plain panel. Only a record Radar has no panel for falls
+ * back to navigating to it.
+ */
+ @action openRecord(target) {
+ const record = recordOf(target);
+ if (!record?.route) {
+ return;
+ }
+
+ return this.openRecordPanel.perform(record);
+ }
+
+ @task *openRecordPanel(record) {
+ const panel = recordPanelFor(record.route);
+ if (!panel) {
+ return this.hostRouter.transitionTo(`console.fleet-ops.${record.route}`, record.model);
+ }
+
+ const resource = yield this.findRecord(panel.modelName, record.model, panel.include);
+ if (!resource) {
+ return;
+ }
+
+ const actions = panel.service ? getOwner(this).lookup(`service:${panel.service}`) : null;
+ if (typeof actions?.panel?.view === 'function') {
+ return yield actions.panel.view(resource);
+ }
+
+ return this.resourceContextPanel.open({
+ resource,
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: panel.component }],
+ ...PANEL_DEFAULTS,
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // Morning brief
+ // ------------------------------------------------------------------
+
+ @action toggleBriefing() {
+ this.briefingCollapsed = !this.briefingCollapsed;
+ this.appCache.set(BRIEFING_CACHE_KEY, this.briefingCollapsed);
+ }
+
+ /** A category row's "Filter": show exactly the items behind that score. */
+ @action filterCategory(category) {
+ this.category = this.category === category ? '' : category;
+ this.status = 'open';
+ this.saved = '';
+ this.page = 1;
+ this.view = 'list';
+ this.loadItems.perform();
+ }
+
+ /**
+ * Confirm a decision card: make the call it describes, keep a strip with
+ * its undo, and refresh the list the change affects.
+ */
+ @task({ drop: true }) *confirmDecision(decision, call) {
+ if (!call?.endpoint) {
+ return;
+ }
+
+ this.busyDecisionKey = decision.key;
+ try {
+ yield this.performCall(call);
+ this.addStrip({ kind: 'confirmed', key: decision.key, title: decision.title, at: new Date().toISOString(), undo: call.undo ?? null, keys: decision.keys ?? [] });
+ this.removeDecision(decision.key);
+ this.notifications.success(call.label ?? this.intl.t('radar.briefing.confirm'));
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ } finally {
+ this.busyDecisionKey = null;
+ }
+ }
+
+ /** An alternative is either another call or a prompt (pick another vehicle). */
+ @task({ drop: true }) *decisionAlternative(decision, alternative) {
+ if (alternative?.endpoint) {
+ return yield this.confirmDecision.perform(decision, alternative);
+ }
+
+ if (alternative?.action === 'pick_vehicle') {
+ const item = { subject: decision.subject, key: decision.keys?.[0] };
+ yield this.assignVehicle(item);
+ }
+ }
+
+ /** "Not now": snooze the items behind the card for a day. */
+ @task({ drop: true }) *dismissDecision(decision) {
+ const keys = decision.keys ?? [];
+ if (!keys.length) {
+ return this.removeDecision(decision.key);
+ }
+
+ this.busyDecisionKey = decision.key;
+ try {
+ const response = yield this.fetch.post('fleet-ops/radar/items/bulk', { keys, action: 'snooze', minutes: 1440 });
+ const until = (response?.results ?? []).find((result) => result.ok)?.state?.snoozed_until ?? null;
+ for (const result of response?.results ?? []) {
+ if (result.ok && result.state) {
+ this.applyState({ key: result.key }, result.state);
+ }
+ }
+ this.addStrip({ kind: 'snoozed', key: decision.key, title: decision.title, until, keys });
+ this.removeDecision(decision.key);
+ this.loadSummary.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ } finally {
+ this.busyDecisionKey = null;
+ }
+ }
+
+ @task({ drop: true }) *undoStrip(strip) {
+ if (!strip.undo?.endpoint) {
+ return;
+ }
+
+ try {
+ yield this.performCall(strip.undo);
+ this.strips = this.strips.filter((entry) => entry !== strip);
+ this.notifications.info(strip.undo.label ?? this.intl.t('radar.briefing.undo'));
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @task({ drop: true }) *wakeStrip(strip) {
+ try {
+ yield this.fetch.post('fleet-ops/radar/items/bulk', { keys: strip.keys ?? [], action: 'wake' });
+ this.strips = this.strips.filter((entry) => entry !== strip);
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ performCall(call) {
+ const method = String(call.method ?? 'POST').toLowerCase();
+ const body = call.body ?? {};
+
+ switch (method) {
+ case 'patch':
+ return this.fetch.patch(call.endpoint, body);
+ case 'put':
+ return this.fetch.put(call.endpoint, body);
+ case 'delete':
+ return this.fetch.delete(call.endpoint, body);
+ default:
+ return this.fetch.post(call.endpoint, body);
+ }
+ }
+
+ addStrip(strip) {
+ this.strips = [strip, ...this.strips].slice(0, 8);
+ }
+
+ removeDecision(key) {
+ if (!this.briefing) {
+ return;
+ }
+
+ this.briefing = { ...this.briefing, decisions: (this.briefing.decisions ?? []).filter((decision) => decision.key !== key) };
+ }
+
+ // ------------------------------------------------------------------
+ // Notices
+ // ------------------------------------------------------------------
+
+ @action newNotice() {
+ return this.modalsManager.show('modals/radar-notice', {
+ title: this.intl.t('radar.notice.title'),
+ acceptButtonText: this.intl.t('radar.notice.create'),
+ acceptButtonIcon: 'bullhorn',
+ notice: { message: '', severity: 'info', due_at: '', scope: '' },
+ confirm: async (modal) => {
+ const notice = modal.getOption('notice');
+ if (!notice.message?.trim()) {
+ return this.notifications.warning(this.intl.t('radar.notice.message'));
+ }
+
+ modal.startLoading();
+ try {
+ await this.fetch.post('fleet-ops/radar/notices', {
+ message: notice.message,
+ severity: notice.severity,
+ due_at: notice.due_at || null,
+ scope: notice.scope || null,
+ });
+ this.notifications.success(this.intl.t('radar.notice.created'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async deleteNotice(item) {
+ const id = item.source?.public_id ?? item.source?.uuid;
+
+ return this.modalsManager.confirm({
+ title: this.intl.t('radar.notice.delete'),
+ acceptButtonText: this.intl.t('common.delete'),
+ acceptButtonScheme: 'danger',
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ await this.fetch.delete(`fleet-ops/radar/notices/${id}`);
+ this.payload = patchPayload(this.payload, item.key, () => null);
+ if (this.drawerItem?.key === item.key) {
+ this.drawerItem = null;
+ }
+ this.notifications.success(this.intl.t('radar.notice.deleted'));
+ modal.done();
+ this.loadSummary.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // Plumbing
+ // ------------------------------------------------------------------
+
+ /**
+ * Load a record Radar points at by its public id (or uuid), the way the
+ * details routes do. `store.findRecord` with a public id registers the
+ * record under an identifier its payload then contradicts, which Ember
+ * Data rejects with "You should not change the
of a
+ * RecordIdentifier" once the record is already in the store.
+ */
+ async findRecord(modelName, id, include = []) {
+ if (!id) {
+ return null;
+ }
+
+ const field = UUID_PATTERN.test(id) ? 'uuid' : 'public_id';
+ const query = { [field]: id, single: true };
+ if (include?.length) {
+ query.with = include;
+ }
+
+ try {
+ return await this.store.queryRecord(modelName, query);
+ } catch (err) {
+ this.notifications.serverError(err);
+
+ return null;
+ }
+ }
+
+ formatTime(value) {
+ const date = value ? new Date(value) : null;
+ if (!date || Number.isNaN(date.getTime())) {
+ return '';
+ }
+
+ return format(date, 'EEE d MMM HH:mm');
}
}
diff --git a/addon/controllers/management/issues/index/details.js b/addon/controllers/management/issues/index/details.js
index 3edfaf6da..748d72224 100644
--- a/addon/controllers/management/issues/index/details.js
+++ b/addon/controllers/management/issues/index/details.js
@@ -13,39 +13,7 @@ export default class ManagementIssuesIndexDetailsController extends Controller {
},
];
- get isClosed() {
- return ['closed', 'resolved', 'completed'].includes(this.model?.status);
- }
-
get actionButtons() {
- const workflowItems = [
- {
- label: 'Change Status',
- icon: 'arrows-rotate',
- fn: () => this.issueActions.openStatusModal(this.model, { onSaved: () => this.refreshIssue() }),
- },
- {
- label: 'Assign Issue',
- icon: 'user-check',
- fn: () => this.issueActions.openAssignModal(this.model, { onSaved: () => this.refreshIssue() }),
- },
- ];
-
- if (this.isClosed) {
- workflowItems.push({
- label: 'Re-open Issue',
- icon: 'rotate-left',
- fn: () => this.issueActions.confirmReopenIssue(this.model, { onSaved: () => this.refreshIssue() }),
- });
- } else {
- workflowItems.push({
- label: 'Close Issue',
- icon: 'circle-check',
- class: 'text-green-600 dark:text-green-400',
- fn: () => this.issueActions.openCloseIssueModal(this.model, { onSaved: () => this.refreshIssue() }),
- });
- }
-
return [
{
icon: 'pencil',
@@ -54,7 +22,7 @@ export default class ManagementIssuesIndexDetailsController extends Controller {
{
icon: 'ellipsis',
type: 'default',
- items: workflowItems,
+ items: this.issueActions.workflowItems(this.model, () => this.refreshIssue()),
},
];
}
diff --git a/addon/controllers/management/trailers/index/details.js b/addon/controllers/management/trailers/index/details.js
index e072337cd..1f5907c79 100644
--- a/addon/controllers/management/trailers/index/details.js
+++ b/addon/controllers/management/trailers/index/details.js
@@ -24,10 +24,6 @@ export default class ManagementTrailersIndexDetailsController extends Controller
];
}
- get isAttached() {
- return this.model?.isAttached ?? this.model?.attachment_state === 'attached';
- }
-
get actionButtons() {
return [
{
@@ -36,83 +32,13 @@ export default class ManagementTrailersIndexDetailsController extends Controller
permission: 'fleet-ops update trailer',
},
{
- // Actions dropdown — mirrors the row-level actions from the trailers index table
+ // Actions dropdown — shared with the trailer context panel
icon: 'ellipsis-h',
iconPrefix: 'fas',
renderInPlace: true,
- items: [
- {
- text: this.intl.t('trailer.actions.locate'),
- icon: 'location-dot',
- fn: () => this.trailerActions.locate(this.model),
- permission: 'fleet-ops view trailer',
- },
- ...(this.isAttached
- ? [
- {
- text: this.intl.t('trailer.actions.detach-vehicle'),
- icon: 'unlink',
- fn: () => this.trailerActions.detachVehicle(this.model),
- permission: 'fleet-ops detach-vehicle-for trailer',
- },
- ]
- : [
- {
- text: this.intl.t('trailer.actions.attach-vehicle'),
- icon: 'link',
- fn: () => this.trailerActions.attachVehicle(this.model),
- permission: 'fleet-ops attach-vehicle-for trailer',
- },
- ]),
- {
- text: this.intl.t('trailer.actions.attach-device'),
- icon: 'microchip',
- fn: () => this.trailerActions.attachDevice(this.model),
- permission: 'fleet-ops attach-device-for trailer',
- },
- {
- text: this.intl.t('trailer.actions.attach-equipment'),
- icon: 'toolbox',
- fn: () => this.trailerActions.attachEquipment(this.model),
- permission: 'fleet-ops attach-equipment-for trailer',
- },
- {
- separator: true,
- },
- {
- text: this.intl.t('trailer.actions.schedule-maintenance'),
- icon: 'calendar-check',
- fn: () => this.trailerActions.scheduleMaintenance(this.model),
- permission: 'fleet-ops create maintenance-schedule',
- },
- {
- text: this.intl.t('trailer.actions.create-work-order'),
- icon: 'clipboard-list',
- fn: () => this.trailerActions.createWorkOrder(this.model),
- permission: 'fleet-ops create work-order',
- },
- {
- text: this.intl.t('trailer.actions.log-maintenance'),
- icon: 'wrench',
- fn: () => this.trailerActions.logMaintenance(this.model),
- permission: 'fleet-ops create maintenance',
- },
- {
- separator: true,
- },
- {
- text: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.trailer') }),
- icon: 'trash',
- fn: () =>
- this.trailerActions.delete(this.model, {
- onConfirm: () => {
- this.hostRouter.transitionTo('console.fleet-ops.management.trailers.index');
- },
- }),
- permission: 'fleet-ops delete trailer',
- class: 'text-red-500 hover:text-red-600',
- },
- ],
+ items: this.trailerActions.detailsMenuItems(this.model, {
+ onDeleted: () => this.hostRouter.transitionTo('console.fleet-ops.management.trailers.index'),
+ }),
},
];
}
diff --git a/addon/controllers/management/vehicles/index/details.js b/addon/controllers/management/vehicles/index/details.js
index 61f0483d0..a159b35cc 100644
--- a/addon/controllers/management/vehicles/index/details.js
+++ b/addon/controllers/management/vehicles/index/details.js
@@ -1,11 +1,9 @@
import Controller from '@ember/controller';
-import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { isArray } from '@ember/array';
export default class ManagementVehiclesIndexDetailsController extends Controller {
@service vehicleActions;
- @service issueActions;
@service('universe/menu-service') menuService;
@service hostRouter;
@service intl;
@@ -70,85 +68,14 @@ export default class ManagementVehiclesIndexDetailsController extends Controller
permission: 'fleet-ops update vehicle',
},
{
- // Actions dropdown — mirrors the row-level actions from the vehicles index table
+ // Actions dropdown — shared with the vehicle context panel
icon: 'ellipsis-h',
iconPrefix: 'fas',
renderInPlace: true,
- items: [
- {
- text: this.intl.t('vehicle.actions.locate-vehicle'),
- icon: 'location-dot',
- fn: () => this.vehicleActions.locate(this.model),
- permission: 'fleet-ops view vehicle',
- },
- {
- text: this.intl.t('vehicle.actions.attach-device'),
- icon: 'link',
- fn: () => this.vehicleActions.attachDevice(this.model),
- permission: 'fleet-ops update vehicle',
- },
- ...(Number(this.model.assigned_orders_count) > 0
- ? [
- {
- text: this.intl.t('vehicle.actions.unassign-orders'),
- icon: 'truck-ramp-box',
- fn: () => this.vehicleActions.unassignOrders(this.model),
- permission: 'fleet-ops update vehicle',
- },
- ]
- : []),
- {
- separator: true,
- },
- {
- text: this.intl.t('vehicle.actions.schedule-maintenance'),
- icon: 'calendar-check',
- fn: () => this.vehicleActions.scheduleMaintenance(this.model),
- permission: 'fleet-ops create maintenance-schedule',
- },
- {
- text: this.intl.t('vehicle.actions.create-work-order'),
- icon: 'clipboard-list',
- fn: () => this.vehicleActions.createWorkOrder(this.model),
- permission: 'fleet-ops create work-order',
- },
- {
- text: this.intl.t('vehicle.actions.log-maintenance'),
- icon: 'wrench',
- fn: () => this.vehicleActions.logMaintenance(this.model),
- permission: 'fleet-ops create maintenance',
- },
- {
- text: this.intl.t('vehicle.actions.create-issue'),
- icon: 'triangle-exclamation',
- fn: () => this.createIssue(this.model),
- permission: 'fleet-ops create issue',
- },
- {
- separator: true,
- },
- {
- text: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.vehicle') }),
- icon: 'trash',
- fn: () =>
- this.vehicleActions.delete(this.model, {
- onConfirm: () => {
- this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index');
- },
- }),
- permission: 'fleet-ops delete vehicle',
- class: 'text-red-500 hover:text-red-600',
- },
- ],
+ items: this.vehicleActions.detailsMenuItems(this.model, {
+ onDeleted: () => this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index'),
+ }),
},
];
}
-
- @action createIssue(vehicle) {
- this.issueActions.modal.create({
- vehicle,
- vehicle_uuid: vehicle.id,
- title: this.intl.t('vehicle.prompts.issue-title', { vehicleName: vehicle.displayName ?? vehicle.name }),
- });
- }
}
diff --git a/addon/extension.js b/addon/extension.js
index 0a6bc89a6..a1a05121f 100644
--- a/addon/extension.js
+++ b/addon/extension.js
@@ -13,6 +13,12 @@ export default {
priority: 0,
description: 'Dispatch, fleet management, driver tracking, and logistics operations.',
shortcuts: [
+ {
+ title: 'Radar',
+ description: 'Everything across resources, maintenance and staffing that needs a decision today.',
+ icon: 'satellite-dish',
+ route: 'console.fleet-ops.management.index',
+ },
{
title: 'Orders',
description: 'Create, dispatch, and track delivery orders in real time.',
@@ -220,6 +226,16 @@ export default {
category: 'KPI Tiles',
default: true,
}),
+ new Widget({
+ id: 'fleet-ops-radar-widget',
+ name: 'Radar',
+ description: 'What needs a decision across the fleet right now: open, overdue and snoozed items, linking to Radar.',
+ icon: 'satellite-dish',
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/radar'),
+ grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
+ category: 'KPI Tiles',
+ default: true,
+ }),
new Widget({
id: 'fleet-ops-kpi-open-issues-widget',
name: 'Open Issues',
diff --git a/addon/helpers/initials.js b/addon/helpers/initials.js
new file mode 100644
index 000000000..74507035b
--- /dev/null
+++ b/addon/helpers/initials.js
@@ -0,0 +1,6 @@
+import { helper } from '@ember/component/helper';
+import { initialsOf } from '../utils/radar';
+
+export default helper(function initials([name]) {
+ return initialsOf(name);
+});
diff --git a/addon/modifiers/radar-keyboard.js b/addon/modifiers/radar-keyboard.js
new file mode 100644
index 000000000..f34a6e150
--- /dev/null
+++ b/addon/modifiers/radar-keyboard.js
@@ -0,0 +1,78 @@
+import { modifier } from 'ember-modifier';
+
+/**
+ * The Radar keyboard layer: j/k move, x selects, e acknowledges, s snoozes,
+ * a assigns, Enter opens the record, Escape closes what is open.
+ *
+ * Keys are ignored while the user is typing in a field or a modal or
+ * overlay is open, so the list never steals a keystroke from a form.
+ * `handlers` is a hash of key => function; a missing key is ignored.
+ */
+const KEYS = {
+ j: 'next',
+ k: 'previous',
+ ArrowDown: 'next',
+ ArrowUp: 'previous',
+ x: 'select',
+ e: 'acknowledge',
+ s: 'snooze',
+ a: 'assign',
+ Enter: 'open',
+ Escape: 'close',
+ '/': 'search',
+};
+
+export function isTypingTarget(target) {
+ if (!target || typeof target.closest !== 'function') {
+ return false;
+ }
+
+ if (target.isContentEditable) {
+ return true;
+ }
+
+ const tag = (target.tagName || '').toLowerCase();
+ if (['input', 'textarea', 'select'].includes(tag)) {
+ return true;
+ }
+
+ return Boolean(target.closest('.ember-basic-dropdown-content, .modal, .next-content-overlay-panel, [role="dialog"], .ember-power-select-dropdown'));
+}
+
+export default modifier(function radarKeyboard(element, [handlers = {}], { enabled = true } = {}) {
+ const onKeydown = (event) => {
+ if (!enabled || event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) {
+ return;
+ }
+
+ // A letter arrives as typed; the map is lowercase.
+ const key = typeof event.key === 'string' && event.key.length === 1 ? event.key.toLowerCase() : event.key;
+ const name = KEYS[key];
+ if (!name) {
+ return;
+ }
+
+ // "/" focuses the search even from a field; every other key only
+ // fires when the user is not typing.
+ if (name !== 'search' && isTypingTarget(event.target)) {
+ return;
+ }
+ if (name === 'search' && isTypingTarget(event.target)) {
+ return;
+ }
+
+ const handler = handlers[name];
+ if (typeof handler !== 'function') {
+ return;
+ }
+
+ event.preventDefault();
+ handler(event);
+ };
+
+ document.addEventListener('keydown', onKeydown);
+
+ return () => {
+ document.removeEventListener('keydown', onKeydown);
+ };
+});
diff --git a/addon/routes/management/index.js b/addon/routes/management/index.js
index 234976b16..0a4dcd62b 100644
--- a/addon/routes/management/index.js
+++ b/addon/routes/management/index.js
@@ -1,3 +1,19 @@
import Route from '@ember/routing/route';
-export default class ManagementIndexRoute extends Route {}
+export default class ManagementIndexRoute extends Route {
+ queryParams = {
+ view: { refreshModel: false },
+ status: { refreshModel: false },
+ filters: { refreshModel: false },
+ fleet: { refreshModel: false },
+ q: { refreshModel: false },
+ saved: { refreshModel: false },
+ assigned: { refreshModel: false },
+ window: { refreshModel: false },
+ };
+
+ setupController(controller, model, transition) {
+ super.setupController(controller, model, transition);
+ controller.reload.perform();
+ }
+}
diff --git a/addon/services/contact-actions.js b/addon/services/contact-actions.js
index 4e5ca7a21..b76d2b3e7 100644
--- a/addon/services/contact-actions.js
+++ b/addon/services/contact-actions.js
@@ -1,6 +1,7 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { inject as service } from '@ember/service';
import { action, get } from '@ember/object';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
const INTERNAL_NAMESPACE = 'int/v1';
@@ -9,6 +10,7 @@ export default class ContactActionsService extends ResourceActionService {
@service placeActions;
@service notifications;
@service hostRouter;
+ @service('universe/menu-service') menuService;
@service('universe/extension-manager') extensionManager;
constructor() {
@@ -48,12 +50,14 @@ export default class ContactActionsService extends ResourceActionService {
view: (contact, options = {}) => {
return this.resourceContextPanel.open({
contact,
+ title: contact?.name,
+ header: 'contact/panel-header',
+ actionButtons: this.panelActionButtons(contact),
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'contact/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'contact/details' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:contact:details'),
],
+ ...PANEL_DEFAULTS,
...options,
});
},
@@ -137,6 +141,20 @@ export default class ContactActionsService extends ResourceActionService {
return this.extensionManager.isInstalled('@fleetbase/customer-portal-engine');
}
+ /**
+ * The header buttons of a contact or customer panel, as on the details
+ * route: edit, then the portal account menu when a login is linked.
+ */
+ panelActionButtons(contact) {
+ const buttons = [{ icon: 'pencil', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(contact)) }];
+ const accountActionButton = this.accountActionButton(contact);
+ if (accountActionButton) {
+ buttons.push(accountActionButton);
+ }
+
+ return buttons;
+ }
+
accountActionButton(contact, options = {}) {
if (!this.hasLinkedUser(contact)) {
return null;
diff --git a/addon/services/customer-actions.js b/addon/services/customer-actions.js
index 11c3f95f0..01756a80a 100644
--- a/addon/services/customer-actions.js
+++ b/addon/services/customer-actions.js
@@ -1,4 +1,5 @@
import ContactActionsService from './contact-actions';
+import { PANEL_DEFAULTS, registeredPanelTabs } from '../utils/context-panel';
export default class CustomerActionsService extends ContactActionsService {
constructor() {
@@ -38,12 +39,14 @@ export default class CustomerActionsService extends ContactActionsService {
view: (customer, options = {}) => {
return this.resourceContextPanel.open({
customer,
+ title: customer?.name,
+ header: 'contact/panel-header',
+ actionButtons: this.panelActionButtons(customer),
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'customer/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'customer/details' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:customer:details'),
],
+ ...PANEL_DEFAULTS,
...options,
});
},
diff --git a/addon/services/device-actions.js b/addon/services/device-actions.js
index 0c8fd228e..54abcd788 100644
--- a/addon/services/device-actions.js
+++ b/addon/services/device-actions.js
@@ -1,6 +1,7 @@
import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class DeviceActionsService extends ResourceActionService {
@service fetch;
@@ -44,10 +45,24 @@ export default class DeviceActionsService extends ResourceActionService {
}
const registeredTabs = this.menuService?.getMenuItems?.('fleet-ops:component:device:details');
+ const service = this;
return this.resourceContextPanel.open({
device,
header: 'device/panel-header',
+ title: device.name ?? device.serial_number,
+ actionButtons: [
+ { icon: 'pencil', permission: 'fleet-ops update device', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(device)) },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ get items() {
+ return service.attachmentItems(device);
+ },
+ },
+ ],
+ ...PANEL_DEFAULTS,
tabs: [
{
key: 'overview',
@@ -181,4 +196,17 @@ export default class DeviceActionsService extends ResourceActionService {
@action detachFromVehicle(device, options = {}) {
return this.detachFromAsset(device, options);
}
+
+ /**
+ * The attach and detach menu of a device's header, shared by the details
+ * route and the context panel. Detach only shows while it is attached.
+ */
+ attachmentItems(device) {
+ const attached = Boolean(device?.attachable_uuid || device?.attached_to_name || device?.attachable);
+
+ return [
+ { text: this.intl.t('device.actions.attach-to-asset'), icon: 'link', fn: () => this.attachToVehicle(device), permission: 'fleet-ops update device' },
+ ...(attached ? [{ text: this.intl.t('device.attachment.detach'), icon: 'unlink', fn: () => this.detachFromVehicle(device), permission: 'fleet-ops update device' }] : []),
+ ];
+ }
}
diff --git a/addon/services/driver-actions.js b/addon/services/driver-actions.js
index b57cd29f7..5252f64cf 100644
--- a/addon/services/driver-actions.js
+++ b/addon/services/driver-actions.js
@@ -4,10 +4,12 @@ import config from 'ember-get-config';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { dasherize } from '@ember/string';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class DriverActionsService extends ResourceActionService {
@service('universe/menu-service') menuService;
@service fetch;
+ @service issueActions;
get registeredTabs() {
const registeredTabs = this.menuService.getMenuItems('fleet-ops:component:driver:details');
@@ -31,7 +33,7 @@ export default class DriverActionsService extends ResourceActionService {
},
{
key: 'positions',
- label: 'Positions',
+ label: this.intl.t('common.positions'),
component: 'positions-replay',
},
{
@@ -39,6 +41,11 @@ export default class DriverActionsService extends ResourceActionService {
label: this.intl.t('common.schedule'),
component: 'driver/schedule',
},
+ {
+ key: 'activity',
+ label: this.intl.t('common.activity'),
+ component: 'resource-activity',
+ },
...this.registeredTabs,
];
}
@@ -114,6 +121,7 @@ export default class DriverActionsService extends ResourceActionService {
},
view: async (driver, options = {}) => {
driver = await this.reloadIndexResource(await this.resolveDriverResource(driver));
+ const service = this;
return this.resourceContextPanel.open({
driver,
@@ -121,13 +129,20 @@ export default class DriverActionsService extends ResourceActionService {
actionButtons: [
{
icon: 'pencil',
- fn: async () => {
- await this.resourceContextPanel.closeAll();
- this.panel.edit(driver);
+ permission: 'fleet-ops update driver',
+ fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(driver)),
+ },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ get items() {
+ return service.detailsMenuItems(driver, { onDeleted: () => service.resourceContextPanel.closeAll() });
},
},
],
tabs: this.panelTabs,
+ ...PANEL_DEFAULTS,
...options,
});
},
@@ -385,4 +400,36 @@ export default class DriverActionsService extends ResourceActionService {
});
});
}
+
+ /**
+ * The actions menu of a driver's header, shared by the details route and
+ * the context panel. Unassign items only show when there is something to
+ * unassign.
+ */
+ detailsMenuItems(driver) {
+ const hasVehicle = Boolean(driver?.vehicle_uuid || driver?.vehicle?.id || driver?.vehicle?.uuid || driver?.vehicle_name);
+
+ return [
+ { text: this.intl.t('driver.actions.assign-order'), icon: 'clipboard-list', fn: () => this.assignOrder(driver), permission: 'fleet-ops assign-order-for driver' },
+ ...(Number(driver?.assigned_orders_count) > 0
+ ? [{ text: this.intl.t('driver.actions.unassign-orders'), icon: 'user-minus', fn: () => this.unassignOrders(driver), permission: 'fleet-ops assign-order-for driver' }]
+ : []),
+ { separator: true },
+ { text: this.intl.t('driver.actions.assign-vehicle'), icon: 'car', fn: () => this.assignVehicle(driver), permission: 'fleet-ops assign-vehicle-for driver' },
+ ...(hasVehicle
+ ? [{ text: this.intl.t('driver.actions.unassign-vehicle'), icon: 'link-slash', fn: () => this.unassignVehicle(driver), permission: 'fleet-ops assign-vehicle-for driver' }]
+ : []),
+ { separator: true },
+ { text: this.intl.t('driver.actions.locate-driver'), icon: 'location-dot', fn: () => this.locate(driver), permission: 'fleet-ops view driver' },
+ { text: this.intl.t('driver.actions.create-issue'), icon: 'triangle-exclamation', fn: () => this.createIssue(driver), permission: 'fleet-ops create issue' },
+ ];
+ }
+
+ @action createIssue(driver) {
+ return this.issueActions.modal.create({
+ driver,
+ driver_uuid: driver.id,
+ title: this.intl.t('driver.prompts.issue-title', { driverName: driver.name }),
+ });
+ }
}
diff --git a/addon/services/equipment-actions.js b/addon/services/equipment-actions.js
index 9cc47f9de..bc8533f70 100644
--- a/addon/services/equipment-actions.js
+++ b/addon/services/equipment-actions.js
@@ -1,6 +1,9 @@
-import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class EquipmentActionsService extends ResourceActionService {
+ @service('universe/menu-service') menuService;
+
get defaultCurrency() {
return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
}
@@ -42,15 +45,21 @@ export default class EquipmentActionsService extends ResourceActionService {
equipment,
});
},
- view: (equipment) => {
+ view: (equipment, options = {}) => {
return this.resourceContextPanel.open({
equipment,
+ title: equipment?.name,
+ header: 'equipment/panel-header',
+ actionButtons: [
+ { icon: 'edit', permission: 'fleet-ops update equipment', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(equipment)) },
+ { icon: 'trash', type: 'danger', permission: 'fleet-ops delete equipment', fn: () => this.delete(equipment, { onConfirm: () => this.resourceContextPanel.closeAll() }) },
+ ],
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'equipment/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'equipment/details' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:equipment:details'),
],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/fleet-actions.js b/addon/services/fleet-actions.js
index af3e3f45a..716439bca 100644
--- a/addon/services/fleet-actions.js
+++ b/addon/services/fleet-actions.js
@@ -1,8 +1,10 @@
import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class FleetActionsService extends ResourceActionService {
@service fetch;
+ @service('universe/menu-service') menuService;
constructor() {
super(...arguments);
@@ -41,12 +43,18 @@ export default class FleetActionsService extends ResourceActionService {
view: (fleet, options = {}) => {
return this.resourceContextPanel.open({
fleet,
+ title: fleet?.name,
+ actionButtons: [
+ { icon: 'pencil', permission: 'fleet-ops update fleet', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(fleet)) },
+ { icon: 'ellipsis-h', iconPrefix: 'fas', renderInPlace: true, items: this.detailsMenuItems(fleet) },
+ ],
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'fleet/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'fleet/details' },
+ { key: 'vehicles', label: this.intl.t('menu.vehicles'), component: 'fleet/vehicle-listing' },
+ { key: 'drivers', label: this.intl.t('menu.drivers'), component: 'fleet/driver-listing' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:fleet:details'),
],
+ ...PANEL_DEFAULTS,
...options,
});
},
@@ -146,4 +154,12 @@ export default class FleetActionsService extends ResourceActionService {
...options,
});
}
+
+ /** The actions menu of a fleet's header, shared by the details route and the context panel. */
+ detailsMenuItems(fleet) {
+ return [
+ { text: this.intl.t('fleet.actions.assign-driver'), icon: 'user-plus', fn: () => this.assignDriver(fleet), permission: 'fleet-ops assign-driver-for fleet' },
+ { text: this.intl.t('fleet.actions.assign-vehicle'), icon: 'car', fn: () => this.assignVehicle(fleet), permission: 'fleet-ops assign-vehicle-for fleet' },
+ ];
+ }
}
diff --git a/addon/services/fuel-report-actions.js b/addon/services/fuel-report-actions.js
index 8ffa13913..1fce62ba1 100644
--- a/addon/services/fuel-report-actions.js
+++ b/addon/services/fuel-report-actions.js
@@ -1,6 +1,7 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class FuelReportActionsService extends ResourceActionService {
@service driverActions;
@@ -40,15 +41,14 @@ export default class FuelReportActionsService extends ResourceActionService {
fuelReport,
});
},
- view: (fuelReport) => {
+ view: (fuelReport, options = {}) => {
return this.resourceContextPanel.open({
fuelReport,
- tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'fuel-report/details',
- },
- ],
+ title: fuelReport?.name ?? `Fuel reported on ${fuelReport?.createdAt ?? ''}`.trim(),
+ actionButtons: [{ icon: 'pencil', permission: 'fleet-ops update fuel-report', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(fuelReport)) }],
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'fuel-report/details' }],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/inspection-form-actions.js b/addon/services/inspection-form-actions.js
index 4275e9367..6624a6823 100644
--- a/addon/services/inspection-form-actions.js
+++ b/addon/services/inspection-form-actions.js
@@ -4,6 +4,7 @@ import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard';
import { normalizeFieldGroups, serializeFieldGroups } from '../utils/inspection-form-structure';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
/** A link's life when nobody chooses; the server applies the same when left blank. */
const DEFAULT_LINK_TTL_HOURS = 72;
@@ -53,6 +54,45 @@ export default class InspectionFormActionsService extends ResourceActionService
create: () => this.transitionTo('maintenance.inspection-forms.index.new'),
};
+ panel = {
+ view: (form, options = {}) => {
+ const service = this;
+
+ return this.resourceContextPanel.open({
+ form,
+ title: form?.name,
+ get actionButtons() {
+ return service.headerActionButtons(form, {
+ onEdit: () => closePanelsThen(service.resourceContextPanel, () => service.transition.edit(form)),
+ onDeleted: () => service.resourceContextPanel.closeAll(),
+ });
+ },
+ tabs: [
+ { key: 'overview', label: this.intl.t('inspection.form.overview'), component: 'inspection-form/details' },
+ { key: 'submissions', label: this.intl.t('inspection.form.submissions'), component: 'inspection-form/details/submissions' },
+ ],
+ ...PANEL_DEFAULTS,
+ ...options,
+ });
+ },
+ };
+
+ /**
+ * The header buttons of a form, shared by the details route and the
+ * context panel: publish a draft or generate a link for a published
+ * form, then edit and delete.
+ */
+ headerActionButtons(form, { onEdit, onDeleted } = {}) {
+ const isPublished = form?.is_published === true || form?.status === 'published';
+
+ return [
+ ...(isPublished ? [] : [{ icon: 'check', fn: () => this.publish(form), text: 'Publish', type: 'success', permission: 'fleet-ops publish inspection-form' }]),
+ ...(isPublished ? [{ icon: 'link', fn: () => this.generateLink(form), text: 'Generate Link', permission: 'fleet-ops view inspection-form' }] : []),
+ { icon: 'edit', fn: () => (onEdit ? onEdit(form) : this.transition.edit(form)), permission: 'fleet-ops update inspection-form' },
+ { icon: 'trash', fn: () => this.delete(form, { onConfirm: onDeleted }), type: 'danger', permission: 'fleet-ops delete inspection-form' },
+ ];
+ }
+
/**
* A form's structure — its field groups and their fields.
*
diff --git a/addon/services/inspection-submission-actions.js b/addon/services/inspection-submission-actions.js
index 9fdd7af1f..6e4597696 100644
--- a/addon/services/inspection-submission-actions.js
+++ b/addon/services/inspection-submission-actions.js
@@ -1,6 +1,7 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class InspectionSubmissionActionsService extends ResourceActionService {
@service fetch;
@@ -26,6 +27,87 @@ export default class InspectionSubmissionActionsService extends ResourceActionSe
create: () => this.transitionTo('maintenance.inspection-submissions.index.new'),
};
+ panel = {
+ view: (submission, options = {}) => {
+ const service = this;
+
+ return this.resourceContextPanel.open({
+ submission,
+ title: this.panelTitle(submission),
+ actionButtons: [
+ { icon: 'edit', permission: 'fleet-ops update inspection-submission', fn: () => closePanelsThen(this.resourceContextPanel, () => this.transition.edit(submission)) },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ get items() {
+ return service.followUpItems(submission, { onDeleted: () => service.resourceContextPanel.closeAll() });
+ },
+ },
+ ],
+ tabs: [
+ { key: 'overview', label: this.intl.t('inspection.record.overview'), component: 'inspection-submission/details' },
+ { key: 'photos', label: this.intl.t('inspection.record.photos'), component: 'inspection-submission/photos' },
+ { key: 'audit', label: this.intl.t('inspection.record.audit'), component: 'resource-activity' },
+ ],
+ ...PANEL_DEFAULTS,
+ ...options,
+ });
+ },
+ };
+
+ /** "DVIR inspection" — the heading a submission's details show. */
+ panelTitle(submission) {
+ const formName = submission?.form?.get?.('name') ?? submission?.form?.name ?? submission?.form_name;
+
+ return formName ? this.intl.t('inspection.record.submission-title', { form: formName }) : (submission?.public_id ?? this.intl.t('inspection.record.inspection'));
+ }
+
+ /**
+ * The follow-up menu of a submission, shared by the details route and the
+ * context panel: raise an issue or a work order for failures that have
+ * none, resolve, and delete.
+ */
+ followUpItems(submission, { onDeleted } = {}) {
+ const items = [];
+
+ if (submission?.has_failures && !submission?.issue_uuid) {
+ items.push({
+ text: this.intl.t('inspection.record.create-issue'),
+ icon: 'triangle-exclamation',
+ fn: () => this.createIssue(submission),
+ permission: 'fleet-ops create-issue inspection-submission',
+ });
+ }
+
+ if (submission?.has_failures && !submission?.work_order_uuid) {
+ items.push({
+ text: this.intl.t('inspection.record.create-work-order'),
+ icon: 'clipboard-list',
+ fn: () => this.createWorkOrder(submission),
+ permission: 'fleet-ops create-work-order inspection-submission',
+ });
+ }
+
+ if (submission?.status !== 'resolved') {
+ items.push({ text: this.intl.t('inspection.record.resolve'), icon: 'check', fn: () => this.resolve(submission), permission: 'fleet-ops resolve inspection-submission' });
+ }
+
+ if (items.length) {
+ items.push({ separator: true });
+ }
+
+ items.push({
+ text: this.intl.t('common.delete'),
+ icon: 'trash',
+ class: 'text-red-500',
+ fn: () => this.delete(submission, { onConfirm: onDeleted }),
+ permission: 'fleet-ops delete inspection-submission',
+ });
+
+ return items;
+ }
+
/**
* The answers filed against a submission, as the resource projects them:
* every value beside the field it answers, with `file:` references
diff --git a/addon/services/issue-actions.js b/addon/services/issue-actions.js
index 3d9caa682..8e81357d3 100644
--- a/addon/services/issue-actions.js
+++ b/addon/services/issue-actions.js
@@ -3,6 +3,7 @@ import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { format } from 'date-fns';
import { issueStatuses } from '../utils/fleet-ops-options';
+import { PANEL_DEFAULTS } from '../utils/context-panel';
export default class IssueActionsService extends ResourceActionService {
@service driverActions;
@@ -47,15 +48,15 @@ export default class IssueActionsService extends ResourceActionService {
issue,
});
},
- view: (issue) => {
+ view: (issue, options = {}) => {
return this.resourceContextPanel.open({
issue,
- tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'issue/details',
- },
- ],
+ header: 'issue/panel-header',
+ width: '800px',
+ actionButtons: this.panelActionButtons(issue),
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'issue/details' }],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
@@ -93,6 +94,56 @@ export default class IssueActionsService extends ResourceActionService {
},
};
+ /**
+ * The header buttons of an issue panel, the same as the issue details
+ * route: edit, then the workflow menu. The menu is read when it renders,
+ * so closing or re-opening the issue swaps the last item.
+ */
+ panelActionButtons(issue) {
+ const service = this;
+ const refresh = () => issue?.reload?.();
+
+ return [
+ {
+ icon: 'pencil',
+ permission: 'fleet-ops update issue',
+ fn: async () => {
+ await this.resourceContextPanel.closeAll();
+ this.panel.edit(issue);
+ },
+ },
+ {
+ icon: 'ellipsis',
+ type: 'default',
+ renderInPlace: true,
+ get items() {
+ return service.workflowItems(issue, refresh);
+ },
+ },
+ ];
+ }
+
+ workflowItems(issue, onSaved) {
+ const items = [
+ { label: 'Change Status', text: 'Change Status', icon: 'arrows-rotate', fn: () => this.openStatusModal(issue, { onSaved }) },
+ { label: 'Assign Issue', text: 'Assign Issue', icon: 'user-check', fn: () => this.openAssignModal(issue, { onSaved }) },
+ ];
+
+ if (this.closedStatuses.includes(issue?.status)) {
+ items.push({ label: 'Re-open Issue', text: 'Re-open Issue', icon: 'rotate-left', fn: () => this.confirmReopenIssue(issue, { onSaved }) });
+ } else {
+ items.push({
+ label: 'Close Issue',
+ text: 'Close Issue',
+ icon: 'circle-check',
+ class: 'text-green-600 dark:text-green-400',
+ fn: () => this.openCloseIssueModal(issue, { onSaved }),
+ });
+ }
+
+ return items;
+ }
+
get closedStatuses() {
return ['closed', 'resolved', 'completed'];
}
diff --git a/addon/services/maintenance-actions.js b/addon/services/maintenance-actions.js
index a1759b291..672b382c3 100644
--- a/addon/services/maintenance-actions.js
+++ b/addon/services/maintenance-actions.js
@@ -1,6 +1,9 @@
-import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class MaintenanceActionsService extends ResourceActionService {
+ @service('universe/menu-service') menuService;
+
get defaultCurrency() {
return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
}
@@ -39,10 +42,26 @@ export default class MaintenanceActionsService extends ResourceActionService {
maintenance,
});
},
- view: (maintenance) => {
+ view: (maintenance, options = {}) => {
return this.resourceContextPanel.open({
maintenance,
- tabs: [{ label: this.intl.t('common.overview'), component: 'maintenance/details' }],
+ title: maintenance?.summary,
+ header: 'maintenance/panel-header',
+ actionButtons: [
+ { icon: 'edit', permission: 'fleet-ops update maintenance', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(maintenance)) },
+ {
+ icon: 'trash',
+ type: 'danger',
+ permission: 'fleet-ops delete maintenance',
+ fn: () => this.delete(maintenance, { onConfirm: () => this.resourceContextPanel.closeAll() }),
+ },
+ ],
+ tabs: [
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'maintenance/details' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:maintenance:details'),
+ ],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/maintenance-schedule-actions.js b/addon/services/maintenance-schedule-actions.js
index 131e0f02c..fe4f1a444 100644
--- a/addon/services/maintenance-schedule-actions.js
+++ b/addon/services/maintenance-schedule-actions.js
@@ -1,11 +1,13 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class MaintenanceScheduleActionsService extends ResourceActionService {
@service fetch;
@service notifications;
@service intl;
+ @service('universe/menu-service') menuService;
constructor() {
super(...arguments);
@@ -39,15 +41,18 @@ export default class MaintenanceScheduleActionsService extends ResourceActionSer
schedule,
});
},
- view: (schedule) => {
+ view: (schedule, options = {}) => {
return this.resourceContextPanel.open({
schedule,
+ title: schedule?.name,
+ actionButtons: this.panelActionButtons(schedule, { onDeleted: () => this.resourceContextPanel.closeAll() }),
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'maintenance-schedule/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'maintenance-schedule/details' },
+ { key: 'work-orders', label: this.intl.t('menu.work-orders'), component: 'maintenance-schedule/work-orders' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:schedule:details'),
],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
@@ -115,4 +120,64 @@ export default class MaintenanceScheduleActionsService extends ResourceActionSer
this.notifications.serverError(error);
}
}
+
+ /**
+ * The header buttons of a schedule, shared by the details route and the
+ * context panel: edit, trigger a work order now, calendar export, delete.
+ * `onEdit` replaces the panel's edit step where the route navigates.
+ */
+ panelActionButtons(schedule, { onEdit, onDeleted } = {}) {
+ return [
+ {
+ icon: 'edit',
+ permission: 'fleet-ops update maintenance-schedule',
+ fn: () => (onEdit ? onEdit(schedule) : closePanelsThen(this.resourceContextPanel, () => this.panel.edit(schedule))),
+ },
+ { icon: 'play', helpText: 'Trigger Work Order Now', permission: 'fleet-ops update maintenance-schedule', fn: () => this.triggerNow(schedule) },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ items: [
+ { text: 'Download .ics', icon: 'download', iconPrefix: 'far', fn: () => this.downloadIcal(schedule) },
+ { text: 'Add to Google Calendar', icon: 'calendar-plus', iconPrefix: 'fab', fn: () => this.addToGoogleCalendar(schedule) },
+ ],
+ },
+ { icon: 'trash', type: 'danger', permission: 'fleet-ops delete maintenance-schedule', fn: () => this.delete(schedule, { onConfirm: onDeleted }) },
+ ];
+ }
+
+ /**
+ * Download the schedule as an iCalendar file.
+ */
+ @action downloadIcal(schedule) {
+ const id = schedule.public_id ?? schedule.id;
+
+ return this.fetch.download(`maintenance-schedules/${id}/ical`, {}, { fileName: `maintenance-schedule-${id}.ics`, mimeType: 'text/calendar' }).catch((error) => {
+ this.notifications.serverError(error);
+ });
+ }
+
+ /**
+ * Open Google Calendar with the schedule's next due date filled in,
+ * repeating on the schedule's time interval when it has one.
+ */
+ @action addToGoogleCalendar(schedule) {
+ const title = encodeURIComponent(schedule.name ?? 'Maintenance Schedule');
+ const dueDate = schedule.next_due_date ? new Date(schedule.next_due_date) : new Date();
+ const pad = (n) => String(n).padStart(2, '0');
+ const dateStr = `${dueDate.getFullYear()}${pad(dueDate.getMonth() + 1)}${pad(dueDate.getDate())}`;
+ const details = encodeURIComponent(schedule.description ?? schedule.instructions ?? '');
+
+ let recur = '';
+ const intervalValue = parseInt(schedule.interval_value, 10);
+ const intervalUnit = schedule.interval_unit;
+ if (intervalValue > 0 && intervalUnit) {
+ const unitMap = { days: 'DAILY', weeks: 'WEEKLY', months: 'MONTHLY', years: 'YEARLY' };
+ recur = `&recur=RRULE:FREQ=${unitMap[intervalUnit] ?? 'DAILY'};INTERVAL=${intervalValue}`;
+ }
+
+ const url = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${title}&dates=${dateStr}/${dateStr}&details=${details}${recur}`;
+ window.open(url, '_blank', 'noopener,noreferrer');
+ }
}
diff --git a/addon/services/part-actions.js b/addon/services/part-actions.js
index d6d579eb5..a97d45457 100644
--- a/addon/services/part-actions.js
+++ b/addon/services/part-actions.js
@@ -1,6 +1,9 @@
-import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class PartActionsService extends ResourceActionService {
+ @service('universe/menu-service') menuService;
+
get defaultCurrency() {
return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
}
@@ -41,15 +44,18 @@ export default class PartActionsService extends ResourceActionService {
part,
});
},
- view: (part) => {
+ view: (part, options = {}) => {
return this.resourceContextPanel.open({
part,
- tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'part/details',
- },
+ title: part?.name,
+ header: 'part/panel-header',
+ actionButtons: [
+ { icon: 'edit', permission: 'fleet-ops update part', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(part)) },
+ { icon: 'trash', type: 'danger', permission: 'fleet-ops delete part', fn: () => this.delete(part, { onConfirm: () => this.resourceContextPanel.closeAll() }) },
],
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'part/details' }, ...registeredPanelTabs(this.menuService, 'fleet-ops:component:part:details')],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/place-actions.js b/addon/services/place-actions.js
index e4527581a..060558c87 100644
--- a/addon/services/place-actions.js
+++ b/addon/services/place-actions.js
@@ -2,9 +2,11 @@ import ResourceActionService from '@fleetbase/ember-core/services/resource-actio
import leafletIcon from '@fleetbase/ember-core/utils/leaflet-icon';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class PlaceActionsService extends ResourceActionService {
@service vendorActions;
+ @service('universe/menu-service') menuService;
constructor() {
super(...arguments);
@@ -44,19 +46,18 @@ export default class PlaceActionsService extends ResourceActionService {
place,
});
},
- view: async (place) => {
+ view: async (place, options = {}) => {
if (place?.meta?._index_resource) {
await place.reload();
}
return this.resourceContextPanel.open({
place,
- tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'place/details',
- },
- ],
+ title: place?.name ?? place?.street1,
+ actionButtons: [{ icon: 'pencil', permission: 'fleet-ops update place', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(place)) }],
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'place/details' }, ...registeredPanelTabs(this.menuService, 'fleet-ops:component:place:details')],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/service-area-actions.js b/addon/services/service-area-actions.js
index 2c2885262..695674521 100644
--- a/addon/services/service-area-actions.js
+++ b/addon/services/service-area-actions.js
@@ -1,4 +1,5 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
import { tracked } from '@glimmer/tracking';
import { debug } from '@ember/debug';
import { task } from 'ember-concurrency';
@@ -38,15 +39,37 @@ export default class ServiceAreaActionsService extends ResourceActionService {
serviceArea,
});
},
- view: (serviceArea) => {
+ view: (serviceArea, options = {}) => {
return this.resourceContextPanel.open({
serviceArea,
- tabs: [
+ title: serviceArea?.name,
+ actionButtons: [
+ { icon: 'pencil', permission: 'fleet-ops update service-area', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(serviceArea)) },
{
- label: this.intl.t('common.overview'),
- component: 'service-area/details',
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ items: [
+ {
+ text: this.intl.t('service-area.actions.edit-boundary'),
+ icon: 'draw-polygon',
+ permission: 'fleet-ops update service-area',
+ fn: () => closePanelsThen(this.resourceContextPanel, () => this.transition.edit(serviceArea)),
+ },
+ { separator: true },
+ {
+ text: this.intl.t('common.delete'),
+ icon: 'trash',
+ class: 'text-red-500',
+ permission: 'fleet-ops delete service-area',
+ fn: () => this.delete(serviceArea, { onConfirm: () => this.resourceContextPanel.closeAll() }),
+ },
+ ],
},
],
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'service-area/details' }],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/trailer-actions.js b/addon/services/trailer-actions.js
index 8f5dc2098..8349ce056 100644
--- a/addon/services/trailer-actions.js
+++ b/addon/services/trailer-actions.js
@@ -4,6 +4,7 @@ import config from 'ember-get-config';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { dasherize } from '@ember/string';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
/**
* Trailer resource actions.
@@ -128,6 +129,7 @@ export default class TrailerActionsService extends ResourceActionService {
},
view: async (trailer, options = {}) => {
trailer = await this.resolveTrailerResource(trailer);
+ const service = this;
return this.resourceContextPanel.open({
trailer,
@@ -135,13 +137,20 @@ export default class TrailerActionsService extends ResourceActionService {
actionButtons: [
{
icon: 'pencil',
- fn: async () => {
- await this.resourceContextPanel.closeAll();
- this.panel.edit(trailer);
+ permission: 'fleet-ops update trailer',
+ fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(trailer)),
+ },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ get items() {
+ return service.detailsMenuItems(trailer, { onDeleted: () => service.resourceContextPanel.closeAll() });
},
},
],
tabs: this.panelTabs,
+ ...PANEL_DEFAULTS,
...options,
});
},
@@ -401,4 +410,38 @@ export default class TrailerActionsService extends ResourceActionService {
return this.maintenanceActions.modal.create({ maintainable: trailer }, options, saveOptions);
}
+
+ /**
+ * The actions menu of a trailer's header, shared by the details route and
+ * the context panel. Attach or detach follows the trailer's state.
+ */
+ detailsMenuItems(trailer, { onDeleted } = {}) {
+ const isAttached = trailer?.isAttached ?? trailer?.attachment_state === 'attached';
+
+ return [
+ { text: this.intl.t('trailer.actions.locate'), icon: 'location-dot', fn: () => this.locate(trailer), permission: 'fleet-ops view trailer' },
+ isAttached
+ ? { text: this.intl.t('trailer.actions.detach-vehicle'), icon: 'unlink', fn: () => this.detachVehicle(trailer), permission: 'fleet-ops detach-vehicle-for trailer' }
+ : { text: this.intl.t('trailer.actions.attach-vehicle'), icon: 'link', fn: () => this.attachVehicle(trailer), permission: 'fleet-ops attach-vehicle-for trailer' },
+ { text: this.intl.t('trailer.actions.attach-device'), icon: 'microchip', fn: () => this.attachDevice(trailer), permission: 'fleet-ops attach-device-for trailer' },
+ { text: this.intl.t('trailer.actions.attach-equipment'), icon: 'toolbox', fn: () => this.attachEquipment(trailer), permission: 'fleet-ops attach-equipment-for trailer' },
+ { separator: true },
+ {
+ text: this.intl.t('trailer.actions.schedule-maintenance'),
+ icon: 'calendar-check',
+ fn: () => this.scheduleMaintenance(trailer),
+ permission: 'fleet-ops create maintenance-schedule',
+ },
+ { text: this.intl.t('trailer.actions.create-work-order'), icon: 'clipboard-list', fn: () => this.createWorkOrder(trailer), permission: 'fleet-ops create work-order' },
+ { text: this.intl.t('trailer.actions.log-maintenance'), icon: 'wrench', fn: () => this.logMaintenance(trailer), permission: 'fleet-ops create maintenance' },
+ { separator: true },
+ {
+ text: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.trailer') }),
+ icon: 'trash',
+ fn: () => this.delete(trailer, { onConfirm: onDeleted }),
+ permission: 'fleet-ops delete trailer',
+ class: 'text-red-500 hover:text-red-600',
+ },
+ ];
+ }
}
diff --git a/addon/services/vehicle-actions.js b/addon/services/vehicle-actions.js
index 9af2ba792..f3533b2ec 100644
--- a/addon/services/vehicle-actions.js
+++ b/addon/services/vehicle-actions.js
@@ -4,6 +4,7 @@ import config from 'ember-get-config';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { dasherize } from '@ember/string';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class VehicleActionsService extends ResourceActionService {
@service('universe/menu-service') menuService;
@@ -11,6 +12,7 @@ export default class VehicleActionsService extends ResourceActionService {
@service maintenanceScheduleActions;
@service workOrderActions;
@service maintenanceActions;
+ @service issueActions;
get registeredTabs() {
const registeredTabs = this.menuService.getMenuItems('fleet-ops:component:vehicle:details');
@@ -67,6 +69,11 @@ export default class VehicleActionsService extends ResourceActionService {
label: 'Maintenance',
component: 'vehicle/details/maintenance-history',
},
+ {
+ key: 'inspections',
+ label: this.intl.t('resource.inspections'),
+ component: 'vehicle/details/inspections',
+ },
...this.registeredTabs,
];
}
@@ -138,6 +145,7 @@ export default class VehicleActionsService extends ResourceActionService {
},
view: async (vehicle, options = {}) => {
vehicle = await this.reloadIndexResource(await this.resolveVehicleResource(vehicle));
+ const service = this;
return this.resourceContextPanel.open({
vehicle,
@@ -145,13 +153,20 @@ export default class VehicleActionsService extends ResourceActionService {
actionButtons: [
{
icon: 'pencil',
- fn: async () => {
- await this.resourceContextPanel.closeAll();
- this.panel.edit(vehicle);
+ permission: 'fleet-ops update vehicle',
+ fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(vehicle)),
+ },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ get items() {
+ return service.detailsMenuItems(vehicle, { onDeleted: () => service.resourceContextPanel.closeAll() });
},
},
],
tabs: this.panelTabs,
+ ...PANEL_DEFAULTS,
...options,
});
},
@@ -453,4 +468,44 @@ export default class VehicleActionsService extends ResourceActionService {
});
});
}
+
+ /**
+ * The actions menu of a vehicle's header, shared by the details route and
+ * the context panel so both offer the same actions.
+ */
+ detailsMenuItems(vehicle, { onDeleted } = {}) {
+ return [
+ { text: this.intl.t('vehicle.actions.locate-vehicle'), icon: 'location-dot', fn: () => this.locate(vehicle), permission: 'fleet-ops view vehicle' },
+ { text: this.intl.t('vehicle.actions.attach-device'), icon: 'link', fn: () => this.attachDevice(vehicle), permission: 'fleet-ops update vehicle' },
+ ...(Number(vehicle?.assigned_orders_count) > 0
+ ? [{ text: this.intl.t('vehicle.actions.unassign-orders'), icon: 'truck-ramp-box', fn: () => this.unassignOrders(vehicle), permission: 'fleet-ops update vehicle' }]
+ : []),
+ { separator: true },
+ {
+ text: this.intl.t('vehicle.actions.schedule-maintenance'),
+ icon: 'calendar-check',
+ fn: () => this.scheduleMaintenance(vehicle),
+ permission: 'fleet-ops create maintenance-schedule',
+ },
+ { text: this.intl.t('vehicle.actions.create-work-order'), icon: 'clipboard-list', fn: () => this.createWorkOrder(vehicle), permission: 'fleet-ops create work-order' },
+ { text: this.intl.t('vehicle.actions.log-maintenance'), icon: 'wrench', fn: () => this.logMaintenance(vehicle), permission: 'fleet-ops create maintenance' },
+ { text: this.intl.t('vehicle.actions.create-issue'), icon: 'triangle-exclamation', fn: () => this.createIssue(vehicle), permission: 'fleet-ops create issue' },
+ { separator: true },
+ {
+ text: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.vehicle') }),
+ icon: 'trash',
+ fn: () => this.delete(vehicle, { onConfirm: onDeleted }),
+ permission: 'fleet-ops delete vehicle',
+ class: 'text-red-500 hover:text-red-600',
+ },
+ ];
+ }
+
+ @action createIssue(vehicle) {
+ return this.issueActions.modal.create({
+ vehicle,
+ vehicle_uuid: vehicle.id,
+ title: this.intl.t('vehicle.prompts.issue-title', { vehicleName: vehicle.displayName ?? vehicle.name }),
+ });
+ }
}
diff --git a/addon/services/vendor-actions.js b/addon/services/vendor-actions.js
index e57eb89a4..ab0e9591b 100644
--- a/addon/services/vendor-actions.js
+++ b/addon/services/vendor-actions.js
@@ -2,6 +2,7 @@ import ResourceActionService from '@fleetbase/ember-core/services/resource-actio
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { normalizeProvider, buildIntegrationPayload } from '../utils/vendor-integration';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class VendorActionsService extends ResourceActionService {
@service placeActions;
@@ -40,18 +41,18 @@ export default class VendorActionsService extends ResourceActionService {
vendor,
});
},
- view: (vendor) => {
+ view: (vendor, options = {}) => {
return this.resourceContextPanel.open({
vendor,
+ title: vendor?.name,
header: 'vendor/panel-header',
+ actionButtons: [{ icon: 'pencil', permission: 'fleet-ops update vendor', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(vendor)) }],
tabs: [
- {
- key: 'overview',
- id: 'overview',
- label: this.intl.t('common.overview'),
- component: 'vendor/details',
- },
+ { key: 'overview', id: 'overview', label: this.intl.t('common.overview'), component: 'vendor/details' },
+ { key: 'personnel', id: 'personnel', label: 'Personnel', component: 'vendor/personnel-tab' },
],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/work-order-actions.js b/addon/services/work-order-actions.js
index 3911c24b5..d0cd15c7c 100644
--- a/addon/services/work-order-actions.js
+++ b/addon/services/work-order-actions.js
@@ -1,10 +1,12 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class WorkOrderActionsService extends ResourceActionService {
@service fetch;
@service notifications;
+ @service('universe/menu-service') menuService;
constructor() {
super(...arguments);
this.initialize('work-order');
@@ -37,15 +39,22 @@ export default class WorkOrderActionsService extends ResourceActionService {
workOrder,
});
},
- view: (workOrder) => {
+ view: (workOrder, options = {}) => {
return this.resourceContextPanel.open({
workOrder,
+ title: workOrder?.code ?? workOrder?.subject,
+ header: 'work-order/panel-header',
+ actionButtons: [
+ { icon: 'paper-plane', text: 'Send to Vendor', permission: 'fleet-ops update work-order', fn: () => this.sendEmail(workOrder) },
+ { icon: 'edit', permission: 'fleet-ops update work-order', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(workOrder)) },
+ { icon: 'trash', type: 'danger', permission: 'fleet-ops delete work-order', fn: () => this.delete(workOrder, { onConfirm: () => this.resourceContextPanel.closeAll() }) },
+ ],
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'work-order/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'work-order/details' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:work-order:details'),
],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/zone-actions.js b/addon/services/zone-actions.js
index b0a4d4546..e811ebea6 100644
--- a/addon/services/zone-actions.js
+++ b/addon/services/zone-actions.js
@@ -1,4 +1,5 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class ZoneActionsService extends ResourceActionService {
constructor() {
@@ -33,15 +34,37 @@ export default class ZoneActionsService extends ResourceActionService {
zone,
});
},
- view: (zone) => {
+ view: (zone, options = {}) => {
return this.resourceContextPanel.open({
zone,
- tabs: [
+ title: zone?.name,
+ actionButtons: [
+ { icon: 'pencil', permission: 'fleet-ops update zone', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(zone)) },
{
- label: this.intl.t('common.overview'),
- component: 'zone/details',
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ items: [
+ {
+ text: this.intl.t('zone.actions.edit-boundary'),
+ icon: 'draw-polygon',
+ permission: 'fleet-ops update zone',
+ fn: () => closePanelsThen(this.resourceContextPanel, () => this.transition.edit(zone)),
+ },
+ { separator: true },
+ {
+ text: this.intl.t('common.delete'),
+ icon: 'trash',
+ class: 'text-red-500',
+ permission: 'fleet-ops delete zone',
+ fn: () => this.delete(zone, { onConfirm: () => this.resourceContextPanel.closeAll() }),
+ },
+ ],
},
],
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'zone/details' }],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css
index 112601830..a17422d2d 100644
--- a/addon/styles/fleetops-engine.css
+++ b/addon/styles/fleetops-engine.css
@@ -10667,6 +10667,1722 @@ html body .inspection-flyout textarea.inspection-defect-comment.form-input:focus
}
}
+/* ==========================================================================
+ Radar — the triage list that replaced the Resources Hub. Sits on the
+ control-hub shell (header band, body width) and adds the pills, tabs,
+ rows and drawer. Light rules first, dark overrides at the end.
+ ========================================================================== */
+
+.fleet-ops-radar-body {
+ max-width: 88rem;
+ gap: 0.625rem;
+ padding-bottom: 1.25rem;
+}
+
+.fleet-ops-radar-header-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem 1rem;
+ width: 100%;
+ min-width: 0;
+}
+
+.fleet-ops-radar-header-summary {
+ font-size: 0.75rem;
+ color: #6b7280;
+ align-self: flex-end;
+ padding-bottom: 0.2rem;
+}
+
+.fleet-ops-radar-header-actions {
+ margin-left: auto;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.5rem;
+}
+
+/* Every control in the header shares one height. */
+.fleet-ops-radar-header-actions .btn-wrapper,
+.fleet-ops-radar-header-actions .btn-wrapper > .btn {
+ height: 2rem;
+}
+
+.fleet-ops-radar-header-actions .btn-wrapper > .btn {
+ display: inline-flex;
+ align-items: center;
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+.fleet-ops-radar-search {
+ position: relative;
+ width: 12rem;
+ height: 2rem;
+}
+
+.fleet-ops-radar-search svg {
+ position: absolute;
+ left: 0.6rem;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 0.7rem;
+ color: #9ca3af;
+ pointer-events: none;
+}
+
+/* Beats ember-ui's `input.form-input.form-input-sm` padding so the text clears the icon. */
+.fleet-ops-radar-search input.form-input.form-input-sm {
+ height: 2rem;
+ padding-left: 1.85rem;
+ padding-right: 1.85rem;
+}
+
+.fleet-ops-radar-search kbd {
+ position: absolute;
+ right: 0.45rem;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.fleet-ops-radar-view-toggle {
+ display: inline-flex;
+ align-items: stretch;
+ box-sizing: border-box;
+ height: 2rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background: #fff;
+ padding: 2px;
+ gap: 2px;
+}
+
+.fleet-ops-radar-view-toggle-button {
+ border: 0;
+ background: transparent;
+ border-radius: 0.25rem;
+ padding: 0 0.6rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: #6b7280;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ cursor: pointer;
+}
+
+.fleet-ops-radar-view-toggle-button.is-active {
+ background: #3485e2;
+ color: #fff;
+}
+
+.fleet-ops-radar-sources-warning {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border: 1px solid #fde68a;
+ background: #fffbeb;
+ color: #b45309;
+ border-radius: 0.375rem;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.75rem;
+}
+
+/* Morning brief: score, category rows, prose, decision cards. */
+.fleet-ops-radar-briefing {
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
+ padding: 0.75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-briefing-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-briefing-toggle {
+ margin-left: auto;
+ border: 0;
+ background: transparent;
+ color: #6b7280;
+ font-size: 0.72rem;
+ font-weight: 600;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.fleet-ops-radar-briefing-loading {
+ display: flex;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.fleet-ops-radar-briefing-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1rem;
+}
+
+@media (width >= 1024px) {
+ .fleet-ops-radar-briefing-grid {
+ grid-template-columns: 11rem minmax(0, 1fr);
+ grid-template-areas:
+ 'score categories'
+ 'prose prose'
+ 'decisions decisions';
+ }
+
+ .fleet-ops-radar-briefing-score {
+ grid-area: score;
+ }
+
+ .fleet-ops-radar-briefing-categories {
+ grid-area: categories;
+ }
+
+ .fleet-ops-radar-briefing-prose {
+ grid-area: prose;
+ }
+
+ .fleet-ops-radar-briefing-decisions {
+ grid-area: decisions;
+ }
+}
+
+@media (width >= 1280px) {
+ .fleet-ops-radar-briefing-grid {
+ grid-template-columns: 11rem minmax(0, 1fr) minmax(20rem, 26rem);
+ grid-template-areas:
+ 'score categories decisions'
+ 'prose prose decisions';
+ }
+}
+
+.fleet-ops-radar-briefing-score-value {
+ display: flex;
+ align-items: baseline;
+ gap: 0.25rem;
+}
+
+.fleet-ops-radar-briefing-score-number {
+ font-size: 2.25rem;
+ font-weight: 900;
+ line-height: 1;
+ color: #111827;
+}
+
+.fleet-ops-radar-briefing-score-of {
+ font-size: 0.8rem;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-briefing-score-delta {
+ margin-top: 0.4rem;
+}
+
+.fleet-ops-radar-briefing-categories {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
+ gap: 0.5rem 0.9rem;
+}
+
+.fleet-ops-radar-briefing-category {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.72rem;
+}
+
+.fleet-ops-radar-briefing-category-head {
+ display: flex;
+ justify-content: space-between;
+ font-weight: 700;
+ color: #111827;
+}
+
+.fleet-ops-radar-briefing-category-score {
+ font-weight: 900;
+}
+
+.fleet-ops-radar-briefing-category-bar {
+ height: 6px;
+ border-radius: 3px;
+ background: #f3f4f6;
+ overflow: hidden;
+}
+
+.fleet-ops-radar-briefing-category-bar > span {
+ display: block;
+ height: 100%;
+ border-radius: 3px;
+ background: #059669;
+ transition: width 0.3s ease;
+}
+
+.fleet-ops-radar-briefing-category-bar > span.is-warn {
+ background: #d97706;
+}
+
+.fleet-ops-radar-briefing-category-bar > span.is-bad {
+ background: #e11d48;
+}
+
+.fleet-ops-radar-briefing-category-gaps {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-briefing-link {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ font: inherit;
+ font-weight: 600;
+ color: #1c6cc7;
+ cursor: pointer;
+ display: inline;
+}
+
+.fleet-ops-radar-briefing-link:hover {
+ text-decoration: underline;
+}
+
+.fleet-ops-radar-briefing-link:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.fleet-ops-radar-briefing-prose p {
+ margin: 0;
+ font-size: 0.8125rem;
+ line-height: 1.65;
+ color: #374151;
+ text-wrap: pretty;
+}
+
+.fleet-ops-radar-briefing-decisions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.fleet-ops-radar-briefing-decisions-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.fleet-ops-radar-decision {
+ border: 1px solid #e5e7eb;
+ border-left-width: 3px;
+ border-radius: 0.375rem;
+ padding: 0.6rem 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ background: #fff;
+}
+
+.fleet-ops-radar-decision.is-critical {
+ border-left-color: #e11d48;
+}
+
+.fleet-ops-radar-decision.is-warning {
+ border-left-color: #d97706;
+}
+
+.fleet-ops-radar-decision.is-info {
+ border-left-color: #3485e2;
+}
+
+.fleet-ops-radar-decision-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.fleet-ops-radar-decision-title {
+ font-size: 0.8125rem;
+ font-weight: 700;
+ color: #111827;
+}
+
+.fleet-ops-radar-decision-reasoning {
+ margin: 0;
+ font-size: 0.75rem;
+ line-height: 1.55;
+ color: #4b5563;
+}
+
+.fleet-ops-radar-decision-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.72rem;
+}
+
+.fleet-ops-radar-briefing-strip {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ padding: 0.4rem 0.75rem;
+ font-size: 0.72rem;
+ color: #374151;
+ background: #f9fafb;
+}
+
+.fleet-ops-radar-briefing-strip.is-confirmed svg {
+ color: #059669;
+}
+
+.fleet-ops-radar-briefing-strip.is-snoozed svg {
+ color: #d97706;
+}
+
+.fleet-ops-radar-briefing-strip-label {
+ font-weight: 700;
+}
+
+.fleet-ops-radar-briefing-strip-title {
+ color: #6b7280;
+ flex: 1 1 auto;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing,
+body[data-theme='dark'] .fleet-ops-radar-decision {
+ background: #1f2937;
+ border-color: #374151;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-strip {
+ background: #111827;
+ border-color: #374151;
+ color: #d1d5db;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-score-number,
+body[data-theme='dark'] .fleet-ops-radar-briefing-category-head,
+body[data-theme='dark'] .fleet-ops-radar-decision-title {
+ color: #f9fafb;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-score-of,
+body[data-theme='dark'] .fleet-ops-radar-briefing-category-gaps,
+body[data-theme='dark'] .fleet-ops-radar-briefing-toggle,
+body[data-theme='dark'] .fleet-ops-radar-briefing-strip-title {
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-prose p,
+body[data-theme='dark'] .fleet-ops-radar-decision-reasoning {
+ color: #d1d5db;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-category-bar {
+ background: #374151;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-link {
+ color: #93c5fd;
+}
+
+/* Count pills: the only filter UI. */
+.fleet-ops-radar-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+
+.fleet-ops-radar-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ border: 1px solid #e5e7eb;
+ background: #fff;
+ color: #374151;
+ border-radius: 999px;
+ padding: 0.3rem 0.7rem;
+ font-size: 0.75rem;
+ line-height: 1;
+ cursor: pointer;
+ transition:
+ border-color 0.12s ease,
+ background-color 0.12s ease;
+}
+
+.fleet-ops-radar-pill:hover {
+ border-color: #bfdbfe;
+}
+
+.fleet-ops-radar-pill.is-active {
+ background: #3485e2;
+ border-color: #3485e2;
+ color: #fff;
+}
+
+.fleet-ops-radar-pill.is-empty:not(.is-active) {
+ color: #9ca3af;
+}
+
+.fleet-ops-radar-pill-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 3px;
+ background: #e11d48;
+}
+
+.fleet-ops-radar-pill.is-active .fleet-ops-radar-pill-dot {
+ background: #fecdd3;
+}
+
+.fleet-ops-radar-pill-count {
+ font-weight: 800;
+}
+
+.fleet-ops-radar-pill-clear {
+ font-size: 0.7rem;
+ opacity: 0.7;
+}
+
+/* Status tabs and saved views. */
+.fleet-ops-radar-view-bar {
+ display: flex;
+ align-items: flex-end;
+ gap: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.fleet-ops-radar-tabs {
+ display: flex;
+ gap: 1rem;
+}
+
+.fleet-ops-radar-tab {
+ border: 0;
+ background: transparent;
+ padding: 0 1px 0.55rem;
+ border-bottom: 2px solid transparent;
+ font-size: 0.8125rem;
+ color: #6b7280;
+ cursor: pointer;
+ display: inline-flex;
+ gap: 0.35rem;
+}
+
+.fleet-ops-radar-tab.is-active {
+ color: #111827;
+ font-weight: 700;
+ border-bottom-color: #3485e2;
+}
+
+.fleet-ops-radar-tab-count {
+ color: #6b7280;
+ font-weight: 600;
+}
+
+.fleet-ops-radar-view-bar-right {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding-bottom: 0.4rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-view-bar-label {
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.fleet-ops-radar-saved-view {
+ display: inline-flex;
+ align-items: center;
+ box-sizing: border-box;
+ height: 1.625rem;
+ gap: 0.35rem;
+ border: 1px solid #e5e7eb;
+ background: #fff;
+ border-radius: 0.375rem;
+ padding: 0 0.55rem;
+ line-height: 1;
+ font-size: 0.75rem;
+ color: #374151;
+ cursor: pointer;
+}
+
+.fleet-ops-radar-saved-view.is-active {
+ border-color: #3485e2;
+ color: #1c6cc7;
+ background: #eff6ff;
+}
+
+.fleet-ops-radar-saved-view-apply,
+.fleet-ops-radar-saved-view-delete {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.fleet-ops-radar-saved-view-delete {
+ opacity: 0.6;
+ font-size: 0.7rem;
+}
+
+.fleet-ops-radar-saved-view-delete:hover {
+ opacity: 1;
+}
+
+.fleet-ops-radar-saved-view-save {
+ font: inherit;
+ font-size: 0.75rem;
+}
+
+/* The bulk bar: appears above the list while something is selected. */
+.fleet-ops-radar-bulk-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.6rem;
+ border: 1px solid #bfdbfe;
+ background: #eff6ff;
+ border-radius: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.78rem;
+ color: #1e3a8a;
+}
+
+.fleet-ops-radar-bulk-bar .fleet-ops-radar-row-check {
+ color: #3485e2;
+}
+
+.fleet-ops-radar-bulk-copy {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.2;
+}
+
+.fleet-ops-radar-bulk-actions {
+ margin-left: auto;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-bulk-bar {
+ background: rgba(28, 108, 199, 0.18);
+ border-color: #1c6cc7;
+ color: #bfdbfe;
+}
+
+/* The list card, its group headers and rows. */
+.fleet-ops-radar-list {
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
+ overflow: hidden;
+}
+
+.fleet-ops-radar-group-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.45rem 0.75rem;
+ border-bottom: 1px solid #e5e7eb;
+ background: #f9fafb;
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-group-header.is-overdue {
+ background: #fff1f2;
+ border-bottom-color: #fecdd3;
+ color: #be123c;
+}
+
+.fleet-ops-radar-group-header.is-today {
+ background: #fffbeb;
+ border-bottom-color: #fde68a;
+ color: #b45309;
+}
+
+.fleet-ops-radar-group-count {
+ font-weight: 800;
+}
+
+.fleet-ops-radar-row {
+ display: grid;
+ grid-template-columns: 1.25rem 5.25rem 8px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.55rem 0.75rem;
+ border-bottom: 1px solid #f3f4f6;
+ position: relative;
+ outline: none;
+ transition: background-color 0.12s ease;
+}
+
+.fleet-ops-radar-row:last-child {
+ border-bottom: 0;
+}
+
+.fleet-ops-radar-row:hover,
+.fleet-ops-radar-row.is-focused {
+ background: #f9fafb;
+}
+
+.fleet-ops-radar-row.is-focused {
+ box-shadow: inset 3px 0 0 #3485e2;
+}
+
+.fleet-ops-radar-row.is-selected {
+ background: #eff6ff;
+}
+
+.fleet-ops-radar-row-check {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ background: transparent;
+ padding: 0;
+ color: #9ca3af;
+ cursor: pointer;
+ font-size: 0.85rem;
+ line-height: 1;
+}
+
+.fleet-ops-radar-row.is-selected .fleet-ops-radar-row-check {
+ color: #3485e2;
+}
+
+.fleet-ops-radar-row-check > div {
+ align-items: center;
+}
+
+.fleet-ops-radar-row-chip .status-badge > span {
+ width: 100%;
+ justify-content: center;
+ font-size: 0.6rem;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 2px 5px;
+}
+
+.fleet-ops-radar-severity {
+ width: 8px;
+ height: 8px;
+ border-radius: 4px;
+ background: #3485e2;
+}
+
+.fleet-ops-radar-severity.is-critical {
+ background: #e11d48;
+}
+
+.fleet-ops-radar-severity.is-warning {
+ background: #d97706;
+}
+
+.fleet-ops-radar-row.is-acknowledged .fleet-ops-radar-severity {
+ opacity: 0.35;
+}
+
+.fleet-ops-radar-row-main {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.35rem 0.6rem;
+ min-width: 0;
+}
+
+.fleet-ops-radar-row-title {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: #111827;
+ text-align: left;
+ cursor: pointer;
+}
+
+.fleet-ops-radar-row-title:hover {
+ color: #1c6cc7;
+}
+
+.fleet-ops-radar-row.is-acknowledged .fleet-ops-radar-row-title {
+ font-weight: 500;
+ color: #4b5563;
+}
+
+.fleet-ops-radar-subject {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ background: #f3f4f6;
+ border-radius: 999px;
+ padding: 2px 8px 2px 3px;
+ font-size: 0.72rem;
+ color: #374151;
+ max-width: 18rem;
+}
+
+.fleet-ops-radar-subject-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.fleet-ops-radar-avatar {
+ width: 18px;
+ height: 18px;
+ border-radius: 9px;
+ background: #d1d5db;
+ color: #111827;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.55rem;
+ font-weight: 800;
+ overflow: hidden;
+ flex: 0 0 auto;
+}
+
+.fleet-ops-radar-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.fleet-ops-radar-avatar.is-user {
+ background: #dbeafe;
+ color: #1d4ed8;
+}
+
+.fleet-ops-radar-row-meta {
+ font-size: 0.72rem;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-row-state {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 0.72rem;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-row-state svg {
+ font-size: 0.65rem;
+}
+
+.fleet-ops-radar-row-side {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ justify-content: flex-end;
+}
+
+.fleet-ops-radar-row-due {
+ font-size: 0.72rem;
+ font-weight: 700;
+ color: #374151;
+ white-space: nowrap;
+}
+
+.fleet-ops-radar-row-due.is-overdue {
+ color: #dc2626;
+}
+
+.fleet-ops-radar-row-due.is-none {
+ color: #9ca3af;
+ font-weight: 500;
+}
+
+.fleet-ops-radar-row-assignee {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.72rem;
+ color: #374151;
+ min-width: 6.5rem;
+}
+
+.fleet-ops-radar-row-assignee.is-empty {
+ color: #9ca3af;
+}
+
+/* The quick-action rail: fixed width so the row never reflows on hover. */
+.fleet-ops-radar-row-rail {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ opacity: 0;
+ transition: opacity 0.12s ease;
+}
+
+.fleet-ops-radar-row:hover .fleet-ops-radar-row-rail,
+.fleet-ops-radar-row.is-focused .fleet-ops-radar-row-rail,
+.fleet-ops-radar-row-rail:focus-within {
+ opacity: 1;
+}
+
+/* The snooze DropdownButton wraps its button in a trigger; flatten it so it lines up. */
+.fleet-ops-radar-row-rail .ember-basic-dropdown,
+.fleet-ops-radar-row-rail .ember-basic-dropdown-trigger,
+.fleet-ops-radar-bulk-actions .ember-basic-dropdown,
+.fleet-ops-radar-bulk-actions .ember-basic-dropdown-trigger {
+ display: inline-flex;
+ align-items: center;
+}
+
+.fleet-ops-radar-row-rail .btn-wrapper,
+.fleet-ops-radar-row-rail .btn-wrapper > .btn,
+.fleet-ops-radar-bulk-actions .btn-wrapper,
+.fleet-ops-radar-bulk-actions .btn-wrapper > .btn {
+ height: 1.75rem;
+}
+
+.fleet-ops-radar-row-rail .btn-wrapper > .btn,
+.fleet-ops-radar-bulk-actions .btn-wrapper > .btn {
+ display: inline-flex;
+ align-items: center;
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+.fleet-ops-radar-row-open {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ background: transparent;
+ color: #9ca3af;
+ cursor: pointer;
+ padding: 0.15rem;
+ font-size: 0.7rem;
+ line-height: 1;
+}
+
+.fleet-ops-radar-row-open svg {
+ width: 0.7rem;
+ height: 0.7rem;
+}
+
+.fleet-ops-radar-row-open:hover {
+ color: #1c6cc7;
+}
+
+@media (width <= 1023px) {
+ .fleet-ops-radar-row {
+ grid-template-columns: 1.25rem minmax(0, 1fr);
+ }
+
+ .fleet-ops-radar-row-chip,
+ .fleet-ops-radar-severity {
+ display: none;
+ }
+
+ .fleet-ops-radar-row-side {
+ grid-column: 2;
+ justify-content: flex-start;
+ }
+
+ .fleet-ops-radar-row-rail {
+ opacity: 1;
+ }
+}
+
+/* Footer: keys and paging, a full-width bar pinned to the bottom of the page while the list scrolls. */
+.fleet-ops-radar-footer {
+ position: sticky;
+ bottom: 0;
+ z-index: 20;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem 1.25rem;
+ width: 100%;
+ box-sizing: border-box;
+
+ /* The sidebar's attribution footer: 0.75rem padding either side of a
+ 0.875rem line plus its 1px top border, so the two borders line up. */
+ min-height: 2.4375rem;
+ margin: 0;
+ padding: 0.25rem 1rem;
+ background: #fff;
+ border-top: 1px solid #e5e7eb;
+ box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
+ font-size: 0.72rem;
+ color: #6b7280;
+}
+
+@media (width >= 768px) {
+ .fleet-ops-radar-footer {
+ flex-wrap: nowrap;
+ height: 2.4375rem;
+ padding: 0 1.5rem;
+ overflow: hidden;
+ }
+}
+
+.fleet-ops-radar-footer-meta {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-footer-meta .fleet-ops-radar-paging {
+ margin-left: 0;
+}
+
+.fleet-ops-radar-footer .btn-wrapper,
+.fleet-ops-radar-footer .btn-wrapper > .btn {
+ height: 1.5rem;
+}
+
+.fleet-ops-radar-footer .btn-wrapper > .btn {
+ display: inline-flex;
+ align-items: center;
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+.fleet-ops-radar-keys {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-keys span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.fleet-ops-radar kbd {
+ border: 1px solid #d1d5db;
+ border-bottom-width: 2px;
+ border-radius: 4px;
+ background: #f9fafb;
+ padding: 0 5px;
+ font-family: inherit;
+ font-size: 0.65rem;
+ font-weight: 700;
+ color: #374151;
+ line-height: 1.3;
+}
+
+.fleet-ops-radar-paging {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+/* Empty states. */
+.fleet-ops-radar-empty {
+ padding: 2.5rem 1.5rem;
+ text-align: center;
+}
+
+.fleet-ops-radar-empty-icon {
+ width: 2.75rem;
+ height: 2.75rem;
+ border-radius: 0.5rem;
+ background: #ecfdf5;
+ color: #047857;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.1rem;
+ margin-bottom: 0.75rem;
+}
+
+.fleet-ops-radar-empty-icon.is-neutral {
+ background: #f3f4f6;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-empty-title {
+ font-size: 0.9375rem;
+ font-weight: 700;
+ color: #111827;
+}
+
+.fleet-ops-radar-empty-hint {
+ font-size: 0.8125rem;
+ color: #6b7280;
+ margin-top: 0.25rem;
+}
+
+.fleet-ops-radar-empty-schedule {
+ margin: 1rem auto 0;
+ max-width: 28rem;
+ text-align: left;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+}
+
+.fleet-ops-radar-empty-schedule > div {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.45rem 0.75rem;
+ border-bottom: 1px solid #f3f4f6;
+ color: #374151;
+}
+
+.fleet-ops-radar-empty-schedule > div:last-child {
+ border-bottom: 0;
+}
+
+.fleet-ops-radar-empty-schedule time {
+ color: #6b7280;
+ white-space: nowrap;
+}
+
+/* Drawer. */
+.fleet-ops-radar-drawer-section {
+ padding: 0.875rem 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.fleet-ops-radar-drawer-label {
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #6b7280;
+ margin-bottom: 0.4rem;
+}
+
+.fleet-ops-radar-drawer-title {
+ font-size: 0.9375rem;
+ font-weight: 700;
+ color: #111827;
+ line-height: 1.35;
+}
+
+.fleet-ops-radar-drawer-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+
+.fleet-ops-radar-drawer-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ font-size: 0.78rem;
+ color: #374151;
+}
+
+.fleet-ops-radar-drawer-list > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-drawer-state {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ font-size: 0.78rem;
+ color: #374151;
+}
+
+/* Agenda: overdue band, four lanes on a time scale, later rail, tray. */
+.fleet-ops-radar-agenda {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-agenda-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-agenda-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.75rem;
+}
+
+@media (width >= 1024px) {
+ .fleet-ops-radar-agenda-grid {
+ grid-template-columns: 11rem minmax(0, 1fr) 10rem;
+ }
+}
+
+.fleet-ops-radar-agenda-band,
+.fleet-ops-radar-agenda-scale,
+.fleet-ops-radar-agenda-tray {
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
+ padding: 0.6rem 0.75rem;
+}
+
+.fleet-ops-radar-agenda-band {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ min-height: 12rem;
+}
+
+.fleet-ops-radar-agenda-band-title {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-agenda-band-title.is-overdue {
+ color: #be123c;
+}
+
+.fleet-ops-radar-agenda-scale {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ overflow-x: auto;
+}
+
+.fleet-ops-radar-agenda-ruler,
+.fleet-ops-radar-agenda-lane {
+ display: grid;
+ grid-template-columns: 7rem minmax(0, 1fr);
+ gap: 0.5rem;
+ align-items: stretch;
+}
+
+.fleet-ops-radar-agenda-lane-label {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.72rem;
+ font-weight: 700;
+ color: #374151;
+ padding: 0.35rem 0;
+}
+
+.fleet-ops-radar-agenda-track {
+ position: relative;
+ min-height: 2.6rem;
+ border-radius: 0.375rem;
+ background: repeating-linear-gradient(to right, #f3f4f6 0, #f3f4f6 1px, transparent 1px, transparent 8.333%);
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.3rem 0;
+}
+
+.fleet-ops-radar-agenda-ruler .fleet-ops-radar-agenda-track {
+ min-height: 1.4rem;
+ background: transparent;
+ border-bottom: 1px solid #e5e7eb;
+ border-radius: 0;
+}
+
+.fleet-ops-radar-agenda-tick {
+ position: absolute;
+ top: 0;
+ transform: translateX(-50%);
+ font-size: 0.65rem;
+ color: #9ca3af;
+}
+
+.fleet-ops-radar-agenda-now {
+ position: absolute;
+ top: 0;
+ bottom: -100vh;
+ width: 2px;
+ background: #3485e2;
+ z-index: 2;
+ pointer-events: none;
+}
+
+.fleet-ops-radar-agenda-now em {
+ position: absolute;
+ top: 0;
+ left: 4px;
+ font-style: normal;
+ font-size: 0.6rem;
+ font-weight: 800;
+ color: #3485e2;
+ background: #fff;
+ padding: 0 3px;
+ border-radius: 3px;
+}
+
+.fleet-ops-radar-agenda-lane.is-drop-target .fleet-ops-radar-agenda-track {
+ outline: 2px dashed #3485e2;
+ outline-offset: -2px;
+}
+
+.fleet-ops-radar-agenda-row {
+ position: relative;
+ height: 1.9rem;
+}
+
+.fleet-ops-radar-agenda-empty-lane {
+ font-size: 0.72rem;
+ color: #9ca3af;
+ padding: 0.5rem 0.75rem;
+}
+
+.fleet-ops-radar-agenda-shift {
+ position: absolute;
+ top: 0;
+ height: 1.8rem;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0 0.5rem 0 0.25rem;
+ border: 1px solid #c7d2fe;
+ background: #e0e7ff;
+ color: #3730a3;
+ border-radius: 0.375rem;
+ font-size: 0.7rem;
+ white-space: nowrap;
+ overflow: hidden;
+ cursor: pointer;
+}
+
+.fleet-ops-radar-agenda-shift.is-not_online {
+ border-color: #fecdd3;
+ background: #fff1f2;
+ color: #9f1239;
+}
+
+.fleet-ops-radar-agenda-shift.has-handover {
+ border-color: #fde68a;
+ background: #fffbeb;
+ color: #92400e;
+}
+
+.fleet-ops-radar-agenda-shift.is-upcoming {
+ opacity: 0.75;
+}
+
+.fleet-ops-radar-agenda-shift.is-ended {
+ opacity: 0.45;
+}
+
+.fleet-ops-radar-agenda-shift-label {
+ font-weight: 700;
+}
+
+.fleet-ops-radar-agenda-shift-meta {
+ opacity: 0.8;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.fleet-ops-radar-agenda-entry {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ max-width: 100%;
+ border: 1px solid #e5e7eb;
+ background: #fff;
+ border-radius: 0.375rem;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.7rem;
+ color: #374151;
+ text-align: left;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.fleet-ops-radar-agenda-entry.is-positioned {
+ position: absolute;
+ top: 0;
+ height: 1.8rem;
+ max-width: 22rem;
+}
+
+.fleet-ops-radar-agenda-entry.is-compact {
+ white-space: normal;
+}
+
+.fleet-ops-radar-agenda-entry.is-planned {
+ border-style: dashed;
+}
+
+.fleet-ops-radar-agenda-entry:hover {
+ border-color: #bfdbfe;
+}
+
+.fleet-ops-radar-agenda-entry strong {
+ color: #111827;
+}
+
+.fleet-ops-radar-agenda-entry-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.fleet-ops-radar-agenda-entry-time {
+ color: #6b7280;
+ font-weight: 700;
+}
+
+.fleet-ops-radar-agenda-grip {
+ color: #9ca3af;
+ cursor: grab;
+}
+
+.fleet-ops-radar-agenda-tray-items {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-top: 0.5rem;
+}
+
+/* Handover card: floats above the page bottom-right. */
+.fleet-ops-radar-handover {
+ position: fixed;
+ right: 1.25rem;
+ bottom: 1.25rem;
+ width: min(26rem, calc(100vw - 2.5rem));
+ z-index: 60;
+ background: #fff;
+ border: 1px solid #fde68a;
+ border-radius: 0.5rem;
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
+ padding: 0.75rem 0.9rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ font-size: 0.78rem;
+}
+
+.fleet-ops-radar-handover-head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.fleet-ops-radar-handover-title {
+ font-weight: 700;
+ color: #111827;
+}
+
+.fleet-ops-radar-handover-orders {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.fleet-ops-radar-handover-order {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ align-items: center;
+ padding: 0.3rem 0.5rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ color: #374151;
+}
+
+.fleet-ops-radar-handover-order.is-late {
+ border-color: #fde68a;
+ background: #fffbeb;
+}
+
+.fleet-ops-radar-handover-late {
+ color: #b45309;
+ font-weight: 700;
+ font-size: 0.68rem;
+}
+
+.fleet-ops-radar-handover-cover {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+@media print {
+ .fleet-ops-control-hub-header,
+ .fleet-ops-radar-agenda-toolbar,
+ .fleet-ops-radar-briefing,
+ .fleet-ops-radar-footer,
+ .fleet-ops-radar-handover,
+ .next-content-overlay {
+ display: none !important;
+ }
+
+ .fleet-ops-radar-agenda-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .fleet-ops-radar-agenda-scale {
+ overflow: visible;
+ }
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-band,
+body[data-theme='dark'] .fleet-ops-radar-agenda-scale,
+body[data-theme='dark'] .fleet-ops-radar-agenda-tray,
+body[data-theme='dark'] .fleet-ops-radar-agenda-entry,
+body[data-theme='dark'] .fleet-ops-radar-handover,
+body[data-theme='dark'] .fleet-ops-radar-handover-order {
+ background: #1f2937;
+ border-color: #374151;
+ color: #d1d5db;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-lane-label,
+body[data-theme='dark'] .fleet-ops-radar-agenda-entry strong,
+body[data-theme='dark'] .fleet-ops-radar-handover-title {
+ color: #f9fafb;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-track {
+ background: repeating-linear-gradient(to right, #374151 0, #374151 1px, transparent 1px, transparent 8.333%);
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-ruler .fleet-ops-radar-agenda-track {
+ background: transparent;
+ border-bottom-color: #374151;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-now em {
+ background: #1f2937;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-shift {
+ background: rgba(99, 102, 241, 0.25);
+ border-color: #4f46e5;
+ color: #c7d2fe;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-shift.is-not_online {
+ background: rgba(225, 29, 72, 0.18);
+ border-color: rgba(225, 29, 72, 0.5);
+ color: #fda4af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-shift.has-handover,
+body[data-theme='dark'] .fleet-ops-radar-handover-order.is-late {
+ background: rgba(217, 119, 6, 0.18);
+ border-color: rgba(217, 119, 6, 0.5);
+ color: #fcd34d;
+}
+
+/* Modals used from Radar. */
+.fleet-ops-radar-modal-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.75rem;
+}
+
+@media (width >= 640px) {
+ .fleet-ops-radar-modal-grid.has-two {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+/* Dark mode. */
+body[data-theme='dark'] .fleet-ops-radar-header-summary,
+body[data-theme='dark'] .fleet-ops-radar-tab,
+body[data-theme='dark'] .fleet-ops-radar-tab-count,
+body[data-theme='dark'] .fleet-ops-radar-view-bar-right,
+body[data-theme='dark'] .fleet-ops-radar-row-meta,
+body[data-theme='dark'] .fleet-ops-radar-row-state,
+body[data-theme='dark'] .fleet-ops-radar-footer,
+body[data-theme='dark'] .fleet-ops-radar-empty-hint,
+body[data-theme='dark'] .fleet-ops-radar-drawer-label,
+body[data-theme='dark'] .fleet-ops-radar-empty-schedule time {
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-footer,
+body[data-theme='dark'] .fleet-ops-radar-view-toggle,
+body[data-theme='dark'] .fleet-ops-radar-pill,
+body[data-theme='dark'] .fleet-ops-radar-saved-view,
+body[data-theme='dark'] .fleet-ops-radar-list,
+body[data-theme='dark'] .fleet-ops-radar-empty-schedule {
+ background: #1f2937;
+ border-color: #374151;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-view-toggle-button,
+body[data-theme='dark'] .fleet-ops-radar-pill,
+body[data-theme='dark'] .fleet-ops-radar-saved-view,
+body[data-theme='dark'] .fleet-ops-radar-row-due,
+body[data-theme='dark'] .fleet-ops-radar-row-assignee,
+body[data-theme='dark'] .fleet-ops-radar-drawer-list,
+body[data-theme='dark'] .fleet-ops-radar-drawer-state,
+body[data-theme='dark'] .fleet-ops-radar-empty-schedule > div {
+ color: #d1d5db;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-view-toggle-button.is-active {
+ background: #1c6cc7;
+ color: #fff;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-pill:hover {
+ border-color: #1c6cc7;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-pill.is-active {
+ background: #1c6cc7;
+ border-color: #1c6cc7;
+ color: #fff;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-pill.is-empty:not(.is-active) {
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-saved-view.is-active {
+ background: rgba(28, 108, 199, 0.2);
+ border-color: #1c6cc7;
+ color: #93c5fd;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-view-bar,
+body[data-theme='dark'] .fleet-ops-radar-group-header,
+body[data-theme='dark'] .fleet-ops-radar-row,
+body[data-theme='dark'] .fleet-ops-radar-drawer-section,
+body[data-theme='dark'] .fleet-ops-radar-empty-schedule > div {
+ border-color: #374151;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-tab.is-active,
+body[data-theme='dark'] .fleet-ops-radar-row-title,
+body[data-theme='dark'] .fleet-ops-radar-empty-title,
+body[data-theme='dark'] .fleet-ops-radar-drawer-title {
+ color: #f9fafb;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-tab.is-active {
+ border-bottom-color: #60a5fa;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-group-header {
+ background: #111827;
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-group-header.is-overdue {
+ background: rgba(225, 29, 72, 0.15);
+ border-bottom-color: rgba(225, 29, 72, 0.35);
+ color: #fda4af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-group-header.is-today {
+ background: rgba(217, 119, 6, 0.15);
+ border-bottom-color: rgba(217, 119, 6, 0.35);
+ color: #fcd34d;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-row:hover,
+body[data-theme='dark'] .fleet-ops-radar-row.is-focused {
+ background: #111827;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-row.is-selected {
+ background: rgba(28, 108, 199, 0.18);
+}
+
+body[data-theme='dark'] .fleet-ops-radar-row.is-acknowledged .fleet-ops-radar-row-title {
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-row-title:hover {
+ color: #93c5fd;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-row-due.is-overdue {
+ color: #fb7185;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-subject {
+ background: #374151;
+ color: #e5e7eb;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-avatar {
+ background: #4b5563;
+ color: #f9fafb;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-avatar.is-user {
+ background: rgba(29, 78, 216, 0.35);
+ color: #bfdbfe;
+}
+
+body[data-theme='dark'] .fleet-ops-radar kbd {
+ background: #111827;
+ border-color: #4b5563;
+ color: #d1d5db;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-empty-icon {
+ background: rgba(5, 150, 105, 0.2);
+ color: #6ee7b7;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-empty-icon.is-neutral {
+ background: #374151;
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-sources-warning {
+ background: rgba(217, 119, 6, 0.15);
+ border-color: rgba(217, 119, 6, 0.4);
+ color: #fcd34d;
+}
+
/* ── Resource list cell ──
A has-many column (trailers, devices) shows one pill per record, drawn at
the identity cell's size: a 1.25rem circle, the name centred beside it,
diff --git a/addon/templates/management/fuel-transactions/index.hbs b/addon/templates/management/fuel-transactions/index.hbs
index 19ddcad94..e05b35ad6 100644
--- a/addon/templates/management/fuel-transactions/index.hbs
+++ b/addon/templates/management/fuel-transactions/index.hbs
@@ -1,5 +1,5 @@
{{#unless this.viewingDetails}}
-
+
-
+
+
-
-
- {{#each this.kpis as |kpi|}}
-
-
-
{{kpi.label}}
-
{{kpi.value}}
-
{{kpi.caption}}
-
- {{kpi.actionLabel}}
-
-
-
-
-
- {{/each}}
-
+
+ {{#if this.sourceErrors.length}}
+
+
+ {{t "radar.sources-warning" sources=this.sourceErrorsText}}
+
+ {{/if}}
-
-
- {{#each this.sections as |section|}}
-
-
+
-
- {{#each section.links as |link|}}
-
-
-
- {{link.label}}
- {{link.description}}
-
- {{link.count}}
-
- {{/each}}
-
-
- {{/each}}
+ {{#if (eq this.view "agenda")}}
+
+ {{else}}
+
+
+
+
+ {{#if this.hasSelection}}
+
+ {{/if}}
+
+
+ {{#if this.isLoading}}
+
+ {{else if this.isEmpty}}
+
+ {{else}}
+
+ {{/if}}
-
-
-
+ {{/if}}
+
-
- {{#each this.actions as |action|}}
- {{#if action.route}}
-
-
-
- {{action.label}}
- {{action.description}}
-
-
- {{else}}
-
-
-
- {{action.label}}
- {{action.description}}
-
-
- {{/if}}
- {{/each}}
+ {{#unless (eq this.view "agenda")}}
+
+
+ {{/unless}}
-
-
-
- Guides
-
-
Read the guide before changing resource setup.
-
- {{#each this.docs as |doc|}}
-
-
-
- Read the guide on {{doc.label}}
- {{doc.description}}
-
-
- {{/each}}
-
-
-
-
-
+
+
{{outlet}}
diff --git a/addon/utils/context-panel.js b/addon/utils/context-panel.js
new file mode 100644
index 000000000..f80c2b322
--- /dev/null
+++ b/addon/utils/context-panel.js
@@ -0,0 +1,34 @@
+import { isArray } from '@ember/array';
+import { dasherize } from '@ember/string';
+
+/**
+ * Options every record context panel shares with its details route: the
+ * details route's 600px width, a header that sits flush on the tab list (the
+ * tab list draws the only rule), and tabs lined up with the header padding.
+ */
+export const PANEL_DEFAULTS = Object.freeze({
+ size: 'md',
+ headerClass: 'no-bottom-border',
+ tablistClass: 'pl-2',
+});
+
+/**
+ * The tabs other extensions registered for a details view that can render
+ * inside a context panel. Route-only tabs have nothing to render there, so
+ * they are left out, and every tab gets the key the panel switches tabs by.
+ */
+export function registeredPanelTabs(menuService, registry) {
+ const tabs = menuService?.getMenuItems?.(registry);
+
+ return (isArray(tabs) ? tabs : []).filter((tab) => tab && (tab.component || tab.render)).map((tab) => ({ ...tab, key: tab.key ?? tab.id ?? dasherize(tab.label ?? tab.title ?? 'tab') }));
+}
+
+/**
+ * Close every open context panel, then run the next step — how a panel's
+ * edit button swaps the details panel for the form panel.
+ */
+export async function closePanelsThen(resourceContextPanel, next) {
+ await resourceContextPanel?.closeAll?.();
+
+ return next();
+}
diff --git a/addon/utils/radar.js b/addon/utils/radar.js
new file mode 100644
index 000000000..8f1bc77e5
--- /dev/null
+++ b/addon/utils/radar.js
@@ -0,0 +1,212 @@
+/**
+ * Shared vocabulary for the Radar page: which pills exist, how a rule maps
+ * to a badge colour, which actions are record-level, and the snooze presets.
+ */
+
+export const RADAR_PILLS = ['overdue', 'due_week', 'unassigned', 'issues', 'inspections', 'shifts', 'expiring', 'low_stock', 'fuel', 'notices'];
+
+export const RADAR_STATUSES = ['open', 'snoozed', 'resolved'];
+
+export const RADAR_GROUPS = ['overdue', 'today', 'week', 'later', 'none'];
+
+/** Saved views every user starts with. */
+export const RADAR_DEFAULT_VIEWS = [
+ { id: 'my-assignments', intl: 'radar.saved-views.defaults.my-assignments', status: 'open', filters: '', assigned: 'me', q: '', isDefault: true },
+ { id: 'shift-changes', intl: 'radar.saved-views.defaults.shift-changes', status: 'open', filters: 'shifts', assigned: '', q: '', isDefault: true },
+ { id: 'due-this-week', intl: 'radar.saved-views.defaults.due-this-week', status: 'open', filters: 'due_week', assigned: '', q: '', isDefault: true },
+ { id: 'unmatched-fuel', intl: 'radar.saved-views.defaults.unmatched-fuel', status: 'open', filters: 'fuel', assigned: '', q: '', isDefault: true },
+];
+
+/** Badge status per category; issues go by severity instead. */
+const CHIP_BY_CATEGORY = {
+ maintenance: 'warning',
+ inspections: 'violet',
+ staffing: 'info',
+ compliance: 'cyan',
+ fuel: 'sky',
+ parts: 'slate',
+ connectivity: 'slate',
+ issues: 'warning',
+ notices: 'gray',
+};
+
+const CHIP_BY_RULE = {
+ work_order_overdue: 'orange',
+ work_order_blocked: 'orange',
+ shift_late_start: 'indigo',
+ shift_no_vehicle: 'indigo',
+ shift_handover: 'indigo',
+};
+
+export function chipStatusFor(item) {
+ if (!item) {
+ return 'gray';
+ }
+
+ if (item.rule === 'issue_open') {
+ return item.severity === 'critical' ? 'error' : 'warning';
+ }
+
+ return CHIP_BY_RULE[item.rule] ?? CHIP_BY_CATEGORY[item.category] ?? 'gray';
+}
+
+/**
+ * Actions that change the record itself, in the order the rail prefers
+ * them, each with its label key and icon. Everything else is state.
+ */
+export const RECORD_ACTIONS = {
+ create_work_order: { label: 'radar.actions.create-work-order', icon: 'clipboard-list' },
+ create_work_order_from_inspection: { label: 'radar.actions.create-work-order', icon: 'clipboard-list' },
+ create_issue_from_inspection: { label: 'radar.actions.create-issue', icon: 'triangle-exclamation' },
+ resolve_inspection: { label: 'radar.actions.mark-resolved', icon: 'check' },
+ resolve_issue: { label: 'radar.actions.mark-resolved', icon: 'check' },
+ assign_vehicle: { label: 'radar.actions.assign-vehicle', icon: 'truck' },
+ assign_driver: { label: 'radar.actions.assign-driver', icon: 'id-card' },
+ match_vehicle: { label: 'radar.actions.match-vehicle', icon: 'link' },
+ ignore_transaction: { label: 'radar.actions.ignore', icon: 'eye-slash' },
+ attach_device: { label: 'radar.actions.attach', icon: 'satellite-dish' },
+ send_pin: { label: 'radar.actions.send-pin', icon: 'paper-plane' },
+ revoke_link: { label: 'radar.actions.revoke-link', icon: 'ban' },
+ call: { label: 'radar.actions.call', icon: 'phone' },
+ cover_shift: { label: 'radar.actions.cover-shift', icon: 'people-arrows' },
+ handover: { label: 'radar.actions.reassign', icon: 'right-left' },
+ extend_shift: { label: 'radar.actions.extend-shift', icon: 'clock' },
+ resolve: { label: 'radar.actions.resolve', icon: 'check' },
+};
+
+export const STATE_ACTIONS = ['acknowledge', 'snooze', 'wake', 'assign', 'unassign', 'plan', 'unplan', 'open_record'];
+
+/** The first record-level action an item offers, with its label and icon. */
+export function primaryActionFor(item) {
+ const key = (item?.actions ?? []).find((action) => RECORD_ACTIONS[action]);
+ if (!key) {
+ return null;
+ }
+
+ return { key, ...RECORD_ACTIONS[key] };
+}
+
+/** Every record-level action after the primary one. */
+export function secondaryActionsFor(item) {
+ const primary = primaryActionFor(item);
+
+ return (item?.actions ?? []).filter((action) => RECORD_ACTIONS[action] && action !== primary?.key).map((key) => ({ key, ...RECORD_ACTIONS[key] }));
+}
+
+export const SNOOZE_PRESETS = ['1h', '4h', 'tomorrow', 'next-week'];
+
+/**
+ * The snooze payload for a preset: minutes for the short ones, a
+ * morning-of date for the longer ones.
+ */
+export function snoozePayloadFor(preset, now = new Date()) {
+ switch (preset) {
+ case '1h':
+ return { minutes: 60 };
+ case '4h':
+ return { minutes: 240 };
+ case 'tomorrow': {
+ const until = new Date(now);
+ until.setDate(until.getDate() + 1);
+ until.setHours(8, 0, 0, 0);
+ return { until: until.toISOString() };
+ }
+ case 'next-week': {
+ const until = new Date(now);
+ until.setDate(until.getDate() + 7);
+ until.setHours(8, 0, 0, 0);
+ return { until: until.toISOString() };
+ }
+ default:
+ return { minutes: 60 };
+ }
+}
+
+export function initialsOf(name) {
+ const parts = String(name ?? '')
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean);
+ if (!parts.length) {
+ return '';
+ }
+
+ const first = parts[0].charAt(0);
+ const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '';
+
+ return (first + last).toUpperCase();
+}
+
+/** Replace one item (by key) inside a Radar items payload, or drop it when the updater returns null. */
+export function patchPayload(payload, key, updater) {
+ if (!payload) {
+ return payload;
+ }
+
+ const apply = (item) => (item.key === key ? updater(item) : item);
+ const items = (payload.items ?? []).map(apply).filter(Boolean);
+ const groups = (payload.groups ?? [])
+ .map((group) => {
+ const members = (group.items ?? []).map(apply).filter(Boolean);
+ return { ...group, items: members, count: members.length };
+ })
+ .filter((group) => group.items.length > 0);
+
+ return { ...payload, items, groups };
+}
+
+/**
+ * How to show a record Radar links to without leaving the page: the Ember
+ * Data model to load, the action service whose `panel.view` renders it, or
+ * the details component to open in a plain context panel when the resource
+ * has no panel of its own. Keyed by the record route's prefix; the longest
+ * matching prefix wins, so `management.vehicles.index.details.devices`
+ * resolves to the vehicle panel.
+ */
+export const RECORD_PANELS = {
+ 'management.drivers': { modelName: 'driver', service: 'driver-actions' },
+ 'management.vehicles': { modelName: 'vehicle', service: 'vehicle-actions' },
+ 'management.trailers': { modelName: 'trailer', service: 'trailer-actions' },
+ 'management.issues': { modelName: 'issue', service: 'issue-actions', include: ['driver', 'vehicle', 'assignee', 'reporter', 'order', 'files'] },
+ 'management.fuel-transactions': { modelName: 'fuel-provider-transaction', component: 'fuel-provider-transaction/summary' },
+ 'maintenance.maintenances': { modelName: 'maintenance', service: 'maintenance-actions' },
+ 'maintenance.equipment': { modelName: 'equipment', service: 'equipment-actions' },
+ 'maintenance.schedules': { modelName: 'maintenance-schedule', service: 'maintenance-schedule-actions' },
+ 'maintenance.work-orders': { modelName: 'work-order', service: 'work-order-actions' },
+ 'maintenance.parts': { modelName: 'part', service: 'part-actions' },
+ 'maintenance.inspection-submissions': { modelName: 'inspection-submission', service: 'inspection-submission-actions' },
+ 'maintenance.inspection-forms': { modelName: 'inspection-form', service: 'inspection-form-actions' },
+ 'connectivity.devices': { modelName: 'device', service: 'device-actions' },
+};
+
+/** The panel definition for a record route, or null when Radar has none for it. */
+export function recordPanelFor(route) {
+ if (!route) {
+ return null;
+ }
+
+ const prefix = Object.keys(RECORD_PANELS)
+ .filter((key) => route === key || route.startsWith(`${key}.`))
+ .sort((a, b) => b.length - a.length)[0];
+
+ return prefix ? RECORD_PANELS[prefix] : null;
+}
+
+/**
+ * The `{ route, model }` a Radar link points at, whichever shape it arrived
+ * in: an item (`item.record`), a decision or handover record, or a brief
+ * sentence segment carrying `route` and `model` itself.
+ */
+export function recordOf(target) {
+ if (!target) {
+ return null;
+ }
+ if (target.record?.route) {
+ return target.record;
+ }
+ if (target.route && target.model) {
+ return { route: target.route, model: target.model };
+ }
+
+ return null;
+}
diff --git a/addon/utils/resource-descriptors/polymorphic.js b/addon/utils/resource-descriptors/polymorphic.js
index 24333e3bf..6080e538b 100644
--- a/addon/utils/resource-descriptors/polymorphic.js
+++ b/addon/utils/resource-descriptors/polymorphic.js
@@ -58,6 +58,30 @@ function delegate(owner, typeAttrs, field, fallback) {
};
}
+/**
+ * The concrete record behind a polymorphic base record: an index payload
+ * normalizes a facilitator as a bare `facilitator` model, which the concrete
+ * resource's panel cannot use. A record that already is the concrete model or
+ * one of its subtypes is returned as it is.
+ */
+export async function loadConcreteRecord(owner, descriptor, record) {
+ const modelName = record?.constructor?.modelName;
+ const known = [...(descriptor.modelNames ?? []), ...(descriptor.aliases ?? [])];
+ const id = record ? (get(record, 'id') ?? get(record, 'uuid')) : null;
+ const canonical = descriptor.modelNames?.[0];
+ const store = owner.lookup('service:store');
+
+ if (!modelName || known.includes(modelName) || !id || !canonical || !store) {
+ return record;
+ }
+
+ try {
+ return store.peekRecord(canonical, id) ?? (await store.findRecord(canonical, id)) ?? record;
+ } catch {
+ return record;
+ }
+}
+
export function resolveConcreteResourceKey(owner, record, typeAttrs = ['attachable_type', 'facilitator_type', 'subject_type', 'customer_type', 'maintainable_type', 'type']) {
return concreteKey(owner, record, typeAttrs);
}
@@ -81,8 +105,18 @@ export default function buildPolymorphicDescriptors(owner) {
open: async (record, context) => {
const registry = owner.lookup('service:resource-registry');
const key = concreteKey(owner, record, base.typeAttrs);
+ const descriptor = key ? registry.getDescriptor(key) : null;
+
+ if (typeof descriptor?.open !== 'function') {
+ return false;
+ }
+
+ // Open through the concrete descriptor directly. Handing the base
+ // record back to `registry.open` resolves it to this descriptor
+ // again, and the promise loop froze the page with no error.
+ const concrete = await loadConcreteRecord(owner, descriptor, record);
- return key ? registry.open(record, { ...context, resourceType: key }) : false;
+ return descriptor.open(concrete, { ...context, resourceType: key });
},
components: { identity: `cell/${base.key}-identity` },
}));
diff --git a/app/components/equipment/card.js b/app/components/equipment/card.js
new file mode 100644
index 000000000..e9f0cdf0f
--- /dev/null
+++ b/app/components/equipment/card.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/equipment/card';
diff --git a/app/components/equipment/panel-header.js b/app/components/equipment/panel-header.js
new file mode 100644
index 000000000..a8d01a025
--- /dev/null
+++ b/app/components/equipment/panel-header.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/equipment/panel-header';
diff --git a/app/components/geofence-map.js b/app/components/geofence-map.js
new file mode 100644
index 000000000..8ebaa4bec
--- /dev/null
+++ b/app/components/geofence-map.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/geofence-map';
diff --git a/app/components/issue/timeline.js b/app/components/issue/timeline.js
new file mode 100644
index 000000000..2bc663882
--- /dev/null
+++ b/app/components/issue/timeline.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/issue/timeline';
diff --git a/app/components/layout/fleet-ops-sidebar/operations-monitor.js b/app/components/layout/fleet-ops-sidebar/operations-monitor.js
new file mode 100644
index 000000000..174ed5cfb
--- /dev/null
+++ b/app/components/layout/fleet-ops-sidebar/operations-monitor.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/layout/fleet-ops-sidebar/operations-monitor';
diff --git a/app/components/maintenance-schedule/work-orders.js b/app/components/maintenance-schedule/work-orders.js
new file mode 100644
index 000000000..4a100df74
--- /dev/null
+++ b/app/components/maintenance-schedule/work-orders.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/maintenance-schedule/work-orders';
diff --git a/app/components/modals/radar-notice.js b/app/components/modals/radar-notice.js
new file mode 100644
index 000000000..a5534f004
--- /dev/null
+++ b/app/components/modals/radar-notice.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/modals/radar-notice';
diff --git a/app/components/modals/radar-save-view.js b/app/components/modals/radar-save-view.js
new file mode 100644
index 000000000..21a6440c0
--- /dev/null
+++ b/app/components/modals/radar-save-view.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/modals/radar-save-view';
diff --git a/app/components/modals/radar-select-resource.js b/app/components/modals/radar-select-resource.js
new file mode 100644
index 000000000..2a87d6712
--- /dev/null
+++ b/app/components/modals/radar-select-resource.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/modals/radar-select-resource';
diff --git a/app/components/modals/radar-send-pin.js b/app/components/modals/radar-send-pin.js
new file mode 100644
index 000000000..de87174e1
--- /dev/null
+++ b/app/components/modals/radar-send-pin.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/modals/radar-send-pin';
diff --git a/app/components/part/card.js b/app/components/part/card.js
new file mode 100644
index 000000000..8fc772e8f
--- /dev/null
+++ b/app/components/part/card.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/part/card';
diff --git a/app/components/part/panel-header.js b/app/components/part/panel-header.js
new file mode 100644
index 000000000..f2dffeca9
--- /dev/null
+++ b/app/components/part/panel-header.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/part/panel-header';
diff --git a/app/components/radar/agenda.js b/app/components/radar/agenda.js
new file mode 100644
index 000000000..445bd6716
--- /dev/null
+++ b/app/components/radar/agenda.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/agenda';
diff --git a/app/components/radar/agenda/entry.js b/app/components/radar/agenda/entry.js
new file mode 100644
index 000000000..fcc20313f
--- /dev/null
+++ b/app/components/radar/agenda/entry.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/agenda/entry';
diff --git a/app/components/radar/agenda/lane.js b/app/components/radar/agenda/lane.js
new file mode 100644
index 000000000..14c856265
--- /dev/null
+++ b/app/components/radar/agenda/lane.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/agenda/lane';
diff --git a/app/components/radar/briefing.js b/app/components/radar/briefing.js
new file mode 100644
index 000000000..0490b27e5
--- /dev/null
+++ b/app/components/radar/briefing.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/briefing';
diff --git a/app/components/radar/bulk-bar.js b/app/components/radar/bulk-bar.js
new file mode 100644
index 000000000..50b6743c5
--- /dev/null
+++ b/app/components/radar/bulk-bar.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/bulk-bar';
diff --git a/app/components/radar/decision-card.js b/app/components/radar/decision-card.js
new file mode 100644
index 000000000..75280932a
--- /dev/null
+++ b/app/components/radar/decision-card.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/decision-card';
diff --git a/app/components/radar/empty-state.js b/app/components/radar/empty-state.js
new file mode 100644
index 000000000..f1a2ca110
--- /dev/null
+++ b/app/components/radar/empty-state.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/empty-state';
diff --git a/app/components/radar/filter-pills.js b/app/components/radar/filter-pills.js
new file mode 100644
index 000000000..9775b5a76
--- /dev/null
+++ b/app/components/radar/filter-pills.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/filter-pills';
diff --git a/app/components/radar/handover-card.js b/app/components/radar/handover-card.js
new file mode 100644
index 000000000..5b8e4afe5
--- /dev/null
+++ b/app/components/radar/handover-card.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/handover-card';
diff --git a/app/components/radar/header.js b/app/components/radar/header.js
new file mode 100644
index 000000000..d97408a51
--- /dev/null
+++ b/app/components/radar/header.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/header';
diff --git a/app/components/radar/item-drawer.js b/app/components/radar/item-drawer.js
new file mode 100644
index 000000000..4209515d3
--- /dev/null
+++ b/app/components/radar/item-drawer.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/item-drawer';
diff --git a/app/components/radar/item-list.js b/app/components/radar/item-list.js
new file mode 100644
index 000000000..eca4017f4
--- /dev/null
+++ b/app/components/radar/item-list.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/item-list';
diff --git a/app/components/radar/item-row.js b/app/components/radar/item-row.js
new file mode 100644
index 000000000..71c89dd8b
--- /dev/null
+++ b/app/components/radar/item-row.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/item-row';
diff --git a/app/components/radar/keyboard-hints.js b/app/components/radar/keyboard-hints.js
new file mode 100644
index 000000000..b51a97a12
--- /dev/null
+++ b/app/components/radar/keyboard-hints.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/keyboard-hints';
diff --git a/app/components/radar/subject.js b/app/components/radar/subject.js
new file mode 100644
index 000000000..1ac430ee9
--- /dev/null
+++ b/app/components/radar/subject.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/subject';
diff --git a/app/components/radar/view-bar.js b/app/components/radar/view-bar.js
new file mode 100644
index 000000000..503eb2751
--- /dev/null
+++ b/app/components/radar/view-bar.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/radar/view-bar';
diff --git a/app/components/resource-activity.js b/app/components/resource-activity.js
new file mode 100644
index 000000000..f5726ae9a
--- /dev/null
+++ b/app/components/resource-activity.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/resource-activity';
diff --git a/app/components/vendor/personnel-tab.js b/app/components/vendor/personnel-tab.js
new file mode 100644
index 000000000..4a0b074d7
--- /dev/null
+++ b/app/components/vendor/personnel-tab.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/vendor/personnel-tab';
diff --git a/app/components/widget/radar.js b/app/components/widget/radar.js
new file mode 100644
index 000000000..22cf777da
--- /dev/null
+++ b/app/components/widget/radar.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/widget/radar';
diff --git a/app/components/work-order/panel-header.js b/app/components/work-order/panel-header.js
new file mode 100644
index 000000000..2ec55b274
--- /dev/null
+++ b/app/components/work-order/panel-header.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/work-order/panel-header';
diff --git a/app/helpers/initials.js b/app/helpers/initials.js
new file mode 100644
index 000000000..2f051ee8d
--- /dev/null
+++ b/app/helpers/initials.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/helpers/initials';
diff --git a/app/modifiers/radar-keyboard.js b/app/modifiers/radar-keyboard.js
new file mode 100644
index 000000000..3c72e5ffb
--- /dev/null
+++ b/app/modifiers/radar-keyboard.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/modifiers/radar-keyboard';
diff --git a/app/utils/radar.js b/app/utils/radar.js
new file mode 100644
index 000000000..d27d32d1b
--- /dev/null
+++ b/app/utils/radar.js
@@ -0,0 +1 @@
+export * from '@fleetbase/fleetops-engine/utils/radar';
diff --git a/server/migrations/2026_09_16_000001_add_public_id_to_alerts_table.php b/server/migrations/2026_09_16_000001_add_public_id_to_alerts_table.php
new file mode 100644
index 000000000..649ce4455
--- /dev/null
+++ b/server/migrations/2026_09_16_000001_add_public_id_to_alerts_table.php
@@ -0,0 +1,45 @@
+string('public_id', 191)->nullable()->unique()->after('uuid');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ if (!Schema::hasTable('alerts') || !Schema::hasColumn('alerts', 'public_id')) {
+ return;
+ }
+
+ Schema::table('alerts', function (Blueprint $table) {
+ $table->dropUnique(['public_id']);
+ $table->dropColumn('public_id');
+ });
+ }
+};
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..a43e2d401
--- /dev/null
+++ b/server/src/Http/Controllers/Internal/v1/RadarController.php
@@ -0,0 +1,1117 @@
+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::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([
+ '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(),
+ ]);
+ }
+
+ /**
+ * 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) use ($request) {
+ RadarItemState::acknowledge($row, $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) {
+ RadarItemState::snooze($row, $this->now()->copy()->addMinutes($minutes), $request->input('reason'), $this->actor($request));
+ });
+ }
+
+ public function wake(Request $request, string $key): JsonResponse
+ {
+ return $this->act($request, $key, function (Alert $row) {
+ RadarItemState::wake($row);
+ }, 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) {
+ RadarItemState::assign($row, $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) {
+ RadarItemState::plan($row, $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) {
+ RadarItemState::resolve($row, $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' => RadarItemState::acknowledge($row, $actor),
+ 'snooze' => RadarItemState::snooze($row, $now->copy()->addMinutes((int) $minutes), $request->input('reason'), $actor),
+ 'wake' => RadarItemState::wake($row),
+ 'assign' => RadarItemState::assign($row, $assignee),
+ 'plan' => RadarItemState::plan($row, $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);
+ $built['sources'] = $sources;
+
+ 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;
+ }
+
+ /**
+ * 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)
+ // ------------------------------------------------------------------
+
+ 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);
+ }
+
+ /**
+ * 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);
+ }
+
+ 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 = RadarItemState::refresh($row);
+
+ return RadarRules::normalizeState(RadarItemState::toState($fresh, $this->usersFor([$fresh])), $now);
+ }
+
+ /**
+ * The users a set of rows point at, keyed by uuid.
+ */
+ protected function usersFor(array $rows): array
+ {
+ return RadarItemState::usersFor($rows);
+ }
+
+ // ------------------------------------------------------------------
+ // 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,
+ 'phone' => $type === 'driver' ? ($model->phone ?? null) : 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/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
new file mode 100644
index 000000000..9cc9e67e4
--- /dev/null
+++ b/server/src/Support/Radar/RadarItemState.php
@@ -0,0 +1,435 @@
+
+ */
+ public static function statesFor(?string $companyUuid): array
+ {
+ $rows = self::rows($companyUuid)->get();
+ $users = self::usersFor($rows);
+ $states = [];
+
+ foreach ($rows as $alert) {
+ $key = self::keyOf($alert);
+ if ($key) {
+ $states[$key] = self::toState($alert, $users);
+ }
+ }
+
+ 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;
+ }
+
+ // ------------------------------------------------------------------
+ // Writing state
+ // ------------------------------------------------------------------
+
+ /**
+ * Mark a row acknowledged by a user. An already acknowledged row keeps
+ * who acknowledged it first.
+ */
+ public static function acknowledge(Alert $alert, ?User $user): Alert
+ {
+ if (RadarRules::carbon($alert->getAttribute('acknowledged_at'))) {
+ return $alert;
+ }
+
+ $attributes = ['acknowledged_at' => now(), 'acknowledged_by_uuid' => $user?->uuid];
+ if (($alert->status ?? 'open') === 'open') {
+ $attributes['status'] = 'acknowledged';
+ }
+
+ return self::write($alert, $attributes);
+ }
+
+ /**
+ * Snooze a row until a moment, noting who did it and why.
+ */
+ public static function snooze(Alert $alert, Carbon $until, ?string $reason, ?User $user): Alert
+ {
+ return self::write($alert, [
+ 'snoozed_until' => $until,
+ 'snoozed_by_uuid' => $user?->uuid,
+ 'meta' => array_merge($alert->meta ?? [], ['snooze_reason' => $reason]),
+ ]);
+ }
+
+ /**
+ * End a snooze early.
+ */
+ public static function wake(Alert $alert): Alert
+ {
+ return self::write($alert, ['snoozed_until' => null, 'snoozed_by_uuid' => null]);
+ }
+
+ /**
+ * Give a row an owner, or clear it with null.
+ */
+ public static function assign(Alert $alert, ?User $user): Alert
+ {
+ return self::write($alert, ['assigned_to_uuid' => $user?->uuid]);
+ }
+
+ /**
+ * Set the time the owner plans to deal with a row, or clear it.
+ */
+ public static function plan(Alert $alert, ?Carbon $at): Alert
+ {
+ return self::write($alert, ['planned_at' => $at]);
+ }
+
+ /**
+ * Resolve a row, recording who resolved it and how.
+ */
+ public static function resolve(Alert $alert, ?User $user, ?string $resolution): Alert
+ {
+ return self::write($alert, [
+ 'status' => 'resolved',
+ 'resolved_at' => now(),
+ 'resolved_by_uuid' => $user?->uuid,
+ 'meta' => array_merge($alert->meta ?? [], ['resolution' => $resolution]),
+ ]);
+ }
+
+ /**
+ * 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;
+ }
+
+ self::resolve($alert, null, 'auto');
+ $closed++;
+ }
+
+ return $closed;
+ }
+
+ // ------------------------------------------------------------------
+ // Reading state
+ // ------------------------------------------------------------------
+
+ /**
+ * 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)
+ ->orderByDesc('resolved_at')
+ ->get();
+ $users = self::usersFor($rows);
+
+ 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, $users);
+ $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' => $state['resolution'] === 'auto' ? 'closed on the record' : ('resolved by ' . ($state['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' => self::iso($alert, 'snoozed_until'),
+ ])
+ ->values()
+ ->all();
+ }
+
+ /**
+ * Notices as source rows for RadarRules::noticeItems().
+ */
+ public static function notices(?string $companyUuid): array
+ {
+ $rows = self::rows($companyUuid, [RadarRules::NOTICE_TYPE])->orderByDesc('created_at')->get();
+ $users = self::usersFor($rows);
+
+ return $rows
+ ->map(function (Alert $alert) use ($users) {
+ $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, $users),
+ ];
+ })
+ ->all();
+ }
+
+ /**
+ * The users the given rows point at, keyed by uuid.
+ *
+ * @param iterable $alerts
+ *
+ * @return array
+ */
+ public static function usersFor(iterable $alerts): array
+ {
+ $uuids = [];
+ foreach ($alerts as $alert) {
+ foreach (self::USER_COLUMNS as $column) {
+ $uuid = $alert->getAttribute($column);
+ if (is_string($uuid) && $uuid !== '') {
+ $uuids[$uuid] = true;
+ }
+ }
+ }
+
+ if (!$uuids) {
+ return [];
+ }
+
+ return User::query()
+ ->whereIn('uuid', array_keys($uuids))
+ ->get(['uuid', 'public_id', 'name'])
+ ->mapWithKeys(fn (User $user) => [$user->uuid => ['uuid' => $user->uuid, 'public_id' => $user->public_id, 'name' => $user->name]])
+ ->all();
+ }
+
+ /**
+ * A row as the console reads it.
+ *
+ * @param array $users the users the row points at, from usersFor()
+ */
+ public static function toState(Alert $alert, array $users = []): array
+ {
+ $assignee = $users[$alert->getAttribute('assigned_to_uuid')] ?? null;
+ $name = fn (string $column) => $users[$alert->getAttribute($column)]['name'] ?? null;
+
+ return [
+ 'status' => $alert->status ?? 'open',
+ 'alert_id' => $alert->public_id ?? $alert->uuid,
+ 'acknowledged_at' => self::iso($alert, 'acknowledged_at'),
+ 'acknowledged_by_name' => $name('acknowledged_by_uuid'),
+ 'snoozed_until' => self::iso($alert, 'snoozed_until'),
+ 'snoozed_by_name' => $name('snoozed_by_uuid'),
+ 'assigned_to' => $assignee ? $assignee + ['initials' => self::initials($assignee['name'])] : null,
+ 'planned_at' => self::iso($alert, 'planned_at'),
+ 'resolved_at' => self::iso($alert, 'resolved_at'),
+ 'resolved_by_name' => $name('resolved_by_uuid'),
+ 'resolution' => $alert->meta['resolution'] ?? null,
+ 'triggered_at' => self::iso($alert, 'triggered_at'),
+ ];
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * A row after a write, as the database now has it.
+ */
+ public static function refresh(Alert $alert): Alert
+ {
+ return ($alert->exists ? $alert->fresh() : null) ?? $alert;
+ }
+
+ /**
+ * 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');
+ }
+
+ /**
+ * Write columns straight onto the row: some of them are not fillable on
+ * every core-api the model may come from.
+ */
+ protected static function write(Alert $alert, array $attributes): Alert
+ {
+ $alert->forceFill($attributes)->save();
+
+ return $alert;
+ }
+
+ /**
+ * A timestamp column as ISO 8601, whether or not the model casts it.
+ */
+ protected static function iso(Alert $alert, string $column): ?string
+ {
+ return RadarRules::carbon($alert->getAttribute($column))?->toIso8601String();
+ }
+}
diff --git a/server/src/Support/Radar/RadarRules.php b/server/src/Support/Radar/RadarRules.php
new file mode 100644
index 000000000..e0bd123c2
--- /dev/null
+++ b/server/src/Support/Radar/RadarRules.php
@@ -0,0 +1,1424 @@
+ ['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,
+ 'triggered_at' => $state['triggered_at'] ?? 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)));
+ }
+
+ /**
+ * 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.
+ */
+ 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,
+ 'phone' => $subject['phone'] ?? 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,
+ 'phone' => $driver['phone'] ?? 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 27651df90..05a7967a0 100644
--- a/server/src/routes.php
+++ b/server/src/routes.php
@@ -841,6 +841,30 @@ 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->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');
+ $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/RadarControllerDatabaseTest.php b/server/tests/Feature/Http/Internal/RadarControllerDatabaseTest.php
new file mode 100644
index 000000000..ee93ca527
--- /dev/null
+++ b/server/tests/Feature/Http/Internal/RadarControllerDatabaseTest.php
@@ -0,0 +1,415 @@
+getMessage() : (string) $exception;
+ }
+}
+
+/**
+ * The real controller over an in-memory database: only the clock is fixed.
+ */
+class FleetOpsRadarDatabaseController extends RadarController
+{
+ protected function now(): Carbon
+ {
+ return Carbon::parse('2026-09-15 08:35:00', 'UTC');
+ }
+}
+
+/**
+ * Every table Radar's loaders read, as nullable strings, with MySQL's
+ * spatial functions standing in so a driver's location reads back as a point.
+ */
+function fleetOpsRadarDatabase(): SQLiteConnection
+{
+ $pdo = new PDO('sqlite::memory:');
+ $asStoredPoint = function ($wkt, $srid = 0, $axisOrder = null) {
+ if (!preg_match('/POINT\s*\(\s*(-?[\d.]+)\s+(-?[\d.]+)\s*\)/i', (string) $wkt, $pair)) {
+ return $wkt;
+ }
+
+ return pack('V', (int) $srid) . pack('C', 1) . pack('V', 1) . pack('d', (float) $pair[1]) . pack('d', (float) $pair[2]);
+ };
+ $pdo->sqliteCreateFunction('ST_PointFromText', $asStoredPoint);
+ $pdo->sqliteCreateFunction('ST_GeomFromText', $asStoredPoint);
+
+ $connection = new SQLiteConnection($pdo);
+ $resolver = new ConnectionResolver(['default' => $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]],
+ 'filesystems' => ['default' => 'local'],
+ ]);
+ 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('/'));
+
+ $common = ['uuid', 'public_id', '_key', 'company_uuid', 'meta', 'slug', 'internal_id'];
+ $tables = [
+ 'companies' => ['name', 'owner_uuid'],
+ 'users' => ['name', 'email', 'phone', 'avatar_uuid', 'type', 'status', 'timezone'],
+ 'company_users' => ['user_uuid', 'role_uuid', 'status'],
+ 'files' => ['uploader_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'bucket', 'type', 'content_type', 'original_filename', 'file_size', 'caption', 'folder', 'etag'],
+ 'settings' => ['key', 'value'],
+ 'alerts' => ['category_uuid', 'acknowledged_by_uuid', 'resolved_by_uuid', 'snoozed_by_uuid', 'assigned_to_uuid', 'type', 'severity', 'status', 'subject_type', 'subject_uuid', 'message', 'rule', 'context', 'triggered_at', 'acknowledged_at', 'resolved_at', 'snoozed_until', 'planned_at'],
+ 'maintenance_schedules' => ['subject_type', 'subject_uuid', 'name', 'type', 'status', 'next_due_date', 'next_due_odometer', 'default_priority', 'default_assignee_type', 'default_assignee_uuid', 'instructions', 'reminder_offsets', 'interval_method', 'interval_type', 'interval_value', 'interval_unit'],
+ 'work_orders' => ['schedule_uuid', 'category_uuid', 'code', 'subject', 'category', 'status', 'priority', 'target_type', 'target_uuid', 'assignee_type', 'assignee_uuid', 'opened_at', 'due_at', 'closed_at', 'instructions', 'checklist', 'currency', 'estimated_cost', 'approved_budget', 'actual_cost', 'created_by_uuid', 'updated_by_uuid'],
+ 'maintenances' => ['maintainable_type', 'maintainable_uuid', 'work_order_uuid', 'status', 'scheduled_at', 'completed_at', 'type', 'priority'],
+ 'issues' => ['reported_by_uuid', 'assigned_to_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'issue_id', 'location', 'category', 'type', 'report', 'title', 'tags', 'priority', 'resolved_at', 'status'],
+ 'drivers' => ['user_uuid', 'vehicle_uuid', 'vendor_uuid', 'current_job_uuid', 'photo_uuid', 'status', 'current_status', 'online', 'location', 'license_expiry', 'drivers_license_number', 'country', 'city'],
+ 'vehicles' => ['vendor_uuid', 'photo_uuid', 'category_uuid', 'warranty_uuid', 'telematic_uuid', 'name', 'make', 'model', 'year', 'trim', 'plate_number', 'vin', 'call_sign', 'status', 'online', 'location', 'fuel_card_number', 'lease_expires_at', 'currency', 'measurement_system'],
+ 'devices' => ['telematic_uuid', 'warranty_uuid', 'photo_uuid', 'attachable_uuid', 'attachable_type', 'type', 'device_id', 'name', 'status', 'online', 'last_online_at', 'last_position', 'data', 'options'],
+ 'assets' => ['asset_class', 'category_uuid', 'vendor_uuid', 'warranty_uuid', 'photo_uuid', 'telematic_uuid', 'assigned_to_uuid', 'assigned_to_type', 'operator_uuid', 'operator_type', 'name', 'code', 'type', 'make', 'model', 'year', 'plate_number', 'vin', 'status', 'online', 'last_online_at', 'location', 'lease_expires_at', 'purchased_at', 'specs', 'attributes', 'telematics', 'notes'],
+ 'asset_connections' => ['asset_uuid', 'connected_to_uuid', 'connected_to_type', 'status', 'connected_at', 'disconnected_at'],
+ 'equipments' => ['name', 'code', 'type', 'status', 'equipable_type', 'equipable_uuid', 'photo_uuid', 'warranty_uuid', 'serial_number', 'manufacturer', 'model'],
+ 'parts' => ['vendor_uuid', 'warranty_uuid', 'photo_uuid', 'asset_type', 'asset_uuid', 'sku', 'name', 'quantity_on_hand', 'reorder_point', 'reorder_quantity', 'unit_cost', 'currency', 'type', 'status', 'specs'],
+ 'fuel_provider_transactions' => ['fuel_provider_connection_uuid', 'fuel_report_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'provider', 'provider_transaction_id', 'vehicle_card_id', 'plate_number', 'vin', 'station_name', 'station_latitude', 'station_longitude', 'transaction_at', 'volume', 'metric_unit', 'amount', 'currency', 'sync_status', 'matched_at'],
+ 'inspection_forms' => ['name', 'description', 'type', 'status', 'subject_type', 'subject_uuid', 'items', 'settings', 'published_at', 'created_by_uuid', 'updated_by_uuid'],
+ 'inspection_submissions' => ['inspection_form_uuid', 'vehicle_uuid', 'driver_uuid', 'submitted_by_uuid', 'issue_uuid', 'work_order_uuid', 'type', 'status', 'result', 'source', 'total_items', 'failed_items', 'started_at', 'submitted_at', 'resolved_at', 'location', 'signature', 'attachments', 'created_by_uuid', 'updated_by_uuid'],
+ 'inspection_item_results' => ['inspection_submission_uuid', 'issue_uuid', 'work_order_uuid', 'item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos', 'created_by_uuid', 'updated_by_uuid'],
+ 'inspection_links' => ['inspection_form_uuid', 'driver_uuid', 'vehicle_uuid', 'assignee_uuid', 'created_by_uuid', 'token_hash', 'token', 'pin_hash', 'pin', 'pin_attempts', 'status', 'single_use', 'expires_at', 'last_viewed_at', 'used_at'],
+ 'schedule_items' => ['schedule_uuid', 'template_uuid', 'assignee_uuid', 'assignee_type', 'resource_uuid', 'resource_type', 'start_at', 'end_at', 'duration', 'break_start_at', 'break_end_at', 'status', 'is_exception', 'exception_for_date'],
+ 'orders' => ['driver_assigned_uuid', 'vehicle_assigned_uuid', 'payload_uuid', 'tracking_number_uuid', 'order_config_uuid', 'status', 'type', 'scheduled_at', 'dispatched_at', 'started_at', 'time_window_start', 'time_window_end'],
+ 'payloads' => ['pickup_uuid', 'dropoff_uuid', 'return_uuid', 'current_waypoint_uuid'],
+ 'entities' => ['payload_uuid', 'destination_uuid', 'customer_uuid', 'customer_type', 'photo_uuid', 'name', 'type'],
+ 'waypoints' => ['payload_uuid', 'place_uuid', 'tracking_number_uuid', 'customer_uuid', 'customer_type', 'order', 'type'],
+ 'places' => ['owner_uuid', 'owner_type', 'name', 'street1', 'street2', 'city', 'country', 'location'],
+ 'activity_log' => ['log_name', 'description', 'subject_type', 'subject_id', 'causer_type', 'causer_id', 'properties', 'event', 'batch_uuid'],
+ ];
+
+ $schema = $connection->getSchemaBuilder();
+ foreach ($tables as $table => $columns) {
+ $schema->create($table, function ($blueprint) use ($common, $columns, $table) {
+ $blueprint->increments('id');
+ foreach (array_unique(array_merge($table === 'settings' ? [] : $common, $columns)) as $column) {
+ $blueprint->string($column)->nullable();
+ }
+ if ($table !== 'settings') {
+ $blueprint->timestamps();
+ $blueprint->timestamp('deleted_at')->nullable();
+ }
+ });
+ }
+
+ $point = fn (float $lat, float $lng) => $asStoredPoint("POINT({$lng} {$lat})");
+ // Multi-row inserts need the same columns on every row.
+ $db = function (string $table, array $rows) use ($connection) {
+ $rows = array_map(fn ($row) => $row + ['company_uuid' => 'company-radar'], $rows);
+ $columns = array_fill_keys(array_merge(...array_map('array_keys', $rows)), null);
+ $connection->table($table)->insert(array_map(fn ($row) => array_merge($columns, $row), $rows));
+ };
+
+ $connection->table('companies')->insert(['uuid' => 'company-radar', 'public_id' => 'company_radar', 'name' => 'Radar Co']);
+ $db('users', [
+ ['uuid' => 'user-ada', 'public_id' => 'user_ada', 'name' => 'Ada Ops', 'type' => 'user'],
+ ['uuid' => 'user-ortega', 'public_id' => 'user_ortega', 'name' => 'Luis Ortega', 'phone' => '+15550001', 'type' => 'driver'],
+ ['uuid' => 'user-alves', 'public_id' => 'user_alves', 'name' => 'Tomas Alves', 'type' => 'driver'],
+ ['uuid' => 'user-nair', 'public_id' => 'user_nair', 'name' => 'Priya Nair', 'type' => 'driver'],
+ ]);
+ $connection->table('users')->insert(['uuid' => 'user-guest', 'public_id' => 'user_guest', 'company_uuid' => 'company-other', 'name' => 'Guest Member', 'type' => 'user']);
+ $connection->table('users')->insert(['uuid' => 'user-stranger', 'public_id' => 'user_stranger', 'company_uuid' => 'company-other', 'name' => 'Stranger', 'type' => 'user']);
+ $connection->table('company_users')->insert(['uuid' => 'cu-guest', 'company_uuid' => 'company-radar', 'user_uuid' => 'user-guest', 'status' => 'active']);
+
+ $db('vehicles', [
+ ['uuid' => 'vehicle-118', 'public_id' => 'vehicle_118', 'name' => 'TRK-118', 'status' => 'available', 'plate_number' => 'PLT-118', 'fuel_card_number' => '4471'],
+ ['uuid' => 'vehicle-042', 'public_id' => 'vehicle_042', 'name' => 'VAN-042', 'status' => 'available', 'lease_expires_at' => '2026-09-30 00:00:00'],
+ ]);
+ $db('drivers', [
+ ['uuid' => 'driver-ortega', 'public_id' => 'driver_ortega', 'user_uuid' => 'user-ortega', 'vehicle_uuid' => 'vehicle-118', 'status' => 'active', 'online' => '1', 'location' => $point(40.70, -74.00)],
+ ['uuid' => 'driver-alves', 'public_id' => 'driver_alves', 'user_uuid' => 'user-alves', 'vehicle_uuid' => 'vehicle-777', 'status' => 'active', 'online' => '1', 'location' => $point(40.71, -74.00)],
+ ['uuid' => 'driver-nair', 'public_id' => 'driver_nair', 'user_uuid' => 'user-nair', 'status' => 'active', 'online' => '0', 'license_expiry' => '2026-10-01', 'drivers_license_number' => 'D-9'],
+ ]);
+ $db('schedule_items', [
+ ['uuid' => 'shift-ortega', 'public_id' => 'shift_ortega', 'assignee_uuid' => 'driver-ortega', 'assignee_type' => Driver::class, 'start_at' => '2026-09-15 01:15:00', 'end_at' => '2026-09-15 09:15:00', 'status' => 'in_progress'],
+ ['uuid' => 'shift-alves', 'public_id' => 'shift_alves', 'assignee_uuid' => 'driver-alves', 'assignee_type' => Driver::class, 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 21:00:00', 'status' => 'in_progress'],
+ ]);
+ $db('places', [['uuid' => 'place-bay', 'public_id' => 'place_bay', 'name' => 'Bay Ridge']]);
+ $db('payloads', [['uuid' => 'payload-1', 'public_id' => 'payload_1', 'dropoff_uuid' => 'place-bay']]);
+ $db('orders', [
+ ['uuid' => 'order-1', 'public_id' => 'order_1', 'driver_assigned_uuid' => 'driver-ortega', 'payload_uuid' => 'payload-1', 'status' => 'dispatched', 'scheduled_at' => '2026-09-15 09:40:00'],
+ ]);
+ $db('assets', [
+ ['uuid' => 'trailer-019', 'public_id' => 'trailer_019', 'asset_class' => 'trailer', 'name' => 'TRL-019', 'status' => 'available', 'lease_expires_at' => '2026-09-20 00:00:00'],
+ ]);
+ $db('devices', [['uuid' => 'device-477', 'public_id' => 'device_477', 'name' => 'GPS-477', 'device_id' => '477', 'status' => 'active']]);
+ $db('equipments', [['uuid' => 'equipment-1', 'public_id' => 'equipment_1', 'name' => 'Reefer unit']]);
+ $db('parts', [['uuid' => 'part-pads', 'public_id' => 'part_pads', 'name' => 'Brake pads', 'sku' => 'PN 8842', 'quantity_on_hand' => '2', 'reorder_point' => '6']]);
+ $db('maintenance_schedules', [
+ ['uuid' => 'schedule-oil', 'public_id' => 'schedule_oil', 'subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-118', 'name' => 'Oil change', 'type' => 'oil_change', 'status' => 'active', 'next_due_date' => '2026-09-12 08:00:00'],
+ ['uuid' => 'schedule-trailer', 'public_id' => 'schedule_trailer', 'subject_type' => Trailer::class, 'subject_uuid' => 'trailer-019', 'name' => 'Trailer service', 'type' => 'inspection', 'status' => 'active', 'next_due_date' => '2026-09-16 08:00:00'],
+ ['uuid' => 'schedule-device', 'public_id' => 'schedule_device', 'subject_type' => Device::class, 'subject_uuid' => 'device-477', 'name' => 'Firmware check', 'type' => 'other', 'status' => 'active', 'next_due_date' => '2026-09-17 08:00:00'],
+ ['uuid' => 'schedule-part', 'public_id' => 'schedule_part', 'subject_type' => Part::class, 'subject_uuid' => 'part-pads', 'name' => 'Stock count', 'type' => 'other', 'status' => 'active', 'next_due_date' => '2026-09-18 08:00:00'],
+ ['uuid' => 'schedule-equipment', 'public_id' => 'schedule_equipment', 'subject_type' => Equipment::class, 'subject_uuid' => 'equipment-1', 'name' => 'Reefer service', 'type' => 'other', 'status' => 'active', 'next_due_date' => '2026-09-19 08:00:00'],
+ ]);
+ $db('work_orders', [
+ ['uuid' => 'wo-2041', 'public_id' => 'work_order_2041', 'code' => 'WO-2041', 'subject' => 'brake pads', 'status' => 'in_progress', 'priority' => 'high', 'target_type' => Vehicle::class, 'target_uuid' => 'vehicle-118', 'due_at' => '2026-09-13 09:00:00'],
+ ['uuid' => 'wo-oil', 'public_id' => 'work_order_oil', 'code' => 'WO-9', 'subject' => 'oil', 'status' => 'open', 'schedule_uuid' => 'schedule-trailer', 'due_at' => '2026-09-30 09:00:00'],
+ ]);
+ $db('issues', [
+ ['uuid' => 'issue-1', 'public_id' => 'issue_1', 'title' => 'Check engine light', 'priority' => 'high', 'status' => 'pending', 'vehicle_uuid' => 'vehicle-118', 'driver_uuid' => 'driver-ortega'],
+ ]);
+ $db('fuel_provider_transactions', [
+ ['uuid' => 'fuel-1', 'public_id' => 'fuel_provider_transaction_1', 'amount' => '21240', 'currency' => 'USD', 'station_name' => 'Pilot #331', 'vehicle_card_id' => '4471', 'sync_status' => 'unmatched', 'transaction_at' => '2026-09-14 08:04:00'],
+ ]);
+ $db('inspection_forms', [['uuid' => 'form-1', 'public_id' => 'inspection_form_1', 'name' => 'Pre-trip DVIR', 'type' => 'dvir', 'status' => 'published']]);
+ $db('inspection_submissions', [
+ ['uuid' => 'sub-failed', 'public_id' => 'inspection_submission_failed', 'inspection_form_uuid' => 'form-1', 'vehicle_uuid' => 'vehicle-042', 'driver_uuid' => 'driver-nair', 'type' => 'dvir', 'status' => 'submitted', 'result' => 'failed', 'total_items' => '3', 'failed_items' => '2', 'submitted_at' => '2026-09-15 07:00:00'],
+ ['uuid' => 'sub-plain', 'public_id' => 'inspection_submission_plain', 'inspection_form_uuid' => 'form-1', 'vehicle_uuid' => 'vehicle-118', 'type' => 'dvir', 'status' => 'submitted', 'result' => 'failed', 'total_items' => '1', 'failed_items' => '1', 'submitted_at' => '2026-09-15 07:00:00'],
+ ['uuid' => 'sub-draft', 'public_id' => 'inspection_submission_draft', 'inspection_form_uuid' => 'form-1', 'type' => 'dvir', 'status' => 'draft', 'total_items' => '0', 'failed_items' => '0', 'started_at' => '2026-09-13 07:00:00'],
+ ]);
+ $db('inspection_item_results', [
+ ['uuid' => 'res-1', 'inspection_submission_uuid' => 'sub-failed', 'label' => 'Brakes', 'severity' => 'Critical', 'status' => 'failed', 'passed' => '0'],
+ ['uuid' => 'res-2', 'inspection_submission_uuid' => 'sub-failed', 'label' => 'Wipers', 'severity' => 'low', 'status' => 'failed', 'passed' => '0'],
+ ]);
+ $db('inspection_links', [
+ ['uuid' => 'link-1', 'public_id' => 'inspection_link_1', 'inspection_form_uuid' => 'form-1', 'driver_uuid' => 'driver-nair', 'status' => 'active', 'expires_at' => '2026-09-15 20:00:00'],
+ ]);
+
+ session(['company' => 'company-radar', 'user' => 'user-ada']);
+ $GLOBALS['fleetOpsRadarReported'] = [];
+
+ return $connection;
+}
+
+function fleetOpsRadarDatabaseRequest(string $method = 'GET', array $parameters = [], ?string $userUuid = 'user-ada'): Request
+{
+ $request = Request::create('/int/v1/fleet-ops/radar', $method, $parameters);
+ $request->setUserResolver(fn () => $userUuid ? User::query()->where('uuid', $userUuid)->first() : null);
+
+ return $request;
+}
+
+afterEach(fn () => Carbon::setTestNow());
+
+test('items, briefing and agenda load every source from the database', function () {
+ fleetOpsRadarDatabase();
+ Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC'));
+ $controller = new FleetOpsRadarDatabaseController();
+
+ $payload = $controller->items(fleetOpsRadarDatabaseRequest())->getData(true);
+ $keys = array_column($payload['items'], 'key');
+ $byKey = array_column($payload['items'], null, 'key');
+
+ expect($payload['sources'])->toBe([])
+ ->and($keys)->toContain(
+ 'maintenance_overdue:schedule_oil',
+ 'inspection_due:schedule_trailer',
+ 'maintenance_due_soon:schedule_device',
+ 'maintenance_due_soon:schedule_part',
+ 'maintenance_due_soon:schedule_equipment',
+ 'work_order_overdue:work_order_2041',
+ 'issue_open:issue_1',
+ 'shift_handover:driver_ortega',
+ 'license_expiring:driver_nair',
+ 'driver_without_vehicle:driver_nair',
+ 'vehicle_without_driver:vehicle_042',
+ 'lease_expiring:vehicle_042',
+ 'lease_expiring:trailer_019',
+ 'device_unattached:device_477',
+ 'part_low_stock:part_pads',
+ 'fuel_unmatched:fuel_provider_transaction_1',
+ 'inspection_failed:inspection_submission_failed',
+ 'inspection_failed:inspection_submission_plain',
+ 'inspection_draft:inspection_submission_draft',
+ 'inspection_link_pending:inspection_link_1',
+ )
+ ->and($byKey['maintenance_overdue:schedule_oil']['subject'])->toMatchArray(['type' => 'vehicle', 'label' => 'TRK-118'])
+ ->and($byKey['inspection_due:schedule_trailer']['subject']['type'])->toBe('trailer')
+ ->and($byKey['maintenance_due_soon:schedule_device']['subject']['type'])->toBe('device')
+ ->and($byKey['maintenance_due_soon:schedule_part']['subject']['type'])->toBe('part')
+ ->and($byKey['maintenance_due_soon:schedule_equipment']['subject']['type'])->toBe('equipment')
+ ->and($byKey['issue_open:issue_1']['subject']['label'])->toBe('TRK-118')
+ ->and($byKey['shift_handover:driver_ortega']['subject'])->toMatchArray(['label' => 'Luis Ortega', 'phone' => '+15550001'])
+ ->and($byKey['fuel_unmatched:fuel_provider_transaction_1']['source']['suggested_vehicle']['public_id'])->toBe('vehicle_118')
+ ->and($byKey['inspection_failed:inspection_submission_failed']['severity'])->toBe('critical')
+ ->and(array_column($byKey['inspection_failed:inspection_submission_failed']['details']['failed_items'], 'label'))->toBe(['Brakes', 'Wipers'])
+ ->and($byKey['inspection_failed:inspection_submission_plain']['severity'])->toBe('warning')
+ ->and($byKey['inspection_link_pending:inspection_link_1']['record']['model'])->toBe('inspection_form_1');
+
+ $brief = $controller->briefing(fleetOpsRadarDatabaseRequest())->getData(true);
+ expect($brief['score']['delta'])->toBeNull()
+ ->and($brief['sources'])->toBe([]);
+
+ // The score was remembered, so a second reading has something to compare.
+ $again = $controller->briefing(fleetOpsRadarDatabaseRequest())->getData(true);
+ expect($again['score']['value'])->toBe($brief['score']['value']);
+
+ $agenda = $controller->agenda(fleetOpsRadarDatabaseRequest('GET', ['window' => '24h']))->getData(true);
+ expect($agenda['handovers'][0]['orders'][0])->toMatchArray(['public_id' => 'order_1', 'destination' => 'Bay Ridge'])
+ ->and($agenda['handovers'][0]['suggested']['driver']['label'])->toBe('Tomas Alves')
+ ->and($agenda['handovers'][0]['suggested']['distance_km'])->toBe(1.1);
+
+ $card = $controller->handoverSuggest(fleetOpsRadarDatabaseRequest(), 'shift_handover:driver_ortega')->getData(true);
+ expect($card['handover']['key'])->toBe('shift_handover:driver_ortega');
+});
+
+test('state actions write alert rows, resolve users by uuid and check company membership', function () {
+ fleetOpsRadarDatabase();
+ Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC'));
+ $controller = new FleetOpsRadarDatabaseController();
+ $key = 'issue_open:issue_1';
+
+ $acknowledged = $controller->acknowledge(fleetOpsRadarDatabaseRequest('POST'), $key)->getData(true);
+ expect($acknowledged['state'])->toMatchArray(['status' => 'acknowledged', 'acknowledged_by_name' => 'Ada Ops'])
+ ->and(Alert::query()->count())->toBe(1)
+ ->and(Alert::query()->first()->public_id)->toStartWith('alert_');
+
+ $snoozed = $controller->snooze(fleetOpsRadarDatabaseRequest('POST', ['minutes' => 60]), $key)->getData(true);
+ expect($snoozed['state'])->toMatchArray(['status' => 'snoozed', 'snoozed_by_name' => 'Ada Ops']);
+
+ $woken = $controller->wake(fleetOpsRadarDatabaseRequest('POST'), $key)->getData(true);
+ expect($woken['state']['snoozed_until'])->toBeNull();
+
+ // A member through company_users, a user of the company, and a stranger.
+ $guest = $controller->assign(fleetOpsRadarDatabaseRequest('POST', ['user' => 'user_guest']), $key)->getData(true);
+ expect($guest['state']['assigned_to'])->toMatchArray(['uuid' => 'user-guest', 'name' => 'Guest Member', 'initials' => 'GM']);
+
+ $own = $controller->assign(fleetOpsRadarDatabaseRequest('POST', ['user' => 'user-ada']), $key)->getData(true);
+ expect($own['state']['assigned_to']['name'])->toBe('Ada Ops');
+
+ expect($controller->assign(fleetOpsRadarDatabaseRequest('POST', ['user' => 'user_stranger']), $key)->getStatusCode())->toBe(422)
+ ->and($controller->assign(fleetOpsRadarDatabaseRequest('POST', ['user' => 'user_nobody']), $key)->getStatusCode())->toBe(422);
+
+ $bulk = $controller->bulk(fleetOpsRadarDatabaseRequest('POST', ['keys' => [$key], 'action' => 'plan', 'planned_at' => '2026-09-15 14:00:00']))->getData(true);
+ expect($bulk['results'][0]['state']['planned_at'])->toBe('2026-09-15T14:00:00+00:00');
+
+ // Without a signed-in user the action still lands, unattributed.
+ $anonymous = $controller->acknowledge(fleetOpsRadarDatabaseRequest('POST', [], null), 'part_low_stock:part_pads')->getData(true);
+ expect($anonymous['state']['acknowledged_by_name'])->toBeNull();
+
+ $notice = $controller->storeNotice(fleetOpsRadarDatabaseRequest('POST', ['message' => 'Yard closed Saturday', 'severity' => 'warning']));
+ $noticeKey = $notice->getData(true)['item']['key'];
+ $publicId = substr($noticeKey, strlen('notice:'));
+
+ $resolved = $controller->resolve(fleetOpsRadarDatabaseRequest('POST', ['resolution' => 'Done']), $noticeKey)->getData(true);
+ expect($resolved['state'])->toMatchArray(['status' => 'resolved', 'resolved_by_name' => 'Ada Ops', 'resolution' => 'Done']);
+
+ expect($controller->destroyNotice(fleetOpsRadarDatabaseRequest('DELETE'), $publicId)->getData(true))->toBe(['deleted' => true])
+ ->and($controller->destroyNotice(fleetOpsRadarDatabaseRequest('DELETE'), 'alert_nope')->getStatusCode())->toBe(404);
+
+ $resolvedTab = $controller->items(fleetOpsRadarDatabaseRequest('GET', ['status' => 'resolved']))->getData(true);
+ expect($resolvedTab['items'])->toBe([], 'a deleted notice leaves the resolved tab too');
+});
+
+test('extending a shift finds it among the company drivers and moves its end', function () {
+ fleetOpsRadarDatabase();
+ Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC'));
+ $controller = new FleetOpsRadarDatabaseController();
+
+ $payload = $controller->extendShift(fleetOpsRadarDatabaseRequest('POST', ['minutes' => 30]), 'shift_ortega')->getData(true);
+ expect($payload['shift']['end_at'])->toBe('2026-09-15T09:45:00+00:00');
+
+ expect($controller->extendShift(fleetOpsRadarDatabaseRequest('POST', ['minutes' => 30]), 'shift_nope')->getStatusCode())->toBe(404);
+
+ // A company with no drivers has no shifts to extend.
+ session(['company' => 'company-empty']);
+ expect($controller->extendShift(fleetOpsRadarDatabaseRequest('POST', ['minutes' => 30]), 'shift_ortega')->getStatusCode())->toBe(404);
+});
+
+test('a failing source, state table or settings table degrades the page instead of failing it', function () {
+ $connection = fleetOpsRadarDatabase();
+ Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC'));
+ $controller = new FleetOpsRadarDatabaseController();
+ $schema = $connection->getSchemaBuilder();
+
+ // Handover orders that cannot load leave the card without orders.
+ $schema->drop('payloads');
+ $agenda = $controller->agenda(fleetOpsRadarDatabaseRequest())->getData(true);
+ expect($agenda['handovers'][0]['orders'])->toBe([]);
+
+ $schema->drop('inspection_links');
+ $schema->drop('settings');
+ $brief = $controller->briefing(fleetOpsRadarDatabaseRequest())->getData(true);
+ expect(array_keys($brief['sources']))->toBe(['inspectionLinks'])
+ ->and($brief['score']['delta'])->toBeNull()
+ ->and($GLOBALS['fleetOpsRadarReported'])->not->toBe([]);
+
+ // Without the alerts table the items still compute; only notices and
+ // state go missing, and nothing is reconciled against them.
+ $schema->drop('alerts');
+ $withoutState = $controller->agenda(fleetOpsRadarDatabaseRequest())->getData(true);
+ expect(array_keys($withoutState['sources']))->toBe(['inspectionLinks', 'notices', 'states']);
+});
+
+test('without a company in the session the brief neither reads nor writes a score history', function () {
+ fleetOpsRadarDatabase();
+ Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC'));
+ session(['company' => null]);
+ $controller = new FleetOpsRadarDatabaseController();
+
+ $brief = $controller->briefing(fleetOpsRadarDatabaseRequest('GET', [], null))->getData(true);
+
+ expect($brief['score']['delta'])->toBeNull()
+ ->and(Fleetbase\Models\Setting::query()->count())->toBe(0);
+});
+
+test('a company with no drivers skips shifts and handover orders, on the real clock', function () {
+ fleetOpsRadarDatabase();
+ Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC'));
+ session(['company' => 'company-empty']);
+
+ $payload = (new RadarController())->agenda(fleetOpsRadarDatabaseRequest())->getData(true);
+
+ expect($payload['handovers'])->toBe([])
+ ->and($payload['lanes']['shifts'])->toBe([])
+ ->and($payload['generated_at'])->toBe('2026-09-15T08:35:00+00:00');
+});
diff --git a/server/tests/Feature/Http/Internal/RadarControllerTest.php b/server/tests/Feature/Http/Internal/RadarControllerTest.php
new file mode 100644
index 000000000..60fe6284d
--- /dev/null
+++ b/server/tests/Feature/Http/Internal/RadarControllerTest.php
@@ -0,0 +1,594 @@
+setRawAttributes($attributes, true);
+ $this->exists = false;
+ }
+
+ // 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 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, $this->usersFor([$spy]));
+ }
+
+ return $states + $this->seededStates;
+ }
+
+ /** The company's users, as the uuid lookup would find them. */
+ protected function usersFor(array $rows): array
+ {
+ return [
+ 'user-1' => ['uuid' => 'user-1', 'public_id' => 'user_1', 'name' => 'Ada Ops'],
+ 'user-2' => ['uuid' => 'user-2', 'public_id' => 'user_2', 'name' => 'Bo Tran'],
+ ];
+ }
+
+ 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;
+ }
+
+ public bool $failReconcile = false;
+
+ protected function reconcile(?string $company, array $liveKeys): int
+ {
+ if ($this->failReconcile) {
+ throw new RuntimeException('alerts are read-only');
+ }
+
+ $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'] ?? [];
+ }
+
+ 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);
+ $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([]);
+
+ // "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']]);
+ $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 () {
+ $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('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('a failed reconcile is reported with the sources and the list still answers', function () {
+ $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures());
+ $controller->failReconcile = true;
+
+ $payload = $controller->items(fleetOpsRadarRequest('items'))->getData(true);
+
+ expect($payload['sources'])->toBe(['reconcile' => 'alerts are read-only'])
+ ->and($payload['items'])->not->toBe([]);
+});
+
+test('wake reaches a row whose item has since closed, and bulk acknowledge marks every key', function () {
+ Carbon::setTestNow(Carbon::parse('2026-09-15 08:35:00', 'UTC'));
+ $controller = new FleetOpsRadarControllerProbe(fleetOpsRadarFixtures());
+
+ $controller->store['issue_open:issue_gone'] = new FleetOpsRadarAlertSpy(['uuid' => 'g', 'public_id' => 'alert_gone', 'type' => 'issue_open', 'status' => 'open', 'snoozed_until' => '2026-09-15 10:00:00', 'context' => ['key' => 'issue_open:issue_gone']]);
+ $woken = $controller->wake(fleetOpsRadarRequest('items/issue_open:issue_gone/wake', 'POST'), 'issue_open:issue_gone')->getData(true);
+ expect($woken['item'])->toBeNull()
+ ->and($woken['state']['snoozed_until'])->toBeNull();
+
+ $bulk = $controller->bulk(fleetOpsRadarRequest('items/bulk', 'POST', ['keys' => ['issue_open:issue_1', 'part_low_stock:part_pads'], 'action' => 'acknowledge']))->getData(true);
+ expect(array_column(array_column($bulk['results'], 'state'), 'status'))->toBe(['acknowledged', 'acknowledged']);
+});
+
+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']->getAttribute('acknowledged_by_uuid'))->toBe('user-1')
+ ->and($controller->store['issue_open:issue_1']->calls)->toBe([['save']]);
+
+ // Acknowledging twice keeps the first acknowledgement and writes nothing.
+ $controller->acknowledge(fleetOpsRadarRequest('items/issue_open:issue_1/acknowledge', 'POST'), 'issue_open:issue_1');
+ expect($controller->store['issue_open:issue_1']->calls)->toBe([['save']]);
+
+ // 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]->getAttribute('snoozed_by_uuid'))->toBe('user-1')
+ ->and($controller->store[$key]->meta['snooze_reason'])->toBe('Order placed');
+
+ $untilTomorrow = $controller->snooze(fleetOpsRadarRequest("items/{$key}/snooze", 'POST', ['until' => '2026-09-16 08:35:00']), $key)->getData(true);
+ expect($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]->getAttribute('assigned_to_uuid'))->toBeNull();
+
+ 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($resolved['state']['resolved_by_name'])->toBe('Ada Ops')
+ ->and($controller->store['notice:alert_yard']->getAttribute('resolved_by_uuid'))->toBe('user-1');
+});
+
+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..1994036b2
--- /dev/null
+++ b/server/tests/RadarRoutesTest.php
@@ -0,0 +1,35 @@
+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');")
+ ->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', '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..68785811f
--- /dev/null
+++ b/server/tests/Unit/Support/RadarAgendaTest.php
@@ -0,0 +1,200 @@
+ $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();
+});
+
+test('cover suggestions prefer online drivers, then the nearest, then the least busy', function () {
+ $now = fleetOpsAgendaNow();
+ $shiftEnd = Carbon::parse('2026-09-15 09:15:00', 'UTC');
+ $shift = fn (string $name, bool $online) => ['uuid' => 'sh-' . $name, 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 21:00:00', 'status' => 'in_progress', 'driver' => fleetOpsAgendaDriver($name), 'driver_online' => $online];
+
+ $offlineFirst = RadarAgenda::suggestCover('driver-leaving', null, $shiftEnd, [$shift('Offline Olga', false), $shift('Online Omar', true)], [], $now);
+ expect($offlineFirst['driver']['label'])->toBe('Online Omar');
+
+ // With no locations to compare, the driver carrying fewer orders wins.
+ $rows = [
+ 'driver-busybea' => ['uuid' => 'driver-busybea', 'active_orders' => 4],
+ 'driver-freefred' => ['uuid' => 'driver-freefred', 'active_orders' => 1],
+ ];
+ $leastBusy = RadarAgenda::suggestCover('driver-leaving', null, $shiftEnd, [$shift('Busy Bea', true), $shift('Free Fred', true)], $rows, $now);
+ expect($leastBusy['driver']['label'])->toBe('Free Fred');
+});
+
+test('a driver with several gaps shows the worst one on their shift bar', function () {
+ $now = fleetOpsAgendaNow();
+ $driver = fleetOpsAgendaDriver('Amara Diallo');
+ $window = ['start_at' => '2026-09-15T08:00:00+00:00', 'end_at' => '2026-09-15T09:30:00+00:00'];
+ $items = [
+ fleetOpsAgendaItem('shift_handover:driver_amaradiallo', ['category' => 'staffing', 'lane' => 'shifts', 'severity' => 'warning', 'subject' => $driver, 'window' => $window]),
+ fleetOpsAgendaItem('shift_late_start:driver_amaradiallo', ['category' => 'staffing', 'lane' => 'shifts', 'severity' => 'critical', 'subject' => $driver, 'window' => $window]),
+ fleetOpsAgendaItem('driver_without_vehicle:driver_amaradiallo', ['category' => 'staffing', 'lane' => 'anytime', 'severity' => 'info', 'subject' => $driver]),
+ ];
+ $shifts = [['uuid' => 'sh', 'public_id' => 'shift_a', 'start_at' => '2026-09-15 08:00:00', 'end_at' => '2026-09-15 09:30:00', 'status' => 'scheduled', 'driver' => $driver, 'driver_online' => false]];
+
+ $bar = collect(RadarAgenda::build($items, $shifts, '24h', $now)['lanes']['shifts'])->firstWhere('kind', 'shift');
+
+ expect($bar['severity'])->toBe('critical')
+ ->and($bar['handover_key'])->toBe('shift_handover:driver_amaradiallo')
+ ->and($bar['gap_keys'])->toHaveCount(3);
+});
diff --git a/server/tests/Unit/Support/RadarBriefingTest.php b/server/tests/Unit/Support/RadarBriefingTest.php
new file mode 100644
index 000000000..96266b656
--- /dev/null
+++ b/server/tests/Unit/Support/RadarBriefingTest.php
@@ -0,0 +1,214 @@
+ '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']);
+});
+
+test('the brief and decisions cope with thin mornings', function () {
+ $now = fleetOpsBriefNow();
+
+ expect(RadarBriefing::score([]))->toBe(100);
+
+ $overdue = [];
+ foreach (['A', 'B', 'C'] as $letter) {
+ $overdue[] = ['uuid' => 's' . $letter, 'public_id' => 'schedule_' . $letter, 'name' => 'Service', 'type' => 'oil_change', 'status' => 'active', 'next_due_date' => '2026-09-12 08:00:00', 'has_open_work_order' => true, 'subject' => fleetOpsBriefVehicle('TRK-' . $letter)];
+ }
+ $items = RadarRules::build([
+ 'schedules' => $overdue,
+ // A failed inspection that already has both follow-ups offers no decision.
+ 'inspectionSubmissions' => [['uuid' => 'x', 'public_id' => 'inspection_submission_x', 'status' => 'submitted', 'result' => 'failed', 'failed_items' => 1, 'issue_uuid' => null, 'work_order_uuid' => null, 'resolved_at' => null, 'form_name' => 'DVIR', 'highest_severity' => 'low', 'vehicle' => null, 'driver' => null]],
+ // Two drivers need a vehicle but only one is idle.
+ 'shifts' => [
+ ['uuid' => 'sh1', 'public_id' => 'shift_1', 'start_at' => '2026-09-15 06:00:00', 'end_at' => '2026-09-15 14:00:00', 'status' => 'in_progress', 'driver' => fleetOpsBriefDriver('One Driver'), 'driver_online' => true, 'driver_vehicle_uuid' => null, '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('Two Driver'), 'driver_online' => true, 'driver_vehicle_uuid' => null, 'active_orders' => 0],
+ ],
+ 'vehicles' => [['uuid' => 'vehicle-idle', 'public_id' => 'vehicle_idle', 'label' => 'IDLE-1', 'status' => 'available', 'driver_uuid' => null, 'device_count' => 1]],
+ ], $now)['items'];
+
+ $items = array_map(fn ($item) => $item['rule'] === 'inspection_failed' ? array_merge($item, ['actions' => ['acknowledge']]) : $item, $items);
+
+ $brief = RadarBriefing::build($items, $now);
+ $sentences = array_map(fn ($sentence) => implode('', array_column($sentence, 'text')), $brief['brief']);
+
+ expect($sentences[0])->toBe('3 jobs are past due: TRK-A (3d overdue) and TRK-B (3d overdue) and 1 more. 1 failed inspection has no follow-up yet: a vehicle 1 failed · no issue · no work order.')
+ ->and($sentences)->toHaveCount(3, 'no housekeeping sentence when nothing expires, fuel is matched and stock is fine')
+ ->and(array_column($brief['decisions'], 'key'))->toBe(['assign_vehicle:driver_onedriver']);
+});
diff --git a/server/tests/Unit/Support/RadarItemStateTest.php b/server/tests/Unit/Support/RadarItemStateTest.php
new file mode 100644
index 000000000..564118978
--- /dev/null
+++ b/server/tests/Unit/Support/RadarItemStateTest.php
@@ -0,0 +1,260 @@
+ $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.
+ RadarItemState::resolve($row, null, 'done');
+ $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());
+ $ada = User::query()->where('uuid', 'user-ada')->first();
+ $bo = User::query()->where('uuid', 'user-bo')->first();
+ RadarItemState::acknowledge($issue, $ada);
+ RadarItemState::assign($issue, $bo);
+ RadarItemState::snooze($issue, now()->addHour(), 'Waiting on parts', $bo);
+ RadarItemState::plan($issue, now()->addHours(3));
+
+ // A second acknowledgement keeps the first one.
+ RadarItemState::acknowledge($issue, $bo);
+ expect(RadarItemState::refresh($issue)->getAttribute('acknowledged_by_uuid'))->toBe('user-ada')
+ ->and(RadarItemState::refresh($issue)->meta['snooze_reason'])->toBe('Waiting on parts')
+ ->and(RadarItemState::refresh(new Alert())->exists)->toBeFalse();
+
+ $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']]);
+
+ // A row written by something other than Radar carries no item key and is left alone.
+ Alert::create(['company_uuid' => 'company-radar', 'type' => 'issue_open', 'severity' => 'info', 'status' => 'open', 'message' => 'Raised elsewhere']);
+ expect(RadarItemState::statesFor('company-radar'))->not->toHaveKey('')
+ ->and(RadarItemState::findRow('company-radar', 'issue_open:elsewhere'))->toBeNull();
+
+ 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']));
+
+ RadarItemState::snooze($later, now()->addDays(3), null, null);
+ RadarItemState::snooze($soon, now()->addHours(2), null, null);
+ RadarItemState::snooze($far, now()->addDays(20), null, null);
+ RadarItemState::snooze($past, now()->subHour(), null, null);
+
+ // Woken early, a row leaves the schedule.
+ $woken = RadarItemState::rowFor('company-radar', fleetOpsRadarStateItem('issue_open:issue_5', ['title' => 'Woken']));
+ RadarItemState::snooze($woken, now()->addHours(1), null, null);
+ RadarItemState::wake($woken);
+
+ $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..45ce21f98
--- /dev/null
+++ b/server/tests/Unit/Support/RadarRulesTest.php
@@ -0,0 +1,523 @@
+ '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(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)
+ ->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');
+});
+
+test('rules handle the less common shapes their sources arrive in', function () {
+ $now = fleetOpsRadarNow();
+
+ // An issue an inspection raised, known only by uuid.
+ $issue = RadarRules::issueItems([
+ ['uuid' => 'i9', 'public_id' => 'issue_9', 'title' => 'Wipers', 'priority' => '', 'status' => 'pending', 'vehicle' => null, 'driver' => null, 'meta' => ['inspection_submission_uuid' => 'sub-1']],
+ ], $now);
+ expect($issue[0]['meta_line'])->toBe('raised by an inspection');
+
+ // A fuel transaction matched by plate, bought minutes and hours ago, and one with no amount.
+ $fuel = RadarRules::fuelItems([
+ ['uuid' => 'f1', 'public_id' => 'fuel_1', 'amount' => '10.5', 'currency' => null, 'station_name' => 'Shell', 'plate_number' => 'ABC-1', 'transaction_at' => '2026-09-15 08:20:00', 'sync_status' => 'unmatched'],
+ ['uuid' => 'f2', 'public_id' => 'fuel_2', 'amount' => null, 'station_name' => null, 'provider' => 'wex', 'transaction_at' => '2026-09-15 05:35:00', 'sync_status' => 'unmatched'],
+ ], $now);
+ expect($fuel[0]['title'])->toBe('10.50 at Shell — no vehicle matched')
+ ->and($fuel[0]['meta_line'])->toBe('plate ABC-1 · 15m ago')
+ ->and($fuel[1]['title'])->toBe('Fuel purchase at wex — no vehicle matched')
+ ->and($fuel[1]['meta_line'])->toBe('3h ago');
+
+ // A lease already over, and a schedule with no public id has no record to open.
+ $vehicles = RadarRules::vehicleItems([
+ ['uuid' => 'v1', 'public_id' => 'vehicle_1', 'label' => 'VAN-1', 'status' => 'retired', 'driver_uuid' => 'd', 'device_count' => 1, 'lease_expires_at' => '2026-09-01'],
+ ], [], $now);
+ expect($vehicles[0]['title'])->toBe('VAN-1 lease ended 1 Sep')
+ ->and($vehicles[0]['severity'])->toBe('critical')
+ ->and(RadarRules::record('maintenance.schedules.index.details', null))->toBeNull();
+
+ // A part with nothing to search checklists for, and a checklist stored as JSON.
+ $parts = RadarRules::partItems([
+ ['uuid' => 'p1', 'public_id' => 'part_1', 'name' => null, 'sku' => null, 'quantity_on_hand' => 0],
+ ['uuid' => 'p2', 'public_id' => 'part_2', 'name' => 'Filter', 'sku' => 'F-1', 'quantity_on_hand' => 0],
+ ], [
+ ['code' => 'WO-7', 'status' => 'open', 'subject' => 'service', 'checklist' => json_encode([['title' => 'Replace filter']])],
+ ], $now);
+ expect($parts[0]['meta_line'])->toBe('reorder point 5')
+ ->and($parts[1]['meta_line'])->toBe('reorder point 5 · blocks WO-7')
+ ->and($parts[1]['severity'])->toBe('critical');
+
+ // The resolved tab keeps only resolved items.
+ $resolved = RadarRules::forTab([
+ ['key' => 'a', 'state' => ['status' => 'resolved']],
+ ['key' => 'b', 'state' => ['status' => 'open']],
+ ], 'resolved', $now);
+ expect(array_column($resolved, 'key'))->toBe(['a']);
+});
diff --git a/tests/dummy/config/ember-intl.js b/tests/dummy/config/ember-intl.js
new file mode 100644
index 000000000..86bbdbc33
--- /dev/null
+++ b/tests/dummy/config/ember-intl.js
@@ -0,0 +1,13 @@
+'use strict';
+
+/**
+ * The dummy app only needs English. Loading every locale here also asks the
+ * browser for Intl data it may not ship (Chrome 152 has none for `mn`), and
+ * ember-intl throws while hydrating, which takes every rendering test down.
+ * The engine still ships all translations to the host console.
+ */
+module.exports = function () {
+ return {
+ includeLocales: ['en-us'],
+ };
+};
diff --git a/tests/integration/components/layout/fleet-ops-sidebar-test.js b/tests/integration/components/layout/fleet-ops-sidebar-test.js
index 1c68089cb..9d42ca9ef 100644
--- a/tests/integration/components/layout/fleet-ops-sidebar-test.js
+++ b/tests/integration/components/layout/fleet-ops-sidebar-test.js
@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
import { setupRenderingTest } from 'dummy/tests/helpers';
import { click, fillIn, render, settled, waitFor } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
import Service from '@ember/service';
import window from 'ember-window-mock';
import { getOwner } from '@ember/application';
@@ -62,6 +63,7 @@ class AbilitiesStub extends Service {
module('Integration | Component | layout/fleet-ops-sidebar', function (hooks) {
setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
hooks.beforeEach(function () {
this.owner.register('service:router', RouterStubService);
@@ -290,24 +292,27 @@ module('Integration | Component | layout/fleet-ops-sidebar', function (hooks) {
assert.dom('.next-sidebar-navigator').doesNotIncludeText('Parts');
});
- test('it hides Resources when the Resources Hub is the only visible child', async function (assert) {
+ test('it hides Resources when Radar is the only visible child', async function (assert) {
const abilities = this.owner.lookup('service:abilities');
+ // Every real child of Resources, so only the hub item would remain.
abilities.denied.add('fleet-ops see driver');
abilities.denied.add('fleet-ops see vehicle');
+ abilities.denied.add('fleet-ops see trailer');
abilities.denied.add('fleet-ops see fleet');
abilities.denied.add('fleet-ops see vendor');
abilities.denied.add('fleet-ops see contact');
abilities.denied.add('fleet-ops see place');
abilities.denied.add('fleet-ops see fuel-report');
+ abilities.denied.add('fleet-ops see fuel-report');
abilities.denied.add('fleet-ops see issue');
await render(hbs` `);
assert.dom('.next-sidebar-navigator-view-in').doesNotIncludeText('Resources');
- await fillIn('.next-sidebar-navigator-search input', 'resources hub');
+ await fillIn('.next-sidebar-navigator-search input', 'radar');
- assert.dom('.next-sidebar-navigator-search-result').doesNotExist('Resources Hub is not searchable when Resources has no real visible children');
+ assert.dom('.next-sidebar-navigator-search-result').doesNotExist('Radar is not searchable when Resources has no real visible children');
});
test('it hides Connectivity when Telematics is the only visible child', async function (assert) {
@@ -375,8 +380,8 @@ module('Integration | Component | layout/fleet-ops-sidebar', function (hooks) {
await render(hbs` `);
await click('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)');
- assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type').includesText('Resources Hub');
- assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type svg[data-icon="layer-group"]').exists();
+ assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type').includesText('Radar');
+ assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type svg[data-icon="crosshairs"]').exists();
await click('.next-sidebar-navigator-back');
await click('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(3)');
@@ -463,7 +468,7 @@ module('Integration | Component | layout/fleet-ops-sidebar', function (hooks) {
const labels = [...this.element.querySelectorAll('.next-sidebar-navigator-view-in .next-sidebar-navigator-item-label')].map((element) => element.textContent.trim());
- assert.deepEqual(labels.slice(0, 5), ['Resources Hub', 'Contracts', 'Drivers', 'Permits', 'Vehicles'], 'hub items stay first while registered section items sort by priority');
+ assert.deepEqual(labels.slice(0, 5), ['Radar', 'Contracts', 'Drivers', 'Permits', 'Vehicles'], 'hub items stay first while registered section items sort by priority');
await click('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)');
diff --git a/tests/integration/components/radar/agenda-test.js b/tests/integration/components/radar/agenda-test.js
new file mode 100644
index 000000000..aec18d762
--- /dev/null
+++ b/tests/integration/components/radar/agenda-test.js
@@ -0,0 +1,204 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, click, triggerEvent } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+
+function agenda() {
+ const driver = { type: 'driver', uuid: 'driver-ortega', public_id: 'driver_ortega', label: 'Luis Ortega' };
+
+ return {
+ now: '2026-09-15T08:35:00',
+ window: {
+ key: '24h',
+ hours: 24,
+ start_at: '2026-09-15T08:00:00Z',
+ end_at: '2026-09-16T08:00:00Z',
+ now_pct: 2.43,
+ ticks: [
+ { at: '2026-09-15T08:00:00Z', label: '08', pct: 0 },
+ { at: '2026-09-15T20:00:00Z', label: '20', pct: 50 },
+ ],
+ },
+ lanes: {
+ shifts: [
+ {
+ kind: 'shift',
+ key: 'shift:shift_1',
+ driver,
+ status: 'in_progress',
+ state: 'on_shift',
+ at: '2026-09-15T01:15:00Z',
+ end_at: '2026-09-15T09:15:00Z',
+ label: '01:15–09:15',
+ pct: 0,
+ width_pct: 5.2,
+ lane: 'shifts',
+ severity: 'warning',
+ gap_keys: ['shift_handover:driver_ortega'],
+ handover_key: 'shift_handover:driver_ortega',
+ active_orders: 2,
+ },
+ {
+ kind: 'item',
+ key: 'shift_handover:driver_ortega',
+ rule: 'shift_handover',
+ chip: 'Handover',
+ category: 'staffing',
+ severity: 'warning',
+ title: 'Luis Ortega shift ends in 40m',
+ subject: driver,
+ lane: 'shifts',
+ at: '2026-09-15T09:15:00Z',
+ label: '09:15',
+ planned: false,
+ pct: 5.2,
+ state: { status: 'open' },
+ actions: ['handover'],
+ record: null,
+ },
+ ],
+ maintenance: [
+ {
+ kind: 'item',
+ key: 'maintenance_due_soon:s',
+ rule: 'maintenance_due_soon',
+ chip: 'Maint',
+ category: 'maintenance',
+ severity: 'warning',
+ title: 'Tire rotation due today',
+ subject: { type: 'vehicle', label: 'TRK-204' },
+ lane: 'maintenance',
+ at: '2026-09-15T14:00:00Z',
+ label: '14:00',
+ planned: false,
+ pct: 25,
+ state: { status: 'open' },
+ actions: [],
+ record: null,
+ },
+ ],
+ expiries: [],
+ notices: [],
+ },
+ overdue: [
+ {
+ kind: 'item',
+ key: 'maintenance_overdue:o',
+ rule: 'maintenance_overdue',
+ chip: 'Maint',
+ category: 'maintenance',
+ severity: 'critical',
+ title: 'Oil change 3d overdue',
+ subject: { type: 'vehicle', label: 'TRK-118' },
+ lane: 'maintenance',
+ at: '2026-09-12T08:00:00Z',
+ label: '08:00',
+ planned: false,
+ pct: 0,
+ state: { status: 'open' },
+ actions: [],
+ record: null,
+ },
+ ],
+ later: [],
+ anytime: [
+ {
+ kind: 'item',
+ key: 'issue_open:i',
+ rule: 'issue_open',
+ chip: 'Issue',
+ category: 'issues',
+ severity: 'warning',
+ title: 'Check engine light',
+ subject: { type: 'vehicle', label: 'TRK-311' },
+ lane: 'maintenance',
+ at: null,
+ label: null,
+ planned: false,
+ pct: null,
+ state: { status: 'open' },
+ actions: [],
+ record: null,
+ },
+ ],
+ handovers: [],
+ counts: { overdue: 1, later: 0, anytime: 1, shifts: 1 },
+ };
+}
+
+module('Integration | Component | radar/agenda', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ hooks.beforeEach(function () {
+ this.calls = [];
+ this.agenda = agenda();
+ this.window = '24h';
+ this.onChangeWindow = (window) => this.calls.push(['window', window]);
+ this.onOpen = (entry) => this.calls.push(['open', entry.key]);
+ this.onPlan = (key, at, lane) => this.calls.push(['plan', key, at, lane]);
+ this.onHandover = (key) => this.calls.push(['handover', key]);
+ this.onSeeAll = () => this.calls.push(['see-all']);
+ });
+
+ test('it draws the overdue band, the four lanes with shifts and entries, and the tray', async function (assert) {
+ await render(
+ hbs` `
+ );
+
+ assert.dom('[data-test-radar-agenda-window="24h"]').hasClass('is-active');
+ assert.dom('[data-test-radar-agenda-summary]').hasText('1 shifts in window · 1 overdue · 1 undated');
+ assert.dom('[data-test-radar-agenda-overdue] [data-test-radar-agenda-entry="maintenance_overdue:o"]').exists();
+ assert.dom('[data-test-radar-agenda-lane]').exists({ count: 4 });
+ assert.dom('[data-test-radar-agenda-lane="shifts"] [data-test-radar-agenda-shift="shift:shift_1"]').hasClass('has-handover');
+ assert.dom('[data-test-radar-agenda-lane="shifts"] [data-test-radar-agenda-shift="shift:shift_1"]').includesText('Luis Ortega');
+ assert.dom('[data-test-radar-agenda-lane="maintenance"] [data-test-radar-agenda-entry="maintenance_due_soon:s"]').hasAttribute('style', /left: 25%/);
+ assert.dom('[data-test-radar-agenda-lane="expiries"] .fleet-ops-radar-agenda-empty-lane').hasText('No events in this window.');
+ assert.dom('[data-test-radar-agenda-tray] [data-test-radar-agenda-entry="issue_open:i"]').hasAttribute('draggable', 'true');
+ assert.dom('.fleet-ops-radar-agenda-now em').hasText('08:35');
+
+ await click('[data-test-radar-agenda-window="7d"]');
+ await click('[data-test-radar-agenda-shift="shift:shift_1"]');
+ await click('[data-test-radar-agenda-lane="maintenance"] [data-test-radar-agenda-entry="maintenance_due_soon:s"]');
+ await click('[data-test-radar-agenda-lane="shifts"] [data-test-radar-agenda-entry="shift_handover:driver_ortega"]');
+ await click('[data-test-radar-agenda-see-all]');
+
+ assert.deepEqual(this.calls, [
+ ['window', '7d'],
+ ['handover', 'shift_handover:driver_ortega'],
+ ['open', 'maintenance_due_soon:s'],
+ ['handover', 'shift_handover:driver_ortega'],
+ ['see-all'],
+ ]);
+ });
+
+ test('dropping a tray item on a lane plans it at the time under the cursor', async function (assert) {
+ await render(
+ hbs` `
+ );
+
+ const track = this.element.querySelector('[data-test-radar-agenda-lane="notices"] [data-test-radar-agenda-track]');
+ const rect = track.getBoundingClientRect();
+ const store = {};
+ const dataTransfer = {
+ types: ['text/radar-key'],
+ setData: (type, value) => (store[type] = value),
+ getData: (type) => store[type],
+ effectAllowed: 'move',
+ };
+
+ await triggerEvent('[data-test-radar-agenda-tray] [data-test-radar-agenda-entry="issue_open:i"]', 'dragstart', { dataTransfer });
+ await triggerEvent(track, 'dragover', { dataTransfer });
+ assert.dom('[data-test-radar-agenda-lane="notices"]').hasClass('is-drop-target');
+
+ await triggerEvent(track, 'drop', { dataTransfer, clientX: rect.left + rect.width / 2, clientY: rect.top + 4 });
+ assert.dom('[data-test-radar-agenda-lane="notices"]').doesNotHaveClass('is-drop-target');
+
+ const [name, key, at, lane] = this.calls.at(-1);
+ assert.strictEqual(name, 'plan');
+ assert.strictEqual(key, 'issue_open:i');
+ assert.strictEqual(lane, 'notices');
+ assert.strictEqual(new Date(at).toISOString(), '2026-09-15T20:00:00.000Z', 'halfway across a 24h window is 12 hours in');
+ });
+});
diff --git a/tests/integration/components/radar/briefing-test.js b/tests/integration/components/radar/briefing-test.js
new file mode 100644
index 000000000..a61f92f2a
--- /dev/null
+++ b/tests/integration/components/radar/briefing-test.js
@@ -0,0 +1,129 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, click } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+
+function briefing(extra = {}) {
+ return {
+ score: { value: 78, delta: -4, summary: '3 overdue · 12 due this week' },
+ categories: [
+ {
+ key: 'maintenance',
+ label: 'Maintenance',
+ score: 70,
+ count: 3,
+ critical: 1,
+ gaps: [{ rule: 'maintenance_overdue', count: 3, label: '3 overdue' }],
+ filter: { category: 'maintenance' },
+ },
+ { key: 'issues', label: 'Issues', score: 100, count: 0, critical: 0, gaps: [], filter: { category: 'issues' } },
+ ],
+ brief: [
+ [{ text: '2 jobs are past due: ' }, { text: 'TRK-118', route: 'maintenance.schedules.index.details', model: 'schedule_oil' }, { text: ' (3d overdue).' }],
+ [{ text: 'Everything else is routine.' }],
+ ],
+ decisions: [
+ {
+ key: 'open_work_order:schedule_oil',
+ severity: 'critical',
+ category: 'maintenance',
+ title: 'Open a work order for TRK-118',
+ subtitle: 'Oil change 3d overdue',
+ reasoning: [{ text: 'TRK-118', route: 'maintenance.schedules.index.details', model: 'schedule_oil' }, { text: ' is 3d overdue.' }],
+ confirm: { label: 'Open work order', action: 'create_work_order', method: 'POST', endpoint: 'maintenance-schedules/schedule_oil/trigger', body: {} },
+ alternatives: [{ label: 'Issue only', action: 'create_issue_from_inspection', method: 'POST', endpoint: 'x', body: {} }],
+ keys: ['maintenance_overdue:schedule_oil'],
+ subject: null,
+ record: { route: 'maintenance.schedules.index.details', model: 'schedule_oil' },
+ },
+ ],
+ yesterday: { closed: 11, rolled_over: 2 },
+ generated_at: '2026-09-15T08:30:00Z',
+ ...extra,
+ };
+}
+
+module('Integration | Component | radar/briefing', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ hooks.beforeEach(function () {
+ this.calls = [];
+ this.briefing = briefing();
+ this.strips = [];
+ this.collapsed = false;
+ this.onToggle = () => this.calls.push(['toggle']);
+ this.onFilterCategory = (category) => this.calls.push(['filter', category]);
+ this.onConfirm = (decision, call) => this.calls.push(['confirm', decision.key, call.endpoint]);
+ this.onAlternative = (decision, alternative) => this.calls.push(['alternative', decision.key, alternative.action]);
+ this.onDismiss = (decision) => this.calls.push(['dismiss', decision.key]);
+ this.onUndo = (strip) => this.calls.push(['undo', strip.key]);
+ this.onWake = (strip) => this.calls.push(['wake', strip.key]);
+ this.onOpenRecord = (record) => this.calls.push(['open', record.model]);
+ });
+
+ test('it renders the score, category rows, prose with links and the decision stack', async function (assert) {
+ await render(
+ hbs` `
+ );
+
+ assert.dom('[data-test-radar-briefing-score] .fleet-ops-radar-briefing-score-number').hasText('78');
+ assert.dom('[data-test-radar-briefing-score] .status-badge').hasClass('error-status-badge');
+ assert.dom('[data-test-radar-briefing-score]').includesText('▼ 4 vs yesterday');
+ assert.dom('[data-test-radar-briefing-yesterday]').hasText('Yesterday: 11 gaps closed, 2 rolled over');
+ assert.dom('[data-test-radar-briefing-category="maintenance"] .fleet-ops-radar-briefing-category-score').hasText('70');
+ assert.dom('[data-test-radar-briefing-category="maintenance"] .fleet-ops-radar-briefing-category-bar > span').hasClass('is-warn');
+ assert.dom('[data-test-radar-briefing-category="maintenance"]').includesText('3 overdue');
+ assert.dom('[data-test-radar-briefing-filter="issues"]').doesNotExist('a category with nothing open has nothing to filter');
+ assert.dom('[data-test-radar-briefing-prose]').includesText('2 jobs are past due: TRK-118 (3d overdue). Everything else is routine.');
+ assert.dom('[data-test-radar-briefing-prose] .fleet-ops-radar-briefing-link').hasText('TRK-118');
+ assert.dom('[data-test-radar-decision="open_work_order:schedule_oil"]').exists();
+ assert.dom('[data-test-radar-briefing-decisions]').includesText('1 of 1');
+
+ await click('[data-test-radar-briefing-filter="maintenance"]');
+ await click('[data-test-radar-briefing-prose] .fleet-ops-radar-briefing-link');
+ await click('[data-test-radar-decision-confirm]');
+ await click('[data-test-radar-decision-alternative="create_issue_from_inspection"]');
+ await click('[data-test-radar-decision-dismiss]');
+ await click('[data-test-radar-decision-open]');
+
+ assert.deepEqual(this.calls, [
+ ['filter', 'maintenance'],
+ ['open', 'schedule_oil'],
+ ['confirm', 'open_work_order:schedule_oil', 'maintenance-schedules/schedule_oil/trigger'],
+ ['alternative', 'open_work_order:schedule_oil', 'create_issue_from_inspection'],
+ ['dismiss', 'open_work_order:schedule_oil'],
+ ['open', 'schedule_oil'],
+ ]);
+ });
+
+ test('strips show what was confirmed or snoozed, and the brief collapses', async function (assert) {
+ this.briefing = briefing({ decisions: [], score: { value: 100, delta: null, summary: 'nothing open' } });
+ this.strips = [
+ { kind: 'confirmed', key: 'a', title: 'Booked TRK-118 oil change', at: '2026-09-15T08:33:00Z', undo: { endpoint: 'x' } },
+ { kind: 'snoozed', key: 'b', title: 'Warranty reminder', until: '2026-09-18T08:00:00Z', keys: ['k'] },
+ ];
+
+ await render(
+ hbs` `
+ );
+
+ assert.dom('[data-test-radar-briefing-nothing]').exists();
+ assert.dom('[data-test-radar-briefing-score]').includesText('first reading');
+ assert.dom('[data-test-radar-briefing-strip="confirmed"]').includesText('Booked TRK-118 oil change');
+ assert.dom('[data-test-radar-briefing-strip="snoozed"]').includesText('Warranty reminder');
+
+ await click('[data-test-radar-briefing-undo]');
+ await click('[data-test-radar-briefing-wake]');
+ assert.deepEqual(this.calls, [
+ ['undo', 'a'],
+ ['wake', 'b'],
+ ]);
+
+ this.set('collapsed', true);
+ assert.dom('[data-test-radar-briefing-score]').doesNotExist('collapsed hides the body');
+ await click('[data-test-radar-briefing-toggle]');
+ assert.deepEqual(this.calls.at(-1), ['toggle']);
+ });
+});
diff --git a/tests/integration/components/radar/bulk-bar-test.js b/tests/integration/components/radar/bulk-bar-test.js
new file mode 100644
index 000000000..3ad935fe3
--- /dev/null
+++ b/tests/integration/components/radar/bulk-bar-test.js
@@ -0,0 +1,42 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, click } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+
+module('Integration | Component | radar/bulk-bar', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ test('it summarises the selection by chip and fires the bulk actions', async function (assert) {
+ this.acts = [];
+ this.cleared = 0;
+ this.items = [
+ { key: 'a', rule: 'maintenance_overdue', state: { status: 'open' } },
+ { key: 'b', rule: 'maintenance_due_soon', state: { status: 'open' } },
+ { key: 'c', rule: 'work_order_overdue', state: { status: 'snoozed' } },
+ ];
+ this.presets = ['1h', '4h'];
+ this.onAct = (action, payload) => this.acts.push([action, payload]);
+ this.onClear = () => this.cleared++;
+
+ await render(hbs` `);
+
+ assert.dom('[data-test-radar-bulk-count]').hasText('3 selected');
+ assert.dom('.fleet-ops-radar-bulk-copy .fleet-ops-radar-row-meta').hasText('2 maint · 1 work order');
+ assert.dom('[data-test-radar-bulk-action="wake"]').exists('a snoozed item in the selection offers wake');
+
+ await click('[data-test-radar-bulk-action="acknowledge"]');
+ await click('[data-test-radar-bulk-action="assign"]');
+ assert.deepEqual(
+ this.acts.map(([action]) => action),
+ ['acknowledge', 'assign']
+ );
+
+ await click('[data-test-radar-bulk-clear]');
+ assert.strictEqual(this.cleared, 1);
+
+ this.set('items', [{ key: 'a', rule: 'issue_open', state: { status: 'open' } }]);
+ assert.dom('[data-test-radar-bulk-action="wake"]').doesNotExist();
+ });
+});
diff --git a/tests/integration/components/radar/empty-state-test.js b/tests/integration/components/radar/empty-state-test.js
new file mode 100644
index 000000000..4ab20c92e
--- /dev/null
+++ b/tests/integration/components/radar/empty-state-test.js
@@ -0,0 +1,41 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+
+module('Integration | Component | radar/empty-state', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ test('an all-clear open tab shows the snooze schedule', async function (assert) {
+ this.schedule = [
+ { key: 'a', title: 'Tire rotation', snoozed_until: '2026-09-18T08:00:00Z' },
+ { key: 'b', title: 'Warranty', snoozed_until: '2026-09-19T08:00:00Z' },
+ ];
+
+ await render(hbs` `);
+
+ assert.dom('[data-test-radar-empty="open"]').exists();
+ assert.dom('.fleet-ops-radar-empty-title').hasText('Nothing needs a decision.');
+ assert.dom('.fleet-ops-radar-empty-hint').includesText('2 snoozed items wake this week.');
+ assert.dom('[data-test-radar-snooze-schedule] > div').exists({ count: 2 });
+ assert.dom('[data-test-radar-snooze-schedule]').includesText('Tire rotation');
+ });
+
+ test('a filtered list, the snoozed tab and the resolved tab each get their own copy', async function (assert) {
+ this.cleared = 0;
+ this.onClear = () => this.cleared++;
+
+ await render(hbs` `);
+ assert.dom('[data-test-radar-empty="filtered"]').exists();
+ assert.dom('.fleet-ops-radar-empty-title').hasText('Nothing matches these filters.');
+ assert.dom('[data-test-radar-snooze-schedule]').doesNotExist();
+
+ await render(hbs` `);
+ assert.dom('.fleet-ops-radar-empty-title').hasText('Nothing is snoozed.');
+
+ await render(hbs` `);
+ assert.dom('.fleet-ops-radar-empty-title').hasText('Nothing resolved in the last 7 days.');
+ });
+});
diff --git a/tests/integration/components/radar/filter-pills-test.js b/tests/integration/components/radar/filter-pills-test.js
new file mode 100644
index 000000000..dee8d0891
--- /dev/null
+++ b/tests/integration/components/radar/filter-pills-test.js
@@ -0,0 +1,46 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, click } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+
+module('Integration | Component | radar/filter-pills', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ test('it renders counts, marks active pills and reports toggles and clears', async function (assert) {
+ this.toggled = [];
+ this.cleared = 0;
+ this.pills = [
+ { key: 'overdue', label: 'Overdue', count: 3, active: true, dot: true },
+ { key: 'due_week', label: 'Due this week', count: 12, active: false },
+ { key: 'fuel', label: 'Unmatched fuel', count: 0, active: false },
+ ];
+ this.onToggle = (key) => this.toggled.push(key);
+ this.onClear = () => this.cleared++;
+
+ await render(hbs` `);
+
+ assert.dom('[data-test-radar-pill="overdue"]').hasClass('is-active');
+ assert.dom('[data-test-radar-pill="overdue"]').hasAttribute('aria-pressed', 'true');
+ assert.dom('[data-test-radar-pill="overdue"] .fleet-ops-radar-pill-dot').exists();
+ assert.dom('[data-test-radar-pill="overdue"] .fleet-ops-radar-pill-count').hasText('3');
+ assert.dom('[data-test-radar-pill="due_week"]').doesNotHaveClass('is-active');
+ assert.dom('[data-test-radar-pill="fuel"]').hasClass('is-empty');
+ assert.dom('[data-test-radar-pills-clear]').exists();
+
+ await click('[data-test-radar-pill="due_week"]');
+ assert.deepEqual(this.toggled, ['due_week']);
+
+ await click('[data-test-radar-pills-clear]');
+ assert.strictEqual(this.cleared, 1);
+
+ this.set(
+ 'pills',
+ this.pills.map((pill) => ({ ...pill, active: false }))
+ );
+ this.set('isFiltered', false);
+ await render(hbs` `);
+ assert.dom('[data-test-radar-pills-clear]').doesNotExist();
+ });
+});
diff --git a/tests/integration/components/radar/handover-card-test.js b/tests/integration/components/radar/handover-card-test.js
new file mode 100644
index 000000000..a61711e26
--- /dev/null
+++ b/tests/integration/components/radar/handover-card-test.js
@@ -0,0 +1,55 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, click } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+
+module('Integration | Component | radar/handover-card', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ test('it lists the orders, the suggested cover and the three actions', async function (assert) {
+ this.calls = [];
+ this.handover = {
+ key: 'shift_handover:driver_ortega',
+ rule: 'shift_handover',
+ driver: { label: 'Luis Ortega' },
+ shift: { uuid: 'sh1', start_at: '2026-09-15T01:15:00Z', end_at: '2026-09-15T09:15:00Z', minutes_left: 40 },
+ orders: [
+ { uuid: 'o1', public_id: 'order_88412', destination: 'Bay Ridge', ends_at: '2026-09-15T09:40:00Z', finishes_after_shift: true },
+ { uuid: 'o2', public_id: 'order_88431', destination: 'Sunset Park', ends_at: '2026-09-15T08:50:00Z', finishes_after_shift: false },
+ ],
+ suggested: { driver: { uuid: 'driver-alves', label: 'Tomas Alves' }, on_shift_until: '2026-09-15T21:00:00Z', distance_km: 1.2, capacity_label: '2 of 6 orders' },
+ record: { route: 'management.drivers.index.details', model: 'driver_ortega' },
+ };
+ this.onClose = () => this.calls.push('close');
+ this.onReassign = (handover) => this.calls.push(['reassign', handover.suggested.driver.uuid]);
+ this.onExtend = (handover) => this.calls.push(['extend', handover.shift.uuid]);
+ this.onSnooze = (handover) => this.calls.push(['snooze', handover.key]);
+ this.onOpenRecord = (record) => this.calls.push(['open', record.model]);
+
+ await render(
+ hbs` `
+ );
+
+ assert.dom('[data-test-radar-handover]').includesText('Luis Ortega');
+ assert.dom('[data-test-radar-handover]').includesText('in 40m');
+ assert.dom('[data-test-radar-handover-order="order_88412"]').hasClass('is-late');
+ assert.dom('[data-test-radar-handover-order="order_88431"]').doesNotHaveClass('is-late');
+ assert.dom('[data-test-radar-handover-suggested]').includesText('Tomas Alves');
+ assert.dom('[data-test-radar-handover-suggested]').includesText('1.2 km away');
+ assert.dom('[data-test-radar-handover-suggested]').includesText('2 of 6 orders');
+ assert.dom('[data-test-radar-handover-reassign]').includesText('Reassign 2 orders to Tomas Alves');
+
+ await click('[data-test-radar-handover-reassign]');
+ await click('[data-test-radar-handover-extend]');
+ await click('[data-test-radar-handover-snooze]');
+ await click('[data-test-radar-handover-close]');
+
+ assert.deepEqual(this.calls, [['reassign', 'driver-alves'], ['extend', 'sh1'], ['snooze', 'shift_handover:driver_ortega'], 'close']);
+
+ this.set('handover', { ...this.handover, suggested: null });
+ assert.dom('[data-test-radar-handover-reassign]').doesNotExist();
+ assert.dom('[data-test-radar-handover-suggested]').includesText('Nobody on shift long enough');
+ });
+});
diff --git a/tests/integration/components/radar/item-row-test.js b/tests/integration/components/radar/item-row-test.js
new file mode 100644
index 000000000..680763329
--- /dev/null
+++ b/tests/integration/components/radar/item-row-test.js
@@ -0,0 +1,115 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, click } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+
+function radarItem(extra = {}) {
+ return {
+ key: 'inspection_failed:inspection_submission_a',
+ rule: 'inspection_failed',
+ category: 'inspections',
+ severity: 'critical',
+ title: 'Pre-trip DVIR failed 2 critical items',
+ subject: { type: 'vehicle', public_id: 'vehicle_a', label: 'TRK-207 Isuzu NPR', photo_url: null },
+ meta_line: '2 failed · no issue · no work order',
+ due_at: null,
+ due_bucket: 'none',
+ due_label: null,
+ record: { route: 'maintenance.inspection-submissions.index.details', model: 'inspection_submission_a' },
+ actions: ['create_work_order_from_inspection', 'create_issue_from_inspection', 'acknowledge', 'snooze', 'assign', 'open_record'],
+ state: { status: 'open', assigned_to: null },
+ ...extra,
+ };
+}
+
+module('Integration | Component | radar/item-row', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ hooks.beforeEach(function () {
+ this.acts = [];
+ this.opened = [];
+ this.selected = [];
+ this.records = [];
+ this.onAct = (item, action, payload) => this.acts.push([item.key, action, payload]);
+ this.onOpen = (item) => this.opened.push(item.key);
+ this.onSelect = (item) => this.selected.push(item.key);
+ this.onFocus = () => {};
+ this.onOpenRecord = (item) => this.records.push(item.key);
+ this.presets = ['1h', '4h', 'tomorrow', 'next-week'];
+ });
+
+ test('it renders the chip, severity, title, subject, meta and the rail with the record action first', async function (assert) {
+ this.item = radarItem();
+
+ await render(
+ hbs` `
+ );
+
+ assert.dom('[data-test-radar-row]').hasAttribute('data-radar-key', 'inspection_failed:inspection_submission_a');
+ assert.dom('.fleet-ops-radar-row-chip .status-badge').hasClass('violet-status-badge');
+ assert.dom('.fleet-ops-radar-severity').hasClass('is-critical');
+ assert.dom('[data-test-radar-title]').hasText('Pre-trip DVIR failed 2 critical items');
+ assert.dom('[data-test-radar-subject="vehicle"]').includesText('TRK-207 Isuzu NPR');
+ assert.dom('.fleet-ops-radar-row-meta').hasText('2 failed · no issue · no work order');
+ assert.dom('[data-test-radar-due]').hasText('—');
+ assert.dom('[data-test-radar-assignee]').hasClass('is-empty');
+ assert.dom('[data-test-radar-action="create_work_order_from_inspection"]').exists('the record action is the primary rail button');
+ assert.dom('[data-test-radar-action="acknowledge"]').exists();
+ assert.dom('[data-test-radar-action="assign"]').exists();
+ assert.dom('[data-test-radar-open-record]').exists();
+
+ await click('[data-test-radar-action="create_work_order_from_inspection"]');
+ await click('[data-test-radar-action="acknowledge"]');
+ assert.deepEqual(
+ this.acts.map(([, action]) => action),
+ ['create_work_order_from_inspection', 'acknowledge']
+ );
+
+ await click('[data-test-radar-title]');
+ assert.deepEqual(this.opened, ['inspection_failed:inspection_submission_a']);
+
+ await click('[data-test-radar-select] input');
+ assert.deepEqual(this.selected, ['inspection_failed:inspection_submission_a']);
+
+ await click('[data-test-radar-open-record]');
+ assert.deepEqual(this.records, ['inspection_failed:inspection_submission_a']);
+ });
+
+ test('an acknowledged row dims and drops the acknowledge button; a snoozed row shows its wake time and a wake button', async function (assert) {
+ this.item = radarItem({
+ state: { status: 'acknowledged', acknowledged_at: '2026-09-15T08:12:00Z', acknowledged_by_name: 'M. Reyes', assigned_to: { name: 'J. Tran', initials: 'JT' } },
+ });
+
+ await render(
+ hbs` `
+ );
+
+ assert.dom('[data-test-radar-row]').hasClass('is-acknowledged');
+ assert.dom('[data-test-radar-state="acknowledged"]').includesText('M. Reyes');
+ assert.dom('[data-test-radar-action="acknowledge"]').doesNotExist();
+ assert.dom('[data-test-radar-assignee]').includesText('J. Tran');
+
+ this.set('item', radarItem({ state: { status: 'snoozed', snoozed_until: '2026-09-18T08:00:00Z' } }));
+ assert.dom('[data-test-radar-row]').hasClass('is-snoozed');
+ assert.dom('[data-test-radar-state="snoozed"]').exists();
+ assert.dom('[data-test-radar-action="wake"]').exists();
+
+ await click('[data-test-radar-action="wake"]');
+ assert.strictEqual(this.acts.at(-1)[1], 'wake');
+ });
+
+ test('an overdue row colours its due label and a selected or focused row is marked', async function (assert) {
+ this.item = radarItem({ due_bucket: 'overdue', due_label: '3d overdue' });
+
+ await render(
+ hbs` `
+ );
+
+ assert.dom('[data-test-radar-due]').hasClass('is-overdue');
+ assert.dom('[data-test-radar-due]').hasText('3d overdue');
+ assert.dom('[data-test-radar-row]').hasClass('is-selected');
+ assert.dom('[data-test-radar-row]').hasClass('is-focused');
+ });
+});
diff --git a/tests/integration/components/radar/view-bar-test.js b/tests/integration/components/radar/view-bar-test.js
new file mode 100644
index 000000000..68aa51366
--- /dev/null
+++ b/tests/integration/components/radar/view-bar-test.js
@@ -0,0 +1,52 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, click } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+
+module('Integration | Component | radar/view-bar', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ test('it renders the status tabs with counts and the saved views', async function (assert) {
+ this.statuses = [];
+ this.applied = [];
+ this.deleted = [];
+ this.saves = 0;
+ this.stats = { open: 32, snoozed: 6 };
+ this.views = [
+ { id: 'my-assignments', label: 'My assignments', isDefault: true },
+ { id: 'view-1', label: 'Unmatched fuel' },
+ ];
+ this.activeView = this.views[1];
+ this.onSetStatus = (status) => this.statuses.push(status);
+ this.onApplyView = (view) => this.applied.push(view.id);
+ this.onDeleteView = (view) => this.deleted.push(view.id);
+ this.onSaveView = () => this.saves++;
+
+ await render(
+ hbs` `
+ );
+
+ assert.dom('[data-test-radar-tab="open"]').hasClass('is-active');
+ assert.dom('[data-test-radar-tab="open"] .fleet-ops-radar-tab-count').hasText('32');
+ assert.dom('[data-test-radar-tab="snoozed"] .fleet-ops-radar-tab-count').hasText('6');
+ assert.dom('[data-test-radar-tab="resolved"] .fleet-ops-radar-tab-count').doesNotExist();
+ assert.dom('[data-test-radar-view-item="view-1"]').hasClass('is-active');
+ assert.dom('[data-test-radar-view-delete="my-assignments"]').doesNotExist('default views cannot be deleted');
+ assert.dom('[data-test-radar-view-delete="view-1"]').exists();
+
+ await click('[data-test-radar-tab="snoozed"]');
+ assert.deepEqual(this.statuses, ['snoozed']);
+
+ await click('[data-test-radar-view-apply="my-assignments"]');
+ assert.deepEqual(this.applied, ['my-assignments']);
+
+ await click('[data-test-radar-view-delete="view-1"]');
+ assert.deepEqual(this.deleted, ['view-1']);
+ assert.deepEqual(this.applied, ['my-assignments'], 'deleting does not also apply the view');
+
+ await click('[data-test-radar-view-save]');
+ assert.strictEqual(this.saves, 1);
+ });
+});
diff --git a/tests/integration/components/widget/radar-test.js b/tests/integration/components/widget/radar-test.js
new file mode 100644
index 000000000..93de725cf
--- /dev/null
+++ b/tests/integration/components/widget/radar-test.js
@@ -0,0 +1,55 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, waitFor } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+import Service from '@ember/service';
+
+class StubFetchService extends Service {
+ response = { summary: { open: 32, overdue: 3, snoozed: 6, critical: 2 }, counts: {} };
+ lastUrl = null;
+ shouldFail = false;
+
+ async get(url) {
+ this.lastUrl = url;
+ if (this.shouldFail) {
+ throw new Error('offline');
+ }
+ return this.response;
+ }
+}
+
+module('Integration | Component | widget/radar', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ hooks.beforeEach(function () {
+ this.owner.register('service:fetch', StubFetchService);
+ this.fetch = this.owner.lookup('service:fetch');
+ });
+
+ test('it shows the open, overdue and snoozed counts from the summary endpoint', async function (assert) {
+ await render(hbs` `);
+ await waitFor('[data-test-radar-widget-open]');
+
+ assert.strictEqual(this.fetch.lastUrl, 'fleet-ops/radar/summary');
+ assert.dom('[data-test-radar-widget-open]').hasText('32');
+ assert.dom('[data-test-radar-widget-overdue]').includesText('3 overdue');
+ assert.dom('[data-test-radar-widget-snoozed]').hasText('6 snoozed');
+ assert.dom('[data-test-radar-widget]').hasClass('kpi-accent-bad');
+ assert.dom('[data-test-radar-widget-link]').exists();
+ });
+
+ test('it reads as all clear with nothing open and reports a failed load', async function (assert) {
+ this.fetch.response = { summary: { open: 0, overdue: 0, snoozed: 0, critical: 0 } };
+ await render(hbs` `);
+ await waitFor('[data-test-radar-widget-open]');
+ assert.dom('[data-test-radar-widget]').hasClass('kpi-accent-good');
+ assert.dom('[data-test-radar-widget-overdue]').doesNotExist();
+
+ this.fetch.shouldFail = true;
+ await render(hbs` `);
+ await waitFor('.text-red-500, .text-red-400');
+ assert.dom('[data-test-radar-widget]').includesText('offline');
+ });
+});
diff --git a/tests/integration/modifiers/radar-keyboard-test.js b/tests/integration/modifiers/radar-keyboard-test.js
new file mode 100644
index 000000000..1b3a21bfb
--- /dev/null
+++ b/tests/integration/modifiers/radar-keyboard-test.js
@@ -0,0 +1,77 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, triggerKeyEvent, fillIn } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { isTypingTarget } from '@fleetbase/fleetops-engine/modifiers/radar-keyboard';
+
+module('Integration | Modifier | radar-keyboard', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it maps keys to handlers and ignores them while typing', async function (assert) {
+ const fired = [];
+ this.handlers = {
+ next: () => fired.push('next'),
+ previous: () => fired.push('previous'),
+ select: () => fired.push('select'),
+ acknowledge: () => fired.push('acknowledge'),
+ snooze: () => fired.push('snooze'),
+ assign: () => fired.push('assign'),
+ open: () => fired.push('open'),
+ close: () => fired.push('close'),
+ };
+
+ await render(hbs`
`);
+
+ for (const key of ['J', 'K', 'X', 'E', 'S', 'A', 'Enter', 'Escape', 'ArrowDown']) {
+ await triggerKeyEvent(document.body, 'keydown', key);
+ }
+ assert.deepEqual(fired, ['next', 'previous', 'select', 'acknowledge', 'snooze', 'assign', 'open', 'close', 'next']);
+
+ await fillIn('#field', 'typing');
+ await triggerKeyEvent('#field', 'keydown', 'J');
+ assert.strictEqual(fired.length, 9, 'a key pressed inside a field is left to the field');
+
+ await triggerKeyEvent(document.body, 'keydown', 'J', { ctrlKey: true });
+ assert.strictEqual(fired.length, 9, 'modifier combinations are not hijacked');
+
+ await triggerKeyEvent(document.body, 'keydown', 'Q');
+ assert.strictEqual(fired.length, 9, 'unmapped keys do nothing');
+ });
+
+ test('it can be disabled and stops listening when removed', async function (assert) {
+ let count = 0;
+ this.handlers = { next: () => count++ };
+ this.enabled = false;
+ this.shown = true;
+
+ await render(hbs`{{#if this.shown}}
{{/if}}`);
+
+ await triggerKeyEvent(document.body, 'keydown', 'J');
+ assert.strictEqual(count, 0, 'disabled');
+
+ this.set('enabled', true);
+ await triggerKeyEvent(document.body, 'keydown', 'J');
+ assert.strictEqual(count, 1);
+
+ this.set('shown', false);
+ await triggerKeyEvent(document.body, 'keydown', 'J');
+ assert.strictEqual(count, 1, 'the listener is removed with the element');
+ });
+
+ test('isTypingTarget recognises fields, editable content and open dropdowns', function (assert) {
+ const input = document.createElement('input');
+ const div = document.createElement('div');
+ const editable = document.createElement('div');
+ editable.contentEditable = 'true';
+ const dropdown = document.createElement('div');
+ dropdown.className = 'ember-basic-dropdown-content';
+ const inside = document.createElement('span');
+ dropdown.appendChild(inside);
+
+ assert.true(isTypingTarget(input));
+ assert.false(isTypingTarget(div));
+ assert.true(isTypingTarget(editable) || editable.isContentEditable === false, 'contenteditable counts when the browser reports it');
+ assert.true(isTypingTarget(inside));
+ assert.false(isTypingTarget(null));
+ });
+});
diff --git a/tests/unit/controllers/management/index-test.js b/tests/unit/controllers/management/index-test.js
new file mode 100644
index 000000000..b36650221
--- /dev/null
+++ b/tests/unit/controllers/management/index-test.js
@@ -0,0 +1,400 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import { setupIntl } from 'ember-intl/test-support';
+import Service from '@ember/service';
+
+class FetchStubService extends Service {
+ calls = [];
+ itemsResponse = { items: [], groups: [], counts: {}, summary: {}, meta: { total: 0, page: 1, pages: 1, limit: 50 }, snooze_schedule: [], sources: {} };
+ summaryResponse = { summary: { open: 3, snoozed: 1 }, counts: { overdue: 2 } };
+ postResponse = { state: { status: 'acknowledged' } };
+ failNextPost = false;
+
+ briefingResponse = {
+ score: { value: 90, delta: null },
+ categories: [],
+ brief: [],
+ decisions: [{ key: 'd1', title: 'Do it', keys: ['issue_open:a'], confirm: { endpoint: 'x' } }],
+ yesterday: {},
+ };
+ agendaResponse = { window: { key: '24h' }, lanes: {}, overdue: [], later: [], anytime: [], handovers: [], counts: {} };
+ handoverResponse = {
+ handover: {
+ key: 'shift_handover:d',
+ driver: { label: 'Luis' },
+ shift: { uuid: 'sh1' },
+ orders: [{ uuid: 'o1' }, { uuid: 'o2' }],
+ suggested: { driver: { uuid: 'driver-alves', label: 'Tomas' } },
+ },
+ };
+
+ async get(url, params) {
+ this.calls.push(['get', url, params]);
+ if (url.startsWith('fleet-ops/radar/summary')) {
+ return this.summaryResponse;
+ }
+ if (url.startsWith('fleet-ops/radar/briefing')) {
+ return this.briefingResponse;
+ }
+ if (url.startsWith('fleet-ops/radar/agenda')) {
+ return this.agendaResponse;
+ }
+ if (url.startsWith('fleet-ops/radar/handovers')) {
+ return this.handoverResponse;
+ }
+ return this.itemsResponse;
+ }
+
+ async patch(url, body) {
+ this.calls.push(['patch', url, body]);
+ return {};
+ }
+
+ async post(url, body) {
+ this.calls.push(['post', url, body]);
+ if (this.failNextPost) {
+ this.failNextPost = false;
+ throw new Error('nope');
+ }
+ return this.postResponse;
+ }
+}
+
+class AppCacheStubService extends Service {
+ store = {};
+ get(key, fallback) {
+ return key in this.store ? this.store[key] : fallback;
+ }
+ set(key, value) {
+ this.store[key] = value;
+ }
+}
+
+class NotificationsStubService extends Service {
+ messages = [];
+ success(message) {
+ this.messages.push(['success', message]);
+ }
+ info(message) {
+ this.messages.push(['info', message]);
+ }
+ warning(message) {
+ this.messages.push(['warning', message]);
+ }
+ serverError(err) {
+ this.messages.push(['error', err?.message]);
+ }
+}
+
+class HostRouterStubService extends Service {
+ transitions = [];
+ transitionTo(...args) {
+ this.transitions.push(args);
+ }
+}
+
+function item(key, extra = {}) {
+ const [rule] = key.split(':');
+ return { key, rule, category: 'issues', severity: 'warning', title: key, due_bucket: 'none', state: { status: 'open' }, actions: ['acknowledge', 'snooze', 'assign'], ...extra };
+}
+
+module('Unit | Controller | management/index (radar)', function (hooks) {
+ setupTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ hooks.beforeEach(function () {
+ this.owner.register('service:fetch', FetchStubService);
+ this.owner.register('service:app-cache', AppCacheStubService);
+ this.owner.register('service:notifications', NotificationsStubService);
+ this.owner.register('service:host-router', HostRouterStubService);
+ this.fetch = this.owner.lookup('service:fetch');
+ this.appCache = this.owner.lookup('service:app-cache');
+ this.notifications = this.owner.lookup('service:notifications');
+ this.controller = this.owner.lookup('controller:management/index');
+ });
+
+ test('it reads the view mode and saved views from the app cache', function (assert) {
+ this.appCache.set('fleetops:radar:view', 'agenda');
+ this.appCache.set('fleetops:radar:views', [{ id: 'view-1', label: 'Mine', filters: 'issues' }, { broken: true }]);
+
+ const controller = this.owner.factoryFor('controller:management/index').create();
+
+ assert.strictEqual(controller.view, 'agenda');
+ assert.deepEqual(
+ controller.allViews.map((view) => view.id),
+ ['my-assignments', 'shift-changes', 'due-this-week', 'unmatched-fuel', 'view-1'],
+ 'defaults first, then the saved ones, dropping malformed entries'
+ );
+ });
+
+ test('pills carry counts from the payload and toggle into the filters query param', async function (assert) {
+ this.fetch.itemsResponse = { ...this.fetch.itemsResponse, counts: { overdue: 3, issues: 8 } };
+ await this.controller.loadItems.perform();
+
+ const pills = this.controller.pills;
+ assert.strictEqual(pills.find((pill) => pill.key === 'overdue').count, 3);
+ assert.strictEqual(pills.find((pill) => pill.key === 'issues').count, 8);
+ assert.strictEqual(pills.find((pill) => pill.key === 'fuel').count, 0);
+
+ this.controller.togglePill('overdue');
+ this.controller.togglePill('issues');
+ assert.strictEqual(this.controller.filters, 'overdue,issues');
+ assert.true(this.controller.isFiltered);
+
+ this.controller.togglePill('overdue');
+ assert.strictEqual(this.controller.filters, 'issues');
+
+ await this.controller.loadItems.last;
+ const [, url, params] = this.fetch.calls.at(-1);
+ assert.strictEqual(url, 'fleet-ops/radar/items');
+ assert.strictEqual(params.filters, 'issues');
+ assert.strictEqual(params.status, 'open');
+
+ this.controller.clearFilters();
+ assert.strictEqual(this.controller.filters, '');
+ assert.false(this.controller.isFiltered);
+ });
+
+ test('applying a saved view sets status, filters, query and assignee; saving and deleting persist to the cache', function (assert) {
+ this.controller.applyView({ id: 'my-assignments', status: 'open', filters: '', assigned: 'me', q: '' });
+ assert.strictEqual(this.controller.assigned, 'me');
+ assert.strictEqual(this.controller.saved, 'my-assignments');
+ assert.true(this.controller.isFiltered, 'my assignments narrows the list');
+
+ this.controller.savedViews = [{ id: 'view-9', label: 'Nine', filters: 'fuel' }];
+ this.controller.writeSavedViews();
+ assert.deepEqual(this.appCache.get('fleetops:radar:views'), [{ id: 'view-9', label: 'Nine', filters: 'fuel' }]);
+
+ this.controller.saved = 'view-9';
+ this.controller.deleteView({ id: 'view-9' });
+ assert.deepEqual(this.controller.savedViews, []);
+ assert.strictEqual(this.controller.saved, '', 'deleting the active view clears it');
+ assert.deepEqual(this.appCache.get('fleetops:radar:views'), []);
+ });
+
+ test('selection and focus move over the loaded items', async function (assert) {
+ this.fetch.itemsResponse = { ...this.fetch.itemsResponse, items: [item('issue_open:a'), item('issue_open:b'), item('issue_open:c')] };
+ await this.controller.loadItems.perform();
+
+ this.controller.focusNext();
+ assert.strictEqual(this.controller.focusedKey, 'issue_open:a');
+ this.controller.focusNext();
+ this.controller.focusNext();
+ this.controller.focusNext();
+ assert.strictEqual(this.controller.focusedKey, 'issue_open:c', 'focus stops at the last item');
+ this.controller.focusPrevious();
+ assert.strictEqual(this.controller.focusedKey, 'issue_open:b');
+
+ this.controller.toggleFocusedSelection();
+ assert.deepEqual(this.controller.selection, ['issue_open:b']);
+ this.controller.selectAll();
+ assert.true(this.controller.isAllSelected);
+ this.controller.selectAll();
+ assert.deepEqual(this.controller.selection, []);
+
+ this.controller.selection = ['issue_open:a', 'issue_open:gone'];
+ this.controller.pruneSelection();
+ assert.deepEqual(this.controller.selection, ['issue_open:a'], 'keys that left the list are dropped');
+ });
+
+ test('shift-clicking a checkbox selects or clears every row between it and the last one toggled', async function (assert) {
+ this.fetch.itemsResponse = { ...this.fetch.itemsResponse, items: ['a', 'b', 'c', 'd', 'e'].map((letter) => item(`issue_open:${letter}`)) };
+ await this.controller.loadItems.perform();
+ const [a, b, , d, e] = this.controller.items;
+
+ this.controller.toggleSelect(b);
+ this.controller.toggleSelect(e, { range: true });
+ assert.deepEqual(this.controller.selection, ['issue_open:b', 'issue_open:c', 'issue_open:d', 'issue_open:e'], 'the range from b to e is selected');
+
+ this.controller.toggleSelect(a, { range: true });
+ assert.deepEqual(this.controller.selection, ['issue_open:a', 'issue_open:b', 'issue_open:c', 'issue_open:d', 'issue_open:e'], 'shift-clicking a selects back up to e');
+
+ this.controller.toggleSelect(d);
+ this.controller.toggleSelect(b, { range: true });
+ assert.deepEqual(this.controller.selection, ['issue_open:a', 'issue_open:e'], 'unselecting d then shift-clicking the selected b clears b through d');
+
+ this.controller.clearSelection();
+ this.controller.toggleSelect(d, { range: true });
+ assert.deepEqual(this.controller.selection, ['issue_open:d'], 'with no earlier row, shift-click selects just that row');
+ });
+
+ test('acknowledge patches the row in place and snooze takes it off the open tab', async function (assert) {
+ const rows = [item('issue_open:a'), item('issue_open:b')];
+ this.fetch.itemsResponse = { ...this.fetch.itemsResponse, items: rows, groups: [{ key: 'none', count: 2, items: rows }] };
+ await this.controller.loadItems.perform();
+
+ this.fetch.postResponse = { state: { status: 'acknowledged', acknowledged_by_name: 'Ada' } };
+ await this.controller.act.perform(this.controller.items[0], 'acknowledge');
+
+ const [, url, body] = this.fetch.calls.find(([method]) => method === 'post');
+ assert.strictEqual(url, 'fleet-ops/radar/items/issue_open%3Aa/acknowledge');
+ assert.deepEqual(body, {});
+ assert.strictEqual(this.controller.items[0].state.status, 'acknowledged');
+ assert.strictEqual(this.controller.groups[0].items[0].state.acknowledged_by_name, 'Ada');
+ assert.strictEqual(this.controller.items.length, 2, 'an acknowledged item stays on the open tab');
+
+ this.fetch.postResponse = { state: { status: 'snoozed', snoozed_until: '2026-09-15T10:00:00Z' } };
+ this.controller.drawerItem = this.controller.items[1];
+ await this.controller.act.perform(this.controller.items[1], 'snooze', { minutes: 60 });
+
+ const [, , snoozeBody] = this.fetch.calls.at(-2);
+ assert.deepEqual(snoozeBody, { minutes: 60 });
+ assert.deepEqual(
+ this.controller.items.map((row) => row.key),
+ ['issue_open:a'],
+ 'a snoozed item leaves the open tab'
+ );
+ assert.strictEqual(this.controller.drawerItem, null, 'the drawer closes with it');
+ assert.strictEqual(this.notifications.messages.at(-1)[0], 'success');
+ });
+
+ test('bulk actions post every selected key once and apply each result', async function (assert) {
+ const rows = [item('issue_open:a'), item('issue_open:b'), item('issue_open:c')];
+ this.fetch.itemsResponse = { ...this.fetch.itemsResponse, items: rows, groups: [{ key: 'none', count: 3, items: rows }] };
+ await this.controller.loadItems.perform();
+
+ this.controller.selection = ['issue_open:a', 'issue_open:b'];
+ this.fetch.postResponse = {
+ results: [
+ { key: 'issue_open:a', ok: true, state: { status: 'snoozed', snoozed_until: '2026-09-15T10:00:00Z' } },
+ { key: 'issue_open:b', ok: false, error: 'not found' },
+ ],
+ };
+ await this.controller.bulkAct.perform('snooze', { minutes: 60 });
+
+ const [, url, body] = this.fetch.calls.find(([method]) => method === 'post');
+ assert.strictEqual(url, 'fleet-ops/radar/items/bulk');
+ assert.deepEqual(body, { keys: ['issue_open:a', 'issue_open:b'], action: 'snooze', minutes: 60, until: undefined });
+ assert.deepEqual(
+ this.controller.items.map((row) => row.key),
+ ['issue_open:b', 'issue_open:c'],
+ 'the snoozed key left the open tab, the failed one stayed'
+ );
+ assert.deepEqual(this.controller.selection, [], 'the selection clears after a bulk action');
+ assert.strictEqual(this.notifications.messages.at(-1)[0], 'success');
+
+ this.controller.selection = ['issue_open:c'];
+ this.controller.keyboardHandlers.acknowledge();
+ await this.controller.bulkAct.last;
+ const [, , ackBody] = this.fetch.calls.at(-2);
+ assert.deepEqual(ackBody, { keys: ['issue_open:c'], action: 'acknowledge' }, 'with a selection the E key runs the bulk action');
+ });
+
+ test('confirming a decision makes its call, keeps an undoable strip and drops the card; not now snoozes its keys', async function (assert) {
+ await this.controller.loadBriefing.perform();
+ assert.strictEqual(this.controller.briefing.decisions.length, 1);
+
+ const decision = { key: 'd1', title: 'Book TRK-118', keys: ['issue_open:a'] };
+ this.fetch.postResponse = {};
+ await this.controller.confirmDecision.perform(decision, {
+ label: 'Confirm',
+ method: 'POST',
+ endpoint: 'maintenance-schedules/s/trigger',
+ body: {},
+ undo: { endpoint: 'undo-it', method: 'POST' },
+ });
+
+ assert.deepEqual(this.fetch.calls.find(([method]) => method === 'post').slice(1), ['maintenance-schedules/s/trigger', {}]);
+ assert.strictEqual(this.controller.strips[0].kind, 'confirmed');
+ assert.strictEqual(this.controller.strips[0].title, 'Book TRK-118');
+ assert.deepEqual(this.controller.briefing.decisions, [], 'the card left the stack');
+
+ await this.controller.undoStrip.perform(this.controller.strips[0]);
+ assert.deepEqual(this.fetch.calls.filter(([, url]) => url === 'undo-it').length, 1);
+ assert.deepEqual(this.controller.strips, []);
+
+ this.fetch.postResponse = { results: [{ key: 'issue_open:a', ok: true, state: { status: 'snoozed', snoozed_until: '2026-09-16T08:35:00Z' } }] };
+ await this.controller.dismissDecision.perform({ key: 'd2', title: 'Later', keys: ['issue_open:a'] });
+ const [, url, body] = this.fetch.calls.find(([method, calledUrl]) => method === 'post' && calledUrl === 'fleet-ops/radar/items/bulk');
+ assert.strictEqual(url, 'fleet-ops/radar/items/bulk');
+ assert.deepEqual(body, { keys: ['issue_open:a'], action: 'snooze', minutes: 1440 });
+ assert.strictEqual(this.controller.strips[0].kind, 'snoozed');
+ assert.strictEqual(this.controller.strips[0].until, '2026-09-16T08:35:00Z');
+
+ this.controller.filterCategory('maintenance');
+ assert.strictEqual(this.controller.category, 'maintenance');
+ assert.true(this.controller.isFiltered);
+ this.controller.filterCategory('maintenance');
+ assert.strictEqual(this.controller.category, '', 'filtering the same category again clears it');
+ });
+
+ test('the agenda view loads on demand, plans dropped items and drives the handover card', async function (assert) {
+ this.controller.setView('agenda');
+ await this.controller.loadAgenda.last;
+ assert.strictEqual(this.controller.agenda.window.key, '24h');
+ assert.deepEqual(this.fetch.calls.at(-1), ['get', 'fleet-ops/radar/agenda', { window: '24h', fleet: '' }]);
+
+ this.controller.setWindow('7d');
+ await this.controller.loadAgenda.last;
+ assert.strictEqual(this.controller.window, '7d');
+
+ await this.controller.planItem.perform('issue_open:a', '2026-09-15T16:00:00Z');
+ const plan = this.fetch.calls.find(([method, url]) => method === 'post' && url.includes('/plan'));
+ assert.deepEqual(plan.slice(1), ['fleet-ops/radar/items/issue_open%3Aa/plan', { planned_at: '2026-09-15T16:00:00Z' }]);
+
+ await this.controller.openHandover.perform('shift_handover:d');
+ assert.strictEqual(this.controller.handover.driver.label, 'Luis');
+
+ await this.controller.reassignOrders.perform(this.controller.handover);
+ const reassign = this.fetch.calls.find(([method]) => method === 'patch');
+ assert.deepEqual(reassign.slice(1), ['orders/bulk-assign-driver', { ids: ['o1', 'o2'], driver: 'driver-alves' }]);
+ assert.strictEqual(this.controller.handover, null, 'the card closes after reassigning');
+
+ await this.controller.openHandover.perform('shift_handover:d');
+ await this.controller.extendShift.perform(this.controller.handover, 60);
+ const extend = this.fetch.calls.find(([method, url]) => method === 'post' && url.includes('/extend'));
+ assert.deepEqual(extend.slice(1), ['fleet-ops/radar/shifts/sh1/extend', { minutes: 60 }]);
+
+ this.controller.openAgendaEntry({ key: 'issue_open:zzz', rule: 'issue_open', title: 'Entry', severity: 'warning', actions: [], state: { status: 'open' } });
+ assert.strictEqual(this.controller.drawerItem.key, 'issue_open:zzz', 'an entry the list does not have opens from its own fields');
+ });
+
+ test('a failed state action reports the error and leaves the row alone', async function (assert) {
+ const rows = [item('issue_open:a')];
+ this.fetch.itemsResponse = { ...this.fetch.itemsResponse, items: rows, groups: [{ key: 'none', count: 1, items: rows }] };
+ await this.controller.loadItems.perform();
+
+ this.fetch.failNextPost = true;
+ await this.controller.act.perform(this.controller.items[0], 'acknowledge');
+
+ assert.strictEqual(this.controller.items[0].state.status, 'open');
+ assert.deepEqual(this.notifications.messages.at(-1), ['error', 'nope']);
+ });
+
+ test('open record loads the record by public id and opens its resource panel', async function (assert) {
+ const hostRouter = this.owner.lookup('service:host-router');
+ const store = this.owner.lookup('service:store');
+ const issue = { id: 'uuid-a', public_id: 'issue_a' };
+ const queries = [];
+ const opened = [];
+ store.queryRecord = async (modelName, query) => {
+ queries.push([modelName, query]);
+ return issue;
+ };
+ this.owner.lookup('service:issue-actions').panel = { view: (record) => opened.push(record) };
+
+ await this.controller.openRecord(item('issue_open:a', { record: { route: 'management.issues.index.details', model: 'issue_a' } }));
+
+ assert.deepEqual(
+ queries,
+ [['issue', { public_id: 'issue_a', single: true, with: ['driver', 'vehicle', 'assignee', 'reporter', 'order', 'files'] }]],
+ 'queried by public id, like the details route'
+ );
+ assert.deepEqual(opened, [issue], 'the issue panel opened with the loaded record');
+
+ await this.controller.openRecord(item('notice:n', { record: null }));
+ assert.strictEqual(opened.length, 1, 'an item without a record opens nothing');
+
+ await this.controller.openRecord(item('x:y', { record: { route: 'operations.orders.index.details', model: 'order_1' } }));
+ assert.deepEqual(hostRouter.transitions, [['console.fleet-ops.operations.orders.index.details', 'order_1']], 'a record with no panel is navigated to');
+ });
+
+ test('the keyboard handlers cover move, select, acknowledge, snooze, assign, open and close', function (assert) {
+ const handlers = this.controller.keyboardHandlers;
+
+ assert.deepEqual(Object.keys(handlers).sort(), ['acknowledge', 'assign', 'close', 'next', 'open', 'previous', 'search', 'select', 'snooze']);
+ for (const handler of Object.values(handlers)) {
+ assert.strictEqual(typeof handler, 'function');
+ }
+ });
+});
diff --git a/tests/unit/routes/hub-index-routes-test.js b/tests/unit/routes/hub-index-routes-test.js
index edaccfeb6..17b947798 100644
--- a/tests/unit/routes/hub-index-routes-test.js
+++ b/tests/unit/routes/hub-index-routes-test.js
@@ -3,7 +3,14 @@ import { setupTest } from 'dummy/tests/helpers';
import Service from '@ember/service';
class FetchStubService extends Service {
- get() {
+ get(url) {
+ if (url.startsWith('fleet-ops/radar/items')) {
+ return Promise.resolve({ items: [], groups: [], counts: {}, summary: {}, meta: { total: 0, page: 1, pages: 1 }, snooze_schedule: [], sources: {} });
+ }
+ if (url.startsWith('fleet-ops/radar/summary')) {
+ return Promise.resolve({ summary: {}, counts: {} });
+ }
+
return Promise.resolve({ actions: [] });
}
}
@@ -21,8 +28,8 @@ module('Unit | Route | fleet-ops hub index routes', function (hooks) {
});
test('it registers the FleetOps hub route templates', function (assert) {
- assert.ok(this.owner.resolveRegistration('template:management/index'), 'resources hub template is registered');
- assert.ok(this.owner.resolveRegistration('controller:management/index'), 'resources hub controller is registered');
+ assert.ok(this.owner.resolveRegistration('template:management/index'), 'radar template is registered');
+ assert.ok(this.owner.resolveRegistration('controller:management/index'), 'radar controller is registered');
assert.ok(this.owner.resolveRegistration('route:maintenance/index'), 'maintenance hub route is registered');
assert.ok(this.owner.resolveRegistration('controller:maintenance/index'), 'maintenance hub controller is registered');
assert.ok(this.owner.resolveRegistration('template:maintenance/index'), 'maintenance hub template is registered');
@@ -47,17 +54,9 @@ module('Unit | Route | fleet-ops hub index routes', function (hooks) {
assert.deepEqual(workOrdersRoute.queryParams.priority, { refreshModel: true }, 'work orders route refreshes priority filters');
});
- test('hub controllers normalize action query values for LinkTo', function (assert) {
- const managementController = this.owner.lookup('controller:management/index');
+ test('the maintenance hub controller normalizes action query values for LinkTo', function (assert) {
const maintenanceController = this.owner.lookup('controller:maintenance/index');
- managementController.hub = {
- actions: [
- { key: 'missing-query', route: 'management.drivers' },
- { key: 'array-query', route: 'management.vehicles', query: [] },
- { key: 'object-query', route: 'management.issues', query: { status: 'open' } },
- ],
- };
maintenanceController.hub = {
actions: [
{ key: 'null-query', route: 'maintenance.schedules', query: null },
@@ -65,9 +64,6 @@ module('Unit | Route | fleet-ops hub index routes', function (hooks) {
],
};
- assert.deepEqual(managementController.actions[0].query, {}, 'missing resource action query becomes an object');
- assert.deepEqual(managementController.actions[1].query, {}, 'array resource action query becomes an object');
- assert.deepEqual(managementController.actions[2].query, { status: 'open' }, 'object resource action query is preserved');
assert.deepEqual(maintenanceController.actions[0].query, {}, 'null maintenance action query becomes an object');
assert.deepEqual(maintenanceController.actions[1].query, { status: 'open' }, 'object maintenance action query is preserved');
});
diff --git a/tests/unit/utils/radar-test.js b/tests/unit/utils/radar-test.js
new file mode 100644
index 000000000..8dae9d4c4
--- /dev/null
+++ b/tests/unit/utils/radar-test.js
@@ -0,0 +1,83 @@
+import { module, test } from 'qunit';
+import { chipStatusFor, primaryActionFor, secondaryActionsFor, snoozePayloadFor, initialsOf, patchPayload } from '@fleetbase/fleetops-engine/utils/radar';
+
+module('Unit | Utility | radar', function () {
+ test('chipStatusFor maps categories and rules to badge statuses, issues by severity', function (assert) {
+ assert.strictEqual(chipStatusFor({ rule: 'maintenance_overdue', category: 'maintenance' }), 'warning');
+ assert.strictEqual(chipStatusFor({ rule: 'work_order_overdue', category: 'maintenance' }), 'orange');
+ assert.strictEqual(chipStatusFor({ rule: 'inspection_failed', category: 'inspections' }), 'violet');
+ assert.strictEqual(chipStatusFor({ rule: 'shift_handover', category: 'staffing' }), 'indigo');
+ assert.strictEqual(chipStatusFor({ rule: 'driver_without_vehicle', category: 'staffing' }), 'info');
+ assert.strictEqual(chipStatusFor({ rule: 'issue_open', category: 'issues', severity: 'critical' }), 'error');
+ assert.strictEqual(chipStatusFor({ rule: 'issue_open', category: 'issues', severity: 'warning' }), 'warning');
+ assert.strictEqual(chipStatusFor({ rule: 'notice', category: 'notices' }), 'gray');
+ assert.strictEqual(chipStatusFor(null), 'gray');
+ });
+
+ test('primaryActionFor picks the first record-level action and secondaryActionsFor the rest', function (assert) {
+ const item = { actions: ['create_work_order_from_inspection', 'create_issue_from_inspection', 'acknowledge', 'snooze', 'assign', 'open_record'] };
+
+ assert.deepEqual(primaryActionFor(item), { key: 'create_work_order_from_inspection', label: 'radar.actions.create-work-order', icon: 'clipboard-list' });
+ assert.deepEqual(
+ secondaryActionsFor(item).map((action) => action.key),
+ ['create_issue_from_inspection']
+ );
+ assert.strictEqual(primaryActionFor({ actions: ['acknowledge', 'snooze'] }), null);
+ assert.strictEqual(primaryActionFor(null), null);
+ });
+
+ test('snoozePayloadFor gives minutes for short presets and a next-morning date for long ones', function (assert) {
+ const now = new Date('2026-09-15T08:35:00');
+
+ assert.deepEqual(snoozePayloadFor('1h', now), { minutes: 60 });
+ assert.deepEqual(snoozePayloadFor('4h', now), { minutes: 240 });
+
+ const tomorrow = new Date(snoozePayloadFor('tomorrow', now).until);
+ assert.strictEqual(tomorrow.getDate(), 16);
+ assert.strictEqual(tomorrow.getHours(), 8);
+
+ const nextWeek = new Date(snoozePayloadFor('next-week', now).until);
+ assert.strictEqual(nextWeek.getDate(), 22);
+ assert.deepEqual(snoozePayloadFor('unknown', now), { minutes: 60 });
+ });
+
+ test('initialsOf takes the first and last name', function (assert) {
+ assert.strictEqual(initialsOf('Ada Ops'), 'AO');
+ assert.strictEqual(initialsOf('Cher'), 'C');
+ assert.strictEqual(initialsOf(' mary jane watson '), 'MW');
+ assert.strictEqual(initialsOf(null), '');
+ });
+
+ test('patchPayload replaces or drops one item in both the flat list and the groups', function (assert) {
+ const payload = {
+ items: [
+ { key: 'a', state: { status: 'open' } },
+ { key: 'b', state: { status: 'open' } },
+ ],
+ groups: [
+ { key: 'overdue', count: 1, items: [{ key: 'a', state: { status: 'open' } }] },
+ { key: 'none', count: 1, items: [{ key: 'b', state: { status: 'open' } }] },
+ ],
+ };
+
+ const patched = patchPayload(payload, 'a', (item) => ({ ...item, state: { status: 'acknowledged' } }));
+ assert.strictEqual(patched.items[0].state.status, 'acknowledged');
+ assert.strictEqual(patched.groups[0].items[0].state.status, 'acknowledged');
+ assert.strictEqual(payload.items[0].state.status, 'open', 'the original payload is untouched');
+
+ const dropped = patchPayload(payload, 'a', () => null);
+ assert.deepEqual(
+ dropped.items.map((item) => item.key),
+ ['b']
+ );
+ assert.deepEqual(
+ dropped.groups.map((group) => group.key),
+ ['none'],
+ 'an emptied group disappears'
+ );
+ assert.strictEqual(
+ patchPayload(null, 'a', () => null),
+ null
+ );
+ });
+});
diff --git a/tests/unit/utils/resource-descriptors-test.js b/tests/unit/utils/resource-descriptors-test.js
index 260b335dd..a78edc2c9 100644
--- a/tests/unit/utils/resource-descriptors-test.js
+++ b/tests/unit/utils/resource-descriptors-test.js
@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
import { setupTest } from 'dummy/tests/helpers';
import { registerResourceDescriptors, resolveResourceKey, getResourceDescriptor, getResourceDescriptors, readDescriptor } from '@fleetbase/ember-ui/utils/resource-registry';
import { buildFleetOpsResourceDescriptors } from '@fleetbase/fleetops-engine/utils/resource-descriptors';
+import { loadConcreteRecord } from '@fleetbase/fleetops-engine/utils/resource-descriptors/polymorphic';
/** Every model file in fleetops-data/addon/models. */
const MODEL_NAMES = [
@@ -212,4 +213,47 @@ module('Unit | Utility | resource-descriptors', function (hooks) {
assert.true(facilitator.canOpen(record));
assert.false(facilitator.canOpen({ name: 'nobody' }), 'nothing to delegate to');
});
+
+ test('opening a polymorphic base calls the concrete descriptor once instead of looping through the registry', async function (assert) {
+ const facilitator = getResourceDescriptor(this.owner, 'facilitator');
+ const vendor = getResourceDescriptor(this.owner, 'vendor');
+ const original = vendor.open;
+ const calls = [];
+ vendor.open = (record, context) => {
+ calls.push([record, context.resourceType]);
+ return true;
+ };
+
+ try {
+ const record = { facilitator_type: 'fleet-ops:vendor', name: 'Pacific Steel' };
+ const result = await facilitator.open(record, {});
+
+ assert.true(result);
+ assert.deepEqual(calls, [[record, 'vendor']], 'the vendor opener ran exactly once with the record');
+ assert.false(await facilitator.open({ name: 'nobody' }, {}), 'nothing to delegate to');
+ } finally {
+ vendor.open = original;
+ }
+ });
+
+ test('a bare polymorphic base record is swapped for its concrete record before opening', async function (assert) {
+ const vendorRecord = { id: 'vendor_1', name: 'Pacific Steel' };
+ const store = {
+ peekRecord: (modelName, id) => (modelName === 'vendor' && id === 'vendor_1' ? vendorRecord : null),
+ findRecord: async () => {
+ throw new Error('unreachable for a peeked record');
+ },
+ };
+ const owner = { lookup: (name) => (name === 'service:store' ? store : null) };
+ const descriptor = { modelNames: ['vendor'], aliases: ['facilitator-vendor'] };
+ const base = { constructor: { modelName: 'facilitator' }, id: 'vendor_1' };
+
+ assert.strictEqual(await loadConcreteRecord(owner, descriptor, base), vendorRecord, 'the peeked vendor stands in for the base record');
+
+ const subtype = { constructor: { modelName: 'facilitator-vendor' }, id: 'vendor_1' };
+ assert.strictEqual(await loadConcreteRecord(owner, descriptor, subtype), subtype, 'a subtype the descriptor knows is kept');
+
+ const failing = { lookup: () => ({ peekRecord: () => null, findRecord: async () => Promise.reject(new Error('404')) }) };
+ assert.strictEqual(await loadConcreteRecord(failing, descriptor, base), base, 'a failed load falls back to the record');
+ });
});
diff --git a/translations/en-us.yaml b/translations/en-us.yaml
index 4c80968be..1996dca4c 100644
--- a/translations/en-us.yaml
+++ b/translations/en-us.yaml
@@ -68,6 +68,7 @@ common:
menu:
operations: Operations
dashboard: Dashboard
+ radar: Radar
orders: Orders
service-rates: Service Rates
scheduler: Scheduler
@@ -1288,6 +1289,8 @@ place:
locate-place: Locate Place on Map
service-area:
+ actions:
+ edit-boundary: Edit boundary on map
fields:
details: Service Area Details
area-color: Fill Color
@@ -1509,6 +1512,8 @@ integrated-vendor:
namespace-help-text: Optionally provide a custom namespace or api version that should be used for the integration.
zone:
+ actions:
+ edit-boundary: Edit boundary on map
fields:
details: Zone Details
border-color: Border Color
@@ -2172,8 +2177,279 @@ modals:
optionally: Optionally give the zone a description.
description-zone: Description of Zone
+radar:
+ title: Radar
+ eyebrow: Resources
+ subtitle: Everything across resources, maintenance, inspections and staffing that needs a decision, one row per record.
+ summary: "{open} open · {snoozed} snoozed · last sync {time}"
+ search-placeholder: Filter items
+ reload: Reload
+ new-notice: New notice
+ showing: "Showing {count} of {total} · grouped by due"
+ sources-warning: "Some sources could not be loaded: {sources}"
+ briefing:
+ title: Morning brief
+ score: Fleet health score
+ of-100: "/ 100"
+ vs-yesterday: "{delta} vs yesterday"
+ no-history: first reading
+ generated: "Generated {time} · sources linked"
+ yesterday: "Yesterday: {closed} gaps closed, {rolled} rolled over"
+ decisions: Decisions waiting on you
+ decisions-count: "{shown} of {total}"
+ nothing-to-decide: Nothing to decide.
+ confirm: Confirm
+ not-now: Not now
+ undo: Undo
+ wake-now: Wake now
+ confirmed: "Confirmed {time}"
+ snoozed-until: "Snoozed to {time}"
+ filter: Filter
+ collapse: Collapse brief
+ expand: Show brief
+ open-record: Open record
+ working: Working…
+ views:
+ list: List
+ agenda: Agenda
+ agenda:
+ window-24h: Next 24h
+ window-7d: 7 days
+ overdue: Overdue
+ later: Later
+ anytime: Anytime
+ anytime-count: "{count} undated"
+ no-events: No events in this window.
+ drag-hint: Drag an item onto a lane to give it a time.
+ print: Print handover sheet
+ see-all: "See all {count} →"
+ planned: Planned
+ now: now
+ lanes:
+ shifts: Shifts
+ maintenance: Maintenance
+ expiries: Expiries
+ notices: Notices
+ shift-states:
+ on_shift: on shift
+ not_online: not online
+ upcoming: upcoming
+ ended: ended
+ summary: "{shifts} shifts in window · {overdue} overdue · {anytime} undated"
+ handover:
+ title: Shift ends
+ ends-in: "in {minutes}"
+ orders-on-driver: "{count} active orders still on {name}"
+ finishes-after: finishes after the shift
+ suggested: Suggested handoff
+ on-shift-till: "on shift till {time}"
+ away: "{distance} km away"
+ reassign: "Reassign {count} orders to {name}"
+ extend: Extend shift 1h
+ snooze: Snooze 1h
+ no-cover: Nobody on shift long enough to cover; extend the shift or reassign by hand.
+ reassigned: "Orders moved to {name}."
+ extended: Shift extended by an hour.
+ loading: Finding cover…
+ tabs:
+ open: Open
+ snoozed: Snoozed
+ resolved: Resolved
+ pills:
+ overdue: Overdue
+ due_week: Due this week
+ unassigned: Unassigned
+ issues: Open issues
+ inspections: Inspections
+ shifts: Shift changes
+ expiring: Expiring 30d
+ low_stock: Low stock
+ fuel: Unmatched fuel
+ notices: Notices
+ groups:
+ overdue: Overdue
+ today: Today
+ week: This week
+ later: Later
+ none: No date
+ rules:
+ maintenance_overdue: Maint
+ maintenance_due_soon: Maint
+ inspection_due: Inspection
+ inspection_failed: Inspection
+ inspection_unresolved: Inspection
+ inspection_draft: Inspection
+ inspection_link_pending: Inspection
+ vehicle_inspection_failed: Inspection
+ work_order_overdue: Work order
+ work_order_blocked: Work order
+ issue_open: Issue
+ shift_late_start: Shift
+ shift_no_vehicle: Shift
+ shift_handover: Handover
+ driver_without_vehicle: Unassigned
+ vehicle_without_driver: Idle
+ vehicle_without_device: Device
+ device_unattached: Device
+ license_expiring: Licence
+ lease_expiring: Lease
+ fuel_unmatched: Fuel
+ part_low_stock: Parts
+ notice: Notice
+ actions:
+ acknowledge: Acknowledge
+ snooze: Snooze
+ wake: Wake now
+ assign: Assign
+ unassign: Clear owner
+ plan: Give it a time
+ unplan: Clear time
+ resolve: Resolve
+ open-record: Open record
+ assign-vehicle: Assign vehicle
+ assign-driver: Assign driver
+ create-work-order: Create work order
+ create-issue: Create issue
+ mark-resolved: Mark resolved
+ send-pin: Send PIN
+ revoke-link: Revoke link
+ revoke-links: "Revoke {count, plural, one {# link} other {# links}}"
+ match-vehicle: Match vehicle
+ ignore: Ignore
+ attach: Attach to vehicle
+ call: Call
+ cover-shift: Cover shift
+ reassign: Reassign orders
+ extend-shift: Extend shift 1h
+ select: Select
+ deselect: Deselect
+ more: More
+ snooze:
+ 1h: 1 hour
+ 4h: 4 hours
+ tomorrow: Tomorrow morning
+ next-week: Next week
+ pick: Pick a date…
+ pick-title: Snooze until
+ pick-label: Wake on
+ state:
+ acknowledged-by: "Acknowledged by {name} {time}"
+ acknowledged: Acknowledged
+ snoozed-until: "Snoozed until {time}"
+ assigned-to: "Assigned to {name}"
+ unassigned: Unassigned
+ planned-for: "Planned for {time}"
+ resolved-by: "Resolved by {name} {time}"
+ resolved-auto: Closed on the record
+ bulk:
+ selected: "{count} selected"
+ clear: Clear
+ summary: "{count} items"
+ keys:
+ move: move
+ select: select
+ acknowledge: acknowledge
+ snooze: snooze
+ assign: assign
+ open: open record
+ close: close
+ empty:
+ title: Nothing needs a decision.
+ snoozed-hint: "{count} snoozed items wake this week."
+ nothing-snoozed: Nothing is snoozed.
+ wakes: "wakes {time}"
+ filtered-title: Nothing matches these filters.
+ filtered-hint: Clear a pill or the search to see more.
+ snoozed-title: Nothing is snoozed.
+ snoozed-tab-hint: Snoozed items come back to the list when their wake time passes.
+ resolved-title: Nothing resolved in the last 7 days.
+ resolved-hint: Items close here when the record they describe changes, or when a notice is resolved.
+ saved-views:
+ title: Views
+ save-current: Save current
+ name-prompt: Name this view
+ name-placeholder: e.g. Due this week
+ saved: View saved.
+ delete: Delete view
+ deleted: View removed.
+ defaults:
+ my-assignments: My assignments
+ shift-changes: Shift changes
+ due-this-week: Due this week
+ unmatched-fuel: Unmatched fuel
+ notice:
+ title: New notice
+ message: Message
+ message-placeholder: Yard closed Saturday for repaving — move trailers by Fri 18:00
+ severity: Severity
+ due: Deadline
+ scope: Applies to
+ scope-placeholder: Yard 3 · whole fleet
+ create: Post notice
+ created: Notice posted.
+ delete: Delete notice
+ deleted: Notice deleted.
+ severities:
+ info: Info
+ warning: Warning
+ critical: Critical
+ drawer:
+ failed-items: Failed items
+ state: State
+ subject: Record
+ due: Due
+ window: Shift
+ details: Details
+ prompts:
+ select-vehicle: Select a vehicle
+ select-user: Select a person
+ assign-vehicle-title: "Assign a vehicle to {name}"
+ assign-driver-title: "Assign a driver to {name}"
+ match-vehicle-title: Match this transaction to a vehicle
+ create-work-order-title: Create a work order from this schedule?
+ create-work-order-body: A work order opens with the schedule's defaults and due date.
+ ignore-title: Ignore this transaction?
+ ignore-body: It leaves the unmatched queue and is not linked to a vehicle.
+ attach-device-title: Attach this device to a vehicle
+ revoke-link-title: Revoke this inspection link?
+ send-pin-title: Send the inspection PIN
+ send-pin-to: "How should {name} receive the link and PIN?"
+ send-pin-body: How should the link and PIN be sent?
+ send-pin-unavailable: The link's recipient has no email address or phone number to send the PIN to.
+ revoke-link-body: The link stops working immediately.
+ bulk-revoke-link-title: "Revoke {count, plural, one {# inspection link} other {# inspection links}}?"
+ bulk-revoke-link-body: The links stop working immediately. Other selected items stay as they are.
+ assign-user-title: "Who owns this?"
+ assign-user-help: The owner shows on the row and in the My assignments view.
+ no-selection: Pick one first.
+ toasts:
+ acknowledged: Acknowledged.
+ snoozed: "Snoozed until {time}."
+ woken: Back on the list.
+ assigned: "Assigned to {name}."
+ unassigned: Owner cleared.
+ planned: Time set.
+ resolved: Resolved.
+ work-order-created: Work order created.
+ vehicle-assigned: Vehicle assigned.
+ driver-assigned: Driver assigned.
+ matched: Transaction matched.
+ ignored: Transaction ignored.
+ device-attached: Device attached.
+ pin-sent: PIN sent.
+ link-revoked: Link revoked.
+ links-revoked: "{count, plural, one {# link revoked} other {# links revoked}}."
+ links-revoke-failed: "{count, plural, one {# link could not be revoked} other {# links could not be revoked}}."
+ bulk-done: "{count} items updated."
widget:
refresh: Refresh
+ radar:
+ title: Radar
+ description: What needs a decision across the fleet right now.
+ open: open
+ overdue: overdue
+ snoozed: snoozed
+ open-radar: Open Radar
kpi-earnings:
title: Earnings
kpi-distance: