@fleetbase/fleetops-engine 0.6.56 → 0.6.58

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/ai/create-order-preview.hbs +297 -0
  2. package/addon/components/ai/create-order-preview.js +362 -0
  3. package/addon/components/ai/route-preview-map.hbs +79 -0
  4. package/addon/components/ai/route-preview-map.js +527 -0
  5. package/addon/components/fleet/details.hbs +21 -8
  6. package/addon/components/fleet/details.js +55 -1
  7. package/addon/components/fleet/hierarchy-tree.hbs +137 -0
  8. package/addon/components/fleet/hierarchy-tree.js +528 -0
  9. package/addon/components/map/drawer/device-event-listing.js +1 -0
  10. package/addon/components/map/drawer/driver-listing.js +1 -0
  11. package/addon/components/map/drawer/place-listing.js +1 -0
  12. package/addon/components/map/drawer/vehicle-listing.js +1 -0
  13. package/addon/components/order/details.hbs +34 -1
  14. package/addon/components/order/form/details.hbs +4 -0
  15. package/addon/components/order/form.hbs +9 -1
  16. package/addon/components/vehicle/card.hbs +14 -2
  17. package/addon/components/vehicle/form.hbs +13 -1
  18. package/addon/controllers/settings/map.js +4 -0
  19. package/addon/extension.js +14 -0
  20. package/addon/routes/management/fleets/index/details.js +1 -1
  21. package/addon/services/map-adapter/google.js +56 -16
  22. package/addon/services/map-manager.js +12 -0
  23. package/addon/styles/fleetops-engine.css +487 -1
  24. package/addon/utils/map-drawer-dropdown-position.js +11 -13
  25. package/app/components/ai/create-order-preview.js +1 -0
  26. package/app/components/ai/route-preview-map.js +1 -0
  27. package/app/components/fleet/hierarchy-tree.js +1 -0
  28. package/app/services/route-engine.js +1 -0
  29. package/composer.json +9 -2
  30. package/extension.json +1 -1
  31. package/package.json +6 -4
  32. package/phpunit.xml.dist +4 -4
  33. package/pnpm-workspace.yaml +2 -1
  34. package/scripts/coverage-runner.php +51 -0
  35. package/scripts/coverage-summary.php +110 -0
  36. package/scripts/normalize-rgba-alpha.js +90 -0
  37. package/scripts/pest-bootstrap.php +177 -0
  38. package/scripts/pest-file-runner.php +82 -0
  39. package/scripts/pest-runner.php +64 -0
  40. package/server/src/Http/Resources/v1/Fleet.php +8 -2
  41. package/server/src/Models/Driver.php +6 -0
  42. package/server/src/Models/Order.php +18 -6
  43. package/server/src/Models/Place.php +11 -3
  44. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +3 -1
  45. package/server/src/Providers/FleetOpsServiceProvider.php +26 -0
  46. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +88 -0
  47. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +108 -0
  48. package/server/src/Support/Ai/Capabilities/ConsoleNavigationCapability.php +55 -0
  49. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +508 -0
  50. package/server/src/Support/Ai/Capabilities/DocsHelpCapability.php +49 -0
  51. package/server/src/Support/Ai/Capabilities/ImportOrdersPreviewCapability.php +66 -0
  52. package/server/src/Support/Ai/Capabilities/OperationalQueryCapability.php +319 -0
  53. package/server/src/Support/Ai/Capabilities/OptimizeOrderRouteCapability.php +240 -0
  54. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +95 -0
  55. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +195 -0
  56. package/server/src/Support/Ai/FleetOpsAiQueryResources.php +150 -0
  57. package/server/src/Support/Utils.php +52 -0
  58. package/server/src/Tracking/TrackingOptions.php +6 -1
  59. package/server/src/routes.php +0 -11
  60. package/server/tests/AiCreateOrderPreviewCapabilityTest.php +131 -0
  61. package/server/tests/AiOperationalQueryCapabilityTest.php +75 -0
  62. package/server/tests/AnalyticsRoutesTest.php +5 -3
  63. package/server/tests/CustomerEndpointTest.php +1 -0
  64. package/server/tests/DeviceFilterTest.php +1 -2
  65. package/server/tests/FleetHierarchyResourceTest.php +12 -0
  66. package/server/tests/FuelProviderRegistryTest.php +7 -9
  67. package/server/tests/LiveControllerViewportTest.php +8 -16
  68. package/server/tests/LiveOrderQueryTest.php +18 -24
  69. package/server/tests/OrderPayloadPlaceResolutionTest.php +53 -0
  70. package/server/tests/PointResolutionTest.php +63 -0
  71. package/server/tests/ServiceAreaZoneApiShapeTest.php +8 -9
  72. package/server/tests/ServiceRateAlgorithmWeightVariablesTest.php +1 -1
  73. package/server/tests/TelematicsHardeningTest.php +31 -26
  74. package/server/tests/TrackingIntelligenceTest.php +65 -25
  75. package/server/tests/VehicleDriverAssignmentTest.php +8 -9
  76. package/addon/components/table/cell/dropdown/action-item.hbs +0 -31
  77. package/addon/components/table/cell/dropdown/action-item.js +0 -62
  78. package/addon/components/table/cell/dropdown.hbs +0 -28
  79. package/addon/components/table/cell/dropdown.js +0 -91
  80. package/app/components/table/cell/dropdown/action-item.js +0 -1
  81. package/app/components/table/cell/dropdown.js +0 -1
  82. package/server/src/Http/Controllers/Api/v1/OrganizationController.php +0 -45
@@ -0,0 +1,79 @@
1
+ <div class="fleetbase-ai-create-order-preview__map" {{did-insert this.setupPreview}}>
2
+ {{#if this.canShowMap}}
3
+ {{#if this.shouldUseGoogleMaps}}
4
+ <div
5
+ class="fleetbase-ai-create-order-preview__google"
6
+ {{did-insert this.setupGoogleMap}}
7
+ {{did-update this.syncPreview this.routeSignature this.mapSettings.googleMapsMapType this.mapSettings.showGoogleMapsTrafficLayer this.mapSettings.showGoogleMapsTransitLayer}}
8
+ ></div>
9
+ {{else if this.leafletPluginsReady}}
10
+ <LeafletMap
11
+ class="fleetbase-ai-create-order-preview__leaflet"
12
+ @lat={{this.center.lat}}
13
+ @lng={{this.center.lng}}
14
+ @zoom={{12}}
15
+ @zoomControl={{false}}
16
+ @onLoad={{this.didLoadLeafletMap}}
17
+ {{did-update this.syncPreview this.routeSignature}}
18
+ as |layers|
19
+ >
20
+ <layers.tile @url={{this.tileUrl}} />
21
+ {{#if this.hasRouteLine}}
22
+ {{#each this.routeStyles as |style|}}
23
+ <layers.polyline
24
+ @locations={{this.routeLineCoordinates}}
25
+ @color={{style.color}}
26
+ @weight={{style.weight}}
27
+ @opacity={{style.opacity}}
28
+ @lineCap={{style.lineCap}}
29
+ @lineJoin={{style.lineJoin}}
30
+ @dashArray={{style.dashArray}}
31
+ />
32
+ {{/each}}
33
+ {{/if}}
34
+ {{#each this.markerStops as |stop|}}
35
+ <layers.marker
36
+ @location={{stop.location}}
37
+ @title={{stop.title}}
38
+ @icon={{div-icon
39
+ html=(this.waypointIconHtml stop.waypointLabel stop.waypointColor)
40
+ className="fleetops-waypoint-marker"
41
+ iconSize=(array 32 32)
42
+ iconAnchor=(array 16 16)
43
+ popupAnchor=(array 0 -20)
44
+ }}
45
+ @riseOnHover={{true}}
46
+ @zIndexOffset={{stop.zIndexOffset}}
47
+ as |marker|
48
+ >
49
+ <marker.tooltip @permanent={{false}} @sticky={{true}} @direction="top">
50
+ {{stop.title}}{{#if stop.address}} - {{stop.address}}{{/if}}
51
+ </marker.tooltip>
52
+ </layers.marker>
53
+ {{/each}}
54
+ </LeafletMap>
55
+ {{else}}
56
+ <div class="fleetbase-ai-create-order-preview__empty-map">
57
+ <Spinner />
58
+ <span>Preparing map preview...</span>
59
+ </div>
60
+ {{/if}}
61
+
62
+ {{#if this.isLoading}}
63
+ <div class="fleetbase-ai-create-order-preview__map-status">
64
+ <Spinner />
65
+ <span>Rendering route...</span>
66
+ </div>
67
+ {{else if this.routeErrorMessage}}
68
+ <div class="fleetbase-ai-create-order-preview__map-status fleetbase-ai-create-order-preview__map-status--error">
69
+ <FaIcon @icon="triangle-exclamation" />
70
+ <span>{{this.routeErrorMessage}}</span>
71
+ </div>
72
+ {{/if}}
73
+ {{else}}
74
+ <div class="fleetbase-ai-create-order-preview__empty-map">
75
+ <FaIcon @icon="route" />
76
+ <span>{{this.emptyText}}</span>
77
+ </div>
78
+ {{/if}}
79
+ </div>
@@ -0,0 +1,527 @@
1
+ import Component from '@glimmer/component';
2
+ import { inject as service } from '@ember/service';
3
+ import { action } from '@ember/object';
4
+ import { tracked } from '@glimmer/tracking';
5
+ import { isArray } from '@ember/array';
6
+ import { debug } from '@ember/debug';
7
+ import { colorForId, routeColorForStatus, routeStyleForStatus, waypointIconHtml } from '../../utils/route-colors';
8
+ import { buildRoutePointMarkerPresentation, buildRoutePointsFromPayload } from '../../utils/route-visualization';
9
+ import ensureLeafletPluginsReady, { hasLeafletPluginsReady } from '../../utils/leaflet-plugin-loader';
10
+
11
+ const PREVIEW_MAX_ZOOM_TWO_POINTS = 13;
12
+ const PREVIEW_MAX_ZOOM_MULTI_POINTS = 12;
13
+ const GOOGLE_MAP_STYLES = [
14
+ {
15
+ featureType: 'all',
16
+ elementType: 'labels.icon',
17
+ stylers: [{ visibility: 'off' }],
18
+ },
19
+ {
20
+ featureType: 'poi',
21
+ elementType: 'all',
22
+ stylers: [{ visibility: 'off' }],
23
+ },
24
+ {
25
+ featureType: 'transit',
26
+ elementType: 'all',
27
+ stylers: [{ visibility: 'off' }],
28
+ },
29
+ {
30
+ featureType: 'administrative.land_parcel',
31
+ elementType: 'all',
32
+ stylers: [{ visibility: 'off' }],
33
+ },
34
+ {
35
+ featureType: 'road',
36
+ elementType: 'labels.icon',
37
+ stylers: [{ visibility: 'off' }],
38
+ },
39
+ {
40
+ featureType: 'landscape.man_made',
41
+ elementType: 'labels',
42
+ stylers: [{ visibility: 'off' }],
43
+ },
44
+ ];
45
+
46
+ export default class AiRoutePreviewMapComponent extends Component {
47
+ @service mapSettings;
48
+ @service routeEngine;
49
+
50
+ @tracked route = null;
51
+ @tracked error = null;
52
+ @tracked isLoading = false;
53
+ @tracked leafletPluginsReady = hasLeafletPluginsReady();
54
+ @tracked googleMap = null;
55
+
56
+ leafletMap = null;
57
+ googleMarkers = [];
58
+ googlePolylines = [];
59
+ googleTrafficLayer = null;
60
+ googleTransitLayer = null;
61
+ googleListeners = [];
62
+ computeToken = 0;
63
+ googleApiLoadPromise = null;
64
+
65
+ willDestroy() {
66
+ super.willDestroy(...arguments);
67
+ this.clearGoogleListeners();
68
+ this.clearGoogleRoute();
69
+ this.googleTrafficLayer?.setMap(null);
70
+ this.googleTransitLayer?.setMap(null);
71
+ this.googleMap = null;
72
+ this.leafletMap = null;
73
+ }
74
+
75
+ get payload() {
76
+ return this.args.payload ?? {};
77
+ }
78
+
79
+ get routePoints() {
80
+ return buildRoutePointsFromPayload(this.payload);
81
+ }
82
+
83
+ get coordinates() {
84
+ return this.routePoints.map(({ place }) => [Number(place.latitude), Number(place.longitude)]).filter(([lat, lng]) => Number.isFinite(lat) && Number.isFinite(lng));
85
+ }
86
+
87
+ get canShowMap() {
88
+ return this.coordinates.length >= 2;
89
+ }
90
+
91
+ get routeSignature() {
92
+ return `${this.provider}:${this.displayRouteEngine}:${this.coordinates.map((coordinate) => coordinate.join(',')).join('|')}`;
93
+ }
94
+
95
+ get provider() {
96
+ return this.mapSettings.mapProvider ?? 'leaflet';
97
+ }
98
+
99
+ get shouldUseGoogleMaps() {
100
+ return this.provider === 'google';
101
+ }
102
+
103
+ get displayRouteEngine() {
104
+ const configuredEngine = this.routeEngine?.getDisplayEngine?.('osrm') ?? 'osrm';
105
+
106
+ return this.routeEngine?.get?.(configuredEngine) ? configuredEngine : 'osrm';
107
+ }
108
+
109
+ get routeErrorMessage() {
110
+ if (!this.error || this.isLoading || this.hasRouteLine) {
111
+ return null;
112
+ }
113
+
114
+ return 'Unable to calculate route';
115
+ }
116
+
117
+ get center() {
118
+ const [lat, lng] = this.coordinates[0] ?? [1.3521, 103.8198];
119
+
120
+ return { lat, lng };
121
+ }
122
+
123
+ get routeColor() {
124
+ return colorForId(this.args.orderId ?? 'ai-order-preview');
125
+ }
126
+
127
+ get routeStatus() {
128
+ return this.args.status ?? 'created';
129
+ }
130
+
131
+ get statusColor() {
132
+ return routeColorForStatus(this.routeStatus);
133
+ }
134
+
135
+ get routeStyles() {
136
+ return routeStyleForStatus(this.routeStatus, this.statusColor);
137
+ }
138
+
139
+ get routeCoordinates() {
140
+ return this.route?.coordinates?.length ? this.route.coordinates : this.coordinates;
141
+ }
142
+
143
+ get routeLineCoordinates() {
144
+ return this.route?.coordinates?.length ? this.route.coordinates : [];
145
+ }
146
+
147
+ get hasRouteLine() {
148
+ return this.routeLineCoordinates.length > 1;
149
+ }
150
+
151
+ get markerStops() {
152
+ return this.routePoints
153
+ .map((routePoint, index) => {
154
+ const location = [Number(routePoint.place.latitude), Number(routePoint.place.longitude)];
155
+ const presentation = buildRoutePointMarkerPresentation(routePoint, this.routeColor);
156
+
157
+ return {
158
+ ...presentation,
159
+ index,
160
+ location,
161
+ title: presentation.title,
162
+ address: this.addressLabel(routePoint.place),
163
+ };
164
+ })
165
+ .filter((stop) => stop.location.every((coordinate) => Number.isFinite(coordinate)));
166
+ }
167
+
168
+ get tileUrl() {
169
+ const theme = document.body?.dataset?.theme;
170
+
171
+ if (theme === 'dark') {
172
+ return 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
173
+ }
174
+
175
+ return 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
176
+ }
177
+
178
+ get emptyText() {
179
+ if (this.error) {
180
+ return 'Unable to preview the route right now.';
181
+ }
182
+
183
+ return 'Select pickup and dropoff with coordinates to preview the route.';
184
+ }
185
+
186
+ addressLabel(place) {
187
+ return [place?.address, place?.street1, place?.name, place?.city, place?.postal_code, place?.country]
188
+ .map((value) => (value === null || value === undefined ? null : String(value).trim()))
189
+ .filter((value) => value && !['undefined', 'null'].includes(value.toLowerCase()))
190
+ .slice(0, 3)
191
+ .join(' - ');
192
+ }
193
+
194
+ waypointIconHtml(label, color) {
195
+ return waypointIconHtml(label, color);
196
+ }
197
+
198
+ @action async setupPreview() {
199
+ await this.mapSettings.load();
200
+
201
+ if (!this.shouldUseGoogleMaps && !this.leafletPluginsReady) {
202
+ await this.prepareLeafletPlugins();
203
+ }
204
+
205
+ this.syncPreview();
206
+ }
207
+
208
+ @action async prepareLeafletPlugins() {
209
+ if (this.leafletPluginsReady) {
210
+ return;
211
+ }
212
+
213
+ await ensureLeafletPluginsReady();
214
+ this.leafletPluginsReady = true;
215
+ }
216
+
217
+ @action didLoadLeafletMap({ target: map }) {
218
+ this.leafletMap = map;
219
+ this.syncPreview();
220
+ }
221
+
222
+ @action async setupGoogleMap(element) {
223
+ await this.mapSettings.load();
224
+ await this.loadGoogleMapsApi();
225
+
226
+ const googleMaps = window.google;
227
+ const { Map } = await googleMaps.maps.importLibrary('maps');
228
+ const mapOptions = {
229
+ center: { lat: this.center.lat, lng: this.center.lng },
230
+ zoom: 12,
231
+ mapTypeId: this.mapSettings.googleMapsMapType ?? googleMaps.maps.MapTypeId.ROADMAP,
232
+ disableDefaultUI: true,
233
+ gestureHandling: 'greedy',
234
+ clickableIcons: false,
235
+ styles: this.googleMapStyles,
236
+ };
237
+
238
+ if (this.mapSettings.googleMapsMapId) {
239
+ mapOptions.mapId = this.mapSettings.googleMapsMapId;
240
+ }
241
+
242
+ this.googleMap = new Map(element, mapOptions);
243
+ this.applyGoogleViewSettings();
244
+ this.syncPreview();
245
+ }
246
+
247
+ @action syncPreview() {
248
+ if (this.shouldUseGoogleMaps) {
249
+ this.applyGoogleViewSettings();
250
+ }
251
+
252
+ this.computeAndDrawRoute();
253
+ }
254
+
255
+ async computeAndDrawRoute() {
256
+ const token = ++this.computeToken;
257
+ this.error = null;
258
+
259
+ if (!this.canShowMap) {
260
+ this.route = null;
261
+ this.clearGoogleRoute();
262
+ return;
263
+ }
264
+
265
+ this.isLoading = true;
266
+
267
+ try {
268
+ const route = await this.computeRoute();
269
+
270
+ if (token !== this.computeToken) {
271
+ return;
272
+ }
273
+
274
+ this.route = route;
275
+ this.error = null;
276
+ this.fitLeafletMap();
277
+ this.drawGoogleRoute().catch((error) => this.logRenderError(error));
278
+ } catch (error) {
279
+ if (token !== this.computeToken) {
280
+ return;
281
+ }
282
+
283
+ this.error = error;
284
+ this.route = null;
285
+ this.logRouteError(error);
286
+ this.drawGoogleRoute().catch((renderError) => this.logRenderError(renderError));
287
+ } finally {
288
+ if (token === this.computeToken) {
289
+ this.isLoading = false;
290
+ }
291
+ }
292
+ }
293
+
294
+ async computeRoute() {
295
+ const engine = this.displayRouteEngine;
296
+ const options = {
297
+ payload: this.payload,
298
+ fitOptions: this.fitOptions,
299
+ };
300
+
301
+ if (!this.routeEngine?.compute) {
302
+ throw new Error('No route engine is available for the AI order preview.');
303
+ }
304
+
305
+ try {
306
+ return await this.routeEngine.compute(engine, this.coordinates, options);
307
+ } catch (error) {
308
+ if (engine !== 'osrm') {
309
+ return this.routeEngine.compute('osrm', this.coordinates, options);
310
+ }
311
+
312
+ throw error;
313
+ }
314
+ }
315
+
316
+ get fitOptions() {
317
+ return {
318
+ padding: [18, 18],
319
+ maxZoom: this.coordinates.length === 2 ? PREVIEW_MAX_ZOOM_TWO_POINTS : PREVIEW_MAX_ZOOM_MULTI_POINTS,
320
+ };
321
+ }
322
+
323
+ get googleMapStyles() {
324
+ if (this.mapSettings.showGoogleMapsTransitLayer) {
325
+ return GOOGLE_MAP_STYLES.filter((style) => style.featureType !== 'transit');
326
+ }
327
+
328
+ return GOOGLE_MAP_STYLES;
329
+ }
330
+
331
+ fitLeafletMap() {
332
+ if (!this.leafletMap || !this.routeCoordinates.length) {
333
+ return;
334
+ }
335
+
336
+ const bounds = this.route?.bounds ?? this.routeCoordinates;
337
+
338
+ if (isArray(bounds) && bounds.length > 1) {
339
+ this.leafletMap.fitBounds(bounds, this.fitOptions);
340
+ }
341
+ }
342
+
343
+ async drawGoogleRoute() {
344
+ if (!this.googleMap || !this.shouldUseGoogleMaps) {
345
+ return;
346
+ }
347
+
348
+ await this.loadGoogleMapsApi();
349
+ this.clearGoogleRoute();
350
+
351
+ const googleMaps = window.google;
352
+ const path = this.routeLineCoordinates.map(([lat, lng]) => ({ lat, lng }));
353
+
354
+ if (path.length > 1) {
355
+ this.routeStyles.forEach((style) => {
356
+ const polyline = new googleMaps.maps.Polyline({
357
+ path,
358
+ map: this.googleMap,
359
+ strokeColor: style.color,
360
+ strokeWeight: style.weight,
361
+ strokeOpacity: style.opacity,
362
+ });
363
+
364
+ this.googlePolylines.push(polyline);
365
+ });
366
+ }
367
+
368
+ const { AdvancedMarkerElement } = await googleMaps.maps.importLibrary('marker');
369
+ this.markerStops.forEach((stop) => {
370
+ const position = { lat: stop.location[0], lng: stop.location[1] };
371
+ const content = document.createElement('div');
372
+ content.className = 'fleetops-map-marker';
373
+ content.innerHTML = waypointIconHtml(stop.waypointLabel, stop.waypointColor);
374
+
375
+ let marker;
376
+ if (this.mapSettings.googleMapsMapId) {
377
+ marker = new AdvancedMarkerElement({
378
+ map: this.googleMap,
379
+ position,
380
+ title: stop.title,
381
+ content,
382
+ zIndex: stop.zIndexOffset,
383
+ });
384
+ } else {
385
+ marker = new googleMaps.maps.Marker({
386
+ map: this.googleMap,
387
+ position,
388
+ title: stop.title,
389
+ zIndex: stop.zIndexOffset,
390
+ icon: this.googleWaypointIcon(stop.waypointLabel, stop.waypointColor),
391
+ });
392
+ }
393
+
394
+ this.googleMarkers.push(marker);
395
+ });
396
+
397
+ this.fitGoogleMap();
398
+ }
399
+
400
+ fitGoogleMap() {
401
+ const googleMaps = window.google;
402
+ const map = this.googleMap;
403
+
404
+ if (!map || !googleMaps?.maps || !this.routeCoordinates.length) {
405
+ return;
406
+ }
407
+
408
+ const maxZoom = this.coordinates.length === 2 ? PREVIEW_MAX_ZOOM_TWO_POINTS : PREVIEW_MAX_ZOOM_MULTI_POINTS;
409
+ const bounds = new googleMaps.maps.LatLngBounds();
410
+ this.routeCoordinates.forEach(([lat, lng]) => bounds.extend({ lat, lng }));
411
+ map.fitBounds(bounds, 18);
412
+
413
+ this.clearGoogleListeners();
414
+ const idleListener = googleMaps.maps.event.addListenerOnce(map, 'idle', () => {
415
+ const currentZoom = map.getZoom?.();
416
+
417
+ if (Number.isFinite(currentZoom) && currentZoom > maxZoom) {
418
+ map.setZoom?.(maxZoom);
419
+ }
420
+ });
421
+
422
+ this.googleListeners.push(idleListener);
423
+ }
424
+
425
+ clearGoogleRoute() {
426
+ this.googleMarkers.forEach((marker) => {
427
+ if ('map' in marker) {
428
+ marker.map = null;
429
+ } else {
430
+ marker.setMap?.(null);
431
+ }
432
+ });
433
+ this.googlePolylines.forEach((polyline) => polyline.setMap(null));
434
+ this.googleMarkers = [];
435
+ this.googlePolylines = [];
436
+ }
437
+
438
+ clearGoogleListeners() {
439
+ const googleMaps = window.google;
440
+
441
+ this.googleListeners.forEach((listener) => {
442
+ googleMaps?.maps?.event?.removeListener?.(listener);
443
+ });
444
+ this.googleListeners = [];
445
+ }
446
+
447
+ applyGoogleViewSettings() {
448
+ if (!this.googleMap || !window.google?.maps) {
449
+ return;
450
+ }
451
+
452
+ const googleMaps = window.google;
453
+ this.googleTrafficLayer?.setMap(null);
454
+ this.googleTransitLayer?.setMap(null);
455
+ this.googleTrafficLayer = null;
456
+ this.googleTransitLayer = null;
457
+ this.googleMap.setOptions({
458
+ mapTypeId: this.mapSettings.googleMapsMapType ?? googleMaps.maps.MapTypeId.ROADMAP,
459
+ clickableIcons: false,
460
+ styles: this.googleMapStyles,
461
+ });
462
+
463
+ if (this.mapSettings.showGoogleMapsTrafficLayer) {
464
+ this.googleTrafficLayer = new googleMaps.maps.TrafficLayer();
465
+ this.googleTrafficLayer.setMap(this.googleMap);
466
+ }
467
+
468
+ if (this.mapSettings.showGoogleMapsTransitLayer) {
469
+ this.googleTransitLayer = new googleMaps.maps.TransitLayer();
470
+ this.googleTransitLayer.setMap(this.googleMap);
471
+ }
472
+ }
473
+
474
+ logRouteError(error) {
475
+ debug(`[Fleet-Ops AI Route Preview] Unable to calculate route with ${this.displayRouteEngine}. coordinates=${this.coordinates.length}. error=${error?.message ?? error}`);
476
+ }
477
+
478
+ logRenderError(error) {
479
+ debug(`[Fleet-Ops AI Route Preview] Route calculated but map rendering failed. error=${error?.message ?? error}`);
480
+ }
481
+
482
+ googleWaypointIcon(label, color) {
483
+ const svg = `
484
+ <svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 34 34">
485
+ <circle cx="17" cy="17" r="15" fill="${color}" stroke="white" stroke-width="2.5" />
486
+ <text x="17" y="21" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="700" fill="white">${label}</text>
487
+ </svg>
488
+ `;
489
+
490
+ return {
491
+ url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`,
492
+ scaledSize: new window.google.maps.Size(34, 34),
493
+ anchor: new window.google.maps.Point(17, 17),
494
+ };
495
+ }
496
+
497
+ async loadGoogleMapsApi() {
498
+ if (window.google?.maps?.importLibrary) {
499
+ return true;
500
+ }
501
+
502
+ if (this.googleApiLoadPromise) {
503
+ return this.googleApiLoadPromise;
504
+ }
505
+
506
+ const apiKey = this.mapSettings.googleMapsApiKey;
507
+ this.googleApiLoadPromise = new Promise((resolve, reject) => {
508
+ const callbackName = `__fleetops_ai_preview_gmaps_${Date.now().toString(36)}`;
509
+ window[callbackName] = () => {
510
+ delete window[callbackName];
511
+ resolve(true);
512
+ };
513
+
514
+ const script = document.createElement('script');
515
+ script.async = true;
516
+ script.defer = true;
517
+ script.onerror = () => {
518
+ delete window[callbackName];
519
+ reject(new Error('Failed to load Google Maps API for AI route preview.'));
520
+ };
521
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=geometry,marker,routes&callback=${callbackName}&loading=async`;
522
+ document.head.appendChild(script);
523
+ });
524
+
525
+ return this.googleApiLoadPromise;
526
+ }
527
+ }
@@ -3,30 +3,43 @@
3
3
  <div class="grid grid-cols-2 gap-2 text-xs dark:text-gray-100">
4
4
  <div class="field-info-container">
5
5
  <div class="field-name">{{t "common.name"}}</div>
6
- <div class="field-value">{{n-a @resource.name}}</div>
6
+ <div class="field-value">{{n-a this.resource.name}}</div>
7
7
  </div>
8
8
  <div class="field-info-container">
9
9
  <div class="field-name">{{t "resource.service-area"}}</div>
10
- <div class="field-value">{{n-a @resource.service_area.name}}</div>
10
+ <div class="field-value">{{n-a this.resource.service_area.name}}</div>
11
11
  </div>
12
12
  <div class="field-info-container">
13
13
  <div class="field-name">{{t "resource.zone"}}</div>
14
- <div class="field-value">{{n-a @resource.zone.name}}</div>
14
+ <div class="field-value">{{n-a this.resource.zone.name}}</div>
15
15
  </div>
16
16
  <div class="field-info-container">
17
17
  <div class="field-name">{{t "fleet.fields.task"}}</div>
18
- <div class="field-value">{{n-a @resource.task}}</div>
18
+ <div class="field-value">{{n-a this.resource.task}}</div>
19
19
  </div>
20
20
  <div class="field-info-container">
21
21
  <div class="field-name">{{t "fleet.fields.active-manpower"}}</div>
22
- <div class="field-value">{{@resource.drivers_online_count}} of {{@resource.drivers_count}} Online</div>
22
+ <div class="field-value">{{this.driversOnlineCount}} of {{this.driversCount}} Online</div>
23
23
  </div>
24
24
  <div class="field-info-container">
25
25
  <div class="field-name">{{t "common.date-created"}}</div>
26
- <div class="field-value">{{@resource.createdAtShort}}</div>
26
+ <div class="field-value">{{this.resource.createdAtShort}}</div>
27
27
  </div>
28
28
  </div>
29
29
  </ContentPanel>
30
30
 
31
- <CustomField::Yield @subject={{@resource}} @viewMode={{true}} @wrapperClass="bordered-top" />
32
- </div>
31
+ <ContentPanel @title="Hierarchy" @open={{true}} @wrapperClass="bordered-top" @panelBodyWrapperClass="p-0i">
32
+ <Fleet::HierarchyTree
33
+ @fleet={{this.resource}}
34
+ @onViewFleet={{this.viewFleet}}
35
+ @onViewDriver={{this.viewDriver}}
36
+ @onViewVehicle={{this.viewVehicle}}
37
+ @onAssignDriver={{this.assignDriver}}
38
+ @onAssignVehicle={{this.assignVehicle}}
39
+ />
40
+ </ContentPanel>
41
+
42
+ <CustomField::Yield @subject={{this.resource}} @viewMode={{true}} @wrapperClass="bordered-top" />
43
+
44
+ <Spacer @height="200px" />
45
+ </div>