@fleetbase/fleetops-engine 0.6.43 → 0.6.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/addon/components/entity/form.hbs +24 -0
  2. package/addon/components/entity/form.js +30 -0
  3. package/addon/components/leaflet-draw-control.js +1 -1
  4. package/addon/components/map/drawer/geofence-event-listing.hbs +123 -0
  5. package/addon/components/map/drawer/geofence-event-listing.js +103 -0
  6. package/addon/components/map/drawer.js +1 -0
  7. package/addon/components/map/leaflet-live-map.js +83 -0
  8. package/addon/components/modals/orchestrator-import.js +2 -78
  9. package/addon/components/order/form/service-rate.hbs +1 -1
  10. package/addon/components/service-area/form.hbs +59 -0
  11. package/addon/components/service-rate/details.hbs +6 -6
  12. package/addon/components/service-rate/form.hbs +28 -15
  13. package/addon/components/zone/form.hbs +60 -1
  14. package/addon/controllers/operations/orders/index/details/virtual.js +5 -0
  15. package/addon/controllers/operations/orders/index/details.js +27 -2
  16. package/addon/controllers/operations/service-rates/index/edit.js +1 -0
  17. package/addon/controllers/operations/service-rates/index/new.js +1 -0
  18. package/addon/helpers/f-to-int.js +6 -0
  19. package/addon/routes/application.js +3 -3
  20. package/addon/routes/operations/orders/index/details/virtual.js +1 -0
  21. package/addon/routes/operations/orders/index/details.js +19 -3
  22. package/addon/services/equipment-actions.js +4 -6
  23. package/addon/services/geofence-event-bus.js +223 -0
  24. package/addon/services/leaflet-map-manager.js +4 -0
  25. package/addon/services/maintenance-actions.js +4 -6
  26. package/addon/services/part-actions.js +4 -6
  27. package/addon/services/service-rate-actions.js +8 -3
  28. package/addon/templates/operations/orders/index/details/virtual.hbs +7 -1
  29. package/addon/utils/fleet-ops-options.js +33 -0
  30. package/addon/utils/serialize-payload.js +1 -1
  31. package/app/components/map/drawer/geofence-event-listing.js +1 -0
  32. package/app/controllers/operations/orders/index/details/virtual.js +1 -0
  33. package/app/helpers/f-to-int.js +1 -0
  34. package/app/services/geofence-event-bus.js +1 -0
  35. package/composer.json +1 -1
  36. package/extension.json +1 -1
  37. package/package.json +2 -2
  38. package/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php +48 -0
  39. package/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php +38 -0
  40. package/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php +62 -0
  41. package/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php +66 -0
  42. package/server/migrations/2026_04_22_000001_add_vehicle_support_to_geofence_events_log.php +31 -0
  43. package/server/migrations/2026_04_22_000002_create_vehicle_geofence_states_table.php +39 -0
  44. package/server/src/Console/Commands/SimulateGeofenceEvents.php +278 -0
  45. package/server/src/Events/GeofenceDwelled.php +208 -0
  46. package/server/src/Events/GeofenceEntered.php +207 -0
  47. package/server/src/Events/GeofenceExited.php +213 -0
  48. package/server/src/Http/Controllers/Api/v1/DriverController.php +100 -0
  49. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +263 -0
  50. package/server/src/Http/Controllers/Api/v1/OrderController.php +13 -8
  51. package/server/src/Http/Controllers/Api/v1/PurchaseRateController.php +11 -3
  52. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  53. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +4 -0
  54. package/server/src/Http/Controllers/Api/v1/ServiceRateController.php +6 -1
  55. package/server/src/Http/Controllers/Api/v1/VehicleController.php +88 -0
  56. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -2
  57. package/server/src/Http/Controllers/Internal/v1/GeofenceController.php +7 -0
  58. package/server/src/Http/Requests/CreateServiceAreaRequest.php +8 -4
  59. package/server/src/Http/Requests/CreateServiceRateRequest.php +2 -2
  60. package/server/src/Http/Requests/CreateZoneRequest.php +8 -4
  61. package/server/src/Http/Resources/v1/Order.php +0 -1
  62. package/server/src/Http/Resources/v1/ServiceQuote.php +2 -2
  63. package/server/src/Jobs/CheckGeofenceDwell.php +139 -0
  64. package/server/src/Listeners/HandleGeofenceDwelled.php +56 -0
  65. package/server/src/Listeners/HandleGeofenceEntered.php +187 -0
  66. package/server/src/Listeners/HandleGeofenceExited.php +56 -0
  67. package/server/src/Models/Entity.php +7 -0
  68. package/server/src/Models/GeofenceEventLog.php +155 -0
  69. package/server/src/Models/Order.php +39 -7
  70. package/server/src/Models/ServiceArea.php +21 -2
  71. package/server/src/Models/ServiceQuote.php +3 -1
  72. package/server/src/Models/ServiceRate.php +122 -30
  73. package/server/src/Models/Zone.php +20 -2
  74. package/server/src/Notifications/DriverArrivedAtGeofence.php +86 -0
  75. package/server/src/Observers/PurchaseRateObserver.php +3 -5
  76. package/server/src/Providers/EventServiceProvider.php +19 -0
  77. package/server/src/Providers/FleetOpsServiceProvider.php +10 -0
  78. package/server/src/Rules/ComputableAlgo.php +1 -3
  79. package/server/src/Support/Algo.php +87 -1
  80. package/server/src/Support/GeofenceIntersectionService.php +206 -0
  81. package/server/src/Support/Utils.php +35 -0
  82. package/server/src/routes.php +16 -0
@@ -23,6 +23,30 @@
23
23
  <div class="grid grid-cols-1 lg:grid-cols-2 gap-2">
24
24
  <InputGroup @name={{t "common.name"}} @value={{@resource.name}} @helpText={{t "modals.entity-form.name-text"}} />
25
25
  <InputGroup @name={{t "common.internal-id"}} @value={{@resource.internal_id}} @helpText={{t "modals.entity-form.id-text"}} />
26
+ <div class="space-y-2">
27
+ <InputGroup @name={{t "common.type"}} @helpText="Choose a common entity type or switch to a custom value.">
28
+ {{#if this.useCustomType}}
29
+ <Input @value={{@resource.type}} @type="text" class="w-full form-input" placeholder="Enter custom entity type" />
30
+ {{else}}
31
+ <div class="fleetbase-model-select fleetbase-power-select ember-model-select">
32
+ <PowerSelect
33
+ @options={{this.entityTypes}}
34
+ @selected={{this.selectedEntityType}}
35
+ @onChange={{this.selectEntityType}}
36
+ @placeholder="Select entity type"
37
+ @triggerClass="form-select"
38
+ as |type|
39
+ >
40
+ <div class="text-sm">
41
+ <div class="font-semibold normalize-in-trigger">{{type.label}}</div>
42
+ <div class="hide-from-trigger">{{type.description}}</div>
43
+ </div>
44
+ </PowerSelect>
45
+ </div>
46
+ {{/if}}
47
+ </InputGroup>
48
+ <Checkbox @value={{this.useCustomType}} @label="Use custom type" @onToggle={{this.toggleCustomType}} @alignItems="center" @labelClass="mb-0i" />
49
+ </div>
26
50
  <InputGroup @name={{t "modals.entity-form.sku"}} @value={{@resource.sku}} @helpText={{t "modals.entity-form.sku-text"}} />
27
51
  <div></div>
28
52
  <InputGroup @name={{t "modals.entity-form.description"}} @helpText={{t "modals.entity-form.description-text"}} @wrapperClass="col-span-2">
@@ -1,11 +1,41 @@
1
1
  import Component from '@glimmer/component';
2
2
  import { inject as service } from '@ember/service';
3
+ import { action } from '@ember/object';
4
+ import { tracked } from '@glimmer/tracking';
3
5
  import { task } from 'ember-concurrency';
6
+ import fleetOpsOptions from '../../utils/fleet-ops-options';
4
7
 
5
8
  export default class EntityFormComponent extends Component {
6
9
  @service fetch;
7
10
  @service currentUser;
8
11
  @service notifications;
12
+ @tracked useCustomType = false;
13
+
14
+ constructor() {
15
+ super(...arguments);
16
+ this.useCustomType = this.selectedEntityType === undefined && Boolean(this.args.resource?.type);
17
+ }
18
+
19
+ get entityTypes() {
20
+ return fleetOpsOptions('entityTypes');
21
+ }
22
+
23
+ get selectedEntityType() {
24
+ return this.entityTypes.find((option) => option.value === this.args.resource?.type);
25
+ }
26
+
27
+ @action selectEntityType(option) {
28
+ this.useCustomType = false;
29
+ this.args.resource.type = option?.value ?? null;
30
+ }
31
+
32
+ @action toggleCustomType(value) {
33
+ this.useCustomType = value;
34
+
35
+ if (!value && !this.selectedEntityType) {
36
+ this.args.resource.type = null;
37
+ }
38
+ }
9
39
 
10
40
  @task *handlePhotoUpload(file) {
11
41
  try {
@@ -27,7 +27,7 @@ export default class LeafletDrawControl extends BaseLayer {
27
27
  leafletOptions = ['draw', 'edit', 'remove', 'poly', 'position'];
28
28
 
29
29
  @computed('leafletEvents.[]', 'args') get usedLeafletEvents() {
30
- const leafletEvents = [...this.leafletEvents, ...Object.values(L.Draw.Event)];
30
+ const leafletEvents = [...this.leafletEvents, ...Object.values(L.Draw?.Event ?? {})];
31
31
  return leafletEvents.filter((eventName) => {
32
32
  eventName = camelize(eventName.replace(':', ' '));
33
33
  let methodName = `_${eventName}`;
@@ -0,0 +1,123 @@
1
+ <div class="geofence-events-panel flex flex-col h-full" ...attributes>
2
+ <div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
3
+ <div class="flex items-center space-x-2">
4
+ <FaIcon @icon="map-pin" class="text-blue-500 w-4 h-4" />
5
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-white">Geofence Events</h3>
6
+ {{#if this.events.length}}
7
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">
8
+ {{this.events.length}}
9
+ </span>
10
+ {{/if}}
11
+ </div>
12
+ <button
13
+ type="button"
14
+ class="text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
15
+ {{on "click" this.clearFeed}}
16
+ >
17
+ Clear
18
+ </button>
19
+ </div>
20
+
21
+ {{#if this.isLoading}}
22
+ <div class="flex items-center justify-center py-8">
23
+ <Spinner class="w-5 h-5 text-blue-500" />
24
+ <span class="ml-2 text-sm text-gray-500">Loading events…</span>
25
+ </div>
26
+ {{else if (eq this.events.length 0)}}
27
+ <div class="flex flex-col items-center justify-center py-10 px-4 text-center">
28
+ <FaIcon @icon="map-pin" class="w-8 h-8 text-gray-300 dark:text-gray-600 mb-3" />
29
+ <p class="text-sm font-medium text-gray-500 dark:text-gray-400">No geofence events yet</p>
30
+ <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
31
+ Events will appear here in real time as drivers and vehicles cross zone and service area boundaries.
32
+ </p>
33
+ </div>
34
+ {{else}}
35
+ <div class="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
36
+ {{#each this.events as |event|}}
37
+ <div class="px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors {{if event.isNew 'bg-blue-50 dark:bg-blue-900/10'}}">
38
+ <div class="flex items-start gap-2.5">
39
+ <div class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-300">
40
+ <FaIcon @icon={{this.iconFor event.eventType}} @size="xs" />
41
+ </div>
42
+
43
+ <div class="min-w-0 flex-1">
44
+ <div class="flex items-start justify-between gap-3">
45
+ <div class="min-w-0 flex-1">
46
+ <div class="flex items-center gap-2 text-[11px] leading-4">
47
+ <span class="inline-flex items-center rounded px-1.5 py-0.5 font-medium {{this.badgeColorFor event.eventType}}">
48
+ {{this.labelFor event.eventType}}
49
+ </span>
50
+ <span class="inline-flex items-center gap-1 text-gray-400 dark:text-gray-500">
51
+ <FaIcon @icon={{this.subjectIconFor event.subjectType}} @size="xs" />
52
+ <span class="capitalize">{{event.subjectType}}</span>
53
+ </span>
54
+ <span class="text-gray-300 dark:text-gray-600">•</span>
55
+ <span class="text-gray-500 dark:text-gray-400">{{this.geofenceTypeLabel event.geofenceType}}</span>
56
+ </div>
57
+
58
+ <div class="mt-1 flex items-center gap-1.5 text-sm leading-5">
59
+ <span class="truncate font-medium text-gray-900 dark:text-white">{{event.subjectName}}</span>
60
+ <FaIcon @icon="arrow-right" @size="xs" class="text-gray-300 dark:text-gray-600" />
61
+ <span class="truncate text-gray-600 dark:text-gray-300">{{event.geofenceName}}</span>
62
+ </div>
63
+
64
+ <div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
65
+ {{#if event.driverName}}
66
+ {{#unless (eq event.subjectType "driver")}}
67
+ <span class="inline-flex items-center gap-1">
68
+ <FaIcon @icon="id-card" @size="xs" class="text-gray-400" />
69
+ {{event.driverName}}
70
+ </span>
71
+ {{/unless}}
72
+ {{/if}}
73
+
74
+ {{#if event.vehiclePlate}}
75
+ <span class="inline-flex items-center gap-1">
76
+ <FaIcon @icon="car" @size="xs" class="text-gray-400" />
77
+ {{event.vehiclePlate}}
78
+ </span>
79
+ {{/if}}
80
+
81
+ {{#if event.orderPublicId}}
82
+ <span class="inline-flex items-center gap-1">
83
+ <FaIcon @icon="hashtag" @size="xs" class="text-gray-400" />
84
+ {{event.orderPublicId}}
85
+ </span>
86
+ {{/if}}
87
+
88
+ {{#if event.dwellMinutes}}
89
+ <span class="inline-flex items-center gap-1">
90
+ <FaIcon @icon="clock" @size="xs" class="text-gray-400" />
91
+ {{event.dwellMinutes}} min
92
+ </span>
93
+ {{/if}}
94
+
95
+ {{#if event.latitude}}
96
+ {{#if event.longitude}}
97
+ <span class="inline-flex items-center gap-1">
98
+ <FaIcon @icon="location-dot" @size="xs" class="text-gray-400" />
99
+ {{event.latitude}}, {{event.longitude}}
100
+ </span>
101
+ {{/if}}
102
+ {{/if}}
103
+ </div>
104
+ </div>
105
+
106
+ <div class="flex-shrink-0 text-right">
107
+ <div class="text-[11px] leading-4 text-gray-500 dark:text-gray-400">
108
+ {{format-date-fns event.occurredAt "dd MMM, HH:mm"}}
109
+ </div>
110
+ {{#if event.isNew}}
111
+ <div class="mt-1 text-[10px] font-medium uppercase tracking-wide text-blue-500 dark:text-blue-300">
112
+ Live
113
+ </div>
114
+ {{/if}}
115
+ </div>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ {{/each}}
121
+ </div>
122
+ {{/if}}
123
+ </div>
@@ -0,0 +1,103 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { inject as service } from '@ember/service';
4
+ import { action } from '@ember/object';
5
+ import { next } from '@ember/runloop';
6
+
7
+ export default class MapDrawerGeofenceEventListingComponent extends Component {
8
+ @service fetch;
9
+ @service geofenceEventBus;
10
+ @service currentUser;
11
+
12
+ @tracked isLoading = false;
13
+
14
+ constructor() {
15
+ super(...arguments);
16
+ next(() => {
17
+ this.geofenceEventBus.subscribe(this.currentUser.companyId);
18
+ this.loadRecentEvents();
19
+ });
20
+ }
21
+
22
+ get events() {
23
+ return this.geofenceEventBus.events;
24
+ }
25
+
26
+ @action
27
+ async loadRecentEvents() {
28
+ this.isLoading = true;
29
+
30
+ try {
31
+ const response = await this.fetch.get('geofences/events', { per_page: 20 });
32
+
33
+ if (response?.data) {
34
+ this.geofenceEventBus.seedEvents(response.data.map((event) => this.geofenceEventBus.normalizeEvent(event.event_type, event)));
35
+ }
36
+ } catch (error) {
37
+ // The live socket feed will still populate events if history loading fails.
38
+ } finally {
39
+ this.isLoading = false;
40
+ }
41
+ }
42
+
43
+ @action
44
+ badgeColorFor(eventType) {
45
+ const colors = {
46
+ 'geofence.entered': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
47
+ 'geofence.exited': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
48
+ 'geofence.dwelled': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
49
+ };
50
+
51
+ return colors[eventType] ?? 'bg-gray-100 text-gray-800';
52
+ }
53
+
54
+ @action
55
+ labelFor(eventType) {
56
+ const labels = {
57
+ 'geofence.entered': 'Entered',
58
+ 'geofence.exited': 'Exited',
59
+ 'geofence.dwelled': 'Dwelled',
60
+ };
61
+
62
+ return labels[eventType] ?? eventType;
63
+ }
64
+
65
+ @action
66
+ iconFor(eventType) {
67
+ const icons = {
68
+ 'geofence.entered': 'arrow-right-to-bracket',
69
+ 'geofence.exited': 'arrow-right-from-bracket',
70
+ 'geofence.dwelled': 'clock',
71
+ };
72
+
73
+ return icons[eventType] ?? 'map-pin';
74
+ }
75
+
76
+ @action
77
+ subjectIconFor(subjectType) {
78
+ return subjectType === 'vehicle' ? 'car' : 'id-card';
79
+ }
80
+
81
+ @action
82
+ geofenceTypeLabel(type) {
83
+ if (type === 'service_area') {
84
+ return 'Service Area';
85
+ }
86
+
87
+ if (type === 'zone') {
88
+ return 'Zone';
89
+ }
90
+
91
+ return type ?? 'Geofence';
92
+ }
93
+
94
+ @action
95
+ hasSecondaryMeta(event) {
96
+ return Boolean(event.vehiclePlate || event.orderPublicId || event.dwellMinutes || event.latitude || event.longitude);
97
+ }
98
+
99
+ @action
100
+ clearFeed() {
101
+ this.geofenceEventBus.clearFeed();
102
+ }
103
+ }
@@ -15,6 +15,7 @@ export default class MapDrawerComponent extends Component {
15
15
  this.universe._createMenuItem('Drivers', null, { icon: 'id-card', component: 'map/drawer/driver-listing' }),
16
16
  this.universe._createMenuItem('Places', null, { icon: 'building', component: 'map/drawer/place-listing' }),
17
17
  this.universe._createMenuItem('Positions', null, { icon: 'map-marker', component: 'map/drawer/position-listing' }),
18
+ this.universe._createMenuItem('Geofences', null, { icon: 'map-pin', component: 'map/drawer/geofence-event-listing' }),
18
19
  this.universe._createMenuItem('Events', null, { icon: 'stream', component: 'map/drawer/device-event-listing' }),
19
20
  ...(isArray(registeredTabs) ? registeredTabs : []),
20
21
  ];
@@ -29,6 +29,8 @@ export default class MapLeafletLiveMapComponent extends Component {
29
29
  @service intl;
30
30
  @service universe;
31
31
  @service('universe/menu-service') menuService;
32
+ @service geofenceEventBus;
33
+ @service currentUser;
32
34
 
33
35
  /** properties */
34
36
  id = guidFor(this);
@@ -57,6 +59,19 @@ export default class MapLeafletLiveMapComponent extends Component {
57
59
 
58
60
  // Ensure we have valid coordinates on initialization
59
61
  this.#updateCoordinatesFromLocation();
62
+
63
+ if (this.currentUser.companyId) {
64
+ this.geofenceEventBus.subscribe(this.currentUser.companyId);
65
+ } else {
66
+ this._currentUserLoadedHandler = () => this.geofenceEventBus.subscribe(this.currentUser.companyId);
67
+ this.currentUser.on('user.loaded', this._currentUserLoadedHandler);
68
+ }
69
+
70
+ // Subscribe to geofence events so the live map can react to boundary crossings
71
+ this._geofenceEnteredHandler = this.#handleGeofenceEntered.bind(this);
72
+ this._geofenceExitedHandler = this.#handleGeofenceExited.bind(this);
73
+ this.universe.on('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
74
+ this.universe.on('fleet-ops.geofence.exited', this._geofenceExitedHandler);
60
75
  }
61
76
 
62
77
  willDestroy() {
@@ -67,6 +82,18 @@ export default class MapLeafletLiveMapComponent extends Component {
67
82
  this.universe.off('user.located', this._locationUpdateHandler);
68
83
  this._locationUpdateHandler = null;
69
84
  }
85
+ if (this._currentUserLoadedHandler) {
86
+ this.currentUser.off('user.loaded', this._currentUserLoadedHandler);
87
+ this._currentUserLoadedHandler = null;
88
+ }
89
+ if (this._geofenceEnteredHandler) {
90
+ this.universe.off('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
91
+ this._geofenceEnteredHandler = null;
92
+ }
93
+ if (this._geofenceExitedHandler) {
94
+ this.universe.off('fleet-ops.geofence.exited', this._geofenceExitedHandler);
95
+ this._geofenceExitedHandler = null;
96
+ }
70
97
  }
71
98
 
72
99
  @action didLoad({ target: map }) {
@@ -259,6 +286,62 @@ export default class MapLeafletLiveMapComponent extends Component {
259
286
  return 14;
260
287
  }
261
288
 
289
+ /**
290
+ * Handles a geofence.entered event from the GeofenceEventBus.
291
+ * Briefly highlights the geofence layer on the map to provide visual feedback.
292
+ *
293
+ * @param {Object} event - Normalised geofence event object
294
+ */
295
+ #handleGeofenceEntered(event) {
296
+ debug(`[LiveMap] geofence.entered — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
297
+ this.#flashGeofenceLayer(event.geofenceUuid, '#22c55e'); // green
298
+ }
299
+
300
+ /**
301
+ * Handles a geofence.exited event from the GeofenceEventBus.
302
+ * Briefly highlights the geofence layer on the map to provide visual feedback.
303
+ *
304
+ * @param {Object} event - Normalised geofence event object
305
+ */
306
+ #handleGeofenceExited(event) {
307
+ debug(`[LiveMap] geofence.exited — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
308
+ this.#flashGeofenceLayer(event.geofenceUuid, '#ef4444'); // red
309
+ }
310
+
311
+ /**
312
+ * Briefly changes the fill colour of a geofence polygon layer on the map
313
+ * to provide visual feedback when a driver enters or exits.
314
+ *
315
+ * @param {string} geofenceUuid - UUID of the zone or service area
316
+ * @param {string} flashColor - Hex colour to flash
317
+ */
318
+ #flashGeofenceLayer(geofenceUuid, flashColor) {
319
+ if (!geofenceUuid || !this.map) {
320
+ return;
321
+ }
322
+
323
+ // Iterate over all Leaflet layers to find the matching geofence polygon
324
+ this.map.eachLayer((layer) => {
325
+ const model = layer._model;
326
+ if (model && model.uuid === geofenceUuid && typeof layer.setStyle === 'function') {
327
+ const originalStyle = {
328
+ color: layer.options.color,
329
+ fillColor: layer.options.fillColor,
330
+ weight: layer.options.weight,
331
+ };
332
+
333
+ // Flash to the event colour
334
+ layer.setStyle({ color: flashColor, fillColor: flashColor, weight: 3 });
335
+
336
+ // Restore original style after 2 seconds
337
+ setTimeout(() => {
338
+ if (!layer._map) return; // layer may have been removed
339
+ layer.setStyle(originalStyle);
340
+ }, 2000);
341
+ }
342
+ });
343
+ }
344
+
262
345
  /**
263
346
  * Handles location updates from the location service
264
347
  * @param {Object} coordinates - The new coordinates
@@ -223,76 +223,6 @@ const ALIASES = {
223
223
  entity_destination: ['entity destination', 'item destination', 'deliver to', 'waypoint'],
224
224
  };
225
225
 
226
- /** Sample row for the downloadable template. */
227
- const SAMPLE_ROW = {
228
- order_type: 'pickup_dropoff',
229
- order_ref: 'ORD-001',
230
- internal_id: 'INT-001',
231
- type: 'default',
232
- status: 'created',
233
- scheduled_at: '2026-05-01 09:00',
234
- notes: 'Leave at front door',
235
- priority: '50',
236
- time_window_start: '08:00',
237
- time_window_end: '12:00',
238
- service_time_min: '10',
239
- required_skills: '',
240
- pickup_name: 'Warehouse A',
241
- pickup_street1: '123 Warehouse Rd',
242
- pickup_street2: '',
243
- pickup_city: 'London',
244
- pickup_state: 'England',
245
- pickup_postal_code: 'E1 6RF',
246
- pickup_country: 'GB',
247
- pickup_phone: '+44 20 7946 0000',
248
- pickup_lat: '',
249
- pickup_lng: '',
250
- dropoff_name: 'John Smith',
251
- dropoff_street1: '456 High Street',
252
- dropoff_street2: 'Apt 3B',
253
- dropoff_city: 'London',
254
- dropoff_state: 'England',
255
- dropoff_postal_code: 'SW1A 1AA',
256
- dropoff_country: 'GB',
257
- dropoff_phone: '+44 7700 900000',
258
- dropoff_lat: '',
259
- dropoff_lng: '',
260
- weight_kg: '5.2',
261
- volume_m3: '0.04',
262
- parcels: '1',
263
- cod_amount: '',
264
- cod_currency: '',
265
- customer_name: 'John Smith',
266
- customer_email: 'john.smith@example.com',
267
- customer_phone: '+44 7700 900000',
268
- customer_type: 'contact',
269
- facilitator_name: '',
270
- facilitator_email: '',
271
- facilitator_phone: '',
272
- facilitator_type: 'vendor',
273
- vehicle_plate: '',
274
- driver_name: '',
275
- driver_phone: '',
276
- driver_email: '',
277
- entity_name: 'Widget A',
278
- entity_type: 'parcel',
279
- entity_description: '1x Widget A in box',
280
- entity_sku: 'WGT-001',
281
- entity_barcode: '',
282
- entity_internal_id: '',
283
- entity_declared_value: '25.00',
284
- entity_currency: 'USD',
285
- entity_price: '25.00',
286
- entity_sale_price: '',
287
- entity_weight: '0.5',
288
- entity_weight_unit: 'kg',
289
- entity_length: '',
290
- entity_width: '',
291
- entity_height: '',
292
- entity_dimensions_unit: 'cm',
293
- entity_destination: 'dropoff',
294
- };
295
-
296
226
  export default class OrchestratorImportComponent extends Component {
297
227
  @service fetch;
298
228
  @service notifications;
@@ -795,13 +725,7 @@ export default class OrchestratorImportComponent extends Component {
795
725
  return this.args.options.onImportTemplate();
796
726
  }
797
727
 
798
- // Build a dynamic XLSX template from the current field list + a sample row
799
- // so the template always stays in sync with the importer field definitions.
800
- const headers = TARGET_FIELDS.map((f) => f.key);
801
- const sampleValues = headers.map((k) => SAMPLE_ROW[k] ?? '');
802
- const ws = XLSX.utils.aoa_to_sheet([headers, sampleValues]);
803
- const wb = XLSX.utils.book_new();
804
- XLSX.utils.book_append_sheet(wb, ws, 'Orders');
805
- XLSX.writeFile(wb, 'Fleetbase_Order_Import_Template.xlsx');
728
+ // Open the import template URL for download
729
+ window.open('https://flb-assets.s3.ap-southeast-1.amazonaws.com/import-templates/Fleetbase_Order_Import_Template.xlsx');
806
730
  }
807
731
  }
@@ -58,7 +58,7 @@
58
58
  @name="serviceQuote"
59
59
  @changed={{fn (mut @resource.service_quote_uuid)}}
60
60
  />
61
- <label for={{serviceQuote.uuid}} class="ml-3 flex-1">{{serviceQuote.public_id}}</label>
61
+ <label for={{serviceQuote.uuid}} class="ml-3 flex-1 text-sm">{{serviceQuote.public_id}} ({{serviceQuote.service_rate_name}})</label>
62
62
  <Badge @hideStatusDot={{true}} @status="info">
63
63
  {{serviceQuote.request_id}}
64
64
  </Badge>
@@ -41,4 +41,63 @@
41
41
  </InputGroup>
42
42
  </div>
43
43
  </ContentPanel>
44
+
45
+ <ContentPanel @title="Geofence Triggers" @open={{true}} @wrapperClass="bordered-top">
46
+ <div class="space-y-4">
47
+ <p class="text-sm text-gray-500 dark:text-gray-400">
48
+ Configure when this service area fires active geofence events. Entry and exit events are dispatched to
49
+ webhooks, WebSocket channels, and the notification system whenever a driver crosses the service area boundary.
50
+ </p>
51
+
52
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
53
+ {{! Entry trigger toggle }}
54
+ <div class="flex items-start space-x-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700">
55
+ <div class="flex-shrink-0 mt-0.5">
56
+ <Toggle
57
+ @isToggled={{@resource.trigger_on_entry}}
58
+ @onToggle={{fn (mut @resource.trigger_on_entry)}}
59
+ />
60
+ </div>
61
+ <div>
62
+ <p class="text-sm font-medium text-gray-900 dark:text-white">Trigger on Entry</p>
63
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
64
+ Fire a <code class="font-mono">geofence.entered</code> event when a driver enters this service area.
65
+ </p>
66
+ </div>
67
+ </div>
68
+
69
+ {{! Exit trigger toggle }}
70
+ <div class="flex items-start space-x-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700">
71
+ <div class="flex-shrink-0 mt-0.5">
72
+ <Toggle
73
+ @isToggled={{@resource.trigger_on_exit}}
74
+ @onToggle={{fn (mut @resource.trigger_on_exit)}}
75
+ />
76
+ </div>
77
+ <div>
78
+ <p class="text-sm font-medium text-gray-900 dark:text-white">Trigger on Exit</p>
79
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
80
+ Fire a <code class="font-mono">geofence.exited</code> event when a driver leaves this service area.
81
+ </p>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <InputGroup
87
+ @name="Dwell Threshold (minutes)"
88
+ @value={{@resource.dwell_threshold_minutes}}
89
+ @type="number"
90
+ @placeholder="e.g. 10"
91
+ @helpText="Fire a geofence.dwelled event after a driver has been inside this service area for this many minutes. Leave blank to disable dwell tracking."
92
+ />
93
+
94
+ <InputGroup
95
+ @name="Speed Limit (km/h)"
96
+ @value={{@resource.speed_limit_kmh}}
97
+ @type="number"
98
+ @placeholder="e.g. 50"
99
+ @helpText="Optional speed limit enforced within this service area. Leave blank for no limit."
100
+ />
101
+ </div>
102
+ </ContentPanel>
44
103
  </div>
@@ -106,11 +106,11 @@
106
106
  </tr>
107
107
  </thead>
108
108
  <tbody>
109
- {{#each @resource.rate_fees as |rateFee|}}
109
+ {{#each @resource.rateFees as |rateFee|}}
110
110
  <tr>
111
111
  <td>{{n-a rateFee.min}}</td>
112
112
  <td>{{n-a rateFee.max}}</td>
113
- <td>{{format-currency rateFee.fee @resource.currency}}</td>
113
+ <td>{{format-currency (f-to-int rateFee.fee) @resource.currency}}</td>
114
114
  </tr>
115
115
  {{else}}
116
116
  <tr>
@@ -158,7 +158,7 @@
158
158
  <div class="field-info-container">
159
159
  <div class="field-name">Algorithm Code</div>
160
160
  <div class="field-value">
161
- <pre class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-xs overflow-x-auto"><code>{{n-a @resource.algorithm}}</code></pre>
161
+ <pre class="bg-gray-100 dark:bg-gray-900 font-mono p-3 rounded text-xs overflow-x-auto"><code class="font-mono text-xs">{{n-a @resource.algorithm}}</code></pre>
162
162
  </div>
163
163
  </div>
164
164
  </ContentPanel>
@@ -167,7 +167,7 @@
167
167
  {{#if @resource.isParcelService}}
168
168
  <ContentPanel @title={{t "service-rate.fields.percel-fee-title"}} @open={{true}} @wrapperClass="bordered-top">
169
169
  <div class="space-y-2">
170
- {{#each @resource.parcel_fees as |parcelFee|}}
170
+ {{#each @resource.parcelFees as |parcelFee|}}
171
171
  <div class="dark:text-gray-100">
172
172
  <div class="grid grid-cols-7">
173
173
  <div class="flex flex-col items-start justify-start pt-3">
@@ -242,7 +242,7 @@
242
242
  {{t "service-rate.fields.additional-fee"}}
243
243
  </div>
244
244
  <div class="field-value">
245
- {{n-a (format-currency parcelFee.fee @resource.currency)}}
245
+ {{n-a (format-currency (f-to-int parcelFee.fee) @resource.currency)}}
246
246
  </div>
247
247
  </div>
248
248
  </div>
@@ -323,4 +323,4 @@
323
323
  </ContentPanel>
324
324
 
325
325
  <CustomField::Yield @subject={{@resource}} @viewMode={{true}} @wrapperClass="bordered-top" />
326
- </div>
326
+ </div>