@fleetbase/fleetops-engine 0.6.20 → 0.6.22

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 (162) hide show
  1. package/addon/components/custom-entity/form.hbs +14 -14
  2. package/addon/components/device/card.hbs +1 -0
  3. package/addon/components/device/card.js +3 -0
  4. package/addon/components/device/details.hbs +92 -43
  5. package/addon/components/device/form.hbs +108 -60
  6. package/addon/components/device/form.js +36 -8
  7. package/addon/components/device/manager.hbs +29 -0
  8. package/addon/components/device/manager.js +95 -0
  9. package/addon/components/device/panel-header.hbs +32 -0
  10. package/addon/components/device/panel-header.js +3 -0
  11. package/addon/components/device/pill.hbs +16 -0
  12. package/addon/components/device/pill.js +3 -0
  13. package/addon/components/driver/details.hbs +4 -0
  14. package/addon/components/driver/details.js +19 -1
  15. package/addon/components/driver/form.hbs +14 -3
  16. package/addon/components/driver/form.js +49 -47
  17. package/addon/components/driver/pill.hbs +17 -0
  18. package/addon/components/driver/pill.js +3 -0
  19. package/addon/components/entity/form.hbs +7 -5
  20. package/addon/components/layout/fleet-ops-sidebar.js +12 -12
  21. package/addon/components/map/drawer/device-event-listing.hbs +64 -0
  22. package/addon/components/map/drawer/device-event-listing.js +181 -0
  23. package/addon/components/map/drawer/position-listing.hbs +100 -0
  24. package/addon/components/map/drawer/position-listing.js +455 -0
  25. package/addon/components/map/drawer.js +2 -0
  26. package/addon/components/map/leaflet-live-map.hbs +7 -2
  27. package/addon/components/modals/attach-device.hbs +18 -0
  28. package/addon/components/modals/attach-device.js +3 -0
  29. package/addon/components/order/details/detail.hbs +2 -54
  30. package/addon/components/order/details/detail.js +1 -0
  31. package/addon/components/order/details/payload.hbs +6 -4
  32. package/addon/components/order/details/payload.js +2 -0
  33. package/addon/components/order/pill.hbs +34 -0
  34. package/addon/components/order/pill.js +3 -0
  35. package/addon/components/order-config-manager/custom-fields.js +1 -1
  36. package/addon/components/positions-replay.hbs +339 -0
  37. package/addon/components/positions-replay.js +409 -0
  38. package/addon/components/sensor/details.hbs +64 -38
  39. package/addon/components/sensor/form.hbs +112 -63
  40. package/addon/components/sensor/form.js +36 -24
  41. package/addon/components/sensor/panel-header.hbs +32 -0
  42. package/addon/components/sensor/panel-header.js +3 -0
  43. package/addon/components/telematic/details.hbs +40 -16
  44. package/addon/components/telematic/form.hbs +63 -64
  45. package/addon/components/telematic/form.js +73 -4
  46. package/addon/components/vehicle/card.hbs +2 -2
  47. package/addon/components/vehicle/details.hbs +4 -0
  48. package/addon/components/vehicle/details.js +19 -1
  49. package/addon/components/vehicle/form.hbs +4 -0
  50. package/addon/components/vehicle/pill.hbs +34 -0
  51. package/addon/components/vehicle/pill.js +3 -0
  52. package/addon/controllers/analytics/reports/index/edit.js +1 -1
  53. package/addon/controllers/connectivity/devices/index/details.js +22 -1
  54. package/addon/controllers/connectivity/devices/index/edit.js +66 -1
  55. package/addon/controllers/connectivity/devices/index.js +51 -9
  56. package/addon/controllers/connectivity/events/index.js +65 -16
  57. package/addon/controllers/connectivity/sensors/index/details.js +22 -1
  58. package/addon/controllers/connectivity/sensors/index/edit.js +66 -1
  59. package/addon/controllers/connectivity/sensors/index.js +66 -6
  60. package/addon/controllers/connectivity/telematics/index/details.js +22 -1
  61. package/addon/controllers/connectivity/telematics/index/edit.js +66 -1
  62. package/addon/controllers/connectivity/telematics/index.js +20 -11
  63. package/addon/controllers/management/fleets/index/details.js +26 -21
  64. package/addon/controllers/management/fleets/index/edit.js +9 -6
  65. package/addon/controllers/management/vehicles/index/details.js +26 -13
  66. package/addon/controllers/settings/custom-fields.js +6 -0
  67. package/addon/helpers/get-fleet-ops-option-label.js +11 -0
  68. package/addon/routes/connectivity/devices/index/details.js +27 -1
  69. package/addon/routes/connectivity/devices/index/edit.js +27 -1
  70. package/addon/routes/connectivity/sensors/index/details.js +27 -1
  71. package/addon/routes/connectivity/sensors/index/edit.js +27 -1
  72. package/addon/routes/connectivity/telematics/index/details.js +27 -1
  73. package/addon/routes/connectivity/telematics/index/edit.js +27 -1
  74. package/addon/routes/management/drivers/index/details/positions.js +3 -0
  75. package/addon/routes/management/vehicles/index/details/positions.js +3 -0
  76. package/addon/routes.js +4 -0
  77. package/addon/services/movement-tracker.js +81 -30
  78. package/addon/services/position-playback.js +486 -0
  79. package/addon/services/resource-metadata.js +46 -0
  80. package/addon/styles/fleetops-engine.css +157 -0
  81. package/addon/templates/connectivity/devices/index/details/index.hbs +2 -2
  82. package/addon/templates/connectivity/devices/index/details.hbs +15 -2
  83. package/addon/templates/connectivity/devices/index/edit.hbs +1 -1
  84. package/addon/templates/connectivity/events/index.hbs +1 -1
  85. package/addon/templates/connectivity/sensors/index/details/index.hbs +2 -2
  86. package/addon/templates/connectivity/sensors/index/details.hbs +15 -2
  87. package/addon/templates/connectivity/sensors/index/edit.hbs +1 -1
  88. package/addon/templates/connectivity/telematics/index/details/index.hbs +2 -2
  89. package/addon/templates/connectivity/telematics/index/details.hbs +14 -2
  90. package/addon/templates/connectivity/telematics/index/edit.hbs +1 -1
  91. package/addon/templates/management/drivers/index/details/positions.hbs +2 -0
  92. package/addon/templates/management/vehicles/index/details/devices.hbs +1 -2
  93. package/addon/templates/management/vehicles/index/details/positions.hbs +1 -0
  94. package/addon/utils/fleet-ops-options.js +95 -0
  95. package/app/components/device/card.js +1 -0
  96. package/app/components/device/manager.js +1 -0
  97. package/app/components/device/panel-header.js +1 -0
  98. package/app/components/device/pill.js +1 -0
  99. package/app/components/driver/pill.js +1 -0
  100. package/app/components/map/drawer/device-event-listing.js +1 -0
  101. package/app/components/map/drawer/position-listing.js +1 -0
  102. package/app/components/modals/attach-device.js +1 -0
  103. package/app/components/order/pill.js +1 -0
  104. package/app/components/positions-replay.js +1 -0
  105. package/app/components/sensor/panel-header.js +1 -0
  106. package/app/components/vehicle/pill.js +1 -0
  107. package/app/helpers/get-fleet-ops-option-label.js +1 -0
  108. package/app/routes/management/drivers/index/details/positions.js +1 -0
  109. package/app/routes/management/vehicles/index/details/positions.js +1 -0
  110. package/app/services/position-playback.js +1 -0
  111. package/app/services/resource-metadata.js +1 -0
  112. package/app/templates/management/drivers/index/details/positions.js +1 -0
  113. package/app/templates/management/vehicles/index/details/positions.js +1 -0
  114. package/composer.json +1 -1
  115. package/extension.json +1 -1
  116. package/package.json +4 -4
  117. package/server/config/telematics.php +111 -0
  118. package/server/migrations/2025_10_27_000001_add_telematics_integration_fields.php +70 -0
  119. package/server/migrations/2025_10_27_171322_fix_device_column_names.php +107 -0
  120. package/server/migrations/2025_10_27_203023_add_company_uuid_to_device_events_table.php +28 -0
  121. package/server/src/Console/Commands/ReplayVehicleLocations.php +225 -0
  122. package/server/src/Contracts/TelematicProviderDescriptor.php +72 -0
  123. package/server/src/Contracts/TelematicProviderInterface.php +119 -0
  124. package/server/src/Exceptions/TelematicProviderException.php +14 -0
  125. package/server/src/Exceptions/TelematicRateLimitExceededException.php +12 -0
  126. package/server/src/Http/Controllers/Api/v1/DriverController.php +24 -14
  127. package/server/src/Http/Controllers/Api/v1/VehicleController.php +27 -7
  128. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +22 -0
  129. package/server/src/Http/Controllers/Internal/v1/PositionController.php +240 -0
  130. package/server/src/Http/Controllers/Internal/v1/SensorController.php +11 -0
  131. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +141 -0
  132. package/server/src/Http/Controllers/TelematicWebhookController.php +169 -0
  133. package/server/src/Http/Filter/DeviceEventFilter.php +68 -0
  134. package/server/src/Http/Filter/PositionFilter.php +35 -0
  135. package/server/src/Http/Resources/v1/Position.php +44 -0
  136. package/server/src/Jobs/ReplayPositions.php +64 -0
  137. package/server/src/Jobs/SendPositionReplay.php +65 -0
  138. package/server/src/Jobs/SyncTelematicDevicesJob.php +106 -0
  139. package/server/src/Jobs/TestTelematicConnectionJob.php +102 -0
  140. package/server/src/Models/Asset.php +10 -8
  141. package/server/src/Models/Device.php +79 -12
  142. package/server/src/Models/DeviceEvent.php +33 -3
  143. package/server/src/Models/Driver.php +28 -1
  144. package/server/src/Models/Maintenance.php +15 -12
  145. package/server/src/Models/Part.php +2 -0
  146. package/server/src/Models/Payload.php +0 -1
  147. package/server/src/Models/Place.php +4 -1
  148. package/server/src/Models/Position.php +27 -17
  149. package/server/src/Models/Sensor.php +78 -13
  150. package/server/src/Models/Telematic.php +116 -6
  151. package/server/src/Models/TrackingNumber.php +3 -1
  152. package/server/src/Models/Vehicle.php +8 -11
  153. package/server/src/Models/WorkOrder.php +8 -5
  154. package/server/src/Providers/FleetOpsServiceProvider.php +2 -0
  155. package/server/src/Support/Telematics/Providers/AbstractProvider.php +151 -0
  156. package/server/src/Support/Telematics/Providers/FlespiProvider.php +182 -0
  157. package/server/src/Support/Telematics/Providers/GeotabProvider.php +181 -0
  158. package/server/src/Support/Telematics/Providers/SamsaraProvider.php +177 -0
  159. package/server/src/Support/Telematics/TelematicProviderRegistry.php +147 -0
  160. package/server/src/Support/Telematics/TelematicService.php +223 -0
  161. package/server/src/Support/Utils.php +1 -1
  162. package/server/src/routes.php +24 -1
@@ -0,0 +1,100 @@
1
+ <div class="positions-replay-component flex flex-row items-center justify-between w-full px-5 py-2 border-b border-gray-200 dark:border-gray-700">
2
+ <div class="flex flex-row items-center space-x-2">
3
+ <div>
4
+ <div class="fleetbase-model-select fleetbase-power-select ember-model-select">
5
+ <PowerSelect
6
+ @options={{this.trackables}}
7
+ @selected={{this.resource}}
8
+ @onChange={{this.onResourceSelected}}
9
+ @placeholder={{t "common.select-field" field="trackable"}}
10
+ @triggerClass="form-select form-input form-input-sm w-48"
11
+ as |option|
12
+ >
13
+ <div class="flex flex-row items-start space-x-2">
14
+ <div class="inline-block">
15
+ {{#if (eq (get-model-name option) "driver")}}
16
+ <Image src={{option.photo_url}} @fallbackSrc={{config "defaultValues.driverAvatar"}} class="w-4 h-4" />
17
+ {{else}}
18
+ <Image src={{option.avatar_url}} @fallbackSrc={{config "defaultValues.vehicleAvatar"}} class="w-4 h-4" />
19
+ {{/if}}
20
+ </div>
21
+ <div class="inline-block text-sm truncate">
22
+ <div class="font-semibold text-sm normalize-in-trigger">{{or option.name option.displayName option.public_id}}</div>
23
+ <div class="hide-from-trigger text-xs">{{or option.email option.serial_number option.plate_number option.internal_id}}</div>
24
+ </div>
25
+ </div>
26
+ </PowerSelect>
27
+ </div>
28
+ </div>
29
+
30
+ <div class="w-48 {{if this.isReplaying 'hidden' ''}}">
31
+ <DatePicker
32
+ @value={{this.dateFilter}}
33
+ @range={{true}}
34
+ @onSelect={{this.onDateRangeChanged}}
35
+ @autoClose={{true}}
36
+ @placeholder="Select date range"
37
+ class="w-full form-input form-input-sm"
38
+ />
39
+ </div>
40
+
41
+ {{#if this.loadPositions.isRunning}}
42
+ <div>
43
+ <Spinner />
44
+ </div>
45
+ {{/if}}
46
+ </div>
47
+
48
+ <div class="replay-controls">
49
+ <div class="flex items-center justify-between space-x-2">
50
+ <div class="flex items-center space-x-2">
51
+ <Button @type="danger" @text="Stop" @icon="stop" @size="xs" @disabled={{not (or this.isReplaying this.isPaused)}} @onClick={{this.stopReplay}} />
52
+
53
+ {{#if (or this.isReplaying this.isPaused)}}
54
+ {{#if this.isReplaying}}
55
+ <Button @type="warning" @text="Pause" @icon="pause" @size="xs" @onClick={{this.pauseReplay}} />
56
+ {{else}}
57
+ <Button @type="success" @text="Resume" @icon="play" @size="xs" @onClick={{this.startReplay}} />
58
+ {{/if}}
59
+ {{else}}
60
+ <Button @type="success" @text="Play" @icon="play" @size="xs" @disabled={{not this.hasPositions}} @onClick={{this.startReplay}} />
61
+ {{/if}}
62
+
63
+ <div class="step-controls flex items-center space-x-1 border-l border-gray-300 dark:border-gray-700 pl-2 ml-2">
64
+ <Button @type="default" @icon="backward-step" @size="xs" @disabled={{not this.hasPositions}} @onClick={{this.stepBackward}} title="Step backward" />
65
+ <Button @type="default" @icon="forward-step" @size="xs" @disabled={{not this.hasPositions}} @onClick={{this.stepForward}} title="Step forward" />
66
+ </div>
67
+
68
+ <div class="speed-control border-l border-gray-300 dark:border-gray-700 pl-2 ml-2">
69
+ <label class="text-xs uppercase tracking-wide font-medium text-gray-700 dark:text-gray-400 mr-1">
70
+ Speed:
71
+ </label>
72
+ <Select
73
+ @value={{this.replaySpeed}}
74
+ @options={{this.speedOptions}}
75
+ @optionValue="value"
76
+ @optionLabel="label"
77
+ @onSelect={{this.onSpeedChanged}}
78
+ class="form-select speed-select"
79
+ />
80
+ </div>
81
+ </div>
82
+
83
+ {{#if (or this.isReplaying this.isPaused)}}
84
+ <div class="replay-progress flex items-center space-x-1">
85
+ <span class="text-sm text-gray-600 dark:text-gray-400">
86
+ {{this.currentReplayIndex}}/{{this.totalPositions}}
87
+ </span>
88
+ <div class="progress-bar w-32 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
89
+ <div class="progress-fill h-full bg-blue-600 transition-all duration-300" style={{this.replayProgressWidth}}></div>
90
+ </div>
91
+ <span class="text-sm font-medium text-gray-700 dark:text-gray-300">
92
+ {{this.replayProgress}}%
93
+ </span>
94
+ </div>
95
+ {{/if}}
96
+ </div>
97
+ </div>
98
+ </div>
99
+ <Table @rows={{this.positions}} @columns={{this.columns}} />
100
+ <Spacer @height="400px" />
@@ -0,0 +1,455 @@
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 { task } from 'ember-concurrency';
6
+ import { isArray } from '@ember/array';
7
+ import { htmlSafe } from '@ember/template';
8
+ import { startOfWeek, endOfWeek, format } from 'date-fns';
9
+ import getModelName from '@fleetbase/ember-core/utils/get-model-name';
10
+
11
+ const L = window.leaflet || window.L;
12
+
13
+ export default class MapDrawerPositionListingComponent extends Component {
14
+ @service leafletMapManager;
15
+ @service store;
16
+ @service fetch;
17
+ @service positionPlayback;
18
+ @service hostRouter;
19
+ @service notifications;
20
+ @service intl;
21
+
22
+ /** Tracked properties - only what's NOT managed by service */
23
+ @tracked positions = [];
24
+ @tracked resource = null;
25
+ @tracked selectedOrder = null;
26
+ @tracked dateFilter = [format(startOfWeek(new Date(), { weekStartsOn: 1 }), 'yyyy-MM-dd'), format(endOfWeek(new Date(), { weekStartsOn: 1 }), 'yyyy-MM-dd')];
27
+ @tracked replaySpeed = '1';
28
+ @tracked positionsLayer = null;
29
+
30
+ /** Computed properties - read state from service */
31
+ get isReplaying() {
32
+ return this.positionPlayback.isPlaying;
33
+ }
34
+
35
+ get isPaused() {
36
+ return this.positionPlayback.isPaused;
37
+ }
38
+
39
+ get currentReplayIndex() {
40
+ return this.positionPlayback.currentIndex;
41
+ }
42
+
43
+ get trackables() {
44
+ const vehicles = this.leafletMapManager._livemap?.vehicles ?? [];
45
+ const drivers = this.leafletMapManager._livemap?.drivers ?? [];
46
+
47
+ return [...vehicles, ...drivers];
48
+ }
49
+
50
+ get replayProgressWidth() {
51
+ return htmlSafe(`width: ${this.replayProgress}%;`);
52
+ }
53
+
54
+ get orderFilters() {
55
+ const params = {};
56
+
57
+ if (this.resourceType === 'vehicle') {
58
+ params.vehicle_assigned_uuid = this.resource?.id;
59
+ }
60
+
61
+ if (this.resourceType === 'driver') {
62
+ params.driver_assigned_uuid = this.resource?.id;
63
+ }
64
+
65
+ return params;
66
+ }
67
+
68
+ get resourceType() {
69
+ if (!this.resource) {
70
+ return 'resource';
71
+ }
72
+ return getModelName(this.resource) || 'resource';
73
+ }
74
+
75
+ get hasPositions() {
76
+ return this.positions.length > 0;
77
+ }
78
+
79
+ get totalPositions() {
80
+ return this.positions.length;
81
+ }
82
+
83
+ get replayProgress() {
84
+ if (this.totalPositions === 0) {
85
+ return 0;
86
+ }
87
+ return Math.round((this.currentReplayIndex / this.totalPositions) * 100);
88
+ }
89
+
90
+ get speedOptions() {
91
+ return [
92
+ { label: '0.5x', value: '0.5' },
93
+ { label: '1x', value: '1' },
94
+ { label: '2x', value: '2' },
95
+ { label: '5x', value: '5' },
96
+ { label: '10x', value: '10' },
97
+ { label: '20x', value: '20' },
98
+ { label: '30x', value: '30' },
99
+ { label: '40x', value: '40' },
100
+ { label: '50x', value: '50' },
101
+ { label: '100x', value: '100' },
102
+ { label: '80x', value: '80' },
103
+ { label: '120x', value: '120' },
104
+ { label: '160x', value: '160' },
105
+ { label: '180x', value: '180' },
106
+ { label: '200x', value: '200' },
107
+ { label: '250x', value: '250' },
108
+ { label: '280x', value: '280' },
109
+ { label: '300x', value: '300' },
110
+ { label: '350x', value: '350' },
111
+ { label: '400x', value: '400' },
112
+ { label: '500x', value: '500' },
113
+ { label: '600x', value: '600' },
114
+ { label: '1000x', value: '1000' },
115
+ ];
116
+ }
117
+
118
+ /** columns */
119
+ get columns() {
120
+ return [
121
+ {
122
+ label: '#',
123
+ valuePath: 'index',
124
+ width: '80px',
125
+ cellComponent: 'table/cell/anchor',
126
+ onClick: this.onPositionClicked,
127
+ },
128
+ {
129
+ label: 'Timestamp',
130
+ valuePath: 'timestamp',
131
+ cellComponent: 'table/cell/anchor',
132
+ onClick: this.onPositionClicked,
133
+ },
134
+ {
135
+ label: 'Latitude',
136
+ valuePath: 'latitude',
137
+ cellComponent: 'table/cell/anchor',
138
+ onClick: this.onPositionClicked,
139
+ },
140
+ {
141
+ label: 'Longitude',
142
+ valuePath: 'longitude',
143
+ cellComponent: 'table/cell/anchor',
144
+ onClick: this.onPositionClicked,
145
+ },
146
+ {
147
+ label: 'Speed (km/h)',
148
+ valuePath: 'speedKmh',
149
+ },
150
+ {
151
+ label: 'Heading',
152
+ valuePath: 'heading',
153
+ },
154
+ {
155
+ label: 'Altitude (m)',
156
+ valuePath: 'altitude',
157
+ },
158
+ ];
159
+ }
160
+
161
+ constructor() {
162
+ super(...arguments);
163
+ this.loadPositions.perform();
164
+ }
165
+
166
+ willDestroy() {
167
+ super.willDestroy?.();
168
+
169
+ // Clean up replay tracker
170
+ this.positionPlayback.reset();
171
+
172
+ // Remove our layer group from the map
173
+ this.#clearPositionsLayer(true);
174
+ this.positionsLayer = null;
175
+ }
176
+
177
+ @action onResourceSelected(resource) {
178
+ this.resource = resource;
179
+ this.focusResource(resource);
180
+ this.loadPositions.perform();
181
+ }
182
+
183
+ @action onOrderSelected(order) {
184
+ this.selectedOrder = order;
185
+ this.loadPositions.perform();
186
+ }
187
+
188
+ @action onDateRangeChanged({ formattedDate }) {
189
+ if (isArray(formattedDate) && formattedDate.length === 2) {
190
+ this.dateFilter = formattedDate;
191
+ this.loadPositions.perform();
192
+ }
193
+ }
194
+
195
+ @action onSpeedChanged(speed) {
196
+ this.replaySpeed = speed;
197
+
198
+ // Update replay speed in real-time if currently playing
199
+ if (this.isReplaying) {
200
+ this.positionPlayback.setSpeed(parseFloat(speed));
201
+ }
202
+ }
203
+
204
+ @action startReplay() {
205
+ if (this.positions.length === 0) {
206
+ this.notifications.warning('No positions to replay');
207
+ return;
208
+ }
209
+
210
+ if (this.isReplaying && !this.isPaused) {
211
+ this.notifications.info('Replay is already running');
212
+ return;
213
+ }
214
+
215
+ // If paused, resume
216
+ if (this.isPaused) {
217
+ this.positionPlayback.play();
218
+ return;
219
+ }
220
+
221
+ // Start new replay
222
+ this.#initializeReplay();
223
+ this.positionPlayback.play();
224
+ }
225
+
226
+ @action pauseReplay() {
227
+ if (!this.isReplaying) {
228
+ return;
229
+ }
230
+
231
+ this.positionPlayback.pause();
232
+ }
233
+
234
+ @action stopReplay() {
235
+ this.positionPlayback.stop();
236
+ }
237
+
238
+ @action stepForward() {
239
+ if (this.isReplaying) {
240
+ this.pauseReplay();
241
+ }
242
+ this.positionPlayback.stepForward(1);
243
+ }
244
+
245
+ @action stepBackward() {
246
+ if (this.isReplaying) {
247
+ this.pauseReplay();
248
+ }
249
+ this.positionPlayback.stepBackward(1);
250
+ }
251
+
252
+ @action clearFilters() {
253
+ this.selectedOrder = null;
254
+ this.dateFilter = null;
255
+ this.loadPositions.perform();
256
+ }
257
+
258
+ @action onPositionClicked(position) {
259
+ if (this.leafletMapManager.map && position.latitude && position.longitude) {
260
+ this.leafletMapManager.map.setView([position.latitude, position.longitude], 16);
261
+ }
262
+ }
263
+
264
+ @action focusResource(resource) {
265
+ const hasValidCoordinates = resource?.hasValidCoordinates || (Number.isFinite(resource?.latitude) && Number.isFinite(resource?.longitude));
266
+ if (hasValidCoordinates) {
267
+ const coordinates = [resource.latitude, resource.longitude];
268
+
269
+ // Use flyTo with a zoom level of 18 for a smooth animation
270
+ this.leafletMapManager.map.flyTo(coordinates, 18, {
271
+ animate: true,
272
+ duration: 0.8,
273
+ });
274
+ }
275
+ }
276
+
277
+ @task *loadPositions() {
278
+ if (!this.resource) return;
279
+
280
+ try {
281
+ const params = {
282
+ limit: 900,
283
+ sort: 'created_at',
284
+ subject_uuid: this.resource.id,
285
+ };
286
+
287
+ if (this.selectedOrder) {
288
+ params.order_uuid = this.selectedOrder.id;
289
+ }
290
+
291
+ if (isArray(this.dateFilter) && this.dateFilter.length === 2) {
292
+ params.created_at = this.dateFilter.join(',');
293
+ }
294
+
295
+ const positions = yield this.store.query('position', params);
296
+ this.positions = isArray(positions)
297
+ ? positions.map((pos, index) => {
298
+ pos.set('index', index + 1);
299
+ return pos;
300
+ })
301
+ : [];
302
+
303
+ this.#renderPositionsOnMap({ fitLast: 5 });
304
+
305
+ // Reset replay state when positions change
306
+ this.stopReplay();
307
+ } catch (error) {
308
+ this.notifications.serverError(error);
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Initialize replay tracker with current positions and settings
314
+ * This replaces the socket-based backend replay approach
315
+ *
316
+ * @private
317
+ */
318
+ #initializeReplay() {
319
+ if (!this.resource) {
320
+ this.notifications.warning('No resource provided for replay');
321
+ return;
322
+ }
323
+
324
+ if (this.positions.length === 0) {
325
+ this.notifications.warning('No positions to replay');
326
+ return;
327
+ }
328
+
329
+ // Initialize replay tracker with positions
330
+ this.positionPlayback.initialize({
331
+ subject: this.resource,
332
+ positions: this.positions,
333
+ speed: parseFloat(this.replaySpeed),
334
+ map: this.leafletMapManager.map,
335
+ callback: (data) => {
336
+ if (data.type === 'complete') {
337
+ // Replay completed
338
+ this.notifications.success('Replay completed');
339
+ }
340
+ },
341
+ });
342
+ }
343
+
344
+ /** Position Rendering */
345
+ #ensurePositionsLayer() {
346
+ if (!this.leafletMapManager.map) return null;
347
+
348
+ if (!this.positionsLayer) {
349
+ this.positionsLayer = L.layerGroup();
350
+ this.positionsLayer.addTo(this.leafletMapManager.map);
351
+ }
352
+ return this.positionsLayer;
353
+ }
354
+
355
+ #clearPositionsLayer(removeFromMap = false) {
356
+ if (this.positionsLayer) {
357
+ this.positionsLayer.clearLayers();
358
+ if (removeFromMap && this.leafletMapManager.map) {
359
+ this.positionsLayer.removeFrom(this.leafletMapManager.map);
360
+ }
361
+ }
362
+ }
363
+
364
+ #addPositionMarker(pos, index) {
365
+ const lat = parseFloat(pos.latitude);
366
+ const lng = parseFloat(pos.longitude);
367
+ if (!this.#isValidLatLng(lat, lng)) return;
368
+
369
+ const marker = L.circleMarker([lat, lng], {
370
+ radius: 3,
371
+ color: '#3b82f6',
372
+ fillColor: '#3b82f6',
373
+ fillOpacity: 0.6,
374
+ });
375
+
376
+ // Popup content (mirrors your template)
377
+ const html = htmlSafe(`<div class="text-xs">
378
+ <div><strong>Position ${index + 1}</strong></div>
379
+ <div>Time: ${pos.timestamp ?? ''}</div>
380
+ <div>Speed: ${pos.speedKmh ?? 'N/A'} km/h</div>
381
+ <div>Heading: ${pos.heading ?? 'N/A'}°</div>
382
+ <div>Altitude: ${pos.altitude ?? 'N/A'} m</div>
383
+ </div>`);
384
+
385
+ marker.bindPopup(html);
386
+
387
+ // Click handler -> reuse your action
388
+ marker.on('click', () => this.onPositionClicked(pos));
389
+
390
+ marker.addTo(this.positionsLayer);
391
+ }
392
+
393
+ #isValidLatLng(lat, lng) {
394
+ return Number.isFinite(lat) && Number.isFinite(lng) && lat <= 90 && lat >= -90 && lng <= 180 && lng >= -180 && lat !== 0 && lng !== 0;
395
+ }
396
+
397
+ #renderPositionsOnMap({ fitLast = 0, minZoom = 15, maxZoom = 18 } = {}) {
398
+ if (!this.leafletMapManager.map || !this.positions?.length) {
399
+ this.#clearPositionsLayer(false);
400
+ return;
401
+ }
402
+
403
+ this.#ensurePositionsLayer();
404
+ this.positionsLayer.clearLayers();
405
+
406
+ const latlngs = [];
407
+ this.positions.forEach((pos, i) => {
408
+ const lat = parseFloat(pos.latitude);
409
+ const lng = parseFloat(pos.longitude);
410
+ if (this.#isValidLatLng(lat, lng)) {
411
+ this.#addPositionMarker(pos, i);
412
+ latlngs.push([lat, lng]);
413
+ }
414
+ });
415
+
416
+ if (!latlngs.length) return;
417
+
418
+ // choose subset (e.g., last 5 points) to bias the view local
419
+ const slice = fitLast > 0 ? latlngs.slice(-fitLast) : latlngs;
420
+
421
+ // Clamp zoom to neighborhood-level
422
+ this.#fitNeighborhood(slice, { zoom: 16, minZoom, maxZoom, padding: [24, 24], animate: true });
423
+ }
424
+
425
+ #fitNeighborhood(latlngs, { zoom = null, minZoom = 15, maxZoom = 18, padding = [16, 16], animate = true } = {}) {
426
+ if (!this.leafletMapManager.map || !latlngs?.length) return;
427
+
428
+ const map = this.leafletMapManager.map;
429
+
430
+ if (latlngs.length === 1) {
431
+ // Single point → center on it at minZoom
432
+ map.setView(latlngs[0], minZoom, { animate });
433
+ return;
434
+ }
435
+
436
+ const bounds = L.latLngBounds(latlngs);
437
+ // Compute the zoom that would fit the bounds, then clamp it
438
+ // Leaflet getBoundsZoom may be (bounds, inside, padding) depending on version
439
+ let targetZoom = zoom;
440
+ if (!zoom) {
441
+ try {
442
+ // Try newer signature
443
+ targetZoom = map.getBoundsZoom(bounds, true, padding);
444
+ } catch {
445
+ // Fallback without padding param
446
+ targetZoom = map.getBoundsZoom(bounds, true);
447
+ }
448
+ targetZoom = Math.max(minZoom, Math.min(maxZoom, targetZoom));
449
+ }
450
+
451
+ // Center on bounds center with clamped zoom
452
+ const center = bounds.getCenter();
453
+ map.setView(center, targetZoom, { animate });
454
+ }
455
+ }
@@ -13,6 +13,8 @@ export default class MapDrawerComponent extends Component {
13
13
  this.universe._createMenuItem('Vehicles', null, { icon: 'car', component: 'map/drawer/vehicle-listing' }),
14
14
  this.universe._createMenuItem('Drivers', null, { icon: 'id-card', component: 'map/drawer/driver-listing' }),
15
15
  this.universe._createMenuItem('Places', null, { icon: 'building', component: 'map/drawer/place-listing' }),
16
+ this.universe._createMenuItem('Positions', null, { icon: 'map-marker', component: 'map/drawer/position-listing' }),
17
+ this.universe._createMenuItem('Events', null, { icon: 'stream', component: 'map/drawer/device-event-listing' }),
16
18
  ...(isArray(registeredTabs) ? registeredTabs : []),
17
19
  ];
18
20
  }
@@ -66,10 +66,11 @@
66
66
  {{/each}}
67
67
 
68
68
  {{#each this.vehicles as |vehicle|}}
69
- <layers.marker
69
+ <layers.tracking-marker
70
70
  @id={{vehicle.id}}
71
71
  @publicId={{vehicle.public_id}}
72
72
  @location={{point-to-coordinates vehicle.location}}
73
+ @rotationAngle={{vehicle.heading}}
73
74
  @icon={{icon iconUrl=vehicle.avatar_url iconSize=(array 24 24)}}
74
75
  @onAdd={{fn this.trigger "onVehicleAdded" vehicle}}
75
76
  @onClick={{fn this.trigger "onVehicleClicked" vehicle}}
@@ -83,6 +84,8 @@
83
84
  </div>
84
85
  <div class="flex-1">
85
86
  <div class="text-xs font-semibold">{{vehicle.displayName}}</div>
87
+ <div class="text-xs">ID: {{n-a vehicle.public_id}}</div>
88
+ <div class="text-xs">Serial No: {{n-a vehicle.serial_number vehicle.vin vehicle.internal_id vehicle.id}}</div>
86
89
  <div class="text-xs">Driver: {{n-a vehicle.driver_name}}</div>
87
90
  <div class="text-xs">Status: <span class="{{if vehicle.online 'text-green-500' 'text-red-400'}}">{{if vehicle.online "Online" "Offline"}}</span></div>
88
91
  <div class="text-xs truncate">Pos: {{point-coordinates vehicle.location}}</div>
@@ -95,9 +98,11 @@
95
98
  <div>•</div>
96
99
  <div class="text-xs {{if vehicle.online 'text-green-500' 'text-red-400'}}">{{if vehicle.online "Online" "Offline"}}</div>
97
100
  </div>
101
+ <div class="text-xs">ID: {{n-a vehicle.public_id}}</div>
102
+ <div class="text-xs">Serial No: {{or vehicle.serial_number vehicle.vin vehicle.internal_id vehicle.id "-"}}</div>
98
103
  <div class="text-xs truncate"><FaIcon @icon="location-dot" @size="xs" class="mr-0.5" />{{point-coordinates vehicle.location}}</div>
99
104
  </marker.tooltip>
100
- </layers.marker>
105
+ </layers.tracking-marker>
101
106
  {{/each}}
102
107
 
103
108
  {{#each this.places as |place|}}
@@ -0,0 +1,18 @@
1
+ <Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{@onConfirm}} @decline={{@onDecline}}>
2
+ <div class="modal-body-container">
3
+ <InputGroup @name={{t "common.select-field" field=(t "resource.device")}} @value={{@options.driver.vehicle_uuid}}>
4
+ <ModelSelect
5
+ @modelName="device"
6
+ @selectedModel={{@options.selectedDevice}}
7
+ @placeholder={{t "common.select-field" field=(t "resource.device")}}
8
+ @triggerClass="form-select form-input"
9
+ @infiniteScroll={{false}}
10
+ @renderInPlace={{true}}
11
+ @onChange={{fn (mut @options.selectedDevice)}}
12
+ as |model|
13
+ >
14
+ <Device::Pill @device={{model}} />
15
+ </ModelSelect>
16
+ </InputGroup>
17
+ </div>
18
+ </Modal::Default>
@@ -0,0 +1,3 @@
1
+ import Component from '@glimmer/component';
2
+
3
+ export default class ModalsAttachDeviceComponent extends Component {}