@fleetbase/fleetops-engine 0.6.48 → 0.6.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/addon/components/customer/create-order-form.hbs +2 -2
  2. package/addon/components/customer/form.hbs +2 -2
  3. package/addon/components/customer/order-form.hbs +2 -2
  4. package/addon/components/customer/orders.hbs +2 -2
  5. package/addon/components/display-place.hbs +2 -2
  6. package/addon/components/driver/details.hbs +5 -1
  7. package/addon/components/driver/form.hbs +11 -1
  8. package/addon/components/fleet-listing-panel.hbs +2 -2
  9. package/addon/components/fleet-listing-panel.js +10 -0
  10. package/addon/components/map/google-live-map.js +18 -75
  11. package/addon/components/map/leaflet-live-map.hbs +187 -37
  12. package/addon/components/map/leaflet-live-map.js +184 -20
  13. package/addon/components/modals/order-change-destination.hbs +46 -0
  14. package/addon/components/modals/order-change-destination.js +106 -0
  15. package/addon/components/modals/stop-activity.hbs +28 -0
  16. package/addon/components/modals/stop-activity.js +52 -0
  17. package/addon/components/modals/update-order-activity.hbs +45 -3
  18. package/addon/components/modals/update-order-activity.js +143 -5
  19. package/addon/components/orchestrator/phase-builder.hbs +21 -0
  20. package/addon/components/orchestrator/phase-builder.js +13 -0
  21. package/addon/components/orchestrator/resource-panel.hbs +0 -10
  22. package/addon/components/orchestrator/resource-panel.js +2 -4
  23. package/addon/components/orchestrator-workbench.js +1 -0
  24. package/addon/components/order/activity-list.hbs +2 -2
  25. package/addon/components/order/activity-timeline.hbs +31 -29
  26. package/addon/components/order/details/activity.hbs +3 -3
  27. package/addon/components/order/details/activity.js +11 -0
  28. package/addon/components/order/details/documents.js +2 -0
  29. package/addon/components/order/details/purchase-rate.hbs +2 -2
  30. package/addon/components/order/details/route.hbs +11 -3
  31. package/addon/components/order/details/route.js +91 -14
  32. package/addon/components/order/details/tracking.hbs +101 -72
  33. package/addon/components/order/details/tracking.js +149 -2
  34. package/addon/components/order/form/details.hbs +3 -3
  35. package/addon/components/order/form/documents.js +11 -4
  36. package/addon/components/order/form/route.hbs +108 -104
  37. package/addon/components/order/form/route.js +37 -1
  38. package/addon/components/order/form/service-rate.hbs +2 -2
  39. package/addon/components/order/form.hbs +2 -2
  40. package/addon/components/order-config-manager/activity-flow.hbs +4 -1
  41. package/addon/components/order-config-manager/activity-flow.js +178 -12
  42. package/addon/components/place/address.js +1 -1
  43. package/addon/components/route-list.hbs +17 -17
  44. package/addon/components/route-list.js +42 -10
  45. package/addon/components/service-rate/details.hbs +41 -0
  46. package/addon/components/service-rate/form.hbs +118 -0
  47. package/addon/components/service-rate/form.js +55 -0
  48. package/addon/components/vehicle/form.js +1 -1
  49. package/addon/components/vehicle/pill.hbs +1 -1
  50. package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
  51. package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
  52. package/addon/components/widget/fuel-efficiency.hbs +26 -0
  53. package/addon/components/widget/fuel-efficiency.js +43 -0
  54. package/addon/components/widget/geofence-violations.hbs +44 -0
  55. package/addon/components/widget/geofence-violations.js +55 -0
  56. package/addon/components/widget/issues-insights.hbs +43 -0
  57. package/addon/components/widget/issues-insights.js +62 -0
  58. package/addon/components/widget/kpi-active-orders.hbs +10 -0
  59. package/addon/components/widget/kpi-active-orders.js +5 -0
  60. package/addon/components/widget/kpi-aov.hbs +10 -0
  61. package/addon/components/widget/kpi-aov.js +5 -0
  62. package/addon/components/widget/kpi-distance.hbs +10 -0
  63. package/addon/components/widget/kpi-distance.js +5 -0
  64. package/addon/components/widget/kpi-drivers-online.hbs +10 -0
  65. package/addon/components/widget/kpi-drivers-online.js +5 -0
  66. package/addon/components/widget/kpi-earnings.hbs +10 -0
  67. package/addon/components/widget/kpi-earnings.js +5 -0
  68. package/addon/components/widget/kpi-open-issues.hbs +11 -0
  69. package/addon/components/widget/kpi-open-issues.js +5 -0
  70. package/addon/components/widget/kpi-tile.hbs +59 -0
  71. package/addon/components/widget/kpi-tile.js +168 -0
  72. package/addon/components/widget/live-fleet.hbs +122 -0
  73. package/addon/components/widget/live-fleet.js +365 -0
  74. package/addon/components/widget/maintenance-overview.hbs +45 -0
  75. package/addon/components/widget/maintenance-overview.js +40 -0
  76. package/addon/components/widget/on-time-delivery.hbs +27 -0
  77. package/addon/components/widget/on-time-delivery.js +73 -0
  78. package/addon/components/widget/operations-pulse.hbs +51 -0
  79. package/addon/components/widget/operations-pulse.js +173 -0
  80. package/addon/components/widget/orders-by-status.hbs +26 -0
  81. package/addon/components/widget/orders-by-status.js +54 -0
  82. package/addon/components/widget/revenue-trend.hbs +34 -0
  83. package/addon/components/widget/revenue-trend.js +67 -0
  84. package/addon/components/widget/top-drivers.hbs +44 -0
  85. package/addon/components/widget/top-drivers.js +54 -0
  86. package/addon/controllers/management/vehicles/index/new.js +1 -1
  87. package/addon/controllers/operations/orders/index/details.js +4 -4
  88. package/addon/controllers/operations/orders/index.js +14 -0
  89. package/addon/controllers/settings/routing.js +26 -0
  90. package/addon/extension.js +172 -3
  91. package/addon/helpers/place-address-html.js +2 -2
  92. package/addon/routes/operations/service-rates/index.js +1 -1
  93. package/addon/services/geofence.js +163 -19
  94. package/addon/services/leaflet-contextmenu-manager.js +27 -0
  95. package/addon/services/map-adapter/google.js +13 -2
  96. package/addon/services/vehicle-actions.js +1 -1
  97. package/addon/styles/fleetops-engine.css +205 -88
  98. package/addon/templates/maintenance/equipment/index.hbs +1 -1
  99. package/addon/templates/maintenance/parts/index.hbs +1 -1
  100. package/addon/templates/maintenance/schedules/index.hbs +1 -1
  101. package/addon/templates/management/vehicles/index.hbs +1 -1
  102. package/addon/templates/settings/avatars.hbs +1 -1
  103. package/addon/templates/settings/custom-fields.hbs +1 -1
  104. package/addon/templates/settings/map.hbs +1 -1
  105. package/addon/templates/settings/navigator-app.hbs +1 -1
  106. package/addon/templates/settings/notifications.hbs +1 -1
  107. package/addon/templates/settings/orchestrator.hbs +1 -1
  108. package/addon/templates/settings/payments/index.hbs +1 -1
  109. package/addon/templates/settings/payments/onboard.hbs +1 -1
  110. package/addon/templates/settings/routing.hbs +22 -2
  111. package/addon/templates/settings/scheduling.hbs +1 -1
  112. package/addon/utils/fleet-ops-options.js +2 -1
  113. package/addon/utils/live-map-card-content.js +171 -0
  114. package/addon/utils/normalize-order-config-flow.js +41 -0
  115. package/addon/utils/place-address-html.js +3 -2
  116. package/addon/utils/route-visualization.js +54 -7
  117. package/app/components/modals/order-change-destination.js +1 -0
  118. package/app/components/modals/stop-activity.js +1 -0
  119. package/app/components/widget/fuel-efficiency.js +1 -0
  120. package/app/components/widget/geofence-violations.js +1 -0
  121. package/app/components/widget/issues-insights.js +1 -0
  122. package/app/components/widget/kpi-active-orders.js +1 -0
  123. package/app/components/widget/kpi-aov.js +1 -0
  124. package/app/components/widget/kpi-distance.js +1 -0
  125. package/app/components/widget/kpi-drivers-online.js +1 -0
  126. package/app/components/widget/kpi-earnings.js +1 -0
  127. package/app/components/widget/kpi-open-issues.js +1 -0
  128. package/app/components/widget/kpi-tile.js +1 -0
  129. package/app/components/widget/live-fleet.js +1 -0
  130. package/app/components/widget/maintenance-overview.js +1 -0
  131. package/app/components/widget/on-time-delivery.js +1 -0
  132. package/app/components/widget/operations-pulse.js +1 -0
  133. package/app/components/widget/orders-by-status.js +1 -0
  134. package/app/components/widget/revenue-trend.js +1 -0
  135. package/app/components/widget/top-drivers.js +1 -0
  136. package/app/utils/live-map-card-content.js +1 -0
  137. package/composer.json +1 -1
  138. package/extension.json +1 -1
  139. package/package.json +3 -3
  140. package/pnpm-workspace.yaml +1 -0
  141. package/server/config/telematics.php +111 -1
  142. package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
  143. package/server/migrations/2026_05_21_000001_add_geographic_rules_to_service_rate_fees_table.php +37 -0
  144. package/server/migrations/2026_05_25_000001_add_endpoint_tracking_refs_to_payloads_table.php +50 -0
  145. package/server/migrations/2026_05_27_000001_default_driver_vehicle_statuses_to_available.php +48 -0
  146. package/server/resources/views/labels/default.php +57 -49
  147. package/server/resources/views/labels/waypoint-label.php +27 -20
  148. package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
  149. package/server/seeders/Testing/ConnectivitySeeder.php +213 -0
  150. package/server/seeders/Testing/FleetSeeder.php +341 -0
  151. package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
  152. package/server/seeders/Testing/NetworkSeeder.php +250 -0
  153. package/server/seeders/Testing/OrdersSeeder.php +198 -0
  154. package/server/seeders/Testing/TestingSeeder.php +40 -0
  155. package/server/seeders/{VroomOrchestrationSeeder.php → Testing/VroomOrchestrationSeeder.php} +29 -22
  156. package/server/src/Console/Commands/AuditCustomerUserConflicts.php +75 -0
  157. package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
  158. package/server/src/Exceptions/CustomerUserConflictException.php +13 -0
  159. package/server/src/Http/Controllers/Api/v1/ContactController.php +17 -2
  160. package/server/src/Http/Controllers/Api/v1/CustomerController.php +876 -0
  161. package/server/src/Http/Controllers/Api/v1/DriverController.php +1 -1
  162. package/server/src/Http/Controllers/Api/v1/OrderConfigController.php +54 -0
  163. package/server/src/Http/Controllers/Api/v1/OrderController.php +98 -70
  164. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  165. package/server/src/Http/Controllers/Api/v1/VehicleController.php +20 -3
  166. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
  167. package/server/src/Http/Controllers/Internal/v1/ContactController.php +19 -0
  168. package/server/src/Http/Controllers/Internal/v1/LiveController.php +101 -66
  169. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
  170. package/server/src/Http/Controllers/Internal/v1/OrderController.php +441 -32
  171. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  172. package/server/src/Http/Controllers/Internal/v1/SettingController.php +46 -1
  173. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +54 -1
  174. package/server/src/Http/Middleware/AuthenticateCustomerToken.php +34 -0
  175. package/server/src/Http/Requests/CreateCustomerOrderRequest.php +63 -0
  176. package/server/src/Http/Requests/CreateCustomerRequest.php +48 -0
  177. package/server/src/Http/Requests/CreateDriverRequest.php +1 -1
  178. package/server/src/Http/Requests/CreateVehicleRequest.php +25 -1
  179. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -1
  180. package/server/src/Http/Requests/VerifyCreateCustomerRequest.php +29 -0
  181. package/server/src/Http/Resources/Internal/v1/OrderConfig.php +41 -0
  182. package/server/src/Http/Resources/v1/Customer.php +79 -0
  183. package/server/src/Http/Resources/v1/Driver.php +2 -0
  184. package/server/src/Http/Resources/v1/Fleet.php +4 -0
  185. package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
  186. package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
  187. package/server/src/Http/Resources/v1/OrderConfig.php +74 -0
  188. package/server/src/Http/Resources/v1/Payload.php +23 -20
  189. package/server/src/Http/Resources/v1/ServiceArea.php +33 -19
  190. package/server/src/Http/Resources/v1/ServiceRateFee.php +7 -0
  191. package/server/src/Listeners/HandleOrderDispatched.php +2 -2
  192. package/server/src/Models/Contact.php +92 -15
  193. package/server/src/Models/Customer.php +64 -0
  194. package/server/src/Models/Driver.php +14 -3
  195. package/server/src/Models/Entity.php +2 -2
  196. package/server/src/Models/Order.php +29 -4
  197. package/server/src/Models/OrderConfig.php +14 -11
  198. package/server/src/Models/Payload.php +17 -1
  199. package/server/src/Models/ServiceRate.php +273 -1
  200. package/server/src/Models/ServiceRateFee.php +39 -9
  201. package/server/src/Models/TrackingNumber.php +8 -2
  202. package/server/src/Models/Vehicle.php +18 -2
  203. package/server/src/Models/Waypoint.php +2 -2
  204. package/server/src/Notifications/LateDeparture.php +48 -0
  205. package/server/src/Notifications/ProlongedStoppage.php +48 -0
  206. package/server/src/Notifications/RouteDeviation.php +48 -0
  207. package/server/src/Observers/ContactObserver.php +2 -0
  208. package/server/src/Observers/ServiceRateObserver.php +6 -6
  209. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +29 -2
  210. package/server/src/Providers/FleetOpsServiceProvider.php +6 -0
  211. package/server/src/Providers/NotificationServiceProvider.php +3 -0
  212. package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
  213. package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
  214. package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
  215. package/server/src/Support/Analytics/IssuesInsights.php +67 -0
  216. package/server/src/Support/Analytics/LiveFleet.php +127 -0
  217. package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
  218. package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
  219. package/server/src/Support/Analytics/OperationsPulse.php +86 -0
  220. package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
  221. package/server/src/Support/Analytics/RevenueTrend.php +78 -0
  222. package/server/src/Support/Analytics/TopDrivers.php +92 -0
  223. package/server/src/Support/CustomerAuth.php +98 -0
  224. package/server/src/Support/LabelPdf.php +52 -0
  225. package/server/src/Support/Metrics/AbstractMetric.php +170 -0
  226. package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
  227. package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
  228. package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
  229. package/server/src/Support/Metrics/EarningsMetric.php +30 -0
  230. package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
  231. package/server/src/Support/Metrics/MoneyMetric.php +30 -0
  232. package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
  233. package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
  234. package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
  235. package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
  236. package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
  237. package/server/src/Support/Metrics/Registry.php +46 -0
  238. package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
  239. package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
  240. package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
  241. package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
  242. package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
  243. package/server/src/Support/Metrics.php +46 -249
  244. package/server/src/Support/ResolvesOrderServiceStops.php +512 -0
  245. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
  246. package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
  247. package/server/src/Tracking/TrackingContextBuilder.php +41 -38
  248. package/server/src/Tracking/TrackingIntelligenceService.php +109 -9
  249. package/server/src/Tracking/TrackingStop.php +13 -11
  250. package/server/src/routes.php +50 -0
  251. package/server/tests/AnalyticsRoutesTest.php +41 -0
  252. package/server/tests/CapacityAllocationEngineTest.php +10 -10
  253. package/server/tests/CustomerContactIdentitySafetyTest.php +54 -0
  254. package/server/tests/CustomerEndpointTest.php +199 -0
  255. package/server/tests/DriverVehicleStatusDefaultTest.php +39 -0
  256. package/server/tests/LabelPdfRenderingTest.php +23 -0
  257. package/server/tests/LiveControllerViewportTest.php +60 -0
  258. package/server/tests/MetricsRegistryTest.php +84 -0
  259. package/server/tests/OrchestrationPayloadBuilderTest.php +2 -2
  260. package/server/tests/OrderConfigEndpointTest.php +86 -0
  261. package/server/tests/OrderFileAttachmentTest.php +20 -0
  262. package/server/tests/ServiceAreaZoneApiShapeTest.php +34 -0
  263. package/server/tests/VehicleDriverAssignmentTest.php +18 -0
  264. package/server/tests/VroomOrchestrationEngineTest.php +91 -0
  265. package/translations/en-us.yaml +38 -0
  266. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +0 -86
@@ -0,0 +1,54 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+ import { inject as service } from '@ember/service';
5
+ import { task } from 'ember-concurrency';
6
+
7
+ const SORTS = [
8
+ { key: 'orders_completed', label: 'Orders' },
9
+ { key: 'on_time', label: 'On-time' },
10
+ { key: 'distance', label: 'Distance' },
11
+ ];
12
+
13
+ export default class WidgetTopDriversComponent extends Component {
14
+ static widgetId = 'fleet-ops-top-drivers-widget';
15
+
16
+ @service fetch;
17
+
18
+ @tracked sortBy = 'orders_completed';
19
+ @tracked data = null;
20
+ @tracked error = null;
21
+
22
+ sorts = SORTS;
23
+
24
+ constructor() {
25
+ super(...arguments);
26
+ this.load.perform();
27
+ }
28
+
29
+ @task *load() {
30
+ try {
31
+ this.data = yield this.fetch.get('fleet-ops/analytics/top-drivers', {
32
+ period: '30d',
33
+ limit: 10,
34
+ sort_by: this.sortBy,
35
+ });
36
+ this.error = null;
37
+ } catch (e) {
38
+ this.error = e?.message ?? 'Failed to load top drivers';
39
+ }
40
+ }
41
+
42
+ @action
43
+ setSort(key) {
44
+ this.sortBy = key;
45
+ this.load.perform();
46
+ }
47
+
48
+ onTimeStatus(pct) {
49
+ if (typeof pct !== 'number') return 'info';
50
+ if (pct >= 95) return 'success';
51
+ if (pct >= 85) return 'warning';
52
+ return 'danger';
53
+ }
54
+ }
@@ -4,7 +4,7 @@ import { inject as service } from '@ember/service';
4
4
  import { action } from '@ember/object';
5
5
  import { task } from 'ember-concurrency';
6
6
 
7
- const DEFAULT_PROPERTIES = { status: 'active' };
7
+ const DEFAULT_PROPERTIES = { status: 'available' };
8
8
 
9
9
  export default class ManagementVehiclesIndexNewController extends Controller {
10
10
  @service store;
@@ -103,10 +103,10 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
103
103
  }),
104
104
  },
105
105
  {
106
- text: 'Unassign driver',
107
- icon: 'user-xmark',
108
- disabled: this.model.status === 'canceled' || !this.model.driver_assigned,
109
- fn: () => this.orderActions.unassignDriver(this.model),
106
+ text: this.model.has_driver_assigned ? 'Unassign driver' : 'Assign driver',
107
+ icon: this.model.has_driver_assigned ? 'user-xmark' : 'edit',
108
+ disabled: this.model.has_driver_assigned ? !this.model.hasActiveStatus || !this.model.driver_assigned : !this.model.hasActiveStatus,
109
+ fn: () => (this.model.has_driver_assigned ? this.orderActions.unassignDriver(this.model) : this.orderActions.assignDriver(this.model)),
110
110
  },
111
111
  {
112
112
  text: 'View order label',
@@ -385,6 +385,20 @@ export default class OperationsOrdersIndexController extends Controller {
385
385
  permission: 'fleet-ops dispatch order',
386
386
  isVisible: (order) => order.canBeDispatched,
387
387
  },
388
+ {
389
+ label: this.intl.t('common.assign-driver'),
390
+ icon: 'edit',
391
+ fn: this.orderActions.assignDriver,
392
+ permission: 'fleet-ops update order',
393
+ isVisible: (order) => !order.has_driver_assigned,
394
+ },
395
+ {
396
+ label: this.intl.t('common.unassign-driver'),
397
+ icon: 'edit',
398
+ fn: this.orderActions.unassignDriver,
399
+ permission: 'fleet-ops update order',
400
+ isVisible: (order) => order.has_driver_assigned,
401
+ },
388
402
  {
389
403
  label: this.intl.t('common.cancel-resource', { resource: this.intl.t('resource.order') }),
390
404
  icon: 'ban',
@@ -20,6 +20,12 @@ export default class SettingsRoutingController extends Controller {
20
20
  @tracked trackingRouteCacheTtlSeconds = 600;
21
21
  @tracked trackingStaleLocationThresholdSeconds = 300;
22
22
  @tracked trackingDefaultVehicleSpeedKph = 35;
23
+ @tracked lateDepartureAlertsEnabled = true;
24
+ @tracked lateDepartureGracePeriodMinutes = 15;
25
+ @tracked routeDeviationAlertsEnabled = true;
26
+ @tracked routeDeviationDistanceThresholdMeters = 500;
27
+ @tracked prolongedStoppageAlertsEnabled = true;
28
+ @tracked prolongedStoppageDurationThresholdMinutes = 30;
23
29
  @tracked showTrackingAdvancedSettings = false;
24
30
  @tracked trackingProviderOptions = [
25
31
  { value: 'google_routes', label: 'Google Routes' },
@@ -89,6 +95,12 @@ export default class SettingsRoutingController extends Controller {
89
95
  this.trackingRouteCacheTtlSeconds = trackingSettings.route_cache_ttl_seconds ?? 600;
90
96
  this.trackingStaleLocationThresholdSeconds = trackingSettings.stale_location_threshold_seconds ?? 300;
91
97
  this.trackingDefaultVehicleSpeedKph = trackingSettings.default_vehicle_speed_kph ?? 35;
98
+ this.lateDepartureAlertsEnabled = trackingSettings.alerts?.late_departures?.enabled ?? true;
99
+ this.lateDepartureGracePeriodMinutes = trackingSettings.alerts?.late_departures?.grace_period_minutes ?? 15;
100
+ this.routeDeviationAlertsEnabled = trackingSettings.alerts?.route_deviations?.enabled ?? true;
101
+ this.routeDeviationDistanceThresholdMeters = trackingSettings.alerts?.route_deviations?.distance_threshold_meters ?? 500;
102
+ this.prolongedStoppageAlertsEnabled = trackingSettings.alerts?.prolonged_stoppages?.enabled ?? true;
103
+ this.prolongedStoppageDurationThresholdMinutes = trackingSettings.alerts?.prolonged_stoppages?.duration_threshold_minutes ?? 30;
92
104
  this.trackingProviderOptions = this.normalizeProviderOptions(trackingSettings.providers ?? this.trackingProviderOptions);
93
105
  } catch (error) {
94
106
  this.notifications.serverError(error);
@@ -110,6 +122,20 @@ export default class SettingsRoutingController extends Controller {
110
122
  route_cache_ttl_seconds: Number(this.trackingRouteCacheTtlSeconds) || 600,
111
123
  stale_location_threshold_seconds: Number(this.trackingStaleLocationThresholdSeconds) || 300,
112
124
  default_vehicle_speed_kph: Number(this.trackingDefaultVehicleSpeedKph) || 35,
125
+ alerts: {
126
+ late_departures: {
127
+ enabled: this.lateDepartureAlertsEnabled,
128
+ grace_period_minutes: Number(this.lateDepartureGracePeriodMinutes) || 15,
129
+ },
130
+ route_deviations: {
131
+ enabled: this.routeDeviationAlertsEnabled,
132
+ distance_threshold_meters: Number(this.routeDeviationDistanceThresholdMeters) || 500,
133
+ },
134
+ prolonged_stoppages: {
135
+ enabled: this.prolongedStoppageAlertsEnabled,
136
+ duration_threshold_minutes: Number(this.prolongedStoppageDurationThresholdMinutes) || 30,
137
+ },
138
+ },
113
139
  };
114
140
  }
115
141
 
@@ -128,13 +128,182 @@ export default {
128
128
 
129
129
  registerWidgets(widgetService) {
130
130
  const widgets = [
131
+ // Legacy monolithic 13-tile widget — kept registered for one release as
132
+ // users have it pinned to existing dashboards. The new KPI tile widgets
133
+ // below supersede it. Scheduled for removal in the next major.
131
134
  new Widget({
132
135
  id: 'fleet-ops-key-metrics-widget',
133
- name: 'Fleet-Ops Metrics',
134
- description: 'Key metrics from Fleet-Ops.',
136
+ name: 'Fleet-Ops Metrics (Legacy)',
137
+ description: 'DEPRECATED — replaced by individual KPI tile widgets. Will be removed in the next major release.',
135
138
  icon: 'truck',
136
139
  component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/fleet-ops-key-metrics'),
137
- grid_options: { w: 12, h: 12, minW: 8, minH: 12 },
140
+ grid_options: { w: 12, h: 8, minW: 8, minH: 6 },
141
+ category: 'Legacy',
142
+ default: false,
143
+ }),
144
+
145
+ // Small KPI tile widgets — S-tier, each wraps <Widget::KpiTile> with a
146
+ // fixed slug pointing at GET /fleet-ops/metrics/{slug}.
147
+ new Widget({
148
+ id: 'fleet-ops-kpi-earnings-widget',
149
+ name: 'Earnings (30d)',
150
+ description: 'Total earnings over the last 30 days with trend.',
151
+ icon: 'sack-dollar',
152
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-earnings'),
153
+ grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
154
+ category: 'KPI Tiles',
155
+ default: true,
156
+ }),
157
+ new Widget({
158
+ id: 'fleet-ops-kpi-aov-widget',
159
+ name: 'Avg Order Value (30d)',
160
+ description: 'Average revenue per completed order with trend.',
161
+ icon: 'receipt',
162
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-aov'),
163
+ grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
164
+ category: 'KPI Tiles',
165
+ default: true,
166
+ }),
167
+ new Widget({
168
+ id: 'fleet-ops-kpi-distance-widget',
169
+ name: 'Distance Travelled (30d)',
170
+ description: 'Total kilometres delivered over the last 30 days.',
171
+ icon: 'route',
172
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-distance'),
173
+ grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
174
+ category: 'KPI Tiles',
175
+ default: false,
176
+ }),
177
+ new Widget({
178
+ id: 'fleet-ops-kpi-active-orders-widget',
179
+ name: 'Active Orders',
180
+ description: 'Live count of orders currently in flight.',
181
+ icon: 'bolt',
182
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-active-orders'),
183
+ grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
184
+ category: 'KPI Tiles',
185
+ default: true,
186
+ }),
187
+ new Widget({
188
+ id: 'fleet-ops-kpi-drivers-online-widget',
189
+ name: 'Drivers Online',
190
+ description: 'Live count of drivers currently active on a job.',
191
+ icon: 'id-card',
192
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-drivers-online'),
193
+ grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
194
+ category: 'KPI Tiles',
195
+ default: true,
196
+ }),
197
+ new Widget({
198
+ id: 'fleet-ops-kpi-open-issues-widget',
199
+ name: 'Open Issues',
200
+ description: 'Pending issues across the fleet (lower is better).',
201
+ icon: 'triangle-exclamation',
202
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-open-issues'),
203
+ grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
204
+ category: 'KPI Tiles',
205
+ default: false,
206
+ }),
207
+
208
+ // Analytics widgets — composed visualizations powered by /fleet-ops/analytics/*.
209
+ new Widget({
210
+ id: 'fleet-ops-operations-pulse-widget',
211
+ name: 'Operations Pulse',
212
+ description: 'Live operational snapshot with day-over-day deltas.',
213
+ icon: 'wave-pulse',
214
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/operations-pulse'),
215
+ grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
216
+ category: 'Analytics',
217
+ default: false,
218
+ }),
219
+ new Widget({
220
+ id: 'fleet-ops-revenue-trend-widget',
221
+ name: 'Revenue Trend',
222
+ description: 'Revenue over time with period comparison.',
223
+ icon: 'chart-line',
224
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/revenue-trend'),
225
+ grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
226
+ category: 'Analytics',
227
+ default: true,
228
+ }),
229
+ new Widget({
230
+ id: 'fleet-ops-orders-by-status-widget',
231
+ name: 'Order Volume by Status',
232
+ description: 'Daily stacked bars of order counts by status.',
233
+ icon: 'chart-column',
234
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/orders-by-status'),
235
+ grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
236
+ category: 'Analytics',
237
+ default: false,
238
+ }),
239
+ new Widget({
240
+ id: 'fleet-ops-on-time-delivery-widget',
241
+ name: 'On-Time Delivery',
242
+ description: 'Percentage of deliveries completed within the SLA window.',
243
+ icon: 'clock',
244
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/on-time-delivery'),
245
+ grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
246
+ category: 'Analytics',
247
+ default: false,
248
+ }),
249
+ new Widget({
250
+ id: 'fleet-ops-top-drivers-widget',
251
+ name: 'Top Drivers',
252
+ description: 'Driver leaderboard sortable by orders, on-time %, or distance.',
253
+ icon: 'medal',
254
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/top-drivers'),
255
+ grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
256
+ category: 'Analytics',
257
+ default: false,
258
+ }),
259
+ new Widget({
260
+ id: 'fleet-ops-fuel-efficiency-widget',
261
+ name: 'Fuel Cost & Efficiency',
262
+ description: 'Weekly fuel cost and cost-per-km trend.',
263
+ icon: 'gas-pump',
264
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/fuel-efficiency'),
265
+ grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
266
+ category: 'Analytics',
267
+ default: false,
268
+ }),
269
+ new Widget({
270
+ id: 'fleet-ops-issues-insights-widget',
271
+ name: 'Issues Insights',
272
+ description: 'Open/resolved issues, category breakdown, and average resolution time.',
273
+ icon: 'triangle-exclamation',
274
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/issues-insights'),
275
+ grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
276
+ category: 'Analytics',
277
+ default: false,
278
+ }),
279
+ new Widget({
280
+ id: 'fleet-ops-maintenance-overview-widget',
281
+ name: 'Maintenance Overview',
282
+ description: 'Overdue, scheduled, and YTD maintenance spend.',
283
+ icon: 'wrench',
284
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/maintenance-overview'),
285
+ grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
286
+ category: 'Analytics',
287
+ default: true,
288
+ }),
289
+ new Widget({
290
+ id: 'fleet-ops-geofence-violations-widget',
291
+ name: 'Geofence Violations',
292
+ description: 'Dwell-time outliers and geofence event hotspots.',
293
+ icon: 'location-crosshairs',
294
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/geofence-violations'),
295
+ grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
296
+ category: 'Analytics',
297
+ default: false,
298
+ }),
299
+ new Widget({
300
+ id: 'fleet-ops-live-fleet-widget',
301
+ name: 'Live Fleet Map',
302
+ description: 'Real-time driver positions and active routes.',
303
+ icon: 'map-location-dot',
304
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/live-fleet'),
305
+ grid_options: { w: 12, h: 10, minW: 8, minH: 8 },
306
+ category: 'Maps',
138
307
  default: true,
139
308
  }),
140
309
  ];
@@ -1,6 +1,6 @@
1
1
  import { helper } from '@ember/component/helper';
2
2
  import placeAddressHtml from '../utils/place-address-html';
3
3
 
4
- export default helper(function placeAddressHtmlHelper([place]) {
5
- return placeAddressHtml(place);
4
+ export default helper(function placeAddressHtmlHelper([place], hash = {}) {
5
+ return placeAddressHtml(place, hash);
6
6
  });
@@ -27,7 +27,7 @@ export default class OperationsServiceRatesIndexRoute extends Route {
27
27
  model(params) {
28
28
  return this.store.query('service-rate', {
29
29
  ...params,
30
- with: ['parcelFees', 'rateFees', 'zone', 'serviceArea'],
30
+ with: ['parcelFees', 'rateFees.zone', 'rateFees.serviceArea', 'zone', 'serviceArea'],
31
31
  });
32
32
  }
33
33
  }
@@ -9,6 +9,7 @@ import Service, { inject as service } from '@ember/service';
9
9
  import { action } from '@ember/object';
10
10
  import { debug } from '@ember/debug';
11
11
  import { dasherize } from '@ember/string';
12
+ import { later } from '@ember/runloop';
12
13
  import toMultiPolygon from '../utils/to-multi-polygon';
13
14
 
14
15
  export default class GeofenceService extends Service {
@@ -173,9 +174,11 @@ export default class GeofenceService extends Service {
173
174
  },
174
175
  {
175
176
  callback: (serviceArea) => {
176
- this.#resetDraftSession({ hideControl: true, removeDraft: true });
177
- this.#showServiceArea(serviceArea);
177
+ this.#finishDraftSave((draftLayer) => {
178
+ this.#reloadThenFocusServiceArea(serviceArea, { draftLayer });
179
+ });
178
180
  },
181
+ refresh: false,
179
182
  }
180
183
  );
181
184
  return;
@@ -184,7 +187,7 @@ export default class GeofenceService extends Service {
184
187
  if (this._draftSession.mode === 'zone') {
185
188
  const serviceArea = this._draftSession.serviceArea;
186
189
  this.zoneActions.modal.create(
187
- { border: geoJson, service_area_uuid: serviceArea.id },
190
+ { border: geoJson, service_area: serviceArea },
188
191
  {
189
192
  decline: (modal) => {
190
193
  this.#resetDraftSession({ hideControl: true, removeDraft: true });
@@ -193,10 +196,11 @@ export default class GeofenceService extends Service {
193
196
  },
194
197
  {
195
198
  callback: (zone) => {
196
- serviceArea?.zones?.pushObject?.(zone);
197
- this.#resetDraftSession({ hideControl: true, removeDraft: true });
198
- this.#showZone(zone);
199
+ this.#finishDraftSave((draftLayer) => {
200
+ this.#reloadThenFocusZone(serviceArea, zone, { draftLayer });
201
+ });
199
202
  },
203
+ refresh: false,
200
204
  }
201
205
  );
202
206
  return;
@@ -246,19 +250,19 @@ export default class GeofenceService extends Service {
246
250
  } else {
247
251
  record.setProperties({
248
252
  border: geoJson,
249
- service_area_uuid: selectedServiceArea.id,
250
- serviceArea: selectedServiceArea,
253
+ service_area: selectedServiceArea,
251
254
  });
252
255
  }
253
256
 
254
257
  return record.save().then((savedRecord) => {
255
- this.#resetDraftSession({ hideControl: true, removeDraft: true });
256
-
257
258
  if (selectedLayerType === 'Service Area') {
258
- this.#showServiceArea(savedRecord);
259
+ this.#finishDraftSave((draftLayer) => {
260
+ this.#reloadThenFocusServiceArea(savedRecord, { draftLayer });
261
+ });
259
262
  } else {
260
- selectedServiceArea?.zones?.pushObject?.(savedRecord);
261
- this.#showZone(savedRecord);
263
+ this.#finishDraftSave((draftLayer) => {
264
+ this.#reloadThenFocusZone(selectedServiceArea, savedRecord, { draftLayer });
265
+ });
262
266
  }
263
267
 
264
268
  return savedRecord;
@@ -289,28 +293,168 @@ export default class GeofenceService extends Service {
289
293
  this._draftSession = null;
290
294
  }
291
295
 
296
+ #finishDraftSave(callback) {
297
+ const draftLayer = this._draftSession?.draftLayer ?? null;
298
+ callback?.(draftLayer);
299
+ this.#resetDraftSession({ hideControl: true, removeDraft: false });
300
+ }
301
+
292
302
  async #editPolygonLayer(originalLayer, { focusBounds = null } = {}) {
293
303
  return this.mapManager.editPolygon(originalLayer, { focusBounds });
294
304
  }
295
305
 
296
306
  #showZone(zone, { pin = false } = {}) {
297
- const layer = this.mapManager.getOverlay(zone.id) ?? zone?.leafletLayer ?? null;
307
+ const overlay = this.mapManager.getOverlay(zone.id);
308
+ const layer = overlay ?? zone?.leafletLayer ?? null;
298
309
  this.#setLayerPinned(layer, pin);
299
- this.mapManager.showPolygon(zone.id);
310
+ if (overlay) {
311
+ this.mapManager.showPolygon(zone.id);
312
+ } else if (layer) {
313
+ this.mapManager.showLayer?.(layer);
314
+ }
315
+ }
316
+
317
+ #includeServiceArea(serviceArea) {
318
+ if (!serviceArea?.id) return;
319
+
320
+ const currentServiceAreas = this.serviceAreaActions.serviceAreas;
321
+ if (!currentServiceAreas) {
322
+ this.serviceAreaActions.serviceAreas = [serviceArea];
323
+ return;
324
+ }
325
+
326
+ const serviceAreas = Array.from(currentServiceAreas);
327
+ const exists = serviceAreas.some((existingServiceArea) => existingServiceArea?.id === serviceArea.id);
328
+ if (exists) return;
329
+
330
+ this.serviceAreaActions.serviceAreas = [...serviceAreas, serviceArea];
331
+ }
332
+
333
+ #includeZone(serviceArea, zone) {
334
+ if (!serviceArea?.id || !zone?.id) return;
335
+
336
+ const zones = serviceArea.zones;
337
+ if (!zones) {
338
+ serviceArea.set?.('zones', [zone]);
339
+ this.serviceAreaActions.serviceAreas = Array.from(this.serviceAreaActions.serviceAreas ?? []);
340
+ return;
341
+ }
342
+
343
+ const exists = Array.from(zones).some((existingZone) => existingZone?.id === zone.id);
344
+ if (exists) return;
345
+
346
+ if (typeof zones.pushObject === 'function') {
347
+ zones.pushObject(zone);
348
+ } else if (typeof zones.addObject === 'function') {
349
+ zones.addObject(zone);
350
+ } else if (typeof zones.push === 'function') {
351
+ zones.push(zone);
352
+ }
353
+
354
+ this.serviceAreaActions.serviceAreas = Array.from(this.serviceAreaActions.serviceAreas ?? []);
355
+ }
356
+
357
+ #reloadThenFocusServiceArea(serviceArea, options = {}) {
358
+ this.#reloadServiceAreas()
359
+ .then((serviceAreas) => {
360
+ const canonicalServiceArea = this.#findResource(serviceAreas, serviceArea) ?? serviceArea;
361
+ this.#focusServiceAreaAfterCreate(canonicalServiceArea, options);
362
+ })
363
+ .catch(() => {
364
+ this.#includeServiceArea(serviceArea);
365
+ this.#focusServiceAreaAfterCreate(serviceArea, options);
366
+ });
367
+ }
368
+
369
+ #reloadThenFocusZone(serviceArea, zone, options = {}) {
370
+ this.#reloadServiceAreas()
371
+ .then((serviceAreas) => {
372
+ const canonicalServiceArea = this.#findResource(serviceAreas, serviceArea) ?? serviceArea;
373
+ const canonicalZone = this.#findResource(canonicalServiceArea?.zones, zone) ?? zone;
374
+ this.#focusZoneAfterCreate(canonicalZone, options);
375
+ })
376
+ .catch(() => {
377
+ this.#includeZone(serviceArea, zone);
378
+ this.#focusZoneAfterCreate(zone, options);
379
+ });
380
+ }
381
+
382
+ #reloadServiceAreas() {
383
+ return Promise.resolve(this.serviceAreaActions.loadAll.perform()).then(() => this.serviceAreaActions.serviceAreas ?? []);
384
+ }
385
+
386
+ #findResource(resources, resource) {
387
+ if (!resource || !resources) return null;
388
+
389
+ const resourceId = resource.id ?? resource.public_id;
390
+ const publicId = resource.public_id ?? resource.id;
391
+
392
+ return (
393
+ Array.from(resources).find((candidate) => {
394
+ return candidate?.id === resourceId || candidate?.id === publicId || candidate?.public_id === publicId || candidate?.public_id === resourceId;
395
+ }) ?? null
396
+ );
397
+ }
398
+
399
+ #focusServiceAreaAfterCreate(serviceArea, options = {}) {
400
+ this.#focusWhenReady([serviceArea], () => this.focusServiceArea(serviceArea), options);
401
+ }
402
+
403
+ #focusZoneAfterCreate(zone, options = {}) {
404
+ this.#focusWhenReady([zone], () => this.focusZone(zone), options);
405
+ }
406
+
407
+ #focusWhenReady(models, focus, { attempts = 60, delay = 100, draftLayer = null } = {}) {
408
+ const overlays = models
409
+ .map((model) => {
410
+ const overlay = this.mapManager.getOverlay(model?.id);
411
+ if (overlay) {
412
+ return overlay;
413
+ }
414
+
415
+ return model?.leafletLayer === draftLayer ? null : model?.leafletLayer;
416
+ })
417
+ .filter(Boolean);
418
+ const allRegistered = overlays.length === models.length;
419
+
420
+ if (allRegistered || attempts <= 0) {
421
+ focus();
422
+ if (draftLayer && !draftLayer.__removedAfterCreate && !overlays.includes(draftLayer)) {
423
+ draftLayer.__removedAfterCreate = true;
424
+ this.mapManager.removeLayer(draftLayer);
425
+ }
426
+ return;
427
+ }
428
+
429
+ later(this, () => this.#focusWhenReady(models, focus, { attempts: attempts - 1, delay, draftLayer }), delay);
300
430
  }
301
431
 
302
432
  #showServiceArea(serviceArea, { pin = false } = {}) {
303
433
  [serviceArea, ...(serviceArea.zones ?? [])].forEach((model) => {
304
- const layer = this.mapManager.getOverlay(model.id) ?? model?.leafletLayer ?? null;
434
+ const overlay = this.mapManager.getOverlay(model.id);
435
+ const layer = overlay ?? model?.leafletLayer ?? null;
305
436
  this.#setLayerPinned(layer, pin);
306
- this.mapManager.showPolygon(model.id);
437
+ if (overlay) {
438
+ this.mapManager.showPolygon(model.id);
439
+ } else if (layer) {
440
+ this.mapManager.showLayer?.(layer);
441
+ }
307
442
  });
308
443
  }
309
444
 
310
445
  #hideServiceArea(serviceArea) {
311
446
  [serviceArea, ...(serviceArea.zones ?? [])].forEach((model) => {
312
- this.#setLayerPinned(model?.leafletLayer ?? this.mapManager.getOverlay(model.id), false);
313
- this.mapManager.hidePolygon(model.id);
447
+ const overlay = this.mapManager.getOverlay(model.id);
448
+ const layer = model?.leafletLayer;
449
+
450
+ this.#setLayerPinned(layer ?? overlay, false);
451
+ if (overlay) {
452
+ this.mapManager.hidePolygon(model.id);
453
+ }
454
+
455
+ if (layer && layer !== overlay) {
456
+ this.mapManager.hideLayer?.(layer);
457
+ }
314
458
  });
315
459
  }
316
460
 
@@ -85,6 +85,33 @@ export default class LeafletContextmenuManagerService extends Service {
85
85
  return null;
86
86
  }
87
87
 
88
+ /**
89
+ * Remove a registered context menu and detach it from its layer when possible.
90
+ *
91
+ * @param {string} registryName - The public context menu registry name.
92
+ * @returns {Object|null} The removed registry, or null when none existed.
93
+ */
94
+ removeContextMenu(registryName) {
95
+ const internalRegistryName = this.createInternalRegistryName(registryName);
96
+ const registry = this.contextMenuRegistry[internalRegistryName];
97
+
98
+ if (!registry) {
99
+ return null;
100
+ }
101
+
102
+ try {
103
+ registry.contextmenuApi?.removeAllItems?.();
104
+ registry.layer?.unbindContextMenu?.();
105
+ } catch (error) {
106
+ // Context menus are disposable UI state; stale layer cleanup should not block record deletion.
107
+ }
108
+
109
+ delete this.contextMenuRegistry[internalRegistryName];
110
+ this.contextMenuRegistry = { ...this.contextMenuRegistry };
111
+
112
+ return registry;
113
+ }
114
+
88
115
  /**
89
116
  * Creates an internal registry name by camelizing the provided registry name and appending "ConextmenuRegistry" to it.
90
117
  *