@fleetbase/fleetops-engine 0.6.60 → 0.6.62

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 (109) hide show
  1. package/RELEASE.md +21 -17
  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 +26 -1
  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/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
  33. package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
  34. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
  35. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
  36. package/server/src/Http/Controllers/Api/v1/DriverController.php +312 -59
  37. package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
  38. package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
  39. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
  40. package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
  41. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  42. package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
  43. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
  44. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +139 -34
  46. package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
  47. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  48. package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
  49. package/server/src/Http/Filter/DriverFilter.php +71 -15
  50. package/server/src/Http/Filter/FleetFilter.php +32 -33
  51. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  52. package/server/src/Http/Filter/IssueFilter.php +29 -0
  53. package/server/src/Http/Filter/VehicleFilter.php +57 -19
  54. package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
  55. package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
  56. package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
  57. package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
  58. package/server/src/Http/Requests/CreateVehicleRequest.php +150 -26
  59. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
  60. package/server/src/Http/Resources/v1/Driver.php +22 -2
  61. package/server/src/Http/Resources/v1/Fleet.php +38 -25
  62. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  63. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  64. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  65. package/server/src/Http/Resources/v1/Vehicle.php +46 -4
  66. package/server/src/Models/Driver.php +2 -2
  67. package/server/src/Models/Place.php +1 -1
  68. package/server/src/routes.php +29 -0
  69. package/server/tests/ApiDriverControllerContractsTest.php +317 -13
  70. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  71. package/server/tests/ApiEntityControllerContractsTest.php +1 -1
  72. package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
  73. package/server/tests/ApiFleetControllerContractsTest.php +354 -21
  74. package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
  75. package/server/tests/ApiIssueControllerContractsTest.php +1 -1
  76. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  77. package/server/tests/ApiOrderControllerContractsTest.php +1 -1
  78. package/server/tests/ApiPartControllerContractsTest.php +2 -2
  79. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
  80. package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
  81. package/server/tests/ApiVehicleControllerContractsTest.php +210 -8
  82. package/server/tests/CompactResourceSerializationTest.php +13 -7
  83. package/server/tests/ControllerFilterContractsTest.php +132 -13
  84. package/server/tests/ControllerHelperContractsTest.php +6 -3
  85. package/server/tests/DriverFilterExecutionTest.php +130 -6
  86. package/server/tests/ExceptionContractsTest.php +15 -0
  87. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
  88. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  89. package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
  90. package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
  91. package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
  92. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
  93. package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
  94. package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
  95. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
  96. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
  97. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
  98. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
  99. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  100. package/server/tests/FleetHierarchyResourceTest.php +56 -8
  101. package/server/tests/RequestContractsTest.php +105 -7
  102. package/server/tests/SettingControllerContractsTest.php +33 -0
  103. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  104. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  105. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
  106. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  107. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  108. package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
  109. package/translations/en-us.yaml +4 -0
package/RELEASE.md CHANGED
@@ -1,34 +1,38 @@
1
- > v0.6.60 ~ "Closes an authentication bypass, and clears a run of API 500s"
1
+ > v0.6.62 ~ "The public Fleet, Vehicle and Driver APIs now expose what the records actually hold"
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
+ The public v1 API exposed a small subset of what the Fleet, Vehicle and Driver records can hold, and the gaps were silent: a caller sending a field the controller did not copy received a `200` and a response body that looked correct while the value was discarded. This release closes that gap, adds fleet hierarchies and fleet membership to the public API, and makes it possible to record a driver who has no email address or phone number.
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
+ - **Fleet create and update accept every safe field.** `name`, `color`, `task`, `status`, and the `service_area`, `zone`, `vendor` and `parent_fleet` relationships. Only `name` and `service_area` were reachable before, so a fleet hierarchy could not be built through the API at all. `"parent_fleet": null` clears a parent; a fleet may not be its own parent, nor sit beneath one of its own descendants.
10
+ - **Fleet membership has public endpoints.** `POST` and `DELETE` on `/v1/fleets/{fleet}/vehicles/{vehicle}` and `/v1/fleets/{fleet}/drivers/{driver}`, all four taking public IDs and sharing one response shape. Assignment is idempotent and restores a soft-deleted membership rather than duplicating it; removal is a safe no-op and touches only the pivot, never the driver, the vehicle, the driver's current vehicle, or any other fleet.
11
+ - **The Vehicle contract covers the whole record.** The input projection accepted 21 of the model's 99 fields; it now accepts all 90 safe ones — identity, odometer and measurement, body, capacity and dimensions, lifecycle and financing, regulatory and engine specifications, structured `specs`/`details`/`meta`, and orchestrator constraints — each with type-appropriate validation. `vendor`, `category`, `warranty` and `photo` resolve from public IDs.
12
+ - **A driver can be recorded without credentials.** `email` and `phone` are optional on create; an operational record may legitimately have neither. Nothing is invented to fill the gap and no invitation is sent when there is nowhere to send one. Such a driver cannot sign in to Navigator until credentials are supplied.
13
+ - **Relationships are readable back as public IDs.** A caller that writes `parent_fleet: "fleet_abc"` can now read the assignment back. Asking for a relation through `?with=` still returns the nested object it always did, and internal console responses are unchanged.
11
14
 
12
15
  ---
13
16
  ## 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.
17
+ - **`Driver::$fillable` listed `'meta,'`** — a trailing comma inside the string — so driver metadata was never mass assignable and never persisted through the public API.
18
+ - **Driver input was a blocklist, not an allowlist.** Anything nobody had thought to exclude reached `Driver::create()` intact, including `auth_token`, `user_uuid` and `company_uuid`, while `location`, `heading`, `altitude`, `speed` and `meta` were dropped on every write.
19
+ - **Driver photo upload wrote `photo_uuid` to `users`,** which has no such column. `User` guards mass assignment by fillable, so every photo uploaded through the public API was discarded without a word. It now writes `avatar_uuid`.
20
+ - **A partial vehicle update took the vehicle offline.** The create-time `online` default was applied on update too, so a plate correction or an odometer reading silently reset it.
21
+ - **Relationship filters could never match.** `?vendor=`, `?fleet=`, `?driver=` and the fleet hierarchy filters compared caller-supplied public IDs against uuid columns. `FleetFilter::query()` searched a `user` relation Fleet does not have, `DriverFilter::phone()` a `phone` relation that does not exist, and `FleetFilter::zone()` a `zone_uuid` column `zones` does not have — the last three raised rather than filtered.
22
+ - **`VehicleFilter` had no `internal_id` filter,** which is the lookup an importer keys on to decide whether a vehicle already exists.
23
+ - **Cross-company relationship IDs were accepted and silently dropped.** Relationship inputs were validated with unscoped `exists` rules, so another organization's public ID passed validation and then resolved to nothing. They are now scoped at validation and again at resolution, and a cross-company ID is answered exactly as a missing one — so a response cannot be used to probe another organization's data.
24
+ - **The Fleet resource ran four `count()` queries per fleet on every public request** and discarded the results, because `when()` evaluates a plain value argument eagerly.
25
+ - **The Fleet webhook payload gated `parent_fleet` on `serviceArea`,** so a subfleet with no service area never reported its parent.
23
26
 
24
27
  ---
25
28
  ## Testing
26
- - Coverage restored to 100% across the QR, geofence, driver auth, customer request and navigator changes.
29
+ - Coverage held at 100% on all three metrics — 34670/34670 statements, 4428/4428 methods, 530/530 classes.
30
+ - New database-backed coverage of the fleet membership pivots: idempotent assignment, restore of a soft-deleted membership, repeated removal as a no-op, and preservation of the driver's vehicle and of unrelated fleet memberships.
31
+ - Data-driven parity tests prove every newly exposed Fleet, Vehicle and Driver field is accepted and persisted, and that tenancy, authentication and generated columns still are not.
27
32
 
28
33
  ---
29
34
  ## 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.
35
+ - The PHP, Ember and Postman contract workflows now run on pull requests targeting `release/v*`. `release.yml` learned that branch name in v0.6.61's follow-up, but the three check workflows did not — so a PR retargeted onto a release branch ran no checks at all.
32
36
 
33
37
  ---
34
38
  ## 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',
@@ -15,7 +18,10 @@ export default class MapSettingsService extends Service {
15
18
  @service fetch;
16
19
  @tracked settings = { ...DEFAULT_SETTINGS };
17
20
  @tracked isLoaded = false;
18
- @tracked loadPromise = null;
21
+ // Internal request deduplication state is not rendered. Keeping this tracked
22
+ // makes load() read and then dirty the same tag when called during component
23
+ // construction, which Ember correctly rejects as a render-time mutation.
24
+ loadPromise = null;
19
25
 
20
26
  get mapProvider() {
21
27
  return this.settings.mapProvider ?? 'leaflet';
@@ -45,6 +51,25 @@ export default class MapSettingsService extends Service {
45
51
  return this.mapProvider === 'google';
46
52
  }
47
53
 
54
+ get leafletTileUrl() {
55
+ return this.getLeafletTileUrl('light');
56
+ }
57
+
58
+ get leafletDarkTileUrl() {
59
+ return this.getLeafletTileUrl('dark');
60
+ }
61
+
62
+ /**
63
+ * Resolve the Leaflet tile URL for a theme, preferring the company's
64
+ * configured custom tile provider and falling back to the keyless default.
65
+ *
66
+ * @param {String} theme 'light' or 'dark'
67
+ * @return {String}
68
+ */
69
+ getLeafletTileUrl(theme = 'light') {
70
+ return getLeafletTileUrl(this.settings, theme);
71
+ }
72
+
48
73
  async load({ force = false } = {}) {
49
74
  if (!force && this.isLoaded) {
50
75
  return this.settings;