@fleetbase/fleetops-engine 0.6.49 → 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 (142) 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/map/google-live-map.js +6 -2
  7. package/addon/components/map/leaflet-live-map.hbs +48 -14
  8. package/addon/components/map/leaflet-live-map.js +184 -20
  9. package/addon/components/modals/order-change-destination.hbs +46 -0
  10. package/addon/components/modals/order-change-destination.js +106 -0
  11. package/addon/components/modals/stop-activity.hbs +28 -0
  12. package/addon/components/modals/stop-activity.js +52 -0
  13. package/addon/components/modals/update-order-activity.hbs +45 -3
  14. package/addon/components/modals/update-order-activity.js +143 -5
  15. package/addon/components/orchestrator/resource-panel.hbs +0 -10
  16. package/addon/components/orchestrator/resource-panel.js +2 -4
  17. package/addon/components/order/activity-list.hbs +2 -2
  18. package/addon/components/order/activity-timeline.hbs +31 -29
  19. package/addon/components/order/details/activity.hbs +3 -3
  20. package/addon/components/order/details/activity.js +11 -0
  21. package/addon/components/order/details/documents.js +2 -0
  22. package/addon/components/order/details/purchase-rate.hbs +2 -2
  23. package/addon/components/order/details/route.hbs +11 -3
  24. package/addon/components/order/details/route.js +91 -14
  25. package/addon/components/order/details/tracking.hbs +101 -72
  26. package/addon/components/order/details/tracking.js +149 -2
  27. package/addon/components/order/form/details.hbs +3 -3
  28. package/addon/components/order/form/documents.js +11 -4
  29. package/addon/components/order/form/route.hbs +108 -104
  30. package/addon/components/order/form/route.js +37 -1
  31. package/addon/components/order/form/service-rate.hbs +2 -2
  32. package/addon/components/order-config-manager/activity-flow.hbs +4 -1
  33. package/addon/components/order-config-manager/activity-flow.js +178 -12
  34. package/addon/components/place/address.js +1 -1
  35. package/addon/components/route-list.hbs +17 -17
  36. package/addon/components/route-list.js +42 -10
  37. package/addon/components/service-rate/details.hbs +41 -0
  38. package/addon/components/service-rate/form.hbs +118 -0
  39. package/addon/components/service-rate/form.js +55 -0
  40. package/addon/components/vehicle/form.js +1 -1
  41. package/addon/components/vehicle/pill.hbs +1 -1
  42. package/addon/controllers/management/vehicles/index/new.js +1 -1
  43. package/addon/controllers/operations/orders/index/details.js +4 -4
  44. package/addon/controllers/operations/orders/index.js +14 -0
  45. package/addon/helpers/place-address-html.js +2 -2
  46. package/addon/routes/operations/service-rates/index.js +1 -1
  47. package/addon/services/geofence.js +163 -19
  48. package/addon/services/leaflet-contextmenu-manager.js +27 -0
  49. package/addon/services/vehicle-actions.js +1 -1
  50. package/addon/styles/fleetops-engine.css +91 -1
  51. package/addon/utils/fleet-ops-options.js +2 -1
  52. package/addon/utils/normalize-order-config-flow.js +41 -0
  53. package/addon/utils/place-address-html.js +3 -2
  54. package/addon/utils/route-visualization.js +54 -7
  55. package/app/components/modals/order-change-destination.js +1 -0
  56. package/app/components/modals/stop-activity.js +1 -0
  57. package/composer.json +1 -1
  58. package/extension.json +1 -1
  59. package/package.json +3 -3
  60. package/server/migrations/2026_05_21_000001_add_geographic_rules_to_service_rate_fees_table.php +37 -0
  61. package/server/migrations/2026_05_25_000001_add_endpoint_tracking_refs_to_payloads_table.php +50 -0
  62. package/server/migrations/2026_05_27_000001_default_driver_vehicle_statuses_to_available.php +48 -0
  63. package/server/resources/views/labels/default.php +57 -49
  64. package/server/resources/views/labels/waypoint-label.php +27 -20
  65. package/server/seeders/Testing/ConnectivitySeeder.php +1 -0
  66. package/server/seeders/Testing/FleetSeeder.php +22 -22
  67. package/server/seeders/Testing/OrdersSeeder.php +6 -22
  68. package/server/seeders/Testing/VroomOrchestrationSeeder.php +21 -21
  69. package/server/src/Console/Commands/AuditCustomerUserConflicts.php +75 -0
  70. package/server/src/Exceptions/CustomerUserConflictException.php +13 -0
  71. package/server/src/Http/Controllers/Api/v1/ContactController.php +17 -2
  72. package/server/src/Http/Controllers/Api/v1/CustomerController.php +876 -0
  73. package/server/src/Http/Controllers/Api/v1/DriverController.php +1 -1
  74. package/server/src/Http/Controllers/Api/v1/OrderConfigController.php +54 -0
  75. package/server/src/Http/Controllers/Api/v1/OrderController.php +98 -70
  76. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  77. package/server/src/Http/Controllers/Api/v1/VehicleController.php +20 -3
  78. package/server/src/Http/Controllers/Internal/v1/ContactController.php +19 -0
  79. package/server/src/Http/Controllers/Internal/v1/LiveController.php +101 -66
  80. package/server/src/Http/Controllers/Internal/v1/OrderController.php +429 -26
  81. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  82. package/server/src/Http/Controllers/Internal/v1/SettingController.php +2 -2
  83. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +54 -1
  84. package/server/src/Http/Middleware/AuthenticateCustomerToken.php +34 -0
  85. package/server/src/Http/Requests/CreateCustomerOrderRequest.php +63 -0
  86. package/server/src/Http/Requests/CreateCustomerRequest.php +48 -0
  87. package/server/src/Http/Requests/CreateDriverRequest.php +1 -1
  88. package/server/src/Http/Requests/CreateVehicleRequest.php +25 -1
  89. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -1
  90. package/server/src/Http/Requests/VerifyCreateCustomerRequest.php +29 -0
  91. package/server/src/Http/Resources/Internal/v1/OrderConfig.php +41 -0
  92. package/server/src/Http/Resources/v1/Customer.php +79 -0
  93. package/server/src/Http/Resources/v1/OrderConfig.php +74 -0
  94. package/server/src/Http/Resources/v1/Payload.php +23 -20
  95. package/server/src/Http/Resources/v1/ServiceArea.php +33 -19
  96. package/server/src/Http/Resources/v1/ServiceRateFee.php +7 -0
  97. package/server/src/Listeners/HandleOrderDispatched.php +2 -2
  98. package/server/src/Models/Contact.php +92 -15
  99. package/server/src/Models/Customer.php +64 -0
  100. package/server/src/Models/Driver.php +12 -3
  101. package/server/src/Models/Entity.php +2 -2
  102. package/server/src/Models/Order.php +29 -4
  103. package/server/src/Models/Payload.php +17 -1
  104. package/server/src/Models/ServiceRate.php +273 -1
  105. package/server/src/Models/ServiceRateFee.php +39 -9
  106. package/server/src/Models/TrackingNumber.php +8 -2
  107. package/server/src/Models/Vehicle.php +18 -2
  108. package/server/src/Models/Waypoint.php +2 -2
  109. package/server/src/Notifications/LateDeparture.php +2 -2
  110. package/server/src/Notifications/ProlongedStoppage.php +2 -2
  111. package/server/src/Notifications/RouteDeviation.php +2 -2
  112. package/server/src/Observers/ContactObserver.php +2 -0
  113. package/server/src/Observers/ServiceRateObserver.php +6 -6
  114. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +1 -1
  115. package/server/src/Providers/FleetOpsServiceProvider.php +1 -0
  116. package/server/src/Support/Analytics/AbstractAnalytics.php +6 -6
  117. package/server/src/Support/Analytics/FuelEfficiency.php +2 -2
  118. package/server/src/Support/Analytics/OrdersByStatus.php +1 -1
  119. package/server/src/Support/Analytics/RevenueTrend.php +0 -1
  120. package/server/src/Support/Analytics/TopDrivers.php +1 -1
  121. package/server/src/Support/CustomerAuth.php +98 -0
  122. package/server/src/Support/LabelPdf.php +52 -0
  123. package/server/src/Support/Metrics/AbstractMetric.php +4 -4
  124. package/server/src/Support/ResolvesOrderServiceStops.php +512 -0
  125. package/server/src/Tracking/TrackingContextBuilder.php +41 -38
  126. package/server/src/Tracking/TrackingIntelligenceService.php +109 -9
  127. package/server/src/Tracking/TrackingStop.php +13 -11
  128. package/server/src/routes.php +43 -9
  129. package/server/tests/CapacityAllocationEngineTest.php +10 -10
  130. package/server/tests/CustomerContactIdentitySafetyTest.php +54 -0
  131. package/server/tests/CustomerEndpointTest.php +199 -0
  132. package/server/tests/DriverVehicleStatusDefaultTest.php +39 -0
  133. package/server/tests/LabelPdfRenderingTest.php +23 -0
  134. package/server/tests/LiveControllerViewportTest.php +60 -0
  135. package/server/tests/MetricsRegistryTest.php +4 -8
  136. package/server/tests/OrchestrationPayloadBuilderTest.php +2 -2
  137. package/server/tests/OrderConfigEndpointTest.php +86 -0
  138. package/server/tests/OrderFileAttachmentTest.php +20 -0
  139. package/server/tests/ServiceAreaZoneApiShapeTest.php +34 -0
  140. package/server/tests/VehicleDriverAssignmentTest.php +18 -0
  141. package/translations/en-us.yaml +1 -0
  142. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +0 -86
@@ -335,7 +335,7 @@
335
335
  {{#each serviceQuote.items as |item|}}
336
336
  <tr>
337
337
  <td>
338
- <span class="font-semibold">{{smart-humanize item.details}}</span>
338
+ <span class="font-semibold">{{item.details}}</span>
339
339
  </td>
340
340
  <td>
341
341
  <span>{{format-currency item.amount serviceQuote.currency}}</span>
@@ -449,4 +449,4 @@
449
449
 
450
450
  <Spacer @height="300px" />
451
451
  </Overlay::Body>
452
- </Overlay>
452
+ </Overlay>
@@ -6,7 +6,7 @@
6
6
  @modelName="user"
7
7
  @selectedModel={{@resource.user}}
8
8
  @placeholder={{t "customer.fields.select-user"}}
9
- @query={{hash doesnt_have_contact=true}}
9
+ @query={{hash doesnt_have_contact=true is_customer=true}}
10
10
  @triggerClass="form-select form-input"
11
11
  @infiniteScroll={{false}}
12
12
  @renderInPlace={{true}}
@@ -141,4 +141,4 @@
141
141
  <RegistryYield @registry="fleet-ops:component:customer:form" as |RegistryComponent|>
142
142
  <RegistryComponent @resource={{@resource}} @controller={{@controller}} @permission={{get-write-permission @resource}} />
143
143
  </RegistryYield>
144
- </div>
144
+ </div>
@@ -351,7 +351,7 @@
351
351
  {{#each serviceQuote.items as |item|}}
352
352
  <tr>
353
353
  <td>
354
- <span class="font-semibold">{{smart-humanize item.details}}</span>
354
+ <span class="font-semibold">{{item.details}}</span>
355
355
  </td>
356
356
  <td>
357
357
  <span>{{format-currency item.amount serviceQuote.currency}}</span>
@@ -473,4 +473,4 @@
473
473
 
474
474
  <Spacer @height="300px" />
475
475
  </Overlay::Body>
476
- </Overlay>
476
+ </Overlay>
@@ -274,7 +274,7 @@
274
274
  {{#each order.purchase_rate.service_quote.items as |item|}}
275
275
  <tr>
276
276
  <td>
277
- <span class="font-semibold">{{smart-humanize item.details}}</span>
277
+ <span class="font-semibold">{{item.details}}</span>
278
278
  </td>
279
279
  <td>
280
280
  <div class="flex flex-row items-center justify-end">{{format-currency
@@ -320,4 +320,4 @@
320
320
  </div>
321
321
  <div id="customer-portal-content-wormhole"></div>
322
322
  {{/let}}
323
- </div>
323
+ </div>
@@ -31,7 +31,7 @@
31
31
  <div class="next-dd-menu-seperator"></div>
32
32
  {{#each-in @waypointActions as |actionId action|}}
33
33
  <div class="px-1">
34
- <a href="javascript:;" class="next-dd-item" disabled={{cannot "fleet-ops view order"}} {{on "click" (dropdown-fn dd action.fn this.place)}}>
34
+ <a href="javascript:;" class="next-dd-item" disabled={{cannot "fleet-ops view order"}} {{on "click" (dropdown-fn dd action.fn (or @waypointActionContext this.place))}}>
35
35
  {{action.label}}
36
36
  </a>
37
37
  </div>
@@ -65,4 +65,4 @@
65
65
  {{/if}}
66
66
  </address>
67
67
  {{/if}}
68
- </div>
68
+ </div>
@@ -276,15 +276,19 @@ export default class MapGoogleLiveMapComponent extends Component {
276
276
  }
277
277
 
278
278
  _showServiceAreaContextMenu(serviceArea, event) {
279
- const items = this.mapManager.getComposedContextMenuItems(`service-area:${serviceArea.public_id}`);
279
+ const items = this.mapManager.getComposedContextMenuItems(this._resourceContextKey('service-area', serviceArea));
280
280
  this.mapManager.showContextMenu(event, items);
281
281
  }
282
282
 
283
283
  _showZoneContextMenu(zone, event) {
284
- const items = this.mapManager.getComposedContextMenuItems(`zone:${zone.public_id}`);
284
+ const items = this.mapManager.getComposedContextMenuItems(this._resourceContextKey('zone', zone));
285
285
  this.mapManager.showContextMenu(event, items);
286
286
  }
287
287
 
288
+ _resourceContextKey(type, resource) {
289
+ return `${type}:${resource?.public_id ?? resource?.id}`;
290
+ }
291
+
288
292
  _closeAllInfoWindows() {
289
293
  this._infoWindows.forEach((iw) => iw.close());
290
294
  this.mapManager.closeContextMenu();
@@ -13,7 +13,7 @@
13
13
  through the provider-agnostic MapManagerService.
14
14
  }}
15
15
 
16
- <div class="live-map-component {{@wrapperClass}}">
16
+ <div class="live-map-component {{@wrapperClass}}" {{did-update this.syncServiceAreaContextMenus this.serviceAreaActions.serviceAreas this.serviceAreaActions.serviceAreas.length}}>
17
17
 
18
18
  {{#if this.shouldUseGoogleMaps}}
19
19
  {{! ─── Google Maps branch ──────────────────────────────────────────── }}
@@ -87,7 +87,11 @@
87
87
  <div class="flex items-center gap-1.5 text-xs font-semibold mb-1.5">
88
88
  <span class="inline-block w-2 h-2 rounded-full {{if driver.online 'bg-green-400' 'bg-red-500'}}"></span>
89
89
  <span>{{driver.name}}</span>
90
- <div class="status-badge {{if driver.status (dasherize driver.status) 'default'}}-status-badge status-badge-{{if driver.status (dasherize driver.status) 'default'}} shadow-none ml-auto">
90
+ <div
91
+ class="status-badge
92
+ {{if driver.status (dasherize driver.status) 'default'}}-status-badge status-badge-{{if driver.status (dasherize driver.status) 'default'}}
93
+ shadow-none ml-auto"
94
+ >
91
95
  <span class="rounded-full text-[9px] px-1.5 py-0.5">{{n-a driver.meta.status_label (smart-humanize driver.status)}}</span>
92
96
  </div>
93
97
  </div>
@@ -122,7 +126,10 @@
122
126
  </div>
123
127
  <div class="rounded bg-gray-900 shadow-md px-1.5 py-1 min-w-0">
124
128
  <div class="text-[9px] font-semibold uppercase text-gray-400 leading-none">Location</div>
125
- <div class="text-[11px] text-white leading-tight whitespace-normal break-words">{{n-a driver.meta.location_coordinates (point-coordinates driver.location)}}</div>
129
+ <div class="text-[11px] text-white leading-tight whitespace-normal break-words">{{n-a
130
+ driver.meta.location_coordinates
131
+ (point-coordinates driver.location)
132
+ }}</div>
126
133
  </div>
127
134
  </div>
128
135
  </div>
@@ -132,7 +139,11 @@
132
139
  <div class="flex items-center gap-1.5 text-xs font-semibold mb-1.5">
133
140
  <span class="inline-block w-2 h-2 rounded-full {{if driver.online 'bg-green-400' 'bg-red-500'}}"></span>
134
141
  <span>{{driver.name}}</span>
135
- <div class="status-badge {{if driver.status (dasherize driver.status) 'default'}}-status-badge status-badge-{{if driver.status (dasherize driver.status) 'default'}} shadow-none ml-auto">
142
+ <div
143
+ class="status-badge
144
+ {{if driver.status (dasherize driver.status) 'default'}}-status-badge status-badge-{{if driver.status (dasherize driver.status) 'default'}}
145
+ shadow-none ml-auto"
146
+ >
136
147
  <span class="rounded-full text-[9px] px-1.5 py-0.5">{{n-a driver.meta.status_label (smart-humanize driver.status)}}</span>
137
148
  </div>
138
149
  </div>
@@ -167,7 +178,10 @@
167
178
  </div>
168
179
  <div class="rounded bg-gray-900 shadow-md px-1.5 py-1 min-w-0">
169
180
  <div class="text-[9px] font-semibold uppercase text-gray-400 leading-none">Location</div>
170
- <div class="text-[11px] text-white leading-tight whitespace-normal break-words">{{n-a driver.meta.location_coordinates (point-coordinates driver.location)}}</div>
181
+ <div class="text-[11px] text-white leading-tight whitespace-normal break-words">{{n-a
182
+ driver.meta.location_coordinates
183
+ (point-coordinates driver.location)
184
+ }}</div>
171
185
  </div>
172
186
  </div>
173
187
  </div>
@@ -192,14 +206,24 @@
192
206
  <div class="flex items-center gap-1.5 text-xs font-semibold mb-1.5">
193
207
  <span class="inline-block w-2 h-2 rounded-full {{if vehicle.online 'bg-green-400' 'bg-red-500'}}"></span>
194
208
  <span>{{vehicle.displayName}}</span>
195
- <div class="status-badge {{if vehicle.status (dasherize vehicle.status) 'default'}}-status-badge status-badge-{{if vehicle.status (dasherize vehicle.status) 'default'}} shadow-none ml-auto">
209
+ <div
210
+ class="status-badge
211
+ {{if vehicle.status (dasherize vehicle.status) 'default'}}-status-badge status-badge-{{if vehicle.status (dasherize vehicle.status) 'default'}}
212
+ shadow-none ml-auto"
213
+ >
196
214
  <span class="rounded-full text-[9px] px-1.5 py-0.5">{{n-a vehicle.meta.status_label}}</span>
197
215
  </div>
198
216
  </div>
199
217
  <div class="grid grid-cols-2 gap-1">
200
218
  <div class="rounded bg-gray-900 shadow-md px-1.5 py-1 min-w-0">
201
219
  <div class="text-[9px] font-semibold uppercase text-gray-400 leading-none">Vehicle #</div>
202
- <div class="text-[11px] text-white leading-tight whitespace-normal break-words">{{n-a vehicle.internal_id vehicle.public_id vehicle.plate_number vehicle.serial_number vehicle.vin}}</div>
220
+ <div class="text-[11px] text-white leading-tight whitespace-normal break-words">{{n-a
221
+ vehicle.internal_id
222
+ vehicle.public_id
223
+ vehicle.plate_number
224
+ vehicle.serial_number
225
+ vehicle.vin
226
+ }}</div>
203
227
  </div>
204
228
  <div class="rounded bg-gray-900 shadow-md px-1.5 py-1 min-w-0">
205
229
  <div class="text-[9px] font-semibold uppercase text-gray-400 leading-none">Driver</div>
@@ -229,14 +253,24 @@
229
253
  <div class="flex items-center gap-1.5 text-xs font-semibold mb-1.5">
230
254
  <span class="inline-block w-2 h-2 rounded-full {{if vehicle.online 'bg-green-400' 'bg-red-500'}}"></span>
231
255
  <span>{{vehicle.displayName}}</span>
232
- <div class="status-badge {{if vehicle.status (dasherize vehicle.status) 'default'}}-status-badge status-badge-{{if vehicle.status (dasherize vehicle.status) 'default'}} shadow-none ml-auto">
256
+ <div
257
+ class="status-badge
258
+ {{if vehicle.status (dasherize vehicle.status) 'default'}}-status-badge status-badge-{{if vehicle.status (dasherize vehicle.status) 'default'}}
259
+ shadow-none ml-auto"
260
+ >
233
261
  <span class="rounded-full text-[9px] px-1.5 py-0.5">{{n-a vehicle.meta.status_label}}</span>
234
262
  </div>
235
263
  </div>
236
264
  <div class="grid grid-cols-2 gap-1">
237
265
  <div class="rounded bg-gray-900 shadow-md px-1.5 py-1 min-w-0">
238
266
  <div class="text-[9px] font-semibold uppercase text-gray-400 leading-none">Vehicle #</div>
239
- <div class="text-[11px] text-white leading-tight whitespace-normal break-words">{{n-a vehicle.internal_id vehicle.public_id vehicle.plate_number vehicle.serial_number vehicle.vin}}</div>
267
+ <div class="text-[11px] text-white leading-tight whitespace-normal break-words">{{n-a
268
+ vehicle.internal_id
269
+ vehicle.public_id
270
+ vehicle.plate_number
271
+ vehicle.serial_number
272
+ vehicle.vin
273
+ }}</div>
240
274
  </div>
241
275
  <div class="rounded bg-gray-900 shadow-md px-1.5 py-1 min-w-0">
242
276
  <div class="text-[9px] font-semibold uppercase text-gray-400 leading-none">Driver</div>
@@ -291,8 +325,8 @@
291
325
  @id={{serviceArea.id}}
292
326
  @record={{serviceArea}}
293
327
  @locations={{unwrap-coordinates serviceArea.leafletCoordinates}}
294
- @fillColor={{serviceArea.color}}
295
- @color={{serviceArea.stroke_color}}
328
+ @fillColor={{or serviceArea.color "#3388ff"}}
329
+ @color={{or serviceArea.stroke_color serviceArea.color "#3388ff"}}
296
330
  @onAdd={{fn this.trigger "onServiceAreaLayerAdded" serviceArea}}
297
331
  as |polygon|
298
332
  >
@@ -304,8 +338,8 @@
304
338
  @id={{zone.id}}
305
339
  @record={{zone}}
306
340
  @locations={{unwrap-coordinates zone.leafletCoordinates}}
307
- @fillColor={{zone.color}}
308
- @color={{zone.stroke_color}}
341
+ @fillColor={{or zone.color "#3388ff"}}
342
+ @color={{or zone.stroke_color zone.color "#3388ff"}}
309
343
  @onAdd={{fn this.trigger "onZoneLayerAdd" zone}}
310
344
  as |polygon|
311
345
  >
@@ -340,4 +374,4 @@
340
374
  @liveMap={{this}}
341
375
  @onDrawerReady={{this.setDrawerContext}}
342
376
  />
343
- {{/if}} --}}
377
+ {{/if}} --}}
@@ -8,11 +8,14 @@ import { guidFor } from '@ember/object/internals';
8
8
  import { camelize, capitalize, dasherize } from '@ember/string';
9
9
  import { singularize, pluralize } from 'ember-inflector';
10
10
  import { all } from 'rsvp';
11
- import { task } from 'ember-concurrency';
11
+ import { task, timeout } from 'ember-concurrency';
12
12
  import { next } from '@ember/runloop';
13
13
  import getModelName from '@fleetbase/ember-core/utils/get-model-name';
14
14
 
15
15
  export default class MapLeafletLiveMapComponent extends Component {
16
+ liveViewportReloadDebounceMs = 300;
17
+ liveViewportResourceLimit = 500;
18
+
16
19
  @service mapManager;
17
20
  @service mapSettings;
18
21
  @service leafletMapManager;
@@ -98,6 +101,11 @@ export default class MapLeafletLiveMapComponent extends Component {
98
101
  this.universe.off('fleet-ops.geofence.exited', this._geofenceExitedHandler);
99
102
  this._geofenceExitedHandler = null;
100
103
  }
104
+ if (this._viewportReloadHandler && this.map && typeof this.map.off === 'function') {
105
+ this.map.off('moveend', this._viewportReloadHandler);
106
+ this.map.off('zoomend', this._viewportReloadHandler);
107
+ this._viewportReloadHandler = null;
108
+ }
101
109
  }
102
110
 
103
111
  @action didLoad({ target: map }) {
@@ -106,8 +114,9 @@ export default class MapLeafletLiveMapComponent extends Component {
106
114
  this.trigger('onLoad', ...arguments);
107
115
  this.load.perform();
108
116
  // Listen for map move/zoom events to trigger viewport-based resource reload
109
- map.on('moveend', () => this.reloadResourcesInViewport.perform());
110
- map.on('zoomend', () => this.reloadResourcesInViewport.perform());
117
+ this._viewportReloadHandler = () => this.reloadResourcesInViewport.perform();
118
+ map.on('moveend', this._viewportReloadHandler);
119
+ map.on('zoomend', this._viewportReloadHandler);
111
120
  }
112
121
 
113
122
  /**
@@ -211,13 +220,27 @@ export default class MapLeafletLiveMapComponent extends Component {
211
220
  this.#createZoneContextMenu(zone, layer);
212
221
  }
213
222
 
223
+ @action rebuildMapContextMenu() {
224
+ if (!this.map) {
225
+ return;
226
+ }
227
+
228
+ this.#createMapContextMenu(this.map);
229
+ this.#refreshResourceContextMenus();
230
+ this.#pruneResourceContextMenus();
231
+ }
232
+
233
+ @action syncServiceAreaContextMenus() {
234
+ this.rebuildMapContextMenu();
235
+ }
236
+
214
237
  /** load resources and wait for stuff here and trigger map ready **/
215
238
  @task *load() {
216
239
  try {
217
240
  // Get initial map bounds for spatial filtering (provider-agnostic)
218
241
  const mapBounds = this.mapManager.getBounds?.() ?? (this.map ? this.map.getBounds() : null);
219
242
  const bounds = this.#serializeBoundsForRequest(mapBounds);
220
- const params = bounds ? { bounds } : {};
243
+ const params = this.#liveViewportRequestParams(bounds);
221
244
 
222
245
  const data = yield all([
223
246
  this.loadResource.perform('routes'),
@@ -242,12 +265,19 @@ export default class MapLeafletLiveMapComponent extends Component {
242
265
  if (this.isViewportReloadSuspended) {
243
266
  return;
244
267
  }
268
+
269
+ yield timeout(this.liveViewportReloadDebounceMs);
270
+
271
+ if (this.isViewportReloadSuspended) {
272
+ return;
273
+ }
274
+
245
275
  // Get current map bounds (provider-agnostic)
246
276
  const rawBounds = this.mapManager.getBounds?.() ?? this.map?.getBounds();
247
277
  if (!rawBounds) return;
248
278
  const bounds = this.#serializeBoundsForRequest(rawBounds);
249
279
  if (!bounds) return;
250
- const params = { bounds };
280
+ const params = this.#liveViewportRequestParams(bounds);
251
281
 
252
282
  // Reload spatially-filtered resources (drivers, vehicles, places)
253
283
  // Orders, routes, and service-areas are not spatially filtered
@@ -445,6 +475,10 @@ export default class MapLeafletLiveMapComponent extends Component {
445
475
  return null;
446
476
  }
447
477
 
478
+ #liveViewportRequestParams(bounds) {
479
+ return bounds ? { bounds, limit: this.liveViewportResourceLimit } : { limit: this.liveViewportResourceLimit };
480
+ }
481
+
448
482
  #setResourceLayer(model, layer, options = {}) {
449
483
  const { hidden = false } = options;
450
484
  const type = getModelName(model);
@@ -462,6 +496,7 @@ export default class MapLeafletLiveMapComponent extends Component {
462
496
  }
463
497
 
464
498
  #createMapContextMenu(map) {
499
+ const serviceAreas = Array.from(this.serviceAreaActions.serviceAreas ?? []);
465
500
  const items = [
466
501
  {
467
502
  text: this.intl.t('live-map.show-coordinates'),
@@ -494,10 +529,10 @@ export default class MapLeafletLiveMapComponent extends Component {
494
529
  callback: () => this.geofence.createServiceArea(),
495
530
  index: 5,
496
531
  },
497
- this.serviceAreaActions.serviceAreas.length ? { separator: true } : null,
498
- ...this.serviceAreaActions.serviceAreas.map((serviceArea, i) => {
532
+ serviceAreas.length ? { separator: true } : null,
533
+ ...serviceAreas.map((serviceArea, i) => {
499
534
  return {
500
- text: this.intl.t('live-map.focus-service', { serviceName: serviceArea.name }),
535
+ text: this.intl.t('live-map.focus-service', { serviceName: this.#resourceDisplayName(serviceArea) }),
501
536
  callback: () => this.geofence.focusServiceArea(serviceArea),
502
537
  index: 6 + i,
503
538
  };
@@ -511,71 +546,200 @@ export default class MapLeafletLiveMapComponent extends Component {
511
546
  }
512
547
 
513
548
  #createZoneContextMenu(zone, layer) {
549
+ const zoneName = this.#resourceDisplayName(zone);
514
550
  let items = [
515
551
  {
516
552
  separator: true,
517
553
  },
518
554
  {
519
- text: this.intl.t('live-map.edit-zone', { zoneName: zone.name }),
555
+ text: this.intl.t('live-map.edit-zone', { zoneName }),
520
556
  callback: () => this.zoneActions.modal.edit(zone),
521
557
  },
522
558
  {
523
- text: this.intl.t('live-map.delete-zone', { zoneName: zone.name }),
524
- callback: () => this.zoneActions.delete(zone),
559
+ text: this.intl.t('live-map.delete-zone', { zoneName }),
560
+ callback: () => this.#deleteZone(zone),
525
561
  },
526
562
  ];
527
563
 
528
564
  if (!this.mapManager.isGoogleMaps) {
529
565
  items.splice(2, 0, {
530
- text: this.intl.t('live-map.edit-boundaries', { resource: zone.name }),
566
+ text: this.intl.t('live-map.edit-boundaries', { resource: zoneName }),
531
567
  callback: () => this.geofence.editZone(zone),
532
568
  });
533
569
  }
534
570
 
535
571
  // create contextmenu registry
536
- const contextmenuRegistry = this.leafletContextmenuManager.createContextMenu(`zone:${zone.public_id}`, layer, items, { zone });
572
+ const contextmenuRegistry = this.leafletContextmenuManager.createContextMenu(this.#resourceContextKey('zone', zone), layer, items, { zone });
537
573
  this.universe.trigger('fleet-ops:contextmenu:zone:created', contextmenuRegistry, this.leafletContextmenuManager);
538
574
 
539
575
  return contextmenuRegistry;
540
576
  }
541
577
 
542
578
  #createServiceAreaContextMenu(serviceArea, layer) {
579
+ const serviceName = this.#resourceDisplayName(serviceArea);
543
580
  let items = [
544
581
  {
545
582
  separator: true,
546
583
  },
547
584
  {
548
- text: this.intl.t('live-map.blur-service', { serviceName: serviceArea.name }),
585
+ text: this.intl.t('live-map.blur-service', { serviceName }),
549
586
  callback: () => this.geofence.blurServiceArea(serviceArea),
550
587
  },
551
588
  {
552
- text: this.intl.t('live-map.create-zone', { serviceName: serviceArea.name }),
589
+ text: this.intl.t('live-map.create-zone', { serviceName }),
553
590
  callback: () => this.geofence.createZone(serviceArea),
554
591
  },
555
592
  {
556
- text: this.intl.t('live-map.edit-service', { serviceName: serviceArea.name }),
593
+ text: this.intl.t('live-map.edit-service', { serviceName }),
557
594
  callback: () => this.serviceAreaActions.modal.edit(serviceArea),
558
595
  },
559
596
  {
560
- text: this.intl.t('live-map.delete-service', { serviceName: serviceArea.name }),
561
- callback: () => this.serviceAreaActions.delete(serviceArea),
597
+ text: this.intl.t('live-map.delete-service', { serviceName }),
598
+ callback: () => this.#deleteServiceArea(serviceArea),
562
599
  },
563
600
  ];
564
601
 
565
602
  if (!this.mapManager.isGoogleMaps) {
566
603
  items.splice(4, 0, {
567
- text: this.intl.t('live-map.edit-boundaries', { resource: serviceArea.name }),
604
+ text: this.intl.t('live-map.edit-boundaries', { resource: serviceName }),
568
605
  callback: () => this.geofence.editServiceArea(serviceArea),
569
606
  });
570
607
  }
571
608
 
572
609
  // create contextmenu registry
573
- const contextmenuRegistry = this.leafletContextmenuManager.createContextMenu(`service-area:${serviceArea.public_id}`, layer, items, { serviceArea });
610
+ const contextmenuRegistry = this.leafletContextmenuManager.createContextMenu(this.#resourceContextKey('service-area', serviceArea), layer, items, { serviceArea });
574
611
  this.universe.trigger('fleet-ops:contextmenu:service-area:created', contextmenuRegistry, this.leafletContextmenuManager);
575
612
 
576
613
  return contextmenuRegistry;
577
614
  }
578
615
 
616
+ #resourceContextKey(type, resource) {
617
+ return `${type}:${resource?.public_id ?? resource?.id}`;
618
+ }
619
+
620
+ #resourceDisplayName(resource) {
621
+ return resource?.get?.('name') || resource?.name || resource?.display_name || resource?.public_id || resource?.id || '';
622
+ }
623
+
624
+ #deleteServiceArea(serviceArea) {
625
+ const zones = Array.from(serviceArea?.zones ?? []);
626
+
627
+ return this.serviceAreaActions.delete(
628
+ serviceArea,
629
+ {},
630
+ {
631
+ callback: () => {
632
+ this.#removeServiceAreaFromMapState(serviceArea, zones);
633
+ this.rebuildMapContextMenu();
634
+ },
635
+ }
636
+ );
637
+ }
638
+
639
+ #deleteZone(zone) {
640
+ return this.zoneActions.delete(
641
+ zone,
642
+ {},
643
+ {
644
+ callback: () => {
645
+ this.#removeZoneFromMapState(zone);
646
+ this.rebuildMapContextMenu();
647
+ },
648
+ }
649
+ );
650
+ }
651
+
652
+ #removeServiceAreaFromMapState(serviceArea, zones = []) {
653
+ const serviceAreaId = serviceArea?.id;
654
+ const serviceAreaKey = this.#resourceContextKey('service-area', serviceArea);
655
+
656
+ zones.forEach((zone) => this.#removeZoneFromMapState(zone));
657
+ this.#removeOverlay(serviceArea);
658
+ this.leafletContextmenuManager.removeContextMenu(serviceAreaKey);
659
+
660
+ this.serviceAreaActions.serviceAreas = Array.from(this.serviceAreaActions.serviceAreas ?? []).filter((candidate) => candidate?.id !== serviceAreaId);
661
+ }
662
+
663
+ #removeZoneFromMapState(zone) {
664
+ const zoneId = zone?.id;
665
+ const zoneKey = this.#resourceContextKey('zone', zone);
666
+
667
+ this.#removeOverlay(zone);
668
+ this.leafletContextmenuManager.removeContextMenu(zoneKey);
669
+
670
+ for (const serviceArea of Array.from(this.serviceAreaActions.serviceAreas ?? [])) {
671
+ const zones = serviceArea?.zones;
672
+ if (!zones) {
673
+ continue;
674
+ }
675
+
676
+ const currentZones = Array.from(zones);
677
+ const nextZones = currentZones.filter((candidate) => candidate?.id !== zoneId);
678
+ if (nextZones.length !== currentZones.length) {
679
+ if (typeof zones.removeObject === 'function') {
680
+ zones.removeObject(zone);
681
+ } else {
682
+ serviceArea.set?.('zones', nextZones);
683
+ }
684
+ break;
685
+ }
686
+ }
687
+
688
+ this.serviceAreaActions.serviceAreas = Array.from(this.serviceAreaActions.serviceAreas ?? []);
689
+ }
690
+
691
+ #removeOverlay(resource) {
692
+ if (!resource?.id) {
693
+ return;
694
+ }
695
+
696
+ this.mapManager.removeOverlay?.(resource.id);
697
+
698
+ const layer = resource?.leafletLayer;
699
+ if (layer) {
700
+ this.mapManager.removeLayer?.(layer);
701
+ }
702
+ }
703
+
704
+ #refreshResourceContextMenus() {
705
+ for (const serviceArea of Array.from(this.serviceAreaActions.serviceAreas ?? [])) {
706
+ const serviceAreaLayer = this.mapManager.getOverlay(serviceArea?.id) ?? serviceArea?.leafletLayer;
707
+ if (serviceAreaLayer) {
708
+ this.#createServiceAreaContextMenu(serviceArea, serviceAreaLayer);
709
+ }
710
+
711
+ for (const zone of Array.from(serviceArea?.zones ?? [])) {
712
+ const zoneLayer = this.mapManager.getOverlay(zone?.id) ?? zone?.leafletLayer;
713
+ if (zoneLayer) {
714
+ this.#createZoneContextMenu(zone, zoneLayer);
715
+ }
716
+ }
717
+ }
718
+ }
719
+
720
+ #pruneResourceContextMenus() {
721
+ const activeServiceAreaKeys = new Set();
722
+ const activeZoneKeys = new Set();
723
+
724
+ for (const serviceArea of Array.from(this.serviceAreaActions.serviceAreas ?? [])) {
725
+ activeServiceAreaKeys.add(this.#resourceContextKey('service-area', serviceArea));
726
+
727
+ for (const zone of Array.from(serviceArea?.zones ?? [])) {
728
+ activeZoneKeys.add(this.#resourceContextKey('zone', zone));
729
+ }
730
+ }
731
+
732
+ for (const registry of Object.values(this.leafletContextmenuManager.contextMenuRegistry ?? {})) {
733
+ if (registry?.serviceArea && !activeServiceAreaKeys.has(this.#resourceContextKey('service-area', registry.serviceArea))) {
734
+ this.leafletContextmenuManager.removeContextMenu(this.#resourceContextKey('service-area', registry.serviceArea));
735
+ }
736
+
737
+ if (registry?.zone && !activeZoneKeys.has(this.#resourceContextKey('zone', registry.zone))) {
738
+ this.leafletContextmenuManager.removeContextMenu(this.#resourceContextKey('zone', registry.zone));
739
+ }
740
+ }
741
+ }
742
+
579
743
  #createDriverContextMenu(driver, layer) {
580
744
  let items = [
581
745
  {
@@ -0,0 +1,46 @@
1
+ <Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{perform this.confirmSelection}} @decline={{@onDecline}}>
2
+ <div class="modal-body-container">
3
+ <div class="space-y-3">
4
+ {{#each this.waypointOptions as |option|}}
5
+ <button
6
+ type="button"
7
+ class="w-full rounded-md border px-4 py-3 text-left shadow-sm transition {{if option.isCurrent 'border-blue-500 bg-blue-50 dark:border-blue-600 dark:bg-blue-900' (if option.isSelected 'border-green-500 bg-green-50 dark:border-green-700 dark:bg-green-900' 'border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-700 dark:hover:bg-gray-900')}}"
8
+ disabled={{this.confirmSelection.isRunning}}
9
+ {{on "click" (fn this.selectOption option)}}
10
+ >
11
+ <div class="flex items-start gap-3">
12
+ <div
13
+ class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold {{if option.isCurrent 'bg-blue-600 text-white' 'bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-100'}}"
14
+ >
15
+ {{option.label}}
16
+ </div>
17
+ <div class="min-w-0 flex-1">
18
+ <div class="flex items-center justify-between gap-2">
19
+ <div class="truncate text-sm font-semibold dark:text-gray-100">{{option.title}} - {{or option.waypoint.name option.waypoint.id}}</div>
20
+ {{#if option.isCurrent}}
21
+ <Badge @status="info" class="uppercase">Current</Badge>
22
+ {{else if option.isSelected}}
23
+ <Badge @status="success" class="uppercase">Selected</Badge>
24
+ {{else if option.waypoint.complete}}
25
+ <Badge @status="completed" class="uppercase">Complete</Badge>
26
+ {{else if option.waypoint.status_code}}
27
+ <Badge @status={{dasherize option.waypoint.status_code}} class="uppercase">{{option.waypoint.status_code}}</Badge>
28
+ {{else if option.waypoint.status}}
29
+ <Badge @status={{dasherize option.waypoint.status}} class="uppercase">{{option.waypoint.status}}</Badge>
30
+ {{/if}}
31
+ </div>
32
+ <div class="mt-1 truncate text-xs text-gray-600 dark:text-gray-300">{{or option.waypoint.address option.waypoint.street1}}</div>
33
+ {{#if option.tracking}}
34
+ <div class="mt-1 text-xs font-mono text-gray-500 dark:text-gray-400">{{option.tracking}}</div>
35
+ {{/if}}
36
+ </div>
37
+ </div>
38
+ </button>
39
+ {{else}}
40
+ <div class="rounded-md border border-dashed border-gray-300 p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
41
+ No waypoint destinations are available.
42
+ </div>
43
+ {{/each}}
44
+ </div>
45
+ </div>
46
+ </Modal::Default>