@fleetbase/fleetops-engine 0.6.21 → 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 (61) hide show
  1. package/addon/components/device/card.hbs +1 -0
  2. package/addon/components/device/card.js +3 -0
  3. package/addon/components/device/manager.hbs +29 -0
  4. package/addon/components/device/manager.js +95 -0
  5. package/addon/components/device/pill.hbs +16 -0
  6. package/addon/components/device/pill.js +3 -0
  7. package/addon/components/driver/details.hbs +4 -0
  8. package/addon/components/driver/details.js +19 -1
  9. package/addon/components/driver/form.hbs +13 -2
  10. package/addon/components/driver/pill.hbs +17 -0
  11. package/addon/components/driver/pill.js +3 -0
  12. package/addon/components/map/drawer/device-event-listing.hbs +6 -0
  13. package/addon/components/map/drawer/position-listing.hbs +35 -19
  14. package/addon/components/map/drawer/position-listing.js +230 -64
  15. package/addon/components/modals/attach-device.hbs +18 -0
  16. package/addon/components/modals/attach-device.js +3 -0
  17. package/addon/components/order/details/detail.hbs +2 -54
  18. package/addon/components/order/details/detail.js +1 -0
  19. package/addon/components/order/pill.hbs +34 -0
  20. package/addon/components/order/pill.js +3 -0
  21. package/addon/components/positions-replay.hbs +26 -20
  22. package/addon/components/positions-replay.js +100 -63
  23. package/addon/components/telematic/form.hbs +4 -4
  24. package/addon/components/vehicle/card.hbs +1 -1
  25. package/addon/components/vehicle/details.hbs +4 -0
  26. package/addon/components/vehicle/details.js +19 -1
  27. package/addon/components/vehicle/form.hbs +4 -0
  28. package/addon/components/vehicle/pill.hbs +34 -0
  29. package/addon/components/vehicle/pill.js +3 -0
  30. package/addon/controllers/management/vehicles/index/details.js +5 -0
  31. package/addon/routes/management/drivers/index/details/positions.js +3 -0
  32. package/addon/routes.js +3 -0
  33. package/addon/services/position-playback.js +486 -0
  34. package/addon/services/resource-metadata.js +46 -0
  35. package/addon/templates/management/drivers/index/details/positions.hbs +2 -0
  36. package/addon/templates/management/vehicles/index/details/devices.hbs +1 -2
  37. package/app/components/device/card.js +1 -0
  38. package/app/components/device/manager.js +1 -0
  39. package/app/components/device/pill.js +1 -0
  40. package/app/components/driver/pill.js +1 -0
  41. package/app/components/modals/attach-device.js +1 -0
  42. package/app/components/order/pill.js +1 -0
  43. package/app/components/vehicle/pill.js +1 -0
  44. package/app/routes/management/drivers/index/details/positions.js +1 -0
  45. package/app/services/position-playback.js +1 -0
  46. package/app/services/resource-metadata.js +1 -0
  47. package/app/templates/management/drivers/index/details/positions.js +1 -0
  48. package/composer.json +1 -1
  49. package/extension.json +1 -1
  50. package/package.json +2 -2
  51. package/server/src/Http/Controllers/{Internal/v1/TelematicWebhookController.php → TelematicWebhookController.php} +1 -2
  52. package/server/src/Http/Resources/v1/Position.php +1 -1
  53. package/server/src/Models/Asset.php +10 -8
  54. package/server/src/Models/Device.php +11 -6
  55. package/server/src/Models/DeviceEvent.php +26 -3
  56. package/server/src/Models/Maintenance.php +15 -12
  57. package/server/src/Models/Part.php +2 -0
  58. package/server/src/Models/Position.php +10 -0
  59. package/server/src/Models/TrackingNumber.php +3 -1
  60. package/server/src/Models/WorkOrder.php +8 -5
  61. package/server/src/routes.php +12 -0
@@ -12,29 +12,38 @@ import getModelName from '@fleetbase/ember-core/utils/get-model-name';
12
12
  export default class PositionsReplayComponent extends Component {
13
13
  @service store;
14
14
  @service fetch;
15
- @service movementTracker;
15
+ @service positionPlayback;
16
16
  @service notifications;
17
17
  @service location;
18
18
 
19
19
  /** Component ID */
20
20
  id = guidFor(this);
21
21
 
22
- /** Tracked properties */
22
+ /** Tracked properties - only what's NOT managed by service */
23
23
  @tracked positions = [];
24
24
  @tracked selectedOrder = null;
25
25
  @tracked dateFilter = [format(startOfWeek(new Date(), { weekStartsOn: 1 }), 'yyyy-MM-dd'), format(endOfWeek(new Date(), { weekStartsOn: 1 }), 'yyyy-MM-dd')];
26
26
  @tracked map = null;
27
- @tracked isReplaying = false;
28
27
  @tracked replaySpeed = '1';
29
- @tracked currentReplayIndex = 0;
30
28
  @tracked metrics = null;
31
29
  @tracked latitude = this.args.resource.latitude || this.location.getLatitude();
32
30
  @tracked longitude = this.args.resource.longitude || this.location.getLongitude();
33
31
  @tracked zoom = 14;
34
32
  @tracked tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
35
- @tracked channelId = null;
36
33
 
37
- /** computed */
34
+ /** Computed properties - read state from service */
35
+ get isReplaying() {
36
+ return this.positionPlayback.isPlaying;
37
+ }
38
+
39
+ get isPaused() {
40
+ return this.positionPlayback.isPaused;
41
+ }
42
+
43
+ get currentReplayIndex() {
44
+ return this.positionPlayback.currentIndex;
45
+ }
46
+
38
47
  get replayProgressWidth() {
39
48
  return htmlSafe(`width: ${this.replayProgress}%;`);
40
49
  }
@@ -179,6 +188,13 @@ export default class PositionsReplayComponent extends Component {
179
188
  this.loadPositions.perform();
180
189
  }
181
190
 
191
+ willDestroy() {
192
+ super.willDestroy?.();
193
+
194
+ // Clean up replay tracker on component destroy
195
+ this.positionPlayback.reset();
196
+ }
197
+
182
198
  /** Actions */
183
199
  @action didLoadMap({ target: map }) {
184
200
  this.map = map;
@@ -210,6 +226,11 @@ export default class PositionsReplayComponent extends Component {
210
226
 
211
227
  @action onSpeedChanged(speed) {
212
228
  this.replaySpeed = speed;
229
+
230
+ // Update replay speed in real-time if currently playing
231
+ if (this.isReplaying) {
232
+ this.positionPlayback.setSpeed(parseFloat(speed));
233
+ }
213
234
  }
214
235
 
215
236
  @action startReplay() {
@@ -217,12 +238,47 @@ export default class PositionsReplayComponent extends Component {
217
238
  this.notifications.warning('No positions to replay');
218
239
  return;
219
240
  }
220
- this.replayPositions.perform();
241
+
242
+ if (this.isReplaying && !this.isPaused) {
243
+ this.notifications.info('Replay is already running');
244
+ return;
245
+ }
246
+
247
+ // If paused, resume
248
+ if (this.isPaused) {
249
+ this.positionPlayback.play();
250
+ return;
251
+ }
252
+
253
+ // Start new replay
254
+ this.#initializeReplay();
255
+ this.positionPlayback.play();
256
+ }
257
+
258
+ @action pauseReplay() {
259
+ if (!this.isReplaying) {
260
+ return;
261
+ }
262
+
263
+ this.positionPlayback.pause();
221
264
  }
222
265
 
223
266
  @action stopReplay() {
224
- this.isReplaying = false;
225
- this.currentReplayIndex = 0;
267
+ this.positionPlayback.stop();
268
+ }
269
+
270
+ @action stepForward() {
271
+ if (this.isReplaying) {
272
+ this.pauseReplay();
273
+ }
274
+ this.positionPlayback.stepForward(1);
275
+ }
276
+
277
+ @action stepBackward() {
278
+ if (this.isReplaying) {
279
+ this.pauseReplay();
280
+ }
281
+ this.positionPlayback.stepBackward(1);
226
282
  }
227
283
 
228
284
  @action clearFilters() {
@@ -269,13 +325,19 @@ export default class PositionsReplayComponent extends Component {
269
325
  if (this.positions?.length) {
270
326
  yield this.loadMetrics.perform();
271
327
 
272
- const bounds = positions.map((pos) => pos.latLng).filter(Boolean);
328
+ const bounds = positions
329
+ .filter(({ latitude, longitude }) => this.#isValidLatLng(latitude, longitude))
330
+ .map((pos) => pos.latLng)
331
+ .filter(Boolean);
273
332
  const lastFiveBounds = bounds.slice(-5);
274
333
  this.map.flyToBounds(lastFiveBounds, {
275
334
  animate: true,
276
335
  zoom: 16,
277
336
  });
278
337
  }
338
+
339
+ // Reset replay state when positions change
340
+ this.stopReplay();
279
341
  } catch (error) {
280
342
  this.notifications.serverError(error);
281
343
  }
@@ -301,65 +363,36 @@ export default class PositionsReplayComponent extends Component {
301
363
  }
302
364
  }
303
365
 
304
- @task *replayPositions() {
366
+ /**
367
+ * Initialize replay tracker with current positions and settings
368
+ * This replaces the socket-based backend replay approach
369
+ *
370
+ * @private
371
+ */
372
+ #initializeReplay() {
305
373
  if (!this.args.resource) {
306
374
  this.notifications.warning('No resource provided for replay');
307
375
  return;
308
376
  }
309
377
 
310
- try {
311
- this.isReplaying = true;
312
- this.currentReplayIndex = 0;
313
-
314
- const positionIds = this.positions.map((p) => p.id);
315
-
316
- if (positionIds.length === 0) {
317
- this.notifications.warning('No positions to replay');
318
- this.isReplaying = false;
319
- return;
320
- }
321
-
322
- // Generate unique channel ID for this replay session
323
- this.channelId = `position.replay.${this.id}.${Date.now()}`;
324
-
325
- // Start tracking on custom channel
326
- yield this.movementTracker.track(this.resource, {
327
- channelId: this.channelId,
328
- callback: (output) => {
329
- const {
330
- data: { additionalData },
331
- } = output;
332
-
333
- const leafletLayer = this.resource.leafletLayer;
334
- if (leafletLayer) {
335
- const latlng = leafletLayer._slideToLatLng ?? leafletLayer.getLatLng();
336
- this.map.panTo(latlng, { animate: true });
337
- }
338
-
339
- if (additionalData && Number.isFinite(additionalData.index)) {
340
- this.currentReplayIndex = additionalData.index + 1;
341
- if (this.currentReplayIndex === this.totalPositions) {
342
- this.isReplaying = false;
343
- }
344
- }
345
- },
346
- });
347
-
348
- // Trigger backend replay
349
- const response = yield this.fetch.post('positions/replay', {
350
- position_ids: positionIds,
351
- channel_id: this.channelId,
352
- speed: parseFloat(this.replaySpeed),
353
- subject_uuid: this.args.resource.id,
354
- });
355
-
356
- if (response && response.status === 'ok') {
357
- this.notifications.success('Replay started successfully');
358
- }
359
- } catch (error) {
360
- this.notifications.serverError(error);
361
- this.isReplaying = false;
378
+ if (this.positions.length === 0) {
379
+ this.notifications.warning('No positions to replay');
380
+ return;
362
381
  }
382
+
383
+ // Initialize replay tracker with positions
384
+ this.positionPlayback.initialize({
385
+ subject: this.resource,
386
+ positions: this.positions,
387
+ speed: parseFloat(this.replaySpeed),
388
+ map: this.map,
389
+ callback: (data) => {
390
+ if (data.type === 'complete') {
391
+ // Replay completed
392
+ this.notifications.success('Replay completed');
393
+ }
394
+ },
395
+ });
363
396
  }
364
397
 
365
398
  #setResourceLayer(model, layer) {
@@ -369,4 +402,8 @@ export default class PositionsReplayComponent extends Component {
369
402
  set(layer, 'record_id', model.id);
370
403
  set(layer, 'record_type', type);
371
404
  }
405
+
406
+ #isValidLatLng(lat, lng) {
407
+ return Number.isFinite(lat) && Number.isFinite(lng) && lat <= 90 && lat >= -90 && lng <= 180 && lng >= -180 && lat !== 0 && lng !== 0;
408
+ }
372
409
  }
@@ -49,11 +49,11 @@
49
49
  <InputGroup @value={{@resource.name}} @name="Integration Name" />
50
50
 
51
51
  {{#if this.selectedProvider.supports_webhooks}}
52
- <InputGroup @name="Webhook URL" @helpText={{concat "Configure the URL in your " this.selectedProvider.label " dashboard to receive real-time updates."}}>
53
- <ClickToCopy @value={{this.selectedProvider.webhook_url}}>
54
- <Input @value={{this.selectedProvider.webhook_url}} class="form-input" readonly />
52
+ <InputGroup @name="Webhook URL" @helpText={{concat "Configure the URL in your " this.selectedProvider.label " dashboard to receive real-time updates."}} @wrapperClass="col-span-2">
53
+ <ClickToCopy @value={{this.selectedProvider.webhook_url}} class="w-full">
54
+ <Input @value={{this.selectedProvider.webhook_url}} class="form-input w-full" readonly />
55
55
  </ClickToCopy>
56
- <small class="form-text text-muted">
56
+ <small class="form-text text-muted mt-1">
57
57
  Configure this URL in your
58
58
  {{this.selectedProvider.label}}
59
59
  dashboard to receive real-time updates.
@@ -1,5 +1,5 @@
1
1
  <Layout::Resource::Card ...attributes as |Card|>
2
- <Card.header class={{@headerClass}}>
2
+ <Card.header class="{{@headerClass}} truncate">
3
3
  <div class="font-semibold">{{or @resource.name @resource.yearMakeModel}}</div>
4
4
  <div class="text-gray-300 dark:text-gray-500 text-sm">{{or @resource.plate_number @resource.vin @resource.serial_number @resource.call_sign @resource.public_id}}</div>
5
5
  {{#if (has-block "header")}}
@@ -47,4 +47,8 @@
47
47
  </div>
48
48
  </div>
49
49
  </ContentPanel>
50
+
51
+ <ContentPanel @title={{t "common.metadata"}} @open={{true}} @actionButtons={{this.metadataButtons}} @wrapperClass="bordered-top" @panelBodyWrapperClass={{unless (is-object-empty @resource.meta) "px-0i" ""}}>
52
+ <MetadataViewer @metadata={{@resource.meta}} />
53
+ </ContentPanel>
50
54
  </div>
@@ -1,3 +1,21 @@
1
1
  import Component from '@glimmer/component';
2
+ import { inject as service } from '@ember/service';
2
3
 
3
- export default class VehicleDetailsComponent extends Component {}
4
+ export default class VehicleDetailsComponent extends Component {
5
+ @service resourceMetadata;
6
+
7
+ get metadataButtons() {
8
+ return [
9
+ {
10
+ type: 'default',
11
+ text: 'Edit',
12
+ icon: 'pencil',
13
+ iconPrefix: 'fas',
14
+ permission: 'fleet-ops update vehicle',
15
+ onClick: () => {
16
+ this.resourceMetadata.edit(this.args.resource);
17
+ },
18
+ },
19
+ ];
20
+ }
21
+ }
@@ -126,4 +126,8 @@
126
126
  <ContentPanel @title={{t "avatar-picker.avatar"}} @open={{true}} @wrapperClass="bordered-top">
127
127
  <AvatarPicker @model={{@resource}} @defaultAvatar={{config "defaultValues.vehicleAvatar"}} @disabled={{cannot-write @resource}} />
128
128
  </ContentPanel>
129
+
130
+ <ContentPanel @title={{t "common.metadata"}} @open={{true}} @wrapperClass="bordered-top">
131
+ <MetadataEditor @value={{@resource.meta}} @onChange={{fn (mut @resource.meta)}} />
132
+ </ContentPanel>
129
133
  </div>
@@ -0,0 +1,34 @@
1
+ {{#let (or @vehicle @resource) as |resource|}}
2
+ <Pill
3
+ @resource={{resource}}
4
+ @imageSrc={{resource.photo_url}}
5
+ @fallbackImageType="vehicleImage"
6
+ @showOnlineIndicator={{true}}
7
+ @onClick={{@onClick}}
8
+ @anchorClass={{@anchorClass}}
9
+ @imageClass={{@imageClass}}
10
+ @imageWrapperClass={{@imageWrapperClass}}
11
+ @contentWrapperClass={{@contentWrapperClass}}
12
+ @titleClass={{@titleClass}}
13
+ @subtitleClass={{@subtitleClass}}
14
+ ...attributes
15
+ >
16
+ <:default>
17
+ <div class="text-sm">{{n-a resource.name resource.yearMakeModel}}</div>
18
+ <div class="text-xs text-gray-400 dark:text-gray-500">{{or resource.plate_number resource.vin resource.serial_number resource.call_sign}}</div>
19
+ </:default>
20
+ <:tooltip>
21
+ <div>
22
+ <div class="text-xs font-semibold">{{resource.name resource.yearMakeModel}}</div>
23
+ <div class="text-xs">{{t "resource.driver"}}: {{n-a resource.driver_name}}</div>
24
+ <div class="text-xs">
25
+ <span>{{t "common.status"}}:</span>
26
+ <span class="{{if resource.online 'text-green-500' 'text-red-400'}}">
27
+ {{if resource.online (t 'common.online') (t 'common.offline')}}
28
+ </span>
29
+ </div>
30
+ <div class="text-xs truncate">Pos: {{point-coordinates resource.location}}</div>
31
+ </div>
32
+ </:tooltip>
33
+ </Pill>
34
+ {{/let}}
@@ -0,0 +1,3 @@
1
+ import Component from '@glimmer/component';
2
+
3
+ export default class VehiclePillComponent extends Component {}
@@ -14,8 +14,13 @@ export default class ManagementVehiclesIndexDetailsController extends Controller
14
14
  route: 'management.vehicles.index.details.positions',
15
15
  label: 'Positions',
16
16
  },
17
+ {
18
+ route: 'management.vehicles.index.details.devices',
19
+ label: 'Devices',
20
+ },
17
21
  ];
18
22
  }
23
+
19
24
  get actionButtons() {
20
25
  return [
21
26
  {
@@ -0,0 +1,3 @@
1
+ import Route from '@ember/routing/route';
2
+
3
+ export default class ManagementDriversIndexDetailsPositionsRoute extends Route {}
package/addon/routes.js CHANGED
@@ -62,6 +62,7 @@ export default buildRoutes(function () {
62
62
  this.route('new');
63
63
  this.route('details', { path: '/:public_id' }, function () {
64
64
  this.route('index', { path: '/' });
65
+ this.route('positions');
65
66
  });
66
67
  this.route('edit', { path: '/edit/:public_id' });
67
68
  });
@@ -72,6 +73,8 @@ export default buildRoutes(function () {
72
73
  this.route('details', { path: '/:public_id' }, function () {
73
74
  this.route('index', { path: '/' });
74
75
  this.route('positions');
76
+ this.route('devices');
77
+ this.route('equipment');
75
78
  });
76
79
  this.route('edit', { path: '/edit/:public_id' });
77
80
  });