diff --git a/addon/components/admin/routing-settings.hbs b/addon/components/admin/routing-settings.hbs index a3a210033..7ee7af45f 100644 --- a/addon/components/admin/routing-settings.hbs +++ b/addon/components/admin/routing-settings.hbs @@ -19,6 +19,7 @@
- \ No newline at end of file + diff --git a/addon/components/cell/attached-vehicle.hbs b/addon/components/cell/attached-vehicle.hbs new file mode 100644 index 000000000..6ad15e212 --- /dev/null +++ b/addon/components/cell/attached-vehicle.hbs @@ -0,0 +1,10 @@ +
+ {{#if this.hasVehicle}} + + {{else}} +
+ + Unattached +
+ {{/if}} +
diff --git a/addon/components/cell/attached-vehicle.js b/addon/components/cell/attached-vehicle.js new file mode 100644 index 000000000..42c1645a8 --- /dev/null +++ b/addon/components/cell/attached-vehicle.js @@ -0,0 +1,58 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class CellAttachedVehicleComponent extends Component { + get device() { + return this.args.row; + } + + get vehicle() { + return this.device?.attachable; + } + + get vehicleResource() { + return ( + this.vehicle ?? { + id: this.device?.attachable_uuid, + displayName: this.device?.attached_to_name, + display_name: this.device?.attached_to_name, + name: this.device?.attached_to_name, + public_id: this.device?.attachable_uuid, + } + ); + } + + get identityColumn() { + return { + ...(this.args.column ?? {}), + action: null, + showStatusBadge: false, + }; + } + + get hasVehicle() { + return Boolean(this.device?.attachable_uuid && this.isVehicleAttachment); + } + + get isVehicleAttachment() { + const attachableType = `${this.device?.attachable_type ?? ''}`.toLowerCase(); + + return !attachableType || attachableType.includes('vehicle'); + } + + @action onClick(_vehicle, event) { + const { column, onClick } = this.args; + + if (!this.hasVehicle) { + return; + } + + if (typeof onClick === 'function') { + onClick(this.device, event); + } + + if (typeof column?.action === 'function') { + column.action(this.device, event); + } + } +} diff --git a/addon/components/cell/device-identity.hbs b/addon/components/cell/device-identity.hbs new file mode 100644 index 000000000..ddcf5a921 --- /dev/null +++ b/addon/components/cell/device-identity.hbs @@ -0,0 +1,24 @@ +{{#if this.resource}} + {{#if this.compact}} + + {{else}} + + {{/if}} +{{else}} +
{{this.emptyText}}
+{{/if}} diff --git a/addon/components/cell/device-identity.js b/addon/components/cell/device-identity.js new file mode 100644 index 000000000..4e97068fb --- /dev/null +++ b/addon/components/cell/device-identity.js @@ -0,0 +1,129 @@ +import Component from '@glimmer/component'; +import { action, get } from '@ember/object'; +import config from 'ember-get-config'; +import { resolveIdentityCellResource } from '../../utils/identity-cell-resource'; + +const DEFAULT_STATUS_TONES = { + online: 'text-green-500', + active: 'text-green-500', + recently_offline: 'text-yellow-500', + offline: 'text-gray-400', + long_offline: 'text-gray-400', + never_connected: 'text-gray-400', + inactive: 'text-gray-400', + error: 'text-red-500', +}; + +export default class CellDeviceIdentityComponent extends Component { + get resource() { + return resolveIdentityCellResource(this.args); + } + + get emptyText() { + return this.args.column?.emptyText ?? '-'; + } + + get showStatus() { + return this.args.column?.showStatus ?? true; + } + + get compact() { + return this.args.column?.compact ?? false; + } + + get label() { + const device = this.resource; + + return get(device, 'displayName') ?? get(device, 'display_name') ?? get(device, 'name') ?? get(device, 'device_id') ?? get(device, 'imei') ?? get(device, 'serial_number'); + } + + get mediaUrl() { + return get(this.resource, 'photo_url'); + } + + get fallbackImage() { + return config?.defaultValues?.placeholderImage; + } + + get hasCompactStatusDot() { + return this.args.column?.showStatusDot ?? this.args.column?.showOnlineIndicator ?? true; + } + + get compactStatusValue() { + const device = this.resource; + + return get(device, 'is_online') ?? get(device, 'connection_status') ?? get(device, 'status'); + } + + get compactStatusToneClass() { + const value = this.compactStatusValue; + const statusToneMap = { + ...DEFAULT_STATUS_TONES, + ...(this.args.column?.statusToneMap ?? {}), + }; + + if (typeof this.args.column?.statusToneClass === 'function') { + return this.args.column.statusToneClass(value, this.resource, this.args.column); + } + + if (typeof value === 'boolean') { + return value ? 'text-green-500' : 'text-yellow-200'; + } + + return statusToneMap[value] ?? statusToneMap[String(value ?? '').toLowerCase()] ?? 'text-gray-400'; + } + + get compactStatusDotClass() { + return this.compactStatusToneClass; + } + + get column() { + return { + ...(this.args.column ?? {}), + labelPath: (device) => + get(device, 'displayName') ?? get(device, 'display_name') ?? get(device, 'name') ?? get(device, 'device_id') ?? get(device, 'imei') ?? get(device, 'serial_number'), + mediaPath: 'photo_url', + fallbackImage: config?.defaultValues?.placeholderImage, + statusPath: this.showStatus ? (device) => get(device, 'connection_status') ?? get(device, 'status') : undefined, + onlinePath: 'is_online', + showStatusBadge: this.showStatus ? (this.args.column?.showStatusBadge ?? true) : false, + statusBadgeSize: this.args.column?.statusBadgeSize ?? 'xxs', + statusBadgeWrapperClass: this.args.column?.statusBadgeWrapperClass ?? 'resource-identity-status-badge device-identity-status-badge', + metaPaths: [ + { + value: (device) => get(device, 'imei') ?? get(device, 'device_id') ?? get(device, 'ident') ?? get(device, 'serial_number'), + icon: 'microchip', + style: 'badge', + class: 'max-w-[12rem]', + }, + ], + statusToneMap: { + online: 'text-green-500', + active: 'text-green-500', + recently_offline: 'text-yellow-500', + offline: 'text-gray-400', + long_offline: 'text-gray-400', + never_connected: 'text-gray-400', + inactive: 'text-gray-400', + error: 'text-red-500', + }, + }; + } + + @action onClick(event) { + const { column, onClick } = this.args; + const resource = this.resource; + + if (typeof onClick === 'function') { + onClick(resource, event); + } + + if (typeof column?.onClick === 'function') { + column.onClick(resource, event); + } + + if (typeof column?.action === 'function') { + column.action(resource, event); + } + } +} diff --git a/addon/components/cell/driver-identity.hbs b/addon/components/cell/driver-identity.hbs new file mode 100644 index 000000000..0b03a862e --- /dev/null +++ b/addon/components/cell/driver-identity.hbs @@ -0,0 +1,33 @@ +{{#if this.resource}} + {{#if this.compact}} + + {{else}} + + {{/if}} +{{else}} +
{{this.emptyText}}
+{{/if}} diff --git a/addon/components/cell/driver-identity.js b/addon/components/cell/driver-identity.js new file mode 100644 index 000000000..8b07e4e94 --- /dev/null +++ b/addon/components/cell/driver-identity.js @@ -0,0 +1,143 @@ +import Component from '@glimmer/component'; +import { action, get } from '@ember/object'; +import config from 'ember-get-config'; +import { resolveIdentityCellResource } from '../../utils/identity-cell-resource'; + +const DEFAULT_STATUS_TONES = { + available: 'text-green-500', + active: 'text-green-500', + on_duty: 'text-green-500', + busy: 'text-yellow-500', + assigned: 'text-yellow-500', + unavailable: 'text-gray-400', + offline: 'text-gray-400', + suspended: 'text-red-500', +}; + +export default class CellDriverIdentityComponent extends Component { + get resource() { + return resolveIdentityCellResource(this.args); + } + + get emptyText() { + return this.args.column?.emptyText ?? '-'; + } + + get compact() { + return this.args.column?.compact ?? false; + } + + get label() { + const driver = this.resource; + + return get(driver, 'name') ?? get(driver, 'displayName') ?? get(driver, 'display_name'); + } + + get mediaUrl() { + return get(this.resource, 'photo_url'); + } + + get fallbackImage() { + return config?.defaultValues?.driverImage; + } + + get hasCompactStatusDot() { + return this.args.column?.showStatusDot ?? this.args.column?.showOnlineIndicator ?? true; + } + + get compactStatusValue() { + const driver = this.resource; + + return get(driver, 'online') ?? get(driver, 'status'); + } + + get compactStatusToneClass() { + const value = this.compactStatusValue; + const statusToneMap = { + ...DEFAULT_STATUS_TONES, + ...(this.args.column?.statusToneMap ?? {}), + }; + + if (typeof this.args.column?.statusToneClass === 'function') { + return this.args.column.statusToneClass(value, this.resource, this.args.column); + } + + if (typeof value === 'boolean') { + return value ? 'text-green-500' : 'text-yellow-200'; + } + + return statusToneMap[value] ?? statusToneMap[String(value ?? '').toLowerCase()] ?? 'text-gray-400'; + } + + get compactStatusDotClass() { + return this.compactStatusToneClass; + } + + get assignedVehicleLabel() { + const column = this.args.column ?? {}; + const driver = this.resource; + + if (typeof column.assignedVehicleLabel === 'function') { + return column.assignedVehicleLabel(driver, this.args.row, column); + } + + if (column.assignedVehicleLabel !== undefined) { + return column.assignedVehicleLabel; + } + + if (typeof column.assignedVehiclePath === 'string') { + return get(this.args.row, column.assignedVehiclePath) ?? get(driver, column.assignedVehiclePath); + } + + return get(driver, 'vehicle_assigned.display_name') ?? get(driver, 'vehicle.display_name') ?? get(driver, 'vehicle_name'); + } + + get column() { + return { + ...(this.args.column ?? {}), + labelPath: 'name', + mediaPath: 'photo_url', + fallbackImage: config?.defaultValues?.driverImage, + statusPath: 'status', + onlinePath: 'online', + showStatusBadge: this.args.column?.showStatusBadge ?? true, + statusBadgeSize: this.args.column?.statusBadgeSize ?? 'xxs', + statusBadgeWrapperClass: this.args.column?.statusBadgeWrapperClass ?? 'resource-identity-status-badge driver-identity-status-badge order-first', + metaPaths: [ + { + value: (driver) => get(driver, 'vehicle_assigned.display_name') ?? get(driver, 'vehicle.display_name') ?? get(driver, 'vehicle_name'), + icon: 'car', + style: 'badge', + class: 'max-w-[12rem]', + }, + ], + statusToneMap: { + available: 'text-green-500', + active: 'text-green-500', + on_duty: 'text-green-500', + busy: 'text-yellow-500', + assigned: 'text-yellow-500', + unavailable: 'text-gray-400', + offline: 'text-gray-400', + suspended: 'text-red-500', + }, + }; + } + + @action onClick(event) { + const { column, onClick } = this.args; + const resource = this.resource; + + if (typeof onClick === 'function') { + onClick(resource, event); + } + + if (typeof column?.onClick === 'function') { + column.onClick(resource, event); + } + + if (typeof column?.action === 'function') { + column.action(resource, event); + } + } +} diff --git a/addon/components/cell/equipment-identity.hbs b/addon/components/cell/equipment-identity.hbs new file mode 100644 index 000000000..4755c976c --- /dev/null +++ b/addon/components/cell/equipment-identity.hbs @@ -0,0 +1,5 @@ +{{#if this.resource}} + +{{else}} +
{{this.emptyText}}
+{{/if}} diff --git a/addon/components/cell/equipment-identity.js b/addon/components/cell/equipment-identity.js new file mode 100644 index 000000000..8c1e5b6b8 --- /dev/null +++ b/addon/components/cell/equipment-identity.js @@ -0,0 +1,48 @@ +import Component from '@glimmer/component'; +import { get } from '@ember/object'; +import config from 'ember-get-config'; +import { resolveIdentityCellResource } from '../../utils/identity-cell-resource'; + +export default class CellEquipmentIdentityComponent extends Component { + get resource() { + return resolveIdentityCellResource(this.args); + } + + get emptyText() { + return this.args.column?.emptyText ?? '-'; + } + + get column() { + return { + ...(this.args.column ?? {}), + labelPath: 'name', + mediaPath: 'photo_url', + fallbackImage: config?.defaultValues?.equipmentImage ?? config?.defaultValues?.placeholderImage, + statusPath: (equipment) => (get(equipment, 'is_equipped') ? 'equipped' : (get(equipment, 'status') ?? 'unequipped')), + statusFormatter: () => null, + metaPaths: [ + { + value: (equipment) => get(equipment, 'type'), + formatter: (type) => type, + icon: 'toolbox', + style: 'badge', + }, + { + value: (equipment) => get(equipment, 'serial_number') ?? get(equipment, 'code') ?? get(equipment, 'public_id'), + icon: 'barcode', + style: 'badge', + class: 'max-w-[12rem]', + }, + ], + statusToneMap: { + active: 'text-green-500', + equipped: 'text-green-500', + available: 'text-green-500', + maintenance: 'text-yellow-500', + unequipped: 'text-gray-400', + inactive: 'text-gray-400', + retired: 'text-red-500', + }, + }; + } +} diff --git a/addon/components/cell/part-identity.hbs b/addon/components/cell/part-identity.hbs new file mode 100644 index 000000000..4755c976c --- /dev/null +++ b/addon/components/cell/part-identity.hbs @@ -0,0 +1,5 @@ +{{#if this.resource}} + +{{else}} +
{{this.emptyText}}
+{{/if}} diff --git a/addon/components/cell/part-identity.js b/addon/components/cell/part-identity.js new file mode 100644 index 000000000..e0ff3de1d --- /dev/null +++ b/addon/components/cell/part-identity.js @@ -0,0 +1,63 @@ +import Component from '@glimmer/component'; +import { get } from '@ember/object'; +import config from 'ember-get-config'; +import { resolveIdentityCellResource } from '../../utils/identity-cell-resource'; + +function inventoryStatus(part) { + if (get(part, 'is_low_stock')) { + return 'low_stock'; + } + + if (get(part, 'is_in_stock')) { + return 'in_stock'; + } + + return 'out_of_stock'; +} + +function inventoryStatusLabel(part) { + return inventoryStatus(part) + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +export default class CellPartIdentityComponent extends Component { + get resource() { + return resolveIdentityCellResource(this.args); + } + + get emptyText() { + return this.args.column?.emptyText ?? '-'; + } + + get column() { + return { + ...(this.args.column ?? {}), + labelPath: 'name', + mediaPath: 'photo_url', + fallbackImage: config?.defaultValues?.partImage ?? config?.defaultValues?.placeholderImage, + statusPath: inventoryStatus, + statusFormatter: () => null, + metaPaths: [ + { + value: (part) => get(part, 'type'), + icon: 'tag', + style: 'badge', + }, + { + value: inventoryStatusLabel, + icon: 'boxes-stacked', + style: 'badge', + }, + ], + statusToneMap: { + in_stock: 'text-green-500', + low_stock: 'text-yellow-500', + out_of_stock: 'text-red-500', + active: 'text-green-500', + inactive: 'text-gray-400', + }, + }; + } +} diff --git a/addon/components/cell/telematic-device.hbs b/addon/components/cell/telematic-device.hbs new file mode 100644 index 000000000..2497b1f57 --- /dev/null +++ b/addon/components/cell/telematic-device.hbs @@ -0,0 +1,38 @@ +
+ +
diff --git a/addon/components/cell/telematic-device.js b/addon/components/cell/telematic-device.js new file mode 100644 index 000000000..1627ab284 --- /dev/null +++ b/addon/components/cell/telematic-device.js @@ -0,0 +1,44 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class CellTelematicDeviceComponent extends Component { + get device() { + return this.args.row; + } + + get name() { + return this.device?.displayName ?? this.device?.display_name ?? this.device?.name ?? this.device?.device_id ?? this.device?.imei ?? this.device?.serial_number; + } + + get identifier() { + return this.device?.imei ?? this.device?.device_id ?? this.device?.internal_id ?? this.device?.serial_number ?? this.device?.public_id; + } + + get imageUrl() { + return this.device?.photo_url; + } + + get connectionStatus() { + return this.device?.connection_status ?? (this.device?.is_online ? 'online' : 'offline'); + } + + get isOnline() { + return this.device?.is_online || this.connectionStatus === 'online'; + } + + @action onClick(event) { + const { column, onClick } = this.args; + + if (typeof onClick === 'function') { + onClick(this.device, event); + } + + if (typeof column?.action === 'function') { + column.action(this.device, event); + } + + if (typeof column?.onClick === 'function') { + column.onClick(this.device, event); + } + } +} diff --git a/addon/components/cell/telematic-provider.hbs b/addon/components/cell/telematic-provider.hbs index 9897c5591..462f0610f 100644 --- a/addon/components/cell/telematic-provider.hbs +++ b/addon/components/cell/telematic-provider.hbs @@ -1,16 +1,33 @@ -
- -
+{{#if this.telematic}} + {{#if this.compact}} + + {{else}} + + {{/if}} +{{else}} +
{{this.emptyText}}
+{{/if}} diff --git a/addon/components/cell/telematic-provider.js b/addon/components/cell/telematic-provider.js index 0aef0f697..3d6213a36 100644 --- a/addon/components/cell/telematic-provider.js +++ b/addon/components/cell/telematic-provider.js @@ -1,36 +1,78 @@ import Component from '@glimmer/component'; -import { action } from '@ember/object'; +import { action, get } from '@ember/object'; + +const DEFAULT_PROVIDER_ICON = '/engines-dist/images/telematics/providers/default.webp'; export default class CellTelematicProviderComponent extends Component { + get telematic() { + const resourcePath = this.args.column?.resourcePath; + + if (typeof resourcePath === 'function') { + const resource = resourcePath(this.args.row, this.args.value, this.args.column); + + return resource ?? null; + } + + if (typeof resourcePath === 'string') { + const resource = get(this.args.row, resourcePath); + + return resource ?? null; + } + + if (this.args.row?.telematic) { + return this.args.row.telematic; + } + + if (this.args.row?.telematic_uuid || this.args.row?.telematic_name) { + return { + id: this.args.row.telematic_uuid, + name: this.args.row.telematic_name, + provider: this.args.row.provider, + provider_descriptor: this.args.row.provider_descriptor, + }; + } + + return this.args.row; + } + + get emptyText() { + return this.args.column?.emptyText ?? '-'; + } + + get compact() { + return this.args.column?.compact ?? false; + } + get descriptor() { - return this.args.row?.provider_descriptor ?? {}; + return this.telematic?.provider_descriptor ?? this.args.row?.provider_descriptor ?? {}; } get name() { - return this.args.row?.name ?? this.descriptor.label ?? this.args.row?.provider; + return this.telematic?.name ?? this.descriptor.label ?? this.telematic?.provider ?? this.args.row?.provider ?? this.args.row?.telematic_name; } get description() { - return this.descriptor.description ?? this.args.row?.provider; + return this.descriptor.description ?? this.telematic?.provider ?? this.args.row?.provider; } get icon() { - return this.descriptor.icon; + return this.descriptor.icon ?? DEFAULT_PROVIDER_ICON; } @action onClick(event) { const { row, column, onClick } = this.args; + const resource = this.telematic ?? row; if (typeof onClick === 'function') { - onClick(row, event); + onClick(resource, event); } if (typeof column?.action === 'function') { - column.action(row, event); + column.action(resource, event); } if (typeof column?.onClick === 'function') { - column.onClick(row, event); + column.onClick(resource, event); } } } diff --git a/addon/components/cell/vehicle-identity.hbs b/addon/components/cell/vehicle-identity.hbs new file mode 100644 index 000000000..6abea09e9 --- /dev/null +++ b/addon/components/cell/vehicle-identity.hbs @@ -0,0 +1,33 @@ +{{#if this.resource}} + {{#if this.compact}} + + {{else}} + + {{/if}} +{{else}} +
{{this.emptyText}}
+{{/if}} diff --git a/addon/components/cell/vehicle-identity.js b/addon/components/cell/vehicle-identity.js new file mode 100644 index 000000000..f1724abeb --- /dev/null +++ b/addon/components/cell/vehicle-identity.js @@ -0,0 +1,138 @@ +import Component from '@glimmer/component'; +import { action, get } from '@ember/object'; +import config from 'ember-get-config'; +import { resolveIdentityCellResource } from '../../utils/identity-cell-resource'; + +const DEFAULT_STATUS_TONES = { + available: 'text-green-500', + active: 'text-green-500', + in_service: 'text-green-500', + maintenance: 'text-yellow-500', + unavailable: 'text-gray-400', + inactive: 'text-gray-400', + out_of_service: 'text-red-500', +}; + +export default class CellVehicleIdentityComponent extends Component { + get resource() { + return resolveIdentityCellResource(this.args); + } + + get emptyText() { + return this.args.column?.emptyText ?? '-'; + } + + get compact() { + return this.args.column?.compact ?? false; + } + + get showStatus() { + return this.args.column?.showStatus ?? true; + } + + get label() { + const vehicle = this.resource; + + return get(vehicle, 'displayName') ?? get(vehicle, 'display_name') ?? get(vehicle, 'name'); + } + + get mediaUrl() { + return get(this.resource, 'photo_url'); + } + + get fallbackImage() { + return config?.defaultValues?.vehicleAvatar; + } + + get hasCompactStatusDot() { + return this.args.column?.showStatusDot ?? this.args.column?.showOnlineIndicator ?? true; + } + + get compactStatusValue() { + const vehicle = this.resource; + + return get(vehicle, 'online') ?? get(vehicle, 'status'); + } + + get compactStatusToneClass() { + const value = this.compactStatusValue; + const statusToneMap = { + ...DEFAULT_STATUS_TONES, + ...(this.args.column?.statusToneMap ?? {}), + }; + + if (typeof this.args.column?.statusToneClass === 'function') { + return this.args.column.statusToneClass(value, this.resource, this.args.column); + } + + if (typeof value === 'boolean') { + return value ? 'text-green-500' : 'text-yellow-200'; + } + + return statusToneMap[value] ?? statusToneMap[String(value ?? '').toLowerCase()] ?? 'text-gray-400'; + } + + get compactStatusDotClass() { + return this.compactStatusToneClass; + } + + get assignedDriverLabel() { + const vehicle = this.resource; + + return get(vehicle, 'driver.displayName') ?? get(vehicle, 'driver.display_name') ?? get(vehicle, 'driver.name') ?? get(vehicle, 'driver_name'); + } + + get column() { + return { + ...(this.args.column ?? {}), + labelPath: (vehicle) => get(vehicle, 'displayName') ?? get(vehicle, 'display_name') ?? get(vehicle, 'name'), + mediaPath: 'photo_url', + fallbackImage: config?.defaultValues?.vehicleAvatar, + statusPath: this.showStatus ? 'status' : undefined, + onlinePath: 'online', + showStatusBadge: this.showStatus ? (this.args.column?.showStatusBadge ?? true) : false, + statusBadgeSize: this.args.column?.statusBadgeSize ?? 'xxs', + statusBadgeWrapperClass: this.args.column?.statusBadgeWrapperClass ?? 'resource-identity-status-badge vehicle-identity-status-badge', + metaPaths: [ + { + value: (vehicle) => get(vehicle, 'plate_number') ?? get(vehicle, 'call_sign') ?? get(vehicle, 'vehicle_number') ?? get(vehicle, 'public_id'), + icon: 'id-card', + style: 'badge', + class: 'max-w-[12rem]', + }, + { + value: (vehicle) => get(vehicle, 'driver.displayName') ?? get(vehicle, 'driver.display_name') ?? get(vehicle, 'driver.name') ?? get(vehicle, 'driver_name'), + icon: 'user', + style: 'badge', + class: 'max-w-[12rem]', + }, + ], + statusToneMap: { + available: 'text-green-500', + active: 'text-green-500', + in_service: 'text-green-500', + maintenance: 'text-yellow-500', + unavailable: 'text-gray-400', + inactive: 'text-gray-400', + out_of_service: 'text-red-500', + }, + }; + } + + @action onClick(event) { + const { column, onClick } = this.args; + const resource = this.resource; + + if (typeof onClick === 'function') { + onClick(resource, event); + } + + if (typeof column?.onClick === 'function') { + column.onClick(resource, event); + } + + if (typeof column?.action === 'function') { + column.action(resource, event); + } + } +} diff --git a/addon/components/device/form.hbs b/addon/components/device/form.hbs index 6da0167bf..93936107c 100644 --- a/addon/components/device/form.hbs +++ b/addon/components/device/form.hbs @@ -10,7 +10,7 @@ @infiniteScroll={{false}} @renderInPlace={{true}} @onChange={{this.selectTelematic}} - @disabled={{cannot-write @resource}} + @disabled={{or (cannot-write @resource) this.isTelematicLocked}} as |model| >
@@ -227,4 +227,4 @@ -
\ No newline at end of file +
diff --git a/addon/components/device/form.js b/addon/components/device/form.js index 0a7008634..cade1d20f 100644 --- a/addon/components/device/form.js +++ b/addon/components/device/form.js @@ -8,6 +8,13 @@ export default class DeviceFormComponent extends Component { @service currentUser; @service notifications; + get isTelematicLocked() { + const resource = this.args.resource; + const hasTelematic = Boolean(resource?.telematic_uuid || resource?.telematic?.id); + + return Boolean(hasTelematic && resource?.isNew === false); + } + @action selectTelematic(telematic) { this.args.resource.setProperties({ telematic, diff --git a/addon/components/device/panel-header.hbs b/addon/components/device/panel-header.hbs index 73613520c..d7ac26ab3 100644 --- a/addon/components/device/panel-header.hbs +++ b/addon/components/device/panel-header.hbs @@ -1,35 +1,60 @@ -
-
-
-
+
+
+
+
{{@resource.displayName}} - +
-
-

{{@resource.displayName}}

-
-
{{concat - (n-a (titleize @resource.provider)) - " " - (n-a (get-fleet-ops-option-label "deviceTypes" @resource.type)) - }}
-
{{n-a @resource.serial_number}}
+
+
+

{{this.name}}

+ {{smart-humanize this.connectionStatus}} +
+ +
+ {{#if this.provider}} + + + {{titleize this.provider}} + + {{/if}} + {{#if this.type}} + + + {{n-a (get-fleet-ops-option-label "deviceTypes" this.type)}} + + {{/if}} + {{#if this.identifier}} + + + {{this.identifier}} + + {{/if}} + {{#if this.attachedVehicle}} + + + {{this.attachedVehicle}} + + {{/if}} + + + {{#if this.lastOnlineAt}}{{format-date-fns this.lastOnlineAt "dd MMM yyyy, HH:mm"}}{{else}}No last online{{/if}} +
- {{if @resource.is_online (t "common.online") (t "common.offline")}}
-
+
+
+
+

Telemetry Events

+

Recent device events, warning signals, and processing state.

+
+
+ + +
diff --git a/addon/components/device/panel-tabs/events.js b/addon/components/device/panel-tabs/events.js new file mode 100644 index 000000000..fc96a5487 --- /dev/null +++ b/addon/components/device/panel-tabs/events.js @@ -0,0 +1,128 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; + +const severityOptions = [ + { label: 'Info', value: 'info' }, + { label: 'Warning', value: 'warning' }, + { label: 'Error', value: 'error' }, + { label: 'Critical', value: 'critical' }, + { label: 'High', value: 'high' }, +]; + +function toRecentList(records) { + const list = Array.from(records ?? []); + + list.meta = { + current_page: 1, + last_page: 1, + per_page: list.length, + total: list.length, + from: list.length > 0 ? 1 : 0, + to: list.length, + }; + + return list; +} + +export default class DevicePanelTabsEventsComponent extends Component { + @service deviceEventActions; + @service store; + + @tracked events = toRecentList(); + + constructor() { + super(...arguments); + this.loadEvents.perform(); + } + + get device() { + return this.args.resource ?? this.args.model; + } + + get columns() { + return [ + { + sticky: true, + label: 'Event', + valuePath: 'event_type', + cellComponent: 'table/cell/anchor', + action: this.deviceEventActions.panel?.view ?? this.deviceEventActions.transition.view, + permission: 'fleet-ops view device-event', + resizable: true, + }, + { + label: 'Severity', + valuePath: 'severity', + cellComponent: 'table/cell/status', + filterOptions: severityOptions, + resizable: true, + }, + { + label: 'Message', + valuePath: 'message', + resizable: true, + }, + { + label: 'Code', + valuePath: 'code', + resizable: true, + }, + { + label: 'Processed', + valuePath: 'processedAt', + sortParam: 'processed_at', + resizable: true, + }, + { + label: 'Occurred', + valuePath: 'occurredAt', + sortParam: 'occurred_at', + resizable: true, + }, + { + label: '', + cellComponent: 'table/cell/dropdown', + ddButtonText: false, + ddButtonIcon: 'ellipsis-h', + ddButtonIconPrefix: 'fas', + wrapperClass: 'flex items-center justify-end mx-2', + sticky: 'right', + width: 60, + actions: [ + { + label: 'View event', + fn: this.deviceEventActions.panel?.view ?? this.deviceEventActions.transition.view, + permission: 'fleet-ops view device-event', + }, + { + label: 'Mark processed', + fn: this.markProcessed, + permission: 'fleet-ops update device-event', + }, + ], + }, + ]; + } + + @action async markProcessed(deviceEvent) { + await this.deviceEventActions.markProcessed(deviceEvent); + await this.loadEvents.perform(); + } + + @action refreshEvents() { + return this.loadEvents.perform(); + } + + @task *loadEvents() { + if (!this.device?.id) { + this.events = toRecentList(); + return; + } + + const events = yield this.store.query('device-event', { device_uuid: this.device.id, limit: 10, sort: '-created_at' }); + this.events = toRecentList(events); + } +} diff --git a/addon/components/device/panel-tabs/sensors.hbs b/addon/components/device/panel-tabs/sensors.hbs new file mode 100644 index 000000000..79f991492 --- /dev/null +++ b/addon/components/device/panel-tabs/sensors.hbs @@ -0,0 +1,30 @@ +
+
+
+

Sensor Inventory

+

Recent sensor readings and health for this device.

+
+
+ + +
diff --git a/addon/components/device/panel-tabs/sensors.js b/addon/components/device/panel-tabs/sensors.js new file mode 100644 index 000000000..25c4e790c --- /dev/null +++ b/addon/components/device/panel-tabs/sensors.js @@ -0,0 +1,93 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; + +function toRecentList(records) { + const list = Array.from(records ?? []); + + list.meta = { + current_page: 1, + last_page: 1, + per_page: list.length, + total: list.length, + from: list.length > 0 ? 1 : 0, + to: list.length, + }; + + return list; +} + +export default class DevicePanelTabsSensorsComponent extends Component { + @service sensorActions; + @service store; + + @tracked sensors = toRecentList(); + + constructor() { + super(...arguments); + this.loadSensors.perform(); + } + + get device() { + return this.args.resource ?? this.args.model; + } + + get columns() { + return [ + { + sticky: true, + label: 'Sensor', + valuePath: 'name', + cellComponent: 'table/cell/anchor', + action: this.sensorActions.panel?.view ?? this.sensorActions.transition.view, + permission: 'fleet-ops view sensor', + resizable: true, + }, + { + label: 'Type', + valuePath: 'type', + cellComponent: 'table/cell/base', + humanize: true, + resizable: true, + }, + { + label: 'Value', + valuePath: 'last_value', + resizable: true, + }, + { + label: 'Unit', + valuePath: 'unit', + resizable: true, + }, + { + label: 'Status', + valuePath: 'status', + cellComponent: 'table/cell/status', + resizable: true, + }, + { + label: 'Last Reading', + valuePath: 'lastReadingAt', + sortParam: 'last_reading_at', + resizable: true, + }, + ]; + } + + @action refreshSensors() { + return this.loadSensors.perform(); + } + + @task *loadSensors() { + if (!this.device?.id) { + this.sensors = toRecentList(); + return; + } + + const sensors = yield this.store.query('sensor', { device_uuid: this.device.id, limit: 10, sort: '-updated_at' }); + this.sensors = toRecentList(sensors); + } +} diff --git a/addon/components/device/panel-tabs/vehicle.hbs b/addon/components/device/panel-tabs/vehicle.hbs new file mode 100644 index 000000000..292b514a8 --- /dev/null +++ b/addon/components/device/panel-tabs/vehicle.hbs @@ -0,0 +1,68 @@ +
+
+
+

Vehicle Attachment

+

Fleet context for telemetry from this device.

+
+
+ {{#if this.hasVehicle}} + {{#if this.canOpenVehicle}} +
+
+ + {{#if this.hasVehicle}} +
+
+ {{this.vehicleName}} +
+
+

{{this.vehicleName}}

+ {{#if this.vehicleStatus}} + {{smart-humanize this.vehicleStatus}} + {{/if}} +
+
{{n-a this.vehicleSubtitle}}
+
+
+
Driver
+
{{n-a this.vehicleDriverName}}
+
+
+
Attached Device
+
{{n-a this.device.displayName this.device.name}}
+
+
+
Device Last Seen
+
{{n-a (format-date-fns this.device.last_online_at "dd MMM yyyy, HH:mm")}}
+
+
+
+
+
+ {{else}} + + {{/if}} +
diff --git a/addon/components/device/panel-tabs/vehicle.js b/addon/components/device/panel-tabs/vehicle.js new file mode 100644 index 000000000..49c1e8d28 --- /dev/null +++ b/addon/components/device/panel-tabs/vehicle.js @@ -0,0 +1,90 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class DevicePanelTabsVehicleComponent extends Component { + @service deviceActions; + @service hostRouter; + @service mapManager; + @service vehicleActions; + + get device() { + return this.args.resource ?? this.args.model; + } + + get vehicle() { + return this.device?.attachable; + } + + get vehicleName() { + return this.device?.attached_to_name ?? this.vehicle?.displayName ?? this.vehicle?.display_name ?? this.vehicle?.name; + } + + get vehicleSubtitle() { + return this.vehicle?.plate_number ?? this.vehicle?.call_sign ?? this.vehicle?.vin ?? this.vehicle?.public_id ?? this.device?.attachable_uuid; + } + + get vehiclePhotoUrl() { + return this.vehicle?.photo_url ?? this.vehicle?.avatar_url; + } + + get vehicleStatus() { + return this.vehicle?.status ?? (this.vehicle?.online ? 'online' : null); + } + + get vehicleDriverName() { + return this.vehicle?.driver?.displayName ?? this.vehicle?.driver?.display_name ?? this.vehicle?.driver?.name ?? this.vehicle?.driver_name; + } + + get hasVehicle() { + return Boolean(this.vehicleName || this.device?.attachable_uuid); + } + + get canOpenVehicle() { + return Boolean(this.vehicle?.id); + } + + get canLocateVehicle() { + return Boolean(this.vehicle?.id && (this.vehicle?.location || this.vehicle?.last_position)); + } + + @action attachToVehicle() { + return this.deviceActions.attachToVehicle(this.device, { callback: () => this.device?.reload?.() }); + } + + @action detachFromVehicle() { + return this.deviceActions.detachFromVehicle(this.device, { callback: () => this.device?.reload?.() }); + } + + @action openVehicle() { + if (this.vehicle?.id) { + return this.vehicleActions.panel?.view + ? this.vehicleActions.panel.view(this.vehicle) + : this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index.details', this.vehicle); + } + } + + @action async locateVehicle() { + if (!this.vehicle?.id) { + return; + } + + await this.transitionToLiveMap(); + await this.mapManager.waitForMap({ timeoutMs: 8000 }); + + this.mapManager.focusResource(this.vehicle, 16, { + paddingBottomRight: [300, 200], + moveend: () => { + this.vehicleActions.panel?.view?.(this.vehicle, { closeOnTransition: true }); + }, + }); + } + + async transitionToLiveMap() { + try { + await this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index', { queryParams: { layout: 'map' } }); + } catch (_) { + // Keep locate usable if another transition is already active. + } + } +} diff --git a/addon/components/equipment/form.js b/addon/components/equipment/form.js index 8e0585d0c..cb855a906 100644 --- a/addon/components/equipment/form.js +++ b/addon/components/equipment/form.js @@ -11,9 +11,16 @@ import { task } from 'ember-concurrency'; const TYPE_TO_MODEL = { 'fleet-ops:vehicle': 'vehicle', 'fleet-ops:driver': 'driver', + 'Fleetbase\\FleetOps\\Models\\Vehicle': 'vehicle', + 'Fleetbase\\FleetOps\\Models\\Driver': 'driver', 'fleet-ops:equipment': 'equipment', }; +const TYPE_TO_OPTION_VALUE = { + 'Fleetbase\\FleetOps\\Models\\Vehicle': 'fleet-ops:vehicle', + 'Fleetbase\\FleetOps\\Models\\Driver': 'fleet-ops:driver', +}; + export default class EquipmentFormComponent extends Component { @service fetch; @service currentUser; @@ -45,7 +52,7 @@ export default class EquipmentFormComponent extends Component { const { resource } = args; if (resource?.equipable_type) { this.equipableModelName = TYPE_TO_MODEL[resource.equipable_type] ?? null; - this.selectedEquipableType = this.equipableTypeOptions.find((o) => o.value === resource.equipable_type) ?? null; + this.selectedEquipableType = this.equipableTypeOptions.find((o) => o.value === (TYPE_TO_OPTION_VALUE[resource.equipable_type] ?? resource.equipable_type)) ?? null; } } diff --git a/addon/components/layout/fleet-ops-sidebar.hbs b/addon/components/layout/fleet-ops-sidebar.hbs index 05506163a..c75bfd5aa 100644 --- a/addon/components/layout/fleet-ops-sidebar.hbs +++ b/addon/components/layout/fleet-ops-sidebar.hbs @@ -3,7 +3,7 @@ @primaryAction={{this.createOrderAction}} @searchPlaceholder="Search Fleet-Ops..." @searchProvider={{this.searchNavigation}} - @initialActiveParentSync={{false}} + @shouldSyncInitialActiveParent={{this.shouldSyncInitialActiveParent}} class="fleet-ops-sidebar-navigator" > <:footer as |state|> diff --git a/addon/components/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js index dfb36919a..c5f57359a 100644 --- a/addon/components/layout/fleet-ops-sidebar.js +++ b/addon/components/layout/fleet-ops-sidebar.js @@ -390,6 +390,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component { } } + @action shouldSyncInitialActiveParent({ activePath = [], routeName }) { + const [parent, child] = activePath; + const defaultOrdersRoutes = [this.fullRoute('operations.orders'), this.fullRoute('operations.orders.index')]; + const isDefaultOrdersLanding = parent?.id === 'operations' && child?.route === this.fullRoute('operations.orders') && defaultOrdersRoutes.includes(routeName); + + return !isDefaultOrdersLanding; + } + @action async searchNavigation({ query, limit = 12 }) { const trimmedQuery = query?.trim(); diff --git a/addon/components/map/drawer/device-event-listing.js b/addon/components/map/drawer/device-event-listing.js index 1f0887808..c79a05032 100644 --- a/addon/components/map/drawer/device-event-listing.js +++ b/addon/components/map/drawer/device-event-listing.js @@ -5,6 +5,7 @@ import { action } from '@ember/object'; import { task } from 'ember-concurrency'; import { isArray } from '@ember/array'; import { startOfWeek, endOfWeek, format } from 'date-fns'; +import calculateMapDrawerDropdownPosition from '../../../utils/map-drawer-dropdown-position'; export default class MapDrawerDeviceEventListingComponent extends Component { @service store; @@ -115,6 +116,7 @@ export default class MapDrawerDeviceEventListingComponent extends Component { ddMenuLabel: this.intl.t('common.resource-actions', { resource: this.intl.t('resource.device-event') }), cellClassNames: 'overflow-visible', wrapperClass: 'flex items-center justify-end mx-2', + calculatePosition: calculateMapDrawerDropdownPosition, width: '10%', actions: [ { diff --git a/addon/components/map/drawer/driver-listing.js b/addon/components/map/drawer/driver-listing.js index f73e8fe1f..cadeef794 100644 --- a/addon/components/map/drawer/driver-listing.js +++ b/addon/components/map/drawer/driver-listing.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import calculateMapDrawerDropdownPosition from '../../../utils/map-drawer-dropdown-position'; export default class MapDrawerDriverListingComponent extends Component { @service driverActions; @@ -71,6 +72,7 @@ export default class MapDrawerDriverListingComponent extends Component { ddMenuLabel: this.intl.t('common.resource-actions', { resource: this.intl.t('resource.driver') }), cellClassNames: 'overflow-visible', wrapperClass: 'flex items-center justify-end mx-2', + calculatePosition: calculateMapDrawerDropdownPosition, width: '90px', actions: [ { diff --git a/addon/components/map/drawer/place-listing.js b/addon/components/map/drawer/place-listing.js index 98142a508..46aac64ea 100644 --- a/addon/components/map/drawer/place-listing.js +++ b/addon/components/map/drawer/place-listing.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import calculateMapDrawerDropdownPosition from '../../../utils/map-drawer-dropdown-position'; export default class MapDrawerPlaceListingComponent extends Component { @service placeActions; @@ -53,6 +54,7 @@ export default class MapDrawerPlaceListingComponent extends Component { ddMenuLabel: this.intl.t('common.resource-actions', { resource: this.intl.t('resource.place') }), cellClassNames: 'overflow-visible', wrapperClass: 'flex items-center justify-end mx-2', + calculatePosition: calculateMapDrawerDropdownPosition, width: '90px', actions: [ { diff --git a/addon/components/map/drawer/position-listing.hbs b/addon/components/map/drawer/position-listing.hbs index 1a220c1e2..42d3808bf 100644 --- a/addon/components/map/drawer/position-listing.hbs +++ b/addon/components/map/drawer/position-listing.hbs @@ -4,23 +4,28 @@
- {{#if (eq (get-model-name option) "driver")}} - + {{#if (eq option.modelName "driver")}} + {{else}} - + {{/if}}
-
{{or option.name option.displayName option.public_id}}
-
{{or option.email option.serial_number option.plate_number option.internal_id}}
+
{{option.primaryLabel}}
+
{{option.secondaryLabel}}
+ {{#if option.deviceLabel}} +
{{option.deviceLabel}}
+ {{/if}}
diff --git a/addon/components/map/drawer/position-listing.js b/addon/components/map/drawer/position-listing.js index f0ae7b251..d85963c49 100644 --- a/addon/components/map/drawer/position-listing.js +++ b/addon/components/map/drawer/position-listing.js @@ -8,6 +8,7 @@ import { htmlSafe } from '@ember/template'; import { startOfWeek, endOfWeek, format } from 'date-fns'; import getModelName from '@fleetbase/ember-core/utils/get-model-name'; import ensureLeafletDrawEditNamespace from '../../../utils/leaflet-draw-namespace-guard'; +import { buildTrackableOption } from '../../../utils/trackable-option'; const L = window.leaflet || window.L; @@ -29,6 +30,7 @@ export default class MapDrawerPositionListingComponent extends Component { @tracked replaySpeed = '1'; @tracked positionsLayer = null; @tracked positionOverlayIds = []; + @tracked selectedTrackableModelName = null; /** Computed properties - read state from service */ get isReplaying() { @@ -47,7 +49,17 @@ export default class MapDrawerPositionListingComponent extends Component { const vehicles = this.mapManager.livemap?.vehicles ?? []; const drivers = this.mapManager.livemap?.drivers ?? []; - return [...vehicles, ...drivers]; + return [...vehicles.map((vehicle) => buildTrackableOption(vehicle, 'vehicle')), ...drivers.map((driver) => buildTrackableOption(driver, 'driver'))]; + } + + get selectedTrackable() { + if (!this.resource) { + return null; + } + + const resourceType = this.resourceType; + + return this.trackables.find((trackable) => trackable.resource?.id === this.resource?.id && trackable.modelName === resourceType) ?? null; } get replayProgressWidth() { @@ -72,7 +84,7 @@ export default class MapDrawerPositionListingComponent extends Component { if (!this.resource) { return 'resource'; } - return getModelName(this.resource) || 'resource'; + return this.selectedTrackableModelName || getModelName(this.resource) || 'resource'; } get hasPositions() { @@ -179,8 +191,9 @@ export default class MapDrawerPositionListingComponent extends Component { this.positionOverlayIds = []; } - @action onResourceSelected(resource) { - this.resource = resource; + @action onResourceSelected(trackable) { + this.resource = trackable?.resource ?? null; + this.selectedTrackableModelName = trackable?.modelName ?? null; this.loadPositions.perform(); } diff --git a/addon/components/map/drawer/vehicle-listing.js b/addon/components/map/drawer/vehicle-listing.js index 314049e6e..a7e07d609 100644 --- a/addon/components/map/drawer/vehicle-listing.js +++ b/addon/components/map/drawer/vehicle-listing.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import calculateMapDrawerDropdownPosition from '../../../utils/map-drawer-dropdown-position'; export default class MapDrawerVehicleListingComponent extends Component { @service vehicleActions; @@ -65,6 +66,7 @@ export default class MapDrawerVehicleListingComponent extends Component { ddMenuLabel: this.intl.t('common.resource-actions', { resource: this.intl.t('resource.vehicle') }), cellClassNames: 'overflow-visible', wrapperClass: 'flex items-center justify-end mx-2', + calculatePosition: calculateMapDrawerDropdownPosition, width: '90px', actions: [ { diff --git a/addon/components/sensor/panel-header.hbs b/addon/components/sensor/panel-header.hbs index 71d17ffad..5ae04db45 100644 --- a/addon/components/sensor/panel-header.hbs +++ b/addon/components/sensor/panel-header.hbs @@ -1,29 +1,61 @@ -
-
-
-
+
+
+
+
{{@resource.displayName}} - +
-
-

{{@resource.displayName}}

-
-
{{n-a (get-fleet-ops-option-label "sensorTypes" @resource.type)}}
-
{{n-a @resource.serial_number}}
+
+
+

{{this.name}}

+ {{smart-humanize this.status}} + {{#if this.thresholdStatus}} + {{smart-humanize this.thresholdStatus}} + {{/if}} +
+ +
+ {{#if this.type}} + + + {{n-a (get-fleet-ops-option-label "sensorTypes" this.type)}} + + {{/if}} + {{#if this.identifier}} + + + {{this.identifier}} + + {{/if}} + {{#if this.reading}} + + + {{this.reading}} + + {{/if}} + {{#if this.deviceName}} + + + {{this.deviceName}} + + {{/if}} + + + {{#if this.lastReadingAt}}{{format-date-fns this.lastReadingAt "dd MMM yyyy, HH:mm"}}{{else}}No reading yet{{/if}} +
- {{if @resource.online (t "common.online") (t "common.offline")}}
-
+
-
\ No newline at end of file +
diff --git a/addon/components/sensor/panel-header.js b/addon/components/sensor/panel-header.js index 898aa197d..0d6b0b802 100644 --- a/addon/components/sensor/panel-header.js +++ b/addon/components/sensor/panel-header.js @@ -1,3 +1,61 @@ import Component from '@glimmer/component'; +import { get } from '@ember/object'; -export default class SensorPanelHeaderComponent extends Component {} +export default class SensorPanelHeaderComponent extends Component { + get resource() { + return this.args.resource; + } + + get name() { + return ( + get(this.resource, 'displayName') ?? + get(this.resource, 'display_name') ?? + get(this.resource, 'name') ?? + get(this.resource, 'serial_number') ?? + get(this.resource, 'imei') ?? + get(this.resource, 'public_id') ?? + '-' + ); + } + + get status() { + return get(this.resource, 'status') ?? (get(this.resource, 'is_active') ? 'active' : 'inactive'); + } + + get thresholdStatus() { + return get(this.resource, 'threshold_status'); + } + + get type() { + return get(this.resource, 'type'); + } + + get identifier() { + return get(this.resource, 'serial_number') ?? get(this.resource, 'imei') ?? get(this.resource, 'internal_id') ?? get(this.resource, 'public_id'); + } + + get reading() { + const value = get(this.resource, 'last_value'); + const unit = get(this.resource, 'unit'); + + if (value === null || value === undefined || value === '') { + return null; + } + + return unit ? `${value} ${unit}` : String(value); + } + + get deviceName() { + return ( + get(this.resource, 'device.displayName') ?? + get(this.resource, 'device.display_name') ?? + get(this.resource, 'device.name') ?? + get(this.resource, 'device_name') ?? + get(this.resource, 'device_uuid') + ); + } + + get lastReadingAt() { + return get(this.resource, 'last_reading_at') ?? get(this.resource, 'lastReadingAt'); + } +} diff --git a/addon/components/table/cell/dropdown.hbs b/addon/components/table/cell/dropdown.hbs new file mode 100644 index 000000000..6c9808f27 --- /dev/null +++ b/addon/components/table/cell/dropdown.hbs @@ -0,0 +1,28 @@ +
+ + + +
diff --git a/addon/components/table/cell/dropdown.js b/addon/components/table/cell/dropdown.js new file mode 100644 index 000000000..241867d79 --- /dev/null +++ b/addon/components/table/cell/dropdown.js @@ -0,0 +1,91 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action, computed } from '@ember/object'; + +export default class TableCellDropdownComponent extends Component { + @tracked tableCellNode; + defaultButtonText = 'Actions'; + + @computed('args.column.ddButtonText', 'defaultButtonText') get buttonText() { + const { ddButtonText } = this.args.column; + + if (ddButtonText === undefined) { + return this.defaultButtonText; + } + + if (ddButtonText === false) { + return null; + } + + return ddButtonText; + } + + @action setupComponent(dropdownWrapperNode) { + const tableCellNode = this.getOwnerTableCell(dropdownWrapperNode); + + if (tableCellNode) { + tableCellNode.style.overflow = 'visible'; + } + + this.tableCellNode = tableCellNode; + } + + @action onOpen() { + if (!this.tableCellNode) { + return; + } + + const currentZIndex = Number.parseInt(this.tableCellNode.style.zIndex, 10) || 0; + this.tableCellNode.style.zIndex = currentZIndex + 1; + } + + @action onClose() { + if (!this.tableCellNode) { + return; + } + + const currentZIndex = Number.parseInt(this.tableCellNode.style.zIndex, 10) || 1; + this.tableCellNode.style.zIndex = currentZIndex - 1; + } + + @action getOwnerTableCell(dropdownWrapperNode) { + while (dropdownWrapperNode) { + dropdownWrapperNode = dropdownWrapperNode.parentNode; + + if (dropdownWrapperNode?.tagName?.toLowerCase() === 'td') { + return dropdownWrapperNode; + } + } + + return undefined; + } + + @action onDropdownItemClick(columnAction, row, dd) { + if (typeof dd?.actions?.close === 'function') { + dd.actions.close(); + } + + if (typeof columnAction?.fn === 'function') { + columnAction.fn(row); + } + } + + @action calculatePosition(trigger, content) { + if (typeof this.args.column?.calculatePosition === 'function') { + return this.args.column.calculatePosition(trigger, content); + } + + const triggerRect = trigger.getBoundingClientRect(); + const contentRect = content?.getBoundingClientRect?.(); + const contentWidth = contentRect?.width || 224; + + const style = { + position: 'fixed', + marginTop: '0px', + left: `${triggerRect.left - contentWidth - 3}px`, + top: `${triggerRect.top}px`, + }; + + return { style }; + } +} diff --git a/addon/components/table/cell/dropdown/action-item.hbs b/addon/components/table/cell/dropdown/action-item.hbs new file mode 100644 index 000000000..b48401843 --- /dev/null +++ b/addon/components/table/cell/dropdown/action-item.hbs @@ -0,0 +1,31 @@ +{{#if this.visible}} + {{#if @columnAction.separator}} +
+ {{else}} + + {{/if}} +{{/if}} diff --git a/addon/components/table/cell/dropdown/action-item.js b/addon/components/table/cell/dropdown/action-item.js new file mode 100644 index 000000000..b43d6792c --- /dev/null +++ b/addon/components/table/cell/dropdown/action-item.js @@ -0,0 +1,62 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { isNone } from '@ember/utils'; + +export default class TableCellDropdownActionItemComponent extends Component { + @service abilities; + @tracked permissionRequired; + @tracked doesntHavePermissions = false; + @tracked disabled = false; + @tracked visible = true; + + constructor(owner, { columnAction = {}, row = {}, disabled = false, permission = null }) { + super(...arguments); + this.permissionRequired = columnAction.permission ?? permission; + this.disabled = this.disabledCheck(columnAction, this.permissionRequired, disabled); + this.visible = (columnAction.visible ?? true) && this.visibilityCheck(columnAction, row); + } + + @action onClick(columnAction, row, dd) { + if (this.disabled) { + return; + } + + if (typeof dd?.actions?.close === 'function') { + dd.actions.close(); + } + + if (typeof columnAction?.fn === 'function') { + columnAction.fn(row); + } + } + + disabledCheck(columnAction, permission, defaultValue = false) { + let disabled = columnAction.disabled ?? defaultValue; + if (!disabled) { + disabled = permission && this.abilities.cannot(permission); + this.doesntHavePermissions = disabled; + } + + return disabled; + } + + visibilityCheck(columnAction, context) { + const isVisible = columnAction.isVisible; + + if (isNone(context) || !isVisible) { + return true; + } + + if (typeof isVisible === 'boolean') { + return isVisible; + } + + if (typeof isVisible === 'function') { + return isVisible(context); + } + + return true; + } +} diff --git a/addon/components/vendor/panel-header.hbs b/addon/components/vendor/panel-header.hbs new file mode 100644 index 000000000..a578156dc --- /dev/null +++ b/addon/components/vendor/panel-header.hbs @@ -0,0 +1,82 @@ +
+
+
+
+ {{this.name}} + + + +
+
+
+

{{this.name}}

+ {{smart-humanize this.status}} +
+ +
+ {{#if this.businessId}} + + + {{this.businessId}} + + {{/if}} + {{#if this.type}} + + + {{smart-humanize this.type}} + + {{/if}} + {{#if this.email}} + + + {{this.email}} + + {{/if}} + {{#if this.phone}} + + + {{this.phone}} + + {{/if}} + {{#if this.country}} + + + + + {{/if}} + {{#if this.address}} + + + {{this.address}} + + {{/if}} + {{#if this.website}} + + + {{this.website}} + + {{/if}} +
+
+
+
+ +
+
+
diff --git a/addon/components/vendor/panel-header.js b/addon/components/vendor/panel-header.js new file mode 100644 index 000000000..356c44938 --- /dev/null +++ b/addon/components/vendor/panel-header.js @@ -0,0 +1,48 @@ +import Component from '@glimmer/component'; +import { get } from '@ember/object'; + +export default class VendorPanelHeaderComponent extends Component { + get resource() { + return this.args.resource; + } + + get imageUrl() { + return get(this.resource, 'photo_url') ?? get(this.resource, 'logo_url'); + } + + get name() { + return get(this.resource, 'name') ?? get(this.resource, 'displayName') ?? get(this.resource, 'business_id') ?? get(this.resource, 'public_id') ?? '-'; + } + + get status() { + return get(this.resource, 'status') ?? 'active'; + } + + get businessId() { + return get(this.resource, 'business_id') ?? get(this.resource, 'internal_id') ?? get(this.resource, 'public_id'); + } + + get type() { + return get(this.resource, 'type'); + } + + get email() { + return get(this.resource, 'email'); + } + + get phone() { + return get(this.resource, 'phone'); + } + + get country() { + return get(this.resource, 'country'); + } + + get address() { + return get(this.resource, 'address_street') ?? get(this.resource, 'address'); + } + + get website() { + return get(this.resource, 'website_url'); + } +} diff --git a/addon/controllers/connectivity/devices/index.js b/addon/controllers/connectivity/devices/index.js index 662e39e5a..e457adcd9 100644 --- a/addon/controllers/connectivity/devices/index.js +++ b/addon/controllers/connectivity/devices/index.js @@ -1,15 +1,41 @@ import Controller from '@ember/controller'; +import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; +import buildDeviceTableColumns from '../../../utils/device-table-columns'; import fleetOpsOptions from '../../../utils/fleet-ops-options'; export default class ConnectivityDevicesIndexController extends Controller { @service deviceActions; + @service hostRouter; @service telematicActions; @service intl; + @service mapManager; + @service notifications; + @service store; + @service vehicleActions; /** query params */ - @tracked queryParams = ['name', 'status', 'attachment_state', 'telematic', 'page', 'limit', 'sort', 'query', 'public_id', 'created_at', 'updated_at']; + @tracked queryParams = [ + 'name', + 'status', + 'attachment_state', + 'telematic', + 'provider', + 'vehicle', + 'connection_status', + 'device_id', + 'type', + 'serial_number', + 'last_online_at', + 'page', + 'limit', + 'sort', + 'query', + 'public_id', + 'created_at', + 'updated_at', + ]; @tracked page = 1; @tracked limit; @tracked sort = '-created_at'; @@ -18,6 +44,13 @@ export default class ConnectivityDevicesIndexController extends Controller { @tracked status; @tracked attachment_state; @tracked telematic; + @tracked provider; + @tracked vehicle; + @tracked connection_status; + @tracked device_id; + @tracked type; + @tracked serial_number; + @tracked last_online_at; /** action buttons */ @tracked actionButtons = [ @@ -56,133 +89,115 @@ export default class ConnectivityDevicesIndexController extends Controller { }, ]; - /** columns */ - @tracked columns = [ - { - sticky: true, - label: this.intl.t('column.name'), - valuePath: 'displayName', - cellComponent: 'table/cell/anchor', - action: this.deviceActions.transition.view, - permission: 'fleet-ops view device', - resizable: true, - sortable: true, - filterable: true, - filterParam: 'name', - filterComponent: 'filter/string', - }, - { - label: 'Telematic', - valuePath: 'telematic.provider', - cellComponent: 'table/cell/anchor', - action: this.telematicActions.transition.view, - permission: 'fleet-ops view telematic', - resizable: true, - sortable: true, - filterable: true, - filterComponent: 'filter/model', - filterComponentPlaceholder: 'Select telematic', - filterParam: 'telematic', - model: 'telematic', - }, - { - label: 'Type', - valuePath: 'type', - resizable: true, - sortable: true, - filterable: true, - filterParam: 'type', - filterComponent: 'filter/multi-option', - filterOptions: fleetOpsOptions('deviceTypes'), - }, - { - label: 'Serial Number', - valuePath: 'serial_number', - resizable: true, - sortable: true, - filterable: true, - filterParam: 'serial_number', - filterComponent: 'filter/string', - }, - { - label: this.intl.t('column.status'), - valuePath: 'status', - cellComponent: 'table/cell/status', - resizable: true, - sortable: true, - filterable: true, - filterComponent: 'filter/multi-option', - filterOptions: fleetOpsOptions('deviceStatuses'), - }, - { - label: this.intl.t('column.created-at'), - valuePath: 'createdAt', - sortParam: 'created_at', - resizable: true, - sortable: true, - filterable: true, - filterComponent: 'filter/date', - }, - { - label: this.intl.t('column.updated-at'), - valuePath: 'updatedAt', - sortParam: 'updated_at', - resizable: true, - sortable: true, - hidden: true, - filterable: true, - filterComponent: 'filter/date', - }, + get deviceTypeOptions() { + return fleetOpsOptions('deviceTypes'); + } - { - label: '', - cellComponent: 'table/cell/dropdown', - ddButtonText: false, - ddButtonIcon: 'ellipsis-h', - ddButtonIconPrefix: 'fas', - ddMenuLabel: this.intl.t('common.resource-actions', { resource: this.intl.t('resource.device') }), - cellClassNames: 'overflow-visible', - wrapperClass: 'flex items-center justify-end mx-2', - sticky: 'right', - width: 60, - actions: [ - { - label: this.intl.t('common.view-resource', { resource: this.intl.t('resource.device') }), - fn: this.deviceActions.transition.view, - permission: 'fleet-ops view device', - }, - { - label: this.intl.t('common.edit-resource', { resource: this.intl.t('resource.device') }), - fn: this.deviceActions.transition.edit, - permission: 'fleet-ops update device', - }, - { - separator: true, - }, - { - label: this.intl.t('device.actions.attach-to-vehicle'), - fn: this.deviceActions.attachToVehicle, - permission: 'fleet-ops update device', - }, - { - label: this.intl.t('device.actions.detach-from-vehicle'), - fn: this.deviceActions.detachFromVehicle, - permission: 'fleet-ops update device', - isVisible: (device) => Boolean(device.attachable_uuid || device.attached_to_name || device.attachable), - }, - { - separator: true, - }, - { - label: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.device') }), - fn: this.deviceActions.delete, - permission: 'fleet-ops delete device', - }, - ], - sortable: false, - filterable: false, - resizable: false, - searchable: false, - }, - ]; + get deviceStatusOptions() { + return fleetOpsOptions('deviceStatuses'); + } + + get columns() { + const columns = buildDeviceTableColumns(this, { deviceActionMode: 'route', showDeviceStatus: false }); + const actionsColumn = columns.find((column) => column.cellComponent === 'table/cell/dropdown'); + + actionsColumn?.actions.push( + { + separator: true, + }, + { + label: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.device') }), + fn: this.deviceActions.delete, + permission: 'fleet-ops delete device', + } + ); + + return columns; + } + + @action openTelematic(telematic) { + if (telematic?.id) { + return this.telematicActions.transition.view(telematic); + } + } + + @action hasAttachedVehicle(device) { + return Boolean(device?.attachable_uuid && this.isVehicleAttachment(device)); + } + + @action async viewAttachedVehicle(device) { + const vehicle = await this.resolveAttachedVehicle(device); + + if (!vehicle) { + return; + } + + if (this.vehicleActions.panel?.view) { + return this.vehicleActions.panel.view(vehicle); + } + + return this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index.details', vehicle); + } + + @action async locateAttachedVehicle(device) { + const vehicle = await this.resolveAttachedVehicle(device); + + if (!vehicle) { + return; + } + + await this.transitionToLiveMap(); + await this.mapManager.waitForMap({ timeoutMs: 8000 }); + + this.mapManager.focusResource(vehicle, 16, { + paddingBottomRight: [300, 200], + moveend: () => { + this.vehicleActions.panel?.view?.(vehicle, { closeOnTransition: true }); + }, + }); + } + + async transitionToLiveMap() { + try { + await this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index', { queryParams: { layout: 'map' } }); + } catch (_) { + // Keep locate actions usable even if another map transition is already active. + } + } + + isVehicleAttachment(device) { + const attachableType = `${device?.attachable_type ?? ''}`.toLowerCase(); + + return !attachableType || attachableType.includes('vehicle'); + } + + async resolveAttachedVehicle(device) { + if (!device?.attachable_uuid || !this.isVehicleAttachment(device)) { + return null; + } + + const attachable = device.attachable; + + if (attachable && typeof attachable.then !== 'function') { + return attachable; + } + + if (attachable && typeof attachable.then === 'function') { + return await attachable; + } + + const cachedVehicle = this.store.peekRecord('vehicle', device.attachable_uuid); + + if (cachedVehicle) { + return cachedVehicle; + } + + try { + return await this.store.findRecord('vehicle', device.attachable_uuid); + } catch (error) { + this.notifications.serverError(error); + } + + return null; + } } diff --git a/addon/controllers/connectivity/devices/index/details/vehicle.js b/addon/controllers/connectivity/devices/index/details/vehicle.js index 3ea32f71a..5d09ae0aa 100644 --- a/addon/controllers/connectivity/devices/index/details/vehicle.js +++ b/addon/controllers/connectivity/devices/index/details/vehicle.js @@ -7,17 +7,27 @@ export default class ConnectivityDevicesIndexDetailsVehicleController extends Co @service deviceActions; @service hostRouter; @service intl; + @service mapManager; + @service vehicleActions; @tracked queryParams = []; get device() { - return this.model; + return this.model?.device ?? this.model; } get vehicle() { return this.device?.attachable; } + get positions() { + return Array.from(this.model?.positions ?? []); + } + + get hasPositions() { + return this.positions.length > 0; + } + get vehicleName() { return this.device?.attached_to_name ?? this.vehicle?.displayName ?? this.vehicle?.display_name ?? this.vehicle?.name; } @@ -58,6 +68,14 @@ export default class ConnectivityDevicesIndexDetailsVehicleController extends Co return Boolean(this.vehicleName || this.device?.attachable_uuid); } + get canOpenVehicle() { + return Boolean(this.vehicle?.id); + } + + get canLocateVehicle() { + return Boolean(this.vehicle?.id && (this.vehicle?.location || this.vehicle?.last_position || this.hasPositions)); + } + @action attachToVehicle() { return this.deviceActions.attachToVehicle(this.device, { callback: () => this.hostRouter.refresh() }); } @@ -71,4 +89,34 @@ export default class ConnectivityDevicesIndexDetailsVehicleController extends Co return this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index.details', this.vehicle); } } + + @action openVehiclePositions() { + if (this.vehicle?.id) { + return this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index.details.positions', this.vehicle); + } + } + + @action async locateVehicle() { + if (!this.vehicle?.id) { + return; + } + + await this.transitionToLiveMap(); + await this.mapManager.waitForMap({ timeoutMs: 8000 }); + + this.mapManager.focusResource(this.vehicle, 16, { + paddingBottomRight: [300, 200], + moveend: () => { + this.vehicleActions.panel?.view?.(this.vehicle, { closeOnTransition: true }); + }, + }); + } + + async transitionToLiveMap() { + try { + await this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index', { queryParams: { layout: 'map' } }); + } catch (_) { + // Keep locate actions usable even if the current transition is already in-flight. + } + } } diff --git a/addon/controllers/connectivity/events/details.js b/addon/controllers/connectivity/events/details.js new file mode 100644 index 000000000..af653c372 --- /dev/null +++ b/addon/controllers/connectivity/events/details.js @@ -0,0 +1,3 @@ +import Controller from '@ember/controller'; + +export default class ConnectivityEventsDetailsController extends Controller {} diff --git a/addon/controllers/connectivity/events/index.js b/addon/controllers/connectivity/events/index.js index 1f7def6db..6e2eef8d0 100644 --- a/addon/controllers/connectivity/events/index.js +++ b/addon/controllers/connectivity/events/index.js @@ -1,19 +1,96 @@ import Controller from '@ember/controller'; +import { action, get } from '@ember/object'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; +const severityOptions = [ + { label: 'Info', value: 'info' }, + { label: 'Warning', value: 'warning' }, + { label: 'Error', value: 'error' }, + { label: 'Critical', value: 'critical' }, + { label: 'High', value: 'high' }, +]; + +const processedOptions = [ + { label: 'Processed', value: 'processed' }, + { label: 'Unprocessed', value: 'unprocessed' }, +]; + +function eventDevice(event) { + const device = get(event, 'device'); + + if (device) { + return device; + } + + const id = get(event, 'device_uuid'); + const deviceId = get(event, 'device_id') ?? get(event, 'provider_device_id') ?? get(event, 'ident'); + const name = get(event, 'device_name') ?? deviceId; + const imei = get(event, 'device_imei') ?? get(event, 'imei') ?? get(event, 'ident'); + + if (!id && !name && !imei && !deviceId) { + return null; + } + + return { + id, + displayName: name, + name, + imei, + device_id: deviceId, + ident: get(event, 'ident'), + serial_number: get(event, 'device_serial_number'), + connection_status: get(event, 'device_connection_status'), + status: get(event, 'device_status'), + photo_url: get(event, 'device_photo_url') ?? get(event, 'photo_url'), + }; +} + +function eventTelematic(event) { + const telematic = get(event, 'device.telematic') ?? get(event, 'telematic'); + + if (telematic) { + return telematic; + } + + const id = get(event, 'telematic_uuid'); + const name = get(event, 'telematic_name'); + const provider = get(event, 'provider'); + + if (!id && !name && !provider) { + return null; + } + + return { + id, + name: name ?? provider, + provider, + provider_descriptor: get(event, 'provider_descriptor'), + }; +} + export default class ConnectivityEventsIndexController extends Controller { @service deviceEventActions; @service deviceActions; + @service hostRouter; @service intl; + @service store; + @service telematicActions; /** query params */ - @tracked queryParams = ['name', 'page', 'limit', 'sort', 'query', 'public_id', 'created_at', 'updated_at']; + @tracked queryParams = ['page', 'limit', 'sort', 'query', 'telematic', 'device', 'event_type', 'severity', 'processed', 'occurred_at', 'created_at', 'updated_at']; @tracked page = 1; @tracked limit; @tracked sort = '-created_at'; - @tracked public_id; - @tracked name; + @tracked query; + @tracked telematic; + @tracked device; + @tracked event_type; + @tracked severity; + @tracked processed; + @tracked occurred_at; + @tracked created_at; + @tracked updated_at; /** action buttons */ @tracked actionButtons = [ @@ -40,13 +117,17 @@ export default class ConnectivityEventsIndexController extends Controller { resizable: true, sortable: true, filterable: true, + filterParam: 'event_type', filterComponent: 'filter/string', }, { label: 'Device', valuePath: 'device.displayName', - cellComponent: 'table/cell/anchor', - action: this.deviceActions.transition.view, + cellComponent: 'cell/device-identity', + resourcePath: eventDevice, + compact: true, + showStatus: false, + action: this.openDevice, permission: 'fleet-ops view device', resizable: true, sortable: true, @@ -55,23 +136,50 @@ export default class ConnectivityEventsIndexController extends Controller { filterComponentPlaceholder: 'Select device', filterParam: 'device', model: 'device', + modelNamePath: 'displayName', }, { label: 'Provider', valuePath: 'provider', + cellComponent: 'cell/telematic-provider', + resourcePath: eventTelematic, + compact: true, + action: this.openTelematic, + permission: 'fleet-ops view telematic', resizable: true, sortable: true, filterable: true, - filterParam: 'provider', - filterComponent: 'filter/string', + filterParam: 'telematic', + filterComponent: 'filter/model', + filterComponentPlaceholder: 'Select telematic', + model: 'telematic', }, { label: 'Severity', valuePath: 'severity', + cellComponent: 'table/cell/status', resizable: true, sortable: true, filterable: true, filterParam: 'severity', + filterComponent: 'filter/multi-option', + filterOptions: severityOptions, + filterOptionLabel: 'label', + filterOptionValue: 'value', + }, + { + label: 'Message', + valuePath: 'message', + resizable: true, + sortable: false, + }, + { + label: 'Code', + valuePath: 'code', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'code', filterComponent: 'filter/string', }, { @@ -96,13 +204,27 @@ export default class ConnectivityEventsIndexController extends Controller { sortable: true, }, { - label: 'Code', - valuePath: 'code', + label: 'Processed', + valuePath: 'processedAt', + sortParam: 'processed_at', resizable: true, sortable: true, filterable: true, - filterParam: 'code', - filterComponent: 'filter/string', + filterParam: 'processed', + filterComponent: 'filter/multi-option', + filterOptions: processedOptions, + filterOptionLabel: 'label', + filterOptionValue: 'value', + }, + { + label: 'Occurred', + valuePath: 'occurredAt', + sortParam: 'occurred_at', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'occurred_at', + filterComponent: 'filter/date', }, { label: this.intl.t('column.created-at'), @@ -111,7 +233,19 @@ export default class ConnectivityEventsIndexController extends Controller { resizable: true, sortable: true, filterable: true, + filterParam: 'created_at', + filterComponent: 'filter/date', + }, + { + label: this.intl.t('column.updated-at'), + valuePath: 'updatedAt', + sortParam: 'updated_at', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'updated_at', filterComponent: 'filter/date', + hidden: true, }, { label: '', @@ -130,6 +264,11 @@ export default class ConnectivityEventsIndexController extends Controller { fn: this.deviceEventActions.transition.view, permission: 'fleet-ops view device-event', }, + { + label: 'Mark processed', + fn: this.markProcessed, + permission: 'fleet-ops update device-event', + }, ], sortable: false, filterable: false, @@ -137,4 +276,41 @@ export default class ConnectivityEventsIndexController extends Controller { searchable: false, }, ]; + + async resolveDevice(device) { + if (!device?.id) { + return null; + } + + const cachedDevice = this.store.peekRecord('device', device.id); + + if (cachedDevice) { + return cachedDevice; + } + + try { + return await this.store.findRecord('device', device.id); + } catch (_) { + return device; + } + } + + @action async openDevice(device) { + const resolvedDevice = await this.resolveDevice(device); + + if (resolvedDevice?.id) { + return this.deviceActions.panel.view(resolvedDevice); + } + } + + @action openTelematic(telematic) { + if (telematic?.id) { + return this.telematicActions.transition.view(telematic); + } + } + + @action async markProcessed(deviceEvent) { + await this.deviceEventActions.markProcessed(deviceEvent); + await this.hostRouter.refresh(); + } } diff --git a/addon/controllers/connectivity/events/index/details.js b/addon/controllers/connectivity/events/index/details.js deleted file mode 100644 index adec60b7f..000000000 --- a/addon/controllers/connectivity/events/index/details.js +++ /dev/null @@ -1,3 +0,0 @@ -import Controller from '@ember/controller'; - -export default class ConnectivityEventsIndexDetailsController extends Controller {} diff --git a/addon/controllers/connectivity/sensors/index.js b/addon/controllers/connectivity/sensors/index.js index 1c82ac325..4a4437aff 100644 --- a/addon/controllers/connectivity/sensors/index.js +++ b/addon/controllers/connectivity/sensors/index.js @@ -1,21 +1,84 @@ import Controller from '@ember/controller'; +import { action, get } from '@ember/object'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import fleetOpsOptions from '../../../utils/fleet-ops-options'; +function sensorTelematic(sensor) { + const telematic = get(sensor, 'telematic'); + + if (telematic) { + return telematic; + } + + const id = get(sensor, 'telematic_uuid'); + const name = get(sensor, 'telematic_name'); + const provider = get(sensor, 'provider'); + + if (!id && !name && !provider) { + return null; + } + + return { + id, + name: name ?? provider, + provider, + provider_descriptor: get(sensor, 'provider_descriptor'), + }; +} + +function sensorDevice(sensor) { + const device = get(sensor, 'device'); + + if (device) { + return device; + } + + const id = get(sensor, 'device_uuid'); + const deviceId = get(sensor, 'device_id') ?? get(sensor, 'provider_device_id') ?? get(sensor, 'ident'); + const name = get(sensor, 'device_name') ?? deviceId; + const imei = get(sensor, 'device_imei') ?? get(sensor, 'imei') ?? get(sensor, 'ident'); + + if (!id && !name && !imei && !deviceId) { + return null; + } + + return { + id, + displayName: name, + name, + imei, + device_id: deviceId, + ident: get(sensor, 'ident'), + serial_number: get(sensor, 'device_serial_number'), + connection_status: get(sensor, 'device_connection_status'), + status: get(sensor, 'device_status'), + photo_url: get(sensor, 'device_photo_url') ?? get(sensor, 'photo_url'), + }; +} + export default class ConnectivitySensorsIndexController extends Controller { @service sensorActions; @service deviceActions; @service telematicActions; @service intl; + @service store; /** query params */ - @tracked queryParams = ['name', 'page', 'limit', 'sort', 'query', 'public_id', 'created_at', 'updated_at']; + @tracked queryParams = ['page', 'limit', 'sort', 'query', 'telematic', 'device', 'type', 'status', 'serial_number', 'imei', 'last_reading_at', 'created_at', 'updated_at']; @tracked page = 1; @tracked limit; @tracked sort = '-created_at'; - @tracked public_id; - @tracked name; + @tracked query; + @tracked telematic; + @tracked device; + @tracked type; + @tracked status; + @tracked serial_number; + @tracked imei; + @tracked last_reading_at; + @tracked created_at; + @tracked updated_at; /** action buttons */ @tracked actionButtons = [ @@ -67,14 +130,16 @@ export default class ConnectivitySensorsIndexController extends Controller { resizable: true, sortable: true, filterable: true, - filterParam: 'name', + filterParam: 'query', filterComponent: 'filter/string', }, { label: 'Telematic', valuePath: 'telematic.provider', - cellComponent: 'table/cell/anchor', - action: this.telematicActions.transition.view, + cellComponent: 'cell/telematic-provider', + resourcePath: sensorTelematic, + compact: true, + action: this.openTelematic, permission: 'fleet-ops view telematic', resizable: true, sortable: true, @@ -87,8 +152,10 @@ export default class ConnectivitySensorsIndexController extends Controller { { label: 'Device', valuePath: 'device.displayName', - cellComponent: 'table/cell/anchor', - action: this.deviceActions.transition.view, + cellComponent: 'cell/device-identity', + resourcePath: sensorDevice, + showStatus: false, + action: this.openDevice, permission: 'fleet-ops view device', resizable: true, sortable: true, @@ -97,10 +164,14 @@ export default class ConnectivitySensorsIndexController extends Controller { filterComponentPlaceholder: 'Select device', filterParam: 'device', model: 'device', + modelNamePath: 'displayName', + query: this.telematic ? { telematic_uuid: this.telematic } : undefined, }, { label: 'Type', valuePath: 'type', + cellComponent: 'table/cell/base', + humanize: true, resizable: true, sortable: true, filterable: true, @@ -109,13 +180,16 @@ export default class ConnectivitySensorsIndexController extends Controller { filterOptions: fleetOpsOptions('sensorTypes'), }, { - label: 'Serial Number', - valuePath: 'serial_number', + label: 'Last Value', + valuePath: 'last_value', + resizable: true, + sortable: true, + }, + { + label: 'Unit', + valuePath: 'unit', resizable: true, sortable: true, - filterable: true, - filterParam: 'serial_number', - filterComponent: 'filter/string', }, { label: this.intl.t('column.status'), @@ -127,6 +201,41 @@ export default class ConnectivitySensorsIndexController extends Controller { filterComponent: 'filter/multi-option', filterOptions: fleetOpsOptions('sensorStatuses'), }, + { + label: 'Threshold', + valuePath: 'threshold_status', + cellComponent: 'table/cell/status', + resizable: true, + sortable: true, + }, + { + label: 'Serial Number', + valuePath: 'serial_number', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'serial_number', + filterComponent: 'filter/string', + }, + { + label: 'IMEI', + valuePath: 'imei', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'imei', + filterComponent: 'filter/string', + }, + { + label: 'Last Reading', + valuePath: 'lastReadingAt', + sortParam: 'last_reading_at', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'last_reading_at', + filterComponent: 'filter/date', + }, { label: this.intl.t('column.created-at'), valuePath: 'createdAt', @@ -134,6 +243,7 @@ export default class ConnectivitySensorsIndexController extends Controller { resizable: true, sortable: true, filterable: true, + filterParam: 'created_at', filterComponent: 'filter/date', }, { @@ -144,9 +254,9 @@ export default class ConnectivitySensorsIndexController extends Controller { sortable: true, hidden: true, filterable: true, + filterParam: 'updated_at', filterComponent: 'filter/date', }, - { label: '', cellComponent: 'table/cell/dropdown', @@ -184,4 +294,36 @@ export default class ConnectivitySensorsIndexController extends Controller { searchable: false, }, ]; + + async resolveDevice(device) { + if (!device?.id) { + return null; + } + + const cachedDevice = this.store.peekRecord('device', device.id); + + if (cachedDevice) { + return cachedDevice; + } + + try { + return await this.store.findRecord('device', device.id); + } catch (_) { + return device; + } + } + + @action async openDevice(device) { + const resolvedDevice = await this.resolveDevice(device); + + if (resolvedDevice?.id) { + return this.deviceActions.panel.view(resolvedDevice); + } + } + + @action openTelematic(telematic) { + if (telematic?.id) { + return this.telematicActions.transition.view(telematic); + } + } } diff --git a/addon/controllers/connectivity/telematics/details/devices.js b/addon/controllers/connectivity/telematics/details/devices.js index 31978dfad..3f1ae8dfe 100644 --- a/addon/controllers/connectivity/telematics/details/devices.js +++ b/addon/controllers/connectivity/telematics/details/devices.js @@ -3,24 +3,36 @@ import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { task } from 'ember-concurrency'; - -const connectionStatusOptions = [ - { label: 'Online', value: 'online' }, - { label: 'Recently Offline', value: 'recently_offline' }, - { label: 'Offline', value: 'offline' }, - { label: 'Long Offline', value: 'long_offline' }, - { label: 'Never Connected', value: 'never_connected' }, -]; +import buildDeviceTableColumns from '../../../../utils/device-table-columns'; +import fleetOpsOptions from '../../../../utils/fleet-ops-options'; export default class ConnectivityTelematicsDetailsDevicesController extends Controller { @service deviceActions; @service fetch; @service hostRouter; @service intl; + @service mapManager; @service modalsManager; @service notifications; - - @tracked queryParams = ['page', 'limit', 'sort', 'query', 'status', 'provider', 'attachment_state', 'vehicle', 'connection_status', 'device_id', 'last_online_at', 'updated_at']; + @service store; + @service vehicleActions; + + @tracked queryParams = [ + 'page', + 'limit', + 'sort', + 'query', + 'status', + 'provider', + 'attachment_state', + 'vehicle', + 'connection_status', + 'device_id', + 'type', + 'serial_number', + 'last_online_at', + 'updated_at', + ]; @tracked telematic; @tracked page = 1; @tracked limit; @@ -32,6 +44,8 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont @tracked vehicle; @tracked connection_status; @tracked device_id; + @tracked type; + @tracked serial_number; @tracked last_online_at; @tracked updated_at; @@ -57,7 +71,17 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont get hasActiveFilters() { return Boolean( - this.query || this.status || this.provider || this.attachment_state || this.vehicle || this.connection_status || this.device_id || this.last_online_at || this.updated_at + this.query || + this.status || + this.provider || + this.attachment_state || + this.vehicle || + this.connection_status || + this.device_id || + this.type || + this.serial_number || + this.last_online_at || + this.updated_at ); } @@ -194,130 +218,16 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont @tracked bulkActions = []; + get deviceTypeOptions() { + return fleetOpsOptions('deviceTypes'); + } + + get deviceStatusOptions() { + return fleetOpsOptions('deviceStatuses'); + } + get columns() { - return [ - { - sticky: true, - label: this.intl.t('column.name'), - valuePath: 'displayName', - cellComponent: 'table/cell/anchor', - action: this.deviceActions.transition.view, - permission: 'fleet-ops view device', - resizable: true, - sortable: true, - filterable: true, - filterParam: 'query', - filterComponent: 'filter/string', - }, - { - label: 'Provider ID', - valuePath: 'device_id', - resizable: true, - sortable: true, - filterable: true, - filterParam: 'device_id', - filterComponent: 'filter/string', - }, - { - label: 'Vehicle', - valuePath: 'attached_to_name', - resizable: true, - sortable: true, - filterable: true, - filterParam: 'vehicle', - filterComponent: 'filter/model', - filterComponentPlaceholder: 'Select vehicle', - model: 'vehicle', - modelNamePath: 'displayName', - }, - { - label: 'Connection', - valuePath: 'connection_status', - cellComponent: 'table/cell/status', - resizable: true, - sortable: true, - filterable: true, - filterParam: 'connection_status', - filterComponent: 'filter/multi-option', - filterOptions: connectionStatusOptions, - filterOptionLabel: 'label', - filterOptionValue: 'value', - }, - { - label: 'Last Seen', - valuePath: 'lastOnlineAt', - sortParam: 'last_online_at', - resizable: true, - sortable: true, - filterable: true, - filterParam: 'last_online_at', - filterComponent: 'filter/date', - }, - { - label: 'Attachment', - valuePath: 'attachable_uuid', - hidden: true, - resizable: true, - sortable: false, - filterable: true, - filterParam: 'attachment_state', - filterComponent: 'filter/select', - filterOptions: [ - { label: 'Attached', value: 'attached' }, - { label: 'Unattached', value: 'unattached' }, - ], - filterOptionLabel: 'label', - filterOptionValue: 'value', - }, - { - label: this.intl.t('column.updated-at'), - valuePath: 'updatedAt', - sortParam: 'updated_at', - resizable: true, - sortable: true, - hidden: true, - filterable: true, - filterComponent: 'filter/date', - }, - { - label: '', - cellComponent: 'table/cell/dropdown', - ddButtonText: false, - ddButtonIcon: 'ellipsis-h', - ddButtonIconPrefix: 'fas', - ddMenuLabel: this.intl.t('common.resource-actions', { resource: this.intl.t('resource.device') }), - cellClassNames: 'overflow-visible align-middle', - wrapperClass: 'flex items-center justify-end mx-2', - sticky: 'right', - width: 60, - actions: [ - { - label: this.intl.t('common.view-resource', { resource: this.intl.t('resource.device') }), - fn: this.deviceActions.transition.view, - permission: 'fleet-ops view device', - }, - { - label: this.intl.t('common.edit-resource', { resource: this.intl.t('resource.device') }), - fn: this.deviceActions.transition.edit, - permission: 'fleet-ops update device', - }, - { - label: 'Attach or change vehicle', - fn: this.openAttachDeviceModal, - permission: 'fleet-ops update device', - }, - { - label: 'Review recent events', - fn: this.openDeviceEvents, - permission: 'fleet-ops view device-event', - }, - ], - sortable: false, - filterable: false, - resizable: false, - searchable: false, - }, - ]; + return buildDeviceTableColumns(this, { showProvider: false, deviceActionMode: 'panel', showDeviceStatus: false }); } @action refresh() { @@ -336,6 +246,8 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont this.vehicle = null; this.connection_status = null; this.device_id = null; + this.type = null; + this.serial_number = null; this.last_online_at = null; this.updated_at = null; this.page = 1; @@ -358,6 +270,86 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont }); } + @action hasAttachedVehicle(device) { + return Boolean(device?.attachable_uuid && this.isVehicleAttachment(device)); + } + + @action async viewAttachedVehicle(device) { + const vehicle = await this.resolveAttachedVehicle(device); + + if (!vehicle) { + return; + } + + return this.vehicleActions.panel.view(vehicle); + } + + @action async locateAttachedVehicle(device) { + const vehicle = await this.resolveAttachedVehicle(device); + + if (!vehicle) { + return; + } + + await this.transitionToLiveMap(); + await this.mapManager.waitForMap({ timeoutMs: 8000 }); + + this.mapManager.focusResource(vehicle, 16, { + paddingBottomRight: [300, 200], + moveend: () => { + this.vehicleActions.panel.view(vehicle, { closeOnTransition: true }); + }, + }); + } + + async transitionToLiveMap() { + try { + await this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index', { queryParams: { layout: 'map' } }); + } catch (_) { + // Keep locate actions usable even if the current transition is already in-flight. + } + } + + isVehicleAttachment(device) { + const attachableType = `${device?.attachable_type ?? ''}`.toLowerCase(); + + return !attachableType || attachableType.includes('vehicle'); + } + + async resolveAttachedVehicle(device) { + if (!device?.attachable_uuid) { + return null; + } + + if (!this.isVehicleAttachment(device)) { + return null; + } + + const attachable = device.attachable; + + if (attachable && typeof attachable.then !== 'function') { + return attachable; + } + + if (attachable && typeof attachable.then === 'function') { + return await attachable; + } + + const cachedVehicle = this.store.peekRecord('vehicle', device.attachable_uuid); + + if (cachedVehicle) { + return cachedVehicle; + } + + try { + return await this.store.findRecord('vehicle', device.attachable_uuid); + } catch (error) { + this.notifications.serverError(error); + } + + return null; + } + @action openDeviceEvents(device) { return this.hostRouter.transitionTo('console.fleet-ops.connectivity.telematics.details.events', this.telematic, { queryParams: { device_uuid: device.id }, diff --git a/addon/controllers/maintenance/equipment/index.js b/addon/controllers/maintenance/equipment/index.js index d0617db39..7c020cfb7 100644 --- a/addon/controllers/maintenance/equipment/index.js +++ b/addon/controllers/maintenance/equipment/index.js @@ -61,8 +61,7 @@ export default class MaintenanceEquipmentIndexController extends Controller { { label: this.intl.t('column.name'), valuePath: 'name', - cellComponent: 'table/cell/anchor', - cellClassNames: 'uppercase', + cellComponent: 'cell/equipment-identity', action: this.equipmentActions.transition.view, permission: 'fleet-ops view equipment', resizable: true, diff --git a/addon/controllers/maintenance/parts/index.js b/addon/controllers/maintenance/parts/index.js index e24b2da8a..3a44dfb5e 100644 --- a/addon/controllers/maintenance/parts/index.js +++ b/addon/controllers/maintenance/parts/index.js @@ -61,8 +61,7 @@ export default class MaintenancePartsIndexController extends Controller { { label: this.intl.t('column.name'), valuePath: 'name', - cellComponent: 'table/cell/anchor', - cellClassNames: 'uppercase', + cellComponent: 'cell/part-identity', action: this.partActions.transition.view, permission: 'fleet-ops view part', resizable: true, diff --git a/addon/controllers/management/drivers/index.js b/addon/controllers/management/drivers/index.js index 607e7414e..e35f9263d 100644 --- a/addon/controllers/management/drivers/index.js +++ b/addon/controllers/management/drivers/index.js @@ -1,7 +1,7 @@ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; +import { action, get } from '@ember/object'; export default class ManagementDriversIndexController extends Controller { @service driverActions; @@ -52,6 +52,22 @@ export default class ManagementDriversIndexController extends Controller { return Boolean(driver?.vehicle_uuid || driver?.vehicle?.id || driver?.vehicle?.uuid || driver?.vehicle_name); } + async resolveAssignedVehicle(vehicle) { + if (!vehicle) { + return null; + } + + if (typeof vehicle.then === 'function') { + return await vehicle; + } + + if (typeof vehicle.loadResource === 'function') { + return (await vehicle.loadResource()) ?? vehicle; + } + + return vehicle; + } + /** action buttons */ get actionButtons() { return [ @@ -102,7 +118,8 @@ export default class ManagementDriversIndexController extends Controller { sticky: true, label: this.intl.t('column.name'), valuePath: 'name', - cellComponent: 'table/cell/driver-name', + cellComponent: 'cell/driver-identity', + compact: true, permission: 'fleet-ops view driver', action: this.driverActions.transition.view, resizable: true, @@ -120,6 +137,69 @@ export default class ManagementDriversIndexController extends Controller { hidden: false, filterComponent: 'filter/string', }, + { + label: this.intl.t('column.phone'), + valuePath: 'phone', + cellComponent: 'table/cell/base', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'phone', + filterComponent: 'filter/string', + }, + { + label: this.intl.t('column.license'), + valuePath: 'drivers_license_number', + cellComponent: 'table/cell/base', + resizable: true, + sortable: true, + filterable: true, + filterComponent: 'filter/string', + }, + { + label: this.intl.t('column.vehicle'), + cellComponent: 'cell/vehicle-identity', + compact: true, + permission: 'fleet-ops view vehicle', + action: async (vehicle) => { + try { + const resolvedVehicle = await this.resolveAssignedVehicle(vehicle); + if (resolvedVehicle) this.vehicleActions.panel.view(resolvedVehicle); + } catch (err) { + this.notifications.serverError(err); + } + }, + valuePath: 'vehicle.display_name', + emptyText: '-', + showStatusBadge: true, + resourcePath: (driver) => { + const vehicle = get(driver, 'vehicle_assigned') ?? get(driver, 'vehicle'); + + if (vehicle) { + return vehicle; + } + + const vehicleName = get(driver, 'vehicle_name'); + + if (vehicleName) { + return { + display_name: vehicleName, + name: vehicleName, + status: 'assigned', + loadResource: () => driver.loadVehicle?.(), + }; + } + + return null; + }, + modelNamePath: 'display_name', + resizable: true, + filterable: true, + filterComponent: 'filter/model', + filterComponentPlaceholder: 'Select vehicle to filter by', + filterParam: 'vehicle', + model: 'vehicle', + }, { label: this.intl.t('column.internal-id'), valuePath: 'internal_id', @@ -150,27 +230,6 @@ export default class ManagementDriversIndexController extends Controller { filterParam: 'vendor', model: 'vendor', }, - { - label: this.intl.t('column.vehicle'), - cellComponent: 'table/cell/anchor', - permission: 'fleet-ops view vehicle', - onClick: async (driver) => { - try { - const vehicle = await driver.loadVehicle(); - if (vehicle) this.vehicleActions.panel.view(vehicle); - } catch (err) { - this.notifications.serverError(err); - } - }, - valuePath: 'vehicle.display_name', - modelNamePath: 'display_name', - resizable: true, - filterable: true, - filterComponent: 'filter/model', - filterComponentPlaceholder: 'Select vehicle to filter by', - filterParam: 'vehicle', - model: 'vehicle', - }, { label: this.intl.t('column.fleet'), cellComponent: 'table/cell/link-list', @@ -185,25 +244,6 @@ export default class ManagementDriversIndexController extends Controller { filterParam: 'fleet', model: 'fleet', }, - { - label: this.intl.t('column.license'), - valuePath: 'drivers_license_number', - cellComponent: 'table/cell/base', - resizable: true, - sortable: true, - filterable: true, - filterComponent: 'filter/string', - }, - { - label: this.intl.t('column.phone'), - valuePath: 'phone', - cellComponent: 'table/cell/base', - resizable: true, - sortable: true, - filterable: true, - filterParam: 'phone', - filterComponent: 'filter/string', - }, { label: this.intl.t('column.country'), valuePath: 'country', diff --git a/addon/controllers/management/fuel-reports/index.js b/addon/controllers/management/fuel-reports/index.js index 044131e8c..e2a0d56e8 100644 --- a/addon/controllers/management/fuel-reports/index.js +++ b/addon/controllers/management/fuel-reports/index.js @@ -1,4 +1,5 @@ import Controller from '@ember/controller'; +import { get } from '@ember/object'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; @@ -113,9 +114,37 @@ export default class ManagementFuelReportsIndexController extends Controller { { label: this.intl.t('column.driver'), valuePath: 'driver_name', - cellComponent: 'table/cell/anchor', + cellComponent: 'cell/driver-identity', permission: 'fleet-ops view driver', - onClick: this.fuelReportActions.viewDriver, + action: async (driver) => { + const resolvedDriver = driver?.loadResource ? await driver.loadResource() : driver; + + if (resolvedDriver) { + this.fuelReportActions.driverActions.panel.view(resolvedDriver); + } + }, + emptyText: '-', + showStatusBadge: true, + resourcePath: (fuelReport) => { + const driver = get(fuelReport, 'driver'); + + if (driver) { + return driver; + } + + const driverName = get(fuelReport, 'driver_name'); + + if (driverName) { + return { + id: get(fuelReport, 'driver_uuid'), + name: driverName, + display_name: driverName, + loadResource: () => fuelReport.loadDriver?.(), + }; + } + + return null; + }, resizable: true, sortable: true, filterable: true, @@ -127,9 +156,39 @@ export default class ManagementFuelReportsIndexController extends Controller { { label: this.intl.t('column.vehicle'), valuePath: 'vehicle_name', - cellComponent: 'table/cell/anchor', + cellComponent: 'cell/vehicle-identity', permission: 'fleet-ops view vehicle', - onClick: this.fuelReportActions.viewVehicle, + action: async (vehicle) => { + const resolvedVehicle = vehicle?.loadResource ? await vehicle.loadResource() : vehicle; + + if (resolvedVehicle) { + this.fuelReportActions.vehicleActions.panel.view(resolvedVehicle); + } + }, + emptyText: '-', + showStatusBadge: true, + resourcePath: (fuelReport) => { + const vehicle = get(fuelReport, 'vehicle'); + + if (vehicle) { + return vehicle; + } + + const vehicleName = get(fuelReport, 'vehicle_name'); + + if (vehicleName) { + return { + id: get(fuelReport, 'vehicle_uuid'), + displayName: vehicleName, + display_name: vehicleName, + name: vehicleName, + vehicle_number: get(fuelReport, 'vehicle_uuid'), + loadResource: () => fuelReport.loadVehicle?.(), + }; + } + + return null; + }, resizable: true, sortable: true, filterable: true, diff --git a/addon/controllers/management/issues/index.js b/addon/controllers/management/issues/index.js index f7c81b446..9a5a678af 100644 --- a/addon/controllers/management/issues/index.js +++ b/addon/controllers/management/issues/index.js @@ -1,4 +1,5 @@ import Controller from '@ember/controller'; +import { get } from '@ember/object'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import fleetOpsOptions from '../../../utils/fleet-ops-options'; @@ -162,9 +163,37 @@ export default class ManagementIssuesIndexController extends Controller { { label: this.intl.t('column.driver'), valuePath: 'driver_name', - cellComponent: 'table/cell/anchor', + cellComponent: 'cell/driver-identity', permission: 'fleet-ops view driver', - onClick: this.issueActions.viewDriver, + action: async (driver) => { + const resolvedDriver = driver?.loadResource ? await driver.loadResource() : driver; + + if (resolvedDriver) { + this.issueActions.driverActions.panel.view(resolvedDriver); + } + }, + emptyText: '-', + showStatusBadge: true, + resourcePath: (issue) => { + const driver = get(issue, 'driver'); + + if (driver) { + return driver; + } + + const driverName = get(issue, 'driver_name'); + + if (driverName) { + return { + id: get(issue, 'driver_uuid'), + name: driverName, + display_name: driverName, + loadResource: () => issue.loadDriver?.(), + }; + } + + return null; + }, resizable: true, sortable: true, filterable: true, @@ -176,9 +205,39 @@ export default class ManagementIssuesIndexController extends Controller { { label: this.intl.t('column.vehicle'), valuePath: 'vehicle_name', - cellComponent: 'table/cell/anchor', + cellComponent: 'cell/vehicle-identity', permission: 'fleet-ops view vehicle', - onClick: this.issueActions.viewVehicle, + action: async (vehicle) => { + const resolvedVehicle = vehicle?.loadResource ? await vehicle.loadResource() : vehicle; + + if (resolvedVehicle) { + this.issueActions.vehicleActions.panel.view(resolvedVehicle); + } + }, + emptyText: '-', + showStatusBadge: true, + resourcePath: (issue) => { + const vehicle = get(issue, 'vehicle'); + + if (vehicle) { + return vehicle; + } + + const vehicleName = get(issue, 'vehicle_name'); + + if (vehicleName) { + return { + id: get(issue, 'vehicle_uuid'), + displayName: vehicleName, + display_name: vehicleName, + name: vehicleName, + vehicle_number: get(issue, 'vehicle_uuid'), + loadResource: () => issue.loadVehicle?.(), + }; + } + + return null; + }, resizable: true, sortable: true, filterable: true, diff --git a/addon/controllers/management/vehicles/index.js b/addon/controllers/management/vehicles/index.js index e383140bd..f5191133c 100644 --- a/addon/controllers/management/vehicles/index.js +++ b/addon/controllers/management/vehicles/index.js @@ -1,5 +1,5 @@ import Controller from '@ember/controller'; -import { action } from '@ember/object'; +import { action, get } from '@ember/object'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; @@ -126,7 +126,8 @@ export default class ManagementVehiclesIndexController extends Controller { label: this.intl.t('column.name'), valuePath: 'displayName', photoPath: 'avatar_url', - cellComponent: 'table/cell/vehicle-name', + cellComponent: 'cell/vehicle-identity', + compact: true, permission: 'fleet-ops view vehicle', action: this.vehicleActions.transition.view, resizable: true, @@ -135,6 +136,7 @@ export default class ManagementVehiclesIndexController extends Controller { filterComponent: 'filter/string', filterParam: 'name', showOnlineIndicator: true, + showStatus: false, }, { label: this.intl.t('column.plate-number'), @@ -160,14 +162,39 @@ export default class ManagementVehiclesIndexController extends Controller { }, { label: this.intl.t('column.driver-assigned'), - cellComponent: 'table/cell/anchor', + cellComponent: 'cell/driver-identity', + compact: true, + assignedVehicleLabel: (_driver, vehicle) => get(vehicle, 'displayName') ?? get(vehicle, 'display_name') ?? get(vehicle, 'name'), permission: 'fleet-ops view driver', - action: async (vehicle) => { - const driver = await vehicle.loadDriver(); + action: async (driver) => { + const resolvedDriver = get(driver, 'id') ? driver : await driver?.loadResource?.(); - return this.driverActions.panel.view(driver); + if (resolvedDriver) { + return this.driverActions.panel.view(resolvedDriver); + } }, valuePath: 'driver_name', + emptyText: '-', + resourcePath: (vehicle) => { + const driver = get(vehicle, 'driver'); + + if (driver) { + return driver; + } + + const driverName = get(vehicle, 'driver_name'); + + if (driverName) { + return { + name: driverName, + display_name: driverName, + status: 'assigned', + loadResource: () => vehicle.loadDriver?.(), + }; + } + + return null; + }, resizable: true, filterable: true, filterComponent: 'filter/model', diff --git a/addon/routes/connectivity/devices/index.js b/addon/routes/connectivity/devices/index.js index d907193e5..a6f907c5b 100644 --- a/addon/routes/connectivity/devices/index.js +++ b/addon/routes/connectivity/devices/index.js @@ -14,6 +14,13 @@ export default class ConnectivityDevicesIndexRoute extends Route { status: { refreshModel: true }, attachment_state: { refreshModel: true }, telematic: { refreshModel: true }, + provider: { refreshModel: true }, + vehicle: { refreshModel: true }, + connection_status: { refreshModel: true }, + device_id: { refreshModel: true }, + type: { refreshModel: true }, + serial_number: { refreshModel: true }, + last_online_at: { refreshModel: true }, created_at: { refreshModel: true }, updated_at: { refreshModel: true }, }; diff --git a/addon/routes/connectivity/devices/index/details/vehicle.js b/addon/routes/connectivity/devices/index/details/vehicle.js index 6e6bbbf17..83795b5cb 100644 --- a/addon/routes/connectivity/devices/index/details/vehicle.js +++ b/addon/routes/connectivity/devices/index/details/vehicle.js @@ -1,7 +1,29 @@ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; export default class ConnectivityDevicesIndexDetailsVehicleRoute extends Route { - model() { - return this.modelFor('connectivity.devices.index.details'); + @service store; + + async model() { + const device = this.modelFor('connectivity.devices.index.details'); + const vehicle = await Promise.resolve(device?.attachable); + + if (!vehicle?.id) { + return { device, positions: [] }; + } + + let positions = []; + + try { + positions = await this.store.query('position', { + subject_uuid: vehicle.id, + sort: '-created_at', + limit: 5, + }); + } catch (_) { + positions = []; + } + + return { device, positions }; } } diff --git a/addon/routes/connectivity/events/details.js b/addon/routes/connectivity/events/details.js new file mode 100644 index 000000000..40e84d594 --- /dev/null +++ b/addon/routes/connectivity/events/details.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class ConnectivityEventsDetailsRoute extends Route {} diff --git a/addon/routes/connectivity/events/index.js b/addon/routes/connectivity/events/index.js index 822851cdb..289569c9d 100644 --- a/addon/routes/connectivity/events/index.js +++ b/addon/routes/connectivity/events/index.js @@ -9,8 +9,12 @@ export default class ConnectivityEventsIndexRoute extends Route { limit: { refreshModel: true }, sort: { refreshModel: true }, query: { refreshModel: true }, - name: { refreshModel: true }, - public_id: { refreshModel: true }, + telematic: { refreshModel: true }, + device: { refreshModel: true }, + event_type: { refreshModel: true }, + severity: { refreshModel: true }, + processed: { refreshModel: true }, + occurred_at: { refreshModel: true }, created_at: { refreshModel: true }, updated_at: { refreshModel: true }, }; diff --git a/addon/routes/connectivity/events/index/details.js b/addon/routes/connectivity/events/index/details.js deleted file mode 100644 index d2c163857..000000000 --- a/addon/routes/connectivity/events/index/details.js +++ /dev/null @@ -1,3 +0,0 @@ -import Route from '@ember/routing/route'; - -export default class ConnectivityEventsIndexDetailsRoute extends Route {} diff --git a/addon/routes/connectivity/sensors/index.js b/addon/routes/connectivity/sensors/index.js index bdadbac8d..d73515b59 100644 --- a/addon/routes/connectivity/sensors/index.js +++ b/addon/routes/connectivity/sensors/index.js @@ -9,8 +9,13 @@ export default class ConnectivitySensorsIndexRoute extends Route { limit: { refreshModel: true }, sort: { refreshModel: true }, query: { refreshModel: true }, - name: { refreshModel: true }, - public_id: { refreshModel: true }, + telematic: { refreshModel: true }, + device: { refreshModel: true }, + type: { refreshModel: true }, + status: { refreshModel: true }, + serial_number: { refreshModel: true }, + imei: { refreshModel: true }, + last_reading_at: { refreshModel: true }, created_at: { refreshModel: true }, updated_at: { refreshModel: true }, }; diff --git a/addon/routes/connectivity/telematics/details/devices.js b/addon/routes/connectivity/telematics/details/devices.js index dedc1e9d4..078aade89 100644 --- a/addon/routes/connectivity/telematics/details/devices.js +++ b/addon/routes/connectivity/telematics/details/devices.js @@ -15,6 +15,8 @@ export default class ConnectivityTelematicsDetailsDevicesRoute extends Route { vehicle: { refreshModel: true }, connection_status: { refreshModel: true }, device_id: { refreshModel: true }, + type: { refreshModel: true }, + serial_number: { refreshModel: true }, last_online_at: { refreshModel: true }, updated_at: { refreshModel: true }, }; diff --git a/addon/services/device-actions.js b/addon/services/device-actions.js index 892f9dc37..1292983f6 100644 --- a/addon/services/device-actions.js +++ b/addon/services/device-actions.js @@ -1,8 +1,10 @@ import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action'; import { action } from '@ember/object'; +import { isArray } from '@ember/array'; export default class DeviceActionsService extends ResourceActionService { @service fetch; + @service('universe/menu-service') menuService; constructor() { super(...arguments); @@ -37,13 +39,41 @@ export default class DeviceActionsService extends ResourceActionService { }); }, view: (device) => { + if (!device?.id) { + return this.notifications?.warning?.(this.intl.t('common.invalid-resource')); + } + + const registeredTabs = this.menuService?.getMenuItems?.('fleet-ops:component:device:details'); + return this.resourceContextPanel.open({ device, + header: 'device/panel-header', tabs: [ { + key: 'overview', + id: 'overview', label: this.intl.t('common.overview'), component: 'device/details', }, + { + key: 'vehicle', + id: 'vehicle', + label: this.intl.t('resource.vehicle'), + component: 'device/panel-tabs/vehicle', + }, + { + key: 'sensors', + id: 'sensors', + label: this.intl.t('resource.sensors'), + component: 'device/panel-tabs/sensors', + }, + { + key: 'events', + id: 'events', + label: this.intl.t('resource.device-events'), + component: 'device/panel-tabs/events', + }, + ...(isArray(registeredTabs) ? registeredTabs.filter((tab) => tab.component || tab.render) : []), ], }); }, diff --git a/addon/services/device-event-actions.js b/addon/services/device-event-actions.js index 436da07e4..f1dcb927d 100644 --- a/addon/services/device-event-actions.js +++ b/addon/services/device-event-actions.js @@ -11,7 +11,7 @@ export default class DeviceEventActionsService extends ResourceActionService { } transition = { - view: (deviceEvent) => this.transitionTo('connectivity.events.index.details', deviceEvent), + view: (deviceEvent) => this.transitionTo('connectivity.events.details', deviceEvent), }; panel = { diff --git a/addon/services/equipment-actions.js b/addon/services/equipment-actions.js index 29f404e73..9cc47f9de 100644 --- a/addon/services/equipment-actions.js +++ b/addon/services/equipment-actions.js @@ -9,6 +9,7 @@ export default class EquipmentActionsService extends ResourceActionService { super(...arguments); this.initialize('equipment', { defaultAttributes: { + status: 'available', currency: this.defaultCurrency, }, }); diff --git a/addon/services/sensor-actions.js b/addon/services/sensor-actions.js index 7ca4407c1..a1b7ae357 100644 --- a/addon/services/sensor-actions.js +++ b/addon/services/sensor-actions.js @@ -36,8 +36,11 @@ export default class SensorActionsService extends ResourceActionService { view: (sensor) => { return this.resourceContextPanel.open({ sensor, + header: 'sensor/panel-header', tabs: [ { + key: 'overview', + id: 'overview', label: this.intl.t('common.overview'), component: 'sensor/details', }, diff --git a/addon/services/service-rate-actions.js b/addon/services/service-rate-actions.js index b1b4537d8..2e24c098e 100644 --- a/addon/services/service-rate-actions.js +++ b/addon/services/service-rate-actions.js @@ -96,8 +96,15 @@ export default class ServiceRateActionsService extends ResourceActionService { }; @task *queryServiceRatesForOrder(order, params = {}) { + const payloadCoordinates = order.payload?.payloadCoordinates ?? []; + const routeCoordinates = payloadCoordinates.map((coordinate) => this.#serializeRouteCoordinate(coordinate)).filter(Boolean); + + if (routeCoordinates.length < 2) { + return []; + } + const queryParams = { - coordinates: order.payload.payloadCoordinates.join(';'), + coordinates: routeCoordinates.join(';'), facilitator: order.facilitator?.get('isIntegratedVendor') ? order.facilitator.get('public_id') : null, service_type: order.order_config?.get('key'), ...params, @@ -117,6 +124,37 @@ export default class ServiceRateActionsService extends ResourceActionService { } } + #serializeRouteCoordinate(coordinate) { + if (Array.isArray(coordinate)) { + const [longitude, latitude] = coordinate; + + if (!this.#isValidCoordinate(latitude) || !this.#isValidCoordinate(longitude)) { + return null; + } + + return `${latitude},${longitude}`; + } + + if (typeof coordinate === 'string') { + const parts = coordinate + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length !== 2 || !parts.every((part) => this.#isValidCoordinate(part))) { + return null; + } + + return parts.join(','); + } + + return null; + } + + #isValidCoordinate(value) { + return value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value)); + } + @task *getServiceQuotes(serviceRate, order) { if (order.payload.payloadCoordinates.length < 2) return; diff --git a/addon/services/vehicle-actions.js b/addon/services/vehicle-actions.js index 9fa208495..5d0922c57 100644 --- a/addon/services/vehicle-actions.js +++ b/addon/services/vehicle-actions.js @@ -66,6 +66,26 @@ export default class VehicleActionsService extends ResourceActionService { this.initialize('vehicle', { status: 'available' }); } + async resolveVehicleResource(vehicle) { + if (typeof vehicle?.then === 'function') { + return await vehicle; + } + + if (typeof vehicle?.loadResource === 'function') { + return (await vehicle.loadResource()) ?? vehicle; + } + + return vehicle; + } + + async reloadIndexResource(vehicle) { + if (vehicle?.meta?._index_resource) { + await vehicle.reload(); + } + + return vehicle; + } + transition = { view: (vehicle) => this.transitionTo('management.vehicles.index.details', vehicle), edit: (vehicle) => this.transitionTo('management.vehicles.index.edit', vehicle), @@ -87,9 +107,7 @@ export default class VehicleActionsService extends ResourceActionService { }); }, edit: async (vehicle, options = {}) => { - if (vehicle?.meta?._index_resource) { - await vehicle.reload(); - } + vehicle = await this.reloadIndexResource(await this.resolveVehicleResource(vehicle)); return this.resourceContextPanel.open({ content: 'vehicle/form', @@ -109,9 +127,7 @@ export default class VehicleActionsService extends ResourceActionService { }); }, view: async (vehicle, options = {}) => { - if (vehicle?.meta?._index_resource) { - await vehicle.reload(); - } + vehicle = await this.reloadIndexResource(await this.resolveVehicleResource(vehicle)); return this.resourceContextPanel.open({ vehicle, @@ -144,9 +160,7 @@ export default class VehicleActionsService extends ResourceActionService { }); }, edit: async (vehicle, options = {}, saveOptions = {}) => { - if (vehicle?.meta?._index_resource) { - await vehicle.reload(); - } + vehicle = await this.reloadIndexResource(await this.resolveVehicleResource(vehicle)); return this.modalsManager.show('modals/resource', { resource: vehicle, @@ -159,9 +173,7 @@ export default class VehicleActionsService extends ResourceActionService { }); }, view: async (vehicle, options = {}) => { - if (vehicle?.meta?._index_resource) { - await vehicle.reload(); - } + vehicle = await this.reloadIndexResource(await this.resolveVehicleResource(vehicle)); return this.modalsManager.show('modals/resource', { resource: vehicle, diff --git a/addon/services/vendor-actions.js b/addon/services/vendor-actions.js index 9a56a96c6..e57eb89a4 100644 --- a/addon/services/vendor-actions.js +++ b/addon/services/vendor-actions.js @@ -43,8 +43,11 @@ export default class VendorActionsService extends ResourceActionService { view: (vendor) => { return this.resourceContextPanel.open({ vendor, + header: 'vendor/panel-header', tabs: [ { + key: 'overview', + id: 'overview', label: this.intl.t('common.overview'), component: 'vendor/details', }, diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index 85307444b..2a4966c54 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -294,6 +294,14 @@ body[data-theme='dark'] .fleetops-connectivity-kpi-accent-rose .fleetops-connect color: #fde047; } +.fleetops-device-status-badge.status-badge > span { + padding: 0 0.25rem !important; + border-radius: 0.125rem !important; + font-size: 0.5625rem !important; + line-height: 0.75rem !important; + font-weight: 700; +} + .fleetops-provider-connections-panel .table-wrapper table tbody tr td, .fleetops-provider-connections-panel .next-table-wrapper table tbody tr td { vertical-align: top; diff --git a/addon/templates/connectivity/devices/index/details/vehicle.hbs b/addon/templates/connectivity/devices/index/details/vehicle.hbs index c129b5543..c7ad696e3 100644 --- a/addon/templates/connectivity/devices/index/details/vehicle.hbs +++ b/addon/templates/connectivity/devices/index/details/vehicle.hbs @@ -7,9 +7,12 @@ {{#if this.hasVehicle}}
- {{#if this.vehicle.id}} + {{#if this.canOpenVehicle}}
@@ -70,6 +73,66 @@
+ +
+
+
+

Latest Positions

+

Recent vehicle coordinates reported through this device attachment.

+
+ {{#if this.canOpenVehicle}} +
+ + {{#if this.hasPositions}} +
+ {{#each this.positions as |position index|}} +
+
+
+ {{add index 1}} +
+
+
{{n-a (format-date-fns position.created_at "dd MMM yyyy, HH:mm")}}
+
{{n-a (point-coordinates position.coordinates)}}
+
+
+
+
+
Speed
+
{{n-a position.speed}}
+
+
+
Heading
+
{{n-a position.heading position.bearing}}
+
+
+
Altitude
+
{{n-a position.altitude}}
+
+
+
+ {{/each}} +
+ +
+ +
+ {{else}} +
+ +
+ {{/if}} +
{{else}} {{outlet}} - \ No newline at end of file + diff --git a/addon/templates/settings/notifications.hbs b/addon/templates/settings/notifications.hbs index 6672225c1..b8d8173a1 100644 --- a/addon/templates/settings/notifications.hbs +++ b/addon/templates/settings/notifications.hbs @@ -19,6 +19,7 @@
- \ No newline at end of file + diff --git a/addon/templates/settings/routing.hbs b/addon/templates/settings/routing.hbs index bcc136e3c..0c08c6998 100644 --- a/addon/templates/settings/routing.hbs +++ b/addon/templates/settings/routing.hbs @@ -64,6 +64,7 @@
{ + const resolvedVehicle = vehicle?.loadResource ? ((await vehicle.loadResource()) ?? vehicle) : vehicle; + + if (resolvedVehicle?.id && controller.vehicleActions?.panel?.view) { + return controller.vehicleActions.panel.view(resolvedVehicle); + } + }, + compact: true, + permission: 'fleet-ops view vehicle', + showStatusBadge: true, + emptyText: '-', + resourcePath: (device) => { + const attachableType = `${device?.attachable_type ?? ''}`.toLowerCase(); + + if (!device?.attachable_uuid || (attachableType && !attachableType.includes('vehicle'))) { + return null; + } + + return ( + device.attachable ?? { + id: device.attachable_uuid, + displayName: device.attached_to_name, + display_name: device.attached_to_name, + name: device.attached_to_name, + public_id: device.attachable_uuid, + vehicle_number: device.plate_number ?? device.call_sign ?? device.vehicle_number ?? device.attachable_uuid, + loadResource: () => controller.resolveAttachedVehicle?.(device), + } + ); + }, + resizable: true, + sortable: false, + filterable: true, + filterParam: 'vehicle', + filterComponent: 'filter/model', + filterComponentPlaceholder: 'Select vehicle', + model: 'vehicle', + modelNamePath: 'displayName', + }; + + if (showProvider) { + columns.push({ + label: 'Telematic Provider', + valuePath: 'telematic_name', + cellComponent: 'cell/telematic-provider', + compact: true, + action: controller.openTelematic, + permission: 'fleet-ops view telematic', + resizable: true, + sortable: false, + filterable: true, + filterParam: 'telematic', + filterComponent: 'filter/model', + filterComponentPlaceholder: 'Select telematic', + model: 'telematic', + }); + columns.push(vehicleColumn, providerIdColumn); + } else { + columns.push(providerIdColumn, vehicleColumn); + } + + columns.push( + { + label: 'Sensors', + valuePath: 'sensors_count', + resizable: true, + sortable: false, + filterable: false, + }, + { + label: 'Last Seen', + valuePath: 'lastOnlineAt', + sortParam: 'last_online_at', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'last_online_at', + filterComponent: 'filter/date', + }, + { + label: 'Provider', + valuePath: 'provider', + hidden: true, + resizable: true, + sortable: true, + filterable: true, + filterParam: 'provider', + filterComponent: 'filter/string', + }, + { + label: 'Type', + valuePath: 'type', + hidden: true, + resizable: true, + sortable: true, + filterable: true, + filterParam: 'type', + filterComponent: 'filter/multi-option', + filterOptions: controller.deviceTypeOptions, + }, + { + label: 'Serial Number', + valuePath: 'serial_number', + hidden: true, + resizable: true, + sortable: true, + filterable: true, + filterParam: 'serial_number', + filterComponent: 'filter/string', + }, + { + label: controller.intl.t('column.status'), + valuePath: 'status', + cellComponent: 'table/cell/status', + hidden: true, + resizable: true, + sortable: true, + filterable: true, + filterParam: 'status', + filterComponent: 'filter/multi-option', + filterOptions: controller.deviceStatusOptions, + }, + { + label: 'Attachment', + valuePath: 'attachable_uuid', + hidden: true, + resizable: true, + sortable: false, + filterable: true, + filterParam: 'attachment_state', + filterComponent: 'filter/select', + filterOptions: attachmentStateOptions, + filterOptionLabel: 'label', + filterOptionValue: 'value', + }, + { + label: controller.intl.t('column.updated-at'), + valuePath: 'updatedAt', + sortParam: 'updated_at', + resizable: true, + sortable: true, + hidden: true, + filterable: true, + filterComponent: 'filter/date', + } + ); + + if (showActions) { + columns.push({ + label: '', + cellComponent: 'table/cell/dropdown', + ddButtonText: false, + ddButtonIcon: 'ellipsis-h', + ddButtonIconPrefix: 'fas', + ddMenuLabel: controller.intl.t('common.resource-actions', { resource: controller.intl.t('resource.device') }), + cellClassNames: 'overflow-visible align-middle', + wrapperClass: 'flex items-center justify-end mx-2', + sticky: 'right', + width: 60, + actions: [ + { + label: controller.intl.t('common.view-resource', { resource: controller.intl.t('resource.device') }), + fn: viewDevice, + permission: 'fleet-ops view device', + }, + { + label: controller.intl.t('common.edit-resource', { resource: controller.intl.t('resource.device') }), + fn: editDevice, + permission: 'fleet-ops update device', + }, + { + label: 'Attach or change vehicle', + fn: controller.openAttachDeviceModal ?? controller.deviceActions.attachToVehicle, + permission: 'fleet-ops update device', + }, + { + label: controller.intl.t('device.actions.detach-from-vehicle'), + fn: controller.deviceActions.detachFromVehicle, + permission: 'fleet-ops update device', + isVisible: controller.hasAttachedVehicle, + }, + { + separator: true, + isVisible: controller.hasAttachedVehicle, + }, + { + label: 'View attached vehicle', + fn: controller.viewAttachedVehicle, + permission: 'fleet-ops view vehicle', + isVisible: controller.hasAttachedVehicle, + }, + { + label: 'Locate attached vehicle on map', + fn: controller.locateAttachedVehicle, + permission: 'fleet-ops view vehicle', + isVisible: controller.hasAttachedVehicle, + }, + ...(controller.openDeviceEvents + ? [ + { + separator: true, + }, + { + label: 'Review recent events', + fn: controller.openDeviceEvents, + permission: 'fleet-ops view device-event', + }, + ] + : []), + ], + sortable: false, + filterable: false, + resizable: false, + searchable: false, + }); + } + + return columns; +} diff --git a/addon/utils/identity-cell-resource.js b/addon/utils/identity-cell-resource.js new file mode 100644 index 000000000..f9afc12fd --- /dev/null +++ b/addon/utils/identity-cell-resource.js @@ -0,0 +1,20 @@ +import { get } from '@ember/object'; + +export function resolveIdentityCellResource(args) { + const column = args.column ?? {}; + const resourcePath = column.resourcePath; + + if (typeof resourcePath === 'function') { + return resourcePath(args.row, args.value, column) ?? null; + } + + if (typeof resourcePath === 'string') { + return get(args.row, resourcePath) ?? null; + } + + if (args.value && typeof args.value === 'object') { + return args.value; + } + + return args.row; +} diff --git a/addon/utils/map-drawer-dropdown-position.js b/addon/utils/map-drawer-dropdown-position.js new file mode 100644 index 000000000..6750f4c7a --- /dev/null +++ b/addon/utils/map-drawer-dropdown-position.js @@ -0,0 +1,47 @@ +export default function calculateMapDrawerDropdownPosition(trigger, content) { + const dropdownRoot = trigger?.closest?.('.ember-basic-dropdown'); + const drawerPanel = trigger?.closest?.('.next-drawer-panel'); + + if (!trigger || !dropdownRoot) { + return { style: {} }; + } + + const triggerRect = trigger.getBoundingClientRect(); + const rootRect = dropdownRoot.getBoundingClientRect(); + const drawerRect = drawerPanel?.getBoundingClientRect?.(); + const contentRect = content?.getBoundingClientRect?.(); + const contentWidth = contentRect?.width || 224; + // const contentHeight = contentRect?.height || 240; + const gap = 6; + + const viewportRect = { + top: 0, + right: window.innerWidth, + bottom: window.innerHeight, + left: 0, + }; + const boundaryRect = drawerRect ?? viewportRect; + const minLeft = boundaryRect.left - rootRect.left + gap; + const maxLeft = boundaryRect.right - rootRect.left - contentWidth - gap; + // const minTop = boundaryRect.top - rootRect.top + gap; + // const maxTop = boundaryRect.bottom - rootRect.top - contentHeight - gap; + + const preferredLeft = triggerRect.left - rootRect.left - contentWidth - gap; + // const preferredTop = triggerRect.top - rootRect.top; + const left = clamp(preferredLeft, minLeft, Math.max(minLeft, maxLeft)); + const top = 0; //clamp(preferredTop, minTop, Math.max(minTop, maxTop)); + + return { + style: { + position: 'absolute', + left: `${left}px`, + top: `${top}px`, + marginTop: '0px', + zIndex: '10000', + }, + }; +} + +function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} diff --git a/addon/utils/trackable-option.js b/addon/utils/trackable-option.js new file mode 100644 index 000000000..953266348 --- /dev/null +++ b/addon/utils/trackable-option.js @@ -0,0 +1,93 @@ +import getModelName from '@fleetbase/ember-core/utils/get-model-name'; +import { isArray } from '@ember/array'; +import { get } from '@ember/object'; + +export function buildTrackableOption(resource, modelName = getTrackableModelName(resource)) { + const deviceLabel = trackableDeviceLabel(resource, modelName); + const secondaryLabel = trackableSecondaryLabel(resource, modelName); + const primaryLabel = getValue(resource, 'name') ?? getValue(resource, 'displayName') ?? getValue(resource, 'display_name') ?? getValue(resource, 'public_id'); + const trackableSearchText = [ + primaryLabel, + secondaryLabel, + getValue(resource, 'public_id'), + getValue(resource, 'internal_id'), + getValue(resource, 'serial_number'), + getValue(resource, 'plate_number'), + getValue(resource, 'email'), + getValue(resource, 'vin'), + getValue(resource, 'call_sign'), + deviceLabel, + ] + .filter(Boolean) + .join(' '); + + return { + resource, + modelName, + primaryLabel, + secondaryLabel, + deviceLabel, + trackableSearchText, + }; +} + +export function trackableSecondaryLabel(resource, modelName = getTrackableModelName(resource)) { + if (modelName === 'driver') { + return getValue(resource, 'email') ?? getValue(resource, 'phone') ?? getValue(resource, 'vehicle_name') ?? getValue(resource, 'internal_id') ?? getValue(resource, 'public_id'); + } + + return getValue(resource, 'plate_number') ?? getValue(resource, 'serial_number') ?? getValue(resource, 'internal_id') ?? getValue(resource, 'vin') ?? getValue(resource, 'public_id'); +} + +export function trackableDeviceLabel(resource, modelName = getTrackableModelName(resource)) { + const devices = trackableDevices(resource, modelName); + const labels = devices.map(deviceLabel).filter(Boolean); + + if (labels.length === 0) { + return null; + } + + return labels.length === 1 ? `Device: ${labels[0]}` : `Devices: ${labels.join(', ')}`; +} + +export function trackableDevices(resource, modelName = getTrackableModelName(resource)) { + const devices = modelName === 'driver' ? getValue(resource, 'vehicle.devices') : getValue(resource, 'devices'); + + if (!devices) { + return []; + } + + if (isArray(devices)) { + return devices; + } + + if (typeof devices.toArray === 'function') { + return devices.toArray(); + } + + return []; +} + +export function deviceLabel(device) { + return ( + getValue(device, 'displayName') ?? + getValue(device, 'display_name') ?? + getValue(device, 'name') ?? + getValue(device, 'device_id') ?? + getValue(device, 'serial_number') ?? + getValue(device, 'imei') ?? + getValue(device, 'public_id') + ); +} + +function getTrackableModelName(resource) { + return getModelName(resource) ?? getValue(resource, 'modelName') ?? resource?.constructor?.modelName; +} + +function getValue(object, path) { + if (!object) { + return undefined; + } + + return get(object, path); +} diff --git a/app/components/cell/attached-vehicle.js b/app/components/cell/attached-vehicle.js new file mode 100644 index 000000000..c315ca6eb --- /dev/null +++ b/app/components/cell/attached-vehicle.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/cell/attached-vehicle'; diff --git a/app/components/cell/device-identity.js b/app/components/cell/device-identity.js new file mode 100644 index 000000000..6fef0dac7 --- /dev/null +++ b/app/components/cell/device-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/cell/device-identity'; diff --git a/app/components/cell/driver-identity.js b/app/components/cell/driver-identity.js new file mode 100644 index 000000000..b5d3ccad4 --- /dev/null +++ b/app/components/cell/driver-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/cell/driver-identity'; diff --git a/app/components/cell/equipment-identity.js b/app/components/cell/equipment-identity.js new file mode 100644 index 000000000..1d48e90f8 --- /dev/null +++ b/app/components/cell/equipment-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/cell/equipment-identity'; diff --git a/app/components/cell/part-identity.js b/app/components/cell/part-identity.js new file mode 100644 index 000000000..6b6d976bb --- /dev/null +++ b/app/components/cell/part-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/cell/part-identity'; diff --git a/app/components/cell/telematic-device.js b/app/components/cell/telematic-device.js new file mode 100644 index 000000000..957584644 --- /dev/null +++ b/app/components/cell/telematic-device.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/cell/telematic-device'; diff --git a/app/components/cell/vehicle-identity.js b/app/components/cell/vehicle-identity.js new file mode 100644 index 000000000..75d5c291b --- /dev/null +++ b/app/components/cell/vehicle-identity.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/cell/vehicle-identity'; diff --git a/app/components/device/panel-tabs/events.hbs b/app/components/device/panel-tabs/events.hbs new file mode 100644 index 000000000..d4534e9e4 --- /dev/null +++ b/app/components/device/panel-tabs/events.hbs @@ -0,0 +1,30 @@ +
+
+
+

Telemetry Events

+

Recent device events, warning signals, and processing state.

+
+
+ + +
diff --git a/app/components/device/panel-tabs/events.js b/app/components/device/panel-tabs/events.js new file mode 100644 index 000000000..fc96a5487 --- /dev/null +++ b/app/components/device/panel-tabs/events.js @@ -0,0 +1,128 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; + +const severityOptions = [ + { label: 'Info', value: 'info' }, + { label: 'Warning', value: 'warning' }, + { label: 'Error', value: 'error' }, + { label: 'Critical', value: 'critical' }, + { label: 'High', value: 'high' }, +]; + +function toRecentList(records) { + const list = Array.from(records ?? []); + + list.meta = { + current_page: 1, + last_page: 1, + per_page: list.length, + total: list.length, + from: list.length > 0 ? 1 : 0, + to: list.length, + }; + + return list; +} + +export default class DevicePanelTabsEventsComponent extends Component { + @service deviceEventActions; + @service store; + + @tracked events = toRecentList(); + + constructor() { + super(...arguments); + this.loadEvents.perform(); + } + + get device() { + return this.args.resource ?? this.args.model; + } + + get columns() { + return [ + { + sticky: true, + label: 'Event', + valuePath: 'event_type', + cellComponent: 'table/cell/anchor', + action: this.deviceEventActions.panel?.view ?? this.deviceEventActions.transition.view, + permission: 'fleet-ops view device-event', + resizable: true, + }, + { + label: 'Severity', + valuePath: 'severity', + cellComponent: 'table/cell/status', + filterOptions: severityOptions, + resizable: true, + }, + { + label: 'Message', + valuePath: 'message', + resizable: true, + }, + { + label: 'Code', + valuePath: 'code', + resizable: true, + }, + { + label: 'Processed', + valuePath: 'processedAt', + sortParam: 'processed_at', + resizable: true, + }, + { + label: 'Occurred', + valuePath: 'occurredAt', + sortParam: 'occurred_at', + resizable: true, + }, + { + label: '', + cellComponent: 'table/cell/dropdown', + ddButtonText: false, + ddButtonIcon: 'ellipsis-h', + ddButtonIconPrefix: 'fas', + wrapperClass: 'flex items-center justify-end mx-2', + sticky: 'right', + width: 60, + actions: [ + { + label: 'View event', + fn: this.deviceEventActions.panel?.view ?? this.deviceEventActions.transition.view, + permission: 'fleet-ops view device-event', + }, + { + label: 'Mark processed', + fn: this.markProcessed, + permission: 'fleet-ops update device-event', + }, + ], + }, + ]; + } + + @action async markProcessed(deviceEvent) { + await this.deviceEventActions.markProcessed(deviceEvent); + await this.loadEvents.perform(); + } + + @action refreshEvents() { + return this.loadEvents.perform(); + } + + @task *loadEvents() { + if (!this.device?.id) { + this.events = toRecentList(); + return; + } + + const events = yield this.store.query('device-event', { device_uuid: this.device.id, limit: 10, sort: '-created_at' }); + this.events = toRecentList(events); + } +} diff --git a/app/components/device/panel-tabs/sensors.hbs b/app/components/device/panel-tabs/sensors.hbs new file mode 100644 index 000000000..79f991492 --- /dev/null +++ b/app/components/device/panel-tabs/sensors.hbs @@ -0,0 +1,30 @@ +
+
+
+

Sensor Inventory

+

Recent sensor readings and health for this device.

+
+
+ + +
diff --git a/app/components/device/panel-tabs/sensors.js b/app/components/device/panel-tabs/sensors.js new file mode 100644 index 000000000..25c4e790c --- /dev/null +++ b/app/components/device/panel-tabs/sensors.js @@ -0,0 +1,93 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; + +function toRecentList(records) { + const list = Array.from(records ?? []); + + list.meta = { + current_page: 1, + last_page: 1, + per_page: list.length, + total: list.length, + from: list.length > 0 ? 1 : 0, + to: list.length, + }; + + return list; +} + +export default class DevicePanelTabsSensorsComponent extends Component { + @service sensorActions; + @service store; + + @tracked sensors = toRecentList(); + + constructor() { + super(...arguments); + this.loadSensors.perform(); + } + + get device() { + return this.args.resource ?? this.args.model; + } + + get columns() { + return [ + { + sticky: true, + label: 'Sensor', + valuePath: 'name', + cellComponent: 'table/cell/anchor', + action: this.sensorActions.panel?.view ?? this.sensorActions.transition.view, + permission: 'fleet-ops view sensor', + resizable: true, + }, + { + label: 'Type', + valuePath: 'type', + cellComponent: 'table/cell/base', + humanize: true, + resizable: true, + }, + { + label: 'Value', + valuePath: 'last_value', + resizable: true, + }, + { + label: 'Unit', + valuePath: 'unit', + resizable: true, + }, + { + label: 'Status', + valuePath: 'status', + cellComponent: 'table/cell/status', + resizable: true, + }, + { + label: 'Last Reading', + valuePath: 'lastReadingAt', + sortParam: 'last_reading_at', + resizable: true, + }, + ]; + } + + @action refreshSensors() { + return this.loadSensors.perform(); + } + + @task *loadSensors() { + if (!this.device?.id) { + this.sensors = toRecentList(); + return; + } + + const sensors = yield this.store.query('sensor', { device_uuid: this.device.id, limit: 10, sort: '-updated_at' }); + this.sensors = toRecentList(sensors); + } +} diff --git a/app/components/device/panel-tabs/vehicle.hbs b/app/components/device/panel-tabs/vehicle.hbs new file mode 100644 index 000000000..292b514a8 --- /dev/null +++ b/app/components/device/panel-tabs/vehicle.hbs @@ -0,0 +1,68 @@ +
+
+
+

Vehicle Attachment

+

Fleet context for telemetry from this device.

+
+
+ {{#if this.hasVehicle}} + {{#if this.canOpenVehicle}} +
+
+ + {{#if this.hasVehicle}} +
+
+ {{this.vehicleName}} +
+
+

{{this.vehicleName}}

+ {{#if this.vehicleStatus}} + {{smart-humanize this.vehicleStatus}} + {{/if}} +
+
{{n-a this.vehicleSubtitle}}
+
+
+
Driver
+
{{n-a this.vehicleDriverName}}
+
+
+
Attached Device
+
{{n-a this.device.displayName this.device.name}}
+
+
+
Device Last Seen
+
{{n-a (format-date-fns this.device.last_online_at "dd MMM yyyy, HH:mm")}}
+
+
+
+
+
+ {{else}} + + {{/if}} +
diff --git a/app/components/device/panel-tabs/vehicle.js b/app/components/device/panel-tabs/vehicle.js new file mode 100644 index 000000000..49c1e8d28 --- /dev/null +++ b/app/components/device/panel-tabs/vehicle.js @@ -0,0 +1,90 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class DevicePanelTabsVehicleComponent extends Component { + @service deviceActions; + @service hostRouter; + @service mapManager; + @service vehicleActions; + + get device() { + return this.args.resource ?? this.args.model; + } + + get vehicle() { + return this.device?.attachable; + } + + get vehicleName() { + return this.device?.attached_to_name ?? this.vehicle?.displayName ?? this.vehicle?.display_name ?? this.vehicle?.name; + } + + get vehicleSubtitle() { + return this.vehicle?.plate_number ?? this.vehicle?.call_sign ?? this.vehicle?.vin ?? this.vehicle?.public_id ?? this.device?.attachable_uuid; + } + + get vehiclePhotoUrl() { + return this.vehicle?.photo_url ?? this.vehicle?.avatar_url; + } + + get vehicleStatus() { + return this.vehicle?.status ?? (this.vehicle?.online ? 'online' : null); + } + + get vehicleDriverName() { + return this.vehicle?.driver?.displayName ?? this.vehicle?.driver?.display_name ?? this.vehicle?.driver?.name ?? this.vehicle?.driver_name; + } + + get hasVehicle() { + return Boolean(this.vehicleName || this.device?.attachable_uuid); + } + + get canOpenVehicle() { + return Boolean(this.vehicle?.id); + } + + get canLocateVehicle() { + return Boolean(this.vehicle?.id && (this.vehicle?.location || this.vehicle?.last_position)); + } + + @action attachToVehicle() { + return this.deviceActions.attachToVehicle(this.device, { callback: () => this.device?.reload?.() }); + } + + @action detachFromVehicle() { + return this.deviceActions.detachFromVehicle(this.device, { callback: () => this.device?.reload?.() }); + } + + @action openVehicle() { + if (this.vehicle?.id) { + return this.vehicleActions.panel?.view + ? this.vehicleActions.panel.view(this.vehicle) + : this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index.details', this.vehicle); + } + } + + @action async locateVehicle() { + if (!this.vehicle?.id) { + return; + } + + await this.transitionToLiveMap(); + await this.mapManager.waitForMap({ timeoutMs: 8000 }); + + this.mapManager.focusResource(this.vehicle, 16, { + paddingBottomRight: [300, 200], + moveend: () => { + this.vehicleActions.panel?.view?.(this.vehicle, { closeOnTransition: true }); + }, + }); + } + + async transitionToLiveMap() { + try { + await this.hostRouter.transitionTo('console.fleet-ops.operations.orders.index', { queryParams: { layout: 'map' } }); + } catch (_) { + // Keep locate usable if another transition is already active. + } + } +} diff --git a/app/components/table/cell/dropdown.js b/app/components/table/cell/dropdown.js new file mode 100644 index 000000000..30911cf87 --- /dev/null +++ b/app/components/table/cell/dropdown.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/table/cell/dropdown'; diff --git a/app/components/table/cell/dropdown/action-item.js b/app/components/table/cell/dropdown/action-item.js new file mode 100644 index 000000000..5213cee52 --- /dev/null +++ b/app/components/table/cell/dropdown/action-item.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/table/cell/dropdown/action-item'; diff --git a/app/components/vendor/panel-header.js b/app/components/vendor/panel-header.js new file mode 100644 index 000000000..8173046c6 --- /dev/null +++ b/app/components/vendor/panel-header.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/vendor/panel-header'; diff --git a/app/controllers/connectivity/events/index/details.js b/app/controllers/connectivity/events/details.js similarity index 64% rename from app/controllers/connectivity/events/index/details.js rename to app/controllers/connectivity/events/details.js index c9c5b5910..e77029c39 100644 --- a/app/controllers/connectivity/events/index/details.js +++ b/app/controllers/connectivity/events/details.js @@ -1 +1 @@ -export { default } from '@fleetbase/fleetops-engine/controllers/connectivity/events/index/details'; +export { default } from '@fleetbase/fleetops-engine/controllers/connectivity/events/details'; diff --git a/app/routes/connectivity/events/index/details.js b/app/routes/connectivity/events/details.js similarity index 67% rename from app/routes/connectivity/events/index/details.js rename to app/routes/connectivity/events/details.js index fcd6d5f56..cdb3027a0 100644 --- a/app/routes/connectivity/events/index/details.js +++ b/app/routes/connectivity/events/details.js @@ -1 +1 @@ -export { default } from '@fleetbase/fleetops-engine/routes/connectivity/events/index/details'; +export { default } from '@fleetbase/fleetops-engine/routes/connectivity/events/details'; diff --git a/app/templates/connectivity/events/index/details.js b/app/templates/connectivity/events/details.js similarity index 65% rename from app/templates/connectivity/events/index/details.js rename to app/templates/connectivity/events/details.js index c1b6f602e..f11349200 100644 --- a/app/templates/connectivity/events/index/details.js +++ b/app/templates/connectivity/events/details.js @@ -1 +1 @@ -export { default } from '@fleetbase/fleetops-engine/templates/connectivity/events/index/details'; +export { default } from '@fleetbase/fleetops-engine/templates/connectivity/events/details'; diff --git a/app/utils/map-drawer-dropdown-position.js b/app/utils/map-drawer-dropdown-position.js new file mode 100644 index 000000000..54176ffb3 --- /dev/null +++ b/app/utils/map-drawer-dropdown-position.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/utils/map-drawer-dropdown-position'; diff --git a/app/utils/trackable-option.js b/app/utils/trackable-option.js new file mode 100644 index 000000000..7ab0b21da --- /dev/null +++ b/app/utils/trackable-option.js @@ -0,0 +1 @@ +export * from '@fleetbase/fleetops-engine/utils/trackable-option'; diff --git a/composer.json b/composer.json index c7fda30f2..7de7141fa 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/fleetops-api", - "version": "0.6.54", + "version": "0.6.55", "description": "Fleet & Transport Management Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index 90bf0e78c..022da44f4 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Fleet-Ops", - "version": "0.6.54", + "version": "0.6.55", "description": "Fleet & Transport Management Extension for Fleetbase", "repository": "https://github.com/fleetbase/fleetops", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 607efbf4c..8b4695a5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-engine", - "version": "0.6.54", + "version": "0.6.55", "description": "Fleet & Transport Management Extension for Fleetbase", "fleetbase": { "route": "fleet-ops" @@ -42,9 +42,9 @@ }, "dependencies": { "@babel/core": "^7.23.2", - "@fleetbase/ember-core": "^0.3.22", - "@fleetbase/ember-ui": "^0.3.36", - "@fleetbase/fleetops-data": "^0.1.39", + "@fleetbase/ember-core": "^0.3.23", + "@fleetbase/ember-ui": "^0.3.37", + "@fleetbase/fleetops-data": "^0.1.40", "@fleetbase/leaflet-routing-machine": "^3.2.17", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63edc7d98..b4e4a094f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^7.23.2 version: 7.29.0 '@fleetbase/ember-core': - specifier: ^0.3.22 - version: 0.3.22(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) + specifier: ^0.3.23 + version: 0.3.23(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) '@fleetbase/ember-ui': - specifier: ^0.3.36 - version: 0.3.36(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0) + specifier: ^0.3.37 + version: 0.3.37(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0) '@fleetbase/fleetops-data': - specifier: ^0.1.39 - version: 0.1.39(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) + specifier: ^0.1.40 + version: 0.1.40(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) '@fleetbase/leaflet-routing-machine': specifier: ^3.2.17 version: 3.2.17 @@ -1381,16 +1381,16 @@ packages: peerDependencies: ember-source: '>= 4.0.0' - '@fleetbase/ember-core@0.3.22': - resolution: {integrity: sha512-tCYgxJoemUXgjsUztzeJZSwU2ejDp5x2WYI3FsBO55AlcC+mfWm0FsYtuPd9xd9PoO1jZ3T3VyvQQ06HRPtqWw==} + '@fleetbase/ember-core@0.3.23': + resolution: {integrity: sha512-geUudr6UWXpvRLfIhlEqjTn3YaZ8Bu+LmhigiHM8OVSmmQtCKn9ZiHHjhlX4hAxixf0+9/82t6/qvPRB8LlX4g==} engines: {node: '>= 18'} - '@fleetbase/ember-ui@0.3.36': - resolution: {integrity: sha512-rwM38fn5jiUr7sumUjxhe1BsCY3PJqdC5J3qRwKTLbUUvGZ8y+7k++qJqFpvdU2Ci2Uds2ePVtRUqcpYBfLgDg==} + '@fleetbase/ember-ui@0.3.37': + resolution: {integrity: sha512-k3f7MlUs0ooeOWisntxBEP0sD4hNPmTWtjnNxA0fMM2kXSp3JeRHb54Ix5/JZOB7VhCArrZnUKtcEqn/pyQeJQ==} engines: {node: '>= 18'} - '@fleetbase/fleetops-data@0.1.39': - resolution: {integrity: sha512-Cxa6Oy3QSHvC/82nF3YFD7dc32Hl1ehTakn7ZPNOZuFj0jM3Q1nWk+T1RQK25umcdyq5Wx6EN1IXSk9SLrL9DQ==} + '@fleetbase/fleetops-data@0.1.40': + resolution: {integrity: sha512-bp4tWaS2cpnitjtmH9hm3T7milwSqgt4j3T/KA8cvsFpbg7p303ADb5CNml5RsribgdBcRYtMsLEFYeYnU9dpg==} engines: {node: '>= 18'} '@fleetbase/intl-lint@0.0.1': @@ -10326,7 +10326,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@fleetbase/ember-core@0.3.22(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))': + '@fleetbase/ember-core@0.3.23(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))': dependencies: '@babel/core': 7.29.0 compress-json: 3.4.0 @@ -10359,7 +10359,7 @@ snapshots: - utf-8-validate - webpack - '@fleetbase/ember-ui@0.3.36(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0)': + '@fleetbase/ember-ui@0.3.37(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0)': dependencies: '@babel/core': 7.29.0 '@ember/render-modifiers': 2.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))) @@ -10465,10 +10465,10 @@ snapshots: - webpack-command - yaml - '@fleetbase/fleetops-data@0.1.39(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))': + '@fleetbase/fleetops-data@0.1.40(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))': dependencies: '@babel/core': 7.29.0 - '@fleetbase/ember-core': 0.3.22(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) + '@fleetbase/ember-core': 0.3.23(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) date-fns: 2.30.0 ember-cli-babel: 8.3.1(@babel/core@7.29.0) ember-cli-htmlbars: 6.3.0 diff --git a/server/src/Console/Commands/SyncTelematics.php b/server/src/Console/Commands/SyncTelematics.php index 2b1d50749..653ddbd9b 100644 --- a/server/src/Console/Commands/SyncTelematics.php +++ b/server/src/Console/Commands/SyncTelematics.php @@ -13,7 +13,7 @@ class SyncTelematics extends Command protected $signature = 'fleetops:sync-telematics {--provider=* : Limit polling to one or more provider keys} {--limit=500 : Maximum provider units to fetch per page} - {--sync-webhook-providers : Include providers that support webhooks} + {--exclude-webhook-providers : Skip providers that support webhooks} {--no-lock : Skip process locking}'; protected $description = 'Poll active telematics providers for device snapshots and positional telemetry.'; @@ -68,10 +68,10 @@ public function handle(TelematicProviderRegistry $registry): int protected function pollableProviderKeys(TelematicProviderRegistry $registry): array { $requestedProviders = array_filter((array) $this->option('provider')); - $includeWebhookProviders = (bool) $this->option('sync-webhook-providers'); + $excludeWebhookProviders = (bool) $this->option('exclude-webhook-providers'); return $registry->all() - ->filter(function ($descriptor) use ($requestedProviders, $includeWebhookProviders) { + ->filter(function ($descriptor) use ($requestedProviders, $excludeWebhookProviders) { if (!empty($requestedProviders) && !in_array($descriptor->key, $requestedProviders, true)) { return false; } @@ -80,7 +80,7 @@ protected function pollableProviderKeys(TelematicProviderRegistry $registry): ar return false; } - return $includeWebhookProviders || !$descriptor->supportsWebhooks; + return !$excludeWebhookProviders || !$descriptor->supportsWebhooks; }) ->keys() ->values() diff --git a/server/src/Http/Controllers/Internal/v1/DeviceController.php b/server/src/Http/Controllers/Internal/v1/DeviceController.php index 9c679a47d..682b7a320 100644 --- a/server/src/Http/Controllers/Internal/v1/DeviceController.php +++ b/server/src/Http/Controllers/Internal/v1/DeviceController.php @@ -28,6 +28,7 @@ class DeviceController extends FleetOpsController public static function onQueryRecord($query, $request): void { $query->with(['telematic', 'warranty', 'attachable']); + $query->withCount('sensors'); if ($request->filled('attachment_state')) { match ($request->input('attachment_state')) { @@ -38,13 +39,17 @@ public static function onQueryRecord($query, $request): void } if ($request->filled('vehicle')) { - $query->where('attachable_uuid', $request->input('vehicle')); + static::applyVehicleFilter($query, $request->input('vehicle')); } if ($request->filled('device_id')) { $query->where('device_id', 'like', '%' . $request->input('device_id') . '%'); } + if ($request->filled('serial_number')) { + $query->where('serial_number', 'like', '%' . $request->input('serial_number') . '%'); + } + if ($request->filled('connection_status')) { $statuses = Utils::arrayFrom($request->input('connection_status')); @@ -80,6 +85,7 @@ public static function onQueryRecord($query, $request): void public static function onFindRecord($query, $request): void { $query->with(['telematic', 'warranty', 'attachable']); + $query->withCount('sensors'); } /** @@ -183,6 +189,17 @@ protected static function applyDateFilter($query, string $column, string|array $ } } + protected static function applyVehicleFilter($query, string $vehicle): void + { + $query->where(function ($vehicleQuery) use ($vehicle) { + $vehicleQuery->where('attachable_uuid', $vehicle) + ->orWhereIn('attachable_uuid', Vehicle::query() + ->where('company_uuid', session('company')) + ->where('public_id', $vehicle) + ->pluck('uuid')); + }); + } + protected function logDeviceAttachmentLookupFailure(string $action, string $missingResource, string $deviceId, ?string $vehicleId): void { Log::warning('Device attachment lookup failed', [ diff --git a/server/src/Http/Controllers/Internal/v1/DeviceEventController.php b/server/src/Http/Controllers/Internal/v1/DeviceEventController.php index 04f99c036..80b8d8467 100644 --- a/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +++ b/server/src/Http/Controllers/Internal/v1/DeviceEventController.php @@ -24,7 +24,7 @@ class DeviceEventController extends FleetOpsController */ public static function onQueryRecord($query, $request): void { - $query->with(['device']); + $query->with(['device.telematic']); if ($request->filled('telematic')) { $query->whereHas('device', function ($deviceQuery) use ($request) { diff --git a/server/src/Http/Controllers/Internal/v1/LiveController.php b/server/src/Http/Controllers/Internal/v1/LiveController.php index 4f9dcee5e..73c8a504d 100644 --- a/server/src/Http/Controllers/Internal/v1/LiveController.php +++ b/server/src/Http/Controllers/Internal/v1/LiveController.php @@ -148,7 +148,7 @@ public function drivers(Request $request) return LiveCacheService::remember('drivers', $cacheParams, function () use ($bounds, $limit) { $query = Driver::where(['company_uuid' => session('company')]) - ->with(['user', 'vehicle']) + ->with(['user', 'vehicle.devices']) ->applyDirectivesForPermissions('fleet-ops list driver'); $this->applyLiveLocationGuards($query); diff --git a/server/src/Http/Controllers/Internal/v1/ServiceRateController.php b/server/src/Http/Controllers/Internal/v1/ServiceRateController.php index fb90f5553..577bc2316 100644 --- a/server/src/Http/Controllers/Internal/v1/ServiceRateController.php +++ b/server/src/Http/Controllers/Internal/v1/ServiceRateController.php @@ -8,6 +8,7 @@ use Fleetbase\FleetOps\Models\ServiceRate; use Fleetbase\Http\Requests\ExportRequest; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Maatwebsite\Excel\Facades\Excel; @@ -27,24 +28,30 @@ class ServiceRateController extends FleetOpsController */ public function getServicesForRoute(Request $request) { - $coordinates = explode(';', $request->input('coordinates')); // ex. 1.3621663,103.8845049;1.353151,103.86458 + $coordinates = $request->input('coordinates'); - // convert coordinates to points - $waypoints = collect($coordinates)->map( - function ($coord) { - $coord = explode(',', $coord); - [$latitude, $longitude] = $coord; + if (!is_string($coordinates) || trim($coordinates) === '') { + return response()->error('Route coordinates are required to query service rates.', 422); + } - return Point::fromText("POINT($longitude $latitude)", 4326); - } - ); + $waypoints = $this->parseRouteCoordinates($coordinates); + + if ($waypoints === null) { + return response()->error('Invalid route coordinates provided.', 422); + } + + if ($waypoints->count() < 2) { + return response()->error('At least two route coordinates are required to query service rates.', 422); + } - $applicableServiceRates = ServiceRate::getServicableForWaypoints( + $serviceType = $this->normalizeOptionalQueryValue($request->input('service_type')); + + $applicableServiceRates = $this->getServicableForWaypoints( $waypoints, - function ($query) use ($request) { + function ($query) use ($request, $serviceType) { $query->where('company_uuid', $request->session()->get('company')); - if ($request->filled('service_type')) { - $query->where('service_type', $request->input('service_type')); + if ($serviceType) { + $query->where('service_type', $serviceType); } } ); @@ -52,6 +59,68 @@ function ($query) use ($request) { return response()->json($applicableServiceRates); } + /** + * Parse semicolon-delimited latitude,longitude coordinate pairs. + */ + protected function parseRouteCoordinates(string $coordinates): ?Collection + { + $coordinates = collect(explode(';', $coordinates)) + ->map(fn ($coordinate) => trim($coordinate)); + + if ($coordinates->contains('')) { + return null; + } + + $waypoints = $coordinates + ->map(function ($coordinate) { + $parts = array_map('trim', explode(',', $coordinate)); + + if (count($parts) !== 2) { + return null; + } + + [$latitude, $longitude] = $parts; + + if (!is_numeric($latitude) || !is_numeric($longitude)) { + return null; + } + + return Point::fromText("POINT($longitude $latitude)", 4326); + }); + + if ($waypoints->contains(null)) { + return null; + } + + return $waypoints->values(); + } + + /** + * Treat placeholder query-string values from the UI as absent. + */ + protected function normalizeOptionalQueryValue($value): ?string + { + if (!is_string($value)) { + return null; + } + + $value = trim($value); + + if ($value === '' || in_array(strtolower($value), ['null', 'undefined'], true)) { + return null; + } + + return $value; + } + + /** + * Resolve applicable service rates for route waypoints. + */ + protected function getServicableForWaypoints(Collection $waypoints, \Closure $queryCallback): array + { + return ServiceRate::getServicableForWaypoints($waypoints, $queryCallback); + } + /** * Export the service rate to excel or csv. * diff --git a/server/src/Http/Filter/DeviceEventFilter.php b/server/src/Http/Filter/DeviceEventFilter.php index cb2caadd6..a1a1a4a83 100644 --- a/server/src/Http/Filter/DeviceEventFilter.php +++ b/server/src/Http/Filter/DeviceEventFilter.php @@ -26,6 +26,27 @@ public function query(?string $searchQuery) $this->builder->search($searchQuery); } + public function eventType(?string $eventType) + { + if ($eventType) { + $this->builder->where('event_type', 'like', '%' . $eventType . '%'); + } + } + + public function provider(?string $provider) + { + if ($provider) { + $this->builder->where('provider', 'like', '%' . $provider . '%'); + } + } + + public function code(?string $code) + { + if ($code) { + $this->builder->where('code', 'like', '%' . $code . '%'); + } + } + public function telematic(?string $telematic) { $this->builder->whereHas('device', function ($query) use ($telematic) { diff --git a/server/src/Http/Filter/DeviceFilter.php b/server/src/Http/Filter/DeviceFilter.php index 425a82545..eeef96204 100644 --- a/server/src/Http/Filter/DeviceFilter.php +++ b/server/src/Http/Filter/DeviceFilter.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Http\Filter; +use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Filter\Filter; @@ -38,6 +39,22 @@ public function deviceId(?string $deviceId) } } + public function type(string|array|null $type) + { + $type = Utils::arrayFrom($type); + + if ($type) { + $this->builder->whereIn('type', $type); + } + } + + public function serialNumber(?string $serialNumber) + { + if ($serialNumber) { + $this->builder->where('serial_number', 'like', '%' . $serialNumber . '%'); + } + } + public function telematic(?string $telematic) { $this->builder->where('telematic_uuid', $telematic); @@ -71,7 +88,13 @@ public function attachableUuid(?string $attachable) public function vehicle(?string $vehicle) { if ($vehicle) { - $this->builder->where('attachable_uuid', $vehicle); + $this->builder->where(function ($query) use ($vehicle) { + $query->where('attachable_uuid', $vehicle) + ->orWhereIn('attachable_uuid', Vehicle::query() + ->where('company_uuid', $this->session->get('company')) + ->where('public_id', $vehicle) + ->pluck('uuid')); + }); } } diff --git a/server/src/Http/Resources/v1/Index/Vehicle.php b/server/src/Http/Resources/v1/Index/Vehicle.php index f22d1c130..031062c39 100644 --- a/server/src/Http/Resources/v1/Index/Vehicle.php +++ b/server/src/Http/Resources/v1/Index/Vehicle.php @@ -46,6 +46,7 @@ public function toArray($request): array 'altitude' => (int) data_get($this, 'altitude', 0), 'speed' => (int) data_get($this, 'speed', 0), 'online' => (bool) data_get($this, 'online', false), + 'devices' => $this->whenLoaded('devices', fn () => $this->compactDevices()), 'assigned_orders_count' => $this->when($isInternal, $this->assignedOrdersCount()), // Meta flag to indicate this is an index resource @@ -65,6 +66,24 @@ protected function assignedOrdersCount(): int return Order::where('vehicle_assigned_uuid', $this->uuid)->count(); } + protected function compactDevices(): array + { + return $this->devices->map( + fn ($device) => [ + 'id' => $device->uuid, + 'uuid' => $device->uuid, + 'public_id' => $device->public_id, + 'name' => $device->name, + 'display_name' => $device->display_name, + 'device_id' => $device->device_id, + 'serial_number' => $device->serial_number, + 'imei' => $device->imei, + 'provider' => $device->provider, + 'status' => $device->status, + ] + )->values()->all(); + } + protected function currentOrderReference(): ?string { $this->loadMissing('driver.currentOrder'); diff --git a/server/src/Models/Device.php b/server/src/Models/Device.php index d0e04f6ec..eb0baaa27 100644 --- a/server/src/Models/Device.php +++ b/server/src/Models/Device.php @@ -80,6 +80,8 @@ class Device extends Model 'vehicle', 'connection_status', 'device_id', + 'type', + 'serial_number', 'last_online_at', 'updated_at', 'warranty_uuid', diff --git a/server/src/Models/DeviceEvent.php b/server/src/Models/DeviceEvent.php index f62866849..6c5b2a5b9 100644 --- a/server/src/Models/DeviceEvent.php +++ b/server/src/Models/DeviceEvent.php @@ -103,6 +103,15 @@ class DeviceEvent extends Model */ protected $appends = [ 'device_name', + 'device_id', + 'device_imei', + 'device_serial_number', + 'device_connection_status', + 'device_status', + 'device_photo_url', + 'telematic_uuid', + 'telematic_name', + 'provider_descriptor', 'is_processed', 'age_minutes', 'processing_delay_minutes', @@ -208,6 +217,78 @@ public function getDeviceNameAttribute(): ?string return $this->device?->name; } + /** + * Get the provider device identifier. + */ + public function getDeviceIdAttribute(): ?string + { + return $this->device?->device_id ?? $this->ident; + } + + /** + * Get the device IMEI. + */ + public function getDeviceImeiAttribute(): ?string + { + return $this->device?->imei; + } + + /** + * Get the device serial number. + */ + public function getDeviceSerialNumberAttribute(): ?string + { + return $this->device?->serial_number; + } + + /** + * Get the device connection status. + */ + public function getDeviceConnectionStatusAttribute(): ?string + { + return $this->device?->connection_status; + } + + /** + * Get the device operational status. + */ + public function getDeviceStatusAttribute(): ?string + { + return $this->device?->status; + } + + /** + * Get the device photo URL. + */ + public function getDevicePhotoUrlAttribute(): ?string + { + return $this->device?->photo_url; + } + + /** + * Get the related telematic UUID. + */ + public function getTelematicUuidAttribute(): ?string + { + return $this->device?->telematic_uuid; + } + + /** + * Get the related telematic name. + */ + public function getTelematicNameAttribute(): ?string + { + return $this->device?->telematic?->name; + } + + /** + * Get the related telematic provider descriptor. + */ + public function getProviderDescriptorAttribute(): array + { + return $this->device?->telematic?->provider_descriptor ?? []; + } + /** * Check if the event has been processed. */ diff --git a/server/src/Models/Equipment.php b/server/src/Models/Equipment.php index 84e38e491..749c5ff82 100644 --- a/server/src/Models/Equipment.php +++ b/server/src/Models/Equipment.php @@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\Relations\Relation; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Sluggable\HasSlug; @@ -94,6 +95,15 @@ class Equipment extends Model 'slug', ]; + /** + * Set attributes and defaults. + * + * @var array + */ + protected $attributes = [ + 'status' => 'available', + ]; + /** * Dynamic attributes that are appended to object. * @@ -148,6 +158,19 @@ class Equipment extends Model */ protected static $logName = 'equipment'; + /** + * Enforce assignment morph aliases for existing short type values. + */ + public static function boot(): void + { + parent::boot(); + + Relation::morphMap([ + 'fleet-ops:vehicle' => Vehicle::class, + 'fleet-ops:driver' => Driver::class, + ]); + } + /** * Get the options for generating the slug. */ @@ -192,6 +215,38 @@ public function equipable(): MorphTo return $this->morphTo(__FUNCTION__, 'equipable_type', 'equipable_uuid'); } + /** + * Set the equipment status attribute. + */ + public function setStatusAttribute(?string $status = 'available'): void + { + $this->attributes['status'] = $status === null || $status === 'active' ? 'available' : $status; + } + + /** + * Normalize equipment assignment type aliases to Eloquent morph classes. + */ + public function setEquipableTypeAttribute(?string $type): void + { + if ($type === null || $type === '') { + $this->attributes['equipable_type'] = null; + + return; + } + + if (str_contains($type, '\\')) { + $this->attributes['equipable_type'] = $type; + + return; + } + + $this->attributes['equipable_type'] = match ($type) { + 'fleet-ops:vehicle', 'vehicle' => Utils::getMutationType('fleet-ops:vehicle'), + 'fleet-ops:driver', 'driver' => Utils::getMutationType('fleet-ops:driver'), + default => $type, + }; + } + public function maintenances(): HasMany { return $this->hasMany(Maintenance::class, 'maintainable_uuid', 'uuid') diff --git a/server/src/Support/Telematics/Providers/AfaqyProvider.php b/server/src/Support/Telematics/Providers/AfaqyProvider.php index dda82152b..c131eeecd 100644 --- a/server/src/Support/Telematics/Providers/AfaqyProvider.php +++ b/server/src/Support/Telematics/Providers/AfaqyProvider.php @@ -151,6 +151,7 @@ public function normalizeDevice(array $payload): array return [ 'device_id' => $payload['_id'] ?? $payload['id'] ?? null, + 'external_id' => $payload['_id'] ?? $payload['id'] ?? null, 'name' => $payload['name'] ?? data_get($payload, 'profile.plate_number') ?? 'Unknown Unit', 'provider' => 'afaqy', 'model' => data_get($payload, 'profile.model') ?? $payload['device'] ?? null, diff --git a/server/src/Support/Telematics/Providers/FlespiProvider.php b/server/src/Support/Telematics/Providers/FlespiProvider.php index f7b5effd1..05a99868b 100644 --- a/server/src/Support/Telematics/Providers/FlespiProvider.php +++ b/server/src/Support/Telematics/Providers/FlespiProvider.php @@ -2,6 +2,8 @@ namespace Fleetbase\FleetOps\Support\Telematics\Providers; +use Illuminate\Support\Carbon; + /** * Class FlespiProvider. * @@ -77,34 +79,71 @@ public function fetchDeviceDetails(string $externalId): array public function normalizeDevice(array $payload): array { + $telemetry = $payload['telemetry'] ?? $payload; + $externalId = $payload['id'] ?? $payload['device.id'] ?? null; + $occurredAt = $this->parseTimestamp($telemetry['timestamp'] ?? $payload['last_active'] ?? null); + return [ - 'external_id' => $payload['id'], - 'device_name' => $payload['name'] ?? 'Unknown Device', - 'device_provider' => 'flespi', - 'device_model' => $payload['device_type_id'] ?? null, - 'imei' => $payload['configuration']['ident'] ?? null, - 'phone' => $payload['configuration']['phone'] ?? null, - 'status' => isset($payload['telemetry']) ? 'active' : 'inactive', - 'location' => [ - 'lat' => $payload['telemetry']['position.latitude'] ?? null, - 'lng' => $payload['telemetry']['position.longitude'] ?? null, + 'device_id' => $externalId, + 'external_id' => $externalId, + 'name' => $payload['name'] ?? $telemetry['device.name'] ?? 'Unknown Device', + 'provider' => 'flespi', + 'model' => $payload['device_type_id'] ?? $payload['device_type_name'] ?? null, + 'imei' => $payload['configuration']['ident'] ?? $payload['ident'] ?? $telemetry['ident'] ?? null, + 'serial_number' => $payload['configuration']['serial'] ?? $payload['serial'] ?? null, + 'phone' => $payload['configuration']['phone'] ?? null, + 'status' => isset($payload['telemetry']) ? 'active' : 'inactive', + 'online' => $this->resolveOnline($telemetry), + 'last_seen_at' => $occurredAt, + 'location' => [ + 'lat' => $telemetry['position.latitude'] ?? null, + 'lng' => $telemetry['position.longitude'] ?? null, + ], + 'speed' => $telemetry['position.speed'] ?? $telemetry['vehicle.speed'] ?? null, + 'heading' => $telemetry['position.direction'] ?? $telemetry['position.heading'] ?? null, + 'altitude' => $telemetry['position.altitude'] ?? null, + 'odometer' => $telemetry['vehicle.mileage'] ?? $telemetry['vehicle.odometer'] ?? null, + 'ignition' => $this->extractIgnition($telemetry), + 'fuel_level' => $telemetry['fuel.level'] ?? $telemetry['can.fuel.level'] ?? null, + 'meta' => [ + 'raw' => $payload, + 'provider_status' => array_filter([ + 'status' => $payload['status'] ?? $telemetry['status'] ?? null, + 'online' => $telemetry['online'] ?? null, + 'connected' => $telemetry['device.connected'] ?? $telemetry['connected'] ?? null, + ], fn ($value) => $value !== null), ], - 'meta' => $payload, ]; } public function normalizeEvent(array $payload): array { + $deviceId = $payload['device.id'] ?? $payload['id'] ?? null; + return [ - 'external_id' => $payload['id'] ?? null, - 'device_id' => $payload['device.id'] ?? null, + 'external_id' => $payload['id'] ?? $deviceId, + 'device_id' => $deviceId, 'event_type' => $payload['event.enum'] ?? 'telemetry_update', - 'occurred_at' => isset($payload['timestamp']) ? date('Y-m-d H:i:s', $payload['timestamp']) : now(), + 'occurred_at' => $this->parseTimestamp($payload['timestamp'] ?? null) ?? now(), + 'online' => $this->resolveOnline($payload), 'location' => [ 'lat' => $payload['position.latitude'] ?? null, 'lng' => $payload['position.longitude'] ?? null, ], - 'meta' => $payload, + 'speed' => $payload['position.speed'] ?? $payload['vehicle.speed'] ?? null, + 'heading' => $payload['position.direction'] ?? $payload['position.heading'] ?? null, + 'altitude' => $payload['position.altitude'] ?? null, + 'odometer' => $payload['vehicle.mileage'] ?? $payload['vehicle.odometer'] ?? null, + 'ignition' => $this->extractIgnition($payload), + 'fuel_level' => $payload['fuel.level'] ?? $payload['can.fuel.level'] ?? null, + 'meta' => [ + 'raw' => $payload, + 'provider_status' => array_filter([ + 'status' => $payload['status'] ?? null, + 'online' => $payload['online'] ?? null, + 'connected' => $payload['device.connected'] ?? $payload['connected'] ?? null, + ], fn ($value) => $value !== null), + ], ]; } @@ -180,4 +219,39 @@ public function supportsWebhooks(): bool { return true; } + + protected function parseTimestamp($value): ?string + { + if (!$value) { + return null; + } + + if (is_numeric($value)) { + return Carbon::createFromTimestamp((float) $value)->toDateTimeString(); + } + + return Carbon::parse($value)->toDateTimeString(); + } + + protected function resolveOnline(array $payload): ?bool + { + $value = $payload['online'] ?? $payload['device.connected'] ?? $payload['connected'] ?? null; + + if ($value === null) { + return isset($payload['timestamp']); + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value; + } + + protected function extractIgnition(array $payload): ?bool + { + $value = $payload['engine.ignition.status'] ?? $payload['ignition.status'] ?? null; + + if ($value === null) { + return null; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value; + } } diff --git a/server/src/Support/Telematics/Providers/GeotabProvider.php b/server/src/Support/Telematics/Providers/GeotabProvider.php index 0149178e1..000ac3527 100644 --- a/server/src/Support/Telematics/Providers/GeotabProvider.php +++ b/server/src/Support/Telematics/Providers/GeotabProvider.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Support\Telematics\Providers; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; /** @@ -75,19 +76,21 @@ public function fetchDevices(array $options = []): array { $limit = $options['limit'] ?? 100; - $response = Http::post($this->baseUrl, [ - 'method' => 'Get', - 'params' => [ - 'credentials' => [ - 'database' => $this->credentials['database'], - 'sessionId' => $this->sessionId, - ], - 'typeName' => 'Device', - 'resultsLimit' => $limit, - ], - ])->json(); + $response = $this->apiCall('Get', [ + 'typeName' => 'Device', + 'resultsLimit' => $limit, + ]); - $devices = $response['result'] ?? []; + $devices = $response['result'] ?? []; + $logsByDevice = $this->fetchLatestLogRecords($devices, $options); + $devices = array_map(function ($device) use ($logsByDevice) { + $deviceId = $device['id'] ?? null; + if ($deviceId && isset($logsByDevice[$deviceId])) { + $device['latest_log_record'] = $logsByDevice[$deviceId]; + } + + return $device; + }, $devices); return [ 'devices' => $devices, @@ -98,43 +101,76 @@ public function fetchDevices(array $options = []): array public function fetchDeviceDetails(string $externalId): array { - $response = Http::post($this->baseUrl, [ - 'method' => 'Get', - 'params' => [ - 'credentials' => [ - 'database' => $this->credentials['database'], - 'sessionId' => $this->sessionId, - ], - 'typeName' => 'Device', - 'search' => ['id' => $externalId], - ], - ])->json(); + $response = $this->apiCall('Get', [ + 'typeName' => 'Device', + 'search' => ['id' => $externalId], + ]); return $response['result'][0] ?? []; } public function normalizeDevice(array $payload): array { + $latestLog = $payload['latest_log_record'] ?? []; + $hasTelemetry = isset($latestLog['dateTime']) || isset($latestLog['latitude'], $latestLog['longitude']); + return [ - 'external_id' => $payload['id'], - 'device_name' => $payload['name'] ?? 'Unknown Device', - 'device_provider' => 'geotab', - 'device_model' => $payload['deviceType'] ?? null, - 'imei' => $payload['serialNumber'] ?? null, - 'vin' => $payload['vehicleIdentificationNumber'] ?? null, - 'status' => 'active', - 'meta' => $payload, + 'device_id' => $payload['id'] ?? null, + 'external_id' => $payload['id'] ?? null, + 'name' => $payload['name'] ?? 'Unknown Device', + 'provider' => 'geotab', + 'model' => $payload['deviceType'] ?? null, + 'imei' => $payload['serialNumber'] ?? null, + 'vin' => $payload['vehicleIdentificationNumber'] ?? null, + 'serial_number' => $payload['serialNumber'] ?? null, + 'status' => 'active', + 'online' => $hasTelemetry ? true : null, + 'last_seen_at' => $latestLog ? $this->parseTimestamp($latestLog['dateTime'] ?? null) : null, + 'location' => [ + 'lat' => $latestLog['latitude'] ?? null, + 'lng' => $latestLog['longitude'] ?? null, + ], + 'speed' => $latestLog['speed'] ?? null, + 'heading' => $latestLog['bearing'] ?? $latestLog['heading'] ?? null, + 'altitude' => $latestLog['altitude'] ?? null, + 'meta' => [ + 'raw' => $payload, + 'provider_status' => array_filter([ + 'active_from' => $payload['activeFrom'] ?? null, + 'active_to' => $payload['activeTo'] ?? null, + 'groups' => $payload['groups'] ?? null, + 'has_log' => $hasTelemetry, + ], fn ($value) => $value !== null), + ], ]; } public function normalizeEvent(array $payload): array { + $latestLog = $payload['latest_log_record'] ?? $payload; + $deviceId = data_get($latestLog, 'device.id') ?? $payload['deviceId'] ?? $payload['id'] ?? null; + $hasTelemetry = isset($latestLog['dateTime']) || isset($latestLog['latitude'], $latestLog['longitude']); + return [ - 'external_id' => $payload['id'] ?? null, - 'device_id' => $payload['device']['id'] ?? $payload['deviceId'] ?? null, + 'external_id' => $latestLog['id'] ?? $payload['id'] ?? null, + 'device_id' => $deviceId, 'event_type' => $payload['type'] ?? 'status_data', - 'occurred_at' => $payload['dateTime'] ?? now(), - 'meta' => $payload, + 'occurred_at' => $this->parseTimestamp($latestLog['dateTime'] ?? null) ?? now(), + 'online' => $hasTelemetry ? true : null, + 'location' => [ + 'lat' => $latestLog['latitude'] ?? null, + 'lng' => $latestLog['longitude'] ?? null, + ], + 'speed' => $latestLog['speed'] ?? null, + 'heading' => $latestLog['bearing'] ?? $latestLog['heading'] ?? null, + 'altitude' => $latestLog['altitude'] ?? null, + 'meta' => [ + 'raw' => $payload, + 'provider_status' => array_filter([ + 'has_log' => $hasTelemetry, + 'device' => data_get($latestLog, 'device.id'), + ], fn ($value) => $value !== null), + ], ]; } @@ -179,4 +215,56 @@ public function supportsWebhooks(): bool { return false; } + + protected function apiCall(string $method, array $params): array + { + $params['credentials'] = [ + 'database' => $this->credentials['database'], + 'sessionId' => $this->sessionId, + ]; + + return Http::post($this->baseUrl, [ + 'method' => $method, + 'params' => $params, + ])->json() ?? []; + } + + protected function fetchLatestLogRecords(array $devices, array $options = []): array + { + $deviceIds = array_values(array_filter(array_map(fn ($device) => $device['id'] ?? null, $devices))); + if (empty($deviceIds)) { + return []; + } + + $fromDate = $options['from_date'] ?? Carbon::now()->subHours(24)->toIso8601String(); + $response = $this->apiCall('Get', [ + 'typeName' => 'LogRecord', + 'search' => ['fromDate' => $fromDate], + 'resultsLimit' => max(count($deviceIds) * 5, 100), + ]); + + $logsByDevice = []; + foreach ($response['result'] ?? [] as $record) { + $deviceId = data_get($record, 'device.id'); + if (!$deviceId || !in_array($deviceId, $deviceIds, true)) { + continue; + } + + $current = $logsByDevice[$deviceId] ?? null; + if (!$current || Carbon::parse($record['dateTime'] ?? now())->greaterThan(Carbon::parse($current['dateTime'] ?? now()))) { + $logsByDevice[$deviceId] = $record; + } + } + + return $logsByDevice; + } + + protected function parseTimestamp($value): ?string + { + if (!$value) { + return null; + } + + return Carbon::parse($value)->toDateTimeString(); + } } diff --git a/server/src/Support/Telematics/Providers/SafeeProvider.php b/server/src/Support/Telematics/Providers/SafeeProvider.php index 7f95e3e44..ed485d27c 100644 --- a/server/src/Support/Telematics/Providers/SafeeProvider.php +++ b/server/src/Support/Telematics/Providers/SafeeProvider.php @@ -110,8 +110,19 @@ public function normalizeDevice(array $payload): array 'lat' => $position['lat'] ?? null, 'lng' => $position['lng'] ?? null, ], - 'meta' => [ - 'raw' => $payload, + 'speed' => $payload['speed'] ?? $payload['lastSpeed'] ?? null, + 'heading' => $payload['heading'] ?? $payload['angle'] ?? null, + 'altitude' => $payload['altitude'] ?? $payload['alt'] ?? null, + 'odometer' => $this->extractOdometer($payload), + 'ignition' => $this->extractIgnition($payload), + 'fuel_level' => $this->extractFuelLevel($payload), + 'meta' => [ + 'raw' => $payload, + 'provider_status' => array_filter([ + 'status' => $rawStatus, + 'normalized' => $status, + 'vehicleStatus' => $payload['vehicleStatus'] ?? null, + ], fn ($value) => $value !== null), 'plate_number' => $payload['plateNumber'] ?? $payload['plate_number'] ?? null, 'door_number' => $payload['doorNumber'] ?? null, 'driver' => $payload['driver'] ?? null, @@ -136,12 +147,14 @@ public function normalizeEvent(array $payload): array 'event_type' => data_get($payload, 'event.code') ?? data_get($payload, 'event.name') ?? 'telemetry_update', 'message' => data_get($payload, 'event.name') ?? data_get($payload, 'event.message') ?? null, 'occurred_at' => $this->parseTimestamp($payload['date'] ?? $payload['deviceTime'] ?? $payload['time'] ?? null), + 'online' => $this->resolveOnline($payload), 'location' => [ 'lat' => $position['lat'] ?? null, 'lng' => $position['lng'] ?? null, ], 'speed' => $payload['speed'] ?? $payload['lastSpeed'] ?? null, 'heading' => $payload['heading'] ?? $payload['angle'] ?? null, + 'altitude' => $payload['altitude'] ?? $payload['alt'] ?? null, 'odometer' => $this->extractOdometer($payload), 'ignition' => $this->extractIgnition($payload), 'fuel_level' => $this->extractFuelLevel($payload), @@ -347,6 +360,17 @@ protected function normalizeVehicleStatus(?string $status): string return in_array(strtolower($status), ['offline', 'inactive', 'deleted', 'expired'], true) ? 'inactive' : 'active'; } + protected function resolveOnline(array $payload): ?bool + { + if (array_key_exists('online', $payload)) { + return filter_var($payload['online'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $payload['online']; + } + + $status = $payload['status'] ?? $payload['vehicleStatus'] ?? null; + + return $status ? $this->normalizeVehicleStatus($status) === 'active' : null; + } + protected function extractOdometer(array $payload): mixed { return data_get($payload, 'canbus.odometer') diff --git a/server/src/Support/Telematics/Providers/SamsaraProvider.php b/server/src/Support/Telematics/Providers/SamsaraProvider.php index 04b533178..ebe2a9985 100644 --- a/server/src/Support/Telematics/Providers/SamsaraProvider.php +++ b/server/src/Support/Telematics/Providers/SamsaraProvider.php @@ -2,6 +2,8 @@ namespace Fleetbase\FleetOps\Support\Telematics\Providers; +use Illuminate\Support\Carbon; + /** * Class SamsaraProvider. * @@ -78,30 +80,68 @@ public function fetchDeviceDetails(string $externalId): array public function normalizeDevice(array $payload): array { + $position = $this->extractPosition($payload); + return [ - 'external_id' => $payload['id'], - 'device_name' => $payload['name'] ?? 'Unknown Device', - 'device_provider' => 'samsara', - 'device_model' => $payload['make'] ?? null, - 'vin' => $payload['vin'] ?? null, - 'license_plate' => $payload['licensePlate'] ?? null, - 'status' => 'active', - 'meta' => $payload, + 'device_id' => $payload['id'] ?? null, + 'external_id' => $payload['id'] ?? null, + 'name' => $payload['name'] ?? 'Unknown Device', + 'provider' => 'samsara', + 'model' => $payload['make'] ?? $payload['model'] ?? null, + 'vin' => $payload['vin'] ?? null, + 'serial_number' => $payload['serial'] ?? $payload['serialNumber'] ?? null, + 'license_plate' => $payload['licensePlate'] ?? null, + 'status' => $this->normalizeStatus($payload), + 'online' => $this->resolveOnline($payload), + 'last_seen_at' => $this->parseTimestamp($payload['time'] ?? data_get($payload, 'location.time') ?? data_get($payload, 'gps.time') ?? null), + 'location' => [ + 'lat' => $position['lat'] ?? null, + 'lng' => $position['lng'] ?? null, + ], + 'speed' => $this->extractSpeed($payload), + 'heading' => $this->extractHeading($payload), + 'altitude' => $this->extractAltitude($payload), + 'odometer' => data_get($payload, 'odometerMeters') ?? data_get($payload, 'obdOdometerMeters.value'), + 'fuel_level' => data_get($payload, 'fuelPercent.value') ?? data_get($payload, 'fuelPercent'), + 'meta' => [ + 'raw' => $payload, + 'provider_status' => array_filter([ + 'status' => $payload['status'] ?? null, + 'gateway_status' => data_get($payload, 'gateway.status'), + 'online' => $payload['online'] ?? $payload['isOnline'] ?? data_get($payload, 'gateway.online'), + ], fn ($value) => $value !== null), + ], ]; } public function normalizeEvent(array $payload): array { + $position = $this->extractPosition($payload); + $deviceId = data_get($payload, 'vehicle.id') ?? $payload['vehicleId'] ?? $payload['id'] ?? null; + return [ - 'external_id' => $payload['id'] ?? null, - 'device_id' => $payload['vehicle']['id'] ?? $payload['vehicleId'] ?? null, + 'external_id' => $payload['id'] ?? $deviceId, + 'device_id' => $deviceId, 'event_type' => $payload['eventType'] ?? 'vehicle_update', - 'occurred_at' => $payload['time'] ?? now(), + 'occurred_at' => $this->parseTimestamp($payload['time'] ?? data_get($payload, 'location.time') ?? data_get($payload, 'gps.time') ?? null) ?? now(), + 'online' => $this->resolveOnline($payload), 'location' => [ - 'lat' => $payload['location']['latitude'] ?? null, - 'lng' => $payload['location']['longitude'] ?? null, + 'lat' => $position['lat'] ?? null, + 'lng' => $position['lng'] ?? null, + ], + 'speed' => $this->extractSpeed($payload), + 'heading' => $this->extractHeading($payload), + 'altitude' => $this->extractAltitude($payload), + 'odometer' => data_get($payload, 'odometerMeters') ?? data_get($payload, 'obdOdometerMeters.value'), + 'fuel_level' => data_get($payload, 'fuelPercent.value') ?? data_get($payload, 'fuelPercent'), + 'meta' => [ + 'raw' => $payload, + 'provider_status' => array_filter([ + 'status' => $payload['status'] ?? null, + 'gateway_status' => data_get($payload, 'gateway.status'), + 'online' => $payload['online'] ?? $payload['isOnline'] ?? data_get($payload, 'gateway.online'), + ], fn ($value) => $value !== null), ], - 'meta' => $payload, ]; } @@ -176,4 +216,72 @@ public function supportsWebhooks(): bool { return true; } + + protected function extractPosition(array $payload): array + { + $position = $payload['location'] + ?? $payload['gps'] + ?? $payload['currentLocation'] + ?? $payload['lastKnownLocation'] + ?? data_get($payload, 'vehicle.location') + ?? []; + + return [ + 'lat' => $position['latitude'] ?? $position['lat'] ?? null, + 'lng' => $position['longitude'] ?? $position['lng'] ?? null, + ]; + } + + protected function extractSpeed(array $payload): mixed + { + return data_get($payload, 'location.speedMilesPerHour') + ?? data_get($payload, 'location.speed') + ?? data_get($payload, 'gps.speedMilesPerHour') + ?? data_get($payload, 'speedMilesPerHour') + ?? data_get($payload, 'speed'); + } + + protected function extractHeading(array $payload): mixed + { + return data_get($payload, 'location.headingDegrees') + ?? data_get($payload, 'location.heading') + ?? data_get($payload, 'gps.headingDegrees') + ?? data_get($payload, 'headingDegrees') + ?? data_get($payload, 'heading'); + } + + protected function extractAltitude(array $payload): mixed + { + return data_get($payload, 'location.altitudeMeters') + ?? data_get($payload, 'gps.altitudeMeters') + ?? data_get($payload, 'altitudeMeters') + ?? data_get($payload, 'altitude'); + } + + protected function parseTimestamp($value): ?string + { + if (!$value) { + return null; + } + + return Carbon::parse($value)->toDateTimeString(); + } + + protected function resolveOnline(array $payload): ?bool + { + $value = $payload['online'] ?? $payload['isOnline'] ?? data_get($payload, 'gateway.online') ?? null; + + if ($value === null) { + return $this->extractPosition($payload)['lat'] !== null; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value; + } + + protected function normalizeStatus(array $payload): string + { + $status = strtolower((string) ($payload['status'] ?? data_get($payload, 'gateway.status') ?? 'active')); + + return in_array($status, ['inactive', 'offline', 'deactivated'], true) ? 'inactive' : 'active'; + } } diff --git a/server/src/routes.php b/server/src/routes.php index 275bd8660..7448d5f77 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -476,7 +476,6 @@ function ($router, $controller) { $router->delete('bulk-delete', $controller('bulkDelete')); $router->get('for-route', $controller('getServicesForRoute')); $router->match(['get', 'post'], 'export', $controller('export')); - $router->get('for-route', $controller('getServicesForRoute')); } ); $router->fleetbaseRoutes('tracking-numbers'); diff --git a/server/tests/DeviceFilterTest.php b/server/tests/DeviceFilterTest.php index 190c8e734..6609b19b7 100644 --- a/server/tests/DeviceFilterTest.php +++ b/server/tests/DeviceFilterTest.php @@ -9,6 +9,8 @@ ->toContain("'vehicle'") ->toContain("'connection_status'") ->toContain("'device_id'") + ->toContain("'type'") + ->toContain("'serial_number'") ->toContain("'last_online_at'") ->toContain("'updated_at'"); @@ -16,8 +18,13 @@ ->toContain('public function query(?string $searchQuery)') ->toContain('public function deviceId(?string $deviceId)') ->toContain("where('device_id', 'like'") + ->toContain('public function type(string|array|null $type)') + ->toContain("whereIn('type', \$type)") + ->toContain('public function serialNumber(?string $serialNumber)') + ->toContain("where('serial_number', 'like'") ->toContain('public function vehicle(?string $vehicle)') ->toContain("where('attachable_uuid', \$vehicle)") + ->toContain("where('public_id', \$vehicle)") ->toContain('public function connectionStatus') ->toContain("'online'") ->toContain("'recently_offline'") @@ -30,7 +37,9 @@ ->toContain('protected function filterDate'); expect($controller) + ->toContain("withCount('sensors')") ->toContain("filled('connection_status')") + ->toContain("filled('serial_number')") ->toContain("filled('last_online_at')") ->toContain("filled('updated_at')"); }); diff --git a/server/tests/DriverVehicleStatusDefaultTest.php b/server/tests/DriverVehicleStatusDefaultTest.php index c5dd6f97f..8cec78254 100644 --- a/server/tests/DriverVehicleStatusDefaultTest.php +++ b/server/tests/DriverVehicleStatusDefaultTest.php @@ -1,16 +1,19 @@ status) ->toBe('available') ->and((new Vehicle())->status) + ->toBe('available') + ->and((new Equipment())->status) ->toBe('available'); }); -test('legacy active and null driver and vehicle statuses normalize to available', function () { +test('legacy active and null driver vehicle and equipment statuses normalize to available', function () { $driver = new Driver(); $driver->status = 'active'; expect($driver->status)->toBe('available'); @@ -24,6 +27,20 @@ $vehicle->status = null; expect($vehicle->status)->toBe('available'); + + $equipment = new Equipment(); + $equipment->status = 'active'; + expect($equipment->status)->toBe('available'); + + $equipment->status = null; + expect($equipment->status)->toBe('available'); +}); + +test('equipment preserves explicit non default status values', function () { + $equipment = new Equipment(); + $equipment->status = 'maintenance'; + + expect($equipment->status)->toBe('maintenance'); }); test('dispatch driver availability queries use available status', function () { diff --git a/server/tests/EquipmentAssignmentMorphTypeTest.php b/server/tests/EquipmentAssignmentMorphTypeTest.php new file mode 100644 index 000000000..dfda16023 --- /dev/null +++ b/server/tests/EquipmentAssignmentMorphTypeTest.php @@ -0,0 +1,35 @@ +equipable_type = 'fleet-ops:vehicle'; + expect($equipment->equipable_type)->toBe(Vehicle::class); + + $equipment->equipable_type = 'fleet-ops:driver'; + expect($equipment->equipable_type)->toBe(Driver::class); +}); + +test('equipment assignment type preserves null and existing model classes', function () { + $equipment = new Equipment(); + + $equipment->equipable_type = null; + expect($equipment->equipable_type)->toBeNull(); + + $equipment->equipable_type = Vehicle::class; + expect($equipment->equipable_type)->toBe(Vehicle::class); +}); + +test('legacy equipment assignment aliases are registered for morph reads', function () { + new Equipment(); + + expect(Relation::getMorphedModel('fleet-ops:vehicle')) + ->toBe(Vehicle::class) + ->and(Relation::getMorphedModel('fleet-ops:driver')) + ->toBe(Driver::class); +}); diff --git a/server/tests/ServiceRateRouteValidationTest.php b/server/tests/ServiceRateRouteValidationTest.php new file mode 100644 index 000000000..afc80dfb3 --- /dev/null +++ b/server/tests/ServiceRateRouteValidationTest.php @@ -0,0 +1,101 @@ +put('company', 'company_test'); + $request->setLaravelSession($session); + + return $request; +} + +function fleetopsServiceRateRouteController() +{ + return new class extends ServiceRateController { + public ?Collection $waypoints = null; + public ?Closure $queryCallback = null; + + protected function getServicableForWaypoints(Collection $waypoints, Closure $queryCallback): array + { + $this->waypoints = $waypoints; + $this->queryCallback = $queryCallback; + + return [['id' => 'service_rate_test']]; + } + }; +} + +test('service rates for route requires coordinates', function ($query) { + $response = fleetopsServiceRateRouteController()->getServicesForRoute(fleetopsServiceRateRouteRequest($query)); + + expect($response->getStatusCode())->toBe(422); +})->with([ + 'missing coordinates' => [[]], + 'empty coordinates' => [['coordinates' => '']], +]); + +test('service rates for route requires at least two coordinates', function () { + $response = fleetopsServiceRateRouteController()->getServicesForRoute(fleetopsServiceRateRouteRequest([ + 'coordinates' => '1.3621663,103.8845049', + ])); + + expect($response->getStatusCode())->toBe(422); +}); + +test('service rates for route rejects malformed coordinates', function ($coordinates) { + $response = fleetopsServiceRateRouteController()->getServicesForRoute(fleetopsServiceRateRouteRequest([ + 'coordinates' => $coordinates, + ])); + + expect($response->getStatusCode())->toBe(422); +})->with([ + 'missing longitude' => ['1.3621663;1.353151,103.86458'], + 'missing latitude' => [',103.8845049;1.353151,103.86458'], + 'blank segment' => ['1.3621663,103.8845049;;1.353151,103.86458'], + 'non numeric' => ['north,103.8845049;1.353151,103.86458'], +]); + +test('service rates for route converts coordinate pairs to waypoints', function () { + $controller = fleetopsServiceRateRouteController(); + $response = $controller->getServicesForRoute(fleetopsServiceRateRouteRequest([ + 'coordinates' => '1.3621663,103.8845049;1.353151,103.86458', + 'service_type' => 'delivery', + ])); + + expect($response->getStatusCode())->toBe(200) + ->and($controller->waypoints)->toHaveCount(2) + ->and($controller->waypoints->first()->x())->toBe(103.8845049) + ->and($controller->waypoints->first()->y())->toBe(1.3621663); +}); + +test('service rates for route ignores placeholder service type values', function ($serviceType) { + $controller = fleetopsServiceRateRouteController(); + $controller->getServicesForRoute(fleetopsServiceRateRouteRequest([ + 'coordinates' => '1.3621663,103.8845049;1.353151,103.86458', + 'service_type' => $serviceType, + ])); + + $query = new class { + public array $wheres = []; + + public function where($column, $value) + { + $this->wheres[] = [$column, $value]; + + return $this; + } + }; + + ($controller->queryCallback)($query); + + expect($query->wheres)->toBe([['company_uuid', 'company_test']]); +})->with([ + 'undefined' => ['undefined'], + 'null' => ['null'], + 'empty' => [''], +]); diff --git a/server/tests/TelematicsHardeningTest.php b/server/tests/TelematicsHardeningTest.php index 5e28acc0d..61da51fb7 100644 --- a/server/tests/TelematicsHardeningTest.php +++ b/server/tests/TelematicsHardeningTest.php @@ -2,7 +2,10 @@ use Fleetbase\FleetOps\Contracts\TelematicProviderDescriptor; use Fleetbase\FleetOps\Support\Telematics\Providers\AfaqyProvider; +use Fleetbase\FleetOps\Support\Telematics\Providers\FlespiProvider; +use Fleetbase\FleetOps\Support\Telematics\Providers\GeotabProvider; use Fleetbase\FleetOps\Support\Telematics\Providers\SafeeProvider; +use Fleetbase\FleetOps\Support\Telematics\Providers\SamsaraProvider; use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; @@ -21,6 +24,11 @@ ->toContain("'occurred_at'") ->toContain("'processed_at'") ->toContain("'data'") + ->toContain("'device_imei'") + ->toContain("'device_connection_status'") + ->toContain("'provider_descriptor'") + ->toContain('public function getDeviceImeiAttribute(): ?string') + ->toContain('public function getProviderDescriptorAttribute(): array') ->toContain("'occurred_at' => 'datetime'") ->toContain("'processed_at' => 'datetime'"); }); @@ -79,20 +87,240 @@ }); test('native providers normalize device payloads to canonical FleetOps keys', function () { - $afaqy = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php'); - $safee = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/SafeeProvider.php'); + $providers = [ + file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php'), + file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/FlespiProvider.php'), + file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/GeotabProvider.php'), + file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/SafeeProvider.php'), + file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/SamsaraProvider.php'), + ]; - foreach ([$afaqy, $safee] as $provider) { + foreach ($providers as $provider) { expect($provider) ->toContain("'device_id'") + ->toContain("'external_id'") ->toContain("'name'") ->toContain("'provider'") ->toContain("'model'") ->toContain("'online'") - ->toContain("'last_seen_at'"); + ->toContain("'last_seen_at'") + ->toContain("'location'") + ->toContain("'speed'") + ->toContain("'heading'") + ->toContain("'altitude'"); } }); +test('flespi telemetry normalizes positional event fields', function () { + $event = (new FlespiProvider())->normalizeEvent([ + 'id' => 'message-1', + 'device.id' => 'device-1', + 'timestamp' => 1781769600, + 'position.latitude' => 25.2048, + 'position.longitude' => 55.2708, + 'position.speed' => 42, + 'position.direction' => 91, + 'position.altitude' => 12, + 'vehicle.mileage' => 12345, + 'engine.ignition.status' => true, + 'fuel.level' => 67, + ]); + + expect($event)->toMatchArray([ + 'external_id' => 'message-1', + 'device_id' => 'device-1', + 'event_type' => 'telemetry_update', + 'online' => true, + 'location' => ['lat' => 25.2048, 'lng' => 55.2708], + 'speed' => 42, + 'heading' => 91, + 'altitude' => 12, + 'odometer' => 12345, + 'ignition' => true, + 'fuel_level' => 67, + ]); + expect($event['meta'])->toHaveKeys(['raw', 'provider_status']); +}); + +test('samsara telemetry variants normalize positional event fields', function () { + $event = (new SamsaraProvider())->normalizeEvent([ + 'id' => 'event-1', + 'vehicle' => ['id' => 'vehicle-1'], + 'time' => '2026-06-18T08:00:00Z', + 'location' => [ + 'latitude' => 25.2048, + 'longitude' => 55.2708, + 'speedMilesPerHour' => 30, + 'headingDegrees' => 180, + 'altitudeMeters' => 16, + ], + 'odometerMeters' => 1000, + 'fuelPercent' => 50, + 'gateway' => ['status' => 'connected', 'online' => true], + ]); + + expect($event)->toMatchArray([ + 'external_id' => 'event-1', + 'device_id' => 'vehicle-1', + 'event_type' => 'vehicle_update', + 'online' => true, + 'location' => ['lat' => 25.2048, 'lng' => 55.2708], + 'speed' => 30, + 'heading' => 180, + 'altitude' => 16, + 'odometer' => 1000, + 'fuel_level' => 50, + ]); + expect($event['meta']['provider_status'])->toMatchArray([ + 'gateway_status' => 'connected', + 'online' => true, + ]); +}); + +test('safee telemetry includes online and altitude event fields', function () { + $event = (new SafeeProvider())->normalizeEvent([ + 'id' => 'event-1', + 'deviceId' => 'device-1', + 'status' => 'online', + 'date' => '2026-06-18T08:00:00Z', + 'lat' => 25.2048, + 'lon' => 55.2708, + 'speed' => 30, + 'heading' => 100, + 'altitude' => 15, + ]); + + expect($event)->toMatchArray([ + 'external_id' => 'event-1', + 'device_id' => 'device-1', + 'online' => true, + 'location' => ['lat' => 25.2048, 'lng' => 55.2708], + 'speed' => 30, + 'heading' => 100, + 'altitude' => 15, + ]); +}); + +test('geotab latest log record drives device and event telemetry', function () { + $payload = [ + 'id' => 'device-1', + 'name' => 'Truck 1', + 'deviceType' => 'GO9', + 'serialNumber' => 'serial-1', + 'vehicleIdentificationNumber' => 'VIN123', + 'latest_log_record' => [ + 'id' => 'log-1', + 'dateTime' => '2026-06-18T08:00:00Z', + 'latitude' => 25.2048, + 'longitude' => 55.2708, + 'speed' => 55, + 'bearing' => 90, + 'altitude' => 20, + 'device' => ['id' => 'device-1'], + ], + ]; + + $provider = new GeotabProvider(); + $device = $provider->normalizeDevice($payload); + $event = $provider->normalizeEvent($payload); + + expect($device)->toMatchArray([ + 'device_id' => 'device-1', + 'external_id' => 'device-1', + 'name' => 'Truck 1', + 'provider' => 'geotab', + 'model' => 'GO9', + 'imei' => 'serial-1', + 'vin' => 'VIN123', + 'serial_number' => 'serial-1', + 'online' => true, + 'location' => ['lat' => 25.2048, 'lng' => 55.2708], + 'speed' => 55, + 'heading' => 90, + 'altitude' => 20, + ]); + + expect($event)->toMatchArray([ + 'external_id' => 'log-1', + 'device_id' => 'device-1', + 'event_type' => 'status_data', + 'online' => true, + 'location' => ['lat' => 25.2048, 'lng' => 55.2708], + 'speed' => 55, + 'heading' => 90, + 'altitude' => 20, + ]); +}); + +test('geotab polling fetches recent log records and merges latest record into device snapshots', function () { + $requests = []; + + Http::fake(function ($request) use (&$requests) { + $requests[] = $request; + $body = json_decode($request->body(), true); + $typeName = data_get($body, 'params.typeName'); + + if ($typeName === 'Device') { + return Http::response([ + 'result' => [ + ['id' => 'device-1', 'name' => 'Truck 1'], + ['id' => 'device-2', 'name' => 'Truck 2'], + ], + ], 200); + } + + if ($typeName === 'LogRecord') { + return Http::response([ + 'result' => [ + ['id' => 'old-log', 'device' => ['id' => 'device-1'], 'dateTime' => '2026-06-18T07:00:00Z', 'latitude' => 1, 'longitude' => 2], + ['id' => 'new-log', 'device' => ['id' => 'device-1'], 'dateTime' => '2026-06-18T08:00:00Z', 'latitude' => 3, 'longitude' => 4], + ['id' => 'other-log', 'device' => ['id' => 'other-device'], 'dateTime' => '2026-06-18T08:00:00Z', 'latitude' => 5, 'longitude' => 6], + ], + ], 200); + } + + return Http::response(['result' => []], 200); + }); + + $provider = new class extends GeotabProvider { + public function fetchDevicesForTest(): array + { + $this->credentials = [ + 'database' => 'testing-db', + ]; + $this->sessionId = 'testing-session'; + + return $this->fetchDevices(['limit' => 2, 'from_date' => '2026-06-18T00:00:00Z']); + } + }; + + $result = $provider->fetchDevicesForTest(); + + expect($result['devices'])->toHaveCount(2); + expect($result['devices'][0]['latest_log_record'])->toMatchArray([ + 'id' => 'new-log', + 'latitude' => 3, + 'longitude' => 4, + ]); + expect($result['devices'][1])->not->toHaveKey('latest_log_record'); + expect($requests)->toHaveCount(2); + expect($requests[0]->data())->toMatchArray([ + 'method' => 'Get', + 'params' => [ + 'typeName' => 'Device', + 'resultsLimit' => 2, + ], + ]); + expect($requests[1]->data())->toMatchArray([ + 'method' => 'Get', + 'params' => [ + 'typeName' => 'LogRecord', + 'search' => ['fromDate' => '2026-06-18T00:00:00Z'], + 'resultsLimit' => 100, + ], + ]); +}); + test('telematics details use public id for consumer webhook URLs and do not read ember uuid', function () { $component = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.js'); $template = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.hbs'); @@ -444,17 +672,20 @@ public function fetchDevicesForTest(array $credentials): array ->toContain("method_exists(\$e, 'context') ? \$e->context() : []"); }); -test('telematics polling command is registered and scheduled for no webhook providers', function () { +test('telematics polling command is registered and scheduled for discovery providers by default', function () { $command = file_get_contents(__DIR__ . '/../src/Console/Commands/SyncTelematics.php'); $provider = file_get_contents(__DIR__ . '/../src/Providers/FleetOpsServiceProvider.php'); $details = file_get_contents(__DIR__ . '/../../addon/components/telematic/details.hbs'); expect($command) ->toContain("protected \$signature = 'fleetops:sync-telematics") + ->toContain('{--exclude-webhook-providers : Skip providers that support webhooks}') ->toContain('SyncTelematicDevicesJob::dispatch($telematic') - ->toContain('!$descriptor->supportsWebhooks') + ->toContain('$excludeWebhookProviders = (bool) $this->option(\'exclude-webhook-providers\')') + ->toContain('!$excludeWebhookProviders || !$descriptor->supportsWebhooks') ->toContain('$descriptor->supportsDiscovery') - ->toContain("whereIn('status', ['active', 'connected'])"); + ->toContain("whereIn('status', ['active', 'connected'])") + ->not->toContain('sync-webhook-providers'); expect($provider) ->toContain('Console\\Commands\\SyncTelematics::class') diff --git a/tests/integration/components/cell/attached-vehicle-test.js b/tests/integration/components/cell/attached-vehicle-test.js new file mode 100644 index 000000000..d962e2d57 --- /dev/null +++ b/tests/integration/components/cell/attached-vehicle-test.js @@ -0,0 +1,47 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | cell/attached-vehicle', function (hooks) { + setupRenderingTest(hooks); + + test('it delegates attached vehicles to the shared vehicle identity cell', async function (assert) { + this.set('device', { + attachable_uuid: 'vehicle-1', + attachable_type: 'fleet-ops:vehicle', + attached_to_name: 'Truck 100', + attachable: { + id: 'vehicle-1', + displayName: 'Truck 100', + plate_number: 'TRK-100', + online: true, + }, + }); + this.set('column', {}); + + await render(hbs``); + + assert.dom('[data-test-resource-identity-image]').exists(); + assert.dom('[data-test-resource-identity-status-dot]').exists(); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('-left-0.5'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('-top-0.5'); + assert.dom(this.element).includesText('Truck 100'); + assert.dom(this.element).includesText('TRK-100'); + }); + + test('it preserves the unattached fallback state', async function (assert) { + this.set('device', { + attachable_uuid: null, + attachable_type: 'fleet-ops:vehicle', + attached_to_name: null, + attachable: null, + }); + this.set('column', {}); + + await render(hbs``); + + assert.dom(this.element).includesText('Unattached'); + assert.dom('[data-test-resource-identity-image]').doesNotExist(); + }); +}); diff --git a/tests/integration/components/cell/resource-identities-test.js b/tests/integration/components/cell/resource-identities-test.js new file mode 100644 index 000000000..402c7cfa9 --- /dev/null +++ b/tests/integration/components/cell/resource-identities-test.js @@ -0,0 +1,346 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { click, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | cell resource identities', function (hooks) { + setupRenderingTest(hooks); + + test('driver identity renders status first and assigned vehicle without phone', async function (assert) { + this.set('driver', { + name: 'Ada Driver', + phone: '+15551234567', + status: 'active', + online: true, + vehicle_name: 'Truck 10', + }); + + await render(hbs``); + + assert.dom(this.element).includesText('Ada Driver'); + assert.dom(this.element).includesText('Active'); + assert.dom(this.element).includesText('Truck 10'); + assert.dom(this.element).doesNotIncludeText('+15551234567'); + assert.dom('[data-test-resource-identity-status-badge]').hasClass('order-first'); + }); + + test('driver identity compact mode renders image, name, and assigned vehicle only', async function (assert) { + assert.expect(18); + + this.set('driver', { + name: 'Compact Driver', + phone: '+15551234567', + status: 'available', + photo_url: 'https://example.test/driver.png', + vehicle_name: 'Truck 10', + }); + this.set('onClick', (driver) => { + assert.strictEqual(driver, this.driver, 'compact click receives the resolved driver resource'); + }); + + await render(hbs``); + + assert.dom('[data-test-driver-identity-compact]').exists(); + assert.dom('[data-test-driver-identity-compact]').includesText('Compact Driver'); + assert.dom('[data-test-driver-identity-compact] img').hasClass('h-5'); + assert.dom('[data-test-driver-identity-compact] img').hasClass('w-5'); + assert.dom('[data-test-driver-identity-compact] .text-sm').exists(); + assert.dom('[data-test-driver-identity-compact] .font-semibold').doesNotExist(); + assert.dom('[data-test-resource-identity-status-dot]').exists(); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('fa-2xs'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('left-0'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('top-0'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('-ml-1'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('-mt-1'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('text-green-500'); + assert.dom('[data-test-resource-identity-meta-badge]').exists({ count: 1 }); + assert.dom('[data-test-resource-identity-meta-badge]').hasText('Truck 10'); + assert.dom('[data-test-resource-identity-status-badge]').doesNotExist(); + assert.dom(this.element).doesNotIncludeText('+15551234567'); + + await click('[data-test-driver-identity-compact]'); + }); + + test('driver identity compact mode renders assigned vehicle from column context', async function (assert) { + this.set('vehicle', { + displayName: 'Parent Truck', + driver: { + name: 'Context Driver', + }, + }); + this.set('column', { + compact: true, + resourcePath: (vehicle) => vehicle.driver, + assignedVehicleLabel: (_driver, vehicle) => vehicle.displayName, + }); + + await render(hbs``); + + assert.dom('[data-test-driver-identity-compact]').exists(); + assert.dom('[data-test-driver-identity-compact]').includesText('Context Driver'); + assert.dom('[data-test-resource-identity-meta-badge]').exists({ count: 1 }); + assert.dom('[data-test-resource-identity-meta-badge]').hasText('Parent Truck'); + }); + + test('device identity renders imei badge and compact status only', async function (assert) { + this.set('device', { + displayName: 'Device 42', + imei: 'IMEI-42', + serial_number: 'SER-42', + attached_to_name: 'Truck 42', + telematic_name: 'AFAQY', + connection_status: 'online', + is_online: true, + }); + + await render(hbs``); + + assert.dom(this.element).includesText('Device 42'); + assert.dom(this.element).includesText('IMEI-42'); + assert.dom(this.element).includesText('Online'); + assert.dom('[data-test-resource-identity-status-badge]').hasText('Online'); + assert.dom(this.element).doesNotIncludeText('SER-42'); + assert.dom(this.element).doesNotIncludeText('Truck 42'); + assert.dom(this.element).doesNotIncludeText('AFAQY'); + }); + + test('device identity can suppress status text and badge', async function (assert) { + this.set('device', { + displayName: 'Device 42', + imei: 'IMEI-42', + connection_status: 'offline', + is_online: false, + }); + + await render(hbs``); + + assert.dom(this.element).includesText('Device 42'); + assert.dom(this.element).includesText('IMEI-42'); + assert.dom(this.element).doesNotIncludeText('Offline'); + assert.dom('[data-test-resource-identity-status-badge]').doesNotExist(); + }); + + test('device identity renders provider identifier badge when imei is missing', async function (assert) { + this.set('device', { + displayName: 'Device 99', + device_id: 'BX-025', + ident: '867747078951793', + }); + + await render(hbs``); + + assert.dom(this.element).includesText('Device 99'); + assert.dom('[data-test-resource-identity-meta-badge]').hasText('BX-025'); + }); + + test('device identity compact mode renders image and name only', async function (assert) { + assert.expect(17); + + this.set('device', { + displayName: 'Device Compact', + imei: 'IMEI-COMPACT', + connection_status: 'online', + photo_url: 'https://example.test/device.png', + }); + this.set('onClick', (device) => { + assert.strictEqual(device, this.device, 'compact click receives the resolved device resource'); + }); + + await render(hbs``); + + assert.dom('[data-test-device-identity-compact]').exists(); + assert.dom('[data-test-device-identity-compact]').includesText('Device Compact'); + assert.dom('[data-test-device-identity-compact] img').hasClass('h-5'); + assert.dom('[data-test-device-identity-compact] img').hasClass('w-5'); + assert.dom('[data-test-device-identity-compact] .text-sm').exists(); + assert.dom('[data-test-device-identity-compact] .font-semibold').doesNotExist(); + assert.dom('[data-test-resource-identity-status-dot]').exists(); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('fa-2xs'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('left-0'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('top-0'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('-ml-1'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('-mt-1'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('text-green-500'); + assert.dom('[data-test-resource-identity-meta-badge]').doesNotExist(); + assert.dom('[data-test-resource-identity-status-badge]').doesNotExist(); + assert.dom(this.element).doesNotIncludeText('IMEI-COMPACT'); + + await click('[data-test-device-identity-compact]'); + }); + + test('vehicle identity renders plate as badge metadata and toggles status badge by column config', async function (assert) { + this.set('vehicle', { + displayName: 'Truck 42', + plate_number: 'GBB-1042', + status: 'available', + online: true, + }); + + await render(hbs``); + + assert.dom(this.element).includesText('Truck 42'); + assert.dom('[data-test-resource-identity-meta-badge]').exists({ count: 1 }); + assert.dom('[data-test-resource-identity-meta-badge]').hasText('GBB-1042'); + assert.dom('[data-test-resource-identity-status-badge]').doesNotExist(); + + await render(hbs``); + + assert.dom('[data-test-resource-identity-status-badge]').exists(); + assert.dom('[data-test-resource-identity-status-badge]').hasText('Available'); + }); + + test('vehicle identity can suppress status text and badge', async function (assert) { + this.set('vehicle', { + displayName: 'Truck 42', + plate_number: 'GBB-1042', + status: 'available', + }); + + await render(hbs``); + + assert.dom(this.element).includesText('Truck 42'); + assert.dom(this.element).includesText('GBB-1042'); + assert.dom(this.element).doesNotIncludeText('Available'); + assert.dom('[data-test-resource-identity-status-badge]').doesNotExist(); + }); + + test('vehicle identity compact mode renders image, name, and assigned driver only', async function (assert) { + assert.expect(19); + + this.set('vehicle', { + displayName: 'Compact Truck', + plate_number: 'GBB-1042', + status: 'available', + photo_url: 'https://example.test/vehicle.png', + driver_name: 'Ada Driver', + }); + this.set('onClick', (vehicle) => { + assert.strictEqual(vehicle, this.vehicle, 'compact click receives the resolved vehicle resource'); + }); + + await render(hbs``); + + assert.dom('[data-test-vehicle-identity-compact]').exists(); + assert.dom('[data-test-vehicle-identity-compact]').includesText('Compact Truck'); + assert.dom('[data-test-vehicle-identity-compact] img').hasClass('h-5'); + assert.dom('[data-test-vehicle-identity-compact] img').hasClass('w-5'); + assert.dom('[data-test-vehicle-identity-compact] .text-sm').exists(); + assert.dom('[data-test-vehicle-identity-compact] .font-semibold').doesNotExist(); + assert.dom('[data-test-resource-identity-status-dot]').exists(); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('fa-2xs'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('left-0'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('top-0'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('-ml-1'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('-mt-1'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('text-green-500'); + assert.dom('[data-test-resource-identity-meta-badge]').exists({ count: 1 }); + assert.dom('[data-test-resource-identity-meta-badge]').hasText('Ada Driver'); + assert.dom('[data-test-resource-identity-status-badge]').doesNotExist(); + assert.dom(this.element).doesNotIncludeText('GBB-1042'); + assert.dom(this.element).doesNotIncludeText('Available'); + + await click('[data-test-vehicle-identity-compact]'); + }); + + test('vehicle identity renders fallback vehicle number badge for attached-device rows', async function (assert) { + this.set('vehicle', { + displayName: 'Attached Vehicle', + vehicle_number: 'vehicle_123', + }); + + await render(hbs``); + + assert.dom('[data-test-resource-identity-meta-badge]').exists({ count: 1 }); + assert.dom('[data-test-resource-identity-meta-badge]').hasText('vehicle_123'); + assert.dom('[data-test-resource-identity-status-badge]').doesNotExist(); + }); + + test('equipment identity renders type and serial/code/public id badges only', async function (assert) { + this.set('equipment', { + name: 'Generator', + type: 'generator', + serial_number: 'SN-900', + code: 'EQ-900', + public_id: 'equipment_public', + status: 'maintenance', + }); + + await render(hbs``); + + assert.dom(this.element).includesText('Generator'); + assert.dom(this.element).includesText('generator'); + assert.dom(this.element).includesText('SN-900'); + assert.dom(this.element).doesNotIncludeText('Maintenance'); + assert.dom('[data-test-resource-identity-meta-badge]').exists({ count: 2 }); + }); + + test('part identity renders type and inventory status as badge-style metadata', async function (assert) { + this.set('part', { + name: 'Brake Pad', + type: 'brake', + quantity_on_hand: 2, + is_low_stock: true, + is_in_stock: true, + }); + + await render(hbs``); + + assert.dom(this.element).includesText('Brake Pad'); + assert.dom(this.element).includesText('brake'); + assert.dom(this.element).includesText('Low Stock'); + assert.dom(this.element).doesNotIncludeText('2 on hand'); + assert.dom('[data-test-resource-identity-meta-badge]').exists({ count: 2 }); + }); + + test('assigned identity cells render only default empty text when resource is missing', async function (assert) { + this.set('vehicleRow', { + displayName: 'Mercedes 1025', + status: 'available', + driver_name: null, + }); + this.set('driverRow', { + name: 'Ken Driver', + status: 'available', + vehicle_name: null, + }); + this.set('missingDriverColumn', { + resourcePath: () => null, + }); + this.set('missingVehicleColumn', { + resourcePath: () => null, + }); + + await render(hbs` + + + `); + + assert.dom(this.element).doesNotIncludeText('Mercedes 1025'); + assert.dom(this.element).doesNotIncludeText('Ken Driver'); + assert.dom('[data-test-identity-empty-text]').exists({ count: 2 }); + assert.dom('[data-test-identity-empty-text]').hasText('- -'); + assert.dom('.table-cell-resource-identity').doesNotExist(); + assert.dom('[data-test-resource-identity-image]').doesNotExist(); + assert.dom('[data-test-resource-identity-status-dot]').doesNotExist(); + assert.dom('[data-test-resource-identity-meta-badge]').doesNotExist(); + assert.dom('[data-test-resource-identity-status-badge]').doesNotExist(); + }); + + test('assigned identity cells support custom empty text', async function (assert) { + this.set('vehicleRow', { + displayName: 'Mercedes 1025', + status: 'available', + driver_name: null, + }); + this.set('missingDriverColumn', { + resourcePath: () => null, + emptyText: 'No driver assigned', + }); + + await render(hbs``); + + assert.dom('[data-test-identity-empty-text]').hasText('No driver assigned'); + assert.dom(this.element).doesNotIncludeText('Mercedes 1025'); + assert.dom('.table-cell-resource-identity').doesNotExist(); + }); +}); diff --git a/tests/integration/components/cell/telematic-device-test.js b/tests/integration/components/cell/telematic-device-test.js new file mode 100644 index 000000000..b0a54c123 --- /dev/null +++ b/tests/integration/components/cell/telematic-device-test.js @@ -0,0 +1,57 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | cell/telematic-device', function (hooks) { + setupRenderingTest(hooks); + + test('it renders an online bulb over the device image', async function (assert) { + this.set('device', { + displayName: 'Gateway 100', + device_id: 'gw-100', + is_online: true, + connection_status: 'online', + }); + this.set('column', {}); + + await render(hbs``); + + assert.dom('[data-test-telematic-device-online-indicator]').exists(); + assert.dom('[data-test-telematic-device-online-indicator]').hasClass('text-green-500'); + assert.dom('[data-test-telematic-device-online-indicator]').hasClass('left-0'); + assert.dom('[data-test-telematic-device-online-indicator]').doesNotHaveClass('right-0'); + assert.dom('[data-test-telematic-device-image]').hasClass('rounded-sm'); + assert.dom('[data-test-telematic-device-image]').hasClass('border'); + assert.dom('[data-test-telematic-device-image]').hasClass('shadow-sm'); + assert.dom('[data-test-telematic-device-status-badge]').exists(); + assert.dom('[data-test-telematic-device-status-badge]').hasClass('online-status-badge'); + assert.dom('[data-test-telematic-device-status-badge]').hasClass('fleetops-device-status-badge'); + assert.dom('[data-test-telematic-device-status-badge]').hasText('Online'); + + const statusBadge = this.element.querySelector('[data-test-telematic-device-status-badge]'); + const identifier = this.element.querySelector('[data-test-telematic-device-identifier]'); + + assert.true(Boolean(statusBadge.compareDocumentPosition(identifier) & 4), 'status badge renders before the identifier'); + }); + + test('it renders an offline bulb over the device image', async function (assert) { + this.set('device', { + displayName: 'Gateway 101', + device_id: 'gw-101', + is_online: false, + connection_status: 'offline', + }); + this.set('column', {}); + + await render(hbs``); + + assert.dom('[data-test-telematic-device-online-indicator]').exists(); + assert.dom('[data-test-telematic-device-online-indicator]').hasClass('text-yellow-200'); + assert.dom('[data-test-telematic-device-online-indicator]').hasClass('left-0'); + assert.dom('[data-test-telematic-device-online-indicator]').doesNotHaveClass('right-0'); + assert.dom('[data-test-telematic-device-status-badge]').hasClass('offline-status-badge'); + assert.dom('[data-test-telematic-device-status-badge]').hasClass('fleetops-device-status-badge'); + assert.dom('[data-test-telematic-device-status-badge]').hasText('Offline'); + }); +}); diff --git a/tests/integration/components/cell/telematic-provider-test.js b/tests/integration/components/cell/telematic-provider-test.js new file mode 100644 index 000000000..15d02cae5 --- /dev/null +++ b/tests/integration/components/cell/telematic-provider-test.js @@ -0,0 +1,78 @@ +import { click, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; + +module('Integration | Component | cell/telematic-provider', function (hooks) { + setupRenderingTest(hooks); + + test('it constrains description width and delegates clicks', async function (assert) { + assert.expect(3); + + this.set('row', { + telematic_uuid: 'telematic_1', + telematic_name: 'AFAQY', + provider: 'afaqy', + provider_descriptor: { + description: 'Vehicle telemetry and location provider', + }, + }); + this.set('column', { + action: (telematic) => { + assert.strictEqual(telematic.id, 'telematic_1', 'click receives the telematic resource'); + }, + }); + + await render(hbs``); + + assert.dom('.max-w-\\[225px\\]').exists(); + assert.dom(this.element).includesText('Vehicle telemetry and location provider'); + + await click('button'); + }); + + test('it renders compact image and name without description', async function (assert) { + assert.expect(8); + + this.set('row', { + telematic_uuid: 'telematic_1', + telematic_name: 'AFAQY', + provider: 'afaqy', + provider_descriptor: { + icon: '/engines-dist/images/telematics/providers/afaqy.webp', + description: 'Vehicle telemetry and location provider', + }, + }); + this.set('column', { + compact: true, + action: (telematic) => { + assert.strictEqual(telematic.id, 'telematic_1', 'compact click receives the telematic resource'); + }, + }); + + await render(hbs``); + + assert.dom('[data-test-telematic-provider-compact]').exists(); + assert.dom('[data-test-telematic-provider-compact] img').hasClass('h-5'); + assert.dom('[data-test-telematic-provider-compact] img').hasClass('w-5'); + assert.dom('[data-test-telematic-provider-compact] .text-sm').exists(); + assert.dom('[data-test-telematic-provider-compact] .font-semibold').doesNotExist(); + assert.dom(this.element).includesText('AFAQY'); + assert.dom(this.element).doesNotIncludeText('Vehicle telemetry and location provider'); + + await click('button'); + }); + + test('it renders empty text when configured resourcePath resolves empty', async function (assert) { + this.set('row', { message: 'event without provider' }); + this.set('column', { + resourcePath: () => null, + emptyText: 'No provider', + }); + + await render(hbs``); + + assert.dom('[data-test-telematic-provider-empty-text]').hasText('No provider'); + assert.dom('button').doesNotExist(); + }); +}); diff --git a/tests/integration/components/device/form-test.js b/tests/integration/components/device/form-test.js index e52911785..c14517ceb 100644 --- a/tests/integration/components/device/form-test.js +++ b/tests/integration/components/device/form-test.js @@ -2,6 +2,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import DeviceFormComponent from '@fleetbase/fleetops-engine/components/device/form'; module('Integration | Component | device/form', function (hooks) { setupRenderingTest(hooks); @@ -23,4 +24,39 @@ module('Integration | Component | device/form', function (hooks) { assert.dom().hasText('template block text'); }); + + test('it only locks telematic selection for persisted provider-synced devices', function (assert) { + const newDevice = new DeviceFormComponent(this.owner, { + resource: { + isNew: true, + telematic_uuid: null, + }, + }); + + const persistedManualDevice = new DeviceFormComponent(this.owner, { + resource: { + isNew: false, + telematic_uuid: null, + }, + }); + + const persistedSyncedDevice = new DeviceFormComponent(this.owner, { + resource: { + isNew: false, + telematic_uuid: 'telematic_1', + }, + }); + + const persistedSyncedRelationshipDevice = new DeviceFormComponent(this.owner, { + resource: { + isNew: false, + telematic: { id: 'telematic_2' }, + }, + }); + + assert.false(newDevice.isTelematicLocked, 'new devices remain selectable'); + assert.false(persistedManualDevice.isTelematicLocked, 'persisted manual devices remain selectable'); + assert.true(persistedSyncedDevice.isTelematicLocked, 'persisted devices with telematic_uuid are locked'); + assert.true(persistedSyncedRelationshipDevice.isTelematicLocked, 'persisted devices with telematic relationship are locked'); + }); }); diff --git a/tests/integration/components/device/panel-header-test.js b/tests/integration/components/device/panel-header-test.js index 2d70bcbf6..85a3f8c9e 100644 --- a/tests/integration/components/device/panel-header-test.js +++ b/tests/integration/components/device/panel-header-test.js @@ -6,21 +6,47 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | device/panel-header', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + test('it renders compact operational device identity', async function (assert) { + this.set('resource', { + displayName: 'BX-046', + photo_url: 'https://example.test/device.png', + connection_status: 'offline', + provider: 'afaqy', + type: 'gps', + imei: '864022084024776', + attached_to_name: 'CLD-06', + last_online_at: '2026-06-18T15:28:00Z', + }); - await render(hbs``); + await render(hbs``); + + assert.dom().includesText('BX-046'); + assert.dom().includesText('Offline'); + assert.dom().includesText('Afaqy'); + assert.dom().includesText('864022084024776'); + assert.dom().includesText('CLD-06'); + assert.dom('img').hasClass('rounded-md'); + assert.dom('img').hasClass('shadow-sm'); + }); + + test('it falls back when device values are missing', async function (assert) { + this.set('resource', { + serial_number: 'SN-100', + is_online: true, + }); - assert.dom().hasText(''); + await render(hbs``); - // Template block usage: - await render(hbs` - - template block text - - `); + assert.dom().includesText('SN-100'); + assert.dom().includesText('Online'); + assert.dom().includesText('No last online'); + }); + + test('it renders safe fallbacks without a resource', async function (assert) { + await render(hbs``); - assert.dom().hasText('template block text'); + assert.dom().includesText('-'); + assert.dom().includesText('Offline'); + assert.dom().includesText('No last online'); }); }); diff --git a/tests/integration/components/device/panel-tabs-test.js b/tests/integration/components/device/panel-tabs-test.js new file mode 100644 index 000000000..3b148bfbe --- /dev/null +++ b/tests/integration/components/device/panel-tabs-test.js @@ -0,0 +1,115 @@ +import Component from '@glimmer/component'; +import Service from '@ember/service'; +import { setComponentTemplate } from '@ember/component'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +let queries; + +class StoreStub extends Service { + query(type, params) { + queries.push({ type, params }); + + if (type === 'sensor') { + return Promise.resolve([{ id: 'sensor_1', name: 'Temperature Sensor' }]); + } + + if (type === 'device-event') { + return Promise.resolve([{ id: 'event_1', event_type: 'ignition_on' }]); + } + + return Promise.resolve([]); + } +} + +class SensorActionsStub extends Service { + panel = { view() {} }; + transition = { view() {} }; +} + +class DeviceEventActionsStub extends Service { + panel = { view() {} }; + transition = { view() {} }; + + markProcessed() { + return Promise.resolve(); + } +} + +class TabularStub extends Component { + get rowCount() { + return this.args.data?.length ?? 0; + } + + get currentPage() { + return this.args.data?.meta?.current_page; + } +} + +module('Integration | Component | device/panel-tabs', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + queries = []; + + this.owner.register('service:store', StoreStub); + this.owner.register('service:sensor-actions', SensorActionsStub); + this.owner.register('service:device-event-actions', DeviceEventActionsStub); + this.owner.register( + 'component:layout/resource/tabular', + setComponentTemplate( + hbs` +
+ `, + TabularStub + ) + ); + + this.set('device', { id: 'device_1' }); + }); + + test('sensor tab renders compact array data with pagination disabled', async function (assert) { + await render(hbs``); + + assert.dom('[data-test-tabular]').hasAttribute('data-resource', 'sensor'); + assert.dom('[data-test-tabular]').hasAttribute('data-pagination', 'false'); + assert.dom('[data-test-tabular]').hasAttribute('data-row-count', '1'); + assert.dom('[data-test-tabular]').hasAttribute('data-current-page', '1'); + assert.deepEqual(queries[0], { + type: 'sensor', + params: { device_uuid: 'device_1', limit: 10, sort: '-updated_at' }, + }); + }); + + test('events tab renders compact array data with pagination disabled', async function (assert) { + await render(hbs``); + + assert.dom('[data-test-tabular]').hasAttribute('data-resource', 'device-event'); + assert.dom('[data-test-tabular]').hasAttribute('data-pagination', 'false'); + assert.dom('[data-test-tabular]').hasAttribute('data-row-count', '1'); + assert.dom('[data-test-tabular]').hasAttribute('data-current-page', '1'); + assert.deepEqual(queries[0], { + type: 'device-event', + params: { device_uuid: 'device_1', limit: 10, sort: '-created_at' }, + }); + }); + + test('tabs without a device keep empty array data safe for tabular rendering', async function (assert) { + this.set('device', null); + + await render(hbs``); + + assert.dom('[data-test-tabular]').hasAttribute('data-pagination', 'false'); + assert.dom('[data-test-tabular]').hasAttribute('data-row-count', '0'); + assert.dom('[data-test-tabular]').hasAttribute('data-current-page', '1'); + assert.deepEqual(queries, []); + }); +}); diff --git a/tests/integration/components/equipment/form-test.js b/tests/integration/components/equipment/form-test.js index 750e6f6b5..ba842a254 100644 --- a/tests/integration/components/equipment/form-test.js +++ b/tests/integration/components/equipment/form-test.js @@ -2,6 +2,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import EquipmentFormComponent from '@fleetbase/fleetops-engine/components/equipment/form'; module('Integration | Component | equipment/form', function (hooks) { setupRenderingTest(hooks); @@ -23,4 +24,33 @@ module('Integration | Component | equipment/form', function (hooks) { assert.dom().hasText('template block text'); }); + + test('it resolves alias and model class equipable types for asset selection', function (assert) { + const aliasComponent = new EquipmentFormComponent(this.owner, { + resource: { + equipable_type: 'fleet-ops:vehicle', + }, + }); + + assert.strictEqual(aliasComponent.equipableModelName, 'vehicle'); + assert.strictEqual(aliasComponent.selectedEquipableType.value, 'fleet-ops:vehicle'); + + const classComponent = new EquipmentFormComponent(this.owner, { + resource: { + equipable_type: 'Fleetbase\\FleetOps\\Models\\Vehicle', + }, + }); + + assert.strictEqual(classComponent.equipableModelName, 'vehicle'); + assert.strictEqual(classComponent.selectedEquipableType.value, 'fleet-ops:vehicle'); + + const driverClassComponent = new EquipmentFormComponent(this.owner, { + resource: { + equipable_type: 'Fleetbase\\FleetOps\\Models\\Driver', + }, + }); + + assert.strictEqual(driverClassComponent.equipableModelName, 'driver'); + assert.strictEqual(driverClassComponent.selectedEquipableType.value, 'fleet-ops:driver'); + }); }); diff --git a/tests/integration/components/layout/fleet-ops-sidebar-test.js b/tests/integration/components/layout/fleet-ops-sidebar-test.js index 76ee3fbaf..78275f46e 100644 --- a/tests/integration/components/layout/fleet-ops-sidebar-test.js +++ b/tests/integration/components/layout/fleet-ops-sidebar-test.js @@ -1,13 +1,13 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { click, fillIn, render, waitFor } from '@ember/test-helpers'; +import { click, fillIn, render, settled, waitFor } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import Service from '@ember/service'; import window from 'ember-window-mock'; import { getOwner } from '@ember/application'; class RouterStubService extends Service { - currentRouteName = 'console.fleet-ops.operations.orders'; + currentRouteName = 'console.fleet-ops.operations.orders.index'; currentURL = '/fleet-ops'; transitions = []; handlers = {}; @@ -60,6 +60,66 @@ module('Integration | Component | layout/fleet-ops-sidebar', function (hooks) { assert.verifySteps(['create-order']); }); + test('it starts on the root menu for the default FleetOps landing route', async function (assert) { + const router = this.owner.lookup('service:router'); + router.currentRouteName = 'console.fleet-ops.operations.orders.index'; + router.currentURL = '/fleet-ops'; + + await render(hbs``); + + assert.dom('.next-sidebar-navigator-back').doesNotExist(); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type').includesText('Operations'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type').hasClass('is-parent-active'); + assert.dom('.next-sidebar-navigator-view-in').includesText('Resources'); + assert.dom('.fleet-ops-operations-monitor').exists('Live Operations remains visible on the root menu'); + + router.triggerRouteDidChange(); + await settled(); + + assert.dom('.next-sidebar-navigator-back').doesNotExist('the first routeDidChange for the default landing route still keeps root-first entry'); + + router.currentRouteName = 'console.fleet-ops.management.vehicles.index'; + router.currentURL = '/fleet-ops/manage/vehicles'; + router.triggerRouteDidChange(); + await settled(); + + assert.dom('.next-sidebar-navigator-back').includesText('Resources', 'later route changes still sync nested state normally'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item').includesText('Vehicles'); + }); + + test('it opens the matching nested menu for specific initial FleetOps routes', async function (assert) { + const router = this.owner.lookup('service:router'); + router.currentRouteName = 'console.fleet-ops.management.vehicles.index'; + router.currentURL = '/fleet-ops/manage/vehicles'; + + await render(hbs``); + + assert.dom('.next-sidebar-navigator-back').includesText('Resources'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item').includesText('Vehicles'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(3)').hasClass('is-active'); + + router.currentRouteName = 'console.fleet-ops.connectivity.telematics.index'; + router.currentURL = '/fleet-ops/connectivity/telematics'; + router.triggerRouteDidChange(); + await settled(); + + assert.dom('.next-sidebar-navigator-back').includesText('Connectivity'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type').includesText('Telematics'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type').hasClass('is-active'); + }); + + test('it opens Operations nested for non-default order entry routes', async function (assert) { + const router = this.owner.lookup('service:router'); + router.currentRouteName = 'console.fleet-ops.operations.orders.index.details.index'; + router.currentURL = '/fleet-ops/orders/order_123'; + + await render(hbs``); + + assert.dom('.next-sidebar-navigator-back').includesText('Operations'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type').includesText('Orders'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type').hasClass('is-active'); + }); + test('it labels the operations landing as Orders and exposes live map keywords', async function (assert) { await render(hbs``); @@ -201,6 +261,40 @@ module('Integration | Component | layout/fleet-ops-sidebar', function (hooks) { assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)').hasClass('is-active'); }); + test('it opens registry item nested context on initial virtual route entry', async function (assert) { + const contractsItem = { + title: 'Contracts', + slug: 'contracts', + section: 'management', + icon: 'file-signature', + priority: -10, + visible: true, + }; + + class MenuServiceStub extends Service { + getMenuItems() { + return [contractsItem]; + } + + getMenuPanels() { + return []; + } + } + + this.owner.register('service:universe/menu-service', MenuServiceStub); + + const router = this.owner.lookup('service:router'); + router.currentRouteName = 'console.fleet-ops.virtual'; + router.currentURL = '/fleet-ops/management/contracts'; + window.location.href = '/fleet-ops/management/contracts'; + + await render(hbs``); + + assert.dom('.next-sidebar-navigator-back').includesText('Resources'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)').includesText('Contracts'); + assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)').hasClass('is-active'); + }); + test('it keeps block usage backwards compatible', async function (assert) { await render(hbs` diff --git a/tests/integration/components/order/form/service-rate-test.js b/tests/integration/components/order/form/service-rate-test.js index e049fc5bc..8f803c0cb 100644 --- a/tests/integration/components/order/form/service-rate-test.js +++ b/tests/integration/components/order/form/service-rate-test.js @@ -42,6 +42,48 @@ module('Integration | Component | order/form/service-rate', function (hooks) { assert.dom('.ember-power-select-search-input').exists(); }); + test('service rate toggle loads options into the selector', async function (assert) { + const calls = []; + + class ServiceRateActionsStub extends Service { + queryServiceRatesForOrder = { + perform(order) { + calls.push(order); + return Promise.resolve([ + { + id: 'service_rate_route', + service_name: 'Local Route Rate', + }, + ]); + }, + }; + } + + this.owner.register('service:service-rate-actions', ServiceRateActionsStub); + + this.set('resource', { + servicable: false, + order_config: {}, + payloadCoordinates: [ + [103.8845049, 1.3621663], + [103.86458, 1.353151], + ], + payload: { + payloadCoordinates: [ + [103.8845049, 1.3621663], + [103.86458, 1.353151], + ], + }, + }); + + await render(hbs``); + await click('[role="checkbox"]'); + await waitUntil(() => calls.length === 1, { timeout: 1000 }); + await click('.ember-power-select-trigger'); + + assert.dom('.ember-power-select-option').hasText('Local Route Rate'); + }); + test('service quote refresh events run debounced quote lookup for the matching order', async function (assert) { const calls = []; const resource = { diff --git a/tests/integration/components/sensor/panel-header-test.js b/tests/integration/components/sensor/panel-header-test.js index 216cc9c47..2d8fcfd7e 100644 --- a/tests/integration/components/sensor/panel-header-test.js +++ b/tests/integration/components/sensor/panel-header-test.js @@ -6,21 +6,43 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | sensor/panel-header', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + test('it renders compact operational sensor identity', async function (assert) { + this.set('resource', { + name: 'Fuel Level Sensor', + photo_url: 'https://example.test/sensor.png', + status: 'active', + threshold_status: 'normal', + type: 'fuel_level', + serial_number: 'SNS-22', + last_value: 72, + unit: '%', + device: { + displayName: 'BX-046', + }, + last_reading_at: '2026-06-18T15:28:00Z', + }); - await render(hbs``); + await render(hbs``); - assert.dom().hasText(''); + assert.dom().includesText('Fuel Level Sensor'); + assert.dom().includesText('Active'); + assert.dom().includesText('Normal'); + assert.dom().includesText('SNS-22'); + assert.dom().includesText('72 %'); + assert.dom().includesText('BX-046'); + assert.dom('img').hasClass('rounded-md'); + }); + + test('it falls back when sensor values are missing', async function (assert) { + this.set('resource', { + imei: 'IMEI-88', + status: 'inactive', + }); - // Template block usage: - await render(hbs` - - template block text - - `); + await render(hbs``); - assert.dom().hasText('template block text'); + assert.dom().includesText('IMEI-88'); + assert.dom().includesText('Inactive'); + assert.dom().includesText('No reading yet'); }); }); diff --git a/tests/integration/components/vendor/panel-header-test.js b/tests/integration/components/vendor/panel-header-test.js new file mode 100644 index 000000000..eef0db9f2 --- /dev/null +++ b/tests/integration/components/vendor/panel-header-test.js @@ -0,0 +1,46 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | vendor/panel-header', function (hooks) { + setupRenderingTest(hooks); + + test('it renders compact vendor identity', async function (assert) { + this.set('resource', { + name: 'Acme Transport', + logo_url: 'https://example.test/vendor.png', + status: 'active', + business_id: 'BRN-100', + type: 'carrier', + email: 'ops@example.test', + phone: '+18005550100', + country: 'US', + address_street: '100 Fleet St', + website_url: 'https://example.test', + }); + + await render(hbs``); + + assert.dom().includesText('Acme Transport'); + assert.dom().includesText('Active'); + assert.dom().includesText('BRN-100'); + assert.dom().includesText('Carrier'); + assert.dom().includesText('ops@example.test'); + assert.dom().includesText('+18005550100'); + assert.dom().includesText('100 Fleet St'); + assert.dom().includesText('https://example.test'); + assert.dom('img').hasClass('rounded-md'); + }); + + test('it falls back when vendor values are missing', async function (assert) { + this.set('resource', { + public_id: 'vendor_123', + }); + + await render(hbs``); + + assert.dom().includesText('vendor_123'); + assert.dom().includesText('Active'); + }); +}); diff --git a/tests/unit/controllers/connectivity/devices/index-test.js b/tests/unit/controllers/connectivity/devices/index-test.js index a1713977c..5d3f749c7 100644 --- a/tests/unit/controllers/connectivity/devices/index-test.js +++ b/tests/unit/controllers/connectivity/devices/index-test.js @@ -1,12 +1,105 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +class DeviceActionsStub extends Service { + panel = { + view() {}, + edit() {}, + }; + + refresh() {} + import() {} + export() {} + attachToVehicle() {} + detachFromVehicle() {} + delete() {} + + transition = { + create() {}, + view() {}, + edit() {}, + }; +} + +class TelematicActionsStub extends Service { + transition = { + view() {}, + }; +} + +class VehicleActionsStub extends Service { + panel = { + view() {}, + }; +} module('Unit | Controller | connectivity/devices/index', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { let controller = this.owner.lookup('controller:connectivity/devices/index'); assert.ok(controller); }); + + test('query params expose device inventory filters', function (assert) { + const controller = this.owner.lookup('controller:connectivity/devices/index'); + + assert.deepEqual( + controller.queryParams.filter((param) => + ['connection_status', 'vehicle', 'provider', 'device_id', 'type', 'serial_number', 'last_online_at', 'attachment_state'].includes(param) + ), + ['attachment_state', 'provider', 'vehicle', 'connection_status', 'device_id', 'type', 'serial_number', 'last_online_at'], + 'device inventory query params include provider, vehicle, connection, identity, type, serial, last seen, and attachment filters' + ); + }); + + test('columns expose telematic device, provider, vehicle, sensors, and connection contracts', function (assert) { + this.owner.register('service:device-actions', DeviceActionsStub); + this.owner.register('service:telematic-actions', TelematicActionsStub); + this.owner.register('service:vehicle-actions', VehicleActionsStub); + + const controller = this.owner.lookup('controller:connectivity/devices/index'); + const deviceColumn = controller.columns.find((column) => column.label === 'Telematic Device'); + const providerColumn = controller.columns.find((column) => column.label === 'Telematic Provider'); + const vehicleColumn = controller.columns.find((column) => column.label === 'Vehicle'); + const sensorColumn = controller.columns.find((column) => column.label === 'Sensors'); + const connectionColumn = controller.columns.find((column) => column.label === 'Connection'); + const actionsColumn = controller.columns.find((column) => column.cellComponent === 'table/cell/dropdown'); + const [viewAction, editAction] = actionsColumn.actions; + const visibleColumnOrder = controller.columns.filter((column) => !column.hidden).map((column) => column.label); + + assert.strictEqual(deviceColumn.cellComponent, 'cell/device-identity', 'device identity uses shared identity wrapper'); + assert.strictEqual(deviceColumn.showStatus, false, 'device identity suppresses duplicate connection status in this index'); + assert.strictEqual(deviceColumn.action, controller.deviceActions.transition.view, 'global device identity transitions to the device details route'); + assert.deepEqual( + visibleColumnOrder.slice(0, 4), + ['Telematic Device', 'Connection', 'Telematic Provider', 'Vehicle'], + 'visible columns place connection after device and vehicle after provider' + ); + assert.strictEqual(providerColumn.cellComponent, 'cell/telematic-provider', 'provider uses provider cell'); + assert.true(providerColumn.compact, 'provider cell uses compact index layout'); + assert.strictEqual(providerColumn.filterParam, 'telematic', 'provider connection filter remains model-backed'); + assert.strictEqual(vehicleColumn.cellComponent, 'cell/vehicle-identity', 'vehicle uses shared vehicle identity cell'); + assert.strictEqual(vehicleColumn.filterParam, 'vehicle', 'vehicle filter maps to vehicle query param'); + assert.strictEqual(vehicleColumn.showStatusBadge, true, 'attached vehicle column renders vehicle status as a compact badge'); + assert.strictEqual( + vehicleColumn.resourcePath({ + attachable_uuid: 'vehicle_1', + attached_to_name: 'Truck 1', + }).vehicle_number, + 'vehicle_1', + 'fallback attached vehicle resource provides badge metadata' + ); + assert.strictEqual(sensorColumn.valuePath, 'sensors_count', 'sensor count column reads backend count'); + assert.false(sensorColumn.sortable, 'sensor count is display-only to avoid fragile aggregate sorting'); + assert.strictEqual(connectionColumn.filterParam, 'connection_status', 'connection filter maps to backend connection status'); + assert.deepEqual( + connectionColumn.filterOptions.map((option) => option.value), + ['online', 'recently_offline', 'offline', 'long_offline', 'never_connected'], + 'connection filter options match backend status buckets' + ); + assert.strictEqual(viewAction.fn, controller.deviceActions.transition.view, 'global dropdown view transitions to the device details route'); + assert.strictEqual(editAction.fn, controller.deviceActions.transition.edit, 'global dropdown edit transitions to the device edit route'); + }); }); diff --git a/tests/unit/controllers/connectivity/devices/index/details/vehicle-test.js b/tests/unit/controllers/connectivity/devices/index/details/vehicle-test.js index a3e1874cd..8a7f33b33 100644 --- a/tests/unit/controllers/connectivity/devices/index/details/vehicle-test.js +++ b/tests/unit/controllers/connectivity/devices/index/details/vehicle-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; module('Unit | Controller | connectivity/devices/index/details/vehicle', function (hooks) { setupTest(hooks); @@ -8,4 +9,100 @@ module('Unit | Controller | connectivity/devices/index/details/vehicle', functio let controller = this.owner.lookup('controller:connectivity/devices/index/details/vehicle'); assert.ok(controller); }); + + test('vehicle model exposes attached vehicle and latest positions', function (assert) { + const controller = this.owner.lookup('controller:connectivity/devices/index/details/vehicle'); + const vehicle = { id: 'vehicle_1', displayName: 'Truck 1', location: { coordinates: [106, 47] } }; + const device = { id: 'device_1', attachable_uuid: 'vehicle_1', attachable: vehicle, attached_to_name: 'Truck 1' }; + const positions = [{ id: 'position_1' }]; + + controller.model = { device, positions }; + + assert.strictEqual(controller.device, device, 'device is read from route model hash'); + assert.strictEqual(controller.vehicle, vehicle, 'attached vehicle is exposed'); + assert.deepEqual(controller.positions, positions, 'latest positions are exposed'); + assert.true(controller.hasPositions, 'positions state is true when positions are loaded'); + assert.true(controller.canOpenVehicle, 'open vehicle action is available'); + assert.true(controller.canLocateVehicle, 'locate action is available when location context exists'); + }); + + test('open vehicle actions route to vehicle details and positions', function (assert) { + assert.expect(4); + + class HostRouterStub extends Service { + transitionTo(route, model) { + if (route.endsWith('.positions')) { + assert.strictEqual(route, 'console.fleet-ops.management.vehicles.index.details.positions', 'opens vehicle positions tab'); + } else { + assert.strictEqual(route, 'console.fleet-ops.management.vehicles.index.details', 'opens vehicle detail route'); + } + + assert.strictEqual(model.id, 'vehicle_1', 'vehicle model is passed through'); + } + } + + this.owner.register('service:host-router', HostRouterStub); + + const controller = this.owner.lookup('controller:connectivity/devices/index/details/vehicle'); + controller.model = { + device: { + attachable: { id: 'vehicle_1', displayName: 'Truck 1' }, + }, + positions: [], + }; + + controller.openVehicle(); + controller.openVehiclePositions(); + }); + + test('locate vehicle transitions to live map and focuses attached vehicle', async function (assert) { + assert.expect(6); + + const vehicle = { id: 'vehicle_1', displayName: 'Truck 1', location: { coordinates: [106, 47] } }; + + class HostRouterStub extends Service { + transitionTo(route, options) { + assert.strictEqual(route, 'console.fleet-ops.operations.orders.index', 'locate transitions to live map'); + assert.deepEqual(options, { queryParams: { layout: 'map' } }, 'locate requests map layout'); + + return Promise.resolve(); + } + } + + class MapManagerStub extends Service { + waitForMap(options) { + assert.deepEqual(options, { timeoutMs: 8000 }, 'locate waits for map readiness'); + + return Promise.resolve(); + } + + focusResource(focusedVehicle, zoom, options) { + assert.strictEqual(focusedVehicle, vehicle, 'attached vehicle is focused'); + assert.strictEqual(zoom, 16, 'expected zoom is used'); + options.moveend(); + } + } + + class VehicleActionsStub extends Service { + panel = { + view(focusedVehicle, options) { + assert.deepEqual({ id: focusedVehicle.id, options }, { id: 'vehicle_1', options: { closeOnTransition: true } }, 'vehicle panel opens after focus'); + }, + }; + } + + this.owner.register('service:host-router', HostRouterStub); + this.owner.register('service:map-manager', MapManagerStub); + this.owner.register('service:vehicle-actions', VehicleActionsStub); + + const controller = this.owner.lookup('controller:connectivity/devices/index/details/vehicle'); + controller.model = { + device: { + attachable: vehicle, + }, + positions: [], + }; + + await controller.locateVehicle(); + }); }); diff --git a/tests/unit/controllers/connectivity/events/index/details-test.js b/tests/unit/controllers/connectivity/events/details-test.js similarity index 62% rename from tests/unit/controllers/connectivity/events/index/details-test.js rename to tests/unit/controllers/connectivity/events/details-test.js index f7c36b136..9aa7cd725 100644 --- a/tests/unit/controllers/connectivity/events/index/details-test.js +++ b/tests/unit/controllers/connectivity/events/details-test.js @@ -1,12 +1,11 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; -module('Unit | Controller | connectivity/events/index/details', function (hooks) { +module('Unit | Controller | connectivity/events/details', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { - let controller = this.owner.lookup('controller:connectivity/events/index/details'); + let controller = this.owner.lookup('controller:connectivity/events/details'); assert.ok(controller); }); }); diff --git a/tests/unit/controllers/connectivity/events/index-test.js b/tests/unit/controllers/connectivity/events/index-test.js index ae1818be1..6d0cb4d24 100644 --- a/tests/unit/controllers/connectivity/events/index-test.js +++ b/tests/unit/controllers/connectivity/events/index-test.js @@ -1,12 +1,144 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +class IntlServiceStub extends Service { + t(key) { + return key; + } +} + +class DeviceEventActionsStub extends Service { + transition = { view() {} }; + refresh() {} + markProcessed() {} +} + +class DeviceActionsStub extends Service { + openedDevice; + panel = { + view: (device) => { + this.openedDevice = device; + }, + }; +} + +class HostRouterStub extends Service { + refresh() {} +} + +class StoreStub extends Service { + peekRecord() { + return null; + } + + findRecord() { + return Promise.resolve({ id: 'device_1', displayName: 'Resolved Device' }); + } +} + +class TelematicActionsStub extends Service { + transition = { view() {} }; +} module('Unit | Controller | connectivity/events/index', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. + hooks.beforeEach(function () { + this.owner.register('service:intl', IntlServiceStub); + this.owner.register('service:device-event-actions', DeviceEventActionsStub); + this.owner.register('service:device-actions', DeviceActionsStub); + this.owner.register('service:host-router', HostRouterStub); + this.owner.register('service:store', StoreStub); + this.owner.register('service:telematic-actions', TelematicActionsStub); + }); + test('it exists', function (assert) { let controller = this.owner.lookup('controller:connectivity/events/index'); assert.ok(controller); }); + + test('it exposes backend-supported query params and identity columns', function (assert) { + let controller = this.owner.lookup('controller:connectivity/events/index'); + + assert.deepEqual(controller.queryParams, ['page', 'limit', 'sort', 'query', 'telematic', 'device', 'event_type', 'severity', 'processed', 'occurred_at', 'created_at', 'updated_at']); + + let eventColumn = controller.columns.find((column) => column.label === 'Event'); + let deviceColumn = controller.columns.find((column) => column.label === 'Device'); + let providerColumn = controller.columns.find((column) => column.label === 'Provider'); + let processedColumn = controller.columns.find((column) => column.label === 'Processed'); + let occurredColumn = controller.columns.find((column) => column.label === 'Occurred'); + + assert.strictEqual(eventColumn.filterParam, 'event_type'); + assert.strictEqual(deviceColumn.cellComponent, 'cell/device-identity'); + assert.true(deviceColumn.compact); + assert.strictEqual(deviceColumn.showStatus, false); + assert.strictEqual(deviceColumn.filterParam, 'device'); + assert.strictEqual(providerColumn.cellComponent, 'cell/telematic-provider'); + assert.true(providerColumn.compact); + assert.strictEqual(providerColumn.filterParam, 'telematic'); + assert.strictEqual(processedColumn.filterParam, 'processed'); + assert.strictEqual(occurredColumn.filterParam, 'occurred_at'); + + assert.ok( + controller.columns.find((column) => column.label === 'Message'), + 'renders message' + ); + assert.ok( + controller.columns.find((column) => column.label === 'Code'), + 'renders code' + ); + assert.ok( + controller.columns.find((column) => column.label === 'IDENT'), + 'renders ident' + ); + assert.ok( + controller.columns.find((column) => column.label === 'Protocol'), + 'renders protocol' + ); + assert.ok( + controller.columns.find((column) => column.label === 'State'), + 'renders state' + ); + }); + + test('it builds event fallback resources and resolves devices before opening panel', async function (assert) { + let controller = this.owner.lookup('controller:connectivity/events/index'); + let deviceColumn = controller.columns.find((column) => column.label === 'Device'); + let providerColumn = controller.columns.find((column) => column.label === 'Provider'); + let event = { + device_uuid: 'device_1', + device_name: 'BX-025', + ident: '867747078951793', + provider: 'afaqy', + provider_descriptor: { + label: 'AFAQY', + }, + }; + + assert.deepEqual(deviceColumn.resourcePath(event), { + id: 'device_1', + displayName: 'BX-025', + name: 'BX-025', + imei: '867747078951793', + device_id: '867747078951793', + ident: '867747078951793', + serial_number: undefined, + connection_status: undefined, + status: undefined, + photo_url: undefined, + }); + assert.deepEqual(providerColumn.resourcePath(event), { + id: undefined, + name: 'afaqy', + provider: 'afaqy', + provider_descriptor: { + label: 'AFAQY', + }, + }); + + await controller.openDevice({ id: 'device_1', displayName: 'BX-025' }); + + assert.deepEqual(controller.deviceActions.openedDevice, { id: 'device_1', displayName: 'Resolved Device' }); + }); }); diff --git a/tests/unit/controllers/connectivity/sensors/index-test.js b/tests/unit/controllers/connectivity/sensors/index-test.js index ee0510eed..6c6a75446 100644 --- a/tests/unit/controllers/connectivity/sensors/index-test.js +++ b/tests/unit/controllers/connectivity/sensors/index-test.js @@ -1,12 +1,132 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +class IntlServiceStub extends Service { + t(key) { + return key; + } +} + +class SensorActionsStub extends Service { + transition = { view() {}, edit() {}, create() {} }; + refresh() {} + import() {} + export() {} + bulkDelete() {} + delete() {} +} + +class DeviceActionsStub extends Service { + openedDevice; + panel = { + view: (device) => { + this.openedDevice = device; + }, + }; +} + +class StoreStub extends Service { + peekRecord() { + return { id: 'device_1', displayName: 'Cached Device' }; + } + + findRecord() { + throw new Error('findRecord should not run when cached'); + } +} + +class TelematicActionsStub extends Service { + transition = { view() {} }; +} module('Unit | Controller | connectivity/sensors/index', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. + hooks.beforeEach(function () { + this.owner.register('service:intl', IntlServiceStub); + this.owner.register('service:sensor-actions', SensorActionsStub); + this.owner.register('service:device-actions', DeviceActionsStub); + this.owner.register('service:store', StoreStub); + this.owner.register('service:telematic-actions', TelematicActionsStub); + }); + test('it exists', function (assert) { let controller = this.owner.lookup('controller:connectivity/sensors/index'); assert.ok(controller); }); + + test('it exposes backend-supported query params and identity columns', function (assert) { + let controller = this.owner.lookup('controller:connectivity/sensors/index'); + + assert.deepEqual(controller.queryParams, [ + 'page', + 'limit', + 'sort', + 'query', + 'telematic', + 'device', + 'type', + 'status', + 'serial_number', + 'imei', + 'last_reading_at', + 'created_at', + 'updated_at', + ]); + + let telematicColumn = controller.columns.find((column) => column.label === 'Telematic'); + let deviceColumn = controller.columns.find((column) => column.label === 'Device'); + let typeColumn = controller.columns.find((column) => column.label === 'Type'); + let serialColumn = controller.columns.find((column) => column.label === 'Serial Number'); + let imeiColumn = controller.columns.find((column) => column.label === 'IMEI'); + let lastReadingColumn = controller.columns.find((column) => column.label === 'Last Reading'); + + assert.strictEqual(telematicColumn.cellComponent, 'cell/telematic-provider'); + assert.true(telematicColumn.compact); + assert.strictEqual(telematicColumn.filterParam, 'telematic'); + assert.strictEqual(deviceColumn.cellComponent, 'cell/device-identity'); + assert.strictEqual(deviceColumn.filterParam, 'device'); + assert.strictEqual(typeColumn.cellComponent, 'table/cell/base'); + assert.true(typeColumn.humanize); + assert.strictEqual(serialColumn.filterParam, 'serial_number'); + assert.strictEqual(imeiColumn.filterParam, 'imei'); + assert.strictEqual(lastReadingColumn.filterParam, 'last_reading_at'); + + assert.ok( + controller.columns.find((column) => column.label === 'Last Value'), + 'renders last value' + ); + assert.ok( + controller.columns.find((column) => column.label === 'Unit'), + 'renders unit' + ); + assert.ok( + controller.columns.find((column) => column.label === 'Threshold'), + 'renders threshold status' + ); + }); + + test('it builds sensor fallback resources and opens cached device panel', async function (assert) { + let controller = this.owner.lookup('controller:connectivity/sensors/index'); + let telematicColumn = controller.columns.find((column) => column.label === 'Telematic'); + let deviceColumn = controller.columns.find((column) => column.label === 'Device'); + let sensor = { + device_uuid: 'device_1', + device_name: 'BX-025', + ident: '867747078951793', + provider: 'afaqy', + provider_descriptor: { + label: 'AFAQY', + }, + }; + + assert.strictEqual(deviceColumn.resourcePath(sensor).imei, '867747078951793'); + assert.strictEqual(deviceColumn.resourcePath(sensor).device_id, '867747078951793'); + assert.deepEqual(telematicColumn.resourcePath(sensor).provider_descriptor, { label: 'AFAQY' }); + + await controller.openDevice({ id: 'device_1', displayName: 'BX-025' }); + + assert.deepEqual(controller.deviceActions.openedDevice, { id: 'device_1', displayName: 'Cached Device' }); + }); }); diff --git a/tests/unit/controllers/connectivity/telematics/index/details/devices-test.js b/tests/unit/controllers/connectivity/telematics/index/details/devices-test.js index 2c450a284..b5025949e 100644 --- a/tests/unit/controllers/connectivity/telematics/index/details/devices-test.js +++ b/tests/unit/controllers/connectivity/telematics/index/details/devices-test.js @@ -1,5 +1,13 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +class DeviceActionsStub extends Service { + panel = { + view() {}, + edit() {}, + }; +} module('Unit | Controller | connectivity/telematics/index/details/devices', function (hooks) { setupTest(hooks); @@ -17,10 +25,28 @@ module('Unit | Controller | connectivity/telematics/index/details/devices', func test('device filters expose vehicle and connection query contracts', function (assert) { const controller = this.owner.lookup('controller:connectivity/telematics/index/details/devices'); + const deviceColumn = controller.columns.find((column) => column.label === 'Telematic Device'); const vehicleColumn = controller.columns.find((column) => column.label === 'Vehicle'); const connectionColumn = controller.columns.find((column) => column.label === 'Connection'); const attachmentColumn = controller.columns.find((column) => column.label === 'Attachment'); + const visibleColumnOrder = controller.columns.filter((column) => !column.hidden).map((column) => column.label); + assert.strictEqual(deviceColumn.showStatus, false, 'device identity suppresses duplicate connection status in the telematics device tab'); + assert.deepEqual( + visibleColumnOrder.slice(0, 4), + ['Telematic Device', 'Connection', 'Provider ID / IMEI', 'Vehicle'], + 'visible columns place connection after device and provider id before vehicle when provider is hidden' + ); + assert.strictEqual(vehicleColumn.cellComponent, 'cell/vehicle-identity', 'vehicle uses shared vehicle identity cell'); + assert.strictEqual(vehicleColumn.showStatusBadge, true, 'vehicle identity renders attached vehicle status as a compact badge'); + assert.strictEqual( + vehicleColumn.resourcePath({ + attachable_uuid: 'vehicle_1', + attached_to_name: 'Truck 1', + }).vehicle_number, + 'vehicle_1', + 'fallback attached vehicle resource provides badge metadata' + ); assert.strictEqual(vehicleColumn.filterComponent, 'filter/model', 'vehicle uses model selector'); assert.strictEqual(vehicleColumn.filterParam, 'vehicle', 'vehicle filter uses vehicle query param'); assert.strictEqual(vehicleColumn.model, 'vehicle', 'vehicle filter queries vehicle records'); @@ -52,6 +78,8 @@ module('Unit | Controller | connectivity/telematics/index/details/devices', func controller.vehicle = 'vehicle_123'; controller.connection_status = 'online'; controller.device_id = 'provider-1'; + controller.type = 'gps_tracker'; + controller.serial_number = 'SN-1'; controller.last_online_at = '2026-06-17'; controller.updated_at = '2026-06-17'; controller.page = 5; @@ -65,8 +93,111 @@ module('Unit | Controller | connectivity/telematics/index/details/devices', func assert.strictEqual(controller.vehicle, null); assert.strictEqual(controller.connection_status, null); assert.strictEqual(controller.device_id, null); + assert.strictEqual(controller.type, null); + assert.strictEqual(controller.serial_number, null); assert.strictEqual(controller.last_online_at, null); assert.strictEqual(controller.updated_at, null); assert.strictEqual(controller.page, 1); }); + + test('device row view actions open overlay panels', function (assert) { + this.owner.register('service:device-actions', DeviceActionsStub); + + const controller = this.owner.lookup('controller:connectivity/telematics/index/details/devices'); + const nameColumn = controller.columns.find((column) => column.valuePath === 'displayName'); + const actionsColumn = controller.columns.find((column) => column.cellComponent === 'table/cell/dropdown'); + const [viewAction, editAction] = actionsColumn.actions; + + assert.strictEqual(nameColumn.cellComponent, 'cell/device-identity', 'device row uses the shared device identity cell'); + assert.strictEqual(nameColumn.action, controller.deviceActions.panel.view, 'device name opens the device panel'); + assert.strictEqual(viewAction.fn, controller.deviceActions.panel.view, 'dropdown view opens the device panel'); + assert.strictEqual(editAction.fn, controller.deviceActions.panel.edit, 'dropdown edit opens the device panel'); + }); + + test('attached vehicle actions only show for vehicle-attached devices', async function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/index/details/devices'); + const actionsColumn = controller.columns.find((column) => column.cellComponent === 'table/cell/dropdown'); + const separator = actionsColumn.actions.find((action) => action.separator && action.isVisible); + const viewVehicleAction = actionsColumn.actions.find((action) => action.label === 'View attached vehicle'); + const locateVehicleAction = actionsColumn.actions.find((action) => action.label === 'Locate attached vehicle on map'); + const vehicle = { id: 'vehicle_1', displayName: 'Truck 1' }; + const attachedDevice = { + attachable_uuid: 'vehicle_1', + attachable_type: 'fleet-ops:vehicle', + attachable: vehicle, + }; + const unattachedDevice = { + attachable_uuid: null, + attachable_type: null, + attachable: null, + }; + const assetAttachedDevice = { + attachable_uuid: 'asset_1', + attachable_type: 'fleet-ops:asset', + attachable: { id: 'asset_1' }, + }; + + assert.true(separator.isVisible(attachedDevice), 'separator appears before vehicle actions when a vehicle is attached'); + assert.true(viewVehicleAction.isVisible(attachedDevice), 'view vehicle action appears for attached vehicles'); + assert.true(locateVehicleAction.isVisible(attachedDevice), 'locate vehicle action appears for attached vehicles'); + assert.strictEqual(await controller.resolveAttachedVehicle(attachedDevice), vehicle, 'attached vehicle resolves from the row'); + + assert.false(viewVehicleAction.isVisible(unattachedDevice), 'view vehicle action is hidden when no vehicle is attached'); + assert.false(locateVehicleAction.isVisible(assetAttachedDevice), 'locate vehicle action is hidden for non-vehicle attachments'); + }); + + test('attached vehicle actions view and locate the vehicle through FleetOps panels', async function (assert) { + assert.expect(8); + + const vehicle = { id: 'vehicle_1', displayName: 'Truck 1' }; + const device = { + attachable_uuid: 'vehicle_1', + attachable_type: 'fleet-ops:vehicle', + attachable: vehicle, + }; + + class VehicleActionsStub extends Service { + panel = { + view(viewedVehicle, options) { + assert.strictEqual(viewedVehicle, vehicle, 'vehicle panel opens with the attached vehicle'); + + if (options) { + assert.deepEqual(options, { closeOnTransition: true }, 'locate opens the panel with transition-close behavior'); + } + }, + }; + } + + class HostRouterStub extends Service { + transitionTo(route, options) { + assert.strictEqual(route, 'console.fleet-ops.operations.orders.index', 'locate transitions to live map'); + assert.deepEqual(options, { queryParams: { layout: 'map' } }, 'locate requests map layout'); + + return Promise.resolve(); + } + } + + class MapManagerStub extends Service { + waitForMap(options) { + assert.deepEqual(options, { timeoutMs: 8000 }, 'locate waits for the map'); + + return Promise.resolve(); + } + + focusResource(focusedVehicle, zoom, options) { + assert.strictEqual(focusedVehicle, vehicle, 'locate focuses the attached vehicle'); + assert.strictEqual(zoom, 16, 'locate uses the expected zoom'); + options.moveend(); + } + } + + this.owner.register('service:vehicle-actions', VehicleActionsStub); + this.owner.register('service:host-router', HostRouterStub); + this.owner.register('service:map-manager', MapManagerStub); + + const controller = this.owner.lookup('controller:connectivity/telematics/index/details/devices'); + + await controller.viewAttachedVehicle(device); + await controller.locateAttachedVehicle(device); + }); }); diff --git a/tests/unit/controllers/management/identity-columns-test.js b/tests/unit/controllers/management/identity-columns-test.js new file mode 100644 index 000000000..1d3556d98 --- /dev/null +++ b/tests/unit/controllers/management/identity-columns-test.js @@ -0,0 +1,175 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +class IntlStub extends Service { + t(key) { + return key; + } +} + +class TableContextStub extends Service { + getSelectedRows() { + return []; + } +} + +class AppCacheStub extends Service { + get(_key, fallback) { + return fallback; + } + + set() {} +} + +class DriverActionsStub extends Service { + transition = { + view() {}, + create() {}, + edit() {}, + }; + + panel = { + view(resource) { + this.viewed = resource; + }, + }; +} + +class VehicleActionsStub extends Service { + transition = { + view() {}, + create() {}, + edit() {}, + }; + + panel = { + view(resource) { + this.viewed = resource; + }, + }; +} + +class GenericActionsStub extends Service { + transition = { + view() {}, + create() {}, + edit() {}, + }; + + panel = { + view() {}, + }; +} + +class RelatedResourceActionsStub extends GenericActionsStub { + driverActions = { + panel: { + view(resource) { + this.viewed = resource; + }, + }, + }; + + vehicleActions = { + panel: { + view(resource) { + this.viewed = resource; + }, + }, + }; +} + +module('Unit | Controller | management identity columns', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:intl', IntlStub); + this.owner.register('service:table-context', TableContextStub); + this.owner.register('service:app-cache', AppCacheStub); + this.owner.register('service:driver-actions', DriverActionsStub); + this.owner.register('service:vehicle-actions', VehicleActionsStub); + this.owner.register('service:fleet-actions', GenericActionsStub); + this.owner.register('service:vendor-actions', GenericActionsStub); + this.owner.register('service:issue-actions', RelatedResourceActionsStub); + this.owner.register('service:fuel-report-actions', RelatedResourceActionsStub); + this.owner.register('service:notifications', GenericActionsStub); + }); + + test('drivers columns place phone, license, and vehicle after ID', async function (assert) { + const controller = this.owner.lookup('controller:management/drivers/index'); + const labels = controller.columns.slice(0, 5).map((column) => column.label); + const vehicleColumn = controller.columns.find((column) => column.label === 'column.vehicle'); + const vehicle = { id: 'vehicle_1', displayName: 'Truck 1' }; + + assert.deepEqual(labels, ['column.name', 'column.id', 'column.phone', 'column.license', 'column.vehicle']); + assert.strictEqual(controller.columns[0].cellComponent, 'cell/driver-identity'); + assert.true(controller.columns[0].compact); + assert.strictEqual(vehicleColumn.cellComponent, 'cell/vehicle-identity'); + assert.true(vehicleColumn.compact); + assert.strictEqual(vehicleColumn.showStatusBadge, true); + + await vehicleColumn.action({ loadResource: () => vehicle }); + + assert.strictEqual(controller.vehicleActions.panel.viewed, vehicle, 'vehicle column opens the vehicle panel with the resolved vehicle'); + + await vehicleColumn.action(Promise.resolve(vehicle)); + + assert.strictEqual(controller.vehicleActions.panel.viewed, vehicle, 'vehicle column resolves async belongsTo-style vehicle values before opening the panel'); + }); + + test('vehicles driver column uses driver identity and opens the driver panel', async function (assert) { + const controller = this.owner.lookup('controller:management/vehicles/index'); + const driverColumn = controller.columns.find((column) => column.label === 'column.driver-assigned'); + const driver = { id: 'driver_1', name: 'Ada Driver' }; + const vehicle = { displayName: 'Truck 1' }; + + assert.strictEqual(controller.columns[0].cellComponent, 'cell/vehicle-identity'); + assert.strictEqual(controller.columns[0].showStatus, false); + assert.strictEqual(driverColumn.cellComponent, 'cell/driver-identity'); + assert.true(driverColumn.compact); + assert.strictEqual(driverColumn.assignedVehicleLabel(driver, vehicle), 'Truck 1'); + + await driverColumn.action({ loadResource: () => driver }); + + assert.strictEqual(controller.driverActions.panel.viewed, driver, 'driver column opens the driver panel with the resolved driver'); + }); + + test('fuel reports driver and vehicle columns use identity cells and preserve panel actions', async function (assert) { + const controller = this.owner.lookup('controller:management/fuel-reports/index'); + const driverColumn = controller.columns.find((column) => column.label === 'column.driver'); + const vehicleColumn = controller.columns.find((column) => column.label === 'column.vehicle'); + const driver = { id: 'driver_1', name: 'Ada Driver' }; + const vehicle = { id: 'vehicle_1', displayName: 'Truck 1' }; + + assert.strictEqual(driverColumn.cellComponent, 'cell/driver-identity'); + assert.strictEqual(vehicleColumn.cellComponent, 'cell/vehicle-identity'); + assert.strictEqual(driverColumn.showStatusBadge, true); + assert.strictEqual(vehicleColumn.showStatusBadge, true); + + await driverColumn.action({ loadResource: () => driver }); + await vehicleColumn.action({ loadResource: () => vehicle }); + + assert.strictEqual(controller.fuelReportActions.driverActions.panel.viewed, driver, 'fuel report driver column opens driver panel'); + assert.strictEqual(controller.fuelReportActions.vehicleActions.panel.viewed, vehicle, 'fuel report vehicle column opens vehicle panel'); + }); + + test('issues driver and vehicle columns use identity cells and preserve panel actions', async function (assert) { + const controller = this.owner.lookup('controller:management/issues/index'); + const driverColumn = controller.columns.find((column) => column.label === 'column.driver'); + const vehicleColumn = controller.columns.find((column) => column.label === 'column.vehicle'); + const driver = { id: 'driver_1', name: 'Ada Driver' }; + const vehicle = { id: 'vehicle_1', displayName: 'Truck 1' }; + + assert.strictEqual(driverColumn.cellComponent, 'cell/driver-identity'); + assert.strictEqual(vehicleColumn.cellComponent, 'cell/vehicle-identity'); + assert.strictEqual(driverColumn.showStatusBadge, true); + assert.strictEqual(vehicleColumn.showStatusBadge, true); + + await driverColumn.action({ loadResource: () => driver }); + await vehicleColumn.action({ loadResource: () => vehicle }); + + assert.strictEqual(controller.issueActions.driverActions.panel.viewed, driver, 'issue driver column opens driver panel'); + assert.strictEqual(controller.issueActions.vehicleActions.panel.viewed, vehicle, 'issue vehicle column opens vehicle panel'); + }); +}); diff --git a/tests/unit/routes/connectivity/devices/index-test.js b/tests/unit/routes/connectivity/devices/index-test.js index 0e14073e0..be71edeb4 100644 --- a/tests/unit/routes/connectivity/devices/index-test.js +++ b/tests/unit/routes/connectivity/devices/index-test.js @@ -8,4 +8,12 @@ module('Unit | Route | connectivity/devices/index', function (hooks) { let route = this.owner.lookup('route:connectivity/devices/index'); assert.ok(route); }); + + test('device inventory filters refresh the route model', function (assert) { + const route = this.owner.lookup('route:connectivity/devices/index'); + + for (const param of ['connection_status', 'vehicle', 'provider', 'device_id', 'type', 'serial_number', 'last_online_at', 'attachment_state']) { + assert.deepEqual(route.queryParams[param], { refreshModel: true }, `${param} refreshes the device inventory model`); + } + }); }); diff --git a/tests/unit/routes/connectivity/devices/index/details/vehicle-test.js b/tests/unit/routes/connectivity/devices/index/details/vehicle-test.js index 75bd21ef0..2e66d74ab 100644 --- a/tests/unit/routes/connectivity/devices/index/details/vehicle-test.js +++ b/tests/unit/routes/connectivity/devices/index/details/vehicle-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; module('Unit | Route | connectivity/devices/index/details/vehicle', function (hooks) { setupTest(hooks); @@ -8,4 +9,28 @@ module('Unit | Route | connectivity/devices/index/details/vehicle', function (ho let route = this.owner.lookup('route:connectivity/devices/index/details/vehicle'); assert.ok(route); }); + + test('model returns device with latest attached vehicle positions', async function (assert) { + const device = { + id: 'device_1', + attachable: { id: 'vehicle_1' }, + }; + const positions = [{ id: 'position_1' }]; + + class StoreStub extends Service { + query(modelName, params) { + assert.strictEqual(modelName, 'position', 'positions are queried'); + assert.deepEqual(params, { subject_uuid: 'vehicle_1', sort: '-created_at', limit: 5 }, 'latest vehicle positions are requested'); + + return Promise.resolve(positions); + } + } + + this.owner.register('service:store', StoreStub); + + const route = this.owner.lookup('route:connectivity/devices/index/details/vehicle'); + route.modelFor = () => device; + + assert.deepEqual(await route.model(), { device, positions }, 'route model contains device and latest positions'); + }); }); diff --git a/tests/unit/routes/connectivity/events/index/details-test.js b/tests/unit/routes/connectivity/events/details-test.js similarity index 72% rename from tests/unit/routes/connectivity/events/index/details-test.js rename to tests/unit/routes/connectivity/events/details-test.js index 414c0c409..9866a8176 100644 --- a/tests/unit/routes/connectivity/events/index/details-test.js +++ b/tests/unit/routes/connectivity/events/details-test.js @@ -1,11 +1,11 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; -module('Unit | Route | connectivity/events/index/details', function (hooks) { +module('Unit | Route | connectivity/events/details', function (hooks) { setupTest(hooks); test('it exists', function (assert) { - let route = this.owner.lookup('route:connectivity/events/index/details'); + let route = this.owner.lookup('route:connectivity/events/details'); assert.ok(route); }); }); diff --git a/tests/unit/routes/connectivity/events/index-test.js b/tests/unit/routes/connectivity/events/index-test.js index a0d3fff7f..aee01f898 100644 --- a/tests/unit/routes/connectivity/events/index-test.js +++ b/tests/unit/routes/connectivity/events/index-test.js @@ -8,4 +8,62 @@ module('Unit | Route | connectivity/events/index', function (hooks) { let route = this.owner.lookup('route:connectivity/events/index'); assert.ok(route); }); + + test('it refreshes for every supported global event filter', function (assert) { + let route = this.owner.lookup('route:connectivity/events/index'); + + assert.deepEqual(Object.keys(route.queryParams), [ + 'page', + 'limit', + 'sort', + 'query', + 'telematic', + 'device', + 'event_type', + 'severity', + 'processed', + 'occurred_at', + 'created_at', + 'updated_at', + ]); + }); + + test('it queries device events with the provided params', function (assert) { + let route = this.owner.lookup('route:connectivity/events/index'); + let query; + + route.store = { + query(modelName, params) { + query = { modelName, params }; + return []; + }, + }; + + route.model({ + query: 'fault', + telematic: 'telematic_1', + device: 'device_1', + event_type: 'diagnostic', + severity: 'warning', + processed: 'unprocessed', + occurred_at: '2026-06-18', + created_at: '2026-06-17', + updated_at: '2026-06-19', + }); + + assert.deepEqual(query, { + modelName: 'device-event', + params: { + query: 'fault', + telematic: 'telematic_1', + device: 'device_1', + event_type: 'diagnostic', + severity: 'warning', + processed: 'unprocessed', + occurred_at: '2026-06-18', + created_at: '2026-06-17', + updated_at: '2026-06-19', + }, + }); + }); }); diff --git a/tests/unit/routes/connectivity/sensors/index-test.js b/tests/unit/routes/connectivity/sensors/index-test.js index 6badd63ad..1114b0b65 100644 --- a/tests/unit/routes/connectivity/sensors/index-test.js +++ b/tests/unit/routes/connectivity/sensors/index-test.js @@ -8,4 +8,65 @@ module('Unit | Route | connectivity/sensors/index', function (hooks) { let route = this.owner.lookup('route:connectivity/sensors/index'); assert.ok(route); }); + + test('it refreshes for every supported global sensor filter', function (assert) { + let route = this.owner.lookup('route:connectivity/sensors/index'); + + assert.deepEqual(Object.keys(route.queryParams), [ + 'page', + 'limit', + 'sort', + 'query', + 'telematic', + 'device', + 'type', + 'status', + 'serial_number', + 'imei', + 'last_reading_at', + 'created_at', + 'updated_at', + ]); + }); + + test('it queries sensors with the provided params', function (assert) { + let route = this.owner.lookup('route:connectivity/sensors/index'); + let query; + + route.store = { + query(modelName, params) { + query = { modelName, params }; + return []; + }, + }; + + route.model({ + query: 'temp', + telematic: 'telematic_1', + device: 'device_1', + type: 'temperature', + status: 'active', + serial_number: 'SN-1', + imei: 'IMEI-1', + last_reading_at: '2026-06-18', + created_at: '2026-06-17', + updated_at: '2026-06-19', + }); + + assert.deepEqual(query, { + modelName: 'sensor', + params: { + query: 'temp', + telematic: 'telematic_1', + device: 'device_1', + type: 'temperature', + status: 'active', + serial_number: 'SN-1', + imei: 'IMEI-1', + last_reading_at: '2026-06-18', + created_at: '2026-06-17', + updated_at: '2026-06-19', + }, + }); + }); }); diff --git a/tests/unit/routes/connectivity/telematics/index/details/devices-test.js b/tests/unit/routes/connectivity/telematics/index/details/devices-test.js index 7d1b65428..7901d6d9a 100644 --- a/tests/unit/routes/connectivity/telematics/index/details/devices-test.js +++ b/tests/unit/routes/connectivity/telematics/index/details/devices-test.js @@ -14,6 +14,8 @@ module('Unit | Route | connectivity/telematics/index/details/devices', function assert.deepEqual(route.queryParams.vehicle, { refreshModel: true }, 'vehicle filter refreshes devices'); assert.deepEqual(route.queryParams.connection_status, { refreshModel: true }, 'connection filter refreshes devices'); + assert.deepEqual(route.queryParams.type, { refreshModel: true }, 'type filter refreshes devices'); + assert.deepEqual(route.queryParams.serial_number, { refreshModel: true }, 'serial number filter refreshes devices'); assert.deepEqual(route.queryParams.last_online_at, { refreshModel: true }, 'last seen date filter refreshes devices'); assert.deepEqual(route.queryParams.updated_at, { refreshModel: true }, 'updated date filter refreshes devices'); }); diff --git a/tests/unit/services/device-actions-test.js b/tests/unit/services/device-actions-test.js index a98b50af4..e6341ac10 100644 --- a/tests/unit/services/device-actions-test.js +++ b/tests/unit/services/device-actions-test.js @@ -1,12 +1,69 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +class IntlServiceStub extends Service { + t(key) { + return key; + } +} + +class MenuServiceStub extends Service { + getMenuItems() { + return [ + { + label: 'Custom', + component: 'device/details/custom', + }, + { + label: 'Route only', + route: 'connectivity.devices.index.details.custom', + }, + ]; + } +} + +class ResourceContextPanelStub extends Service { + open(config) { + this.config = config; + return config; + } +} module('Unit | Service | device-actions', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. + hooks.beforeEach(function () { + this.owner.register('service:intl', IntlServiceStub); + this.owner.register('service:universe/menu-service', MenuServiceStub); + this.owner.register('service:resource-context-panel', ResourceContextPanelStub); + }); + test('it exists', function (assert) { let service = this.owner.lookup('service:device-actions'); assert.ok(service); }); + + test('panel.view opens overview, vehicle, sensors, events, and compatible custom tabs', function (assert) { + let service = this.owner.lookup('service:device-actions'); + let config = service.panel.view({ id: 'device_1', name: 'Device 1' }); + + assert.strictEqual(config.header, 'device/panel-header'); + assert.deepEqual( + config.tabs.map((tab) => tab.component), + ['device/details', 'device/panel-tabs/vehicle', 'device/panel-tabs/sensors', 'device/panel-tabs/events', 'device/details/custom'] + ); + + assert.deepEqual( + config.tabs.slice(0, 4).map((tab) => tab.key), + ['overview', 'vehicle', 'sensors', 'events'] + ); + }); + + test('panel.view ignores missing device resources', function (assert) { + let service = this.owner.lookup('service:device-actions'); + let result = service.panel.view(); + + assert.strictEqual(result, undefined); + }); }); diff --git a/tests/unit/services/device-event-actions-test.js b/tests/unit/services/device-event-actions-test.js index 519500267..ae13f813b 100644 --- a/tests/unit/services/device-event-actions-test.js +++ b/tests/unit/services/device-event-actions-test.js @@ -9,4 +9,16 @@ module('Unit | Service | device-event-actions', function (hooks) { let service = this.owner.lookup('service:device-event-actions'); assert.ok(service); }); + + test('transition view targets the registered connectivity event details route', function (assert) { + let service = this.owner.lookup('service:device-event-actions'); + let event = { id: 'event_1' }; + + service.transitionTo = (routeName, resource) => { + assert.strictEqual(routeName, 'connectivity.events.details'); + assert.strictEqual(resource, event); + }; + + service.transition.view(event); + }); }); diff --git a/tests/unit/services/equipment-actions-test.js b/tests/unit/services/equipment-actions-test.js index 6e452af9b..504b05f1e 100644 --- a/tests/unit/services/equipment-actions-test.js +++ b/tests/unit/services/equipment-actions-test.js @@ -1,12 +1,22 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; module('Unit | Service | equipment-actions', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. - test('it exists', function (assert) { - let service = this.owner.lookup('service:equipment-actions'); + test('it creates new equipment with default status and currency', function (assert) { + class CurrentUserStubService extends Service { + currency = 'SGD'; + } + + this.owner.register('service:current-user', CurrentUserStubService); + + const service = this.owner.lookup('service:equipment-actions'); + const equipment = service.createNewInstance(); + assert.ok(service); + assert.strictEqual(equipment.status, 'available'); + assert.strictEqual(equipment.currency, 'SGD'); }); }); diff --git a/tests/unit/services/sensor-actions-test.js b/tests/unit/services/sensor-actions-test.js index b7e5f0c40..8f7eb663c 100644 --- a/tests/unit/services/sensor-actions-test.js +++ b/tests/unit/services/sensor-actions-test.js @@ -1,12 +1,40 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +class IntlServiceStub extends Service { + t(key) { + return key; + } +} + +class ResourceContextPanelStub extends Service { + open(config) { + return config; + } +} module('Unit | Service | sensor-actions', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. + hooks.beforeEach(function () { + this.owner.register('service:intl', IntlServiceStub); + this.owner.register('service:resource-context-panel', ResourceContextPanelStub); + }); + test('it exists', function (assert) { let service = this.owner.lookup('service:sensor-actions'); assert.ok(service); }); + + test('panel.view uses the sensor panel header', function (assert) { + let service = this.owner.lookup('service:sensor-actions'); + let config = service.panel.view({ id: 'sensor_1', name: 'Fuel Sensor' }); + + assert.strictEqual(config.header, 'sensor/panel-header'); + assert.deepEqual( + config.tabs.map((tab) => tab.key), + ['overview'] + ); + }); }); diff --git a/tests/unit/services/service-rate-actions-test.js b/tests/unit/services/service-rate-actions-test.js index 246cecad3..497f13f61 100644 --- a/tests/unit/services/service-rate-actions-test.js +++ b/tests/unit/services/service-rate-actions-test.js @@ -1,12 +1,118 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; +import { A } from '@ember/array'; + +class FetchStub extends Service { + calls = []; + + get(...args) { + this.calls.push(args); + + return Promise.resolve( + A([ + { + id: 'service_rate_test', + service_name: 'Test Rate', + }, + ]) + ); + } +} module('Unit | Service | service-rate-actions', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. + hooks.beforeEach(function () { + this.owner.register('service:fetch', FetchStub); + }); + test('it exists', function (assert) { let service = this.owner.lookup('service:service-rate-actions'); assert.ok(service); }); + + test('queryServiceRatesForOrder skips incomplete route coordinates', async function (assert) { + const service = this.owner.lookup('service:service-rate-actions'); + const fetch = this.owner.lookup('service:fetch'); + + let emptyResult = await service.queryServiceRatesForOrder.perform({ + payload: { + payloadCoordinates: [], + }, + }); + let singlePointResult = await service.queryServiceRatesForOrder.perform({ + payload: { + payloadCoordinates: [[103.8845049, 1.3621663]], + }, + }); + + assert.deepEqual(emptyResult, []); + assert.deepEqual(singlePointResult, []); + assert.strictEqual(fetch.calls.length, 0, 'does not request service rates without a complete route'); + }); + + test('queryServiceRatesForOrder requests service rates for complete array route coordinates', async function (assert) { + const service = this.owner.lookup('service:service-rate-actions'); + const fetch = this.owner.lookup('service:fetch'); + const orderConfig = { + get(key) { + return key === 'key' ? 'delivery' : undefined; + }, + }; + + let result = await service.queryServiceRatesForOrder.perform({ + facilitator: null, + order_config: orderConfig, + payload: { + payloadCoordinates: [ + [103.8845049, 1.3621663], + [103.86458, 1.353151], + ], + }, + }); + + assert.strictEqual(fetch.calls.length, 1); + assert.strictEqual(fetch.calls[0][0], 'service-rates/for-route'); + assert.deepEqual(fetch.calls[0][1], { + coordinates: '1.3621663,103.8845049;1.353151,103.86458', + facilitator: null, + service_type: 'delivery', + }); + assert.strictEqual(result[0].id, 'all', 'prepends the quote-all service-rate option after a successful request'); + }); + + test('queryServiceRatesForOrder ignores invalid route coordinates before requesting', async function (assert) { + const service = this.owner.lookup('service:service-rate-actions'); + const fetch = this.owner.lookup('service:fetch'); + + await service.queryServiceRatesForOrder.perform({ + facilitator: null, + payload: { + payloadCoordinates: [null, [103.8845049, 1.3621663], ['bad', 1.2], '', [103.86458, 1.353151]], + }, + }); + + assert.strictEqual(fetch.calls.length, 1); + assert.deepEqual(fetch.calls[0][1], { + coordinates: '1.3621663,103.8845049;1.353151,103.86458', + facilitator: null, + service_type: undefined, + }); + }); + + test('queryServiceRatesForOrder still supports legacy string route coordinates', async function (assert) { + const service = this.owner.lookup('service:service-rate-actions'); + const fetch = this.owner.lookup('service:fetch'); + + await service.queryServiceRatesForOrder.perform({ + facilitator: null, + payload: { + payloadCoordinates: ['1.3621663,103.8845049', '1.353151,103.86458'], + }, + }); + + assert.strictEqual(fetch.calls.length, 1); + assert.strictEqual(fetch.calls[0][1].coordinates, '1.3621663,103.8845049;1.353151,103.86458'); + }); }); diff --git a/tests/unit/services/vehicle-actions-test.js b/tests/unit/services/vehicle-actions-test.js index 2256a0139..ace492ff9 100644 --- a/tests/unit/services/vehicle-actions-test.js +++ b/tests/unit/services/vehicle-actions-test.js @@ -1,14 +1,47 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +class MenuServiceStub extends Service { + getMenuItems() { + return []; + } +} + +class ResourceContextPanelStub extends Service { + open(config) { + this.config = config; + return config; + } +} module('Unit | Service | vehicle-actions', function (hooks) { setupTest(hooks); + hooks.beforeEach(function () { + this.owner.register('service:universe/menu-service', MenuServiceStub); + this.owner.register('service:resource-context-panel', ResourceContextPanelStub); + }); + test('it exists', function (assert) { let service = this.owner.lookup('service:vehicle-actions'); assert.ok(service); }); + test('panel.view resolves promise-like vehicle resources before reading metadata', async function (assert) { + const service = this.owner.lookup('service:vehicle-actions'); + const vehicle = { + id: 'vehicle-1', + name: 'Truck 12', + meta: { _index_resource: true }, + reload: async () => assert.step('vehicle reloaded'), + }; + const config = await service.panel.view(Promise.resolve(vehicle)); + + assert.strictEqual(config.vehicle, vehicle); + assert.verifySteps(['vehicle reloaded']); + }); + test('unassignOrders loads assigned orders, highlights the current job, and posts selected orders', async function (assert) { const service = this.owner.lookup('service:vehicle-actions'); const options = {}; diff --git a/tests/unit/services/vendor-actions-test.js b/tests/unit/services/vendor-actions-test.js index bc846f662..361d840c8 100644 --- a/tests/unit/services/vendor-actions-test.js +++ b/tests/unit/services/vendor-actions-test.js @@ -1,12 +1,40 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +class IntlServiceStub extends Service { + t(key) { + return key; + } +} + +class ResourceContextPanelStub extends Service { + open(config) { + return config; + } +} module('Unit | Service | vendor-actions', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. + hooks.beforeEach(function () { + this.owner.register('service:intl', IntlServiceStub); + this.owner.register('service:resource-context-panel', ResourceContextPanelStub); + }); + test('it exists', function (assert) { let service = this.owner.lookup('service:vendor-actions'); assert.ok(service); }); + + test('panel.view uses the vendor panel header', function (assert) { + let service = this.owner.lookup('service:vendor-actions'); + let config = service.panel.view({ id: 'vendor_1', name: 'Acme Transport' }); + + assert.strictEqual(config.header, 'vendor/panel-header'); + assert.deepEqual( + config.tabs.map((tab) => tab.key), + ['overview'] + ); + }); }); diff --git a/tests/unit/utils/map-drawer-dropdown-position-test.js b/tests/unit/utils/map-drawer-dropdown-position-test.js new file mode 100644 index 000000000..37610ba6b --- /dev/null +++ b/tests/unit/utils/map-drawer-dropdown-position-test.js @@ -0,0 +1,57 @@ +import { module, test } from 'qunit'; +import calculateMapDrawerDropdownPosition from '@fleetbase/fleetops-engine/utils/map-drawer-dropdown-position'; + +module('Unit | Utility | map-drawer-dropdown-position', function () { + test('positions the dropdown to the left of the trigger', function (assert) { + const result = calculateMapDrawerDropdownPosition(mockTrigger({ left: 500, top: 300, right: 532, bottom: 332 }), mockContent({ width: 220, height: 160 })); + + assert.strictEqual(result.style.left, '274px', 'left edge is trigger left minus menu width and gap'); + assert.strictEqual(result.style.top, '300px', 'top aligns to trigger top'); + assert.strictEqual(result.style.zIndex, '10000', 'z-index is returned as a string for the style modifier'); + assert.strictEqual(typeof result.style.zIndex, 'string', 'z-index value type is compatible with the style modifier'); + }); + + test('clamps inside the drawer when there is not enough room on the left', function (assert) { + const result = calculateMapDrawerDropdownPosition(mockTrigger({ left: 80, top: 300, right: 112, bottom: 332 }), mockContent({ width: 220, height: 160 })); + + assert.strictEqual(result.style.left, '6px', 'left edge is clamped to the drawer boundary'); + assert.strictEqual(result.style.top, '300px', 'top still aligns to trigger top'); + }); + + test('clamps vertically without flipping above the trigger', function (assert) { + const result = calculateMapDrawerDropdownPosition(mockTrigger({ left: 500, top: 560, right: 532, bottom: 592 }), mockContent({ width: 220, height: 160 })); + + assert.strictEqual(result.style.left, '274px', 'left edge remains to the left of the trigger'); + assert.strictEqual(result.style.top, '434px', 'top is clamped inside the drawer instead of flipped above the trigger'); + }); +}); + +function mockTrigger(rect) { + const root = mockElement({ left: 0, top: 0, right: 0, bottom: 0 }); + const drawer = mockElement({ left: 0, top: 100, right: 800, bottom: 600 }); + + return { + getBoundingClientRect: () => rect, + closest(selector) { + if (selector === '.ember-basic-dropdown') { + return root; + } + + if (selector === '.next-drawer-panel') { + return drawer; + } + + return null; + }, + }; +} + +function mockContent({ width, height }) { + return mockElement({ left: 0, top: 0, right: width, bottom: height, width, height }); +} + +function mockElement(rect) { + return { + getBoundingClientRect: () => rect, + }; +} diff --git a/tests/unit/utils/trackable-option-test.js b/tests/unit/utils/trackable-option-test.js new file mode 100644 index 000000000..a503ed861 --- /dev/null +++ b/tests/unit/utils/trackable-option-test.js @@ -0,0 +1,69 @@ +import { module, test } from 'qunit'; +import EmberObject from '@ember/object'; +import ObjectProxy from '@ember/object/proxy'; +import { buildTrackableOption, trackableDeviceLabel } from '@fleetbase/fleetops-engine/utils/trackable-option'; + +module('Unit | Utility | trackable-option', function () { + test('builds searchable text for vehicle trackables including attached devices', function (assert) { + const option = buildTrackableOption( + { + constructor: { modelName: 'vehicle' }, + id: 'vehicle_1', + name: 'Box Truck', + public_id: 'vehicle_public', + plate_number: 'ABC-123', + serial_number: 'SER-9', + devices: [{ name: 'Dashcam 1' }, { device_id: 'OBD-2' }], + }, + 'vehicle' + ); + + assert.strictEqual(option.primaryLabel, 'Box Truck'); + assert.strictEqual(option.secondaryLabel, 'ABC-123'); + assert.strictEqual(option.deviceLabel, 'Devices: Dashcam 1, OBD-2'); + assert.true(option.trackableSearchText.includes('ABC-123'), 'vehicle identifiers are searchable'); + assert.true(option.trackableSearchText.includes('Dashcam 1'), 'attached device names are searchable'); + }); + + test('builds searchable text for driver trackables using assigned vehicle devices', function (assert) { + const option = buildTrackableOption( + { + constructor: { modelName: 'driver' }, + id: 'driver_1', + name: 'Mira Driver', + email: 'mira@example.test', + vehicle: { + devices: [{ serial_number: 'SN-7' }], + }, + }, + 'driver' + ); + + assert.strictEqual(option.primaryLabel, 'Mira Driver'); + assert.strictEqual(option.secondaryLabel, 'mira@example.test'); + assert.strictEqual(option.deviceLabel, 'Device: SN-7'); + assert.true(option.trackableSearchText.includes('SN-7'), 'assigned vehicle device identifiers are searchable'); + }); + + test('omits device labels when no attached devices exist', function (assert) { + assert.strictEqual(trackableDeviceLabel({ constructor: { modelName: 'vehicle' }, devices: [] }, 'vehicle'), null); + assert.strictEqual(trackableDeviceLabel({ constructor: { modelName: 'driver' }, vehicle: null }, 'driver'), null); + }); + + test('reads devices from Ember proxy records', function (assert) { + const driver = ObjectProxy.create({ + content: EmberObject.create({ + name: 'Proxy Driver', + email: 'proxy@example.test', + vehicle: EmberObject.create({ + devices: [EmberObject.create({ name: 'Proxy Tracker' })], + }), + }), + }); + + const option = buildTrackableOption(driver, 'driver'); + + assert.strictEqual(option.deviceLabel, 'Device: Proxy Tracker'); + assert.true(option.trackableSearchText.includes('Proxy Tracker'), 'proxy device name is searchable'); + }); +});