@fleetbase/fleetops-engine 0.6.43 → 0.6.45

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 (82) hide show
  1. package/addon/components/entity/form.hbs +24 -0
  2. package/addon/components/entity/form.js +30 -0
  3. package/addon/components/leaflet-draw-control.js +1 -1
  4. package/addon/components/map/drawer/geofence-event-listing.hbs +123 -0
  5. package/addon/components/map/drawer/geofence-event-listing.js +103 -0
  6. package/addon/components/map/drawer.js +1 -0
  7. package/addon/components/map/leaflet-live-map.js +83 -0
  8. package/addon/components/modals/orchestrator-import.js +2 -78
  9. package/addon/components/order/form/service-rate.hbs +1 -1
  10. package/addon/components/service-area/form.hbs +59 -0
  11. package/addon/components/service-rate/details.hbs +6 -6
  12. package/addon/components/service-rate/form.hbs +28 -15
  13. package/addon/components/zone/form.hbs +60 -1
  14. package/addon/controllers/operations/orders/index/details/virtual.js +5 -0
  15. package/addon/controllers/operations/orders/index/details.js +27 -2
  16. package/addon/controllers/operations/service-rates/index/edit.js +1 -0
  17. package/addon/controllers/operations/service-rates/index/new.js +1 -0
  18. package/addon/helpers/f-to-int.js +6 -0
  19. package/addon/routes/application.js +3 -3
  20. package/addon/routes/operations/orders/index/details/virtual.js +1 -0
  21. package/addon/routes/operations/orders/index/details.js +19 -3
  22. package/addon/services/equipment-actions.js +4 -6
  23. package/addon/services/geofence-event-bus.js +223 -0
  24. package/addon/services/leaflet-map-manager.js +4 -0
  25. package/addon/services/maintenance-actions.js +4 -6
  26. package/addon/services/part-actions.js +4 -6
  27. package/addon/services/service-rate-actions.js +8 -3
  28. package/addon/templates/operations/orders/index/details/virtual.hbs +7 -1
  29. package/addon/utils/fleet-ops-options.js +33 -0
  30. package/addon/utils/serialize-payload.js +1 -1
  31. package/app/components/map/drawer/geofence-event-listing.js +1 -0
  32. package/app/controllers/operations/orders/index/details/virtual.js +1 -0
  33. package/app/helpers/f-to-int.js +1 -0
  34. package/app/services/geofence-event-bus.js +1 -0
  35. package/composer.json +1 -1
  36. package/extension.json +1 -1
  37. package/package.json +2 -2
  38. package/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php +48 -0
  39. package/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php +38 -0
  40. package/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php +62 -0
  41. package/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php +66 -0
  42. package/server/migrations/2026_04_22_000001_add_vehicle_support_to_geofence_events_log.php +31 -0
  43. package/server/migrations/2026_04_22_000002_create_vehicle_geofence_states_table.php +39 -0
  44. package/server/src/Console/Commands/SimulateGeofenceEvents.php +278 -0
  45. package/server/src/Events/GeofenceDwelled.php +208 -0
  46. package/server/src/Events/GeofenceEntered.php +207 -0
  47. package/server/src/Events/GeofenceExited.php +213 -0
  48. package/server/src/Http/Controllers/Api/v1/DriverController.php +100 -0
  49. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +263 -0
  50. package/server/src/Http/Controllers/Api/v1/OrderController.php +13 -8
  51. package/server/src/Http/Controllers/Api/v1/PurchaseRateController.php +11 -3
  52. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  53. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +4 -0
  54. package/server/src/Http/Controllers/Api/v1/ServiceRateController.php +6 -1
  55. package/server/src/Http/Controllers/Api/v1/VehicleController.php +88 -0
  56. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -2
  57. package/server/src/Http/Controllers/Internal/v1/GeofenceController.php +7 -0
  58. package/server/src/Http/Requests/CreateServiceAreaRequest.php +8 -4
  59. package/server/src/Http/Requests/CreateServiceRateRequest.php +2 -2
  60. package/server/src/Http/Requests/CreateZoneRequest.php +8 -4
  61. package/server/src/Http/Resources/v1/Order.php +0 -1
  62. package/server/src/Http/Resources/v1/ServiceQuote.php +2 -2
  63. package/server/src/Jobs/CheckGeofenceDwell.php +139 -0
  64. package/server/src/Listeners/HandleGeofenceDwelled.php +56 -0
  65. package/server/src/Listeners/HandleGeofenceEntered.php +187 -0
  66. package/server/src/Listeners/HandleGeofenceExited.php +56 -0
  67. package/server/src/Models/Entity.php +7 -0
  68. package/server/src/Models/GeofenceEventLog.php +155 -0
  69. package/server/src/Models/Order.php +39 -7
  70. package/server/src/Models/ServiceArea.php +21 -2
  71. package/server/src/Models/ServiceQuote.php +3 -1
  72. package/server/src/Models/ServiceRate.php +122 -30
  73. package/server/src/Models/Zone.php +20 -2
  74. package/server/src/Notifications/DriverArrivedAtGeofence.php +86 -0
  75. package/server/src/Observers/PurchaseRateObserver.php +3 -5
  76. package/server/src/Providers/EventServiceProvider.php +19 -0
  77. package/server/src/Providers/FleetOpsServiceProvider.php +10 -0
  78. package/server/src/Rules/ComputableAlgo.php +1 -3
  79. package/server/src/Support/Algo.php +87 -1
  80. package/server/src/Support/GeofenceIntersectionService.php +206 -0
  81. package/server/src/Support/Utils.php +35 -0
  82. package/server/src/routes.php +16 -0
@@ -176,7 +176,7 @@
176
176
  </tr>
177
177
  </thead>
178
178
  <tbody>
179
- {{#each @resource.rate_fees as |rateFee|}}
179
+ {{#each @resource.rateFees as |rateFee|}}
180
180
  <tr>
181
181
  <td>
182
182
  <Input
@@ -252,25 +252,38 @@
252
252
  {{else if @resource.isAlgorithm}}
253
253
  <ContentPanel @title={{t "service-rate.fields.custom-algorithm-title"}} @open={{true}} @wrapperClass="bordered-top">
254
254
  <InfoBlock class="mb-5">
255
- {{t "service-rate.fields.custom-algorithm-info-message"}}
256
- {{t "service-rate.fields.custom-algorithm-info-second-message"}}
255
+ Define a custom formula for this service rate using variables wrapped in a single pair of curly braces.
257
256
 
258
257
  <div class="block my-4 break-text">
259
258
  <ul class="list-disc space-y-2 pl-16">
260
- <li class="leading-5"><i>{{t "service-rate.fields.distance-message"}}</i>
261
- {{t "service-rate.fields.distance-continue-message"}}</li>
262
- <li class="leading-5"><i>{{t "service-rate.fields.time-message"}}</i>
263
- {{t "service-rate.fields.time-continue-message"}}</li>
259
+ <li class="leading-5"><code>&#123;distance&#125;</code>, <code>&#123;distance_m&#125;</code>, <code>&#123;distance_km&#125;</code>, <code>&#123;distance_mi&#125;</code>: route distance in meters, kilometers, or miles.</li>
260
+ <li class="leading-5"><code>&#123;time&#125;</code>, <code>&#123;time_s&#125;</code>, <code>&#123;time_min&#125;</code>: route time in seconds or minutes.</li>
261
+ <li class="leading-5"><code>&#123;stops&#125;</code>: total service stops including pickup, dropoff, and intermediate waypoints.</li>
262
+ <li class="leading-5"><code>&#123;waypoints&#125;</code>: intermediate waypoint count only.</li>
263
+ <li class="leading-5"><code>&#123;parcels&#125;</code>, <code>&#123;entities&#125;</code>: parcel count and total payload entity count.</li>
264
+ <li class="leading-5"><code>&#123;base_fee&#125;</code>: the configured base fee for this service rate.</li>
265
+ <li class="leading-5">Supported functions: <code>max(a,b)</code>, <code>min(a,b)</code>, <code>ceil(x)</code>, <code>floor(x)</code>, <code>round(x)</code>.</li>
264
266
  </ul>
265
267
  </div>
266
268
 
267
- <div>
268
- <h4 class="mb-1 text-sm font-semibold">{{t "service-rate.fields.example"}}</h4>
269
- <div class="mb-3 text-inherit">
270
- {{t "service-rate.fields.example-message"}}
271
- {{t "service-rate.fields.example-second-message"}}
269
+ <div class="space-y-3">
270
+ <div>
271
+ <h4 class="mb-1 text-sm font-semibold">{{t "service-rate.fields.example"}} 1</h4>
272
+ <div class="mb-2 text-inherit">Charge by distance in miles after the first 15 miles, plus the base fee.</div>
273
+ <code class="text-xs font-mono">max(&#123;distance_mi&#125; - 15, 0) * 5 + &#123;base_fee&#125;</code>
274
+ </div>
275
+
276
+ <div>
277
+ <h4 class="mb-1 text-sm font-semibold">{{t "service-rate.fields.example"}} 2</h4>
278
+ <div class="mb-2 text-inherit">Charge extra for stops after the first 2 service stops.</div>
279
+ <code class="text-xs font-mono">max(&#123;stops&#125; - 2, 0) * 10 + &#123;base_fee&#125;</code>
280
+ </div>
281
+
282
+ <div>
283
+ <h4 class="mb-1 text-sm font-semibold">{{t "service-rate.fields.example"}} 3</h4>
284
+ <div class="mb-2 text-inherit">Combine waypoint, parcel, and distance surcharges in one formula.</div>
285
+ <code class="text-xs font-mono">max(&#123;waypoints&#125;, 0) * 15 + max(&#123;parcels&#125; - 3, 0) * 2 + max(&#123;distance_km&#125; - 25, 0) * 0.5 + &#123;base_fee&#125;</code>
272
286
  </div>
273
- <code class="text-xs font-mono">(( {distance} / 50 ) * .05 ) + (( {time} / 60 ) * .01)</code>
274
287
  </div>
275
288
  </InfoBlock>
276
289
 
@@ -287,7 +300,7 @@
287
300
  {{else if @resource.isParcelService}}
288
301
  <ContentPanel @title={{t "service-rate.fields.percel-fee-title"}} @open={{true}} @wrapperClass="bordered-top">
289
302
  <div class="space-y-2">
290
- {{#each @resource.parcel_fees as |parcelFee|}}
303
+ {{#each @resource.parcelFees as |parcelFee|}}
291
304
  <div class="dark:text-gray-100">
292
305
  <div class="grid grid-cols-7">
293
306
  <div class="flex flex-col items-start justify-start pt-3">
@@ -541,4 +554,4 @@
541
554
  </InputGroup>
542
555
  {{/if}}
543
556
  </ContentPanel>
544
- </div>
557
+ </div>
@@ -30,4 +30,63 @@
30
30
  </InputGroup>
31
31
  </div>
32
32
  </ContentPanel>
33
- </div>
33
+
34
+ <ContentPanel @title="Geofence Triggers" @open={{true}} @wrapperClass="bordered-top">
35
+ <div class="space-y-4">
36
+ <p class="text-sm text-gray-500 dark:text-gray-400">
37
+ Configure when this zone fires active geofence events. Entry and exit events are dispatched to webhooks,
38
+ WebSocket channels, and the notification system whenever a driver crosses the zone boundary.
39
+ </p>
40
+
41
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
42
+ {{! Entry trigger toggle }}
43
+ <div class="flex items-start space-x-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700">
44
+ <div class="flex-shrink-0 mt-0.5">
45
+ <Toggle
46
+ @isToggled={{@resource.trigger_on_entry}}
47
+ @onToggle={{fn (mut @resource.trigger_on_entry)}}
48
+ />
49
+ </div>
50
+ <div>
51
+ <p class="text-sm font-medium text-gray-900 dark:text-white">Trigger on Entry</p>
52
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
53
+ Fire a <code class="font-mono">geofence.entered</code> event when a driver enters this zone.
54
+ </p>
55
+ </div>
56
+ </div>
57
+
58
+ {{! Exit trigger toggle }}
59
+ <div class="flex items-start space-x-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700">
60
+ <div class="flex-shrink-0 mt-0.5">
61
+ <Toggle
62
+ @isToggled={{@resource.trigger_on_exit}}
63
+ @onToggle={{fn (mut @resource.trigger_on_exit)}}
64
+ />
65
+ </div>
66
+ <div>
67
+ <p class="text-sm font-medium text-gray-900 dark:text-white">Trigger on Exit</p>
68
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
69
+ Fire a <code class="font-mono">geofence.exited</code> event when a driver leaves this zone.
70
+ </p>
71
+ </div>
72
+ </div>
73
+ </div>
74
+
75
+ <InputGroup
76
+ @name="Dwell Threshold (minutes)"
77
+ @value={{@resource.dwell_threshold_minutes}}
78
+ @type="number"
79
+ @placeholder="e.g. 10"
80
+ @helpText="Fire a geofence.dwelled event after a driver has been inside this zone for this many minutes. Leave blank to disable dwell tracking."
81
+ />
82
+
83
+ <InputGroup
84
+ @name="Speed Limit (km/h)"
85
+ @value={{@resource.speed_limit_kmh}}
86
+ @type="number"
87
+ @placeholder="e.g. 50"
88
+ @helpText="Optional speed limit enforced within this zone. Leave blank for no limit."
89
+ />
90
+ </div>
91
+ </ContentPanel>
92
+ </div>
@@ -0,0 +1,5 @@
1
+ import Controller, { inject as controller } from '@ember/controller';
2
+
3
+ export default class OperationsOrdersIndexDetailsVirtualController extends Controller {
4
+ @controller('operations.orders.index.details') parent;
5
+ }
@@ -16,6 +16,7 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
16
16
  @service universe;
17
17
  @service sidebar;
18
18
  @tracked routingControl;
19
+ @tracked routingCompleted = false;
19
20
 
20
21
  get tabs() {
21
22
  const registeredTabs = this.menuService.getMenuItems('fleet-ops:component:order:details');
@@ -109,7 +110,19 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
109
110
  @action async setup() {
110
111
  // Change to map layout and display order route
111
112
  this.index.changeLayout('map');
112
- this.routingControl = await this.leafletMapManager.addRoutingControl(this.model.routeWaypoints);
113
+ this.routingCompleted = false;
114
+ this.routingControl = await this.leafletMapManager.addRoutingControl(this.model.routeWaypoints, {
115
+ onRouteFound: () => {
116
+ this.routingCompleted = true;
117
+ },
118
+ onRoutingError: () => {
119
+ this.routingCompleted = true;
120
+ },
121
+ });
122
+
123
+ if (!this.routingControl) {
124
+ this.routingCompleted = true;
125
+ }
113
126
 
114
127
  // Hide sidebar
115
128
  this.sidebar.hideNow();
@@ -125,7 +138,19 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
125
138
  async (_msg, { reloadable }) => {
126
139
  if (reloadable) {
127
140
  await this.hostRouter.refresh();
128
- this.leafletMapManager.replaceRoutingControl(this.model.routeWaypoints, this.routingControl);
141
+ this.routingCompleted = false;
142
+ this.routingControl = await this.leafletMapManager.replaceRoutingControl(this.model.routeWaypoints, this.routingControl, {
143
+ onRouteFound: () => {
144
+ this.routingCompleted = true;
145
+ },
146
+ onRoutingError: () => {
147
+ this.routingCompleted = true;
148
+ },
149
+ });
150
+
151
+ if (!this.routingControl) {
152
+ this.routingCompleted = true;
153
+ }
129
154
  }
130
155
  },
131
156
  { debounceMs: 250 }
@@ -22,6 +22,7 @@ export default class OperationsServiceRatesIndexEditController extends Controlle
22
22
  @task *save(serviceRate) {
23
23
  try {
24
24
  yield serviceRate.save();
25
+ yield serviceRate.reload();
25
26
  this.events.trackResourceUpdated(serviceRate);
26
27
  this.overlay?.close();
27
28
 
@@ -16,6 +16,7 @@ export default class OperationsServiceRatesIndexNewController extends Controller
16
16
  @task *save(serviceRate) {
17
17
  try {
18
18
  yield serviceRate.save();
19
+ yield serviceRate.reload();
19
20
  this.events.trackResourceCreated(serviceRate);
20
21
  this.overlay?.close();
21
22
 
@@ -0,0 +1,6 @@
1
+ import { helper } from '@ember/component/helper';
2
+ import numbersOnly from '@fleetbase/ember-core/utils/numbers-only';
3
+
4
+ export default helper(function fToInt([value]) {
5
+ return parseInt(numbersOnly(value));
6
+ });
@@ -20,14 +20,14 @@ export default class ApplicationRoute extends Route {
20
20
  });
21
21
  }
22
22
 
23
- beforeModel() {
23
+ async beforeModel() {
24
24
  if (this.abilities.cannot('fleet-ops see extension')) {
25
25
  this.notifications.warning(this.intl.t('common.unauthorized-access'));
26
26
  return this.hostRouter.transitionTo('console');
27
27
  }
28
28
 
29
- this.location.getUserLocation();
30
- this.#loadRoutingSettings();
29
+ await this.location.getUserLocation();
30
+ await this.#loadRoutingSettings();
31
31
  }
32
32
 
33
33
  async #loadRoutingSettings() {
@@ -19,5 +19,6 @@ export default class OperationsOrdersIndexDetailsVirtualRoute extends Route {
19
19
  setupController(controller) {
20
20
  super.setupController(...arguments);
21
21
  controller.resource = this.modelFor('operations.orders.index.details');
22
+ controller.detailsController = this.controllerFor('operations.orders.index.details');
22
23
  }
23
24
  }
@@ -15,9 +15,12 @@ export default class OperationsOrdersIndexDetailsRoute extends Route {
15
15
  @action willTransition(transition) {
16
16
  const fromName = transition.from?.name;
17
17
  const toName = transition.to?.name;
18
+ const orderDetailsRoute = 'console.fleet-ops.operations.orders.index.details';
18
19
 
19
- // only cleanup when actually leaving this route (not intra-route changes)
20
- if (fromName && fromName !== toName) {
20
+ // only cleanup when leaving the order details route tree entirely
21
+ const isLeavingOrderDetails = fromName?.startsWith(orderDetailsRoute) && !toName?.startsWith(orderDetailsRoute);
22
+
23
+ if (isLeavingOrderDetails) {
21
24
  const controller = this.controllerFor('operations.orders.index.details');
22
25
  const rc = controller.routingControl;
23
26
 
@@ -55,7 +58,20 @@ export default class OperationsOrdersIndexDetailsRoute extends Route {
55
58
  return this.store.queryRecord('order', {
56
59
  public_id,
57
60
  single: true,
58
- with: ['payload', 'driverAssigned', 'orderConfig', 'customer', 'facilitator', 'trackingStatuses', 'trackingNumber', 'purchaseRate', 'comments', 'files'],
61
+ with: [
62
+ 'payload',
63
+ 'driverAssigned',
64
+ 'orderConfig',
65
+ 'customer',
66
+ 'facilitator',
67
+ 'trackingStatuses',
68
+ 'trackingNumber',
69
+ 'purchaseRate',
70
+ 'purchaseRate.serviceQuote',
71
+ 'purchaseRate.serviceQuote.items',
72
+ 'comments',
73
+ 'files',
74
+ ],
59
75
  });
60
76
  }
61
77
 
@@ -1,7 +1,9 @@
1
- import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
1
+ import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
2
2
 
3
3
  export default class EquipmentActionsService extends ResourceActionService {
4
- @service currentUser;
4
+ get defaultCurrency() {
5
+ return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
6
+ }
5
7
 
6
8
  constructor() {
7
9
  super(...arguments);
@@ -12,10 +14,6 @@ export default class EquipmentActionsService extends ResourceActionService {
12
14
  });
13
15
  }
14
16
 
15
- get defaultCurrency() {
16
- return this.currentUser?.company?.currency || 'USD';
17
- }
18
-
19
17
  transition = {
20
18
  view: (equipment) => this.transitionTo('maintenance.equipment.index.details', equipment),
21
19
  edit: (equipment) => this.transitionTo('maintenance.equipment.index.edit', equipment),
@@ -0,0 +1,223 @@
1
+ import Service from '@ember/service';
2
+ import { inject as service } from '@ember/service';
3
+ import { tracked } from '@glimmer/tracking';
4
+ import { action } from '@ember/object';
5
+ import { debug } from '@ember/debug';
6
+
7
+ /**
8
+ * GeofenceEventBus
9
+ *
10
+ * A singleton service that subscribes to the company-level geofence WebSocket
11
+ * channel and maintains a live feed of geofence events (entered, exited, dwelled).
12
+ *
13
+ * Other components (e.g. the live map, the geofence events panel) inject this
14
+ * service and observe `this.events` to react to incoming events.
15
+ *
16
+ * Usage:
17
+ * @service geofenceEventBus;
18
+ *
19
+ * // In a template: {{#each this.geofenceEventBus.events as |event|}} ...
20
+ * // In JS: this.geofenceEventBus.on('geofence.entered', this.handleEntry);
21
+ */
22
+ export default class GeofenceEventBusService extends Service {
23
+ @service socket;
24
+ @service currentUser;
25
+ @service universe;
26
+
27
+ /**
28
+ * Live event feed — most recent events at the front.
29
+ * Capped at MAX_EVENTS to prevent unbounded memory growth.
30
+ *
31
+ * @type {Array}
32
+ */
33
+ @tracked events = [];
34
+
35
+ /**
36
+ * Whether the service has successfully subscribed to the socket channel.
37
+ *
38
+ * @type {boolean}
39
+ */
40
+ @tracked isSubscribed = false;
41
+
42
+ /**
43
+ * Maximum number of events to retain in the live feed.
44
+ */
45
+ MAX_EVENTS = 100;
46
+
47
+ /**
48
+ * The geofence event types to listen for.
49
+ */
50
+ GEOFENCE_EVENTS = ['geofence.entered', 'geofence.exited', 'geofence.dwelled'];
51
+
52
+ /**
53
+ * Internal map of event type → array of handler functions.
54
+ * Supports the simple pub/sub API.
55
+ *
56
+ * @type {Map}
57
+ */
58
+ #handlers = new Map();
59
+
60
+ /**
61
+ * The active socket channel subscription.
62
+ */
63
+ #channel = null;
64
+
65
+ /**
66
+ * Subscribe to the company geofence channel.
67
+ * Should be called once after the user session is established.
68
+ *
69
+ * @param {string} companyUuid - The company UUID to subscribe to
70
+ */
71
+ @action
72
+ async subscribe(companyUuid) {
73
+ if (this.isSubscribed || !companyUuid) {
74
+ return;
75
+ }
76
+
77
+ try {
78
+ const socketInstance = this.socket.instance();
79
+ const channelId = `company.${companyUuid}`;
80
+
81
+ this.#channel = socketInstance.subscribe(channelId);
82
+ if (this.#channel.state !== 'subscribed') {
83
+ await this.#channel.subscribe();
84
+ }
85
+
86
+ this.isSubscribed = true;
87
+ debug(`[GeofenceEventBus] Subscribed to channel: ${channelId}`);
88
+
89
+ // Start consuming events from the channel
90
+ (async () => {
91
+ for await (const output of this.#channel) {
92
+ const { event, data } = output;
93
+
94
+ if (this.GEOFENCE_EVENTS.includes(event)) {
95
+ this.#handleIncomingEvent(event, data);
96
+ }
97
+ }
98
+ })();
99
+ } catch (error) {
100
+ debug(`[GeofenceEventBus] Failed to subscribe: ${error.message}`);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Unsubscribe from the geofence channel and clear state.
106
+ * Called on user logout or session end.
107
+ */
108
+ @action
109
+ unsubscribe() {
110
+ if (this.#channel) {
111
+ this.#channel.close();
112
+ this.#channel = null;
113
+ }
114
+ this.isSubscribed = false;
115
+ this.events = [];
116
+ this.#handlers.clear();
117
+ debug('[GeofenceEventBus] Unsubscribed.');
118
+ }
119
+
120
+ /**
121
+ * Register a handler function for a specific geofence event type.
122
+ *
123
+ * @param {string} eventType - 'geofence.entered' | 'geofence.exited' | 'geofence.dwelled'
124
+ * @param {Function} handler - Called with the normalised event object
125
+ */
126
+ on(eventType, handler) {
127
+ if (!this.#handlers.has(eventType)) {
128
+ this.#handlers.set(eventType, []);
129
+ }
130
+ this.#handlers.get(eventType).push(handler);
131
+ }
132
+
133
+ /**
134
+ * Unregister a previously registered handler.
135
+ *
136
+ * @param {string} eventType
137
+ * @param {Function} handler
138
+ */
139
+ off(eventType, handler) {
140
+ if (!this.#handlers.has(eventType)) {
141
+ return;
142
+ }
143
+ const handlers = this.#handlers.get(eventType).filter((h) => h !== handler);
144
+ this.#handlers.set(eventType, handlers);
145
+ }
146
+
147
+ /**
148
+ * Clear the live event feed.
149
+ */
150
+ @action
151
+ clearFeed() {
152
+ this.events = [];
153
+ }
154
+
155
+ @action
156
+ seedEvents(events = []) {
157
+ this.events = Array.isArray(events) ? events.slice(0, this.MAX_EVENTS).map((event) => ({ ...event, isNew: false })) : [];
158
+ }
159
+
160
+ /**
161
+ * Process an incoming geofence event from the WebSocket channel.
162
+ *
163
+ * @param {string} eventType
164
+ * @param {Object} data
165
+ */
166
+ #handleIncomingEvent(eventType, data) {
167
+ const normalised = this.normalizeEvent(eventType, data);
168
+
169
+ // Prepend and cap
170
+ this.events = [normalised, ...this.events].slice(0, this.MAX_EVENTS);
171
+
172
+ setTimeout(() => {
173
+ this.events = this.events.map((event) => (event.id === normalised.id ? { ...event, isNew: false } : event));
174
+ }, 3000);
175
+
176
+ debug(`[GeofenceEventBus] ${eventType} — driver: ${normalised.driverName}, geofence: ${normalised.geofenceName}`);
177
+
178
+ // Notify registered handlers
179
+ const handlers = this.#handlers.get(eventType) ?? [];
180
+ handlers.forEach((handler) => {
181
+ try {
182
+ handler(normalised);
183
+ } catch (e) {
184
+ debug(`[GeofenceEventBus] Handler error for ${eventType}: ${e.message}`);
185
+ }
186
+ });
187
+
188
+ // Also emit on the universe bus so any component can react
189
+ this.universe.trigger(`fleet-ops.geofence.${eventType.replace('geofence.', '')}`, normalised);
190
+ }
191
+
192
+ /**
193
+ * Normalise a raw WebSocket payload into a consistent display object.
194
+ *
195
+ * @param {string} eventType
196
+ * @param {Object} raw
197
+ * @returns {Object}
198
+ */
199
+ normalizeEvent(eventType, raw) {
200
+ const subject = raw.subject ?? null;
201
+ const subjectType = subject?.type ?? (raw.vehicle && !raw.driver ? 'vehicle' : 'driver');
202
+ const subjectName = subject?.name ?? raw.driver?.name ?? raw.vehicle?.name ?? raw.vehicle?.plate ?? 'Unknown Subject';
203
+ return {
204
+ id: raw.id ?? raw.uuid ?? `${Date.now()}-${Math.random()}`,
205
+ eventType,
206
+ occurredAt: raw.occurred_at ?? new Date().toISOString(),
207
+ subjectType,
208
+ subjectName,
209
+ driverName: raw.driver?.name ?? null,
210
+ driverUuid: raw.driver?.uuid ?? null,
211
+ vehicleName: raw.vehicle?.name ?? null,
212
+ vehiclePlate: raw.vehicle?.plate ?? null,
213
+ geofenceName: raw.geofence?.name ?? 'Unknown Geofence',
214
+ geofenceUuid: raw.geofence?.uuid ?? null,
215
+ geofenceType: raw.geofence?.type ?? 'zone',
216
+ orderPublicId: raw.order?.id ?? null,
217
+ latitude: raw.location?.latitude ?? null,
218
+ longitude: raw.location?.longitude ?? null,
219
+ dwellMinutes: raw.dwell_duration_minutes ?? null,
220
+ isNew: true,
221
+ };
222
+ }
223
+ }
@@ -270,6 +270,10 @@ export default class LeafletMapManagerService extends Service {
270
270
  this.route = routes[0];
271
271
  });
272
272
 
273
+ routingControl.on('routingerror', (event) => {
274
+ options?.onRoutingError?.(event);
275
+ });
276
+
273
277
  this.positionWaypoints(waypoints);
274
278
 
275
279
  return routingControl;
@@ -1,7 +1,9 @@
1
- import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
1
+ import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
2
2
 
3
3
  export default class MaintenanceActionsService extends ResourceActionService {
4
- @service currentUser;
4
+ get defaultCurrency() {
5
+ return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
6
+ }
5
7
 
6
8
  constructor() {
7
9
  super(...arguments);
@@ -12,10 +14,6 @@ export default class MaintenanceActionsService extends ResourceActionService {
12
14
  });
13
15
  }
14
16
 
15
- get defaultCurrency() {
16
- return this.currentUser?.company?.currency || 'USD';
17
- }
18
-
19
17
  transition = {
20
18
  view: (maintenance) => this.transitionTo('maintenance.maintenances.index.details', maintenance),
21
19
  edit: (maintenance) => this.transitionTo('maintenance.maintenances.index.edit', maintenance),
@@ -1,7 +1,9 @@
1
- import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
1
+ import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
2
2
 
3
3
  export default class PartActionsService extends ResourceActionService {
4
- @service currentUser;
4
+ get defaultCurrency() {
5
+ return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
6
+ }
5
7
 
6
8
  constructor() {
7
9
  super(...arguments);
@@ -12,10 +14,6 @@ export default class PartActionsService extends ResourceActionService {
12
14
  });
13
15
  }
14
16
 
15
- get defaultCurrency() {
16
- return this.currentUser?.company?.currency || 'USD';
17
- }
18
-
19
17
  transition = {
20
18
  view: (part) => this.transitionTo('maintenance.parts.index.details', part),
21
19
  edit: (part) => this.transitionTo('maintenance.parts.index.edit', part),
@@ -6,13 +6,17 @@ import serializePayload from '../utils/serialize-payload';
6
6
  export default class ServiceRateActionsService extends ResourceActionService {
7
7
  modelNamePath = 'service_name';
8
8
 
9
+ get defaultCurrency() {
10
+ return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
11
+ }
12
+
9
13
  constructor() {
10
14
  super(...arguments);
11
15
  this.initialize('service-rate', {
12
16
  defaultAttributes: {
13
17
  rate_calculation_method: 'per_meter',
14
18
  per_meter_unit: 'm',
15
- currency: this.currentUser.currency,
19
+ currency: this.defaultCurrency,
16
20
  parcel_fees: this.#getDefaultParcelFees(),
17
21
  },
18
22
  });
@@ -118,12 +122,13 @@ export default class ServiceRateActionsService extends ResourceActionService {
118
122
 
119
123
  const hasFacilitator = !isNone(order.facilitator);
120
124
  const facilitatorServiceType = order.facilitator?.get('service_types.firstObject.key') ?? order.type;
125
+ const routeSummary = order.route?.summary ?? order.route?.details?.summary ?? {};
121
126
 
122
127
  try {
123
128
  const serviceQuotes = yield this.fetch.post('service-quotes/preliminary', {
124
129
  payload: serializePayload(order.payload),
125
- distance: order.route.summary?.totalDistance,
126
- time: order.route.summary?.totalTime,
130
+ distance: routeSummary.totalDistance,
131
+ time: routeSummary.totalTime,
127
132
  service_type: hasFacilitator ? facilitatorServiceType : order.type,
128
133
  facilitator: order.facilitator?.public_id,
129
134
  scheduled_at: order.scheduled_at,
@@ -1 +1,7 @@
1
- {{component (lazy-engine-component @model.component) resource=this.resource order=this.resource params=@model.componentParams}}
1
+ {{#if this.parent.routingCompleted}}
2
+ {{component (lazy-engine-component @model.component) resource=this.resource order=this.resource params=@model.componentParams}}
3
+ {{else}}
4
+ <div class="flex items-center justify-center py-12">
5
+ <Spinner />
6
+ </div>
7
+ {{/if}}