@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
@@ -8,23 +8,37 @@ import { htmlSafe } from '@ember/template';
8
8
  import { startOfWeek, endOfWeek, format } from 'date-fns';
9
9
  import getModelName from '@fleetbase/ember-core/utils/get-model-name';
10
10
 
11
+ const L = window.leaflet || window.L;
12
+
11
13
  export default class MapDrawerPositionListingComponent extends Component {
12
14
  @service leafletMapManager;
13
15
  @service store;
14
16
  @service fetch;
15
- @service movementTracker;
17
+ @service positionPlayback;
16
18
  @service hostRouter;
17
19
  @service notifications;
18
-
19
20
  @service intl;
21
+
22
+ /** Tracked properties - only what's NOT managed by service */
20
23
  @tracked positions = [];
21
24
  @tracked resource = null;
22
25
  @tracked selectedOrder = null;
23
26
  @tracked dateFilter = [format(startOfWeek(new Date(), { weekStartsOn: 1 }), 'yyyy-MM-dd'), format(endOfWeek(new Date(), { weekStartsOn: 1 }), 'yyyy-MM-dd')];
24
- @tracked isReplaying = false;
25
27
  @tracked replaySpeed = '1';
26
- @tracked currentReplayIndex = 0;
27
- @tracked channelId = null;
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
+ }
28
42
 
29
43
  get trackables() {
30
44
  const vehicles = this.leafletMapManager._livemap?.vehicles ?? [];
@@ -107,19 +121,27 @@ export default class MapDrawerPositionListingComponent extends Component {
107
121
  {
108
122
  label: '#',
109
123
  valuePath: 'index',
110
- width: '55px',
124
+ width: '80px',
125
+ cellComponent: 'table/cell/anchor',
126
+ onClick: this.onPositionClicked,
111
127
  },
112
128
  {
113
129
  label: 'Timestamp',
114
130
  valuePath: 'timestamp',
131
+ cellComponent: 'table/cell/anchor',
132
+ onClick: this.onPositionClicked,
115
133
  },
116
134
  {
117
135
  label: 'Latitude',
118
136
  valuePath: 'latitude',
137
+ cellComponent: 'table/cell/anchor',
138
+ onClick: this.onPositionClicked,
119
139
  },
120
140
  {
121
141
  label: 'Longitude',
122
142
  valuePath: 'longitude',
143
+ cellComponent: 'table/cell/anchor',
144
+ onClick: this.onPositionClicked,
123
145
  },
124
146
  {
125
147
  label: 'Speed (km/h)',
@@ -141,8 +163,20 @@ export default class MapDrawerPositionListingComponent extends Component {
141
163
  this.loadPositions.perform();
142
164
  }
143
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
+
144
177
  @action onResourceSelected(resource) {
145
178
  this.resource = resource;
179
+ this.focusResource(resource);
146
180
  this.loadPositions.perform();
147
181
  }
148
182
 
@@ -160,6 +194,11 @@ export default class MapDrawerPositionListingComponent extends Component {
160
194
 
161
195
  @action onSpeedChanged(speed) {
162
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
+ }
163
202
  }
164
203
 
165
204
  @action startReplay() {
@@ -167,12 +206,47 @@ export default class MapDrawerPositionListingComponent extends Component {
167
206
  this.notifications.warning('No positions to replay');
168
207
  return;
169
208
  }
170
- this.replayPositions.perform();
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();
171
232
  }
172
233
 
173
234
  @action stopReplay() {
174
- this.isReplaying = false;
175
- this.currentReplayIndex = 0;
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);
176
250
  }
177
251
 
178
252
  @action clearFilters() {
@@ -183,7 +257,20 @@ export default class MapDrawerPositionListingComponent extends Component {
183
257
 
184
258
  @action onPositionClicked(position) {
185
259
  if (this.leafletMapManager.map && position.latitude && position.longitude) {
186
- this.leafletMapManager.map.setView([position.latitude, position.longitude], this.zoom);
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
+ });
187
274
  }
188
275
  }
189
276
 
@@ -213,77 +300,156 @@ export default class MapDrawerPositionListingComponent extends Component {
213
300
  })
214
301
  : [];
215
302
 
216
- if (this.positions?.length) {
217
- const bounds = positions.map((pos) => pos.latLng).filter(Boolean);
218
- const lastFiveBounds = bounds.slice(-5);
219
- this.leafletMapManager.map.flyToBounds(lastFiveBounds, {
220
- animate: true,
221
- zoom: 16,
222
- });
223
- }
303
+ this.#renderPositionsOnMap({ fitLast: 5 });
304
+
305
+ // Reset replay state when positions change
306
+ this.stopReplay();
224
307
  } catch (error) {
225
308
  this.notifications.serverError(error);
226
309
  }
227
310
  }
228
311
 
229
- @task *replayPositions() {
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() {
230
319
  if (!this.resource) {
231
320
  this.notifications.warning('No resource provided for replay');
232
321
  return;
233
322
  }
234
323
 
235
- try {
236
- this.isReplaying = true;
237
- this.currentReplayIndex = 0;
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
+ }
238
343
 
239
- const positionIds = this.positions.map((p) => p.id);
344
+ /** Position Rendering */
345
+ #ensurePositionsLayer() {
346
+ if (!this.leafletMapManager.map) return null;
240
347
 
241
- if (positionIds.length === 0) {
242
- this.notifications.warning('No positions to replay');
243
- this.isReplaying = false;
244
- return;
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);
245
360
  }
361
+ }
362
+ }
246
363
 
247
- // Generate unique channel ID for this replay session
248
- this.channelId = `position.replay.${this.id}.${Date.now()}`;
249
-
250
- // Start tracking on custom channel
251
- yield this.movementTracker.track(this.resource, {
252
- channelId: this.channelId,
253
- callback: (output) => {
254
- const {
255
- data: { additionalData },
256
- } = output;
257
-
258
- const leafletLayer = this.resource.leafletLayer;
259
- if (leafletLayer) {
260
- const latlng = leafletLayer._slideToLatLng ?? leafletLayer.getLatLng();
261
- this.leafletMapManager.map.panTo(latlng, { animate: true });
262
- }
263
-
264
- if (additionalData && Number.isFinite(additionalData.index)) {
265
- this.currentReplayIndex = additionalData.index + 1;
266
- if (this.currentReplayIndex === this.totalPositions) {
267
- this.isReplaying = false;
268
- }
269
- }
270
- },
271
- });
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
+ }
272
392
 
273
- // Trigger backend replay
274
- const response = yield this.fetch.post('positions/replay', {
275
- position_ids: positionIds,
276
- channel_id: this.channelId,
277
- speed: parseFloat(this.replaySpeed),
278
- subject_uuid: this.resource.id,
279
- });
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();
280
405
 
281
- if (response && response.status === 'ok') {
282
- this.notifications.success('Replay started successfully');
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]);
283
413
  }
284
- } catch (error) {
285
- this.notifications.serverError(error);
286
- this.isReplaying = false;
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;
287
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 });
288
454
  }
289
455
  }
@@ -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 {}
@@ -16,25 +16,7 @@
16
16
  <span>{{t "order.fields.driver-assigned"}}</span>
17
17
  </div>
18
18
  <div class="field-value">
19
- <div>
20
- <a href="javascript:;" class="flex flex-row space-x-2" {{on "click" (fn this.focusOrderAssignedDriver @resource.driver_assigned)}}>
21
- <div class="relative shrink-0">
22
- <Image
23
- src={{avatar-url @resource.driver_assigned.photo_url}}
24
- @fallbackSrc={{config "defaultValues.driverImage"}}
25
- width="28"
26
- height="28"
27
- class="w-7 h-7 rounded-full ring-2 ring-gray-800 dark:ring-gray-700 shadow transition-shadow hover:shadow-md focus:shadow-md"
28
- alt={{@resource.driver_assigned.name}}
29
- />
30
- <FaIcon @icon="circle" @size="2xs" class="absolute left-0 top-0 h-2 w-2 {{if @resource.driver_assigned.online 'text-green-500' 'text-yellow-200'}}" />
31
- </div>
32
- <div>
33
- <div class="text-sm">{{n-a @resource.driver_assigned.name}}</div>
34
- <div class="text-xs text-gray-400 dark:text-gray-500">{{n-a @resource.driver_assigned.phone "No Phone"}}</div>
35
- </div>
36
- </a>
37
- </div>
19
+ <Driver::Pill @driver={{@resource.driver_assigned}} @onClick={{this.focusOrderAssignedDriver}} />
38
20
  </div>
39
21
  </div>
40
22
  <div class="field-info-container">
@@ -42,41 +24,7 @@
42
24
  <span>{{t "order.fields.vehicle-assigned"}}</span>
43
25
  </div>
44
26
  <div class="field-value">
45
- <a href="javascript:;" class="flex flex-row space-x-2 shrink-0">
46
- <div class="relative shrink-0">
47
- <Image
48
- src={{@resource.vehicle_assigned.photo_url}}
49
- @fallbackSrc={{config "defaultValues.vehicleImage"}}
50
- width="28"
51
- height="28"
52
- class="w-7 h-7 rounded-full ring-2 ring-gray-800 dark:ring-gray-700 shadow transition-shadow hover:shadow-md focus:shadow-md"
53
- alt={{n-a @resource.vehicle_assigned.name @resource.vehicle_assigned.yearMakeModel}}
54
- />
55
- <FaIcon @icon="circle" @size="2xs" class="absolute left-0 top-0 h-2 w-2 {{if @resource.vehicle_assigned.online 'text-green-500' 'text-yellow-200'}}" />
56
- </div>
57
- <div>
58
- <div class="text-sm">{{n-a @resource.vehicle_assigned.name @resource.vehicle_assigned.yearMakeModel}}</div>
59
- <div class="text-xs text-gray-400 dark:text-gray-500">{{or
60
- @resource.vehicle_assigned.plate_number
61
- @resource.vehicle_assigned.vin
62
- @resource.vehicle_assigned.serial_number
63
- @resource.vehicle_assigned.call_sign
64
- }}</div>
65
- </div>
66
- <Attach::Tooltip @class="clean" @animation="scale" @placement="top">
67
- <InputInfo>
68
- <div class="text-xs font-semibold">{{@resource.vehicle_assigned.name @resource.vehicle_assigned.yearMakeModel}}</div>
69
- <div class="text-xs">Driver: {{n-a @resource.vehicle_assigned.driver_name}}</div>
70
- <div class="text-xs">
71
- <span>Status:</span>
72
- <span class="{{if @resource.vehicle_assigned.online 'text-green-500' 'text-red-400'}}">
73
- {{if @resource.vehicle_assigned.online "Online" "Offline"}}
74
- </span>
75
- </div>
76
- <div class="text-xs truncate">Pos: {{point-coordinates @resource.vehicle_assigned.location}}</div>
77
- </InputInfo>
78
- </Attach::Tooltip>
79
- </a>
27
+ <Vehicle::Pill @vehicle={{@resource.vehicle_assigned}} />
80
28
  </div>
81
29
  </div>
82
30
  <div class="field-info-container">
@@ -36,6 +36,7 @@ export default class OrderDetailsDetailComponent extends Component {
36
36
  }
37
37
 
38
38
  @action focusOrderAssignedDriver(driver) {
39
+ console.log('[focusOrderAssignedDriver]', ...arguments);
39
40
  this.driverActions.panel.view(driver);
40
41
  this.leafletMapManager.map?.flyTo(driver.coordinates, 18);
41
42
  }
@@ -0,0 +1,34 @@
1
+ {{#let (or @order @resource) as |resource|}}
2
+ <Pill
3
+ @resource={{resource}}
4
+ @onClick={{@onClick}}
5
+ @anchorClass={{@anchorClass}}
6
+ @imageClass={{@imageClass}}
7
+ @imageWrapperClass={{@imageWrapperClass}}
8
+ @contentWrapperClass={{@contentWrapperClass}}
9
+ @titleClass={{@titleClass}}
10
+ @subtitleClass={{@subtitleClass}}
11
+ ...attributes
12
+ >
13
+ <:image>
14
+ <div class="pt-1">
15
+ <div class="rounded-md bg-white p-1">
16
+ <img src={{concat "data:image/png;base64," resource.tracking_number.qr_code}} class="w-12 h-12" alt={{resource.public_id}} />
17
+ </div>
18
+ </div>
19
+ </:image>
20
+ <:default>
21
+ <div class="text-sm font-semibold">{{n-a resource.tracking}}</div>
22
+ <div class="flex flex-row space-x-1">
23
+ <Badge @status={{@resource.status}} />
24
+ {{#if @resource.dispatched_at}}
25
+ <Badge @status="dispatched">{{concat "Dispatched at " @resource.dispatchedAt}}</Badge>
26
+ {{/if}}
27
+ </div>
28
+ <div class="mt-1">
29
+ <div class="text-xs text-gray-400 dark:text-gray-500">Date Created: {{@resource.createdAt}}</div>
30
+ <div class="text-xs text-gray-400 dark:text-gray-500">Type: {{smart-humanize @resource.type}}</div>
31
+ </div>
32
+ </:default>
33
+ </Pill>
34
+ {{/let}}
@@ -0,0 +1,3 @@
1
+ import Component from '@glimmer/component';
2
+
3
+ export default class OrderPillComponent extends Component {}
@@ -84,7 +84,14 @@
84
84
  <label class="block uppercase tracking-wide text-xs font-medium text-gray-700 dark:text-gray-400 mb-0.5">
85
85
  Date Range
86
86
  </label>
87
- <DatePicker @value={{this.dateFilter}} @range={{true}} @onSelect={{this.onDateRangeChanged}} @autoClose={{true}} @placeholder="Select date range" class="w-full form-input form-input-sm" />
87
+ <DatePicker
88
+ @value={{this.dateFilter}}
89
+ @range={{true}}
90
+ @onSelect={{this.onDateRangeChanged}}
91
+ @autoClose={{true}}
92
+ @placeholder="Select date range"
93
+ class="w-full form-input form-input-sm"
94
+ />
88
95
  </div>
89
96
 
90
97
  <div class="filter-group">
@@ -110,7 +117,6 @@
110
117
  </div>
111
118
 
112
119
  <div class="filter-actions flex items-end space-x-2">
113
- <Button @icon="search" @text={{t "common.search"}} @isLoading={{this.loadPositions.isRunning}} @onClick={{perform this.loadPositions}} />
114
120
  <Button @type="danger" @icon="times" @text={{t "common.clear"}} @disabled={{this.loadPositions.isRunning}} @onClick={{this.clearFilters}} />
115
121
  </div>
116
122
  </div>
@@ -120,18 +126,24 @@
120
126
  <div class="replay-controls px-3 py-2">
121
127
  <div class="flex items-center justify-between space-x-2">
122
128
  <div class="flex items-center space-x-2">
123
- <Button @type="danger" @text="Stop" @icon="stop" @size="xs" @onClick={{this.stopReplay}} />
124
- <Button
125
- @type="success"
126
- @text="Play"
127
- @icon="play"
128
- @size="xs"
129
- @isLoading={{this.isReplaying}}
130
- @disabled={{or this.replayPositions.isRunning (not this.hasPositions)}}
131
- @onClick={{this.startReplay}}
132
- />
129
+ <Button @type="danger" @text="Stop" @icon="stop" @size="xs" @disabled={{not (or this.isReplaying this.isPaused)}} @onClick={{this.stopReplay}} />
133
130
 
134
- <div class="speed-control">
131
+ {{#if (or this.isReplaying this.isPaused)}}
132
+ {{#if this.isReplaying}}
133
+ <Button @type="warning" @text="Pause" @icon="pause" @size="xs" @onClick={{this.pauseReplay}} />
134
+ {{else}}
135
+ <Button @type="success" @text="Resume" @icon="play" @size="xs" @onClick={{this.startReplay}} />
136
+ {{/if}}
137
+ {{else}}
138
+ <Button @type="success" @text="Play" @icon="play" @size="xs" @disabled={{not this.hasPositions}} @onClick={{this.startReplay}} />
139
+ {{/if}}
140
+
141
+ <div class="step-controls flex items-center space-x-1 border-l border-gray-300 dark:border-gray-700 pl-2 ml-2">
142
+ <Button @type="default" @icon="backward-step" @size="xs" @disabled={{not this.hasPositions}} @onClick={{this.stepBackward}} title="Step backward" />
143
+ <Button @type="default" @icon="forward-step" @size="xs" @disabled={{not this.hasPositions}} @onClick={{this.stepForward}} title="Step forward" />
144
+ </div>
145
+
146
+ <div class="speed-control border-l border-gray-300 dark:border-gray-700 pl-2 ml-2">
135
147
  <label class="text-xs uppercase tracking-wide font-medium text-gray-700 dark:text-gray-400 mr-1">
136
148
  Speed:
137
149
  </label>
@@ -146,7 +158,7 @@
146
158
  </div>
147
159
  </div>
148
160
 
149
- {{#if this.isReplaying}}
161
+ {{#if (or this.isReplaying this.isPaused)}}
150
162
  <div class="replay-progress flex items-center space-x-1">
151
163
  <span class="text-sm text-gray-600 dark:text-gray-400">
152
164
  {{this.currentReplayIndex}}/{{this.totalPositions}}
@@ -214,12 +226,6 @@
214
226
  {{this.accelerationCount}}
215
227
  </div>
216
228
  </div>
217
- <div class="metric-card">
218
- <div class="metric-label truncate text-xs text-gray-500 dark:text-gray-400">Total Positions</div>
219
- <div class="metric-value font-semibold text-gray-600 dark:text-gray-400">
220
- {{this.totalPositions}}
221
- </div>
222
- </div>
223
229
  </div>
224
230
  </div>
225
231
  {{/if}}