@fleetbase/fleetops-engine 0.6.60 → 0.6.61

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 (57) hide show
  1. package/RELEASE.md +9 -20
  2. package/addon/components/ai/route-preview-map.js +1 -5
  3. package/addon/components/customer/orders.js +2 -1
  4. package/addon/components/driver/card.hbs +49 -0
  5. package/addon/components/driver/card.js +30 -0
  6. package/addon/components/map/leaflet-live-map.js +4 -19
  7. package/addon/components/modals/place-details.hbs +1 -1
  8. package/addon/components/modals/point-map.hbs +1 -1
  9. package/addon/components/orchestrator-workbench.js +2 -1
  10. package/addon/components/order/form/route.js +3 -0
  11. package/addon/components/order-tracking-lookup.js +2 -1
  12. package/addon/components/place/details.hbs +1 -1
  13. package/addon/components/positions-replay.js +4 -1
  14. package/addon/components/service-area/details.hbs +1 -1
  15. package/addon/components/widget/live-fleet.js +7 -7
  16. package/addon/components/zone/details.hbs +1 -1
  17. package/addon/controllers/management/drivers/index.js +22 -1
  18. package/addon/controllers/settings/map.js +16 -0
  19. package/addon/helpers/leaflet-tile-url.js +19 -0
  20. package/addon/services/map-settings.js +22 -0
  21. package/addon/templates/management/drivers/index.hbs +67 -33
  22. package/addon/templates/settings/map.hbs +10 -0
  23. package/addon/utils/leaflet-tile-url.js +31 -0
  24. package/addon/utils/prepare-place-for-save.js +13 -0
  25. package/app/components/driver/card.js +1 -0
  26. package/app/helpers/leaflet-tile-url.js +1 -0
  27. package/app/utils/leaflet-tile-url.js +1 -0
  28. package/composer.json +1 -1
  29. package/extension.json +1 -1
  30. package/index.js +11 -0
  31. package/package.json +2 -1
  32. package/server/src/Http/Controllers/Api/v1/DriverController.php +220 -3
  33. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  34. package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
  35. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  36. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  37. package/server/src/Http/Filter/IssueFilter.php +29 -0
  38. package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
  39. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  40. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  41. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  42. package/server/src/Models/Place.php +1 -1
  43. package/server/src/routes.php +21 -0
  44. package/server/tests/ApiDriverControllerContractsTest.php +33 -0
  45. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  46. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  47. package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
  48. package/server/tests/CompactResourceSerializationTest.php +13 -7
  49. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  50. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  51. package/server/tests/RequestContractsTest.php +5 -0
  52. package/server/tests/SettingControllerContractsTest.php +33 -0
  53. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  54. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  55. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  56. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  57. package/translations/en-us.yaml +4 -0
package/RELEASE.md CHANGED
@@ -1,34 +1,23 @@
1
- > v0.6.60 ~ "Closes an authentication bypass, and clears a run of API 500s"
1
+ > v0.6.61 ~ "A grid view for drivers, a sequenceable order flow, and two silent API no-ops closed"
2
2
 
3
3
  ---
4
4
  ## Highlights
5
- A security fix and a broad sweep of public API defects surfaced by running the official Postman collection against a live stack. Several endpoints answered `500` where a `404` or `422` belonged, and a few were unreachable entirely.
5
+ Two API defects in this release were silent: both answered `200` and neither did what the caller asked. A vehicle odometer update was discarded, and a driver-scoped list came back unscoped. Alongside them, the order config flow now publishes enough of its shape to be sequenced, and Drivers Management gains a card layout.
6
6
 
7
7
  ---
8
- ## Security
9
- - **Closed a verify-code authentication bypass in the driver flow.** Please upgrade.
10
- - The non-production verification-code bypass is now scoped to explicitly designated review accounts, so a bypass code alone is not enough — the identity has to be on the allowlist too.
8
+ ## Features
9
+ - **Drivers Management has a card view.** A layout toggle switches the index between the table and a card grid, and the choice is remembered across visits.
10
+ - **The order config flow publishes its graph.** `activities`, `sequence` and `logic` now ride along with each activity, so a consumer can order the flow and offer a next step instead of rendering an unordered set. Transitions are normalised to a list of codes regardless of which of the two stored shapes a flow was authored in.
11
11
 
12
12
  ---
13
13
  ## Bug Fixes
14
- - **Driver `register-device` was unreachable on both driver routes.** Laravel never injects a class-typed parameter that declares a default, so the injected request was always null.
15
- - **Geofence driver history asked for a UUID the API never issues.** It now resolves the driver by the public id callers actually hold.
16
- - **`/from-qr` returned a 500**, and the QR code's content is now published in debug mode so the flow can be exercised.
17
- - **Fuel reports could not be created without a location**, and could not be updated.
18
- - **A sensor could not be created at all** — `last_position` had no default.
19
- - **Customer signup with a place failed** — the Place location now defaults.
20
- - Unknown onboard organization answers `404` instead of `500`.
21
- - Duplicate part SKU and fuel transaction answer `422` instead of `500`.
22
- - Restored the vehicle maintenance schedule workflows.
14
+ - **`PUT /v1/vehicles/{id}` silently discarded the odometer.** The field is fillable on the model but was missing from the controller's input projection, so a driver app recording mileage received a `200` and a correct-looking body while the reading was dropped. `odometer` and `odometer_unit` are now accepted and validated.
15
+ - **Issue and fuel-report lists scoped by `driver_uuid` came back scoped by nothing.** The base filter silently ignores a query parameter it cannot match to a method, so the filter was dropped and the response was narrowed only by company — every driver's records, with no sign the request had been narrowed at all. `driver_uuid`, `driver_assigned` and `vehicle_uuid` are now recognised on both filters.
16
+ - **Leaflet marker icons are served from the leaflet package** rather than resolving to a broken URL.
23
17
 
24
18
  ---
25
19
  ## Testing
26
- - Coverage restored to 100% across the QR, geofence, driver auth, customer request and navigator changes.
27
-
28
- ---
29
- ## Continuous Integration
30
- - The server, Ember and Postman workflows now run on `dev-v*` release branches.
31
- - The contract run tests this branch's API code rather than the published package.
20
+ - Coverage held at 100% across the vehicle input projection, both driver-scoped filters, and the order config flow projection.
32
21
 
33
22
  ---
34
23
  ## Need help?
@@ -168,11 +168,7 @@ export default class AiRoutePreviewMapComponent extends Component {
168
168
  get tileUrl() {
169
169
  const theme = document.body?.dataset?.theme;
170
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';
171
+ return this.mapSettings.getLeafletTileUrl(theme === 'dark' ? 'dark' : 'light');
176
172
  }
177
173
 
178
174
  get emptyText() {
@@ -9,6 +9,7 @@ import { debug } from '@ember/debug';
9
9
  import { task, timeout } from 'ember-concurrency';
10
10
  import { Control as RoutingControl } from '@fleetbase/leaflet-routing-machine';
11
11
  import engineService from '@fleetbase/ember-core/decorators/engine-service';
12
+ import { DEFAULT_LEAFLET_TILE_URL } from '../../utils/leaflet-tile-url';
12
13
  import registerComponent from '@fleetbase/ember-core/utils/register-component';
13
14
  import OrderProgressCardComponent from '../order-progress-card';
14
15
  import DisplayPlaceComponent from '../display-place';
@@ -38,7 +39,7 @@ export default class CustomerOrdersComponent extends Component {
38
39
  @tracked longitude;
39
40
  @tracked route;
40
41
  @tracked query;
41
- @tracked tileSourceUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
42
+ @tracked tileSourceUrl = DEFAULT_LEAFLET_TILE_URL;
42
43
  @tracked scheduledAt;
43
44
  @tracked deliveryInstructions = {};
44
45
 
@@ -0,0 +1,49 @@
1
+ <Layout::Resource::Card ...attributes as |Card|>
2
+ <Card.header class="{{@headerClass}}">
3
+ <RegistryYield @registry="fleet-ops:component:driver:card:header:start" as |RegistryComponent|>
4
+ <RegistryComponent @order={{@resource}} @resource={{@resource}} />
5
+ </RegistryYield>
6
+ <div class="flex items-center w-full min-w-0">
7
+ <div class="font-semibold flex-1 truncate" data-test-driver-name>{{or @resource.name @resource.display_name}}</div>
8
+ <FaIcon @icon="circle" @size="xs" class="ml-2 flex-none {{if @resource.online 'text-green-500' 'text-gray-400'}}" data-test-driver-online-indicator />
9
+ </div>
10
+ <div class="text-gray-400 dark:text-gray-500 text-sm w-full truncate">{{n-a @resource.phone @resource.public_id}}</div>
11
+ {{#if (has-block "header")}}
12
+ {{yield to="header"}}
13
+ {{/if}}
14
+ <RegistryYield @registry="fleet-ops:component:driver:card:header:end" as |RegistryComponent|>
15
+ <RegistryComponent @order={{@resource}} @resource={{@resource}} />
16
+ </RegistryYield>
17
+ </Card.header>
18
+ <Card.body class="{{@bodyClass}} h-40 overflow-hidden">
19
+ <Image src={{avatar-url @resource.photo_url}} @fallbackSrc={{this.fallbackImage}} class="block w-full h-full object-cover rounded-none" data-test-driver-photo />
20
+ {{#if (has-block "body")}}
21
+ {{yield to="body"}}
22
+ {{/if}}
23
+ </Card.body>
24
+ <Card.footer class={{@footerClass}}>
25
+ <RegistryYield @registry="fleet-ops:component:driver:card:footer:start" as |RegistryComponent|>
26
+ <RegistryComponent @order={{@resource}} @resource={{@resource}} />
27
+ </RegistryYield>
28
+ <div class="flex flex-row items-center justify-between w-full">
29
+ <Badge @status={{or this.statusValue "default"}} @size="xs" data-test-driver-status-badge>{{this.statusLabel}}</Badge>
30
+ <div class="flex flex-row items-center space-x-1">
31
+ <Button @icon="eye" @size="xs" @onClick={{fn this.driverActions.transition.view @resource}} />
32
+ <Button @icon="pencil" @size="xs" @onClick={{fn this.driverActions.transition.edit @resource}} />
33
+ <Button @icon="trash" @size="xs" @type="danger" @onClick={{fn this.driverActions.delete @resource}} />
34
+ </div>
35
+ </div>
36
+ <RegistryYield @registry="fleet-ops:component:driver:card:footer:end" as |RegistryComponent|>
37
+ <RegistryComponent @order={{@resource}} @resource={{@resource}} />
38
+ </RegistryYield>
39
+ {{#if (has-block "footer")}}
40
+ {{yield to="footer"}}
41
+ {{/if}}
42
+ <div class="mt-1.5 w-full">
43
+ <div class="text-gray-400 dark:text-gray-500 text-xs truncate">
44
+ <FaIcon @icon="car" @prefix="fas" class="mr-1" />{{n-a this.assignedVehicleLabel}}
45
+ </div>
46
+ <div class="text-gray-400 dark:text-gray-500 text-xs">Last Modified: {{@resource.updatedAt}}</div>
47
+ </div>
48
+ </Card.footer>
49
+ </Layout::Resource::Card>
@@ -0,0 +1,30 @@
1
+ import Component from '@glimmer/component';
2
+ import { inject as service } from '@ember/service';
3
+ import { get } from '@ember/object';
4
+ import config from 'ember-get-config';
5
+
6
+ export default class DriverCardComponent extends Component {
7
+ @service driverActions;
8
+
9
+ get resource() {
10
+ return this.args.resource;
11
+ }
12
+
13
+ get fallbackImage() {
14
+ return config?.defaultValues?.driverImage;
15
+ }
16
+
17
+ get statusValue() {
18
+ return get(this.resource, 'status') ?? '';
19
+ }
20
+
21
+ get statusLabel() {
22
+ return this.statusValue || '-';
23
+ }
24
+
25
+ get assignedVehicleLabel() {
26
+ const driver = this.resource;
27
+
28
+ return get(driver, 'vehicle_assigned.display_name') ?? get(driver, 'vehicle.display_name') ?? get(driver, 'vehicle_name');
29
+ }
30
+ }
@@ -48,7 +48,6 @@ export default class MapLeafletLiveMapComponent extends Component {
48
48
  @tracked latitude = this.location.getLatitude();
49
49
  @tracked longitude = this.location.getLongitude();
50
50
  @tracked contextmenuItems = [];
51
- @tracked tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
52
51
  @tracked theme = 'light';
53
52
  @tracked routes = [];
54
53
  @tracked drivers = [];
@@ -57,6 +56,10 @@ export default class MapLeafletLiveMapComponent extends Component {
57
56
  @tracked leafletPluginsReady = hasLeafletPluginsReady();
58
57
  _viewportReloadLocks = new Set();
59
58
 
59
+ get tileUrl() {
60
+ return this.mapSettings.getLeafletTileUrl(this.theme);
61
+ }
62
+
60
63
  constructor() {
61
64
  super(...arguments);
62
65
 
@@ -888,22 +891,4 @@ export default class MapLeafletLiveMapComponent extends Component {
888
891
  // Fallback to default Singapore longitude
889
892
  return 103.8864;
890
893
  }
891
-
892
- #changeTileSource(source) {
893
- switch (source) {
894
- case 'dark':
895
- this.theme = 'dark';
896
- this.tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
897
- break;
898
- case 'custom':
899
- this.theme = 'custom';
900
- this.tileUrl = source.startsWith('https://') ? source : 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
901
- break;
902
- case 'light':
903
- default:
904
- this.theme = 'light';
905
- this.tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
906
- break;
907
- }
908
- }
909
894
  }
@@ -3,7 +3,7 @@
3
3
  <div class="flex">
4
4
  <div class="w-48 mb-6 mr-6">
5
5
  <LeafletMap class="w-full h-48 mb-2 rounded-md shadow-md" @lat={{@options.place.latitude}} @lng={{@options.place.longitude}} @zoom={{12}} as |layers|>
6
- <layers.tile @url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png" />
6
+ <layers.tile @url={{leaflet-tile-url}} />
7
7
 
8
8
  <layers.marker @location={{@options.place.coordinates}} />
9
9
  </LeafletMap>
@@ -1,7 +1,7 @@
1
1
  <Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{@onConfirm}} @decline={{@onDecline}}>
2
2
  <div class="fleetbase-leaflet-map-container">
3
3
  <LeafletMap class="map-height-base" @lat={{@options.latitude}} @lng={{@options.longitude}} @zoom={{12}} as |layers|>
4
- <layers.tile @url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png" />
4
+ <layers.tile @url={{leaflet-tile-url}} />
5
5
 
6
6
  <layers.marker @icon={{@options.icon}} @location={{point-to-coordinates @options.location}} @riseOnHover={{true}} as |marker|>
7
7
  <marker.popup @maxWidth="500" @minWidth="180">
@@ -36,6 +36,7 @@ export default class OrchestratorWorkbenchComponent extends Component {
36
36
  @service modalsManager;
37
37
  @service location;
38
38
  @service mapManager;
39
+ @service mapSettings;
39
40
  @service routeEngine;
40
41
  @service('order-allocation') allocationService;
41
42
 
@@ -661,7 +662,7 @@ export default class OrchestratorWorkbenchComponent extends Component {
661
662
 
662
663
  get tileSourceUrl() {
663
664
  const isDark = document.documentElement.classList.contains('dark');
664
- return isDark ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' : 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
665
+ return this.mapSettings.getLeafletTileUrl(isDark ? 'dark' : 'light');
665
666
  }
666
667
 
667
668
  // ── Computed helpers ──────────────────────────────────────────────────────
@@ -5,6 +5,7 @@ import { action, get } from '@ember/object';
5
5
  import { task } from 'ember-concurrency';
6
6
  import { colorForId, routeColorForStatus, routeStyleForStatus } from '../../../utils/route-colors';
7
7
  import { buildRoutePointMarkerPresentation, buildRoutePointsFromPayload, describeRoutePoint } from '../../../utils/route-visualization';
8
+ import preparePlaceForSave from '../../../utils/prepare-place-for-save';
8
9
 
9
10
  const ORDER_ROUTE_PREVIEW_PADDING_BOTTOM_RIGHT = [420, 0];
10
11
  const ORDER_ROUTE_PREVIEW_MAX_ZOOM_TWO_POINTS = 13;
@@ -153,6 +154,7 @@ export default class OrderFormRouteComponent extends Component {
153
154
  @action setWaypointPlace(index, place) {
154
155
  if (!this.args.resource.payload.waypoints[index]) return;
155
156
 
157
+ place = preparePlaceForSave(this.store, place);
156
158
  this.args.resource.payload.waypoints[index].place = place;
157
159
  this.args.resource.payload.waypoints[index]?.setProperties({
158
160
  street1: place.street1,
@@ -186,6 +188,7 @@ export default class OrderFormRouteComponent extends Component {
186
188
  }
187
189
 
188
190
  @action setPayloadPlace(prop, place) {
191
+ place = preparePlaceForSave(this.store, place);
189
192
  this.args.resource.payload[prop] = place;
190
193
  this.previewRoute();
191
194
  this.requestServiceQuoteRefresh(`route.${prop}.changed`);
@@ -10,6 +10,7 @@ import { task } from 'ember-concurrency';
10
10
  import { OSRMv1, Control as RoutingControl } from '@fleetbase/leaflet-routing-machine';
11
11
  import getRoutingHost from '@fleetbase/ember-core/utils/get-routing-host';
12
12
  import engineService from '@fleetbase/ember-core/decorators/engine-service';
13
+ import { DEFAULT_LEAFLET_TILE_URL } from '../utils/leaflet-tile-url';
13
14
 
14
15
  export default class OrderTrackingLookupComponent extends Component {
15
16
  @service urlSearchParams;
@@ -28,7 +29,7 @@ export default class OrderTrackingLookupComponent extends Component {
28
29
  @tracked latitude;
29
30
  @tracked longitude;
30
31
  @tracked route;
31
- @tracked tileSourceUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
32
+ @tracked tileSourceUrl = DEFAULT_LEAFLET_TILE_URL;
32
33
 
33
34
  constructor() {
34
35
  super(...arguments);
@@ -75,7 +75,7 @@
75
75
  @zoomControl={{false}}
76
76
  as |layers|
77
77
  >
78
- <layers.tile @url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png" />
78
+ <layers.tile @url={{leaflet-tile-url}} />
79
79
  <layers.marker
80
80
  @id={{@resource.id}}
81
81
  @icon={{icon iconUrl=(or @resource.avatar_url "/engines-dist/images/building-marker.png") iconSize=(array 40 40)}}
@@ -15,6 +15,7 @@ export default class PositionsReplayComponent extends Component {
15
15
  @service positionPlayback;
16
16
  @service notifications;
17
17
  @service location;
18
+ @service mapSettings;
18
19
 
19
20
  /** Component ID */
20
21
  id = guidFor(this);
@@ -29,7 +30,9 @@ export default class PositionsReplayComponent extends Component {
29
30
  @tracked latitude = this.args.resource.latitude || this.location.getLatitude();
30
31
  @tracked longitude = this.args.resource.longitude || this.location.getLongitude();
31
32
  @tracked zoom = 14;
32
- @tracked tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
33
+ get tileUrl() {
34
+ return this.mapSettings.getLeafletTileUrl('light');
35
+ }
33
36
 
34
37
  /** Computed properties - read state from service */
35
38
  get isReplaying() {
@@ -27,7 +27,7 @@
27
27
  @zoomControl={{false}}
28
28
  as |layers|
29
29
  >
30
- <layers.tile @url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png" />
30
+ <layers.tile @url={{leaflet-tile-url}} />
31
31
  <layers.polygon
32
32
  @id={{@resource.id}}
33
33
  @record={{@resource}}
@@ -8,15 +8,11 @@ import { task } from 'ember-concurrency';
8
8
  import { renderCompleted, waitForInsertedAndSized } from '@fleetbase/ember-ui/utils/dom';
9
9
  import LeafletTrackingMarkerComponent from '../leaflet-tracking-marker';
10
10
  import ensureLeafletDrawEditNamespace from '../../utils/leaflet-draw-namespace-guard';
11
+ import { DEFAULT_LEAFLET_TILE_ATTRIBUTION } from '../../utils/leaflet-tile-url';
11
12
 
12
13
  const DEFAULT_CENTER = [1.31, 103.85];
13
14
  const DEFAULT_ZOOM = 11;
14
- // Match the rest of fleetops' Leaflet maps (light Carto basemap), which keeps the
15
- // dashboard styling consistent with the operational live map. Falls back gracefully
16
- // in dark mode via the next-leaflet-container-map theming.
17
- const TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
18
- const TILE_URL_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
19
- const TILE_ATTRIBUTION = '&copy; OpenStreetMap contributors &copy; CARTO';
15
+ const TILE_ATTRIBUTION = DEFAULT_LEAFLET_TILE_ATTRIBUTION;
20
16
  const RECONCILE_INTERVAL_MS = 5 * 60_000;
21
17
 
22
18
  /**
@@ -30,6 +26,7 @@ export default class WidgetLiveFleetComponent extends Component {
30
26
  @service fetch;
31
27
  @service socket;
32
28
  @service currentUser;
29
+ @service mapSettings;
33
30
 
34
31
  @tracked data = null;
35
32
  @tracked error = null;
@@ -38,7 +35,7 @@ export default class WidgetLiveFleetComponent extends Component {
38
35
 
39
36
  get tileUrl() {
40
37
  const isDark = typeof document !== 'undefined' && document.documentElement?.dataset?.theme === 'dark';
41
- return isDark ? TILE_URL_DARK : TILE_URL;
38
+ return this.mapSettings.getLeafletTileUrl(isDark ? 'dark' : 'light');
42
39
  }
43
40
 
44
41
  socketChannel = null;
@@ -78,6 +75,9 @@ export default class WidgetLiveFleetComponent extends Component {
78
75
  this.#ensureTrackingMarkerRegistered();
79
76
  this.#patchLeafletDrawInitHook();
80
77
  this.#resetMapDeferred();
78
+ // Widgets can render before the fleetops engine boots, so make sure the
79
+ // company's map settings (custom tile provider URL) are loaded.
80
+ this.mapSettings.load();
81
81
  this.load.perform();
82
82
  this.subscribe();
83
83
  this.reconcileTimer = setInterval(() => this.load.perform(), RECONCILE_INTERVAL_MS);
@@ -22,7 +22,7 @@
22
22
  @zoomControl={{false}}
23
23
  as |layers|
24
24
  >
25
- <layers.tile @url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png" />
25
+ <layers.tile @url={{leaflet-tile-url}} />
26
26
  <layers.polygon
27
27
  @id={{@resource.id}}
28
28
  @record={{@resource}}
@@ -12,6 +12,7 @@ export default class ManagementDriversIndexController extends Controller {
12
12
  @service notifications;
13
13
  @service tableContext;
14
14
  @service intl;
15
+ @service appCache;
15
16
 
16
17
  /** query params */
17
18
  @tracked queryParams = [
@@ -45,7 +46,7 @@ export default class ManagementDriversIndexController extends Controller {
45
46
  @tracked status;
46
47
  @tracked created_at;
47
48
  @tracked updated_at;
48
- @tracked layout = 'table';
49
+ @tracked layout = this.appCache.get('fleetops:drivers:layout', 'table');
49
50
  @tracked table;
50
51
 
51
52
  hasAssignedVehicle(driver) {
@@ -71,6 +72,25 @@ export default class ManagementDriversIndexController extends Controller {
71
72
  /** action buttons */
72
73
  get actionButtons() {
73
74
  return [
75
+ {
76
+ component: 'dropdown-button',
77
+ icon: 'display',
78
+ size: 'xs',
79
+ items: [
80
+ {
81
+ label: this.intl.t('common.table-view'),
82
+ icon: 'table-list',
83
+ onClick: () => this.changeLayout('table'),
84
+ },
85
+ {
86
+ label: this.intl.t('common.grid-view'),
87
+ icon: 'grip',
88
+ onClick: () => this.changeLayout('grid'),
89
+ },
90
+ ],
91
+ renderInPlace: true,
92
+ helpText: 'Change the layout',
93
+ },
74
94
  {
75
95
  icon: 'refresh',
76
96
  onClick: this.driverActions.refresh,
@@ -377,6 +397,7 @@ export default class ManagementDriversIndexController extends Controller {
377
397
 
378
398
  @action changeLayout(layout) {
379
399
  this.layout = layout;
400
+ this.appCache.set('fleetops:drivers:layout', layout);
380
401
  }
381
402
 
382
403
  @action createIssue(driver) {
@@ -33,6 +33,8 @@ export default class SettingsMapController extends Controller {
33
33
  * @var {String}
34
34
  */
35
35
  @tracked mapProvider = 'leaflet';
36
+ @tracked leafletTileUrl = '';
37
+ @tracked leafletDarkTileUrl = '';
36
38
  @tracked googleMapsMapType = 'roadmap';
37
39
  @tracked showGoogleMapsTrafficLayer = false;
38
40
  @tracked showGoogleMapsTransitLayer = false;
@@ -62,6 +64,16 @@ export default class SettingsMapController extends Controller {
62
64
  return this.mapProvider === 'google';
63
65
  }
64
66
 
67
+ /**
68
+ * True when the user has selected Leaflet as the provider.
69
+ *
70
+ * @memberof SettingsMapController
71
+ * @return {Boolean}
72
+ */
73
+ get isLeafletSelected() {
74
+ return this.mapProvider === 'leaflet';
75
+ }
76
+
65
77
  // ─── Actions ───────────────────────────────────────────────────────────────
66
78
 
67
79
  /**
@@ -77,6 +89,8 @@ export default class SettingsMapController extends Controller {
77
89
  @task *saveSettings() {
78
90
  const settings = {
79
91
  mapProvider: this.mapProvider,
92
+ leafletTileUrl: (this.leafletTileUrl ?? '').trim(),
93
+ leafletDarkTileUrl: (this.leafletDarkTileUrl ?? '').trim(),
80
94
  googleMapsMapType: this.googleMapsMapType,
81
95
  showGoogleMapsTrafficLayer: this.showGoogleMapsTrafficLayer,
82
96
  showGoogleMapsTransitLayer: this.showGoogleMapsTransitLayer,
@@ -105,6 +119,8 @@ export default class SettingsMapController extends Controller {
105
119
  const response = yield this.mapSettings.load({ force: true });
106
120
 
107
121
  this.mapProvider = response?.mapProvider ?? 'leaflet';
122
+ this.leafletTileUrl = response?.leafletTileUrl ?? '';
123
+ this.leafletDarkTileUrl = response?.leafletDarkTileUrl ?? '';
108
124
  this.googleMapsMapType = response?.googleMapsMapType ?? 'roadmap';
109
125
  this.showGoogleMapsTrafficLayer = Boolean(response?.showGoogleMapsTrafficLayer);
110
126
  this.showGoogleMapsTransitLayer = Boolean(response?.showGoogleMapsTransitLayer);
@@ -0,0 +1,19 @@
1
+ import Helper from '@ember/component/helper';
2
+ import { inject as service } from '@ember/service';
3
+
4
+ /**
5
+ * Resolves the Leaflet tile URL from Fleet-Ops map settings.
6
+ *
7
+ * Usage:
8
+ * <layers.tile @url={{leaflet-tile-url}} />
9
+ * <layers.tile @url={{leaflet-tile-url theme="dark"}} />
10
+ *
11
+ * Recomputes automatically when map settings load or change.
12
+ */
13
+ export default class LeafletTileUrlHelper extends Helper {
14
+ @service mapSettings;
15
+
16
+ compute(_params, { theme = 'light' } = {}) {
17
+ return this.mapSettings.getLeafletTileUrl(theme);
18
+ }
19
+ }
@@ -1,9 +1,12 @@
1
1
  import Service from '@ember/service';
2
2
  import { tracked } from '@glimmer/tracking';
3
3
  import { inject as service } from '@ember/service';
4
+ import { getLeafletTileUrl } from '../utils/leaflet-tile-url';
4
5
 
5
6
  const DEFAULT_SETTINGS = {
6
7
  mapProvider: 'leaflet',
8
+ leafletTileUrl: '',
9
+ leafletDarkTileUrl: '',
7
10
  googleMapsApiKey: '',
8
11
  googleMapsMapId: '',
9
12
  googleMapsMapType: 'roadmap',
@@ -45,6 +48,25 @@ export default class MapSettingsService extends Service {
45
48
  return this.mapProvider === 'google';
46
49
  }
47
50
 
51
+ get leafletTileUrl() {
52
+ return this.getLeafletTileUrl('light');
53
+ }
54
+
55
+ get leafletDarkTileUrl() {
56
+ return this.getLeafletTileUrl('dark');
57
+ }
58
+
59
+ /**
60
+ * Resolve the Leaflet tile URL for a theme, preferring the company's
61
+ * configured custom tile provider and falling back to the keyless default.
62
+ *
63
+ * @param {String} theme 'light' or 'dark'
64
+ * @return {String}
65
+ */
66
+ getLeafletTileUrl(theme = 'light') {
67
+ return getLeafletTileUrl(this.settings, theme);
68
+ }
69
+
48
70
  async load({ force = false } = {}) {
49
71
  if (!force && this.isLoaded) {
50
72
  return this.settings;
@@ -1,33 +1,67 @@
1
- <Layout::Resource::Tabular
2
- @resource="driver"
3
- @title={{t "resource.drivers"}}
4
- @searchQuery={{this.query}}
5
- @onSearch={{perform this.driverActions.controllerSearchTask this}}
6
- @data={{@model}}
7
- @columns={{this.columns}}
8
- @page={{this.page}}
9
- @onPageChange={{fn (mut this.page)}}
10
- @setupTable={{fn (mut this.table)}}
11
- @actionButtons={{this.actionButtons}}
12
- @bulkActions={{this.bulkActions}}
13
- @controller={{this}}
14
- @emptyStateComponent={{component
15
- "table/empty-state"
16
- icon="id-card"
17
- title="Add your first driver"
18
- description="Drivers power dispatching, live fleet visibility, assignments, and delivery activity."
19
- filteredTitle="No drivers match your search"
20
- filteredDescription="Adjust the search or filters to find another driver."
21
- primaryText="New driver"
22
- primaryIcon="plus"
23
- primaryAction=this.driverActions.transition.create
24
- secondaryText="Import"
25
- secondaryIcon="upload"
26
- secondaryAction=this.driverActions.import
27
- docsSlug="fleet-ops/resources/drivers/overview"
28
- docsTitle="Drivers guide"
29
- docsText="Driver guide"
30
- docsSource="fleet-ops-empty-drivers"
31
- }}
32
- />
33
- {{outlet}}
1
+ {{#if (eq this.layout "table")}}
2
+ <Layout::Resource::Tabular
3
+ @resource="driver"
4
+ @title={{t "resource.drivers"}}
5
+ @searchQuery={{this.query}}
6
+ @onSearch={{perform this.driverActions.controllerSearchTask this}}
7
+ @data={{@model}}
8
+ @columns={{this.columns}}
9
+ @page={{this.page}}
10
+ @onPageChange={{fn (mut this.page)}}
11
+ @setupTable={{fn (mut this.table)}}
12
+ @actionButtons={{this.actionButtons}}
13
+ @bulkActions={{this.bulkActions}}
14
+ @controller={{this}}
15
+ @emptyStateComponent={{component
16
+ "table/empty-state"
17
+ icon="id-card"
18
+ title="Add your first driver"
19
+ description="Drivers power dispatching, live fleet visibility, assignments, and delivery activity."
20
+ filteredTitle="No drivers match your search"
21
+ filteredDescription="Adjust the search or filters to find another driver."
22
+ primaryText="New driver"
23
+ primaryIcon="plus"
24
+ primaryAction=this.driverActions.transition.create
25
+ secondaryText="Import"
26
+ secondaryIcon="upload"
27
+ secondaryAction=this.driverActions.import
28
+ docsSlug="fleet-ops/resources/drivers/overview"
29
+ docsTitle="Drivers guide"
30
+ docsText="Driver guide"
31
+ docsSource="fleet-ops-empty-drivers"
32
+ }}
33
+ />
34
+ {{else}}
35
+ <Layout::Section::Header @title={{t "resource.drivers"}} @actionsWrapperClass="space-x-1">
36
+ <Layout::Resource::TabularActions
37
+ @searchQuery={{this.query}}
38
+ @onSearch={{perform this.driverActions.controllerSearchTask this}}
39
+ @columns={{this.columns}}
40
+ @page={{this.page}}
41
+ @onPageChange={{fn (mut this.page)}}
42
+ @setupTable={{fn (mut this.table)}}
43
+ @actionButtons={{this.actionButtons}}
44
+ @bulkActions={{this.bulkActions}}
45
+ @controller={{this}}
46
+ @hideColumnsPicker={{true}}
47
+ />
48
+ </Layout::Section::Header>
49
+
50
+ <Layout::Section::Body class="overflow-y-scroll-i h-full">
51
+ <Layout::Resource::CardsGrid
52
+ @resource="driver"
53
+ @data={{@model}}
54
+ @columns="4"
55
+ @showPagination={{true}}
56
+ @page={{this.page}}
57
+ @onPageChange={{fn (mut this.page)}}
58
+ @paginationMeta={{@model.meta}}
59
+ @containerClass="p-4"
60
+ class="h-full"
61
+ as |Item|
62
+ >
63
+ <Driver::Card @resource={{Item.model}} />
64
+ </Layout::Resource::CardsGrid>
65
+ </Layout::Section::Body>
66
+ {{/if}}
67
+ {{outlet}}