diff --git a/addon/components/device/details.hbs b/addon/components/device/details.hbs index 168f831eb..e59e465dd 100644 --- a/addon/components/device/details.hbs +++ b/addon/components/device/details.hbs @@ -1,154 +1,125 @@ -
- -
-
-
{{t "device.fields.telematic"}}
-
- {{#if @resource.telematic}} -
-
- -
-
-
{{@resource.telematic.provider_descriptor.label}}
-
- {{n-a @resource.telematic.provider_descriptor.description}} -
-
-
- {{else}} - {{n-a null}} - {{/if}} +
+
+
+
+
+

{{this.displayName}}

+ {{smart-humanize this.connectionLabel}} + {{this.attachmentLabel}} +
+
+ Last seen: {{n-a (format-date-fns this.lastSeenLabel "dd MMM yyyy, HH:mm")}} + Provider: {{n-a (titleize this.providerLabel)}} + Signal: {{n-a this.signalLabel}} + Device ID: {{n-a @resource.device_id}}
-
-
{{t "device.fields.data-frequency"}}
-
{{n-a @resource.data_frequency}}
-
-
- - - -
-
-
{{t "device.fields.device-name"}}
-
{{n-a @resource.name}}
-
- -
-
{{t "device.fields.device-type"}}
-
{{n-a (get-fleet-ops-option-label "deviceTypes" @resource.type)}}
-
- -
-
{{t "device.fields.device-id"}}
-
{{n-a @resource.device_id}}
-
- -
-
Internal ID
-
{{n-a @resource.internal_id}}
-
+ {{#if this.providerIcon}} +
+ + {{n-a this.providerLabel}} +
+ {{/if}}
-
- - -
-
-
{{t "device.fields.device-provider"}}
-
{{n-a (titleize @resource.provider)}}
-
- -
-
{{t "device.fields.device-model"}}
-
{{n-a @resource.model}}
+
+ +
+ {{#each this.metrics as |metric|}} +
+
+
+
{{metric.label}}
+
{{metric.value}}
+ {{#if metric.meta}} +
{{metric.meta}}
+ {{/if}} +
+
+ +
+
+ {{/each}} +
-
-
{{t "device.fields.manufacturer"}}
-
{{n-a @resource.manufacturer}}
+
+
+
+

Operational Snapshot

- -
-
{{t "device.fields.serial-number"}}
-
{{n-a @resource.serial_number}}
+
+
+
Location
+
{{n-a (or @resource.location this.lastPositionLabel)}}
+
+
+
{{t "device.fields.data-frequency"}}
+
{{n-a @resource.data_frequency}}
+
+
+
Firmware
+
{{n-a this.firmwareLabel}}
+
+
+
{{t "device.fields.installation-date"}}
+
{{n-a (format-date @resource.installation_date)}}
+
+
+
{{t "device.fields.last-maintenance-date"}}
+
{{n-a (format-date @resource.last_maintenance_date)}}
+
+
+
{{t "device.fields.status"}}
+
{{smart-humanize @resource.status}}
+
- - -
-
-
{{t "device.fields.device-location"}}
-
{{n-a @resource.location}}
+
+
+

Critical Details

- -
-
{{t "device.fields.installation-date"}}
-
{{or (format-date @resource.installation_date) "-"}}
-
- -
-
{{t "device.fields.last-maintenance-date"}}
-
{{or (format-date @resource.last_maintenance_date) "-"}}
-
-
- - - -
-
-
{{t "common.online"}}
-
- {{#if @resource.is_online}} - {{t "common.online"}} - {{else}} - {{t "common.offline"}} - {{/if}} +
+
+
{{t "device.fields.device-type"}}
+
{{n-a (get-fleet-ops-option-label "deviceTypes" @resource.type)}}
-
- -
-
{{t "common.last-seen-at"}}
-
{{or (format-date @resource.last_online_at) "-"}}
-
- - {{#if @resource.signal_strength}}
-
{{t "device.fields.signal-strength"}}
-
{{n-a @resource.signal_strength}}
+
{{t "device.fields.device-model"}}
+
{{n-a @resource.model}}
- {{/if}} -
- - - -
-
-
{{t "device.fields.status"}}
-
- - {{smart-humanize @resource.status}} - +
+
{{t "device.fields.manufacturer"}}
+
{{n-a @resource.manufacturer}}
+
+
+
{{t "device.fields.serial-number"}}
+
{{n-a @resource.serial_number}}
+
+
+
IMEI
+
{{n-a @resource.imei}}
+
+
+
IMSI
+
{{n-a @resource.imsi}}
+
+
+
Internal ID
+
{{n-a @resource.internal_id}}
+
+
+
{{t "device.fields.warranty"}}
+
{{n-a (or @resource.warranty.name @resource.warranty_name)}}
+
+
+
{{t "device.fields.notes"}}
+
{{n-a @resource.notes}}
-
- -
-
{{t "device.fields.warranty"}}
-
{{n-a @resource.warranty.name}}
-
-
- - - -
-
-
{{t "device.fields.notes"}}
-
{{n-a @resource.notes}}
-
+
-
\ No newline at end of file +
diff --git a/addon/components/device/details.js b/addon/components/device/details.js index da889c178..837914be9 100644 --- a/addon/components/device/details.js +++ b/addon/components/device/details.js @@ -1,3 +1,176 @@ import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; -export default class DeviceDetailsComponent extends Component {} +const warningSeverities = ['warning', 'error', 'critical', 'high']; + +export default class DeviceDetailsComponent extends Component { + @service store; + @service intl; + + @tracked sensors = []; + @tracked events = []; + @tracked sensorTotal; + @tracked eventTotal; + + constructor() { + super(...arguments); + this.loadOperationalSummary.perform(); + } + + get resource() { + return this.args.resource; + } + + get displayName() { + return this.resource?.displayName ?? this.resource?.name ?? this.resource?.device_id ?? this.resource?.serial_number ?? this.resource?.public_id ?? this.intl.t('resource.device'); + } + + get providerDescriptor() { + return this.resource?.telematic?.provider_descriptor; + } + + get providerLabel() { + return this.providerDescriptor?.label ?? this.resource?.telematic?.name ?? this.resource?.provider ?? this.resource?.telematic_name; + } + + get providerIcon() { + return this.providerDescriptor?.icon; + } + + get connectionStatus() { + return this.resource?.connection_status ?? (this.resource?.is_online ? 'online' : 'offline'); + } + + get connectionTone() { + switch (this.connectionStatus) { + case 'online': + return 'online'; + case 'recently_offline': + return 'warning'; + case 'never_connected': + case 'long_offline': + return 'danger'; + default: + return 'offline'; + } + } + + get connectionLabel() { + return this.connectionStatus ? this.connectionStatus.replaceAll('_', ' ') : 'Unknown'; + } + + get attachedVehicleName() { + return this.resource?.attached_to_name ?? this.resource?.attachable?.displayName ?? this.resource?.attachable?.display_name ?? this.resource?.attachable?.name; + } + + get attachmentLabel() { + return this.attachedVehicleName ?? 'Unattached'; + } + + get attachmentTone() { + return this.attachedVehicleName ? 'success' : 'warning'; + } + + get lastSeenLabel() { + return this.resource?.last_online_at ?? this.resource?.lastOnlineAt; + } + + get signalLabel() { + return this.resource?.signal_strength ?? this.resource?.data?.signal_strength ?? this.resource?.meta?.signal_strength; + } + + get firmwareLabel() { + return this.resource?.firmware_version ?? this.resource?.data?.firmware_version ?? this.resource?.meta?.firmware_version; + } + + get lastPositionLabel() { + const position = this.resource?.last_position; + + if (!position) { + return null; + } + + if (typeof position === 'string') { + return position; + } + + const latitude = position.latitude ?? position.lat ?? position.coordinates?.[1]; + const longitude = position.longitude ?? position.lng ?? position.lon ?? position.coordinates?.[0]; + + if (latitude && longitude) { + return `${latitude}, ${longitude}`; + } + + return null; + } + + get warningEventsCount() { + return this.events.filter((event) => warningSeverities.includes(String(event.severity ?? '').toLowerCase())).length; + } + + get unprocessedEventsCount() { + return this.events.filter((event) => !event.processed_at && !event.is_processed).length; + } + + get activeSensorsCount() { + return this.sensors.filter((sensor) => sensor.is_active || sensor.status === 'active' || sensor.status === 'online').length; + } + + get reportingSensorsCount() { + return this.sensors.filter((sensor) => sensor.last_value || sensor.last_reading_at).length; + } + + get metrics() { + return [ + { + label: 'Connection', + value: this.connectionLabel, + icon: this.connectionStatus === 'online' ? 'signal' : 'power-off', + accentClass: this.connectionStatus === 'online' ? 'fleetops-connectivity-kpi-accent-green' : 'fleetops-connectivity-kpi-accent-rose', + }, + { + label: 'Sensors', + value: this.sensorTotal ?? this.sensors.length, + meta: `${this.activeSensorsCount} active`, + icon: 'gauge-high', + accentClass: 'fleetops-connectivity-kpi-accent-blue', + }, + { + label: 'Events', + value: this.eventTotal ?? this.events.length, + meta: `${this.warningEventsCount + this.unprocessedEventsCount} need review`, + icon: 'bolt', + accentClass: this.warningEventsCount || this.unprocessedEventsCount ? 'fleetops-connectivity-kpi-accent-amber' : 'fleetops-connectivity-kpi-accent-green', + }, + { + label: 'Vehicle', + value: this.attachmentLabel, + icon: this.attachedVehicleName ? 'truck' : 'link-slash', + accentClass: this.attachedVehicleName ? 'fleetops-connectivity-kpi-accent-green' : 'fleetops-connectivity-kpi-accent-amber', + }, + ]; + } + + @task *loadOperationalSummary() { + if (!this.resource?.id) { + return; + } + + try { + const [sensors, events] = yield Promise.all([ + this.store.query('sensor', { device_uuid: this.resource.id, limit: 10 }), + this.store.query('device-event', { device_uuid: this.resource.id, limit: 10, sort: '-created_at' }), + ]); + + this.sensors = Array.from(sensors ?? []); + this.events = Array.from(events ?? []); + this.sensorTotal = sensors?.meta?.total; + this.eventTotal = events?.meta?.total; + } catch { + this.sensors = []; + this.events = []; + } + } +} diff --git a/addon/components/device/panel-header.hbs b/addon/components/device/panel-header.hbs index 7b42df89a..73613520c 100644 --- a/addon/components/device/panel-header.hbs +++ b/addon/components/device/panel-header.hbs @@ -24,7 +24,7 @@ }}
{{n-a @resource.serial_number}}
- {{if @resource.online (t "common.online") (t "common.offline")}} + {{if @resource.is_online (t "common.online") (t "common.offline")}}
@@ -40,4 +40,4 @@ />
- \ No newline at end of file + diff --git a/addon/components/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js index 6251792d0..dfb36919a 100644 --- a/addon/components/layout/fleet-ops-sidebar.js +++ b/addon/components/layout/fleet-ops-sidebar.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 isMenuItemActive from '@fleetbase/ember-ui/utils/is-menu-item-active'; const SECTION_REGISTRY_KEYS = { operations: 'universeOperationsMenuItems', @@ -198,23 +199,26 @@ export default class LayoutFleetOpsSidebarComponent extends Component { } get registryRootItems() { - return this.universeMenuItems.filter((item) => !item.renderComponentInPlace).map((item) => this.registryItem(item)); + return this.sortByPriority(this.universeMenuItems.filter((item) => !item.renderComponentInPlace).map((item) => this.registryItem(item))); } get registryPanelItems() { - return this.universeMenuPanels.map((panel) => { - const children = (panel.items ?? []).filter((item) => !item.renderComponentInPlace).map((item) => this.registryItem(item)); - - return { - id: panel.id ?? panel.slug ?? panel.title, - label: panel.intl ? this.intl.t(panel.intl) : panel.title, - icon: panel.icon, - visible: panel.visible, - permission: panel.permission, - keywords: [panel.slug, panel.title, panel.intl].filter(Boolean), - children, - }; - }); + return this.sortByPriority( + this.universeMenuPanels.map((panel) => { + const children = this.sortByPriority((panel.items ?? []).filter((item) => !item.renderComponentInPlace).map((item) => this.registryItem(item))); + + return { + id: panel.id ?? panel.slug ?? panel.title, + label: panel.intl ? this.intl.t(panel.intl) : panel.title, + icon: panel.icon, + visible: panel.visible, + permission: panel.permission, + priority: panel.priority, + keywords: [panel.slug, panel.title, panel.intl].filter(Boolean), + children, + }; + }) + ); } get footerRegistryComponents() { @@ -257,6 +261,7 @@ export default class LayoutFleetOpsSidebarComponent extends Component { createItem(intl, icon, route, permission, ability, keywords = []) { return { + priority: this.defaultPriorityForRoute(route), label: this.intl.t(intl), description: this.intl.t(intl), icon, @@ -269,6 +274,8 @@ export default class LayoutFleetOpsSidebarComponent extends Component { createHubItem(label, icon, route, permission, ability, keywords = []) { return { + pinnedFirst: true, + priority: this.defaultPriorityForRoute(route), label, description: label, icon, @@ -280,8 +287,9 @@ export default class LayoutFleetOpsSidebarComponent extends Component { } registryItem(menuItem) { - return { + const registryMenuItem = { ...menuItem, + _virtual: true, label: menuItem.intl ? this.intl.t(menuItem.intl) : (menuItem.title ?? menuItem.label), description: menuItem.description, icon: menuItem.icon, @@ -289,15 +297,83 @@ export default class LayoutFleetOpsSidebarComponent extends Component { permission: menuItem.permission, visible: menuItem.visible, keywords: [menuItem.slug, menuItem.view, menuItem.section, menuItem.title, menuItem.label, menuItem.intl, ...(menuItem.keywords ?? [])].filter(Boolean), - onClick: () => this.universe.transitionMenuItem(`${this.routePrefix}virtual`, menuItem), + activeWhen: () => isMenuItemActive(menuItem.section, menuItem.slug, menuItem.view), }; + + registryMenuItem.onClick = () => this.universe.transitionMenuItem(`${this.routePrefix}virtual`, registryMenuItem); + + return registryMenuItem; } withRegistryItems(section, items) { const registryProperty = SECTION_REGISTRY_KEYS[section]; const registryItems = (this[registryProperty] ?? []).filter((item) => !item.renderComponentInPlace).map((item) => this.registryItem(item)); - return [...items, ...registryItems]; + return this.sortByPriority([...items, ...registryItems]); + } + + sortByPriority(items = []) { + return [...items] + .map((item, index) => ({ item, index })) + .sort((a, b) => { + if (a.item.pinnedFirst !== b.item.pinnedFirst) { + return a.item.pinnedFirst ? -1 : 1; + } + + const priorityOrder = (a.item.priority ?? 0) - (b.item.priority ?? 0); + + if (priorityOrder !== 0) { + return priorityOrder; + } + + return a.index - b.index; + }) + .map(({ item }) => item); + } + + defaultPriorityForRoute(route) { + const priorities = { + 'operations.orders': 0, + 'operations.orchestrator': 1, + 'operations.scheduler': 2, + 'operations.order-config': 3, + 'operations.service-rates': 4, + 'management.index': 0, + 'management.drivers': 1, + 'management.vehicles': 2, + 'management.fleets': 3, + 'management.vendors': 4, + 'management.contacts': 5, + 'management.places': 6, + 'management.fuel-reports': 7, + 'management.fuel-transactions': 8, + 'management.issues': 9, + 'maintenance.index': 0, + 'maintenance.schedules': 1, + 'maintenance.work-orders': 2, + 'maintenance.maintenances': 3, + 'maintenance.equipment': 4, + 'maintenance.parts': 5, + 'connectivity.telematics': 0, + 'connectivity.fuel-providers': 1, + 'connectivity.devices': 2, + 'connectivity.sensors': 3, + 'connectivity.events': 4, + 'analytics.index': 0, + 'analytics.reports': 1, + 'settings.index': 0, + 'settings.navigator-app': 1, + 'settings.map': 2, + 'settings.payments': 3, + 'settings.notifications': 4, + 'settings.routing': 5, + 'settings.orchestrator': 6, + 'settings.scheduling': 7, + 'settings.custom-fields': 8, + 'settings.avatars': 9, + }; + + return priorities[route] ?? 0; } fullRoute(route) { diff --git a/addon/components/layout/fleet-ops-sidebar/operations-monitor.js b/addon/components/layout/fleet-ops-sidebar/operations-monitor.js index b1390b5e8..59a0f1113 100644 --- a/addon/components/layout/fleet-ops-sidebar/operations-monitor.js +++ b/addon/components/layout/fleet-ops-sidebar/operations-monitor.js @@ -7,6 +7,7 @@ import { task } from 'ember-concurrency'; export default class LayoutFleetOpsSidebarOperationsMonitorComponent extends Component { @service store; + @service fetch; @service universe; @service hostRouter; @service mapManager; @@ -203,11 +204,11 @@ export default class LayoutFleetOpsSidebarOperationsMonitorComponent extends Com } fleetDrivers(fleet) { - return this.resourceArray(fleet?.drivers); + return this.resolveFleetResources(fleet, 'driver'); } fleetVehicles(fleet) { - return this.resourceArray(fleet?.vehicles); + return this.resolveFleetResources(fleet, 'vehicle'); } fleetSubfleets(fleet) { @@ -339,6 +340,53 @@ export default class LayoutFleetOpsSidebarOperationsMonitorComponent extends Com }); } + get driverById() { + return this.resourcesById(this.fallbackDrivers); + } + + get vehicleById() { + return this.resourcesById(this.fallbackVehicles); + } + + resourcesById(resources = []) { + return this.resourceArray(resources).reduce((map, resource) => { + this.resourceIdentifiers(resource).forEach((id) => map.set(id, resource)); + + return map; + }, new Map()); + } + + resourceIdentifiers(resource) { + return [resource?.id, resource?.uuid, resource?.public_id].filter(Boolean).map((id) => String(id)); + } + + resolveFleetResources(fleet, type) { + const embeddedResources = this.resourceArray(fleet?.[`${type}s`]); + + if (embeddedResources.length) { + return embeddedResources; + } + + const ids = this.resourceArray(fleet?.[`${type}_ids`]); + const resources = type === 'driver' ? this.driverById : this.vehicleById; + + return ids.map((id) => resources.get(String(id))).filter(Boolean); + } + + normalizeMonitorResource(type, resource) { + if (!resource) { + return resource; + } + + try { + this.store.pushPayload?.(type, { [type]: resource }); + } catch (_) { + // Keep the monitor usable in tests and older hosts without pushPayload support. + } + + return this.store.peekRecord?.(type, resource.id) ?? this.store.peekRecord?.(type, resource.public_id) ?? resource; + } + listenForChanges() { this.universe.on('fleet-ops.driver.saved', () => this.loadFallbackResources.perform()); this.universe.on('fleet-ops.vehicle.saved', () => this.loadFallbackResources.perform()); @@ -572,18 +620,35 @@ export default class LayoutFleetOpsSidebarOperationsMonitorComponent extends Com @task *loadFallbackResources() { try { - const [drivers, vehicles, fleets] = yield Promise.all([ - this.store.query('driver', { limit: 20, without: ['vendor'] }), - this.store.query('vehicle', { limit: 20 }), - this.store.query('fleet', { limit: 20, with: ['vehicles', 'drivers', 'subfleets'], parents_only: true }), - ]); - - this.fallbackDrivers = drivers.toArray?.() ?? drivers; - this.fallbackVehicles = vehicles.toArray?.() ?? vehicles; - this.fallbackFleets = fleets.toArray?.() ?? fleets; + const response = yield this.fetch.get('fleet-ops/live/operations-monitor', {}, { namespace: 'int/v1' }); + const drivers = this.resourceArray(response?.drivers).map((driver) => this.normalizeMonitorResource('driver', driver)); + const vehicles = this.resourceArray(response?.vehicles).map((vehicle) => this.normalizeMonitorResource('vehicle', vehicle)); + const fleets = this.resourceArray(response?.fleets).map((fleet) => this.normalizeMonitorFleet(fleet)); + + this.fallbackDrivers = drivers; + this.fallbackVehicles = vehicles; + this.fallbackFleets = fleets; this.expandedFleetIds = new Set(this.collectFleetKeys(this.fallbackFleets)); } catch (error) { this.notifications.serverError(error); } } + + normalizeMonitorFleet(fleet) { + const normalizedFleet = this.normalizeMonitorResource('fleet', fleet); + const subfleets = this.resourceArray(fleet?.subfleets).map((subfleet) => this.normalizeMonitorFleet(subfleet)); + + if (normalizedFleet?.set) { + normalizedFleet.set('driver_ids', this.resourceArray(fleet?.driver_ids)); + normalizedFleet.set('vehicle_ids', this.resourceArray(fleet?.vehicle_ids)); + normalizedFleet.set('subfleets', subfleets); + + return normalizedFleet; + } + + return { + ...fleet, + subfleets, + }; + } } diff --git a/addon/components/order/form.hbs b/addon/components/order/form.hbs index c89de9c7a..8163f622b 100644 --- a/addon/components/order/form.hbs +++ b/addon/components/order/form.hbs @@ -10,6 +10,7 @@ ServiceRate=(component "order/form/service-rate" resource=@resource) Notes=(component "order/form/notes" resource=@resource) Documents=(component "order/form/documents" resource=@resource) + OrchestratorConstraints=(component "order/form/orchestrator-constraints" resource=@resource) Metadata=(component "order/form/metadata" resource=@resource) ) }} @@ -23,6 +24,7 @@ + {{/if}} diff --git a/addon/components/order/form/details.hbs b/addon/components/order/form/details.hbs index bf39913ea..e6f1523ca 100644 --- a/addon/components/order/form/details.hbs +++ b/addon/components/order/form/details.hbs @@ -28,7 +28,7 @@ @value={{@resource.scheduled_at}} @minDate={{format-date-fns (now) "yyyy-MM-dd"}} @minTime={{format-date-fns (now) "HH:mm"}} - @onUpdate={{fn (mut @resource.scheduled_at)}} + @onUpdate={{this.setScheduledAt}} @disabled={{or @resource.dispatched (cannot-write @resource)}} /> @@ -237,46 +237,4 @@ -{{! ORCHESTRATOR CONSTRAINTS }} - -
-
- Delivery Window -
- - - - - - -
- Requirements & Priority -
-
- - - {{option.label}} - - -
- - - -
-
- diff --git a/addon/components/order/form/details.js b/addon/components/order/form/details.js index 86a99a450..84ffdcda2 100644 --- a/addon/components/order/form/details.js +++ b/addon/components/order/form/details.js @@ -20,9 +20,14 @@ export default class OrderFormDetailsComponent extends Component { this.orderConfigActions.loadAll.perform(); } + get integratedVendorServiceType() { + return this.args.resource?.type; + } + @action selectFacilitator(model) { this.args.resource.set('facilitator', model); this.args.resource.set('driver', null); + this.requestServiceQuoteRefresh('details.facilitator.changed'); } @action selectCustomer(model) { @@ -39,6 +44,7 @@ export default class OrderFormDetailsComponent extends Component { type: orderConfig.key, }); this.args.resource.payload.set('type', orderConfig.key); + this.requestServiceQuoteRefresh('details.order_config.changed'); try { const customFieldsManager = yield this.customFieldsRegistry.loadSubjectCustomFields.perform(orderConfig); @@ -70,61 +76,16 @@ export default class OrderFormDetailsComponent extends Component { this.leafletLayerVisibilityManager.showModelLayer(this.args.resource.driver_assigned); } - /** - * Resolves the reference date to use when pre-populating the date portion - * of a time window field. Prefers scheduled_at, falls back to created_at, - * and finally falls back to now so there is always a valid date. - * - * @returns {Date} - */ - get _timeWindowReferenceDate() { - const raw = this.args.resource.scheduled_at ?? this.args.resource.created_at ?? new Date(); - return raw instanceof Date ? raw : new Date(raw); + @action setScheduledAt(value) { + this.args.resource.scheduled_at = value; + this.requestServiceQuoteRefresh('details.scheduled_at.changed'); } - /** - * Called by DateTimeInput @onUpdate for time_window_start and time_window_end. - * - * When the user picks a time, we preserve their chosen time but replace the - * date portion with the order's scheduled_at date (or created_at if - * scheduled_at is not set). This means the user only ever needs to set the - * time — the date is always contextually correct. - * - * If the incoming value already carries a different date (e.g. the user - * explicitly changed it via the date part of the picker) we respect that - * and do not override it. - * - * @param {'time_window_start'|'time_window_end'} field - * @param {Date|string|null} value Value emitted by DateTimeInput - */ - @action setTimeWindow(field, value) { - if (!value) { - this.args.resource[field] = null; - return; - } - - const picked = value instanceof Date ? value : new Date(value); - if (isNaN(picked.getTime())) { - this.args.resource[field] = value; - return; - } - - const ref = this._timeWindowReferenceDate; - - // Only inject the reference date when the picked value has no meaningful - // date of its own — i.e. when the date portion is the Unix epoch - // (1970-01-01), which is what DateTimeInput emits when the user has only - // touched the time picker and not the date picker. - const isEpochDate = picked.getUTCFullYear() === 1970 && picked.getUTCMonth() === 0 && picked.getUTCDate() === 1; - - if (isEpochDate) { - const merged = new Date(ref); - merged.setHours(picked.getHours(), picked.getMinutes(), picked.getSeconds(), 0); - this.args.resource[field] = merged; - } else { - // User explicitly set a date — honour it as-is. - this.args.resource[field] = picked; - } + @action selectIntegratedServiceType(serviceType) { + const type = serviceType?.key ?? serviceType?.value ?? serviceType; + this.args.resource.type = type; + this.args.resource.payload.set('type', type); + this.requestServiceQuoteRefresh('details.integrated_service_type.changed'); } @action toggleAdhoc(toggled) { @@ -136,4 +97,8 @@ export default class OrderFormDetailsComponent extends Component { this.args.resource.pod_required = toggled; this.args.resource.pod_method = toggled ? 'scan' : null; } + + requestServiceQuoteRefresh(reason) { + this.orderCreation.requestServiceQuoteRefresh(reason, this.args.resource); + } } diff --git a/addon/components/order/form/orchestrator-constraints.hbs b/addon/components/order/form/orchestrator-constraints.hbs new file mode 100644 index 000000000..74c87fb7e --- /dev/null +++ b/addon/components/order/form/orchestrator-constraints.hbs @@ -0,0 +1,40 @@ + +
+
+ Delivery Window +
+ + + + + + +
+ Requirements & Priority +
+
+ + + {{option.label}} + + +
+ + + +
+
diff --git a/addon/components/order/form/orchestrator-constraints.js b/addon/components/order/form/orchestrator-constraints.js new file mode 100644 index 000000000..730b00a96 --- /dev/null +++ b/addon/components/order/form/orchestrator-constraints.js @@ -0,0 +1,61 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class OrderFormOrchestratorConstraintsComponent extends Component { + /** + * Resolves the reference date to use when pre-populating the date portion + * of a time window field. Prefers scheduled_at, falls back to created_at, + * and finally falls back to now so there is always a valid date. + * + * @returns {Date} + */ + get _timeWindowReferenceDate() { + const raw = this.args.resource.scheduled_at ?? this.args.resource.created_at ?? new Date(); + return raw instanceof Date ? raw : new Date(raw); + } + + /** + * Called by DateTimeInput @onUpdate for time_window_start and time_window_end. + * + * When the user picks a time, we preserve their chosen time but replace the + * date portion with the order's scheduled_at date (or created_at if + * scheduled_at is not set). This means the user only ever needs to set the + * time, the date is always contextually correct. + * + * If the incoming value already carries a different date (e.g. the user + * explicitly changed it via the date part of the picker) we respect that + * and do not override it. + * + * @param {'time_window_start'|'time_window_end'} field + * @param {Date|string|null} value Value emitted by DateTimeInput + */ + @action setTimeWindow(field, value) { + if (!value) { + this.args.resource[field] = null; + return; + } + + const picked = value instanceof Date ? value : new Date(value); + if (isNaN(picked.getTime())) { + this.args.resource[field] = value; + return; + } + + const ref = this._timeWindowReferenceDate; + + // Only inject the reference date when the picked value has no meaningful + // date of its own, i.e. when the date portion is the Unix epoch + // (1970-01-01), which is what DateTimeInput emits when the user has only + // touched the time picker and not the date picker. + const isEpochDate = picked.getUTCFullYear() === 1970 && picked.getUTCMonth() === 0 && picked.getUTCDate() === 1; + + if (isEpochDate) { + const merged = new Date(ref); + merged.setHours(picked.getHours(), picked.getMinutes(), picked.getSeconds(), 0); + this.args.resource[field] = merged; + } else { + // User explicitly set a date, honour it as-is. + this.args.resource[field] = picked; + } + } +} diff --git a/addon/components/order/form/payload.js b/addon/components/order/form/payload.js index 2b3e401e9..a87738c20 100644 --- a/addon/components/order/form/payload.js +++ b/addon/components/order/form/payload.js @@ -7,6 +7,7 @@ export default class OrderFormPayloadComponent extends Component { @service store; @service fetch; @service entityActions; + @service orderCreation; get entitiesByImportId() { const groups = []; @@ -32,6 +33,7 @@ export default class OrderFormPayloadComponent extends Component { const { value } = target; this.args.resource.payload.entities[index].destination_uuid = value; + this.requestServiceQuoteRefresh('entity.destination.changed'); } @action addFromCustomEntity(customEntity) { @@ -41,11 +43,15 @@ export default class OrderFormPayloadComponent extends Component { }); this.args.resource.payload.entities.pushObject(entity); + this.requestServiceQuoteRefresh('entity.added'); } @action addEntities(entities = []) { if (isArray(entities)) { this.args.resource.payload.entities.pushObjects(entities); + if (entities.length) { + this.requestServiceQuoteRefresh('entity.batch_added'); + } } } @@ -56,21 +62,61 @@ export default class OrderFormPayloadComponent extends Component { }); this.args.resource.payload.entities.pushObject(entity); + this.requestServiceQuoteRefresh('entity.added'); } @action removeEntity(entity) { if (this.args.resource.payload.entities.length === 1) return; if (!entity.isNew) { - return entity.destroyRecord(); + return entity.destroyRecord().then(() => { + this.requestServiceQuoteRefresh('entity.removed'); + }); } this.args.resource.payload.entities.removeObject(entity); + this.requestServiceQuoteRefresh('entity.removed'); } @action editEntity(entity) { this.entityActions.modal.edit(entity, { - confirm: (modal) => modal.done(), + confirm: (modal) => { + modal.done(); + this.requestServiceQuoteRefresh('entity.edited'); + }, }); } + + @action setEntityQuoteField(entity, field, value) { + this.setEntityValue(entity, field, value); + this.requestServiceQuoteRefresh(`entity.${field}.changed`); + } + + @action setEntityCurrency(entity, value) { + this.setEntityValue(entity, 'currency', value); + this.requestServiceQuoteRefresh('entity.currency.changed'); + } + + @action setEntityDimensionsUnit(entity, value) { + this.setEntityValue(entity, 'dimensions_unit', value); + this.requestServiceQuoteRefresh('entity.dimensions_unit.changed'); + } + + @action setEntityWeightUnit(entity, value) { + this.setEntityValue(entity, 'weight_unit', value); + this.requestServiceQuoteRefresh('entity.weight_unit.changed'); + } + + setEntityValue(entity, field, value) { + if (typeof entity?.set === 'function') { + entity.set(field, value); + return; + } + + entity[field] = value; + } + + requestServiceQuoteRefresh(reason) { + this.orderCreation.requestServiceQuoteRefresh(reason, this.args.resource); + } } diff --git a/addon/components/order/form/route.js b/addon/components/order/form/route.js index 1961843b6..0185ccde6 100644 --- a/addon/components/order/form/route.js +++ b/addon/components/order/form/route.js @@ -20,6 +20,7 @@ export default class OrderFormRouteComponent extends Component { @service currentUser; @service notifications; @service placeActions; + @service orderCreation; @tracked multipleWaypoints = false; @tracked routingControl; @tracked route; @@ -119,6 +120,8 @@ export default class OrderFormRouteComponent extends Component { this.clearWaypoints(); } + + this.requestServiceQuoteRefresh('route.waypoints.toggled'); } @action sortWaypoints({ sourceList, sourceIndex, targetList, targetIndex }) { @@ -132,6 +135,7 @@ export default class OrderFormRouteComponent extends Component { targetList.insertAt(targetIndex, item); this.previewRoute(); + this.requestServiceQuoteRefresh('route.waypoints.reordered'); } @action addWaypoint(properties = {}) { @@ -143,6 +147,7 @@ export default class OrderFormRouteComponent extends Component { this.args.resource.payload.waypoints.pushObject(waypoint); this.previewRoute(); + this.requestServiceQuoteRefresh('route.waypoint.added'); } @action setWaypointPlace(index, place) { @@ -159,6 +164,7 @@ export default class OrderFormRouteComponent extends Component { location: place.location, }); this.previewRoute(); + this.requestServiceQuoteRefresh('route.waypoint.place.changed'); } @action setWaypointCustomer(waypoint, model) { @@ -170,16 +176,19 @@ export default class OrderFormRouteComponent extends Component { if (this.multipleWaypoints && this.args.resource.payload.waypoints.length === 1) return; this.args.resource.payload.waypoints.removeObject(waypoint); this.previewRoute(); + this.requestServiceQuoteRefresh('route.waypoint.removed'); } @action clearWaypoints() { this.args.resource.payload.waypoints.clear(); this.previewRoute(); + this.requestServiceQuoteRefresh('route.waypoints.cleared'); } @action setPayloadPlace(prop, place) { this.args.resource.payload[prop] = place; this.previewRoute(); + this.requestServiceQuoteRefresh(`route.${prop}.changed`); } @action editPlace(place) { @@ -291,6 +300,7 @@ export default class OrderFormRouteComponent extends Component { } this.previewRoute(); this.args.resource.set('optimized', true); + this.requestServiceQuoteRefresh('route.optimized'); } @action setOptimizedRoute(route, trip, waypoints, engine = 'osrm') { @@ -314,5 +324,10 @@ export default class OrderFormRouteComponent extends Component { const routeModel = this.store.createRecord('route', payload); this.args.resource.route = routeModel; this.route = payload; + this.requestServiceQuoteRefresh('route.changed'); + } + + requestServiceQuoteRefresh(reason) { + this.orderCreation.requestServiceQuoteRefresh(reason, this.args.resource); } } diff --git a/addon/components/order/form/service-rate.hbs b/addon/components/order/form/service-rate.hbs index d023e05fe..06111ebd6 100644 --- a/addon/components/order/form/service-rate.hbs +++ b/addon/components/order/form/service-rate.hbs @@ -1,14 +1,55 @@
- - {{#if @resource.servicable}} + {{#if this.hasServiceQuoteOverride}} +
+
+
+
{{this.serviceQuoteOverride.title}}
+ {{#if this.serviceQuoteOverride.description}} +
{{this.serviceQuoteOverride.description}}
+ {{/if}} +
+ Locked +
+ + {{#if this.serviceQuoteOverride.isLoading}} +
+ +
+ {{else if this.overrideQuote}} +
+
+ {{t "order.fields.breakdown"}} +
+
+ {{#each this.overrideQuoteItems as |item|}} +
+ {{item.details}} + + {{format-currency item.amount this.overrideCurrency}} + +
+ {{/each}} +
+ {{t "common.total"}} + + {{format-currency this.overrideQuote.amount this.overrideCurrency}} + +
+
+
+ {{/if}} +
+ {{else}} + + {{#if @resource.servicable}}
{{#unless @resource.facilitator.isIntegratedVendor}} @@ -36,18 +77,27 @@
- {{#if this.getServiceQuotes.isRunning}} + {{#if this.shouldShowServiceQuotesLoader}}
{{else}} + {{#if (and this.isAutoRefreshingServiceQuotes this.hasServiceQuotes)}} +
+ +
+ {{/if}}
{{#each this.serviceQuotes as |serviceQuote|}}
@@ -106,6 +156,7 @@
{{/if}}
+ {{/if}} {{/if}}
diff --git a/addon/components/order/form/service-rate.js b/addon/components/order/form/service-rate.js index 23c379af1..f6e9fbc75 100644 --- a/addon/components/order/form/service-rate.js +++ b/addon/components/order/form/service-rate.js @@ -1,18 +1,72 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; -import { task } from 'ember-concurrency'; +import { task, timeout } from 'ember-concurrency'; +import { SERVICE_QUOTE_REFRESH_REQUESTED } from '../../../services/order-creation'; + +const SERVICE_QUOTE_REFRESH_DEBOUNCE_MS = 500; export default class OrderFormServiceRateComponent extends Component { @service serviceRateActions; + @service orderCreation; @tracked selectedRate; @tracked serviceRates = []; @tracked serviceQuotes = []; + @tracked isAutoRefreshingServiceQuotes = false; + serviceQuoteRefreshRequestId = 0; + + constructor() { + super(...arguments); + this._serviceQuoteRefreshRequested = (event) => this.handleServiceQuoteRefreshRequest(event); + this.orderCreation.on(SERVICE_QUOTE_REFRESH_REQUESTED, this._serviceQuoteRefreshRequested); + } + + willDestroy() { + this.orderCreation.off(SERVICE_QUOTE_REFRESH_REQUESTED, this._serviceQuoteRefreshRequested); + this.refreshServiceQuotes.cancelAll(); + super.willDestroy(...arguments); + } get isServicable() { return this.args.resource?.order_config && this.args.resource?.payloadCoordinates?.length >= 2; } + get canRefreshServiceQuotes() { + return this.args.resource?.servicable && this.selectedRate && this.args.resource?.payloadCoordinates?.length >= 2; + } + + get hasServiceQuotes() { + return this.serviceQuotes?.length > 0; + } + + get isLoadingServiceQuotes() { + return this.getServiceQuotes.isRunning || this.isAutoRefreshingServiceQuotes; + } + + get shouldShowServiceQuotesLoader() { + return this.isLoadingServiceQuotes && !this.hasServiceQuotes; + } + + get serviceQuoteOverride() { + return this.orderCreation.getServiceQuoteOverride(this.args.resource); + } + + get hasServiceQuoteOverride() { + return this.serviceQuoteOverride?.mode === 'locked'; + } + + get overrideQuote() { + return this.serviceQuoteOverride?.quote; + } + + get overrideQuoteItems() { + return this.overrideQuote?.items ?? []; + } + + get overrideCurrency() { + return this.overrideQuote?.currency ?? this.serviceQuoteOverride?.currency ?? 'USD'; + } + @task *queryServiceRates(toggled) { this.args.resource.servicable = toggled; if (!toggled) return; @@ -21,6 +75,74 @@ export default class OrderFormServiceRateComponent extends Component { @task *getServiceQuotes(serviceRate) { this.selectedRate = serviceRate; - this.serviceQuotes = yield this.serviceRateActions.getServiceQuotes.perform(serviceRate, this.args.resource); + yield this.loadServiceQuotes(serviceRate); + } + + @task *refreshServiceQuotes(refreshRequestId) { + this.isAutoRefreshingServiceQuotes = true; + + try { + yield timeout(SERVICE_QUOTE_REFRESH_DEBOUNCE_MS); + + if (!this.canRefreshServiceQuotes) { + return; + } + + yield this.loadServiceQuotes(this.selectedRate); + } finally { + if (refreshRequestId === this.serviceQuoteRefreshRequestId) { + this.isAutoRefreshingServiceQuotes = false; + } + } + } + + handleServiceQuoteRefreshRequest({ order } = {}) { + if (order && order !== this.args.resource) { + return; + } + + if (this.hasServiceQuoteOverride) { + return; + } + + if (this.args.resource?.servicable && !this.serviceRates?.length && !this.queryServiceRates.isRunning) { + this.queryServiceRates.perform(true); + } + + if (!this.canRefreshServiceQuotes) { + return; + } + + this.refreshServiceQuotes.cancelAll(); + this.serviceQuoteRefreshRequestId++; + this.isAutoRefreshingServiceQuotes = true; + this.refreshServiceQuotes.perform(this.serviceQuoteRefreshRequestId); + } + + async loadServiceQuotes(serviceRate) { + const serviceQuotes = await this.serviceRateActions.getServiceQuotes.perform(serviceRate, this.args.resource); + this.serviceQuotes = serviceQuotes ?? []; + this.clearStaleSelectedServiceQuote(); + } + + clearStaleSelectedServiceQuote() { + const selectedServiceQuote = this.args.resource?.service_quote_uuid; + + if (!selectedServiceQuote) { + return; + } + + if (!this.serviceQuotes?.length) { + this.args.resource.service_quote_uuid = null; + return; + } + + const selectedQuoteExists = this.serviceQuotes.some((serviceQuote) => { + return serviceQuote?.uuid === selectedServiceQuote || serviceQuote?.id === selectedServiceQuote; + }); + + if (!selectedQuoteExists) { + this.args.resource.service_quote_uuid = null; + } } } diff --git a/addon/components/telematic/details.hbs b/addon/components/telematic/details.hbs index de8b10d47..68f7e7125 100644 --- a/addon/components/telematic/details.hbs +++ b/addon/components/telematic/details.hbs @@ -32,7 +32,7 @@ {{#if this.attentionItems.length}}
{{#each this.attentionItems as |item|}} -
+
@@ -88,6 +88,12 @@
Webhook URL unavailable until this integration has a public ID.
{{/if}}
+ {{else}} +
+
Update Mode
+
Provider polling
+
FleetOps polls this provider for device snapshots and telemetry updates.
+
{{/if}}
diff --git a/addon/components/telematic/form.js b/addon/components/telematic/form.js index b6f5b56cc..ae248eba6 100644 --- a/addon/components/telematic/form.js +++ b/addon/components/telematic/form.js @@ -415,13 +415,17 @@ export default class TelematicFormComponent extends Component { this.args.resource.setProperties({ name: this.args.resource.name ?? provider.label, provider: provider.key, - credentials: (provider.required_fields ?? []).reduce((acc, item) => { - acc[item.name] = item.advanced || item.is_endpoint ? null : (item.default_value ?? null); - return acc; - }, {}), + credentials: this.buildProviderCredentials(provider), }); } + buildProviderCredentials(provider) { + return (provider.required_fields ?? []).reduce((acc, item) => { + acc[item.name] = item.default_value ?? null; + return acc; + }, {}); + } + @action goToStep(index) { if (!this.canReachStep(index)) { return; @@ -553,10 +557,7 @@ export default class TelematicFormComponent extends Component { this.lastConnectionTestCompletedAt = null; try { - const result = yield this.fetch.post(`telematics/${this.selectedProvider.key}/test-credentials`, { - credentials: this.args.resource.credentials, - telematic_id: this.args.resource?.id, - }); + const result = yield this.fetch.post(`telematics/${this.selectedProvider.key}/test-credentials`, this.getConnectionTestPayload()); this.connectionTestResult = result; this.lastConnectionTestCompletedAt = new Date(); this.updateResourceConnectionTestMeta(result); @@ -577,6 +578,13 @@ export default class TelematicFormComponent extends Component { } } + getConnectionTestPayload() { + return { + credentials: this.args.resource.credentials, + telematic_id: this.args.resource?.id, + }; + } + updateResourceConnectionTestMeta(result) { const meta = this.args.resource?.meta ?? {}; const testedAt = this.lastConnectionTestCompletedAt ?? new Date(); diff --git a/addon/components/work-order/details.hbs b/addon/components/work-order/details.hbs index 2ba8819a3..8113bdafc 100644 --- a/addon/components/work-order/details.hbs +++ b/addon/components/work-order/details.hbs @@ -20,6 +20,10 @@
Subject
{{n-a @resource.subject}}
+
+
Category
+
{{n-a (smart-humanize @resource.category)}}
+
{{! — Status & Priority — }}
@@ -185,4 +189,4 @@ -
\ No newline at end of file + diff --git a/addon/components/work-order/form.hbs b/addon/components/work-order/form.hbs index 880a058b7..d9b2afb53 100644 --- a/addon/components/work-order/form.hbs +++ b/addon/components/work-order/form.hbs @@ -13,6 +13,27 @@ + {{! Work Classification }} +
+ Work Order Classification +
+ +
+ +
{{category.label}}
+
{{category.description}}
+
+
+
+ {{! Status & Priority }}
Status & Priority @@ -21,14 +42,15 @@
- {{smart-humanize status}} +
{{status.label}}
+
{{status.description}}
@@ -243,4 +265,4 @@ -
\ No newline at end of file + diff --git a/addon/components/work-order/form.js b/addon/components/work-order/form.js index a34c6b4a6..b89f1e22d 100644 --- a/addon/components/work-order/form.js +++ b/addon/components/work-order/form.js @@ -1,6 +1,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; +import { workOrderCategories, workOrderStatuses } from '../../utils/fleet-ops-options'; /** * Maps a user-facing polymorphic type string to the Ember Data model name @@ -42,7 +43,10 @@ const ASSIGNEE_MODEL_TO_TYPE = { export default class WorkOrderFormComponent extends Component { /** Status options for work orders. */ - statusOptions = ['open', 'in_progress', 'on_hold', 'completed', 'cancelled']; + statusOptions = workOrderStatuses; + + /** Operational category options for work orders. */ + categoryOptions = workOrderCategories; /** Priority options for work orders. */ priorityOptions = ['low', 'medium', 'high', 'critical']; diff --git a/addon/controllers/connectivity/devices/index/details.js b/addon/controllers/connectivity/devices/index/details.js index 3b2c13ddc..7dab89cab 100644 --- a/addon/controllers/connectivity/devices/index/details.js +++ b/addon/controllers/connectivity/devices/index/details.js @@ -15,6 +15,18 @@ export default class ConnectivityDevicesIndexDetailsController extends Controlle route: 'connectivity.devices.index.details.index', label: this.intl.t('common.overview'), }, + { + route: 'connectivity.devices.index.details.vehicle', + label: this.intl.t('resource.vehicle'), + }, + { + route: 'connectivity.devices.index.details.sensors', + label: this.intl.t('resource.sensors'), + }, + { + route: 'connectivity.devices.index.details.events', + label: this.intl.t('resource.device-events'), + }, ...(isArray(registeredTabs) ? registeredTabs : []), ]; } diff --git a/addon/controllers/connectivity/devices/index/details/events.js b/addon/controllers/connectivity/devices/index/details/events.js index 6ad576e00..3f017bb2a 100644 --- a/addon/controllers/connectivity/devices/index/details/events.js +++ b/addon/controllers/connectivity/devices/index/details/events.js @@ -1,3 +1,246 @@ import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task, timeout } from 'ember-concurrency'; -export default class ConnectivityDevicesIndexDetailsEventsController extends Controller {} +const severityOptions = [ + { label: 'Info', value: 'info' }, + { label: 'Warning', value: 'warning' }, + { label: 'Error', value: 'error' }, + { label: 'Critical', value: 'critical' }, + { label: 'High', value: 'high' }, +]; + +const warningSeverities = ['warning', 'error', 'critical', 'high']; + +export default class ConnectivityDevicesIndexDetailsEventsController extends Controller { + @service deviceEventActions; + @service hostRouter; + @service intl; + + @tracked queryParams = [ + 'events_page', + 'events_limit', + 'events_sort', + 'events_query', + 'events_event_type', + 'events_severity', + 'events_processed', + 'events_occurred_at', + 'events_created_at', + ]; + @tracked device; + @tracked events_page = 1; + @tracked events_limit; + @tracked events_sort = '-created_at'; + @tracked events_query; + @tracked events_event_type; + @tracked events_severity; + @tracked events_processed; + @tracked events_occurred_at; + @tracked events_created_at; + + @tracked bulkActions = []; + + get events() { + return Array.from(this.model ?? []); + } + + get totalEvents() { + return this.model?.meta?.total ?? this.events.length; + } + + get warningEventsCount() { + return this.events.filter((event) => warningSeverities.includes(String(event.severity ?? '').toLowerCase())).length; + } + + get unprocessedEventsCount() { + return this.events.filter((event) => !event.processed_at && !event.is_processed).length; + } + + get processedEventsCount() { + return Math.max(this.events.length - this.unprocessedEventsCount, 0); + } + + get hasHealthyEventState() { + return this.events.length > 0 && this.warningEventsCount === 0 && this.unprocessedEventsCount === 0 && !this.hasActiveFilters; + } + + get hasActiveFilters() { + return Boolean(this.events_query || this.events_event_type || this.events_severity || this.events_processed || this.events_occurred_at || this.events_created_at); + } + + get metrics() { + return [ + { label: 'Recent events', value: this.totalEvents, icon: 'list', accentClass: 'fleetops-connectivity-kpi-accent-blue' }, + { + label: 'Warnings', + value: this.warningEventsCount, + icon: 'triangle-exclamation', + accentClass: this.warningEventsCount ? 'fleetops-connectivity-kpi-accent-amber' : 'fleetops-connectivity-kpi-accent-green', + }, + { + label: 'Unprocessed', + value: this.unprocessedEventsCount, + icon: 'clock', + accentClass: this.unprocessedEventsCount ? 'fleetops-connectivity-kpi-accent-rose' : 'fleetops-connectivity-kpi-accent-green', + }, + { label: 'Processed', value: this.processedEventsCount, icon: 'check', accentClass: 'fleetops-connectivity-kpi-accent-green' }, + ]; + } + + get actionButtons() { + return [ + { + icon: 'refresh', + size: 'sm', + onClick: this.refresh, + helpText: this.intl.t('common.refresh'), + wrapperClass: 'fleetops-telematics-action-button', + isLoading: this.refreshTask.isRunning, + disabled: this.refreshTask.isRunning, + }, + ]; + } + + get columns() { + return [ + { + sticky: true, + label: 'Event', + valuePath: 'event_type', + cellComponent: 'table/cell/anchor', + action: this.deviceEventActions.transition.view, + permission: 'fleet-ops view device-event', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'events_event_type', + filterComponent: 'filter/string', + }, + { + label: 'Severity', + valuePath: 'severity', + cellComponent: 'table/cell/status', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'events_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, + }, + { + label: 'Processed', + valuePath: 'processedAt', + sortParam: 'processed_at', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'events_processed', + filterComponent: 'filter/multi-option', + filterOptions: [ + { label: 'Processed', value: 'processed' }, + { label: 'Unprocessed', value: 'unprocessed' }, + ], + filterOptionLabel: 'label', + filterOptionValue: 'value', + }, + { + label: 'Occurred', + valuePath: 'occurredAt', + sortParam: 'occurred_at', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'events_occurred_at', + filterComponent: 'filter/date', + }, + { + label: this.intl.t('column.created-at'), + valuePath: 'createdAt', + sortParam: 'created_at', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'events_created_at', + filterComponent: 'filter/date', + hidden: true, + }, + { + 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-event') }), + 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-event') }), + 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, + resizable: false, + searchable: false, + }, + ]; + } + + @action refresh() { + if (this.refreshTask.isRunning) { + return; + } + + return this.refreshTask.perform(); + } + + @action async markProcessed(deviceEvent) { + await this.deviceEventActions.markProcessed(deviceEvent); + await this.hostRouter.refresh(); + } + + @task *refreshTask() { + yield this.hostRouter.refresh(); + } + + @task({ restartable: true }) *searchTask(event) { + const { + target: { value }, + } = event; + + if (!value) { + this.events_query = null; + return; + } + + yield timeout(250); + if (this.events_page > 1) this.events_page = 1; + this.events_query = value; + } +} diff --git a/addon/controllers/connectivity/devices/index/details/sensors.js b/addon/controllers/connectivity/devices/index/details/sensors.js new file mode 100644 index 000000000..e68f869b5 --- /dev/null +++ b/addon/controllers/connectivity/devices/index/details/sensors.js @@ -0,0 +1,199 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { task, timeout } from 'ember-concurrency'; +import fleetOpsOptions from '../../../../../utils/fleet-ops-options'; + +export default class ConnectivityDevicesIndexDetailsSensorsController extends Controller { + @service sensorActions; + @service hostRouter; + @service intl; + + @tracked queryParams = ['sensors_page', 'sensors_limit', 'sensors_sort', 'sensors_query', 'sensors_status', 'sensors_type', 'sensors_last_reading_at']; + @tracked device; + @tracked sensors_page = 1; + @tracked sensors_limit; + @tracked sensors_sort = '-updated_at'; + @tracked sensors_query; + @tracked sensors_status; + @tracked sensors_type; + @tracked sensors_last_reading_at; + + @tracked bulkActions = []; + + get sensors() { + return Array.from(this.model ?? []); + } + + get totalSensors() { + return this.model?.meta?.total ?? this.sensors.length; + } + + get activeSensorsCount() { + return this.sensors.filter((sensor) => sensor.is_active || sensor.status === 'active' || sensor.status === 'online').length; + } + + get reportingSensorsCount() { + return this.sensors.filter((sensor) => sensor.last_value || sensor.last_reading_at).length; + } + + get outOfRangeSensorsCount() { + return this.sensors.filter((sensor) => ['out_of_range', 'above_maximum', 'below_minimum'].includes(sensor.threshold_status)).length; + } + + get hasActiveFilters() { + return Boolean(this.sensors_query || this.sensors_status || this.sensors_type || this.sensors_last_reading_at); + } + + get metrics() { + return [ + { label: 'Sensors', value: this.totalSensors, icon: 'gauge-high', accentClass: 'fleetops-connectivity-kpi-accent-blue' }, + { label: 'Active', value: this.activeSensorsCount, icon: 'signal', accentClass: 'fleetops-connectivity-kpi-accent-green' }, + { label: 'Reporting', value: this.reportingSensorsCount, icon: 'chart-line', accentClass: 'fleetops-connectivity-kpi-accent-amber' }, + { + label: 'Out of range', + value: this.outOfRangeSensorsCount, + icon: 'triangle-exclamation', + accentClass: this.outOfRangeSensorsCount ? 'fleetops-connectivity-kpi-accent-rose' : 'fleetops-connectivity-kpi-accent-green', + }, + ]; + } + + get actionButtons() { + return [ + { + icon: 'refresh', + size: 'sm', + onClick: this.refresh, + helpText: this.intl.t('common.refresh'), + wrapperClass: 'fleetops-telematics-action-button', + isLoading: this.refreshTask.isRunning, + disabled: this.refreshTask.isRunning, + }, + ]; + } + + get columns() { + return [ + { + sticky: true, + label: this.intl.t('column.name'), + valuePath: 'name', + cellComponent: 'table/cell/anchor', + action: this.sensorActions.transition.view, + permission: 'fleet-ops view sensor', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'sensors_query', + filterComponent: 'filter/string', + }, + { + label: 'Type', + valuePath: 'type', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'sensors_type', + filterComponent: 'filter/multi-option', + filterOptions: fleetOpsOptions('sensorTypes'), + }, + { + label: 'Value', + valuePath: 'last_value', + resizable: true, + sortable: true, + }, + { + label: 'Unit', + valuePath: 'unit', + resizable: true, + sortable: true, + }, + { + label: 'Threshold', + valuePath: 'threshold_status', + cellComponent: 'table/cell/status', + resizable: true, + sortable: true, + }, + { + label: this.intl.t('column.status'), + valuePath: 'status', + cellComponent: 'table/cell/status', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'sensors_status', + filterComponent: 'filter/multi-option', + filterOptions: fleetOpsOptions('sensorStatuses'), + }, + { + label: 'Last Reading', + valuePath: 'lastReadingAt', + sortParam: 'last_reading_at', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'sensors_last_reading_at', + 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.sensor') }), + 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.sensor') }), + fn: this.sensorActions.transition.view, + permission: 'fleet-ops view sensor', + }, + { + label: this.intl.t('common.edit-resource', { resource: this.intl.t('resource.sensor') }), + fn: this.sensorActions.transition.edit, + permission: 'fleet-ops update sensor', + }, + ], + sortable: false, + filterable: false, + resizable: false, + searchable: false, + }, + ]; + } + + @action refresh() { + if (this.refreshTask.isRunning) { + return; + } + + return this.refreshTask.perform(); + } + + @task *refreshTask() { + yield this.hostRouter.refresh(); + } + + @task({ restartable: true }) *searchTask(event) { + const { + target: { value }, + } = event; + + if (!value) { + this.sensors_query = null; + return; + } + + yield timeout(250); + if (this.sensors_page > 1) this.sensors_page = 1; + this.sensors_query = value; + } +} diff --git a/addon/controllers/connectivity/devices/index/details/vehicle.js b/addon/controllers/connectivity/devices/index/details/vehicle.js new file mode 100644 index 000000000..3ea32f71a --- /dev/null +++ b/addon/controllers/connectivity/devices/index/details/vehicle.js @@ -0,0 +1,74 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class ConnectivityDevicesIndexDetailsVehicleController extends Controller { + @service deviceActions; + @service hostRouter; + @service intl; + + @tracked queryParams = []; + + get device() { + return this.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?.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?.name ?? this.vehicle?.driver_name; + } + + get plateNumber() { + return this.vehicle?.plate_number; + } + + get vin() { + return this.vehicle?.vin; + } + + get callSign() { + return this.vehicle?.call_sign; + } + + get yearMakeModel() { + return this.vehicle?.yearMakeModel ?? [this.vehicle?.year, this.vehicle?.make, this.vehicle?.model].filter(Boolean).join(' '); + } + + get hasVehicle() { + return Boolean(this.vehicleName || this.device?.attachable_uuid); + } + + @action attachToVehicle() { + return this.deviceActions.attachToVehicle(this.device, { callback: () => this.hostRouter.refresh() }); + } + + @action detachFromVehicle() { + return this.deviceActions.detachFromVehicle(this.device, { callback: () => this.hostRouter.refresh() }); + } + + @action openVehicle() { + if (this.vehicle?.id) { + return this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index.details', this.vehicle); + } + } +} diff --git a/addon/controllers/connectivity/telematics/details/attachments.js b/addon/controllers/connectivity/telematics/details/attachments.js index ae21037b8..16b9429b8 100644 --- a/addon/controllers/connectivity/telematics/details/attachments.js +++ b/addon/controllers/connectivity/telematics/details/attachments.js @@ -5,78 +5,136 @@ import { tracked } from '@glimmer/tracking'; export default class ConnectivityTelematicsDetailsAttachmentsController extends Controller { @service deviceActions; + @service fetch; @service hostRouter; @service intl; @service modalsManager; @service notifications; - @tracked queryParams = ['query', 'status', 'attachment_state', 'vehicle', 'sort']; + @tracked queryParams = ['query', 'status', 'vehicle', 'sort']; @tracked telematic; @tracked query; @tracked status; - @tracked attachment_state; @tracked vehicle; @tracked sort = '-updated_at'; + @tracked selectedDevice = null; + @tracked selectedVehicle = null; + @tracked isRefreshing = false; get syncedDevices() { - return Array.from(this.model ?? []); + return Array.from(this.model?.devices ?? this.model ?? []); } - get devices() { - const query = String(this.query ?? '') - .trim() - .toLowerCase(); - - return this.syncedDevices.filter((device) => { - const matchesQuery = - !query || - [device.displayName, device.name, device.device_id, device.serial_number, device.imei, device.attached_to_name, device.status, device.connection_status] - .filter(Boolean) - .some((value) => String(value).toLowerCase().includes(query)); + get loadError() { + return this.model?.error; + } - const matchesStatus = !this.status || device.status === this.status || device.connection_status === this.status; - const matchesAttachmentState = - !this.attachment_state || (this.attachment_state === 'attached' && device.attachable_uuid) || (this.attachment_state === 'unattached' && !device.attachable_uuid); - const matchesVehicle = !this.vehicle || device.attachable_uuid === this.vehicle; + get totalSyncedDevices() { + return this.model?.meta?.total ?? this.syncedDevices.length; + } - return matchesQuery && matchesStatus && matchesAttachmentState && matchesVehicle; - }); + get devices() { + return [...this.unattachedDevices, ...this.attachedDevices]; } get unattachedDevices() { - return this.devices.filter((device) => !device.attachable_uuid); + return this.syncedDevices.filter((device) => !device.attachable_uuid && this.deviceMatchesQuery(device) && this.deviceMatchesStatus(device)); } get attachedDevices() { - return this.devices.filter((device) => device.attachable_uuid); + return this.syncedDevices.filter((device) => device.attachable_uuid && this.deviceMatchesQuery(device) && this.deviceMatchesStatus(device) && this.deviceMatchesVehicle(device)); } get onlineDevicesCount() { - return this.devices.filter((device) => device.is_online || device.connection_status === 'online' || device.status === 'online' || device.status === 'active').length; + return this.syncedDevices.filter((device) => device.is_online || device.connection_status === 'online' || device.status === 'online' || device.status === 'active').length; + } + + get attachedDevicesCount() { + return this.syncedDevices.filter((device) => device.attachable_uuid).length; + } + + get unattachedDevicesCount() { + return this.syncedDevices.filter((device) => !device.attachable_uuid).length; + } + + get mappedVehiclesCount() { + const vehicleIds = new Set(this.syncedDevices.filter((device) => device.attachable_uuid).map((device) => device.attachable_uuid)); + + return vehicleIds.size; } get vehicleGroups() { const groups = new Map(); for (const device of this.attachedDevices) { - const key = device.attachable_uuid; + this.addDeviceToVehicleGroup(groups, device); + } + + return this.sortVehicleGroups(groups); + } - if (!groups.has(key)) { - groups.set(key, { - id: key, - name: device.attached_to_name ?? 'Unknown vehicle', - devices: [], - }); - } + addDeviceToVehicleGroup(groups, device) { + const key = device.attachable_uuid; - groups.get(key).devices.push(device); + if (!groups.has(key)) { + const vehicle = device.attachable; + + groups.set(key, { + id: key, + name: this.getDeviceVehicleName(device), + vehicle, + devices: [], + }); } + groups.get(key).devices.push(device); + } + + sortVehicleGroups(groups) { return Array.from(groups.values()).sort((a, b) => String(a.name).localeCompare(String(b.name))); } + get normalizedQuery() { + return String(this.query ?? '') + .trim() + .toLowerCase(); + } + + deviceMatchesQuery(device) { + const query = this.normalizedQuery; + + if (!query) { + return true; + } + + return [ + device.displayName, + device.name, + device.device_id, + device.serial_number, + device.imei, + device.public_id, + device.attached_to_name, + device.attachable?.displayName, + device.attachable?.display_name, + device.attachable?.name, + device.status, + device.connection_status, + ] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(query)); + } + + deviceMatchesStatus(device) { + return !this.status || device.status === this.status || device.connection_status === this.status; + } + + deviceMatchesVehicle(device) { + return !this.vehicle || device.attachable_uuid === this.vehicle; + } + get hasActiveFilters() { - return Boolean(this.query || this.status || this.attachment_state || this.vehicle); + return Boolean(this.query || this.status || this.vehicle); } get hasSyncedDevices() { @@ -96,6 +154,10 @@ export default class ConnectivityTelematicsDetailsAttachmentsController extends } get emptyStateVariant() { + if (this.loadError) { + return 'error'; + } + if (!this.hasSyncedDevices) { return 'not_synced'; } @@ -117,6 +179,19 @@ export default class ConnectivityTelematicsDetailsAttachmentsController extends get emptyStateContent() { switch (this.emptyStateVariant) { + case 'error': + return { + tone: 'danger', + icon: 'triangle-exclamation', + title: 'Unable to load device attachments', + message: 'Fleetbase could not load the full provider device list. Refresh the tab or review the connection logs.', + primaryText: 'Refresh', + primaryIcon: 'refresh', + primaryAction: this.refresh, + secondaryText: 'Go to Logs', + secondaryIcon: 'history', + secondaryAction: this.goToLogs, + }; case 'not_synced': return { tone: 'warning', @@ -158,36 +233,96 @@ export default class ConnectivityTelematicsDetailsAttachmentsController extends get metrics() { return [ - { label: 'Vehicles mapped', value: this.vehicleGroups.length, icon: 'truck', accentClass: 'fleetops-connectivity-kpi-accent-blue' }, - { label: 'Attached devices', value: this.attachedDevices.length, icon: 'link', accentClass: 'fleetops-connectivity-kpi-accent-green' }, - { label: 'Unattached devices', value: this.unattachedDevices.length, icon: 'link-slash', accentClass: 'fleetops-connectivity-kpi-accent-amber' }, + { label: 'Synced devices', value: this.totalSyncedDevices, icon: 'microchip', accentClass: 'fleetops-connectivity-kpi-accent-blue' }, + { label: 'Vehicles mapped', value: this.mappedVehiclesCount, icon: 'truck', accentClass: 'fleetops-connectivity-kpi-accent-blue' }, + { label: 'Attached devices', value: this.attachedDevicesCount, icon: 'link', accentClass: 'fleetops-connectivity-kpi-accent-green' }, + { label: 'Unattached devices', value: this.unattachedDevicesCount, icon: 'link-slash', accentClass: 'fleetops-connectivity-kpi-accent-amber' }, { label: 'Online devices', value: this.onlineDevicesCount, icon: 'signal', accentClass: 'fleetops-connectivity-kpi-accent-green' }, ]; } - @action updateQuery(event) { - this.query = event.target.value; + get visibleDevicesCount() { + return this.devices.length; + } + + get filteredUnattachedCount() { + return this.unattachedDevices.length; + } + + get filteredAttachedCount() { + return this.attachedDevices.length; } - @action updateAttachmentState(event) { - this.attachment_state = event.target.value || null; + get selectedDeviceName() { + return this.selectedDevice?.displayName ?? this.selectedDevice?.name ?? this.selectedDevice?.device_id; + } + + @action updateQuery(event) { + this.query = event.target.value; } @action updateStatus(event) { this.status = event.target.value || null; } + @action updateVehicle(valueOrEvent) { + const value = valueOrEvent?.target ? valueOrEvent.target.value : valueOrEvent; + + this.vehicle = value || null; + } + + @action updateSelectedVehicle(vehicle) { + this.selectedVehicle = vehicle; + this.vehicle = vehicle?.id ?? null; + } + @action clearFilters() { this.query = null; this.status = null; - this.attachment_state = null; this.vehicle = null; + this.selectedVehicle = null; } @action goToDevices() { return this.hostRouter.transitionTo('console.fleet-ops.connectivity.telematics.details.devices', this.telematic); } + @action goToLogs() { + return this.hostRouter.transitionTo('console.fleet-ops.connectivity.telematics.details.logs', this.telematic); + } + + @action async refresh() { + this.isRefreshing = true; + + try { + return await this.hostRouter.refresh(); + } finally { + this.isRefreshing = false; + } + } + + @action selectUnattachedDevice(device) { + if (this.selectedDevice === device) { + this.selectedDevice = null; + + return; + } + + this.selectedDevice = device; + } + + @action clearSelectedDevice() { + this.selectedDevice = null; + } + + @action attachSelectedDeviceToGroup(group) { + if (!this.selectedDevice) { + return; + } + + return this.attachDeviceToVehicle(this.selectedDevice, group.vehicle ?? { id: group.id, name: group.name }); + } + @action openAttachDeviceModal(device) { this.modalsManager.show('modals/attach-telematic-device', { title: this.intl.t('device.prompts.attach-device-to-vehicle-title', { deviceName: device.displayName ?? device.name ?? device.device_id ?? this.intl.t('resource.device') }), @@ -200,20 +335,12 @@ export default class ConnectivityTelematicsDetailsAttachmentsController extends return; } - device.setProperties({ - attachable_uuid: selectedVehicle.id, - attachable_type: 'fleet-ops:vehicle', - }); - modal.startLoading(); try { - await device.save(); - this.notifications.success(this.intl.t('device.prompts.attach-to-vehicle-success')); - await this.hostRouter.refresh(); + await this.attachDeviceToVehicle(device, selectedVehicle); modal.done(); } catch (error) { - device.rollbackAttributes(); this.notifications.serverError(error); modal.stopLoading(); } @@ -221,6 +348,14 @@ export default class ConnectivityTelematicsDetailsAttachmentsController extends }); } + async attachDeviceToVehicle(device, selectedVehicle) { + const response = await this.fetch.post(`devices/${device.id}/attach`, { vehicle: selectedVehicle.id }); + + this.applyDeviceAttachment(device, selectedVehicle, response?.device); + this.selectedDevice = null; + this.notifications.success(this.intl.t('device.prompts.attach-to-vehicle-success')); + } + @action detachDevice(device) { const deviceName = device.displayName ?? device.name ?? device.device_id ?? this.intl.t('resource.device'); @@ -229,19 +364,50 @@ export default class ConnectivityTelematicsDetailsAttachmentsController extends body: this.intl.t('device.prompts.detach-telematic-device-body', { deviceName }), confirm: async (modal) => { modal.startLoading(); - device.setProperties({ attachable_uuid: null, attachable_type: null }); try { - await device.save(); + const response = await this.fetch.post(`devices/${device.id}/detach`); + + this.applyDeviceDetachment(device, response?.device); this.notifications.success(this.intl.t('device.prompts.detach-from-vehicle-success')); - await this.hostRouter.refresh(); modal.done(); } catch (error) { - device.rollbackAttributes(); this.notifications.serverError(error); modal.stopLoading(); } }, }); } + + getDeviceVehicleName(device) { + return device.attached_to_name ?? device.attachable?.display_name ?? device.attachable?.name ?? 'Unknown vehicle'; + } + + applyDeviceAttachment(device, selectedVehicle, serverDevice = {}) { + this.updateDevice(device, { + attachable_uuid: serverDevice.attachable_uuid ?? selectedVehicle.id, + attachable_type: serverDevice.attachable_type ?? 'fleet-ops:vehicle', + attached_to_name: serverDevice.attached_to_name ?? selectedVehicle.displayName ?? selectedVehicle.display_name ?? selectedVehicle.name, + attachable: serverDevice.attachable ?? selectedVehicle, + }); + } + + applyDeviceDetachment(device, serverDevice = {}) { + this.updateDevice(device, { + attachable_uuid: serverDevice.attachable_uuid ?? null, + attachable_type: serverDevice.attachable_type ?? null, + attached_to_name: serverDevice.attached_to_name ?? null, + attachable: serverDevice.attachable ?? null, + }); + } + + updateDevice(device, properties) { + if (typeof device.setProperties === 'function') { + device.setProperties(properties); + + return; + } + + Object.assign(device, properties); + } } diff --git a/addon/controllers/connectivity/telematics/details/devices.js b/addon/controllers/connectivity/telematics/details/devices.js index 4fbcaf072..31978dfad 100644 --- a/addon/controllers/connectivity/telematics/details/devices.js +++ b/addon/controllers/connectivity/telematics/details/devices.js @@ -3,7 +3,14 @@ import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { task } from 'ember-concurrency'; -import fleetOpsOptions from '../../../../utils/fleet-ops-options'; + +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' }, +]; export default class ConnectivityTelematicsDetailsDevicesController extends Controller { @service deviceActions; @@ -13,7 +20,7 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont @service modalsManager; @service notifications; - @tracked queryParams = ['page', 'limit', 'sort', 'query', 'status', 'provider', 'attachment_state', 'device_id']; + @tracked queryParams = ['page', 'limit', 'sort', 'query', 'status', 'provider', 'attachment_state', 'vehicle', 'connection_status', 'device_id', 'last_online_at', 'updated_at']; @tracked telematic; @tracked page = 1; @tracked limit; @@ -22,7 +29,11 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont @tracked status; @tracked provider; @tracked attachment_state; + @tracked vehicle; + @tracked connection_status; @tracked device_id; + @tracked last_online_at; + @tracked updated_at; get devices() { return Array.from(this.model ?? []); @@ -45,7 +56,9 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont } get hasActiveFilters() { - return Boolean(this.query || this.status || this.provider || this.attachment_state || this.device_id); + 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 + ); } get hasDevices() { @@ -145,35 +158,39 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont ]; } - @tracked actionButtons = [ - { - icon: 'refresh', - size: 'xs', - onClick: this.refresh, - helpText: this.intl.t('common.refresh'), - wrapperClass: 'fleetops-telematics-action-button', - }, - { - icon: 'ellipsis-h', - prefix: 'fas', - text: 'Actions', - type: 'primary', - size: 'xs', - triggerClass: 'fleetops-telematics-action-button', - items: [ - { - icon: 'satellite-dish', - text: 'Sync Devices', - onClick: this.startDeviceSync, - }, - { - icon: 'link', - text: 'Attach Unassigned', - onClick: this.openAttachmentsForUnassigned, - }, - ], - }, - ]; + get actionButtons() { + return [ + { + icon: 'refresh', + size: 'sm', + onClick: this.refresh, + helpText: this.intl.t('common.refresh'), + wrapperClass: 'fleetops-telematics-action-button', + isLoading: this.refreshTask.isRunning, + disabled: this.refreshTask.isRunning, + }, + { + icon: 'ellipsis-h', + prefix: 'fas', + helpText: 'Actions', + type: 'primary', + size: 'sm', + triggerClass: 'fleetops-telematics-action-button', + items: [ + { + icon: 'satellite-dish', + text: 'Sync Devices', + onClick: this.startDeviceSync, + }, + { + icon: 'link', + text: 'Attach Unassigned', + onClick: this.openAttachmentsForUnassigned, + }, + ], + }, + ]; + } @tracked bulkActions = []; @@ -207,12 +224,11 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont resizable: true, sortable: true, filterable: true, - filterParam: 'attachment_state', - filterComponent: 'filter/multi-option', - filterOptions: [ - { label: 'Attached', value: 'attached' }, - { label: 'Unattached', value: 'unattached' }, - ], + filterParam: 'vehicle', + filterComponent: 'filter/model', + filterComponentPlaceholder: 'Select vehicle', + model: 'vehicle', + modelNamePath: 'displayName', }, { label: 'Connection', @@ -221,9 +237,11 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont resizable: true, sortable: true, filterable: true, - filterParam: 'status', + filterParam: 'connection_status', filterComponent: 'filter/multi-option', - filterOptions: fleetOpsOptions('deviceStatuses'), + filterOptions: connectionStatusOptions, + filterOptionLabel: 'label', + filterOptionValue: 'value', }, { label: 'Last Seen', @@ -231,6 +249,25 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont 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'), @@ -284,7 +321,11 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont } @action refresh() { - return this.hostRouter.refresh(); + if (this.refreshTask.isRunning) { + return; + } + + return this.refreshTask.perform(); } @action clearFilters() { @@ -292,7 +333,11 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont this.status = null; this.provider = null; this.attachment_state = null; + this.vehicle = null; + this.connection_status = null; this.device_id = null; + this.last_online_at = null; + this.updated_at = null; this.page = 1; } @@ -356,6 +401,10 @@ export default class ConnectivityTelematicsDetailsDevicesController extends Cont return this.syncDevices.perform(); } + @task *refreshTask() { + yield this.hostRouter.refresh(); + } + @task *syncDevices() { try { yield this.fetch.post(`telematics/${this.telematic.id}/discover`); diff --git a/addon/controllers/connectivity/telematics/details/events.js b/addon/controllers/connectivity/telematics/details/events.js index 1ad1537ff..b719ad83a 100644 --- a/addon/controllers/connectivity/telematics/details/events.js +++ b/addon/controllers/connectivity/telematics/details/events.js @@ -2,6 +2,15 @@ import Controller from '@ember/controller'; 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' }, +]; export default class ConnectivityTelematicsDetailsEventsController extends Controller { @service deviceEventActions; @@ -99,15 +108,19 @@ export default class ConnectivityTelematicsDetailsEventsController extends Contr ]; } - @tracked actionButtons = [ - { - icon: 'refresh', - size: 'xs', - onClick: this.refresh, - helpText: this.intl.t('common.refresh'), - wrapperClass: 'fleetops-telematics-action-button', - }, - ]; + get actionButtons() { + return [ + { + icon: 'refresh', + size: 'sm', + onClick: this.refresh, + helpText: this.intl.t('common.refresh'), + wrapperClass: 'fleetops-telematics-action-button', + isLoading: this.refreshTask.isRunning, + disabled: this.refreshTask.isRunning, + }, + ]; + } @tracked bulkActions = []; @@ -136,6 +149,8 @@ export default class ConnectivityTelematicsDetailsEventsController extends Contr filterComponentPlaceholder: 'Select device', filterParam: 'device_uuid', model: 'device', + modelNamePath: 'displayName', + query: { telematic_uuid: this.telematic?.id }, }, { label: 'Provider', @@ -155,7 +170,10 @@ export default class ConnectivityTelematicsDetailsEventsController extends Contr sortable: true, filterable: true, filterParam: 'severity', - filterComponent: 'filter/string', + filterComponent: 'filter/multi-option', + filterOptions: severityOptions, + filterOptionLabel: 'label', + filterOptionValue: 'value', }, { label: 'Processed', @@ -170,6 +188,8 @@ export default class ConnectivityTelematicsDetailsEventsController extends Contr { label: 'Processed', value: 'processed' }, { label: 'Unprocessed', value: 'unprocessed' }, ], + filterOptionLabel: 'label', + filterOptionValue: 'value', }, { label: 'Occurred', @@ -222,7 +242,11 @@ export default class ConnectivityTelematicsDetailsEventsController extends Contr } @action refresh() { - return this.hostRouter.refresh(); + if (this.refreshTask.isRunning) { + return; + } + + return this.refreshTask.perform(); } @action clearFilters() { @@ -241,4 +265,8 @@ export default class ConnectivityTelematicsDetailsEventsController extends Contr await this.deviceEventActions.markProcessed(deviceEvent); await this.hostRouter.refresh(); } + + @task *refreshTask() { + yield this.hostRouter.refresh(); + } } diff --git a/addon/controllers/connectivity/telematics/details/sensors.js b/addon/controllers/connectivity/telematics/details/sensors.js index 92d292dc6..6cb650195 100644 --- a/addon/controllers/connectivity/telematics/details/sensors.js +++ b/addon/controllers/connectivity/telematics/details/sensors.js @@ -2,6 +2,7 @@ import Controller from '@ember/controller'; import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; import fleetOpsOptions from '../../../../utils/fleet-ops-options'; export default class ConnectivityTelematicsDetailsSensorsController extends Controller { @@ -10,7 +11,7 @@ export default class ConnectivityTelematicsDetailsSensorsController extends Cont @service hostRouter; @service intl; - @tracked queryParams = ['page', 'limit', 'sort', 'query', 'status', 'type', 'device_uuid']; + @tracked queryParams = ['page', 'limit', 'sort', 'query', 'status', 'type', 'device_uuid', 'last_reading_at']; @tracked telematic; @tracked page = 1; @tracked limit; @@ -19,6 +20,7 @@ export default class ConnectivityTelematicsDetailsSensorsController extends Cont @tracked status; @tracked type; @tracked device_uuid; + @tracked last_reading_at; get sensors() { return Array.from(this.model ?? []); @@ -37,7 +39,7 @@ export default class ConnectivityTelematicsDetailsSensorsController extends Cont } get hasActiveFilters() { - return Boolean(this.query || this.status || this.type || this.device_uuid); + return Boolean(this.query || this.status || this.type || this.device_uuid || this.last_reading_at); } get hasSensors() { @@ -122,22 +124,26 @@ export default class ConnectivityTelematicsDetailsSensorsController extends Cont ]; } - @tracked actionButtons = [ - { - icon: 'refresh', - size: 'xs', - onClick: this.refresh, - helpText: this.intl.t('common.refresh'), - wrapperClass: 'fleetops-telematics-action-button', - }, - { - icon: 'microchip', - text: 'View Devices', - size: 'xs', - onClick: this.goToDevices, - wrapperClass: 'fleetops-telematics-action-button', - }, - ]; + get actionButtons() { + return [ + { + icon: 'refresh', + size: 'sm', + onClick: this.refresh, + helpText: this.intl.t('common.refresh'), + wrapperClass: 'fleetops-telematics-action-button', + isLoading: this.refreshTask.isRunning, + disabled: this.refreshTask.isRunning, + }, + { + icon: 'microchip', + text: 'View Devices', + size: 'sm', + onClick: this.goToDevices, + wrapperClass: 'fleetops-telematics-action-button', + }, + ]; + } @tracked bulkActions = []; @@ -179,6 +185,8 @@ export default class ConnectivityTelematicsDetailsSensorsController extends Cont filterComponentPlaceholder: 'Select device', filterParam: 'device_uuid', model: 'device', + modelNamePath: 'displayName', + query: { telematic_uuid: this.telematic?.id }, }, { label: 'Value', @@ -208,6 +216,9 @@ export default class ConnectivityTelematicsDetailsSensorsController extends Cont sortParam: 'last_reading_at', resizable: true, sortable: true, + filterable: true, + filterParam: 'last_reading_at', + filterComponent: 'filter/date', }, { label: '', @@ -241,7 +252,11 @@ export default class ConnectivityTelematicsDetailsSensorsController extends Cont } @action refresh() { - return this.hostRouter.refresh(); + if (this.refreshTask.isRunning) { + return; + } + + return this.refreshTask.perform(); } @action clearFilters() { @@ -249,6 +264,7 @@ export default class ConnectivityTelematicsDetailsSensorsController extends Cont this.status = null; this.type = null; this.device_uuid = null; + this.last_reading_at = null; this.page = 1; } @@ -267,4 +283,8 @@ export default class ConnectivityTelematicsDetailsSensorsController extends Cont queryParams: { device_id: sensor.device_uuid }, }); } + + @task *refreshTask() { + yield this.hostRouter.refresh(); + } } diff --git a/addon/controllers/maintenance/work-orders/index.js b/addon/controllers/maintenance/work-orders/index.js index 4068a07a2..70069061d 100644 --- a/addon/controllers/maintenance/work-orders/index.js +++ b/addon/controllers/maintenance/work-orders/index.js @@ -1,16 +1,18 @@ import Controller from '@ember/controller'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; +import fleetOpsOptions from '../../../utils/fleet-ops-options'; export default class MaintenanceWorkOrdersIndexController extends Controller { @service workOrderActions; @service intl; - @tracked queryParams = ['status', 'priority', 'page', 'limit', 'sort', 'query', 'public_id', 'created_at', 'updated_at']; + @tracked queryParams = ['category', 'status', 'priority', 'page', 'limit', 'sort', 'query', 'public_id', 'created_at', 'updated_at']; @tracked page = 1; @tracked limit; @tracked sort = '-created_at'; @tracked public_id; + @tracked category; @tracked status; @tracked priority; @@ -43,6 +45,21 @@ export default class MaintenanceWorkOrdersIndexController extends Controller { filterComponent: 'filter/string', }, { label: this.intl.t('column.subject'), valuePath: 'subject', resizable: true, sortable: true, filterable: true, filterParam: 'subject', filterComponent: 'filter/string' }, + { + label: this.intl.t('column.category'), + valuePath: 'category', + cellComponent: 'table/cell/base', + humanize: true, + resizable: true, + sortable: true, + filterable: true, + filterParam: 'category', + filterComponent: 'filter/select', + filterOptionLabel: 'label', + filterOptionValue: 'value', + filterOptions: fleetOpsOptions('workOrderCategories'), + placeholder: 'Select work order category', + }, { label: this.intl.t('column.status'), valuePath: 'status', diff --git a/addon/routes.js b/addon/routes.js index 547e41832..ce07daab9 100644 --- a/addon/routes.js +++ b/addon/routes.js @@ -184,6 +184,8 @@ export default buildRoutes(function () { this.route('edit', { path: '/edit/:public_id' }); this.route('details', { path: '/:public_id' }, function () { this.route('index', { path: '/' }); + this.route('vehicle'); + this.route('sensors'); this.route('events'); this.route('virtual', { path: '/:slug' }); }); diff --git a/addon/routes/connectivity/devices/index/details/events.js b/addon/routes/connectivity/devices/index/details/events.js index 9a0e4b1b6..e30b20b5a 100644 --- a/addon/routes/connectivity/devices/index/details/events.js +++ b/addon/routes/connectivity/devices/index/details/events.js @@ -1,3 +1,39 @@ import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; -export default class ConnectivityDevicesIndexDetailsEventsRoute extends Route {} +export default class ConnectivityDevicesIndexDetailsEventsRoute extends Route { + @service store; + + queryParams = { + events_page: { refreshModel: true }, + events_limit: { refreshModel: true }, + events_sort: { refreshModel: true }, + events_query: { refreshModel: true }, + events_event_type: { refreshModel: true }, + events_severity: { refreshModel: true }, + events_processed: { refreshModel: true }, + events_occurred_at: { refreshModel: true }, + events_created_at: { refreshModel: true }, + }; + + model(params) { + const device = this.modelFor('connectivity.devices.index.details'); + return this.store.query('device-event', { + page: params.events_page, + limit: params.events_limit, + sort: params.events_sort, + query: params.events_query, + event_type: params.events_event_type, + severity: params.events_severity, + processed: params.events_processed, + occurred_at: params.events_occurred_at, + created_at: params.events_created_at, + device_uuid: device.id, + }); + } + + setupController(controller, model) { + super.setupController(controller, model); + controller.device = this.modelFor('connectivity.devices.index.details'); + } +} diff --git a/addon/routes/connectivity/devices/index/details/sensors.js b/addon/routes/connectivity/devices/index/details/sensors.js new file mode 100644 index 000000000..eedb13305 --- /dev/null +++ b/addon/routes/connectivity/devices/index/details/sensors.js @@ -0,0 +1,35 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class ConnectivityDevicesIndexDetailsSensorsRoute extends Route { + @service store; + + queryParams = { + sensors_page: { refreshModel: true }, + sensors_limit: { refreshModel: true }, + sensors_sort: { refreshModel: true }, + sensors_query: { refreshModel: true }, + sensors_status: { refreshModel: true }, + sensors_type: { refreshModel: true }, + sensors_last_reading_at: { refreshModel: true }, + }; + + model(params) { + const device = this.modelFor('connectivity.devices.index.details'); + return this.store.query('sensor', { + page: params.sensors_page, + limit: params.sensors_limit, + sort: params.sensors_sort, + query: params.sensors_query, + status: params.sensors_status, + type: params.sensors_type, + last_reading_at: params.sensors_last_reading_at, + device_uuid: device.id, + }); + } + + setupController(controller, model) { + super.setupController(controller, model); + controller.device = this.modelFor('connectivity.devices.index.details'); + } +} diff --git a/addon/routes/connectivity/devices/index/details/vehicle.js b/addon/routes/connectivity/devices/index/details/vehicle.js new file mode 100644 index 000000000..6e6bbbf17 --- /dev/null +++ b/addon/routes/connectivity/devices/index/details/vehicle.js @@ -0,0 +1,7 @@ +import Route from '@ember/routing/route'; + +export default class ConnectivityDevicesIndexDetailsVehicleRoute extends Route { + model() { + return this.modelFor('connectivity.devices.index.details'); + } +} diff --git a/addon/routes/connectivity/telematics/details.js b/addon/routes/connectivity/telematics/details.js index f7cf13c34..4566f4d42 100644 --- a/addon/routes/connectivity/telematics/details.js +++ b/addon/routes/connectivity/telematics/details.js @@ -24,6 +24,6 @@ export default class ConnectivityTelematicsDetailsRoute extends Route { } model({ public_id }) { - return this.store.findRecord('telematic', public_id); + return this.store.queryRecord('telematic', { public_id, single: true }); } } diff --git a/addon/routes/connectivity/telematics/details/attachments.js b/addon/routes/connectivity/telematics/details/attachments.js index f5f3d9929..5975cc3f2 100644 --- a/addon/routes/connectivity/telematics/details/attachments.js +++ b/addon/routes/connectivity/telematics/details/attachments.js @@ -1,28 +1,91 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; +const PAGE_LIMIT = 100; +const MAX_PAGES = 100; + export default class ConnectivityTelematicsDetailsAttachmentsRoute extends Route { @service store; queryParams = { - query: { refreshModel: true }, - status: { refreshModel: true }, - attachment_state: { refreshModel: true }, - vehicle: { refreshModel: true }, + query: { refreshModel: false }, + status: { refreshModel: false }, + vehicle: { refreshModel: false }, sort: { refreshModel: true }, }; - model(params) { + async model(params) { const telematic = this.modelFor('connectivity.telematics.details'); + const selectedVehicle = await this.loadSelectedVehicle(params.vehicle); + const devices = []; + let page = 1; + let total = null; + let lastPage = null; + + try { + do { + const pageDevices = await this.store.query('device', { + telematic_uuid: telematic.id, + sort: params.sort, + page, + limit: PAGE_LIMIT, + }); + const records = Array.from(pageDevices ?? []); + const meta = pageDevices?.meta ?? {}; + const perPage = Number(meta.per_page ?? meta.limit ?? PAGE_LIMIT); + const currentPage = Number(meta.current_page ?? meta.currentPage ?? page); + + total = Number(meta.total ?? total ?? records.length); + lastPage = Number(meta.last_page ?? meta.lastPage ?? (total && perPage ? Math.ceil(total / perPage) : null)); + + devices.push(...records); + + if (records.length === 0 || (lastPage && currentPage >= lastPage) || (!lastPage && records.length < PAGE_LIMIT)) { + break; + } + + page += 1; + } while (page <= MAX_PAGES); + + return { + devices, + error: null, + selectedVehicle, + meta: { + total: total ?? devices.length, + loaded: devices.length, + pages: page, + }, + }; + } catch (error) { + return { + devices: [], + error, + selectedVehicle, + meta: { + total: 0, + loaded: 0, + pages: page, + }, + }; + } + } + + async loadSelectedVehicle(vehicleId) { + if (!vehicleId) { + return null; + } - return this.store.query('device', { - telematic_uuid: telematic.id, - sort: params.sort, - }); + try { + return await this.store.findRecord('vehicle', vehicleId); + } catch { + return null; + } } setupController(controller, model) { super.setupController(controller, model); controller.telematic = this.modelFor('connectivity.telematics.details'); + controller.selectedVehicle = model?.selectedVehicle ?? null; } } diff --git a/addon/routes/connectivity/telematics/details/devices.js b/addon/routes/connectivity/telematics/details/devices.js index a4b4d18bf..dedc1e9d4 100644 --- a/addon/routes/connectivity/telematics/details/devices.js +++ b/addon/routes/connectivity/telematics/details/devices.js @@ -12,7 +12,11 @@ export default class ConnectivityTelematicsDetailsDevicesRoute extends Route { status: { refreshModel: true }, provider: { refreshModel: true }, attachment_state: { refreshModel: true }, + vehicle: { refreshModel: true }, + connection_status: { refreshModel: true }, device_id: { refreshModel: true }, + last_online_at: { refreshModel: true }, + updated_at: { refreshModel: true }, }; model(params) { diff --git a/addon/routes/connectivity/telematics/details/sensors.js b/addon/routes/connectivity/telematics/details/sensors.js index eb84cb14b..0058f9509 100644 --- a/addon/routes/connectivity/telematics/details/sensors.js +++ b/addon/routes/connectivity/telematics/details/sensors.js @@ -12,6 +12,7 @@ export default class ConnectivityTelematicsDetailsSensorsRoute extends Route { status: { refreshModel: true }, type: { refreshModel: true }, device_uuid: { refreshModel: true }, + last_reading_at: { refreshModel: true }, }; model(params) { diff --git a/addon/routes/connectivity/telematics/edit.js b/addon/routes/connectivity/telematics/edit.js index 7e779b767..b5cfb53ab 100644 --- a/addon/routes/connectivity/telematics/edit.js +++ b/addon/routes/connectivity/telematics/edit.js @@ -24,6 +24,6 @@ export default class ConnectivityTelematicsEditRoute extends Route { } model({ public_id }) { - return this.store.findRecord('telematic', public_id); + return this.store.queryRecord('telematic', { public_id, single: true }); } } diff --git a/addon/routes/maintenance/work-orders/index.js b/addon/routes/maintenance/work-orders/index.js index 6e9f71ab3..9ff2e5588 100644 --- a/addon/routes/maintenance/work-orders/index.js +++ b/addon/routes/maintenance/work-orders/index.js @@ -11,6 +11,7 @@ export default class MaintenanceWorkOrdersIndexRoute extends Route { query: { refreshModel: true }, name: { refreshModel: true }, public_id: { refreshModel: true }, + category: { refreshModel: true }, status: { refreshModel: true }, priority: { refreshModel: true }, created_at: { refreshModel: true }, diff --git a/addon/services/order-creation.js b/addon/services/order-creation.js index 8fb3f0f0f..f2855e92e 100644 --- a/addon/services/order-creation.js +++ b/addon/services/order-creation.js @@ -1,12 +1,17 @@ import Service, { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { next } from '@ember/runloop'; +import Evented from '@ember/object/evented'; -export default class OrderCreationService extends Service { +export const SERVICE_QUOTE_REFRESH_REQUESTED = 'service-quote-refresh-requested'; + +export default class OrderCreationService extends Service.extend(Evented) { @service orderActions; @tracked context; @tracked order; @tracked cfManager; + @tracked serviceQuoteOverrides = {}; + orderValidationRules = {}; newOrder(attrs = {}) { const order = this.orderActions.createNewInstance(attrs); @@ -34,4 +39,52 @@ export default class OrderCreationService extends Service { removeContext(key) { delete this.context[key]; } + + requestServiceQuoteRefresh(reason, order = this.order) { + this.trigger(SERVICE_QUOTE_REFRESH_REQUESTED, { + reason, + order, + }); + } + + setServiceQuoteOverride(key, config = {}) { + this.serviceQuoteOverrides = { + ...this.serviceQuoteOverrides, + [key]: { + ...config, + key, + }, + }; + } + + getServiceQuoteOverride() { + return Object.values(this.serviceQuoteOverrides ?? {}).find((override) => override?.mode === 'locked') ?? null; + } + + clearServiceQuoteOverride(key) { + const overrides = { ...(this.serviceQuoteOverrides ?? {}) }; + delete overrides[key]; + this.serviceQuoteOverrides = overrides; + } + + setOrderValidationRule(key, fn) { + if (typeof fn !== 'function') { + return; + } + + this.orderValidationRules = { + ...(this.orderValidationRules ?? {}), + [key]: fn, + }; + } + + clearOrderValidationRule(key) { + const rules = { ...(this.orderValidationRules ?? {}) }; + delete rules[key]; + this.orderValidationRules = rules; + } + + validateOrderRules(order, cfManager = null) { + return Object.values(this.orderValidationRules ?? {}).every((fn) => fn(order, cfManager) !== false); + } } diff --git a/addon/services/order-validation.js b/addon/services/order-validation.js index 092a9faba..04fac5631 100644 --- a/addon/services/order-validation.js +++ b/addon/services/order-validation.js @@ -24,12 +24,13 @@ export default class OrderValidationService extends Service { const hasPickup = isNotEmpty(order.payload.pickup); const hasDropoff = isNotEmpty(order.payload.dropoff); const hasValidCustomFields = cfManager ? this.isCustomFieldsValid(cfManager) : true; + const hasValidExtensionRules = this.orderCreation.validateOrderRules(order, cfManager); if (hasWaypoints) { - return hasOrderConfig && hasOrderType && hasValidCustomFields; + return hasOrderConfig && hasOrderType && hasValidCustomFields && hasValidExtensionRules; } - return hasPickup && hasDropoff && hasOrderConfig && hasOrderType && hasValidCustomFields; + return hasPickup && hasDropoff && hasOrderConfig && hasOrderType && hasValidCustomFields && hasValidExtensionRules; } validationFails(order, cfManager) { diff --git a/addon/services/service-rate-actions.js b/addon/services/service-rate-actions.js index 72dccf77c..b1b4537d8 100644 --- a/addon/services/service-rate-actions.js +++ b/addon/services/service-rate-actions.js @@ -134,6 +134,7 @@ export default class ServiceRateActionsService extends ResourceActionService { scheduled_at: order.scheduled_at, is_route_optimized: order.optimized, service: serviceRate.id, + meta: order.meta, }); return serviceQuotes; diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index 88f04d5b1..85307444b 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -235,8 +235,6 @@ body[data-theme='dark'] .fleetops-connectivity-kpi-accent-rose .fleetops-connect .fleetops-provider-connections-actions .fleetops-provider-connections-search { min-width: 16rem; - height: 2.25rem; - min-height: 2.25rem; } .fleetops-provider-connections-header { @@ -257,8 +255,6 @@ body[data-theme='dark'] .fleetops-connectivity-kpi-accent-rose .fleetops-connect .fleetops-provider-connections-actions .fleetops-telematics-action-button, .fleetops-provider-connections-actions button { flex-shrink: 0; - height: 2.25rem; - min-height: 2.25rem; white-space: nowrap; } @@ -266,6 +262,26 @@ body[data-theme='dark'] .fleetops-connectivity-kpi-accent-rose .fleetops-connect white-space: nowrap; } +.fleetops-attachments-vehicle-filter-trigger.ember-power-select-trigger { + display: flex; + align-items: center; + min-height: 2rem; + padding-right: 1.75rem; +} + +.fleetops-attachments-vehicle-filter-trigger.ember-power-select-trigger .ember-power-select-placeholder, +.fleetops-attachments-vehicle-filter-trigger.ember-power-select-trigger .ember-power-select-selected-item { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fleetops-attachments-vehicle-filter-trigger.ember-power-select-trigger .ember-power-select-status-icon { + display: block; + right: 0.65rem; +} + .status-badge.long-offline-status-badge > span, .status-badge.long_offline-status-badge > span { background-color: #854d0e; diff --git a/addon/templates/connectivity/devices/index/details/events.hbs b/addon/templates/connectivity/devices/index/details/events.hbs index e2147cab0..c2b9ae4f1 100644 --- a/addon/templates/connectivity/devices/index/details/events.hbs +++ b/addon/templates/connectivity/devices/index/details/events.hbs @@ -1 +1,85 @@ -{{outlet}} \ No newline at end of file +
+
+

Event Review + Triage

+

Warning signals, diagnostics, and unprocessed telemetry.

+
+ +
+ {{#each this.metrics as |metric|}} +
+
+
+
{{metric.label}}
+
{{metric.value}}
+
+
+ +
+
+
+ {{/each}} +
+ + {{#if this.hasHealthyEventState}} + + {{/if}} + +
+
+
+

Telemetry Events

+

Filter by type, severity, processed state, and date.

+
+
+ +
+
+ + +
+
diff --git a/addon/templates/connectivity/devices/index/details/sensors.hbs b/addon/templates/connectivity/devices/index/details/sensors.hbs new file mode 100644 index 000000000..d602ff541 --- /dev/null +++ b/addon/templates/connectivity/devices/index/details/sensors.hbs @@ -0,0 +1,73 @@ +
+
+

Sensor Inventory + Health

+

Readings, thresholds, and reporting health for this device.

+
+ +
+ {{#each this.metrics as |metric|}} +
+
+
+
{{metric.label}}
+
{{metric.value}}
+
+
+ +
+
+
+ {{/each}} +
+ +
+
+
+

Device Sensors

+

Filter by type, status, and last reading.

+
+
+ +
+
+ + +
+
diff --git a/addon/templates/connectivity/devices/index/details/vehicle.hbs b/addon/templates/connectivity/devices/index/details/vehicle.hbs new file mode 100644 index 000000000..c129b5543 --- /dev/null +++ b/addon/templates/connectivity/devices/index/details/vehicle.hbs @@ -0,0 +1,84 @@ +
+
+
+

Vehicle Attachment

+

Fleet context for telemetry from this device.

+
+ + {{#if this.hasVehicle}} +
+ {{#if this.vehicle.id}} +
+ {{/if}} +
+ + {{#if this.hasVehicle}} +
+
+ {{this.vehicleName}} + +
+
+

{{this.vehicleName}}

+ {{#if this.vehicleStatus}} + {{smart-humanize this.vehicleStatus}} + {{/if}} +
+ +
+
+
Plate
+
{{n-a this.plateNumber}}
+
+
+
VIN
+
{{n-a this.vin}}
+
+
+
Call Sign
+
{{n-a this.callSign}}
+
+
+
Driver
+
{{n-a this.vehicleDriverName}}
+
+
+ +
+
+
Vehicle
+
{{n-a this.yearMakeModel}}
+
+
+
Attached Device
+
{{n-a this.device.displayName}}
+
+
+
Device Last Seen
+
{{n-a (format-date-fns this.device.last_online_at "dd MMM yyyy, HH:mm")}}
+
+
+
+
+
+ {{else}} + + {{/if}} +
diff --git a/addon/templates/connectivity/telematics/details/attachments.hbs b/addon/templates/connectivity/telematics/details/attachments.hbs index c45160a15..92dc96b50 100644 --- a/addon/templates/connectivity/telematics/details/attachments.hbs +++ b/addon/templates/connectivity/telematics/details/attachments.hbs @@ -1,55 +1,59 @@
-
-

Vehicle Attachment Mapping

-

Map synced provider devices to vehicles so telemetry can power vehicle state, maintenance, and operations workflows.

-
- -
- {{#each this.metrics as |metric|}} -
-
-
-
{{metric.label}}
-
{{metric.value}}
-
-
- +
+
+
+
+

Attachment Workspace

+

+ Loaded + {{this.syncedDevices.length}} + of + {{this.totalSyncedDevices}} + synced devices. +

+
+
+
+ + +
+ + {{vehicle.displayName}} + +
+
- {{/each}} -
- -
-
-
-

Vehicle Groups

-

Find a vehicle to review its attached devices, or resolve the unattached queue first.

-
-
- - - -
- {{#if (or (eq this.emptyStateVariant "not_synced") (eq this.emptyStateVariant "filtered_empty"))}} + {{#if (or (eq this.emptyStateVariant "error") (eq this.emptyStateVariant "not_synced") (eq this.emptyStateVariant "filtered_empty"))}}
{{else}} -
- {{#if (eq this.emptyStateVariant "empty")}} -
- -
- {{/if}} - - {{#if (eq this.emptyStateVariant "unattached_only")}} -
- -
- {{/if}} - -
-
-
-

Unattached Devices

-

These devices are synced, but not yet mapped to a vehicle. Attach devices deliberately one at a time.

+
+
+
+
+
+
+

Unattached

+ {{this.filteredUnattachedCount}} devices +
+ {{#if this.selectedDevice}} +

+ Selected: + {{this.selectedDeviceName}}. + Choose a vehicle on the right. +

+ {{else}} +

Select a device, then choose a vehicle target.

+ {{/if}} +
+ {{#if this.selectedDevice}} +
- - {{this.unattachedDevices.length}} devices - -
- {{#if this.unattachedDevices.length}} -
- {{#each this.unattachedDevices as |device|}} -
-
- -
-
-
+
+ {{/each}} +
+ {{else}} +
+
+ All visible synced devices are mapped to vehicles.
- {{/each}} + {{/if}}
- {{else}} -
- All synced devices in this view are mapped to vehicles. -
- {{/if}}
- {{#each this.vehicleGroups as |group|}} -
-
+
+
+
-

{{group.name}}

-

{{group.devices.length}} attached provider devices

+
+

Vehicles

+ {{this.vehicleGroups.length}} mapped +
+ {{#if this.selectedDevice}} +

+ Choose a vehicle for + {{this.selectedDeviceName}} +

+ {{else}} +

Attached devices grouped by vehicle.

+ {{/if}}
- Vehicle mapped
+
-
- {{#each group.devices as |device|}} -
- -
-
-
Provider ID
-
{{n-a device.device_id}}
+
+ {{#if this.vehicleGroups.length}} +
+ {{#each this.vehicleGroups as |group|}} +
+
+
+ {{group.name}} +
+
{{or group.vehicle.displayName group.vehicle.display_name group.vehicle.name group.name}}
+
+ {{n-a (or group.vehicle.plate_number group.vehicle.internal_id group.vehicle.call_sign group.vehicle.vin)}} + · + {{group.devices.length}} + devices +
+
+
+ {{#if this.selectedDevice}} +
-
-
Last seen
-
{{n-a (format-date-fns device.last_online_at "dd MMM HH:mm")}}
+ +
+ {{#each group.devices as |device|}} +
+
+
+ {{or device.displayName device.name device.device_id}} + + {{smart-humanize (or device.connection_status device.status "unknown")}} + +
+
+ {{or device.public_id device.device_id device.serial_number device.imei device.id}} + + {{n-a (format-date-fns device.last_online_at "dd MMM HH:mm")}} +
+
+
+
+
+ {{/each}}
-
-
+ {{/each}} +
+ {{else}} +
+
+ No visible vehicle groups. Attach a device from the left pane or adjust the filters.
- {{/each}} -
+
+ {{/if}}
- {{/each}} +
{{/if}}
diff --git a/addon/templates/connectivity/telematics/details/devices.hbs b/addon/templates/connectivity/telematics/details/devices.hbs index 3efe12e2d..9cb5c9b40 100644 --- a/addon/templates/connectivity/telematics/details/devices.hbs +++ b/addon/templates/connectivity/telematics/details/devices.hbs @@ -26,7 +26,7 @@

Synced Devices

Search provider identifiers, filter attachment state, and open devices for troubleshooting.

-
+
@@ -70,6 +72,7 @@ @onPageChange={{fn (mut this.page)}} @controller={{this}} @withoutHeader={{true}} + @tableWrapperClass="no-table-extra-spacing" @emptyStateComponent={{component "table/empty-state" variant="compact" diff --git a/addon/templates/connectivity/telematics/details/events.hbs b/addon/templates/connectivity/telematics/details/events.hbs index 3b8ebfb10..30c46e340 100644 --- a/addon/templates/connectivity/telematics/details/events.hbs +++ b/addon/templates/connectivity/telematics/details/events.hbs @@ -26,7 +26,7 @@

Telemetry Events

Filter events by severity, device, type, and processed state before opening details.

-
+
@@ -83,6 +85,7 @@ @onPageChange={{fn (mut this.page)}} @controller={{this}} @withoutHeader={{true}} + @tableWrapperClass="no-table-extra-spacing" @emptyStateComponent={{component "table/empty-state" variant="compact" diff --git a/addon/templates/connectivity/telematics/details/sensors.hbs b/addon/templates/connectivity/telematics/details/sensors.hbs index f3d840532..70409cce0 100644 --- a/addon/templates/connectivity/telematics/details/sensors.hbs +++ b/addon/templates/connectivity/telematics/details/sensors.hbs @@ -40,6 +40,8 @@ @searchInputClass="fleetops-provider-connections-search" @filterPickerButtonClass="fleetops-provider-connections-action-button" @columnPickerButtonClass="fleetops-provider-connections-action-button" + @filterPickerSize="sm" + @columnPickerSize="sm" />
@@ -70,6 +72,7 @@ @onPageChange={{fn (mut this.page)}} @controller={{this}} @withoutHeader={{true}} + @tableWrapperClass="no-table-extra-spacing" @emptyStateComponent={{component "table/empty-state" variant="compact" diff --git a/addon/templates/management/vehicles/index/edit.hbs b/addon/templates/management/vehicles/index/edit.hbs index a5e3af2bb..e5023d031 100644 --- a/addon/templates/management/vehicles/index/edit.hbs +++ b/addon/templates/management/vehicles/index/edit.hbs @@ -1,7 +1,7 @@ = 18'} - '@fleetbase/ember-ui@0.3.35': - resolution: {integrity: sha512-pJP74Nev5Xr87bqHkLVKRI7NJ3zmsfI2lwdq/Vwx9wgXR77n+20k0Yr753U7q+TFZoD1Zj+Ozm5Ywl2GGiJVMQ==} + '@fleetbase/ember-ui@0.3.36': + resolution: {integrity: sha512-rwM38fn5jiUr7sumUjxhe1BsCY3PJqdC5J3qRwKTLbUUvGZ8y+7k++qJqFpvdU2Ci2Uds2ePVtRUqcpYBfLgDg==} engines: {node: '>= 18'} - '@fleetbase/fleetops-data@0.1.38': - resolution: {integrity: sha512-OiK3q7+Ol9/9xlDwu45IB4mAOanReZAolVVMyI5NkQkS1Qu3U5qN+AZt6KMt4kTNIPRy9La1Wms6P2wI6Ahz5A==} + '@fleetbase/fleetops-data@0.1.39': + resolution: {integrity: sha512-Cxa6Oy3QSHvC/82nF3YFD7dc32Hl1ehTakn7ZPNOZuFj0jM3Q1nWk+T1RQK25umcdyq5Wx6EN1IXSk9SLrL9DQ==} engines: {node: '>= 18'} '@fleetbase/intl-lint@0.0.1': @@ -8870,7 +8870,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-compilation-targets@7.28.6': dependencies: @@ -8904,7 +8904,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.12 @@ -8918,7 +8918,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -8947,7 +8947,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.28.6': {} @@ -8958,7 +8958,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-wrap-function': 7.28.6 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -8974,7 +8974,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -8990,9 +8990,9 @@ snapshots: '@babel/helper-wrap-function@7.28.6': dependencies: - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -9012,7 +9012,7 @@ snapshots: '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -9020,17 +9020,17 @@ snapshots: '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color @@ -9038,7 +9038,7 @@ snapshots: '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) transitivePeerDependencies: @@ -9047,7 +9047,7 @@ snapshots: '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -9056,7 +9056,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -9073,7 +9073,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -9086,7 +9086,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -9104,38 +9104,38 @@ snapshots: '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) '@babel/traverse': 7.29.0 transitivePeerDependencies: @@ -9144,8 +9144,8 @@ snapshots: '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -9153,7 +9153,7 @@ snapshots: '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': dependencies: @@ -9164,7 +9164,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -9172,7 +9172,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -9182,7 +9182,7 @@ snapshots: '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-globals': 7.28.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) '@babel/traverse': 7.29.0 transitivePeerDependencies: @@ -9191,13 +9191,13 @@ snapshots: '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/template': 7.28.6 '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -9206,28 +9206,28 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -9235,17 +9235,17 @@ snapshots: '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color @@ -9254,7 +9254,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -9262,28 +9262,28 @@ snapshots: '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -9291,7 +9291,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -9299,7 +9299,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 transitivePeerDependencies: @@ -9309,7 +9309,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -9317,28 +9317,28 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) '@babel/traverse': 7.29.0 @@ -9348,7 +9348,7 @@ snapshots: '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -9356,12 +9356,12 @@ snapshots: '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color @@ -9369,13 +9369,13 @@ snapshots: '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -9384,36 +9384,36 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.0) babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.0) @@ -9424,12 +9424,12 @@ snapshots: '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 transitivePeerDependencies: - supports-color @@ -9437,24 +9437,24 @@ snapshots: '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) transitivePeerDependencies: @@ -9463,14 +9463,14 @@ snapshots: '@babel/plugin-transform-typescript@7.4.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) '@babel/plugin-transform-typescript@7.5.5(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -9478,25 +9478,25 @@ snapshots: '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/polyfill@7.12.1': dependencies: @@ -9508,7 +9508,7 @@ snapshots: '@babel/compat-data': 7.29.3 '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.27.1 '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) @@ -9583,8 +9583,8 @@ snapshots: '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/types': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/types': 7.29.7 esutils: 2.0.3 '@babel/runtime@7.12.18': @@ -10199,7 +10199,7 @@ snapshots: js-string-escape: 1.0.1 lodash: 4.18.1 resolve-package-path: 4.0.3 - semver: 7.8.0 + semver: 7.8.4 typescript-memoize: 1.1.1 '@embroider/shared-internals@2.9.2': @@ -10359,7 +10359,7 @@ snapshots: - utf-8-validate - webpack - '@fleetbase/ember-ui@0.3.35(@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))': + '@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)': 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))) @@ -10377,7 +10377,7 @@ snapshots: '@fullcalendar/daygrid': 6.1.20(@fullcalendar/core@6.1.20) '@fullcalendar/interaction': 6.1.20(@fullcalendar/core@6.1.20) '@makepanic/ember-power-calendar-date-fns': 0.4.2 - '@tailwindcss/forms': 0.5.11(tailwindcss@3.4.19) + '@tailwindcss/forms': 0.5.11(tailwindcss@3.4.19(yaml@2.9.0)) '@tiptap/core': 2.27.2(@tiptap/pm@2.27.2) '@tiptap/extension-color': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-text-style@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))) '@tiptap/extension-font-family': 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))(@tiptap/extension-text-style@2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2))) @@ -10443,7 +10443,7 @@ snapshots: postcss-import: 15.1.0(postcss@8.5.14) postcss-mixins: 9.0.4(postcss@8.5.14) postcss-preset-env: 9.6.0(postcss@8.5.14) - tailwindcss: 3.4.19 + tailwindcss: 3.4.19(yaml@2.9.0) transitivePeerDependencies: - '@ember/test-helpers' - '@glimmer/component' @@ -10465,7 +10465,7 @@ snapshots: - webpack-command - yaml - '@fleetbase/fleetops-data@0.1.38(@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.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))': 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)) @@ -10996,10 +10996,10 @@ snapshots: dependencies: defer-to-connect: 1.1.3 - '@tailwindcss/forms@0.5.11(tailwindcss@3.4.19)': + '@tailwindcss/forms@0.5.11(tailwindcss@3.4.19(yaml@2.9.0))': dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.4.19 + tailwindcss: 3.4.19(yaml@2.9.0) '@terraformer/common@2.1.2': {} @@ -11314,7 +11314,7 @@ snapshots: '@types/minimatch@6.0.0': dependencies: - minimatch: 7.4.9 + minimatch: 10.2.5 '@types/minimist@1.2.5': {} @@ -13574,7 +13574,7 @@ snapshots: ember-animated@1.1.4(@babel/core@7.29.0)(@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-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))): dependencies: - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 '@embroider/macros': 1.20.2(@babel/core@7.29.0) '@embroider/util': 1.13.5(@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))) assert-never: 1.4.0 @@ -13608,7 +13608,7 @@ snapshots: ember-assign-helper@0.5.1: dependencies: - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 transitivePeerDependencies: - supports-color @@ -13618,8 +13618,8 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/preset-env': 7.29.5(@babel/core@7.29.0) - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@embroider/shared-internals': 1.8.3 babel-core: 6.26.3 babel-loader: 8.4.1(@babel/core@7.29.0)(webpack@4.47.0) @@ -13640,7 +13640,7 @@ snapshots: mkdirp: 0.5.6 resolve-package-path: 3.1.0 rimraf: 2.7.1 - semver: 7.8.0 + semver: 7.8.4 symlink-or-copy: 1.3.1 typescript-memoize: 1.1.1 walk-sync: 0.3.4 @@ -13698,7 +13698,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@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)) - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 '@embroider/macros': 1.20.2(@babel/core@7.29.0) '@embroider/util': 1.13.5(@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))) '@glimmer/component': 1.1.2(@babel/core@7.29.0) @@ -14268,8 +14268,8 @@ snapshots: ember-concurrency-async@1.0.0(ember-concurrency@2.3.7(@babel/core@7.29.0)): dependencies: - '@babel/helper-plugin-utils': 7.28.6 - '@babel/types': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/types': 7.29.7 ember-cli-babel: 7.26.11 ember-cli-babel-plugin-helpers: 1.1.1 ember-cli-htmlbars: 4.5.0 @@ -14287,8 +14287,8 @@ snapshots: ember-concurrency@2.3.7(@babel/core@7.29.0): dependencies: - '@babel/helper-plugin-utils': 7.28.6 - '@babel/types': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/types': 7.29.7 '@glimmer/tracking': 1.1.2 ember-cli-babel: 7.26.11 ember-cli-babel-plugin-helpers: 1.1.1 @@ -14304,7 +14304,7 @@ snapshots: '@babel/helper-module-imports': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/types': 7.29.7 - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 decorator-transforms: 1.2.1(@babel/core@7.29.0) transitivePeerDependencies: - '@babel/core' @@ -14312,7 +14312,7 @@ snapshots: ember-cookies@1.3.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))): dependencies: - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 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)) transitivePeerDependencies: - supports-color @@ -14404,7 +14404,7 @@ snapshots: ember-element-helper@0.8.8: dependencies: - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 transitivePeerDependencies: - supports-color @@ -14437,7 +14437,7 @@ snapshots: dependencies: '@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/test-waiters': 3.1.0 - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 '@embroider/macros': 1.20.2(@babel/core@7.29.0) '@glimmer/component': 1.1.2(@babel/core@7.29.0) '@glimmer/tracking': 1.1.2 @@ -14452,7 +14452,7 @@ snapshots: ember-focus-trap@1.2.0(@babel/core@7.29.0): dependencies: - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 decorator-transforms: 2.3.2(@babel/core@7.29.0) focus-trap: 7.8.0 transitivePeerDependencies: @@ -14559,7 +14559,7 @@ snapshots: ember-lifeline@7.0.0(@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))): dependencies: - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 optionalDependencies: '@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)) transitivePeerDependencies: @@ -14637,7 +14637,7 @@ snapshots: ember-modifier@4.3.0(@babel/core@7.29.0): dependencies: - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 decorator-transforms: 2.3.2(@babel/core@7.29.0) transitivePeerDependencies: - '@babel/core' @@ -14677,7 +14677,7 @@ snapshots: ember-power-select@8.6.2(@babel/core@7.29.0)(@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-basic-dropdown@8.4.0(@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-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-concurrency@4.0.6(@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))): dependencies: '@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)) - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 '@embroider/util': 1.13.5(@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))) '@glimmer/component': 1.1.2(@babel/core@7.29.0) '@glimmer/tracking': 1.1.2 @@ -14863,7 +14863,7 @@ snapshots: ember-style-modifier@4.6.0(@babel/core@7.29.0): dependencies: - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 csstype: 3.2.3 decorator-transforms: 2.3.2(@babel/core@7.29.0) ember-modifier: 4.3.0(@babel/core@7.29.0) @@ -14953,7 +14953,7 @@ snapshots: ember-truth-helpers@4.0.3(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))): dependencies: - '@embroider/addon-shim': 1.10.2 + '@embroider/addon-shim': 1.10.3 ember-functions-as-helper-polyfill: 2.1.3(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)) transitivePeerDependencies: @@ -17866,12 +17866,13 @@ snapshots: '@csstools/utilities': 1.0.0(postcss@8.5.14) postcss: 8.5.14 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.14): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.14)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.14 + yaml: 2.9.0 postcss-logical@7.0.1(postcss@8.5.14): dependencies: @@ -19364,7 +19365,7 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - tailwindcss@3.4.19: + tailwindcss@3.4.19(yaml@2.9.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -19383,7 +19384,7 @@ snapshots: postcss: 8.5.14 postcss-import: 15.1.0(postcss@8.5.14) postcss-js: 4.1.0(postcss@8.5.14) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.14) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.14)(yaml@2.9.0) postcss-nested: 6.2.0(postcss@8.5.14) postcss-selector-parser: 6.1.2 resolve: 1.22.12 diff --git a/server/migrations/2025_08_28_054930_create_work_orders_table.php b/server/migrations/2025_08_28_054930_create_work_orders_table.php index 7064077e7..60cd9c46a 100644 --- a/server/migrations/2025_08_28_054930_create_work_orders_table.php +++ b/server/migrations/2025_08_28_054930_create_work_orders_table.php @@ -19,6 +19,7 @@ public function up(): void $table->string('code')->nullable()->index(); // external WO number $table->string('subject')->index(); + $table->string('category')->nullable()->index(); // preventive_maintenance, tire_issue, breakdown, etc. $table->string('status')->default('open')->index(); // open, in_progress, blocked, done, canceled $table->string('priority')->nullable()->index(); // low, normal, high, critical diff --git a/server/migrations/2026_06_18_000001_add_category_to_work_orders_table.php b/server/migrations/2026_06_18_000001_add_category_to_work_orders_table.php new file mode 100644 index 000000000..33983ab8e --- /dev/null +++ b/server/migrations/2026_06_18_000001_add_category_to_work_orders_table.php @@ -0,0 +1,32 @@ +string('category')->nullable()->index()->after('subject'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('work_orders', 'category')) { + Schema::table('work_orders', function (Blueprint $table) { + $table->dropIndex(['category']); + $table->dropColumn('category'); + }); + } + } +}; diff --git a/server/seeders/Testing/MaintenanceSeeder.php b/server/seeders/Testing/MaintenanceSeeder.php index 8fcb2a4eb..40656c805 100644 --- a/server/seeders/Testing/MaintenanceSeeder.php +++ b/server/seeders/Testing/MaintenanceSeeder.php @@ -155,18 +155,19 @@ protected function seedWorkOrders(Company $company, array $schedules): array $driver = $this->seededModel(Driver::class, 'driver_mira'); $vehicle = $this->seededModel(Vehicle::class, 'truck_maintenance'); $workOrders = [ - 'work_order_open' => ['WO-TEST-OPEN', 'Open Inspection', 'open', 'medium', $this->timestamp(-4), $this->timestamp(24), null, 'schedule_due_soon'], - 'work_order_overdue' => ['WO-TEST-OVERDUE', 'Overdue Service', 'open', 'high', $this->timestamp(-120), $this->timestamp(-24), null, 'schedule_overdue'], - 'work_order_completed' => ['WO-TEST-CLOSED', 'Completed Repair', 'completed', 'low', $this->timestamp(-240), $this->timestamp(-120), $this->timestamp(-96), 'schedule_overdue'], + 'work_order_open' => ['WO-TEST-OPEN', 'Open Inspection', 'inspection_request', 'open', 'medium', $this->timestamp(-4), $this->timestamp(24), null, 'schedule_due_soon'], + 'work_order_overdue' => ['WO-TEST-OVERDUE', 'Overdue Service', 'preventive_maintenance', 'open', 'high', $this->timestamp(-120), $this->timestamp(-24), null, 'schedule_overdue'], + 'work_order_completed' => ['WO-TEST-CLOSED', 'Completed Repair', 'general_repair', 'completed', 'low', $this->timestamp(-240), $this->timestamp(-120), $this->timestamp(-96), 'schedule_overdue'], ]; $models = []; - foreach ($workOrders as $seedId => [$code, $subject, $status, $priority, $openedAt, $dueAt, $closedAt, $scheduleSeedId]) { + foreach ($workOrders as $seedId => [$code, $subject, $category, $status, $priority, $openedAt, $dueAt, $closedAt, $scheduleSeedId]) { $models[$seedId] = $this->createRecord(WorkOrder::class, [ '_key' => $this->fixtureKey($seedId), 'company_uuid' => $company->uuid, 'code' => $code, 'subject' => $subject, + 'category' => $category, 'status' => $status, 'priority' => $priority, 'target_type' => Vehicle::class, diff --git a/server/src/Console/Commands/ProcessMaintenanceTriggers.php b/server/src/Console/Commands/ProcessMaintenanceTriggers.php index 58ae6b12d..6b964856c 100644 --- a/server/src/Console/Commands/ProcessMaintenanceTriggers.php +++ b/server/src/Console/Commands/ProcessMaintenanceTriggers.php @@ -124,6 +124,7 @@ public function handle(): void 'company_uuid' => $schedule->company_uuid, 'schedule_uuid' => $schedule->uuid, 'subject' => $schedule->name, + 'category' => 'preventive_maintenance', 'code' => $woCode, 'status' => 'open', 'priority' => $schedule->default_priority ?? 'normal', diff --git a/server/src/Console/Commands/SyncTelematics.php b/server/src/Console/Commands/SyncTelematics.php new file mode 100644 index 000000000..2b1d50749 --- /dev/null +++ b/server/src/Console/Commands/SyncTelematics.php @@ -0,0 +1,89 @@ +option('no-lock'); + $lock = null; + + if ($useLock) { + $lock = Cache::lock('fleetops:sync-telematics', 600); + if (!$lock->get()) { + $this->warn('Another telematics sync run appears to be in progress.'); + + return self::SUCCESS; + } + } + + try { + $providerKeys = $this->pollableProviderKeys($registry); + if (empty($providerKeys)) { + $this->info('No pollable telematics providers found.'); + + return self::SUCCESS; + } + + $query = Telematic::withoutGlobalScopes() + ->whereIn('provider', $providerKeys) + ->whereIn('status', ['active', 'connected']) + ->whereNotNull('company_uuid'); + + $queued = 0; + $query->orderBy('id')->chunkById(100, function ($telematics) use (&$queued) { + foreach ($telematics as $telematic) { + SyncTelematicDevicesJob::dispatch($telematic, [ + 'limit' => (int) $this->option('limit'), + ]); + $queued++; + } + }); + + $this->info("Queued {$queued} telematics sync job(s)."); + + return self::SUCCESS; + } finally { + if ($lock) { + $lock->release(); + } + } + } + + protected function pollableProviderKeys(TelematicProviderRegistry $registry): array + { + $requestedProviders = array_filter((array) $this->option('provider')); + $includeWebhookProviders = (bool) $this->option('sync-webhook-providers'); + + return $registry->all() + ->filter(function ($descriptor) use ($requestedProviders, $includeWebhookProviders) { + if (!empty($requestedProviders) && !in_array($descriptor->key, $requestedProviders, true)) { + return false; + } + + if (!$descriptor->supportsDiscovery) { + return false; + } + + return $includeWebhookProviders || !$descriptor->supportsWebhooks; + }) + ->keys() + ->values() + ->all(); + } +} diff --git a/server/src/Exceptions/TelematicProviderException.php b/server/src/Exceptions/TelematicProviderException.php index 2b27558d9..92e8e5593 100644 --- a/server/src/Exceptions/TelematicProviderException.php +++ b/server/src/Exceptions/TelematicProviderException.php @@ -2,8 +2,6 @@ namespace Fleetbase\FleetOps\Exceptions; -use Exception; - /** * Class TelematicProviderException. * @@ -11,4 +9,17 @@ */ class TelematicProviderException extends \Exception { + protected array $context; + + public function __construct(string $message = '', array $context = [], int $code = 0, ?\Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + + $this->context = $context; + } + + public function context(): array + { + return $this->context; + } } diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index 40ead24e9..edf63434a 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -3,7 +3,6 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1; use Fleetbase\FleetOps\Events\OrderDispatchFailed; -use Fleetbase\FleetOps\Events\OrderReady; use Fleetbase\FleetOps\Events\OrderStarted; use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException; use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException; @@ -14,6 +13,7 @@ use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource; use Fleetbase\FleetOps\Http\Resources\v1\Order as OrderResource; use Fleetbase\FleetOps\Http\Resources\v1\Proof as ProofResource; +use Fleetbase\FleetOps\Jobs\FinalizeApiOrderCreation; use Fleetbase\FleetOps\Models\Contact; use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Entity; @@ -335,26 +335,11 @@ public function create(CreateOrderRequest $request) // Determine if order should be dispatched on creation $shouldDispatch = $request->boolean('dispatch') && $integratedVendorOrder === null; - // Run background processes on queue - dispatch(function () use ($order, $serviceQuote, $shouldDispatch): void { - // notify driver if assigned - $order->notifyDriverAssigned(); - - // set driving distance and time - $order->setPreliminaryDistanceAndTime(); - - // if service quote attached purchase - $order->purchaseServiceQuote($serviceQuote); - - // dispatch if flagged true - if ($shouldDispatch) { - $order->dispatchWithActivity(); - } - - // Trigger order created event - event(new OrderReady($order)); - }) - ->afterCommit(); + FinalizeApiOrderCreation::dispatch( + $order->uuid, + $serviceQuote instanceof ServiceQuote ? $serviceQuote->uuid : null, + $shouldDispatch + )->afterCommit(); // response the driver resource return new OrderResource($order); diff --git a/server/src/Http/Controllers/Internal/v1/DeviceController.php b/server/src/Http/Controllers/Internal/v1/DeviceController.php index 260f5c787..9c679a47d 100644 --- a/server/src/Http/Controllers/Internal/v1/DeviceController.php +++ b/server/src/Http/Controllers/Internal/v1/DeviceController.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Http\Controllers\FleetOpsController; use Fleetbase\FleetOps\Models\Device; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\Utils; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -43,6 +44,31 @@ public static function onQueryRecord($query, $request): void if ($request->filled('device_id')) { $query->where('device_id', 'like', '%' . $request->input('device_id') . '%'); } + + if ($request->filled('connection_status')) { + $statuses = Utils::arrayFrom($request->input('connection_status')); + + $query->where(function ($statusQuery) use ($statuses) { + foreach ($statuses as $status) { + match ($status) { + 'online' => $statusQuery->orWhere('last_online_at', '>=', now()->subMinutes(10)), + 'recently_offline' => $statusQuery->orWhereBetween('last_online_at', [now()->subMinutes(60), now()->subMinutes(10)]), + 'offline' => $statusQuery->orWhereBetween('last_online_at', [now()->subDay(), now()->subMinutes(60)]), + 'long_offline' => $statusQuery->orWhere('last_online_at', '<', now()->subDay()), + 'never_connected' => $statusQuery->orWhereNull('last_online_at'), + default => null, + }; + } + }); + } + + if ($request->filled('last_online_at')) { + static::applyDateFilter($query, 'last_online_at', $request->input('last_online_at')); + } + + if ($request->filled('updated_at')) { + static::applyDateFilter($query, 'updated_at', $request->input('updated_at')); + } } /** @@ -146,6 +172,17 @@ protected function resolveVehicle(?string $id): ?Vehicle ->first(); } + protected static function applyDateFilter($query, string $column, string|array $value): void + { + $dates = Utils::dateRange($value); + + if (is_array($dates)) { + $query->whereBetween($column, $dates); + } else { + $query->whereDate($column, $dates); + } + } + 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 eba6d0099..04f99c036 100644 --- a/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +++ b/server/src/Http/Controllers/Internal/v1/DeviceEventController.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Http\Controllers\FleetOpsController; use Fleetbase\FleetOps\Models\DeviceEvent; +use Fleetbase\FleetOps\Support\Utils; use Illuminate\Http\JsonResponse; class DeviceEventController extends FleetOpsController @@ -32,11 +33,21 @@ public static function onQueryRecord($query, $request): void } if ($request->filled('processed')) { - match ($request->input('processed')) { - 'processed' => $query->whereNotNull('processed_at'), - 'unprocessed' => $query->whereNull('processed_at'), - default => null, - }; + $states = Utils::arrayFrom($request->input('processed')); + + if (!$states) { + return; + } + + $query->where(function ($query) use ($states) { + foreach ($states as $state) { + match ($state) { + 'processed' => $query->orWhereNotNull('processed_at'), + 'unprocessed' => $query->orWhereNull('processed_at'), + default => null, + }; + } + }); } } diff --git a/server/src/Http/Controllers/Internal/v1/FleetController.php b/server/src/Http/Controllers/Internal/v1/FleetController.php index b5d3d5bc2..35f4b8f3d 100644 --- a/server/src/Http/Controllers/Internal/v1/FleetController.php +++ b/server/src/Http/Controllers/Internal/v1/FleetController.php @@ -11,6 +11,7 @@ use Fleetbase\FleetOps\Models\FleetDriver; use Fleetbase\FleetOps\Models\FleetVehicle; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\LiveCacheService; use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\ImportRequest; use Illuminate\Http\Request; @@ -121,6 +122,8 @@ public static function removeDriver(FleetActionRequest $request) 'driver_uuid' => $driver->uuid, ])->delete(); + LiveCacheService::invalidate('operations-monitor'); + return response()->json([ 'status' => 'ok', 'deleted' => $deleted, @@ -153,6 +156,8 @@ public static function assignDriver(FleetActionRequest $request) ]); } + LiveCacheService::invalidate('operations-monitor'); + return response()->json([ 'status' => 'ok', 'exists' => $exists, @@ -178,6 +183,8 @@ public static function removeVehicle(FleetActionRequest $request) 'vehicle_uuid' => $vehicle->uuid, ])->delete(); + LiveCacheService::invalidate('operations-monitor'); + return response()->json([ 'status' => 'ok', 'deleted' => $deleted, @@ -210,6 +217,8 @@ public static function assignVehicle(FleetActionRequest $request) ]); } + LiveCacheService::invalidate('operations-monitor'); + return response()->json([ 'status' => 'ok', 'exists' => $exists, diff --git a/server/src/Http/Controllers/Internal/v1/LiveController.php b/server/src/Http/Controllers/Internal/v1/LiveController.php index b1a3e08e0..4f9dcee5e 100644 --- a/server/src/Http/Controllers/Internal/v1/LiveController.php +++ b/server/src/Http/Controllers/Internal/v1/LiveController.php @@ -8,12 +8,16 @@ use Fleetbase\FleetOps\Http\Resources\v1\Index\Place as PlaceIndexResource; use Fleetbase\FleetOps\Http\Resources\v1\Index\Vehicle as VehicleIndexResource; use Fleetbase\FleetOps\Models\Driver; +use Fleetbase\FleetOps\Models\Fleet; +use Fleetbase\FleetOps\Models\FleetDriver; +use Fleetbase\FleetOps\Models\FleetVehicle; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Place; use Fleetbase\FleetOps\Models\Route; use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Support\LiveCacheService; use Fleetbase\FleetOps\Support\LiveOrderQuery; +use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Illuminate\Http\Request; @@ -186,6 +190,179 @@ public function vehicles(Request $request) }); } + /** + * Get the complete resource snapshot used by the operations sidebar monitor. + * + * @return array + */ + public function operationsMonitor() + { + return LiveCacheService::remember('operations-monitor', [], function () { + $drivers = Driver::where(['company_uuid' => session('company')]) + ->with(['user', 'vehicle']) + ->applyDirectivesForPermissions('fleet-ops list driver') + ->orderBy('id') + ->get(); + + $vehicles = Vehicle::where(['company_uuid' => session('company')]) + ->with(['driver']) + ->applyDirectivesForPermissions('fleet-ops list vehicle') + ->orderBy('id') + ->get(); + + $fleets = Fleet::where(['company_uuid' => session('company')]) + ->applyDirectivesForPermissions('fleet-ops list fleet') + ->orderBy('name') + ->orderBy('id') + ->get(); + + $driverIdsByUuid = $drivers->pluck('uuid', 'uuid'); + $vehicleIdsByUuid = $vehicles->pluck('uuid', 'uuid'); + $onlineDriverUuids = $drivers->where('online', true)->pluck('uuid')->flip(); + $onlineVehicleUuids = $vehicles->where('online', true)->pluck('uuid')->flip(); + $fleetUuids = $fleets->pluck('uuid'); + + $driverMemberships = FleetDriver::whereIn('fleet_uuid', $fleetUuids) + ->whereIn('driver_uuid', $driverIdsByUuid->keys()) + ->get() + ->groupBy('fleet_uuid'); + + $vehicleMemberships = FleetVehicle::whereIn('fleet_uuid', $fleetUuids) + ->whereIn('vehicle_uuid', $vehicleIdsByUuid->keys()) + ->get() + ->groupBy('fleet_uuid'); + + $fleetNodes = $fleets->mapWithKeys(function (Fleet $fleet) use ($driverMemberships, $vehicleMemberships, $driverIdsByUuid, $vehicleIdsByUuid, $onlineDriverUuids, $onlineVehicleUuids) { + $fleetDriverUuids = $driverMemberships->get($fleet->uuid, collect())->pluck('driver_uuid')->unique()->values(); + $fleetVehicleUuids = $vehicleMemberships->get($fleet->uuid, collect())->pluck('vehicle_uuid')->unique()->values(); + $driverIds = $fleetDriverUuids->map(fn ($uuid) => $driverIdsByUuid->get($uuid))->filter()->values(); + $vehicleIds = $fleetVehicleUuids->map(fn ($uuid) => $vehicleIdsByUuid->get($uuid))->filter()->values(); + + return [ + $fleet->uuid => [ + 'id' => $fleet->uuid, + 'uuid' => $fleet->uuid, + 'public_id' => $fleet->public_id, + 'name' => $fleet->name, + 'task' => $fleet->task, + 'status' => $fleet->status, + 'slug' => $fleet->slug, + 'parent_fleet_uuid' => $fleet->parent_fleet_uuid, + 'drivers_count' => $driverIds->count(), + 'drivers_online_count' => $fleetDriverUuids->filter(fn ($uuid) => $onlineDriverUuids->has($uuid))->count(), + 'vehicles_count' => $vehicleIds->count(), + 'vehicles_online_count' => $fleetVehicleUuids->filter(fn ($uuid) => $onlineVehicleUuids->has($uuid))->count(), + 'driver_ids' => $driverIds->all(), + 'vehicle_ids' => $vehicleIds->all(), + 'subfleets' => [], + 'updated_at' => $fleet->updated_at, + 'created_at' => $fleet->created_at, + ], + ]; + }); + + return [ + 'drivers' => $drivers->map(fn (Driver $driver) => $this->serializeMonitorDriver($driver))->values()->all(), + 'vehicles' => $vehicles->map(fn (Vehicle $vehicle) => $this->serializeMonitorVehicle($vehicle))->values()->all(), + 'fleets' => $this->buildOperationsMonitorFleetTree($fleetNodes), + 'meta' => [ + 'generated_at' => now()->toISOString(), + 'ttl' => LiveCacheService::DEFAULT_TTL, + 'drivers_count' => $drivers->count(), + 'vehicles_count' => $vehicles->count(), + 'fleets_count' => $fleets->count(), + ], + ]; + }); + } + + protected function serializeMonitorDriver(Driver $driver): array + { + return [ + 'id' => $driver->uuid, + 'uuid' => $driver->uuid, + 'public_id' => $driver->public_id, + 'company_uuid' => $driver->company_uuid, + 'user_uuid' => $driver->user_uuid, + 'vehicle_uuid' => $driver->vehicle_uuid, + 'vendor_uuid' => $driver->vendor_uuid, + 'current_job_uuid' => $driver->current_job_uuid, + 'name' => $driver->name, + 'email' => $driver->email, + 'phone' => $driver->phone, + 'photo_url' => $driver->photo_url, + 'avatar_url' => $driver->photo_url, + 'vehicle_name' => $driver->vehicle_name, + 'status' => $driver->status, + 'location' => Utils::castPoint($driver->location), + 'heading' => (int) data_get($driver, 'heading', 0), + 'altitude' => (int) data_get($driver, 'altitude', 0), + 'speed' => (int) data_get($driver, 'speed', 0), + 'online' => (bool) data_get($driver, 'online', false), + 'assigned_orders_count' => null, + 'meta' => [ + '_index_resource' => true, + ], + 'updated_at' => $driver->updated_at, + 'created_at' => $driver->created_at, + ]; + } + + protected function serializeMonitorVehicle(Vehicle $vehicle): array + { + return [ + 'id' => $vehicle->uuid, + 'uuid' => $vehicle->uuid, + 'public_id' => $vehicle->public_id, + 'company_uuid' => $vehicle->company_uuid, + 'vendor_uuid' => $vehicle->vendor_uuid, + 'photo_uuid' => $vehicle->photo_uuid, + 'internal_id' => $vehicle->internal_id, + 'name' => $vehicle->name, + 'display_name' => $vehicle->display_name, + 'driver_name' => $vehicle->driver_name, + 'plate_number' => $vehicle->plate_number, + 'serial_number' => $vehicle->serial_number, + 'fuel_card_number' => $vehicle->fuel_card_number, + 'vin' => $vehicle->vin, + 'make' => $vehicle->make, + 'model' => $vehicle->model, + 'year' => $vehicle->year, + 'photo_url' => $vehicle->photo_url, + 'avatar_url' => $vehicle->avatar_url, + 'status' => $vehicle->status, + 'location' => Utils::castPoint($vehicle->location), + 'heading' => (int) data_get($vehicle, 'heading', 0), + 'altitude' => (int) data_get($vehicle, 'altitude', 0), + 'speed' => (int) data_get($vehicle, 'speed', 0), + 'online' => (bool) data_get($vehicle, 'online', false), + 'assigned_orders_count' => null, + 'meta' => [ + '_index_resource' => true, + ], + 'updated_at' => $vehicle->updated_at, + 'created_at' => $vehicle->created_at, + ]; + } + + protected function buildOperationsMonitorFleetTree($fleetNodes): array + { + $nodes = $fleetNodes->map(fn ($node) => $node)->all(); + + foreach ($nodes as $uuid => $node) { + $parentUuid = data_get($node, 'parent_fleet_uuid'); + + if ($parentUuid && isset($nodes[$parentUuid])) { + $nodes[$parentUuid]['subfleets'][] = &$nodes[$uuid]; + } + } + + return collect($nodes) + ->filter(fn ($node) => empty(data_get($node, 'parent_fleet_uuid')) || !isset($nodes[data_get($node, 'parent_fleet_uuid')])) + ->values() + ->all(); + } + /** * Get places based on filters for the current company. * diff --git a/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php b/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php index f4af84221..e2072371f 100644 --- a/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +++ b/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php @@ -102,6 +102,7 @@ public function trigger(string $id, Request $request): JsonResponse 'company_uuid' => $schedule->company_uuid, 'schedule_uuid' => $schedule->uuid, 'subject' => $schedule->name, + 'category' => 'preventive_maintenance', 'status' => 'open', 'priority' => $schedule->default_priority ?? 'normal', 'target_type' => $schedule->subject_type, diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index bb7f269c2..e18b2ff80 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -6,7 +6,6 @@ use Fleetbase\FleetOps\Events\EntityActivityChanged; use Fleetbase\FleetOps\Events\EntityCompleted; use Fleetbase\FleetOps\Events\OrderDispatchFailed; -use Fleetbase\FleetOps\Events\OrderReady; use Fleetbase\FleetOps\Events\OrderStarted; use Fleetbase\FleetOps\Events\WaypointActivityChanged; use Fleetbase\FleetOps\Events\WaypointCompleted; @@ -20,6 +19,8 @@ use Fleetbase\FleetOps\Http\Resources\v1\Order as OrderResource; use Fleetbase\FleetOps\Http\Resources\v1\Proof as ProofResource; use Fleetbase\FleetOps\Imports\OrdersImport; +use Fleetbase\FleetOps\Jobs\FinalizeInternalOrderCreation; +use Fleetbase\FleetOps\Jobs\NotifyBulkAssignedDriver; use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Entity; use Fleetbase\FleetOps\Models\Order; @@ -210,14 +211,7 @@ function (&$request, Order &$order, &$requestInput) { // if service quote attached purchase $order->purchaseServiceQuote($serviceQuote); - // Run background processes on queue - dispatch(function () use ($order): void { - // notify driver if assigned - $order->notifyDriverAssigned(); - - // Trigger order created event - event(new OrderReady($order)); - })->afterCommit(); + FinalizeInternalOrderCreation::dispatch($order->uuid)->afterCommit(); } ); @@ -527,29 +521,7 @@ public function bulkAssignDriver(BulkActionRequest $request) // Queue Per‑Order Notifications if (!$request->boolean('silent')) { - dispatch(function () use ($orderUuids, $driver): void { - // Re‑hydrate Driver To Avoid Serializing The Full Model - $driver = Driver::whereUuid($driver->uuid)->first(); - - // Stream Orders To Keep Memory Footprint Low - Order::whereIn('uuid', $orderUuids) - ->cursor() - ->each(function (Order $order) use ($driver): void { - // Synchronize In‑Memory Model - $order->setRelation('driverAssigned', $driver); - $order->driver_assigned_uuid = $driver->uuid; - - try { - $order->notifyDriverAssigned(); - } catch (\Throwable $e) { - logger()->warning( - 'Failed notifying driver on order ' . $order->uuid, - ['error' => $e->getMessage()] - ); - } - }); - }) - ->afterCommit(); + NotifyBulkAssignedDriver::dispatch($orderUuids->all(), $driver->uuid)->afterCommit(); } return response()->json([ diff --git a/server/src/Http/Controllers/Internal/v1/SearchController.php b/server/src/Http/Controllers/Internal/v1/SearchController.php index cc78e60f2..e2925a48d 100644 --- a/server/src/Http/Controllers/Internal/v1/SearchController.php +++ b/server/src/Http/Controllers/Internal/v1/SearchController.php @@ -332,9 +332,9 @@ private function searchMaintenanceSchedules(string $query, int $limit): Collecti private function searchWorkOrders(string $query, int $limit): Collection { - return $this->searchGeneric(WorkOrder::class, ['code', 'subject', 'instructions', 'status', 'priority', 'public_id', 'uuid'], $query, $limit, fn (WorkOrder $workOrder) => [ + return $this->searchGeneric(WorkOrder::class, ['code', 'subject', 'category', 'instructions', 'status', 'priority', 'public_id', 'uuid'], $query, $limit, fn (WorkOrder $workOrder) => [ 'label' => $workOrder->code ?: $workOrder->subject, - 'description' => $this->description($workOrder->status, $workOrder->priority, $workOrder->subject), + 'description' => $this->description($workOrder->status, $workOrder->priority, $workOrder->category ?: $workOrder->subject), 'icon' => 'clipboard-list', 'type' => 'Work Order', 'route' => 'console.fleet-ops.maintenance.work-orders.index.details', diff --git a/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php b/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php index a6d30f2fa..cd1a4938f 100644 --- a/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php @@ -115,7 +115,7 @@ public function queryRecord(Request $request) ]); }); - $quote->setRelation('items', $items); + $quote->setRelation('items', $quote->items()->get()); // if single quotation requested if ($single) { @@ -161,7 +161,7 @@ function ($query) use ($request) { ]); }); - $quote->setRelation('items', $items); + $quote->setRelation('items', $quote->items()->get()); $serviceQuotes->push($quote); } @@ -298,7 +298,7 @@ public function preliminaryQuery(Request $request) ]); }); - $quote->setRelation('items', $items); + $quote->setRelation('items', $quote->items()->get()); $serviceQuotes->push($quote); // if requesting single @@ -346,7 +346,7 @@ function ($query) use ($request) { ]); }); - $quote->setRelation('items', $items); + $quote->setRelation('items', $quote->items()->get()); $serviceQuotes->push($quote); } diff --git a/server/src/Http/Filter/DeviceEventFilter.php b/server/src/Http/Filter/DeviceEventFilter.php index a97954805..cb2caadd6 100644 --- a/server/src/Http/Filter/DeviceEventFilter.php +++ b/server/src/Http/Filter/DeviceEventFilter.php @@ -44,6 +44,50 @@ public function device(?string $device) }); } + public function deviceUuid(?string $device) + { + $this->device($device); + } + + public function severity(string|array $severity) + { + $severity = Utils::arrayFrom($severity); + + if ($severity) { + $this->builder->whereIn('severity', $severity); + } + } + + public function processed(string|array $processed) + { + $states = Utils::arrayFrom($processed); + + if (!$states) { + return; + } + + $this->builder->where(function ($query) use ($states) { + foreach ($states as $state) { + match ($state) { + 'processed' => $query->orWhereNotNull('processed_at'), + 'unprocessed' => $query->orWhereNull('processed_at'), + default => null, + }; + } + }); + } + + public function occurredAt($occurredAt) + { + $occurredAt = Utils::dateRange($occurredAt); + + if (is_array($occurredAt)) { + $this->builder->whereBetween('occurred_at', $occurredAt); + } else { + $this->builder->whereDate('occurred_at', $occurredAt); + } + } + public function createdAt($createdAt) { $createdAt = Utils::dateRange($createdAt); diff --git a/server/src/Http/Filter/DeviceFilter.php b/server/src/Http/Filter/DeviceFilter.php index 8ab7919fa..425a82545 100644 --- a/server/src/Http/Filter/DeviceFilter.php +++ b/server/src/Http/Filter/DeviceFilter.php @@ -31,6 +31,13 @@ public function status(string|array $status) } } + public function deviceId(?string $deviceId) + { + if ($deviceId) { + $this->builder->where('device_id', 'like', '%' . $deviceId . '%'); + } + } + public function telematic(?string $telematic) { $this->builder->where('telematic_uuid', $telematic); @@ -61,6 +68,35 @@ public function attachableUuid(?string $attachable) $this->builder->where('attachable_uuid', $attachable); } + public function vehicle(?string $vehicle) + { + if ($vehicle) { + $this->builder->where('attachable_uuid', $vehicle); + } + } + + public function connectionStatus(string|array $connectionStatus) + { + $statuses = Utils::arrayFrom($connectionStatus); + + if (!$statuses) { + return; + } + + $this->builder->where(function ($query) use ($statuses) { + foreach ($statuses as $status) { + match ($status) { + 'online' => $query->orWhere('last_online_at', '>=', now()->subMinutes(10)), + 'recently_offline' => $query->orWhereBetween('last_online_at', [now()->subMinutes(60), now()->subMinutes(10)]), + 'offline' => $query->orWhereBetween('last_online_at', [now()->subDay(), now()->subMinutes(60)]), + 'long_offline' => $query->orWhere('last_online_at', '<', now()->subDay()), + 'never_connected' => $query->orWhereNull('last_online_at'), + default => null, + }; + } + }); + } + public function attachmentState(?string $attachmentState) { if ($attachmentState === 'attached') { @@ -71,4 +107,25 @@ public function attachmentState(?string $attachmentState) $this->builder->whereNull('attachable_uuid'); } } + + public function lastOnlineAt(string|array $lastOnlineAt) + { + $this->filterDate('last_online_at', $lastOnlineAt); + } + + public function updatedAt(string|array $updatedAt) + { + $this->filterDate('updated_at', $updatedAt); + } + + protected function filterDate(string $column, string|array $value): void + { + $dates = Utils::dateRange($value); + + if (is_array($dates)) { + $this->builder->whereBetween($column, $dates); + } else { + $this->builder->whereDate($column, $dates); + } + } } diff --git a/server/src/Http/Filter/SensorFilter.php b/server/src/Http/Filter/SensorFilter.php new file mode 100644 index 000000000..56a6f705b --- /dev/null +++ b/server/src/Http/Filter/SensorFilter.php @@ -0,0 +1,125 @@ +builder->where('company_uuid', $this->session->get('company')); + } + + public function queryForPublic() + { + $this->queryForInternal(); + } + + public function query(?string $searchQuery) + { + $this->builder->search($searchQuery); + } + + public function type(string|array $type) + { + $types = Utils::arrayFrom($type); + + if ($types) { + $this->builder->whereIn('type', $types); + } + } + + public function sensorType(string|array $type) + { + $this->type($type); + } + + public function status(string|array $status) + { + $statuses = Utils::arrayFrom($status); + + if ($statuses) { + $this->builder->whereIn('status', $statuses); + } + } + + public function device(?string $device) + { + if ($device) { + $this->builder->where('device_uuid', $device); + } + } + + public function deviceUuid(?string $device) + { + $this->device($device); + } + + public function telematic(?string $telematic) + { + if ($telematic) { + $this->builder->where('telematic_uuid', $telematic); + } + } + + public function telematicUuid(?string $telematic) + { + $this->telematic($telematic); + } + + public function warrantyUuid(?string $warranty) + { + if ($warranty) { + $this->builder->where('warranty_uuid', $warranty); + } + } + + public function sensorableType(?string $sensorableType) + { + if ($sensorableType) { + $this->builder->where('sensorable_type', $sensorableType); + } + } + + public function serialNumber(?string $serialNumber) + { + if ($serialNumber) { + $this->builder->where('serial_number', 'like', '%' . $serialNumber . '%'); + } + } + + public function imei(?string $imei) + { + if ($imei) { + $this->builder->where('imei', 'like', '%' . $imei . '%'); + } + } + + public function lastReadingAt(string|array $lastReadingAt) + { + $this->filterDate('last_reading_at', $lastReadingAt); + } + + public function createdAt(string|array $createdAt) + { + $this->filterDate('created_at', $createdAt); + } + + public function updatedAt(string|array $updatedAt) + { + $this->filterDate('updated_at', $updatedAt); + } + + protected function filterDate(string $column, string|array $value): void + { + $dates = Utils::dateRange($value); + + if (is_array($dates)) { + $this->builder->whereBetween($column, $dates); + } else { + $this->builder->whereDate($column, $dates); + } + } +} diff --git a/server/src/Http/Filter/VehicleFilter.php b/server/src/Http/Filter/VehicleFilter.php index 6f29ce56b..54ad2dadc 100644 --- a/server/src/Http/Filter/VehicleFilter.php +++ b/server/src/Http/Filter/VehicleFilter.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; @@ -131,4 +132,16 @@ public function assignedFleet(string $assignedFleet) $this->builder->whereDoesntHave('fleets'); } } + + public function telematicUuid(?string $telematic) + { + if (!$telematic) { + return; + } + + $this->builder->whereHas('devices', function ($query) use ($telematic) { + $query->where('telematic_uuid', $telematic); + $query->whereIn('attachable_type', ['fleet-ops:vehicle', Vehicle::class]); + }); + } } diff --git a/server/src/Http/Requests/CreateDriverRequest.php b/server/src/Http/Requests/CreateDriverRequest.php index 5de9fbe82..007499372 100644 --- a/server/src/Http/Requests/CreateDriverRequest.php +++ b/server/src/Http/Requests/CreateDriverRequest.php @@ -28,19 +28,20 @@ public function rules() $isCreating = $this->isMethod('POST'); return [ - 'name' => [Rule::requiredIf($isCreating)], - 'email' => [Rule::requiredIf($isCreating), Rule::when($this->filled('email'), ['email']), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], - 'phone' => [Rule::requiredIf($isCreating), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], - 'password' => 'nullable|string', - 'country' => 'nullable|size:2', - 'city' => 'nullable|string', - 'vehicle' => 'nullable|string|starts_with:vehicle_|exists:vehicles,public_id', - 'status' => 'nullable|string|in:active,available,inactive', - 'vendor' => 'nullable|exists:vendors,public_id', - 'job' => 'nullable|exists:orders,public_id', - 'location' => ['nullable', new ResolvablePoint()], - 'latitude' => ['nullable', 'required_with:longitude'], - 'longitude' => ['nullable', 'required_with:latitude'], + 'name' => [Rule::requiredIf($isCreating)], + 'email' => [Rule::requiredIf($isCreating), Rule::when($this->filled('email'), ['email']), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], + 'phone' => [Rule::requiredIf($isCreating), Rule::when($isCreating, [Rule::unique('users')->whereNull('deleted_at')])], + 'password' => 'nullable|string', + 'country' => 'nullable|size:2', + 'city' => 'nullable|string', + 'vehicle' => 'nullable|string|starts_with:vehicle_|exists:vehicles,public_id', + 'license_expiry' => 'nullable|date', + 'status' => 'nullable|string|in:active,available,inactive', + 'vendor' => 'nullable|exists:vendors,public_id', + 'job' => 'nullable|exists:orders,public_id', + 'location' => ['nullable', new ResolvablePoint()], + 'latitude' => ['nullable', 'required_with:longitude'], + 'longitude' => ['nullable', 'required_with:latitude'], ]; } diff --git a/server/src/Http/Requests/Internal/CreateDriverRequest.php b/server/src/Http/Requests/Internal/CreateDriverRequest.php index 28d900538..4a406116a 100644 --- a/server/src/Http/Requests/Internal/CreateDriverRequest.php +++ b/server/src/Http/Requests/Internal/CreateDriverRequest.php @@ -51,6 +51,7 @@ public function rules() 'country' => 'nullable|string|size:2', 'city' => 'nullable|string|max:255', 'vehicle' => ['nullable', new ResolvableVehicle()], + 'license_expiry' => 'nullable|date', 'status' => 'nullable|string|in:active,available,inactive', 'vendor' => 'nullable|exists:vendors,public_id', 'job' => 'nullable|exists:orders,public_id', diff --git a/server/src/Http/Resources/v1/Driver.php b/server/src/Http/Resources/v1/Driver.php index 3be0f91a3..2e8fc4cda 100644 --- a/server/src/Http/Resources/v1/Driver.php +++ b/server/src/Http/Resources/v1/Driver.php @@ -38,7 +38,7 @@ public function toArray($request) 'email' => $this->email, 'phone' => $this->phone, 'drivers_license_number' => $this->drivers_license_number, - 'license_expiry' => $this->license_expiry, + 'license_expiry' => $this->formatDateOnly($this->license_expiry), 'photo_url' => $this->photo_url, 'avatar_url' => $this->avatar_url, 'avatar_value' => $this->when(Http::isInternalRequest(), $this->getOriginal('avatar_url')), @@ -96,6 +96,15 @@ public function getJobs(): AnonymousResourceCollection|FleetbaseResourceCollecti ); } + protected function formatDateOnly($date): ?string + { + if (!$date) { + return null; + } + + return method_exists($date, 'toDateString') ? $date->toDateString() : (string) $date; + } + /** * Transform the resource into an webhook payload. * @@ -110,7 +119,7 @@ public function toWebhookPayload() 'email' => $this->email, 'phone' => $this->phone, 'photo_url' => $this->photo_url, - 'license_expiry' => $this->license_expiry, + 'license_expiry' => $this->formatDateOnly($this->license_expiry), 'vehicle' => data_get($this, 'vehicle.public_id'), 'current_job' => data_get($this, 'currentJob.public_id'), 'vendor' => data_get($this, 'vendor.public_id'), diff --git a/server/src/Http/Resources/v1/WorkOrder.php b/server/src/Http/Resources/v1/WorkOrder.php index de4f9b3c4..0c70ab54a 100644 --- a/server/src/Http/Resources/v1/WorkOrder.php +++ b/server/src/Http/Resources/v1/WorkOrder.php @@ -41,6 +41,7 @@ public function toArray($request) // Core attributes 'code' => $this->code, 'subject' => $this->subject, + 'category' => $this->category, 'status' => $this->status, 'priority' => $this->priority, 'instructions' => $this->instructions, diff --git a/server/src/Jobs/FinalizeApiOrderCreation.php b/server/src/Jobs/FinalizeApiOrderCreation.php new file mode 100644 index 000000000..08b3b69ad --- /dev/null +++ b/server/src/Jobs/FinalizeApiOrderCreation.php @@ -0,0 +1,47 @@ +orderUuid)->first(); + if (!$order) { + return; + } + + $serviceQuote = $this->serviceQuoteUuid ? ServiceQuote::where('uuid', $this->serviceQuoteUuid)->first() : null; + + $order->notifyDriverAssigned(); + $order->setPreliminaryDistanceAndTime(); + $order->purchaseServiceQuote($serviceQuote); + + if ($this->shouldDispatch) { + $order->dispatchWithActivity(); + } + + event(new OrderReady($order)); + } +} diff --git a/server/src/Jobs/FinalizeInternalOrderCreation.php b/server/src/Jobs/FinalizeInternalOrderCreation.php new file mode 100644 index 000000000..c4d16cd53 --- /dev/null +++ b/server/src/Jobs/FinalizeInternalOrderCreation.php @@ -0,0 +1,35 @@ +orderUuid)->first(); + if (!$order) { + return; + } + + $order->notifyDriverAssigned(); + + event(new OrderReady($order)); + } +} diff --git a/server/src/Jobs/NotifyBulkAssignedDriver.php b/server/src/Jobs/NotifyBulkAssignedDriver.php new file mode 100644 index 000000000..b7007d4fb --- /dev/null +++ b/server/src/Jobs/NotifyBulkAssignedDriver.php @@ -0,0 +1,47 @@ +driverUuid)->first(); + if (!$driver) { + return; + } + + Order::whereIn('uuid', $this->orderUuids) + ->cursor() + ->each(function (Order $order) use ($driver): void { + $order->setRelation('driverAssigned', $driver); + $order->driver_assigned_uuid = $driver->uuid; + + try { + $order->notifyDriverAssigned(); + } catch (\Throwable $e) { + logger()->warning( + 'Failed notifying driver on order ' . $order->uuid, + ['error' => $e->getMessage()] + ); + } + }); + } +} diff --git a/server/src/Jobs/SyncTelematicDevicesJob.php b/server/src/Jobs/SyncTelematicDevicesJob.php index d08c93630..2b7cf835e 100644 --- a/server/src/Jobs/SyncTelematicDevicesJob.php +++ b/server/src/Jobs/SyncTelematicDevicesJob.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; /** @@ -27,7 +28,7 @@ class SyncTelematicDevicesJob implements ShouldQueue public Telematic $telematic; public array $options; public string $jobId; - public int $tries = 3; + public int $tries = 1; public int $timeout = 300; /** @@ -46,141 +47,186 @@ public function __construct(Telematic $telematic, array $options = [], ?string $ public function handle(TelematicProviderRegistry $registry, TelematicService $service): void { $correlationId = \Illuminate\Support\Str::uuid()->toString(); + $lockKey = 'fleetops:sync-telematic-devices:' . $this->telematic->uuid; + $lock = Cache::lock($lockKey, $this->timeout + 60); - Log::info('Device discovery started', [ - 'correlation_id' => $correlationId, - 'telematic_uuid' => $this->telematic->uuid, - 'provider' => $this->telematic->provider, - ]); + if (!$lock->get()) { + Log::info('Device discovery skipped because another sync is already running', [ + 'correlation_id' => $correlationId, + 'telematic_uuid' => $this->telematic->uuid, + 'provider' => $this->telematic->provider, + 'lock_key' => $lockKey, + ]); + + $this->telematic->meta = array_merge($this->telematic->meta ?? [], [ + 'last_sync_job_id' => $this->jobId, + 'last_sync_result' => 'skipped', + 'last_sync_skipped_reason' => 'sync_already_running', + 'last_sync_skipped_at' => now()->toDateTimeString(), + ]); + $this->telematic->save(); - $cursor = null; - $totalFetched = 0; - $totalLinked = 0; - $totalSkipped = 0; - $pageCount = 0; - $lastProviderAllCount = null; - $lastProviderFiltersCount = null; + return; + } try { - $provider = $registry->resolve($this->telematic->provider); - $provider->connect($this->telematic); - - do { - $response = $provider->fetchDevices([ - 'limit' => $this->options['limit'] ?? null, - 'cursor' => $cursor, - 'filters' => $this->options['filters'] ?? [], - ]); + Log::info('Device discovery started', [ + 'correlation_id' => $correlationId, + 'telematic_uuid' => $this->telematic->uuid, + 'provider' => $this->telematic->provider, + ]); - $devices = $response['devices'] ?? []; - $pagination = $response['pagination'] ?? []; - $pageCount++; - $totalFetched += count($devices); - - if (isset($pagination['allCount'])) { - $lastProviderAllCount = $pagination['allCount']; - } - - if (isset($pagination['filtersCount'])) { - $lastProviderFiltersCount = $pagination['filtersCount']; - } - - Log::info('Device discovery page fetched', [ - 'correlation_id' => $correlationId, - 'telematic_uuid' => $this->telematic->uuid, - 'provider' => $this->telematic->provider, - 'page' => $pageCount, - 'cursor' => $cursor, - 'limit' => $pagination['limit'] ?? ($this->options['limit'] ?? null), - 'result_count' => count($devices), - 'all_count' => $lastProviderAllCount, - 'filters_count' => $lastProviderFiltersCount, - 'next_cursor' => $response['next_cursor'] ?? null, - 'has_more' => $response['has_more'] ?? false, - ]); + $cursor = null; + $totalFetched = 0; + $totalLinked = 0; + $totalEvents = 0; + $totalSensors = 0; + $totalSkipped = 0; + $pageCount = 0; + $lastProviderAllCount = null; + $lastProviderFiltersCount = null; + + try { + $provider = $registry->resolve($this->telematic->provider); + $provider->connect($this->telematic); + + do { + $response = $provider->fetchDevices([ + 'limit' => $this->options['limit'] ?? null, + 'cursor' => $cursor, + 'filters' => $this->options['filters'] ?? [], + ]); + + $devices = $response['devices'] ?? []; + $pagination = $response['pagination'] ?? []; + $pageCount++; + $totalFetched += count($devices); + + if (isset($pagination['allCount'])) { + $lastProviderAllCount = $pagination['allCount']; + } - foreach ($devices as $devicePayload) { - $normalizedDevice = $provider->normalizeDevice($devicePayload); - try { - $service->linkDevice($this->telematic, $normalizedDevice); - $totalLinked++; - } catch (\Illuminate\Validation\ValidationException $e) { - $totalSkipped++; - Log::warning('Skipping telematics device without provider identity', [ - 'correlation_id' => $correlationId, - 'provider' => $this->telematic->provider, - 'provider_unit_id' => $devicePayload['_id'] ?? $devicePayload['id'] ?? null, - 'device_id' => $normalizedDevice['device_id'] ?? null, - 'name' => $normalizedDevice['name'] ?? $devicePayload['name'] ?? null, - 'imei' => $devicePayload['imei'] ?? null, - ]); + if (isset($pagination['filtersCount'])) { + $lastProviderFiltersCount = $pagination['filtersCount']; } - } - $cursor = $response['next_cursor'] ?? null; + Log::info('Device discovery page fetched', [ + 'correlation_id' => $correlationId, + 'telematic_uuid' => $this->telematic->uuid, + 'provider' => $this->telematic->provider, + 'page' => $pageCount, + 'cursor' => $cursor, + 'limit' => $pagination['limit'] ?? ($this->options['limit'] ?? null), + 'result_count' => count($devices), + 'all_count' => $lastProviderAllCount, + 'filters_count' => $lastProviderFiltersCount, + 'next_cursor' => $response['next_cursor'] ?? null, + 'has_more' => $response['has_more'] ?? false, + ]); + + foreach ($devices as $devicePayload) { + $normalizedDevice = $provider->normalizeDevice($devicePayload); + try { + $result = $service->ingestDeviceSnapshot($this->telematic, $provider, $devicePayload); + $totalLinked++; + if ($result['event']) { + $totalEvents++; + } + $totalSensors += $result['sensors'] ?? 0; + } catch (\Illuminate\Validation\ValidationException $e) { + $totalSkipped++; + Log::warning('Skipping telematics device without provider identity', [ + 'correlation_id' => $correlationId, + 'provider' => $this->telematic->provider, + 'provider_unit_id' => $devicePayload['_id'] ?? $devicePayload['id'] ?? null, + 'device_id' => $normalizedDevice['device_id'] ?? null, + 'name' => $normalizedDevice['name'] ?? $devicePayload['name'] ?? null, + 'imei' => $devicePayload['imei'] ?? null, + ]); + } + } - Log::info('Device discovery progress', [ - 'correlation_id' => $correlationId, - 'fetched' => $totalFetched, - 'linked' => $totalLinked, - 'skipped' => $totalSkipped, - 'has_more' => $response['has_more'] ?? false, + $cursor = $response['next_cursor'] ?? null; + + Log::info('Device discovery progress', [ + 'correlation_id' => $correlationId, + 'fetched' => $totalFetched, + 'linked' => $totalLinked, + 'events' => $totalEvents, + 'sensors' => $totalSensors, + 'skipped' => $totalSkipped, + 'has_more' => $response['has_more'] ?? false, + ]); + + // Broadcast progress (TODO: implement WebSocket broadcasting) + } while (($response['has_more'] ?? false) && $cursor); + + Log::info('Device discovery completed', [ + 'correlation_id' => $correlationId, + 'total_fetched' => $totalFetched, + 'total_linked' => $totalLinked, + 'total_events' => $totalEvents, + 'total_sensors' => $totalSensors, + 'total_skipped' => $totalSkipped, + 'page_count' => $pageCount, + 'provider_all_count' => $lastProviderAllCount, + 'provider_filter_count' => $lastProviderFiltersCount, ]); - // Broadcast progress (TODO: implement WebSocket broadcasting) - } while (($response['has_more'] ?? false) && $cursor); - - Log::info('Device discovery completed', [ - 'correlation_id' => $correlationId, - 'total_fetched' => $totalFetched, - 'total_linked' => $totalLinked, - 'total_skipped' => $totalSkipped, - 'page_count' => $pageCount, - 'provider_all_count' => $lastProviderAllCount, - 'provider_filter_count' => $lastProviderFiltersCount, - ]); - - $this->telematic->status = 'active'; - $this->telematic->meta = array_merge($this->telematic->meta ?? [], [ - 'last_sync_job_id' => $this->jobId, - 'last_sync_completed_at' => now()->toDateTimeString(), - 'last_sync_result' => 'success', - 'last_sync_total' => $totalLinked, - 'last_sync_fetched_total' => $totalFetched, - 'last_sync_linked_total' => $totalLinked, - 'last_sync_skipped_total' => $totalSkipped, - 'last_sync_page_count' => $pageCount, - 'last_sync_provider_total' => $lastProviderFiltersCount ?? $lastProviderAllCount, - 'last_sync_provider_all_count' => $lastProviderAllCount, - 'last_sync_provider_filters_count' => $lastProviderFiltersCount, - 'last_sync_error' => null, - ]); - $this->telematic->save(); - } catch (\Exception $e) { - Log::error('Device discovery failed', [ - 'correlation_id' => $correlationId, - 'error' => $e->getMessage(), - 'exception' => get_class($e), - ]); + $this->telematic->status = 'active'; + $this->telematic->meta = array_merge($this->telematic->meta ?? [], [ + 'last_sync_job_id' => $this->jobId, + 'last_sync_completed_at' => now()->toDateTimeString(), + 'last_sync_result' => 'success', + 'last_sync_total' => $totalLinked, + 'last_sync_fetched_total' => $totalFetched, + 'last_sync_linked_total' => $totalLinked, + 'last_sync_events_total' => $totalEvents, + 'last_sync_sensors_total' => $totalSensors, + 'last_sync_skipped_total' => $totalSkipped, + 'last_sync_page_count' => $pageCount, + 'last_sync_provider_total' => $lastProviderFiltersCount ?? $lastProviderAllCount, + 'last_sync_provider_all_count' => $lastProviderAllCount, + 'last_sync_provider_filters_count' => $lastProviderFiltersCount, + 'last_sync_error' => null, + 'last_sync_error_context' => null, + ]); + $this->telematic->save(); + } catch (\Exception $e) { + $failureContext = method_exists($e, 'context') ? $e->context() : []; + $failureMessage = $this->safeSyncErrorMessage($e); + + Log::error('Device discovery failed', [ + 'correlation_id' => $correlationId, + 'error' => $failureMessage, + 'exception' => get_class($e), + 'provider_context' => $failureContext, + ]); - $this->telematic->status = 'error'; - $this->telematic->meta = array_merge($this->telematic->meta ?? [], [ - 'last_sync_job_id' => $this->jobId, - 'last_sync_result' => 'failed', - 'last_sync_fetched_total' => $totalFetched, - 'last_sync_linked_total' => $totalLinked, - 'last_sync_skipped_total' => $totalSkipped, - 'last_sync_page_count' => $pageCount, - 'last_sync_provider_total' => $lastProviderFiltersCount ?? $lastProviderAllCount, - 'last_sync_provider_all_count' => $lastProviderAllCount, - 'last_sync_provider_filters_count' => $lastProviderFiltersCount, - 'last_sync_error' => 'Device sync failed. Review the provider connection and server logs, then try again.', - 'last_sync_error_type' => class_basename($e), - 'last_sync_failed_at' => now()->toDateTimeString(), - ]); - $this->telematic->save(); + $this->telematic->status = 'error'; + $this->telematic->meta = array_merge($this->telematic->meta ?? [], [ + 'last_sync_job_id' => $this->jobId, + 'last_sync_result' => 'failed', + 'last_sync_fetched_total' => $totalFetched, + 'last_sync_linked_total' => $totalLinked, + 'last_sync_events_total' => $totalEvents, + 'last_sync_sensors_total' => $totalSensors, + 'last_sync_skipped_total' => $totalSkipped, + 'last_sync_page_count' => $pageCount, + 'last_sync_provider_total' => $lastProviderFiltersCount ?? $lastProviderAllCount, + 'last_sync_provider_all_count' => $lastProviderAllCount, + 'last_sync_provider_filters_count' => $lastProviderFiltersCount, + 'last_sync_error' => $failureMessage, + 'last_sync_error_type' => class_basename($e), + 'last_sync_error_context' => $failureContext, + 'last_sync_failed_at' => now()->toDateTimeString(), + ]); + $this->telematic->save(); - throw $e; + throw $e; + } + } finally { + $lock->release(); } } @@ -191,4 +237,15 @@ public function getJobId(): string { return $this->jobId; } + + protected function safeSyncErrorMessage(\Throwable $e): string + { + $message = $e->getMessage(); + + if (!$message || preg_match('/(token=|password|client_secret|authorization|bearer\s+[a-z0-9._-]+)/i', $message)) { + return 'Device sync failed. Review the provider connection and safe sync metadata, then try again.'; + } + + return $message; + } } diff --git a/server/src/Models/Device.php b/server/src/Models/Device.php index a8fec3d96..d0e04f6ec 100644 --- a/server/src/Models/Device.php +++ b/server/src/Models/Device.php @@ -74,7 +74,21 @@ class Device extends Model * * @var array */ - protected $filterParams = ['status', 'attachment_state', 'warranty_uuid', 'attachable_type', 'attachable_uuid', 'telematic', 'telematic_uuid', 'provider']; + protected $filterParams = [ + 'status', + 'attachment_state', + 'vehicle', + 'connection_status', + 'device_id', + 'last_online_at', + 'updated_at', + 'warranty_uuid', + 'attachable_type', + 'attachable_uuid', + 'telematic', + 'telematic_uuid', + 'provider', + ]; /** * The attributes that are mass assignable. diff --git a/server/src/Models/DeviceEvent.php b/server/src/Models/DeviceEvent.php index c3b393046..f62866849 100644 --- a/server/src/Models/DeviceEvent.php +++ b/server/src/Models/DeviceEvent.php @@ -65,7 +65,7 @@ class DeviceEvent extends Model * * @var array */ - protected $filterParams = ['event_type', 'severity', 'device_uuid', 'provider', 'code']; + protected $filterParams = ['event_type', 'severity', 'device_uuid', 'provider', 'code', 'processed', 'occurred_at', 'created_at', 'updated_at', 'telematic']; /** * The attributes that are mass assignable. diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php index 6ae9b334f..d82c045cb 100644 --- a/server/src/Models/Driver.php +++ b/server/src/Models/Driver.php @@ -32,6 +32,7 @@ use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Arr; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -235,6 +236,21 @@ protected static function boot() static::addGlobalScope(new DriverScope()); } + public function setLicenseExpiryAttribute($value): void + { + if (empty($value)) { + if ($this->exists && !empty($this->getOriginal('license_expiry'))) { + return; + } + + $this->attributes['license_expiry'] = null; + + return; + } + + $this->attributes['license_expiry'] = Carbon::parse($value)->toDateString(); + } + /** * @return BelongsTo */ diff --git a/server/src/Models/Sensor.php b/server/src/Models/Sensor.php index fd0d67a76..efa4151d4 100644 --- a/server/src/Models/Sensor.php +++ b/server/src/Models/Sensor.php @@ -66,14 +66,27 @@ class Sensor extends Model * * @var array */ - protected $searchableColumns = ['name', 'type', 'internal_id', 'unit', 'public_id']; + protected $searchableColumns = ['name', 'type', 'internal_id', 'serial_number', 'imei', 'unit', 'public_id']; /** * The attributes that can be used for filtering. * * @var array */ - protected $filterParams = ['type', 'sensor_type', 'status', 'device_uuid', 'warranty_uuid', 'sensorable_type', 'telematic_uuid']; + protected $filterParams = [ + 'type', + 'sensor_type', + 'status', + 'device_uuid', + 'serial_number', + 'imei', + 'warranty_uuid', + 'sensorable_type', + 'telematic_uuid', + 'last_reading_at', + 'created_at', + 'updated_at', + ]; /** * The attributes that are mass assignable. diff --git a/server/src/Models/Telematic.php b/server/src/Models/Telematic.php index 5b0c206ef..4a6715704 100644 --- a/server/src/Models/Telematic.php +++ b/server/src/Models/Telematic.php @@ -63,7 +63,7 @@ class Telematic extends Model * * @var array */ - protected $filterParams = ['provider', 'status', 'warranty_uuid']; + protected $filterParams = ['public_id', 'provider', 'status', 'warranty_uuid']; /** * The attributes that are mass assignable. diff --git a/server/src/Models/Vehicle.php b/server/src/Models/Vehicle.php index 290f17a04..405b6c983 100644 --- a/server/src/Models/Vehicle.php +++ b/server/src/Models/Vehicle.php @@ -72,7 +72,7 @@ class Vehicle extends Model * * @var array */ - protected $filterParams = ['vendor', 'driver', 'driver_uuid', 'vehicle_make', 'vehicle_model']; + protected $filterParams = ['vendor', 'driver', 'driver_uuid', 'vehicle_make', 'vehicle_model', 'telematic_uuid']; /** * Relationships to auto load with driver. @@ -407,6 +407,10 @@ public function getPhotoUrlAttribute() */ public function getDisplayNameAttribute() { + if (isset($this->name) && strlen($this->name)) { + return $this->name; + } + // Initialize an empty array to hold the name segments $nameSegments = []; diff --git a/server/src/Models/WorkOrder.php b/server/src/Models/WorkOrder.php index 0b25151a7..2cfd2cdcf 100644 --- a/server/src/Models/WorkOrder.php +++ b/server/src/Models/WorkOrder.php @@ -59,14 +59,14 @@ class WorkOrder extends Model * * @var array */ - protected $searchableColumns = ['code', 'subject', 'instructions', 'public_id']; + protected $searchableColumns = ['code', 'subject', 'category', 'instructions', 'public_id']; /** * The attributes that can be used for filtering. * * @var array */ - protected $filterParams = ['status', 'priority', 'target_type', 'assignee_type']; + protected $filterParams = ['category', 'status', 'priority', 'target_type', 'assignee_type']; /** * The attributes that are mass assignable. @@ -77,6 +77,7 @@ class WorkOrder extends Model 'company_uuid', 'code', 'subject', + 'category', 'status', 'priority', 'target_type', @@ -575,6 +576,7 @@ public static function createFromImport(array $row, bool $saveInstance = false): $row = array_filter($row); $subject = Utils::or($row, ['subject', 'title', 'name']); + $category = Utils::or($row, ['category', 'work_order_category', 'work_type', 'type']); $status = Utils::or($row, ['status'], 'open'); $priority = Utils::or($row, ['priority'], 'normal'); $instructions = Utils::or($row, ['instructions', 'description', 'notes']); @@ -592,6 +594,7 @@ public static function createFromImport(array $row, bool $saveInstance = false): $workOrder = new static([ 'company_uuid' => session('company'), 'subject' => $subject, + 'category' => $category, 'status' => $status, 'priority' => $priority, 'instructions' => $instructions, diff --git a/server/src/Observers/DriverObserver.php b/server/src/Observers/DriverObserver.php index b6cfd8cdc..84d17bb24 100644 --- a/server/src/Observers/DriverObserver.php +++ b/server/src/Observers/DriverObserver.php @@ -30,7 +30,7 @@ public function creating(Driver $driver) */ public function created(Driver $driver) { - LiveCacheService::invalidate('drivers'); + LiveCacheService::invalidateMultiple(['drivers', 'operations-monitor']); } /** @@ -40,7 +40,7 @@ public function created(Driver $driver) */ public function updated(Driver $driver) { - LiveCacheService::invalidate('drivers'); + LiveCacheService::invalidateMultiple(['drivers', 'operations-monitor']); } /** @@ -70,6 +70,6 @@ public function deleted(Driver $driver) $user->delete(); } - LiveCacheService::invalidate('drivers'); + LiveCacheService::invalidateMultiple(['drivers', 'operations-monitor']); } } diff --git a/server/src/Observers/FleetObserver.php b/server/src/Observers/FleetObserver.php index 107993034..4a5fc9b55 100644 --- a/server/src/Observers/FleetObserver.php +++ b/server/src/Observers/FleetObserver.php @@ -3,9 +3,30 @@ namespace Fleetbase\FleetOps\Observers; use Fleetbase\FleetOps\Models\Fleet; +use Fleetbase\FleetOps\Support\LiveCacheService; class FleetObserver { + /** + * Handle the Fleet "created" event. + * + * @return void + */ + public function created(Fleet $fleet) + { + LiveCacheService::invalidate('operations-monitor'); + } + + /** + * Handle the Fleet "updated" event. + * + * @return void + */ + public function updated(Fleet $fleet) + { + LiveCacheService::invalidate('operations-monitor'); + } + /** * Handle the Driver "deleted" event. * @@ -15,5 +36,7 @@ public function deleted(Fleet $fleet) { // If the fleet being deleted is set as parent fleet, remove it as the parent fleet $subFleets = Fleet::where(['parent_fleet_uuid' => $fleet->uuid])->update(['parent_fleet_uuid' => null]); + + LiveCacheService::invalidate('operations-monitor'); } } diff --git a/server/src/Observers/VehicleObserver.php b/server/src/Observers/VehicleObserver.php index 8eedc1524..a0e2b6b5e 100644 --- a/server/src/Observers/VehicleObserver.php +++ b/server/src/Observers/VehicleObserver.php @@ -30,7 +30,7 @@ public function created(Vehicle $vehicle) } } - LiveCacheService::invalidate('vehicles'); + LiveCacheService::invalidateMultiple(['vehicles', 'operations-monitor']); } /** @@ -55,7 +55,7 @@ public function updating(Vehicle $vehicle) } } - LiveCacheService::invalidate('vehicles'); + LiveCacheService::invalidateMultiple(['vehicles', 'operations-monitor']); } /** @@ -68,6 +68,6 @@ public function deleted(Vehicle $vehicle) // Unassign the deleted vehicle from matching driver/(s) Driver::where(['vehicle_uuid' => $vehicle->uuid])->delete(); - LiveCacheService::invalidate('vehicles'); + LiveCacheService::invalidateMultiple(['vehicles', 'operations-monitor']); } } diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index 26d39f6d2..653952fa7 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -69,6 +69,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider \Fleetbase\FleetOps\Console\Commands\ProcessMaintenanceTriggers::class, \Fleetbase\FleetOps\Console\Commands\SendMaintenanceReminders::class, \Fleetbase\FleetOps\Console\Commands\ProcessOperationalAlerts::class, + \Fleetbase\FleetOps\Console\Commands\SyncTelematics::class, ]; /** @@ -141,6 +142,7 @@ public function boot() $schedule->command('fleetops:process-maintenance-triggers')->daily()->withoutOverlapping()->storeOutputInDb(); $schedule->command('fleetops:send-maintenance-reminders')->daily()->withoutOverlapping()->storeOutputInDb(); $schedule->command('fleetops:process-operational-alerts')->everyMinute()->withoutOverlapping()->storeOutputInDb(); + $schedule->command('fleetops:sync-telematics')->everyMinute()->withoutOverlapping()->storeOutputInDb(); }); $this->registerNotifications(); $this->registerExpansionsFrom(__DIR__ . '/../Expansions'); diff --git a/server/src/Support/LiveCacheService.php b/server/src/Support/LiveCacheService.php index be93fa7a1..39e2fdf0c 100644 --- a/server/src/Support/LiveCacheService.php +++ b/server/src/Support/LiveCacheService.php @@ -108,7 +108,7 @@ public static function invalidate(?string $endpoint = null): void } } else { // Invalidate all endpoints - $endpoints = ['orders', 'routes', 'coordinates', 'drivers', 'vehicles', 'places']; + $endpoints = ['orders', 'routes', 'coordinates', 'drivers', 'vehicles', 'places', 'operations-monitor']; foreach ($endpoints as $ep) { static::incrementVersion($ep); } diff --git a/server/src/Support/Telematics/Providers/AfaqyProvider.php b/server/src/Support/Telematics/Providers/AfaqyProvider.php index 0930cb0ab..dda82152b 100644 --- a/server/src/Support/Telematics/Providers/AfaqyProvider.php +++ b/server/src/Support/Telematics/Providers/AfaqyProvider.php @@ -2,6 +2,9 @@ namespace Fleetbase\FleetOps\Support\Telematics\Providers; +use Fleetbase\FleetOps\Exceptions\TelematicProviderException; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\Response; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -14,20 +17,23 @@ */ class AfaqyProvider extends AbstractProvider { - protected string $baseUrl = 'https://api.afaqy.sa'; - protected int $requestsPerMinute = 60; + protected string $baseUrl = 'https://api.afaqy.sa'; + protected int $requestsPerMinute = 60; + protected int $dataTimeout = 120; + protected int $connectTimeout = 15; + protected int $connectionTestTimeout = 30; + protected int $connectionTestConnectTimeout = 10; protected function prepareAuthentication(): void { $this->baseUrl = rtrim($this->credentials['base_url'] ?? $this->baseUrl, '/'); - $token = $this->credentials['token'] ?? $this->authenticate(); + $token = $this->canRefreshToken() ? $this->authenticate() : ($this->credentials['token'] ?? null); - $this->credentials['token'] = $token; - $this->headers = [ - 'Accept' => 'application/json', - 'Content-Type' => 'application/json', - 'Authorization' => 'Bearer ' . $token, - ]; + if (!$token) { + throw new \InvalidArgumentException('AFAQY username/password or token is required.'); + } + + $this->setToken($token); } public function testConnection(array $credentials): array @@ -41,7 +47,7 @@ public function testConnection(array $credentials): array 'projection' => ['_id', 'name', 'imei', 'last_update'], 'filters' => new \stdClass(), ], - ], true); + ], true, $this->connectionTestTimeout, $this->connectionTestConnectTimeout); return [ 'success' => true, @@ -55,7 +61,7 @@ public function testConnection(array $credentials): array return [ 'success' => false, 'message' => $e->getMessage(), - 'metadata' => [], + 'metadata' => method_exists($e, 'context') ? $e->context() : [], ]; } } @@ -187,12 +193,14 @@ public function normalizeEvent(array $payload): array 'event_type' => $payload['event'] ?? $payload['event_type'] ?? 'telemetry_update', 'message' => $payload['message'] ?? $payload['event'] ?? null, 'occurred_at' => $this->parseTimestamp($lastUpdate['dtt'] ?? $lastUpdate['dts'] ?? null), + 'online' => $payload['active'] ?? null, 'location' => [ 'lat' => $lastUpdate['lat'] ?? data_get($lastUpdate, 'loc.coordinates.1'), 'lng' => $lastUpdate['lng'] ?? data_get($lastUpdate, 'loc.coordinates.0'), ], 'speed' => $lastUpdate['speed'] ?? $lastUpdate['spd'] ?? null, 'heading' => $lastUpdate['angle'] ?? $lastUpdate['ang'] ?? null, + 'altitude' => $lastUpdate['alt'] ?? null, 'odometer' => data_get($payload, 'counters.odometer'), 'ignition' => $this->extractIgnition($payload), 'fuel_level' => $this->extractFuelLevel($payload), @@ -271,19 +279,64 @@ protected function authenticate(): string ]); if ($response->failed()) { - throw new \RuntimeException('AFAQY authentication failed with status ' . $response->status()); + throw new TelematicProviderException('AFAQY authentication failed with status ' . $response->status(), $this->providerErrorContext('/auth/login', $response, false)); } $token = data_get($response->json(), 'data.token'); if (!$token) { - throw new \RuntimeException('AFAQY authentication did not return a token.'); + throw new TelematicProviderException('AFAQY authentication did not return a token.', $this->providerErrorContext('/auth/login', $response, false)); } return $token; } - protected function afaqyPost(string $endpoint, array $payload = [], bool $tokenInQuery = false): array + protected function afaqyPost(string $endpoint, array $payload = [], bool $tokenInQuery = false, ?int $timeout = null, ?int $connectTimeout = null): array + { + return $this->authenticatedPost($endpoint, $payload, $tokenInQuery, true, $timeout, $connectTimeout); + } + + protected function authenticatedPost(string $endpoint, array $payload = [], bool $tokenInQuery = false, bool $allowRetry = true, ?int $timeout = null, ?int $connectTimeout = null): array + { + [$url, $body] = $this->buildAuthenticatedRequest($endpoint, $payload, $tokenInQuery); + + $startedAt = microtime(true); + $timeout ??= $this->dataTimeout; + $connectTimeout ??= $this->connectTimeout; + + try { + $response = Http::withHeaders($this->headers) + ->timeout($timeout) + ->connectTimeout($connectTimeout) + ->post($url, $body); + } catch (ConnectionException $e) { + throw new TelematicProviderException('AFAQY API request timed out while waiting for provider response.', $this->transportErrorContext($endpoint, $payload, $e, !$allowRetry, $startedAt, $timeout, $connectTimeout), previous: $e); + } + + if ($this->isTokenRejected($response)) { + if (!$allowRetry || !$this->canRefreshToken()) { + $message = $this->canRefreshToken() + ? 'AFAQY token rejected after refresh with status ' . $response->status() + : 'AFAQY token rejected and username/password credentials are required to refresh it.'; + + throw new TelematicProviderException($message, $this->providerErrorContext($endpoint, $response, !$allowRetry)); + } + + Log::warning('AFAQY token rejected; refreshing token and retrying request', $this->providerErrorContext($endpoint, $response, true)); + + $this->refreshToken(); + + return $this->authenticatedPost($endpoint, $payload, $tokenInQuery, false, $timeout, $connectTimeout); + } + + if ($response->failed()) { + throw new TelematicProviderException('AFAQY API request failed with status ' . $response->status(), $this->providerErrorContext($endpoint, $response, !$allowRetry)); + } + + return $response->json() ?? []; + } + + protected function buildAuthenticatedRequest(string $endpoint, array $payload = [], bool $tokenInQuery = false): array { $url = $this->baseUrl . $endpoint; $body = $tokenInQuery ? $payload : array_merge(['token' => $this->credentials['token']], $payload); @@ -292,15 +345,84 @@ protected function afaqyPost(string $endpoint, array $payload = [], bool $tokenI $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query(['token' => $this->credentials['token']]); } - $response = Http::withHeaders($this->headers) - ->timeout(30) - ->post($url, $body); + return [$url, $body]; + } - if ($response->failed()) { - throw new \RuntimeException('AFAQY API request failed with status ' . $response->status()); + protected function setToken(string $token): void + { + $this->credentials['token'] = $token; + $this->headers = [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $token, + ]; + } + + protected function refreshToken(): void + { + unset($this->credentials['token']); + + $this->setToken($this->authenticate()); + } + + protected function canRefreshToken(): bool + { + return !empty($this->credentials['username']) && !empty($this->credentials['password']); + } + + protected function isTokenRejected(Response $response): bool + { + return in_array($response->status(), [401, 403], true); + } + + protected function providerErrorContext(string $endpoint, Response $response, bool $retryAttempted): array + { + $json = $response->json() ?? []; + $json = is_array($json) ? $json : []; + + return array_filter([ + 'provider' => 'afaqy', + 'endpoint' => $endpoint, + 'status_code' => $response->status(), + 'provider_code' => data_get($json, 'code') ?? data_get($json, 'error.code') ?? data_get($json, 'error_code'), + 'provider_message' => $this->providerErrorMessage($json), + 'retry_attempted' => $retryAttempted, + ], fn ($value) => $value !== null); + } + + protected function providerErrorMessage(array $json): ?string + { + $message = data_get($json, 'message') + ?? data_get($json, 'error.message') + ?? data_get($json, 'error_description') + ?? data_get($json, 'error'); + + return is_scalar($message) ? (string) $message : null; + } + + protected function transportErrorContext(string $endpoint, array $payload, ConnectionException $e, bool $retryAttempted, float $startedAt, int $timeout, int $connectTimeout): array + { + return array_filter([ + 'provider' => 'afaqy', + 'endpoint' => $endpoint, + 'requested_limit' => data_get($payload, 'data.limit'), + 'requested_offset' => data_get($payload, 'data.offset'), + 'timeout' => $timeout, + 'connect_timeout' => $connectTimeout, + 'elapsed_ms' => (int) ((microtime(true) - $startedAt) * 1000), + 'bytes_received' => $this->extractBytesReceived($e->getMessage()), + 'retry_attempted' => $retryAttempted, + 'transport_error' => 'connection_exception', + ], fn ($value) => $value !== null); + } + + protected function extractBytesReceived(string $message): ?int + { + if (preg_match('/with\s+(\d+)\s+bytes\s+received/i', $message, $matches)) { + return (int) $matches[1]; } - return $response->json() ?? []; + return null; } protected function compactLastUpdate(array $lastUpdate): array diff --git a/server/src/Support/Telematics/Providers/SafeeProvider.php b/server/src/Support/Telematics/Providers/SafeeProvider.php index 658c13dd2..7f95e3e44 100644 --- a/server/src/Support/Telematics/Providers/SafeeProvider.php +++ b/server/src/Support/Telematics/Providers/SafeeProvider.php @@ -16,10 +16,11 @@ class SafeeProvider extends AbstractProvider protected string $baseUrl = 'https://api.safee.com'; protected int $requestsPerMinute = 3000; protected ?string $accessToken = null; + protected array $authContext = []; protected function prepareAuthentication(): void { - $this->baseUrl = rtrim($this->credentials['api_base_url'] ?? $this->credentials['server_uri'] ?? $this->baseUrl, '/'); + $this->baseUrl = $this->resolveBaseUrl(); $this->accessToken = $this->credentials['access_token'] ?? $this->authenticate(); $scheme = $this->credentials['authorization_scheme'] ?? 'Bearer'; @@ -45,13 +46,14 @@ public function testConnection(array $credentials): array 'metadata' => [ 'status' => $response['status'] ?? null, 'time' => $response['time'] ?? null, + ...$this->safeDiagnosticMetadata(), ], ]; } catch (\Throwable $e) { return [ 'success' => false, 'message' => $e->getMessage(), - 'metadata' => [], + 'metadata' => $this->safeDiagnosticMetadata(), ]; } } @@ -219,7 +221,8 @@ protected function authenticate(): string } } - $tokenUrl = $this->baseUrl . '/auth/realms/' . $this->credentials['realm_id'] . '/protocol/openid-connect/token'; + $tokenUrl = $this->baseUrl . '/auth/realms/' . $this->credentials['realm_id'] . '/protocol/openid-connect/token'; + $this->authContext = $this->buildAuthContext($tokenUrl); $response = Http::asForm() ->acceptJson() @@ -245,6 +248,44 @@ protected function authenticate(): string return $token; } + protected function resolveBaseUrl(): string + { + $baseUrl = $this->filledCredential('api_base_url') ?? $this->filledCredential('server_uri') ?? $this->baseUrl; + + return rtrim($baseUrl, '/'); + } + + protected function filledCredential(string $key): ?string + { + $value = $this->credentials[$key] ?? null; + + if (!is_string($value)) { + return null; + } + + $value = trim($value); + + return $value === '' ? null : $value; + } + + protected function buildAuthContext(string $tokenUrl): array + { + $parts = parse_url($tokenUrl) ?: []; + $scheme = $parts['scheme'] ?? null; + $host = $parts['host'] ?? null; + + return [ + 'auth_host' => $host ? trim(($scheme ? $scheme . '://' : '') . $host, '/') : null, + 'auth_path' => $parts['path'] ?? null, + 'realm_id' => $this->credentials['realm_id'] ?? null, + ]; + } + + protected function safeDiagnosticMetadata(): array + { + return array_filter($this->authContext, fn ($value) => $value !== null && $value !== ''); + } + protected function safeeGet(string $endpoint): array { $response = Http::withHeaders($this->headers) diff --git a/server/src/Support/Telematics/TelematicService.php b/server/src/Support/Telematics/TelematicService.php index 3fa22ff6d..141cf7c67 100644 --- a/server/src/Support/Telematics/TelematicService.php +++ b/server/src/Support/Telematics/TelematicService.php @@ -2,12 +2,17 @@ namespace Fleetbase\FleetOps\Support\Telematics; +use Fleetbase\FleetOps\Contracts\TelematicProviderInterface; +use Fleetbase\FleetOps\Events\VehicleLocationChanged; use Fleetbase\FleetOps\Jobs\SyncTelematicDevicesJob; use Fleetbase\FleetOps\Jobs\TestTelematicConnectionJob; use Fleetbase\FleetOps\Models\Device; use Fleetbase\FleetOps\Models\DeviceEvent; use Fleetbase\FleetOps\Models\Sensor; use Fleetbase\FleetOps\Models\Telematic; +use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; @@ -21,6 +26,14 @@ */ class TelematicService { + protected const PROTECTED_DEVICE_STATUSES = [ + 'disabled', + 'decommissioned', + 'maintenance', + 'provisioning', + 'pending_activation', + ]; + protected TelematicProviderRegistry $registry; public function __construct(TelematicProviderRegistry $registry) @@ -168,42 +181,34 @@ public function linkDevice(Telematic $telematic, array $deviceData): Device 'device_id' => $externalId, ]); - $device->company_uuid = $telematic->company_uuid; - $device->name = $deviceData['name'] ?? $deviceData['device_name'] ?? $device->name ?? 'Unknown Device'; - $device->model = $deviceData['model'] ?? $deviceData['device_model'] ?? $device->model; - $device->provider = $deviceData['provider'] ?? $deviceData['device_provider'] ?? $telematic->provider; - $device->type = $deviceData['type'] ?? null; - $device->internal_id = $deviceData['internal_id'] ?? $externalId; - $device->imei = $deviceData['imei'] ?? null; - $device->imsi = $deviceData['imsi'] ?? null; - $device->serial_number = $deviceData['serial_number'] ?? null; - $device->firmware_version = $deviceData['firmware_version'] ?? null; - $device->status = $deviceData['status'] ?? $device->status ?? 'active'; + $this->reconcileDeviceTelemetry($device, $telematic, array_merge($deviceData, [ + 'external_id' => $externalId, + ])); - if (array_key_exists('online', $deviceData) && $deviceData['online'] !== null) { - $device->online = (bool) $deviceData['online']; - } elseif (!$device->exists) { - $device->online = $device->status === 'active'; - } + $device->save(); - if (!empty($deviceData['last_online_at']) || !empty($deviceData['last_seen_at'])) { - $device->last_online_at = $deviceData['last_online_at'] ?? $deviceData['last_seen_at']; - } + return $device; + } - $device->meta = array_merge($device->meta ?? [], [ - 'external_id' => $externalId, - ], $deviceData['meta'] ?? []); + public function ingestDeviceSnapshot(Telematic $telematic, TelematicProviderInterface $provider, array $payload): array + { + $device = $this->linkDevice($telematic, $provider->normalizeDevice($payload)); - $location = $this->normalizeLocation($deviceData['location'] ?? null); - if ($location) { - $device->last_position = $location; - } elseif (!$device->exists || !$device->last_position) { - $device->last_position = $this->defaultLocation(); + $event = null; + try { + $eventData = $provider->normalizeEvent($payload); + if ($this->hasEventSignal($eventData)) { + $event = $this->storeDeviceEvent($telematic, $eventData, $device); + } + } catch (\Throwable) { + $event = null; } - $device->save(); - - return $device; + return [ + 'device' => $device, + 'event' => $event, + 'sensors' => $this->storeSnapshotSensors($telematic, $provider, $payload, $device), + ]; } public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device $device = null): DeviceEvent @@ -214,6 +219,7 @@ public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device $eventKey = $this->makeEventKey($telematic, $eventData, $device); $event = $eventKey ? DeviceEvent::firstOrNew(['_key' => $eventKey]) : new DeviceEvent(); + $wasRecentlyCreated = !$event->exists; $event->company_uuid = $telematic->company_uuid; $event->device_uuid = $device?->uuid; $event->event_type = $eventData['event_type'] ?? $eventData['type'] ?? 'telemetry_update'; @@ -228,6 +234,7 @@ public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device $event->data = $eventData['data'] ?? array_filter([ 'speed' => $eventData['speed'] ?? null, 'heading' => $eventData['heading'] ?? null, + 'altitude' => $eventData['altitude'] ?? null, 'odometer' => $eventData['odometer'] ?? null, 'ignition' => $eventData['ignition'] ?? null, 'fuel_level' => $eventData['fuel_level'] ?? null, @@ -241,6 +248,7 @@ public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device 'occurred_at' => $eventData['occurred_at'] ?? null, 'speed' => $eventData['speed'] ?? null, 'heading' => $eventData['heading'] ?? null, + 'altitude' => $eventData['altitude'] ?? null, 'odometer' => $eventData['odometer'] ?? null, 'ignition' => $eventData['ignition'] ?? null, 'fuel_level' => $eventData['fuel_level'] ?? null, @@ -252,6 +260,7 @@ public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device } $event->save(); + $this->applyDeviceEventTelemetry($event, $eventData, $device, $wasRecentlyCreated, $telematic); return $event; } @@ -470,6 +479,297 @@ protected function normalizeLocation(?array $location): mixed return ['latitude' => (float) $lat, 'longitude' => (float) $lng]; } + protected function reconcileDeviceTelemetry(Device $device, ?Telematic $telematic, array $payload): void + { + $externalId = $this->resolveExternalId($payload); + + if ($telematic) { + $device->company_uuid = $telematic->company_uuid; + } + + $this->setDeviceAttributeIfPresent($device, 'name', $payload['name'] ?? $payload['device_name'] ?? (!$device->exists ? 'Unknown Device' : null)); + $this->setDeviceAttributeIfPresent($device, 'model', $payload['model'] ?? $payload['device_model'] ?? null); + $this->setDeviceAttributeIfPresent($device, 'provider', $payload['provider'] ?? $payload['device_provider'] ?? $telematic?->provider); + $this->setDeviceAttributeIfPresent($device, 'type', $payload['type'] ?? null); + $this->setDeviceAttributeIfPresent($device, 'internal_id', $payload['internal_id'] ?? $externalId); + $this->setDeviceAttributeIfPresent($device, 'imei', $payload['imei'] ?? null); + $this->setDeviceAttributeIfPresent($device, 'imsi', $payload['imsi'] ?? null); + $this->setDeviceAttributeIfPresent($device, 'serial_number', $payload['serial_number'] ?? null); + $this->setDeviceAttributeIfPresent($device, 'firmware_version', $payload['firmware_version'] ?? null); + + $location = $this->normalizeLocation($payload['location'] ?? null); + if ($location) { + $device->last_position = $location; + } elseif (!$device->exists || !$device->last_position) { + $device->last_position = $this->defaultLocation(); + } + + $lastSeen = $this->resolveTelemetryTimestamp($payload); + $reportedOnline = $this->resolveReportedOnline($payload); + + if (!$lastSeen && $reportedOnline === true) { + $lastSeen = now(); + } + + if ($lastSeen) { + $device->last_online_at = $lastSeen; + } + + $connectionStatus = $this->connectionStatusForDevice($device, $lastSeen, $reportedOnline); + $device->online = $connectionStatus === 'online'; + + if (!$this->isProtectedDeviceStatus($device->status)) { + $device->status = $connectionStatus; + } + + $device->meta = array_merge($device->meta ?? [], [ + 'external_id' => $externalId, + 'provider_status' => array_filter([ + 'status' => $payload['status'] ?? null, + 'state' => $payload['state'] ?? null, + 'active' => $payload['active'] ?? data_get($payload, 'meta.active'), + 'online' => $payload['online'] ?? null, + 'is_online' => $payload['is_online'] ?? null, + ], fn ($value) => $value !== null), + 'telemetry_summary' => array_filter([ + 'last_seen_at' => $lastSeen?->toDateTimeString(), + 'status' => $connectionStatus, + 'speed' => $payload['speed'] ?? null, + 'heading' => $payload['heading'] ?? null, + 'altitude' => $payload['altitude'] ?? null, + 'odometer' => $payload['odometer'] ?? null, + 'ignition' => $payload['ignition'] ?? null, + 'fuel_level' => $payload['fuel_level'] ?? null, + ], fn ($value) => $value !== null), + ], $payload['meta'] ?? []); + } + + protected function setDeviceAttributeIfPresent(Device $device, string $key, mixed $value): void + { + if ($value === null || $value === '') { + return; + } + + $device->{$key} = $value; + } + + protected function resolveTelemetryTimestamp(array $payload): ?Carbon + { + $value = $payload['last_online_at'] + ?? $payload['last_seen_at'] + ?? $payload['occurred_at'] + ?? $payload['recorded_at'] + ?? $payload['timestamp'] + ?? data_get($payload, 'meta.last_update.occurred_at') + ?? data_get($payload, 'meta.occurred_at') + ?? null; + + if (!$value) { + return null; + } + + try { + return $value instanceof Carbon ? $value : Carbon::parse($value); + } catch (\Throwable) { + return null; + } + } + + protected function resolveReportedOnline(array $payload): ?bool + { + $value = $payload['online'] ?? $payload['is_online'] ?? null; + + if ($value === null) { + return null; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value; + } + + protected function connectionStatusForDevice(Device $device, ?Carbon $lastSeen = null, ?bool $reportedOnline = null): string + { + if ($reportedOnline === true) { + return 'online'; + } + + $lastSeen ??= $device->last_online_at; + + if (!$lastSeen) { + return 'never_connected'; + } + + $minutesOffline = Carbon::parse($lastSeen)->diffInMinutes(now()); + + if ($minutesOffline <= 10) { + return 'online'; + } + + if ($minutesOffline <= 60) { + return 'recently_offline'; + } + + if ($minutesOffline <= 1440) { + return 'offline'; + } + + return 'long_offline'; + } + + protected function isProtectedDeviceStatus(?string $status): bool + { + return in_array($status, self::PROTECTED_DEVICE_STATUSES, true); + } + + protected function applyDeviceEventTelemetry(DeviceEvent $event, array $eventData, ?Device $device = null, bool $wasRecentlyCreated = true, ?Telematic $telematic = null): void + { + $location = $this->normalizeLocation($eventData['location'] ?? null); + + $device ??= $event->device; + if ($device) { + $this->reconcileDeviceTelemetry($device, $telematic, $eventData); + $device->save(); + $device->loadMissing('attachable'); + } + + if (!$location) { + return; + } + + $positionData = $this->makePositionData($location, $eventData); + if ($wasRecentlyCreated && $positionData) { + $event->createPosition($positionData); + } + + $attachable = $device?->attachable; + if ($attachable instanceof Vehicle) { + $this->updateVehicleTelemetry($attachable, $location, $eventData, $event); + } + } + + protected function updateVehicleTelemetry(Vehicle $vehicle, array $location, array $eventData, DeviceEvent $event): void + { + $vehicle->location = new SpatialPoint($location['latitude'], $location['longitude']); + $vehicle->online = array_key_exists('online', $eventData) && $eventData['online'] !== null ? (bool) $eventData['online'] : true; + + if (array_key_exists('speed', $eventData) && $eventData['speed'] !== null) { + $vehicle->speed = $eventData['speed']; + } + + if (array_key_exists('heading', $eventData) && $eventData['heading'] !== null) { + $vehicle->heading = $eventData['heading']; + } + + if (array_key_exists('altitude', $eventData) && $eventData['altitude'] !== null) { + $vehicle->altitude = $eventData['altitude']; + } + + $vehicle->telematics = array_merge($vehicle->telematics ?? [], [ + 'last_event_uuid' => $event->uuid, + 'last_event_id' => $event->public_id, + 'last_event_type' => $event->event_type, + 'last_event_at' => optional($event->occurred_at)->toDateTimeString() ?? now()->toDateTimeString(), + 'last_device_uuid' => $event->device_uuid, + 'last_provider' => $event->provider, + 'last_telemetry_data' => array_filter([ + 'speed' => $eventData['speed'] ?? null, + 'heading' => $eventData['heading'] ?? null, + 'altitude' => $eventData['altitude'] ?? null, + 'odometer' => $eventData['odometer'] ?? null, + 'ignition' => $eventData['ignition'] ?? null, + 'fuel_level' => $eventData['fuel_level'] ?? null, + ], fn ($value) => $value !== null), + ]); + $vehicle->save(); + + broadcast(new VehicleLocationChanged($vehicle, [ + 'source' => 'telematics', + 'device_event_uuid' => $event->uuid, + 'provider' => $event->provider, + ])); + } + + protected function makePositionData(array $location, array $eventData): array + { + return array_filter([ + 'latitude' => $location['latitude'], + 'longitude' => $location['longitude'], + 'heading' => $eventData['heading'] ?? null, + 'bearing' => $eventData['heading'] ?? null, + 'speed' => $eventData['speed'] ?? null, + 'altitude' => $eventData['altitude'] ?? null, + ], fn ($value) => $value !== null); + } + + protected function hasEventSignal(array $eventData): bool + { + return (bool) array_filter([ + $eventData['event_id'] ?? null, + $eventData['external_event_id'] ?? null, + $eventData['ident'] ?? null, + $eventData['event_type'] ?? null, + $eventData['occurred_at'] ?? null, + $eventData['recorded_at'] ?? null, + $eventData['timestamp'] ?? null, + $eventData['location']['lat'] ?? null, + $eventData['location']['latitude'] ?? null, + $eventData['speed'] ?? null, + $eventData['heading'] ?? null, + $eventData['altitude'] ?? null, + $eventData['odometer'] ?? null, + $eventData['ignition'] ?? null, + $eventData['fuel_level'] ?? null, + ], fn ($value) => $value !== null); + } + + protected function storeSnapshotSensors(Telematic $telematic, TelematicProviderInterface $provider, array $payload, Device $device): int + { + $rawSensors = $payload['sensors'] ?? $payload['sensors_last_val'] ?? []; + if (!is_array($rawSensors) || empty($rawSensors)) { + return 0; + } + + $stored = 0; + $externalId = $this->resolveExternalId($payload); + + foreach ($this->normalizeRawSensorList($rawSensors) as $rawSensor) { + try { + $sensorData = $provider->normalizeSensor(array_merge([ + 'device_id' => $externalId, + ], $rawSensor)); + $sensorData['device_id'] ??= $externalId; + + $this->storeSensor($telematic, $sensorData, $device); + $stored++; + } catch (\Throwable) { + continue; + } + } + + return $stored; + } + + protected function normalizeRawSensorList(array $rawSensors): array + { + if (array_is_list($rawSensors)) { + return array_filter($rawSensors, fn ($sensor) => is_array($sensor)); + } + + return collect($rawSensors) + ->map(function ($sensor, $name) { + if (is_array($sensor)) { + return array_merge(['name' => $name], $sensor); + } + + return [ + 'name' => $name, + 'type' => $name, + 'value' => $sensor, + ]; + }) + ->values() + ->all(); + } + protected function defaultLocation(): array { return ['latitude' => 0, 'longitude' => 0]; diff --git a/server/src/routes.php b/server/src/routes.php index 5724c924a..275bd8660 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -612,6 +612,7 @@ function ($router) { $router->get('coordinates', 'LiveController@coordinates'); $router->get('routes', 'LiveController@routes'); $router->get('orders', 'LiveController@orders'); + $router->get('operations-monitor', 'LiveController@operationsMonitor'); $router->get('drivers', 'LiveController@drivers'); $router->get('vehicles', 'LiveController@vehicles'); $router->get('places', 'LiveController@places'); diff --git a/server/tests/DeviceFilterTest.php b/server/tests/DeviceFilterTest.php new file mode 100644 index 000000000..190c8e734 --- /dev/null +++ b/server/tests/DeviceFilterTest.php @@ -0,0 +1,36 @@ +toContain("'vehicle'") + ->toContain("'connection_status'") + ->toContain("'device_id'") + ->toContain("'last_online_at'") + ->toContain("'updated_at'"); + + expect($filter) + ->toContain('public function query(?string $searchQuery)') + ->toContain('public function deviceId(?string $deviceId)') + ->toContain("where('device_id', 'like'") + ->toContain('public function vehicle(?string $vehicle)') + ->toContain("where('attachable_uuid', \$vehicle)") + ->toContain('public function connectionStatus') + ->toContain("'online'") + ->toContain("'recently_offline'") + ->toContain("'offline'") + ->toContain("'long_offline'") + ->toContain("'never_connected'") + ->toContain('public function lastOnlineAt') + ->toContain('public function updatedAt') + ->toContain('Utils::dateRange') + ->toContain('protected function filterDate'); + + expect($controller) + ->toContain("filled('connection_status')") + ->toContain("filled('last_online_at')") + ->toContain("filled('updated_at')"); +}); diff --git a/server/tests/OperationsMonitorEndpointTest.php b/server/tests/OperationsMonitorEndpointTest.php new file mode 100644 index 000000000..19ae7a321 --- /dev/null +++ b/server/tests/OperationsMonitorEndpointTest.php @@ -0,0 +1,38 @@ +toContain("\$router->get('operations-monitor', 'LiveController@operationsMonitor');") + ->toContain("['prefix' => 'live']"); +}); + +test('operations monitor endpoint returns a cached flat resource snapshot with id linked fleets', function () { + $controller = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/LiveController.php'); + + expect($controller) + ->toContain("LiveCacheService::remember('operations-monitor'") + ->toContain("'drivers' => \$drivers->map") + ->toContain("'vehicles' => \$vehicles->map") + ->toContain("'fleets' => \$this->buildOperationsMonitorFleetTree") + ->toContain("'driver_ids' => \$driverIds->all()") + ->toContain("'vehicle_ids' => \$vehicleIds->all()") + ->toContain("'meta' => [") + ->not->toContain("'drivers' => \$this->whenLoaded('drivers'") + ->not->toContain("'vehicles' => \$this->whenLoaded('vehicles'"); +}); + +test('operations monitor cache is invalidated by live cache and resource mutations', function () { + $cache = file_get_contents(__DIR__ . '/../src/Support/LiveCacheService.php'); + $driverObserver = file_get_contents(__DIR__ . '/../src/Observers/DriverObserver.php'); + $vehicleObserver = file_get_contents(__DIR__ . '/../src/Observers/VehicleObserver.php'); + $fleetObserver = file_get_contents(__DIR__ . '/../src/Observers/FleetObserver.php'); + $fleetController = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/FleetController.php'); + + expect($cache)->toContain("'operations-monitor'"); + expect($driverObserver)->toContain("LiveCacheService::invalidateMultiple(['drivers', 'operations-monitor'])"); + expect($vehicleObserver)->toContain("LiveCacheService::invalidateMultiple(['vehicles', 'operations-monitor'])"); + expect($fleetObserver)->toContain("LiveCacheService::invalidate('operations-monitor')"); + expect(substr_count($fleetController, "LiveCacheService::invalidate('operations-monitor')"))->toBe(4); +}); diff --git a/server/tests/OrderQueuedClosureRegressionTest.php b/server/tests/OrderQueuedClosureRegressionTest.php new file mode 100644 index 000000000..bc6031e3e --- /dev/null +++ b/server/tests/OrderQueuedClosureRegressionTest.php @@ -0,0 +1,36 @@ +not->toContain('dispatch(function') + ->toContain('FinalizeApiOrderCreation::dispatch(') + ->toContain(')->afterCommit()') + ->and($internalController) + ->not->toContain('dispatch(function') + ->toContain('FinalizeInternalOrderCreation::dispatch($order->uuid)->afterCommit()') + ->toContain('NotifyBulkAssignedDriver::dispatch($orderUuids->all(), $driver->uuid)->afterCommit()'); +}); + +test('order queue jobs serialize only scalar identifiers and options', function () { + $apiJob = new FinalizeApiOrderCreation('order-uuid', 'service-quote-uuid', true); + $internalJob = new FinalizeInternalOrderCreation('order-uuid'); + $bulkJob = new NotifyBulkAssignedDriver(['order-uuid-1', 'order-uuid-2'], 'driver-uuid'); + + expect($apiJob)->toBeInstanceOf(ShouldQueue::class) + ->and($apiJob->orderUuid)->toBe('order-uuid') + ->and($apiJob->serviceQuoteUuid)->toBe('service-quote-uuid') + ->and($apiJob->shouldDispatch)->toBeTrue() + ->and($internalJob)->toBeInstanceOf(ShouldQueue::class) + ->and($internalJob->orderUuid)->toBe('order-uuid') + ->and($bulkJob)->toBeInstanceOf(ShouldQueue::class) + ->and($bulkJob->orderUuids)->toBe(['order-uuid-1', 'order-uuid-2']) + ->and($bulkJob->driverUuid)->toBe('driver-uuid'); +}); diff --git a/server/tests/TelematicsHardeningTest.php b/server/tests/TelematicsHardeningTest.php index feddfcd7f..5e28acc0d 100644 --- a/server/tests/TelematicsHardeningTest.php +++ b/server/tests/TelematicsHardeningTest.php @@ -1,6 +1,10 @@ toContain('Provider device identity is required to link a telematics device.') + ->toContain('public function ingestDeviceSnapshot') ->toContain('DeviceEvent::firstOrNew([\'_key\' => $eventKey])') + ->toContain('reconcileDeviceTelemetry') + ->toContain('PROTECTED_DEVICE_STATUSES') + ->toContain("'provider_status'") + ->toContain("'telemetry_summary'") + ->toContain('connectionStatusForDevice') + ->toContain('applyDeviceEventTelemetry') + ->toContain('$event->createPosition($positionData)') + ->toContain('updateVehicleTelemetry') + ->toContain('broadcast(new VehicleLocationChanged') + ->toContain('storeSnapshotSensors') + ->toContain("\$payload['sensors'] ?? \$payload['sensors_last_val']") ->toContain('protected function makeEventKey') ->toContain('$telematic->public_id ?? $telematic->uuid') ->toContain('resolveWebhookTelematic') @@ -49,6 +65,19 @@ ->toContain('meta->provider_account_id'); }); +test('device details render consistent telematics connection state and timestamp', function () { + $details = file_get_contents(__DIR__ . '/../../addon/components/device/details.hbs'); + $header = file_get_contents(__DIR__ . '/../../addon/components/device/panel-header.hbs'); + + expect($details) + ->toContain('format-date-fns @resource.last_online_at "dd MMM yyyy, HH:mm"') + ->not->toContain('format-date @resource.last_online_at'); + + expect($header) + ->toContain('@resource.is_online') + ->not->toContain('@resource.online "online"'); +}); + 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'); @@ -133,6 +162,7 @@ $provider = file_get_contents(__DIR__ . '/../src/Support/Telematics/Providers/AfaqyProvider.php'); expect($provider) + ->toContain('TelematicProviderException') ->toContain('compactLastUpdate') ->toContain("'provider_unit_id'") ->toContain("'plate_number'") @@ -140,6 +170,29 @@ ->not->toContain("'raw' => \$payload") ->not->toContain("'sensors',") ->toContain("'Authorization' => 'Bearer ' . \$token") + ->toContain('protected function authenticatedPost') + ->toContain('protected function refreshToken') + ->toContain('protected function isTokenRejected') + ->toContain('protected int $dataTimeout = 120') + ->toContain('protected int $connectTimeout = 15') + ->toContain('protected int $connectionTestTimeout = 30') + ->toContain('protected int $connectionTestConnectTimeout = 10') + ->toContain('->timeout($timeout)') + ->toContain('->connectTimeout($connectTimeout)') + ->toContain('ConnectionException') + ->toContain('transportErrorContext') + ->toContain('extractBytesReceived') + ->toContain("'requested_limit'") + ->toContain("'requested_offset'") + ->toContain("'bytes_received'") + ->toContain('AFAQY token rejected; refreshing token and retrying request') + ->toContain('AFAQY token rejected after refresh with status') + ->toContain('AFAQY token rejected and username/password credentials are required to refresh it.') + ->toContain('providerErrorContext') + ->toContain("'endpoint'") + ->toContain("'provider_code'") + ->toContain("'provider_message'") + ->toContain("'retry_attempted'") ->toContain('?? 500), 500') ->toContain('if (is_array($filters) && empty($filters))') ->toContain('$filters = new \stdClass();') @@ -160,7 +213,195 @@ ->toContain("'pagination' => [") ->toContain("'allCount'") ->toContain("'filtersCount'") - ->toContain("'resultCount'"); + ->toContain("'resultCount'") + ->toContain("'online' => \$payload['active'] ?? null") + ->toContain("'altitude' => \$lastUpdate['alt'] ?? null"); +}); + +test('afaqy sync keeps default limit and uses extended data request path', function () { + $requests = []; + + Http::fake(function ($request) use (&$requests) { + $requests[] = $request; + + if (str_ends_with($request->url(), '/auth/login')) { + return Http::response(['data' => ['token' => 'testing-token']], 200); + } + + return Http::response([ + 'data' => [], + 'pagination' => ['resultCount' => 0], + ], 200); + }); + + $provider = new class extends AfaqyProvider { + public function fetchDevicesForTest(array $credentials): array + { + $this->credentials = $credentials; + $this->prepareAuthentication(); + + return $this->fetchDevices(); + } + }; + + $result = $provider->fetchDevicesForTest([ + 'base_url' => 'https://api.afaqy.test', + 'username' => 'testing-user', + 'password' => 'testing-password', + ]); + + expect($result['pagination']['resultCount'])->toBe(0); + expect($requests)->toHaveCount(2); + expect($requests[0]->url())->toBe('https://api.afaqy.test/auth/login'); + expect($requests[1]->url())->toContain('/units/lists?token=testing-token'); + expect($requests[1]->body())->toContain('"limit":500'); +}); + +test('afaqy sync timeout failures are converted to sanitized provider metadata', function () { + Http::fake(function ($request) { + if (str_ends_with($request->url(), '/auth/login')) { + return Http::response(['data' => ['token' => 'timeout-testing-token']], 200); + } + + throw new ConnectionException('cURL error 28: Operation timed out after 30000 milliseconds with 1186621 bytes received for https://api.afaqy.test/units/lists?token=timeout-testing-token'); + }); + + $provider = new class extends AfaqyProvider { + public function fetchDevicesForTest(array $credentials): array + { + $this->credentials = $credentials; + $this->prepareAuthentication(); + + return $this->fetchDevices(); + } + }; + + try { + $provider->fetchDevicesForTest([ + 'base_url' => 'https://api.afaqy.test', + 'username' => 'testing-user', + 'password' => 'testing-password', + ]); + } catch (Throwable $e) { + $result = [ + 'success' => false, + 'message' => $e->getMessage(), + 'metadata' => method_exists($e, 'context') ? $e->context() : [], + ]; + } + + expect($result['success'])->toBeFalse(); + expect($result['message'])->toBe('AFAQY API request timed out while waiting for provider response.'); + expect($result['metadata'])->toMatchArray([ + 'provider' => 'afaqy', + 'endpoint' => '/units/lists', + 'requested_limit' => 500, + 'requested_offset' => 0, + 'timeout' => 120, + 'connect_timeout' => 15, + 'bytes_received' => 1186621, + 'retry_attempted' => false, + 'transport_error' => 'connection_exception', + ]); + expect(json_encode($result)) + ->not->toContain('timeout-testing-token') + ->not->toContain('token=') + ->not->toContain('testing-password') + ->not->toContain('testing-user'); +}); + +test('afaqy credential test refreshes token once when units list rejects token', function () { + $authCount = 0; + $requests = []; + + Http::fake(function ($request) use (&$authCount, &$requests) { + $requests[] = $request; + + if (str_ends_with($request->url(), '/auth/login')) { + $authCount++; + + return Http::response(['data' => ['token' => $authCount === 1 ? 'first-testing-token' : 'second-testing-token']], 200); + } + + if (str_contains($request->url(), '/units/lists?token=first-testing-token')) { + return Http::response(['message' => 'Token expired', 'code' => 'TOKEN_EXPIRED'], 401); + } + + return Http::response([ + 'data' => [['_id' => 'unit-1', 'name' => 'Truck 1']], + 'pagination' => ['resultCount' => 1], + ], 200); + }); + + $result = (new AfaqyProvider())->testConnection([ + 'base_url' => 'https://api.afaqy.test', + 'username' => 'testing-user', + 'password' => 'testing-password', + ]); + + expect($result['success'])->toBeTrue(); + expect($authCount)->toBe(2); + expect(collect($requests)->filter(fn ($request) => str_contains($request->url(), '/units/lists'))->count())->toBe(2); + expect($requests[1]->url())->toContain('/units/lists?token=first-testing-token'); + expect($requests[2]->url())->toContain('/auth/login'); + expect($requests[3]->url())->toContain('/units/lists?token=second-testing-token'); +}); + +test('afaqy token rejection failure metadata is sanitized', function () { + $authCount = 0; + + Http::fake(function ($request) use (&$authCount) { + if (str_ends_with($request->url(), '/auth/login')) { + $authCount++; + + return Http::response(['data' => ['token' => 'rejected-testing-token-' . $authCount]], 200); + } + + return Http::response(['message' => 'Token rejected', 'code' => 'TOKEN_REJECTED'], 401); + }); + + $result = (new AfaqyProvider())->testConnection([ + 'base_url' => 'https://api.afaqy.test', + 'username' => 'testing-user', + 'password' => 'testing-password', + ]); + + expect($result['success'])->toBeFalse(); + expect($result['message'])->toBe('AFAQY token rejected after refresh with status 401'); + expect($result['metadata'])->toMatchArray([ + 'provider' => 'afaqy', + 'endpoint' => '/units/lists', + 'status_code' => 401, + 'provider_code' => 'TOKEN_REJECTED', + 'provider_message' => 'Token rejected', + 'retry_attempted' => true, + ]); + expect(json_encode($result)) + ->not->toContain('rejected-testing-token') + ->not->toContain('testing-password') + ->not->toContain('testing-user'); +}); + +test('afaqy supplied token rejection requires password credentials for refresh', function () { + Http::fake([ + 'https://api.afaqy.test/units/lists?token=static-testing-token' => Http::response(['message' => 'Token rejected'], 401), + ]); + + $result = (new AfaqyProvider())->testConnection([ + 'base_url' => 'https://api.afaqy.test', + 'token' => 'static-testing-token', + ]); + + expect($result['success'])->toBeFalse(); + expect($result['message'])->toBe('AFAQY token rejected and username/password credentials are required to refresh it.'); + expect($result['metadata'])->toMatchArray([ + 'provider' => 'afaqy', + 'endpoint' => '/units/lists', + 'status_code' => 401, + 'provider_message' => 'Token rejected', + 'retry_attempted' => false, + ]); + expect(json_encode($result))->not->toContain('static-testing-token'); }); test('telematics device sync records provider pagination and skipped device counts', function () { @@ -172,20 +413,56 @@ ->not->toContain("'limit' => \$request->input('limit', 100)"); expect($job) + ->toContain('public int $tries = 1') ->toContain("'limit' => \$this->options['limit'] ?? null") + ->toContain('Cache::lock($lockKey, $this->timeout + 60)') + ->toContain("'fleetops:sync-telematic-devices:' . \$this->telematic->uuid") + ->toContain('Device discovery skipped because another sync is already running') + ->toContain("'last_sync_skipped_reason'") + ->toContain("'sync_already_running'") ->toContain('$totalFetched') ->toContain('$totalLinked') + ->toContain('$totalEvents') + ->toContain('$totalSensors') ->toContain('$totalSkipped') ->toContain('$pageCount') + ->toContain('$service->ingestDeviceSnapshot($this->telematic, $provider, $devicePayload)') ->toContain('Device discovery page fetched') ->toContain("'provider_unit_id'") ->toContain("'last_sync_fetched_total'") ->toContain("'last_sync_linked_total'") + ->toContain("'last_sync_events_total'") + ->toContain("'last_sync_sensors_total'") ->toContain("'last_sync_skipped_total'") ->toContain("'last_sync_page_count'") ->toContain("'last_sync_provider_total'") ->toContain("'last_sync_provider_all_count'") - ->toContain("'last_sync_provider_filters_count'"); + ->toContain("'last_sync_provider_filters_count'") + ->toContain("'last_sync_error_context'") + ->toContain('safeSyncErrorMessage') + ->toContain('token=|password|client_secret|authorization') + ->toContain("method_exists(\$e, 'context') ? \$e->context() : []"); +}); + +test('telematics polling command is registered and scheduled for no webhook providers', 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('SyncTelematicDevicesJob::dispatch($telematic') + ->toContain('!$descriptor->supportsWebhooks') + ->toContain('$descriptor->supportsDiscovery') + ->toContain("whereIn('status', ['active', 'connected'])"); + + expect($provider) + ->toContain('Console\\Commands\\SyncTelematics::class') + ->toContain("command('fleetops:sync-telematics')->everyMinute()"); + + expect($details) + ->toContain('Provider polling') + ->toContain('FleetOps polls this provider for device snapshots and telemetry updates.'); }); test('native endpoint fields are advanced optional overrides with provider defaults', function () { @@ -206,6 +483,90 @@ } }); +test('safee credential test sends documented form auth request to custom server uri', function () { + $requests = []; + + Http::fake(function ($request) use (&$requests) { + $requests[] = $request; + + if (str_ends_with($request->url(), '/protocol/openid-connect/token')) { + return Http::response(['access_token' => 'testing-access-token'], 200); + } + + return Http::response([ + 'code' => 0, + 'time' => 1509946353.033, + 'status' => 'success', + 'message' => 'operation completed successfully', + ], 200); + }); + + $result = (new SafeeProvider())->testConnection([ + 'server_uri' => ' https://fms.example.test/ ', + 'realm_id' => 'dsco', + 'client_id' => 'api', + 'client_secret' => 'testing-client-secret', + 'username' => 'testing-user', + 'password' => 'testing-password', + ]); + + expect($result['success'])->toBeTrue(); + expect($result['metadata']) + ->toMatchArray([ + 'auth_host' => 'https://fms.example.test', + 'auth_path' => '/auth/realms/dsco/protocol/openid-connect/token', + 'realm_id' => 'dsco', + ]); + + expect($requests)->toHaveCount(2); + + $tokenRequest = $requests[0]; + parse_str($tokenRequest->body(), $tokenBody); + + expect($tokenRequest->method())->toBe('POST'); + expect($tokenRequest->url())->toBe('https://fms.example.test/auth/realms/dsco/protocol/openid-connect/token'); + expect(implode(' ', (array) $tokenRequest->header('Content-Type')))->toContain('application/x-www-form-urlencoded'); + expect($tokenBody)->toMatchArray([ + 'grant_type' => 'password', + 'client_secret' => 'testing-client-secret', + 'client_id' => 'api', + 'username' => 'testing-user', + 'password' => 'testing-password', + ]); + + expect($requests[1]->method())->toBe('GET'); + expect($requests[1]->url())->toBe('https://fms.example.test/api/v2/status'); + expect(implode(' ', (array) $requests[1]->header('Authorization')))->toBe('Bearer testing-access-token'); +}); + +test('safee credential test reports token endpoint 401 with sanitized auth context', function () { + Http::fake([ + 'https://fms.example.test/auth/realms/dsco/protocol/openid-connect/token' => Http::response(['error' => 'unauthorized'], 401), + ]); + + $result = (new SafeeProvider())->testConnection([ + 'server_uri' => 'https://fms.example.test', + 'realm_id' => 'dsco', + 'client_id' => 'api', + 'client_secret' => 'testing-client-secret', + 'username' => 'testing-user', + 'password' => 'testing-password', + ]); + + expect($result['success'])->toBeFalse(); + expect($result['message'])->toBe('Safee authentication failed with status 401'); + expect($result['metadata']) + ->toMatchArray([ + 'auth_host' => 'https://fms.example.test', + 'auth_path' => '/auth/realms/dsco/protocol/openid-connect/token', + 'realm_id' => 'dsco', + ]) + ->not->toHaveKey('client_secret') + ->not->toHaveKey('password') + ->not->toHaveKey('access_token') + ->not->toHaveKey('refresh_token'); +}); + test('telematics activity logging excludes large json and spatial payloads', function () { $device = telematics_activity_log_method(file_get_contents(__DIR__ . '/../src/Models/Device.php')); $sensor = telematics_activity_log_method(file_get_contents(__DIR__ . '/../src/Models/Sensor.php')); diff --git a/server/tests/VehicleDriverAssignmentTest.php b/server/tests/VehicleDriverAssignmentTest.php index 5556408c0..6fcc931c7 100644 --- a/server/tests/VehicleDriverAssignmentTest.php +++ b/server/tests/VehicleDriverAssignmentTest.php @@ -17,6 +17,33 @@ ->toContain("Driver::where('vehicle_uuid', \$this->uuid)->update(['vehicle_uuid' => null])"); }); +test('driver vehicle assignment preserves license expiry', function () { + $apiController = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Api/v1/DriverController.php'); + $internalController = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/DriverController.php'); + $driverModel = file_get_contents(dirname(__DIR__) . '/src/Models/Driver.php'); + $driverResource = file_get_contents(dirname(__DIR__) . '/src/Http/Resources/v1/Driver.php'); + + expect($apiController) + ->not->toContain('normalizeDriverUpdateInput') + ->not->toContain("array_key_exists('license_expiry', \$input)") + ->and($internalController) + ->not->toContain('normalizeDriverUpdateInput') + ->toContain('public function assignVehicle(Request $request, string $id): JsonResponse') + ->toContain('$driver->assignVehicle($vehicle)') + ->not->toContain("'license_expiry' => null") + ->and($driverModel) + ->toContain('public function setLicenseExpiryAttribute($value): void') + ->toContain("!empty(\$this->getOriginal('license_expiry'))") + ->toContain('Carbon::parse($value)->toDateString()') + ->toContain('public function assignVehicle(Vehicle $vehicle): self') + ->toContain('$this->setVehicle($vehicle)') + ->toContain('$this->save()') + ->not->toContain('$this->license_expiry = null') + ->and($driverResource) + ->toContain("'license_expiry' => \$this->formatDateOnly(\$this->license_expiry)") + ->toContain('protected function formatDateOnly($date): ?string'); +}); + test('driver assigned-order workflows expose list and multi-unassign endpoints', function () { $routes = file_get_contents(dirname(__DIR__) . '/src/routes.php'); $controller = file_get_contents(dirname(__DIR__) . '/src/Http/Controllers/Internal/v1/DriverController.php'); diff --git a/tests/integration/components/device/details-test.js b/tests/integration/components/device/details-test.js index 1d43af91d..f0b04b834 100644 --- a/tests/integration/components/device/details-test.js +++ b/tests/integration/components/device/details-test.js @@ -6,21 +6,44 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | device/details', 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 the operational overview without optional associations', async function (assert) { + this.set('device', { + displayName: 'Gateway 101', + connection_status: 'never_connected', + provider: 'samsara', + device_id: 'VG-101', + status: 'active', + }); - await render(hbs``); + await render(hbs``); - assert.dom().hasText(''); + assert.dom('h2').hasText('Gateway 101'); + assert.dom().includesText('Operational Snapshot'); + assert.dom().includesText('Critical Details'); + assert.dom().includesText('Unattached'); + assert.dom().includesText('Never Connected'); + }); + + test('it renders provider and vehicle context when present', async function (assert) { + this.set('device', { + displayName: 'Cold Chain Tracker', + connection_status: 'online', + attached_to_name: 'Truck 24', + device_id: 'CC-24', + status: 'active', + telematic: { + provider_descriptor: { + label: 'Geotab', + icon: '/assets/images/telematics/providers/geotab.webp', + }, + }, + }); - // Template block usage: - await render(hbs` - - template block text - - `); + await render(hbs``); - assert.dom().hasText('template block text'); + assert.dom().includesText('Cold Chain Tracker'); + assert.dom().includesText('Truck 24'); + assert.dom().includesText('Geotab'); + assert.dom().includesText('Online'); }); }); diff --git a/tests/integration/components/layout/fleet-ops-sidebar-test.js b/tests/integration/components/layout/fleet-ops-sidebar-test.js index e7fa66fcd..76ee3fbaf 100644 --- a/tests/integration/components/layout/fleet-ops-sidebar-test.js +++ b/tests/integration/components/layout/fleet-ops-sidebar-test.js @@ -3,21 +3,33 @@ import { setupRenderingTest } from 'dummy/tests/helpers'; import { click, fillIn, render, 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'; currentURL = '/fleet-ops'; transitions = []; + handlers = {}; - on() {} + on(eventName, handler) { + this.handlers[eventName] = handler; + } - off() {} + off(eventName) { + delete this.handlers[eventName]; + } transitionTo(route, ...args) { this.currentRouteName = route; this.transitions.push({ route, args }); + this.triggerRouteDidChange(); return Promise.resolve(); } + + triggerRouteDidChange() { + this.handlers.routeDidChange?.(); + } } module('Integration | Component | layout/fleet-ops-sidebar', function (hooks) { @@ -113,6 +125,82 @@ module('Integration | Component | layout/fleet-ops-sidebar', function (hooks) { assert.dom('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:first-of-type svg[data-icon="table-cells-large"]').doesNotExist(); }); + test('it preserves registered item priority, virtual metadata, and nested active state', async function (assert) { + assert.expect(8); + + const contractsItem = { + title: 'Contracts', + slug: 'contracts', + section: 'management', + icon: 'file-signature', + priority: -10, + visible: true, + }; + const permitsItem = { + title: 'Permits', + slug: 'permits', + section: 'management', + icon: 'stamp', + priority: 1.5, + visible: true, + }; + + class MenuServiceStub extends Service { + getMenuItems(registryName) { + assert.strictEqual(registryName, 'engine:fleet-ops'); + return [permitsItem, contractsItem]; + } + + getMenuPanels(registryName) { + assert.strictEqual(registryName, 'engine:fleet-ops'); + return [ + { + title: 'Registry Late', + icon: 'box', + priority: 20, + items: [], + }, + { + title: 'Registry Early', + icon: 'box', + priority: 10, + items: [], + }, + ]; + } + } + + class UniverseStub extends Service { + transitionMenuItem(route, menuItem) { + assert.strictEqual(route, 'console.fleet-ops.virtual'); + assert.true(menuItem._virtual, 'registered item keeps virtual metadata'); + assert.strictEqual(menuItem.slug, 'contracts'); + + const router = getOwner(this).lookup('service:router'); + router.currentRouteName = 'console.fleet-ops.virtual'; + router.currentURL = '/fleet-ops/management/contracts'; + window.location.href = '/fleet-ops/management/contracts'; + router.triggerRouteDidChange(); + } + } + + this.owner.register('service:universe/menu-service', MenuServiceStub); + this.owner.register('service:universe', UniverseStub); + + await render(hbs``); + + await click('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)'); + + const labels = [...this.element.querySelectorAll('.next-sidebar-navigator-view-in .next-sidebar-navigator-item-label')].map((element) => element.textContent.trim()); + + assert.deepEqual(labels.slice(0, 5), ['Resources Hub', 'Contracts', 'Drivers', 'Permits', 'Vehicles'], 'hub items stay first while registered section items sort by priority'); + + await click('.next-sidebar-navigator-view-in .next-sidebar-navigator-item:nth-of-type(2)'); + + assert.dom('.next-sidebar-navigator-back').includesText('Resources'); + 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/layout/fleet-ops-sidebar/operations-monitor-test.js b/tests/integration/components/layout/fleet-ops-sidebar/operations-monitor-test.js index 6aafe6e17..95c25b9d5 100644 --- a/tests/integration/components/layout/fleet-ops-sidebar/operations-monitor-test.js +++ b/tests/integration/components/layout/fleet-ops-sidebar/operations-monitor-test.js @@ -1,25 +1,65 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { click, render, waitUntil } from '@ember/test-helpers'; +import { click, fillIn, render, waitUntil } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import Service from '@ember/service'; class StoreStubService extends Service { - query(modelName) { - const data = { - driver: [ - { name: 'Offline Driver', public_id: 'driver_2', online: false, status: 'inactive' }, - { name: 'Online Driver', public_id: 'driver_1', online: true, status: 'active' }, + records = new Map(); + + pushPayload(modelName, payload) { + const resource = payload[modelName]; + + if (resource?.id) { + this.records.set(`${modelName}:${resource.id}`, resource); + } + + if (resource?.public_id) { + this.records.set(`${modelName}:${resource.public_id}`, resource); + } + } + + peekRecord(modelName, id) { + return this.records.get(`${modelName}:${id}`); + } +} + +class FetchStubService extends Service { + get() { + return Promise.resolve({ + drivers: [ + { id: 'driver_2', name: 'Offline Driver', public_id: 'driver_2', online: false, status: 'inactive' }, + { id: 'driver_1', name: 'Online Driver', public_id: 'driver_1', online: true, status: 'active' }, ], - vehicle: [ - { display_name: 'Offline Van', public_id: 'vehicle_2', online: false, status: 'inactive' }, - { display_name: 'Online Truck', public_id: 'vehicle_1', online: true, status: 'active' }, - { display_name: 'Standby Car', public_id: 'vehicle_3', online: false, status: 'standby' }, + vehicles: [ + { id: 'vehicle_2', display_name: 'Offline Van', public_id: 'vehicle_2', online: false, status: 'inactive' }, + { id: 'vehicle_1', display_name: 'Online Truck', public_id: 'vehicle_1', online: true, status: 'active' }, + { id: 'vehicle_3', display_name: 'Standby Car', public_id: 'vehicle_3', online: false, status: 'standby' }, ], - fleet: [{ name: 'North Fleet', public_id: 'fleet_1' }], - }; - - return Promise.resolve(data[modelName] ?? []); + fleets: [ + { + id: 'fleet_1', + name: 'North Fleet', + public_id: 'fleet_1', + driver_ids: ['driver_1', 'missing_driver'], + vehicle_ids: ['vehicle_1'], + drivers_count: 1, + vehicles_count: 1, + subfleets: [ + { + id: 'fleet_2', + name: 'North Subfleet', + public_id: 'fleet_2', + driver_ids: ['driver_2'], + vehicle_ids: ['missing_vehicle'], + drivers_count: 1, + vehicles_count: 0, + subfleets: [], + }, + ], + }, + ], + }); } } @@ -73,6 +113,7 @@ module('Integration | Component | layout/fleet-ops-sidebar/operations-monitor', hooks.beforeEach(function () { this.owner.register('service:store', StoreStubService); + this.owner.register('service:fetch', FetchStubService); this.owner.register('service:universe', UniverseStubService); this.owner.register('service:map-manager', MapManagerStubService); this.owner.register('service:host-router', HostRouterStubService); @@ -93,6 +134,9 @@ module('Integration | Component | layout/fleet-ops-sidebar/operations-monitor', assert.dom('[data-test-operations-monitor-tab="drivers"]').hasText('Drivers'); assert.dom('[data-test-operations-monitor-tab="vehicles"]').hasText('Vehicles'); assert.dom('[data-test-operations-monitor-tab="fleets"]').hasText('Fleets'); + + await click('[data-test-operations-monitor-tab="drivers"]'); + assert.dom('[data-test-operations-monitor-row]:first-of-type').includesText('Online Driver'); assert.dom('[data-test-operations-monitor-row]:first-of-type').includesText('Online'); @@ -107,4 +151,22 @@ module('Integration | Component | layout/fleet-ops-sidebar/operations-monitor', assert.dom('[data-test-operations-monitor-row]:first-of-type').includesText('Online'); assert.dom('[data-test-operations-monitor-row]:nth-of-type(2)').includesText('Offline'); }); + + test('it renders fleet children from composite response id links', async function (assert) { + await render(hbs``); + await waitUntil(() => this.element.textContent.includes('North Fleet')); + + assert.dom('[data-test-operations-monitor-list]').includesText('North Fleet'); + assert.dom('[data-test-operations-monitor-list]').includesText('North Subfleet'); + assert.dom('[data-test-operations-monitor-list]').includesText('Online Driver'); + assert.dom('[data-test-operations-monitor-list]').includesText('Online Truck'); + assert.dom('[data-test-operations-monitor-list]').doesNotIncludeText('missing_driver'); + assert.dom('[data-test-operations-monitor-list]').doesNotIncludeText('missing_vehicle'); + + await fillIn('[data-test-operations-monitor-filter]', 'offline'); + + assert.dom('[data-test-operations-monitor-list]').includesText('North Fleet'); + assert.dom('[data-test-operations-monitor-list]').includesText('North Subfleet'); + assert.dom('[data-test-operations-monitor-list]').includesText('Offline Driver'); + }); }); diff --git a/tests/integration/components/order/form-test.js b/tests/integration/components/order/form-test.js index d4d416c54..a1ea2cf87 100644 --- a/tests/integration/components/order/form-test.js +++ b/tests/integration/components/order/form-test.js @@ -23,4 +23,31 @@ module('Integration | Component | order/form', function (hooks) { assert.dom().hasText('template block text'); }); + + test('it exposes orchestrator constraints between documents and metadata', async function (assert) { + this.set('resource', { + files: [], + meta: {}, + required_skills: [], + }); + + await render(hbs` + + + + + + `); + + const text = this.element.textContent; + const documentsIndex = text.indexOf('Documents'); + const constraintsIndex = text.indexOf('Orchestrator Constraints'); + const metadataIndex = text.indexOf('Metadata'); + + assert.true(documentsIndex > -1, 'documents panel is rendered'); + assert.true(constraintsIndex > -1, 'orchestrator constraints panel is rendered'); + assert.true(metadataIndex > -1, 'metadata panel is rendered'); + assert.true(documentsIndex < constraintsIndex, 'orchestrator constraints render after documents'); + assert.true(constraintsIndex < metadataIndex, 'orchestrator constraints render before metadata'); + }); }); diff --git a/tests/integration/components/order/form/details-test.js b/tests/integration/components/order/form/details-test.js index f683eac96..0f3f7abcb 100644 --- a/tests/integration/components/order/form/details-test.js +++ b/tests/integration/components/order/form/details-test.js @@ -3,6 +3,7 @@ import Service from '@ember/service'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import OrderFormDetailsComponent from '@fleetbase/fleetops-engine/components/order/form/details'; module('Integration | Component | order/form/details', function (hooks) { setupRenderingTest(hooks); @@ -37,4 +38,58 @@ module('Integration | Component | order/form/details', function (hooks) { assert.true(requiredLabels.includes('Order Type')); assert.true(requiredLabels.includes('Proof of Delivery')); }); + + test('it does not render orchestrator constraint inputs', async function (assert) { + this.set('resource', { + facilitator: { + isIntegratedVendor: false, + }, + order_config: null, + payload: {}, + pod_required: false, + required_skills: [], + }); + + await render(hbs``); + + assert.dom().doesNotContainText('Orchestrator Constraints'); + assert.dom().doesNotContainText('Time Window Start'); + assert.dom().doesNotContainText('Required Skills'); + assert.dom().doesNotContainText('Orchestrator Priority'); + }); + + test('quote-relevant detail changes request service quote refresh', function (assert) { + const requests = []; + const resource = { + payload: { + set() {}, + }, + set(field, value) { + this[field] = value; + }, + }; + + class OrderCreationStub extends Service { + requestServiceQuoteRefresh(reason, order) { + requests.push({ reason, order }); + } + } + + this.owner.register('service:order-creation', OrderCreationStub); + + const component = new OrderFormDetailsComponent(this.owner, { resource }); + + component.selectFacilitator({ id: 'facilitator-1' }); + component.setScheduledAt('2026-06-17T12:00:00Z'); + component.selectIntegratedServiceType('express'); + + assert.deepEqual( + requests.map((request) => request.reason), + ['details.facilitator.changed', 'details.scheduled_at.changed', 'details.integrated_service_type.changed'] + ); + assert.true( + requests.every((request) => request.order === resource), + 'requests refresh for the current order' + ); + }); }); diff --git a/tests/integration/components/order/form/orchestrator-constraints-test.js b/tests/integration/components/order/form/orchestrator-constraints-test.js new file mode 100644 index 000000000..62f30cd2b --- /dev/null +++ b/tests/integration/components/order/form/orchestrator-constraints-test.js @@ -0,0 +1,39 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import OrderFormOrchestratorConstraintsComponent from '@fleetbase/fleetops-engine/components/order/form/orchestrator-constraints'; + +module('Integration | Component | order/form/orchestrator-constraints', function (hooks) { + setupRenderingTest(hooks); + + test('it renders optional orchestrator constraint inputs', async function (assert) { + this.set('resource', { + required_skills: [], + orchestrator_priority: 50, + }); + + await render(hbs``); + + assert.dom().containsText('Orchestrator Constraints'); + assert.dom().containsText('Time Window Start'); + assert.dom().containsText('Time Window End'); + assert.dom().containsText('Required Skills'); + assert.dom().containsText('Orchestrator Priority'); + }); + + test('it normalizes epoch-only time window values against the order date', function (assert) { + const resource = { + scheduled_at: new Date(2026, 5, 18), + }; + const component = new OrderFormOrchestratorConstraintsComponent(this.owner, { resource }); + + component.setTimeWindow('time_window_start', new Date(1970, 0, 1, 9, 30)); + + assert.strictEqual(resource.time_window_start.getFullYear(), 2026); + assert.strictEqual(resource.time_window_start.getMonth(), 5); + assert.strictEqual(resource.time_window_start.getDate(), 18); + assert.strictEqual(resource.time_window_start.getHours(), 9); + assert.strictEqual(resource.time_window_start.getMinutes(), 30); + }); +}); diff --git a/tests/integration/components/order/form/payload-test.js b/tests/integration/components/order/form/payload-test.js index 28b2dc144..6cbfdc681 100644 --- a/tests/integration/components/order/form/payload-test.js +++ b/tests/integration/components/order/form/payload-test.js @@ -2,6 +2,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import Service from '@ember/service'; import { click, findAll, render } from '@ember/test-helpers'; +import { A } from '@ember/array'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | order/form/payload', function (hooks) { @@ -62,4 +63,47 @@ module('Integration | Component | order/form/payload', function (hooks) { assert.strictEqual(savedCount, 0, 'clicking edit does not save the entity directly'); }); + + test('adding and removing entities requests service quote refresh', async function (assert) { + const requests = []; + const entity = { + isNew: true, + name: 'Draft box', + sku: 'BOX-1', + type: 'entity', + photo_url: 'https://example.test/box.png', + }; + const order = { + imported: false, + payload: { + entities: A([entity]), + waypoints: A([]), + }, + }; + + class OrderCreationStub extends Service { + requestServiceQuoteRefresh(reason, resource) { + requests.push({ reason, resource }); + } + } + + this.owner.register('service:order-creation', OrderCreationStub); + this.set('order', order); + + await render(hbs``); + + const addButton = findAll('button').find((button) => button.textContent.includes('Add Item')); + assert.ok(addButton, 'renders the add item button'); + + await click(addButton); + + assert.strictEqual(requests.at(-1).reason, 'entity.added'); + assert.strictEqual(requests.at(-1).resource, order); + + const removeButtons = findAll('button').filter((button) => button.querySelector('[data-icon="times"]')); + await click(removeButtons.at(-1)); + + assert.strictEqual(requests.at(-1).reason, 'entity.removed'); + assert.strictEqual(requests.at(-1).resource, order); + }); }); diff --git a/tests/integration/components/order/form/route-test.js b/tests/integration/components/order/form/route-test.js index 7036d3100..e68b1980a 100644 --- a/tests/integration/components/order/form/route-test.js +++ b/tests/integration/components/order/form/route-test.js @@ -2,6 +2,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { click, render, settled } from '@ember/test-helpers'; import { A } from '@ember/array'; +import Service from '@ember/service'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | order/form/route', function (hooks) { @@ -62,4 +63,45 @@ module('Integration | Component | order/form/route', function (hooks) { assert.dom('[data-test-waypoint-row="3"]').doesNotHaveClass('fleetops-order-form-waypoint--required'); assert.dom('[data-test-required-waypoint-tab]').exists({ count: 2 }); }); + + test('route mutations request service quote refresh', async function (assert) { + const requests = []; + + class OrderCreationStub extends Service { + requestServiceQuoteRefresh(reason, resource) { + requests.push({ reason, resource }); + } + } + + this.owner.register('service:order-creation', OrderCreationStub); + this.set('resource', { + customer: null, + driver_assigned: null, + id: 'test-order', + facilitator: { + isIntegratedVendor: false, + }, + payload: { + pickup: null, + dropoff: null, + return: null, + waypoints: A([]), + setProperties(properties) { + Object.assign(this, properties); + }, + }, + }); + + await render(hbs``); + await click('[role="checkbox"]'); + + assert.true( + requests.some((request) => request.reason === 'route.waypoints.toggled'), + 'requests refresh when waypoint mode changes' + ); + assert.true( + requests.every((request) => request.resource === this.resource), + 'requests refresh for the current order' + ); + }); }); diff --git a/tests/integration/components/order/form/service-rate-test.js b/tests/integration/components/order/form/service-rate-test.js index 652a4c148..e049fc5bc 100644 --- a/tests/integration/components/order/form/service-rate-test.js +++ b/tests/integration/components/order/form/service-rate-test.js @@ -1,7 +1,9 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { click, render } from '@ember/test-helpers'; +import Service from '@ember/service'; +import { click, render, waitUntil } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import OrderFormServiceRateComponent from '@fleetbase/fleetops-engine/components/order/form/service-rate'; module('Integration | Component | order/form/service-rate', function (hooks) { setupRenderingTest(hooks); @@ -39,4 +41,168 @@ module('Integration | Component | order/form/service-rate', function (hooks) { assert.dom('.ember-power-select-search-input').exists(); }); + + test('service quote refresh events run debounced quote lookup for the matching order', async function (assert) { + const calls = []; + const resource = { + servicable: true, + service_quote_uuid: 'stale-quote', + payloadCoordinates: ['1,1', '2,2'], + payload: { + payloadCoordinates: ['1,1', '2,2'], + }, + }; + const selectedRate = { id: 'rate-1' }; + + class ServiceRateActionsStub extends Service { + getServiceQuotes = { + perform(serviceRate, order) { + calls.push({ serviceRate, order }); + return Promise.resolve([{ uuid: 'fresh-quote' }]); + }, + }; + } + + this.owner.register('service:service-rate-actions', ServiceRateActionsStub); + + const orderCreation = this.owner.lookup('service:order-creation'); + const component = new OrderFormServiceRateComponent(this.owner, { resource }); + component.selectedRate = selectedRate; + + orderCreation.requestServiceQuoteRefresh('entity.added', { id: 'other-order' }); + orderCreation.requestServiceQuoteRefresh('entity.added', resource); + + assert.true(component.isAutoRefreshingServiceQuotes, 'sets auto-refresh loading state immediately'); + assert.true(component.isLoadingServiceQuotes, 'reports service quotes as loading during auto-refresh'); + + await waitUntil(() => calls.length === 1, { timeout: 1000 }); + await waitUntil(() => !component.isAutoRefreshingServiceQuotes, { timeout: 1000 }); + + assert.strictEqual(calls.length, 1, 'refreshes once after debounce'); + assert.strictEqual(calls[0].serviceRate, selectedRate); + assert.strictEqual(calls[0].order, resource); + assert.strictEqual(resource.service_quote_uuid, null, 'clears stale selected quote'); + assert.false(component.isAutoRefreshingServiceQuotes, 'clears auto-refresh loading state after quotes resolve'); + assert.false(component.isLoadingServiceQuotes, 'clears unified service quote loading state after refresh'); + + component.willDestroy(); + }); + + test('service quote refresh events are ignored until a rate is selected', async function (assert) { + const calls = []; + const resource = { + servicable: true, + payloadCoordinates: ['1,1', '2,2'], + payload: { + payloadCoordinates: ['1,1', '2,2'], + }, + }; + + class ServiceRateActionsStub extends Service { + getServiceQuotes = { + perform(serviceRate, order) { + calls.push({ serviceRate, order }); + return Promise.resolve([]); + }, + }; + } + + this.owner.register('service:service-rate-actions', ServiceRateActionsStub); + + const orderCreation = this.owner.lookup('service:order-creation'); + const component = new OrderFormServiceRateComponent(this.owner, { resource }); + + orderCreation.requestServiceQuoteRefresh('entity.added', resource); + + await new Promise((resolve) => setTimeout(resolve, 600)); + + assert.strictEqual(calls.length, 0); + + component.willDestroy(); + }); + + test('manual quote lookup uses the normal loading state', async function (assert) { + assert.expect(4); + + let resolveQuotes; + const resource = { + servicable: true, + payloadCoordinates: ['1,1', '2,2'], + payload: { + payloadCoordinates: ['1,1', '2,2'], + }, + }; + const selectedRate = { id: 'rate-1' }; + + class ServiceRateActionsStub extends Service { + getServiceQuotes = { + perform() { + return new Promise((resolve) => { + resolveQuotes = resolve; + }); + }, + }; + } + + this.owner.register('service:service-rate-actions', ServiceRateActionsStub); + + const component = new OrderFormServiceRateComponent(this.owner, { resource }); + const quoteTask = component.getServiceQuotes.perform(selectedRate); + + assert.true(component.getServiceQuotes.isRunning, 'manual quote task is running'); + assert.true(component.isLoadingServiceQuotes, 'unified loading state includes manual lookup'); + assert.false(component.isAutoRefreshingServiceQuotes, 'manual lookup does not set auto-refresh state'); + + resolveQuotes([{ uuid: 'quote-1' }]); + await quoteTask; + + assert.false(component.isLoadingServiceQuotes, 'manual lookup clears the unified loading state'); + + component.willDestroy(); + }); + + test('existing quotes remain available while auto-refresh runs', async function (assert) { + assert.expect(4); + + let resolveQuotes; + const resource = { + servicable: true, + payloadCoordinates: ['1,1', '2,2'], + payload: { + payloadCoordinates: ['1,1', '2,2'], + }, + }; + const selectedRate = { id: 'rate-1' }; + + class ServiceRateActionsStub extends Service { + getServiceQuotes = { + perform() { + return new Promise((resolve) => { + resolveQuotes = resolve; + }); + }, + }; + } + + this.owner.register('service:service-rate-actions', ServiceRateActionsStub); + + const orderCreation = this.owner.lookup('service:order-creation'); + const component = new OrderFormServiceRateComponent(this.owner, { resource }); + component.selectedRate = selectedRate; + component.serviceQuotes = [{ uuid: 'existing-quote' }]; + + orderCreation.requestServiceQuoteRefresh('entity.measurements.changed', resource); + + assert.true(component.isAutoRefreshingServiceQuotes, 'auto-refresh loading state starts immediately'); + assert.true(component.hasServiceQuotes, 'existing quote cards remain available to render'); + assert.false(component.shouldShowServiceQuotesLoader, 'full loader is not shown over existing quotes'); + + await waitUntil(() => resolveQuotes, { timeout: 1000 }); + resolveQuotes([{ uuid: 'fresh-quote' }]); + await waitUntil(() => !component.isAutoRefreshingServiceQuotes, { timeout: 1000 }); + + assert.deepEqual(component.serviceQuotes, [{ uuid: 'fresh-quote' }], 'quotes update after auto-refresh resolves'); + + component.willDestroy(); + }); }); diff --git a/tests/integration/components/work-order/form-test.js b/tests/integration/components/work-order/form-test.js index 2a9f70e87..d7619097a 100644 --- a/tests/integration/components/work-order/form-test.js +++ b/tests/integration/components/work-order/form-test.js @@ -1,26 +1,86 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { click, findAll, render } from '@ember/test-helpers'; +import { helper } from '@ember/component/helper'; import { hbs } from 'ember-cli-htmlbars'; +async function choosePowerSelectOption(index, text) { + await click(findAll('.ember-power-select-trigger')[index]); + + const option = findAll('.ember-power-select-option').find((element) => element.textContent.includes(text)); + await click(option); +} + module('Integration | Component | work-order/form', 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) { ... }); + hooks.beforeEach(function () { + this.owner.register( + 'helper:cannot-write', + helper(() => false) + ); + }); + + test('it renders lifecycle status and category option labels', async function (assert) { + this.set('resource', { + code: null, + subject: 'Oil service', + category: 'preventive_maintenance', + status: 'open', + priority: 'medium', + meta: {}, + }); + + await render(hbs``); + + assert.dom('.work-order-form').exists(); + assert.dom().containsText('Preventive Maintenance (PM)'); + assert.dom().containsText('Open'); + }); + + test('selecting a lifecycle status stores the status value', async function (assert) { + this.set('resource', { + status: 'open', + priority: 'medium', + meta: {}, + }); + + await render(hbs``); + await choosePowerSelectOption(1, 'Quality Check'); + + assert.strictEqual(this.resource.status, 'quality_check'); + }); + + test('selecting a category preserves existing metadata', async function (assert) { + this.set('resource', { + status: 'open', + priority: 'medium', + meta: { + existing_key: 'keep-me', + }, + }); + + await render(hbs``); + await choosePowerSelectOption(0, 'Tire Issue'); + + assert.strictEqual(this.resource.meta.existing_key, 'keep-me'); + assert.strictEqual(this.resource.category, 'tire_issue'); + }); + + test('closed status still reveals completion details', async function (assert) { + this.set('resource', { + status: 'open', + priority: 'medium', + meta: {}, + }); - await render(hbs``); + await render(hbs``); - assert.dom().hasText(''); + assert.dom().doesNotContainText('Completion Details'); - // Template block usage: - await render(hbs` - - template block text - - `); + await choosePowerSelectOption(1, 'Closed'); - assert.dom().hasText('template block text'); + assert.strictEqual(this.resource.status, 'closed'); + assert.dom().containsText('Completion Details'); }); }); diff --git a/tests/integration/helpers/get-fleet-ops-options-test.js b/tests/integration/helpers/get-fleet-ops-options-test.js index 6b57c17a9..c4d499158 100644 --- a/tests/integration/helpers/get-fleet-ops-options-test.js +++ b/tests/integration/helpers/get-fleet-ops-options-test.js @@ -6,12 +6,27 @@ import { hbs } from 'ember-cli-htmlbars'; module('Integration | Helper | get-fleet-ops-options', function (hooks) { setupRenderingTest(hooks); - // TODO: Replace this with your real tests. - test('it renders', async function (assert) { - this.set('inputValue', '1234'); + test('it returns work order lifecycle statuses', async function (assert) { + await render(hbs` + {{#each (get-fleet-ops-options "workOrderStatuses") as |status|}} + {{status.value}}:{{status.label}}; + {{/each}} + `); - await render(hbs`{{get-fleet-ops-options this.inputValue}}`); + assert.dom().containsText('open:Open'); + assert.dom().containsText('closed:Closed'); + assert.dom().containsText('canceled:Canceled'); + }); + + test('it returns work order operational categories', async function (assert) { + await render(hbs` + {{#each (get-fleet-ops-options "workOrderCategories") as |category|}} + {{category.value}}:{{category.label}}; + {{/each}} + `); - assert.dom().hasText('1234'); + assert.dom().containsText('preventive_maintenance:Preventive Maintenance (PM)'); + assert.dom().containsText('tire_issue:Tire Issue'); + assert.dom().containsText('breakdown:Breakdown'); }); }); diff --git a/tests/unit/components/telematic/form-test.js b/tests/unit/components/telematic/form-test.js new file mode 100644 index 000000000..6d26595f1 --- /dev/null +++ b/tests/unit/components/telematic/form-test.js @@ -0,0 +1,79 @@ +import { module, test } from 'qunit'; +import TelematicFormComponent from 'dummy/components/telematic/form'; + +function makeResource(initial = {}) { + return { + ...initial, + set(key, value) { + this[key] = value; + }, + setProperties(values) { + Object.assign(this, values); + }, + }; +} + +module('Unit | Component | telematic/form', function () { + test('provider credential defaults include advanced endpoint values', function (assert) { + const provider = { + required_fields: [ + { name: 'server_uri', advanced: true, is_endpoint: true, default_value: 'https://api.safee.com' }, + { name: 'client_id' }, + { name: 'password', default_value: null }, + ], + }; + + const credentials = TelematicFormComponent.prototype.buildProviderCredentials(provider); + + assert.deepEqual(credentials, { + server_uri: 'https://api.safee.com', + client_id: null, + password: null, + }); + }); + + test('editing server uri updates resource credentials', function (assert) { + const resource = makeResource({ + credentials: { + server_uri: 'https://api.safee.com', + client_id: 'api', + }, + }); + + TelematicFormComponent.prototype.setCredential.call( + { + args: { resource }, + resetConnectionTest() {}, + }, + { name: 'server_uri' }, + { target: { value: 'https://fms.example.test' } } + ); + + assert.deepEqual(resource.credentials, { + server_uri: 'https://fms.example.test', + client_id: 'api', + }); + }); + + test('connection test payload includes custom server uri', function (assert) { + const resource = makeResource({ + id: 'telematic_1', + credentials: { + server_uri: 'https://fms.example.test', + realm_id: 'dsco', + }, + }); + + const payload = TelematicFormComponent.prototype.getConnectionTestPayload.call({ + args: { resource }, + }); + + assert.deepEqual(payload, { + credentials: { + server_uri: 'https://fms.example.test', + realm_id: 'dsco', + }, + telematic_id: 'telematic_1', + }); + }); +}); diff --git a/tests/unit/controllers/connectivity/devices/index/details-test.js b/tests/unit/controllers/connectivity/devices/index/details-test.js index c4d574789..b997f3cd4 100644 --- a/tests/unit/controllers/connectivity/devices/index/details-test.js +++ b/tests/unit/controllers/connectivity/devices/index/details-test.js @@ -1,12 +1,38 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; + +class MenuServiceStub extends Service { + getMenuItems() { + return [ + { + route: 'connectivity.devices.index.details.virtual', + label: 'Custom', + }, + ]; + } +} + +class IntlServiceStub extends Service { + t(key) { + return key; + } +} module('Unit | Controller | connectivity/devices/index/details', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. - test('it exists', function (assert) { + hooks.beforeEach(function () { + this.owner.register('service:universe/menu-service', MenuServiceStub); + this.owner.register('service:intl', IntlServiceStub); + }); + + test('it uses existing translation keys and preserves registered tabs', function (assert) { let controller = this.owner.lookup('controller:connectivity/devices/index/details'); - assert.ok(controller); + + assert.deepEqual( + controller.tabs.map((tab) => tab.label), + ['common.overview', 'resource.vehicle', 'resource.sensors', 'resource.device-events', 'Custom'] + ); }); }); diff --git a/tests/unit/controllers/connectivity/devices/index/details/events-test.js b/tests/unit/controllers/connectivity/devices/index/details/events-test.js index e590c85d3..453815c6c 100644 --- a/tests/unit/controllers/connectivity/devices/index/details/events-test.js +++ b/tests/unit/controllers/connectivity/devices/index/details/events-test.js @@ -4,9 +4,24 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Controller | connectivity/devices/index/details/events', 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/details/events'); assert.ok(controller); }); + + test('it namespaces query params away from the parent devices index', function (assert) { + let controller = this.owner.lookup('controller:connectivity/devices/index/details/events'); + + assert.deepEqual(controller.queryParams, [ + 'events_page', + 'events_limit', + 'events_sort', + 'events_query', + 'events_event_type', + 'events_severity', + 'events_processed', + 'events_occurred_at', + 'events_created_at', + ]); + }); }); diff --git a/tests/unit/controllers/connectivity/devices/index/details/sensors-test.js b/tests/unit/controllers/connectivity/devices/index/details/sensors-test.js new file mode 100644 index 000000000..cb4e0a4ba --- /dev/null +++ b/tests/unit/controllers/connectivity/devices/index/details/sensors-test.js @@ -0,0 +1,17 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Controller | connectivity/devices/index/details/sensors', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup('controller:connectivity/devices/index/details/sensors'); + assert.ok(controller); + }); + + test('it namespaces query params away from the parent devices index', function (assert) { + let controller = this.owner.lookup('controller:connectivity/devices/index/details/sensors'); + + assert.deepEqual(controller.queryParams, ['sensors_page', 'sensors_limit', 'sensors_sort', 'sensors_query', 'sensors_status', 'sensors_type', 'sensors_last_reading_at']); + }); +}); diff --git a/tests/unit/controllers/connectivity/devices/index/details/vehicle-test.js b/tests/unit/controllers/connectivity/devices/index/details/vehicle-test.js new file mode 100644 index 000000000..a3e1874cd --- /dev/null +++ b/tests/unit/controllers/connectivity/devices/index/details/vehicle-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Controller | connectivity/devices/index/details/vehicle', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let controller = this.owner.lookup('controller:connectivity/devices/index/details/vehicle'); + assert.ok(controller); + }); +}); diff --git a/tests/unit/controllers/connectivity/telematics/details/attachments-test.js b/tests/unit/controllers/connectivity/telematics/details/attachments-test.js new file mode 100644 index 000000000..8af97366b --- /dev/null +++ b/tests/unit/controllers/connectivity/telematics/details/attachments-test.js @@ -0,0 +1,365 @@ +import Service from '@ember/service'; +import EmberObject from '@ember/object'; +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Controller | connectivity/telematics/details/attachments', function (hooks) { + setupTest(hooks); + + test('groups attached devices and keeps metrics based on the full loaded collection', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + + controller.model = { + meta: { total: 33, loaded: 33 }, + devices: [ + makeDevice({ id: 'device_1', attachable_uuid: null, name: 'Unattached Alpha', connection_status: 'online', is_online: true }), + makeDevice({ id: 'device_2', attachable_uuid: 'vehicle_1', attached_to_name: 'Truck 100', name: 'Attached Beta' }), + makeDevice({ id: 'device_3', attachable_uuid: 'vehicle_1', attached_to_name: 'Truck 100', name: 'Attached Gamma' }), + makeDevice({ id: 'device_4', attachable_uuid: 'vehicle_2', attached_to_name: 'Truck 200', name: 'Attached Delta' }), + ], + }; + + assert.strictEqual(controller.totalSyncedDevices, 33, 'total uses route meta'); + assert.strictEqual(controller.attachedDevicesCount, 3, 'attached count uses the full loaded set'); + assert.strictEqual(controller.unattachedDevicesCount, 1, 'unattached count uses the full loaded set'); + assert.strictEqual(controller.mappedVehiclesCount, 2, 'mapped vehicle count is de-duplicated'); + assert.strictEqual(controller.onlineDevicesCount, 1, 'online count uses the full loaded set'); + assert.deepEqual( + controller.vehicleGroups.map((group) => `${group.name}:${group.devices.length}`), + ['Truck 100:2', 'Truck 200:1'], + 'attached devices are grouped by vehicle' + ); + + controller.query = 'alpha'; + + assert.strictEqual(controller.visibleDevicesCount, 1, 'search filters visible devices'); + assert.strictEqual(controller.unattachedDevices.length, 1, 'search keeps matching unattached devices visible'); + assert.strictEqual(controller.attachedDevicesCount, 3, 'metrics still use full loaded set while filtered'); + }); + + test('local attach and detach updates move devices between panes after endpoint success', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + const device = makeDevice({ id: 'device_1', attachable_uuid: null, name: 'AFAQY 1' }); + const vehicle = { id: 'vehicle_1', displayName: 'Truck 100' }; + + controller.model = { + devices: [device], + meta: { total: 1, loaded: 1 }, + }; + + assert.strictEqual(controller.unattachedDevices.length, 1, 'device starts unattached'); + + controller.applyDeviceAttachment(device, vehicle); + + assert.strictEqual(controller.unattachedDevices.length, 0, 'attached device leaves unattached pane'); + assert.strictEqual(controller.vehicleGroups.length, 1, 'attached device creates a vehicle group'); + assert.strictEqual(controller.vehicleGroups[0].name, 'Truck 100', 'vehicle group uses selected vehicle display name'); + + controller.applyDeviceDetachment(device); + + assert.strictEqual(controller.unattachedDevices.length, 1, 'detached device returns to unattached pane'); + assert.strictEqual(controller.vehicleGroups.length, 0, 'detached device is removed from vehicle groups'); + }); + + test('selecting an unattached device marks and toggles the selected device', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + const device = makeDevice({ id: 'device_1', attachable_uuid: null, name: 'AFAQY 1' }); + + controller.selectUnattachedDevice(device); + + assert.strictEqual(controller.selectedDevice, device, 'device is selected'); + assert.strictEqual(controller.selectedDeviceName, 'AFAQY 1', 'selected device name is exposed for pane instructions'); + + controller.selectUnattachedDevice(device); + + assert.strictEqual(controller.selectedDevice, null, 'clicking selected device clears selection'); + }); + + test('vehicle filter stores the selected vehicle model and uuid', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + const vehicle = { id: 'vehicle_1', displayName: 'Truck 100' }; + + controller.updateSelectedVehicle(vehicle); + + assert.strictEqual(controller.selectedVehicle, vehicle, 'selected vehicle model is retained for the select trigger'); + assert.strictEqual(controller.vehicle, 'vehicle_1', 'vehicle query param stores the selected vehicle id'); + + controller.updateSelectedVehicle(null); + + assert.strictEqual(controller.selectedVehicle, null, 'clearing the select clears the selected model'); + assert.strictEqual(controller.vehicle, null, 'clearing the select clears the query param id'); + }); + + test('vehicle filter only filters attached vehicle groups', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + + controller.model = { + meta: { total: 3, loaded: 3 }, + devices: [ + makeDevice({ id: 'device_1', attachable_uuid: null, name: 'Unattached Alpha' }), + makeDevice({ id: 'device_2', attachable_uuid: 'vehicle_1', attached_to_name: 'Truck 100', name: 'Attached Beta' }), + makeDevice({ id: 'device_3', attachable_uuid: 'vehicle_2', attached_to_name: 'Truck 200', name: 'Attached Gamma' }), + ], + }; + + controller.vehicle = 'vehicle_2'; + + assert.strictEqual(controller.unattachedDevices.length, 1, 'vehicle filter does not remove unattached devices'); + assert.deepEqual( + controller.vehicleGroups.map((group) => group.name), + ['Truck 200'], + 'vehicle filter narrows attached vehicle groups' + ); + assert.strictEqual(controller.visibleDevicesCount, 2, 'visible count includes left pane plus the filtered right pane'); + }); + + test('filtered empty only applies when both panes have no visible rows', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + + controller.model = { + meta: { total: 2, loaded: 2 }, + devices: [ + makeDevice({ id: 'device_1', attachable_uuid: null, name: 'Unattached Alpha' }), + makeDevice({ id: 'device_2', attachable_uuid: 'vehicle_1', attached_to_name: 'Truck 100', name: 'Attached Beta' }), + ], + }; + + controller.vehicle = 'vehicle_2'; + + assert.strictEqual(controller.emptyStateVariant, null, 'vehicle-only misses do not replace the workspace while unattached rows remain visible'); + + controller.query = 'missing-device'; + + assert.strictEqual(controller.emptyStateVariant, 'filtered_empty', 'filtered empty appears when both panes have no visible rows'); + }); + + test('search filters both panes without changing full metrics', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + + controller.model = { + meta: { total: 3, loaded: 3 }, + devices: [ + makeDevice({ id: 'device_1', attachable_uuid: null, name: 'Alpha Device' }), + makeDevice({ id: 'device_2', attachable_uuid: 'vehicle_1', attached_to_name: 'Alpha Truck', name: 'Mounted Device' }), + makeDevice({ id: 'device_3', attachable_uuid: 'vehicle_2', attached_to_name: 'Beta Truck', name: 'Other Device' }), + ], + }; + + controller.query = 'alpha'; + + assert.strictEqual(controller.unattachedDevices.length, 1, 'search filters matching unattached devices'); + assert.deepEqual( + controller.vehicleGroups.map((group) => group.name), + ['Alpha Truck'], + 'search filters matching attached vehicle groups' + ); + assert.strictEqual(controller.attachedDevicesCount, 2, 'full attached metrics are not filtered'); + assert.strictEqual(controller.unattachedDevicesCount, 1, 'full unattached metrics are not filtered'); + }); + + test('status filters both panes', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + + controller.model = { + meta: { total: 4, loaded: 4 }, + devices: [ + makeDevice({ id: 'device_1', attachable_uuid: null, name: 'Unattached Online', connection_status: 'online' }), + makeDevice({ id: 'device_2', attachable_uuid: null, name: 'Unattached Offline', connection_status: 'offline' }), + makeDevice({ id: 'device_3', attachable_uuid: 'vehicle_1', attached_to_name: 'Online Truck', name: 'Attached Online', connection_status: 'online' }), + makeDevice({ id: 'device_4', attachable_uuid: 'vehicle_2', attached_to_name: 'Offline Truck', name: 'Attached Offline', connection_status: 'offline' }), + ], + }; + + controller.status = 'online'; + + assert.deepEqual( + controller.unattachedDevices.map((device) => device.name), + ['Unattached Online'], + 'status filters unattached devices' + ); + assert.deepEqual( + controller.vehicleGroups.map((group) => group.name), + ['Online Truck'], + 'status filters attached vehicle groups' + ); + }); + + test('clearing filters clears the selected vehicle model and uuid', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + const vehicle = { id: 'vehicle_1', displayName: 'Truck 100' }; + + controller.query = 'truck'; + controller.status = 'online'; + controller.vehicle = vehicle.id; + controller.selectedVehicle = vehicle; + + controller.clearFilters(); + + assert.strictEqual(controller.query, null, 'query is cleared'); + assert.strictEqual(controller.status, null, 'status is cleared'); + assert.strictEqual(controller.vehicle, null, 'vehicle query param is cleared'); + assert.strictEqual(controller.selectedVehicle, null, 'selected vehicle model is cleared'); + }); + + test('refresh toggles loading state and resets after success', async function (assert) { + assert.expect(3); + + class HostRouterStub extends Service { + refresh() { + assert.true(controller.isRefreshing, 'refresh starts loading before calling the router'); + + return Promise.resolve('refreshed'); + } + } + + this.owner.register('service:host-router', HostRouterStub); + + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + + assert.false(controller.isRefreshing, 'refresh starts idle'); + + await controller.refresh(); + + assert.false(controller.isRefreshing, 'refresh resets loading after success'); + }); + + test('refresh resets loading state after failure', async function (assert) { + assert.expect(3); + + class HostRouterStub extends Service { + refresh() { + assert.true(controller.isRefreshing, 'refresh starts loading before calling the router'); + + return Promise.reject(new Error('Refresh failed')); + } + } + + this.owner.register('service:host-router', HostRouterStub); + + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + + assert.false(controller.isRefreshing, 'refresh starts idle'); + + try { + await controller.refresh(); + } catch (error) { + assert.false(controller.isRefreshing, 'refresh resets loading after failure'); + } + }); + + test('attaching a selected device to a vehicle group moves it and clears selection', async function (assert) { + class FetchStub extends Service { + post(url, payload) { + assert.strictEqual(url, 'devices/device_1/attach', 'attach endpoint is used'); + assert.deepEqual(payload, { vehicle: 'vehicle_1' }, 'vehicle target is sent'); + + return Promise.resolve({ device: { attachable_uuid: 'vehicle_1', attached_to_name: 'Truck 100' } }); + } + } + + class NotificationsStub extends Service { + success() { + assert.step('success'); + } + } + + this.owner.register('service:fetch', FetchStub); + this.owner.register('service:notifications', NotificationsStub); + + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + const device = makeDevice({ id: 'device_1', attachable_uuid: null, name: 'AFAQY 1' }); + + controller.model = { + devices: [device], + meta: { total: 1, loaded: 1 }, + }; + controller.selectedDevice = device; + + await controller.attachSelectedDeviceToGroup({ id: 'vehicle_1', name: 'Truck 100' }); + + assert.strictEqual(controller.selectedDevice, null, 'selection is cleared after successful attach'); + assert.strictEqual(controller.unattachedDevices.length, 0, 'device leaves unattached pane'); + assert.strictEqual(controller.vehicleGroups.length, 1, 'device appears in attached vehicle groups'); + assert.verifySteps(['success']); + }); + + test('failed attach endpoint keeps the original attachment state', async function (assert) { + assert.expect(3); + const selectedVehicle = { id: 'vehicle_1', displayName: 'Truck 100' }; + + class FetchStub extends Service { + post() { + return Promise.reject(new Error('Attach failed')); + } + } + + class ModalStub extends Service { + show(_name, options) { + const modal = { + getOption(key) { + if (key === 'selectedVehicle') { + return selectedVehicle; + } + + return options[key]; + }, + startLoading() { + assert.step('startLoading'); + }, + stopLoading() { + assert.step('stopLoading'); + }, + done() { + throw new Error('modal should not complete'); + }, + }; + + return options.confirm(modal); + } + } + + class NotificationsStub extends Service { + serverError() { + assert.step('serverError'); + } + } + + this.owner.register('service:fetch', FetchStub); + this.owner.register('service:modals-manager', ModalStub); + this.owner.register('service:notifications', NotificationsStub); + + const controller = this.owner.lookup('controller:connectivity/telematics/details/attachments'); + const device = makeDevice({ id: 'device_1', attachable_uuid: null, name: 'AFAQY 1' }); + + controller.selectedDevice = device; + controller.openAttachDeviceModal(device); + await settledPromise(); + + assert.strictEqual(device.attachable_uuid, null, 'device remains unattached after failed mutation'); + assert.strictEqual(controller.selectedDevice, device, 'selected device remains selected after failed mutation'); + assert.verifySteps(['startLoading', 'serverError', 'stopLoading']); + }); +}); + +function makeDevice(attributes = {}) { + return EmberObject.create({ + id: attributes.id, + displayName: attributes.displayName ?? attributes.name, + name: attributes.name, + device_id: attributes.device_id ?? attributes.id, + serial_number: attributes.serial_number, + imei: attributes.imei, + public_id: attributes.public_id, + status: attributes.status, + connection_status: attributes.connection_status, + is_online: attributes.is_online ?? false, + attachable_uuid: attributes.attachable_uuid, + attachable_type: attributes.attachable_type, + attached_to_name: attributes.attached_to_name, + attachable: attributes.attachable, + }); +} + +function settledPromise() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} 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 03f946996..2c450a284 100644 --- a/tests/unit/controllers/connectivity/telematics/index/details/devices-test.js +++ b/tests/unit/controllers/connectivity/telematics/index/details/devices-test.js @@ -4,9 +4,69 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Controller | connectivity/telematics/index/details/devices', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. - test('it exists', function (assert) { - let controller = this.owner.lookup('controller:connectivity/telematics/index/details/devices'); + test('device tab toolbar actions use small Fleetbase controls', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/index/details/devices'); + assert.ok(controller); + assert.deepEqual( + controller.actionButtons.map((button) => button.size), + ['sm', 'sm'], + 'toolbar action buttons render at sm size' + ); + }); + + test('device filters expose vehicle and connection query contracts', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/index/details/devices'); + 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'); + + 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'); + + assert.strictEqual(connectionColumn.filterParam, 'connection_status', 'connection filter maps to computed connection status'); + assert.deepEqual( + connectionColumn.filterOptions.map((option) => option.value), + ['online', 'recently_offline', 'offline', 'long_offline', 'never_connected'], + 'connection filter options are scalar values' + ); + assert.true( + connectionColumn.filterOptions.every((option) => typeof option.label === 'string'), + 'connection labels are strings' + ); + assert.strictEqual(connectionColumn.filterOptionLabel, 'label', 'connection filter reads option labels explicitly'); + assert.strictEqual(connectionColumn.filterOptionValue, 'value', 'connection filter serializes option values explicitly'); + + assert.strictEqual(attachmentColumn.filterParam, 'attachment_state', 'attachment state remains available as its own filter'); + assert.true(attachmentColumn.hidden, 'attachment state filter does not duplicate the visible vehicle column'); + }); + + test('clearFilters resets every device table filter', function (assert) { + const controller = this.owner.lookup('controller:connectivity/telematics/index/details/devices'); + + controller.query = 'abc'; + controller.status = 'active'; + controller.provider = 'samsara'; + controller.attachment_state = 'attached'; + controller.vehicle = 'vehicle_123'; + controller.connection_status = 'online'; + controller.device_id = 'provider-1'; + controller.last_online_at = '2026-06-17'; + controller.updated_at = '2026-06-17'; + controller.page = 5; + + controller.clearFilters(); + + assert.strictEqual(controller.query, null); + assert.strictEqual(controller.status, null); + assert.strictEqual(controller.provider, null); + assert.strictEqual(controller.attachment_state, null); + assert.strictEqual(controller.vehicle, null); + assert.strictEqual(controller.connection_status, null); + assert.strictEqual(controller.device_id, null); + assert.strictEqual(controller.last_online_at, null); + assert.strictEqual(controller.updated_at, null); + assert.strictEqual(controller.page, 1); }); }); diff --git a/tests/unit/controllers/maintenance/work-orders/index-test.js b/tests/unit/controllers/maintenance/work-orders/index-test.js index 570fd327a..5690245ab 100644 --- a/tests/unit/controllers/maintenance/work-orders/index-test.js +++ b/tests/unit/controllers/maintenance/work-orders/index-test.js @@ -4,9 +4,21 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Controller | maintenance/work-orders/index', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { let controller = this.owner.lookup('controller:maintenance/work-orders/index'); assert.ok(controller); }); + + test('it exposes category as a query param and filterable column', function (assert) { + let controller = this.owner.lookup('controller:maintenance/work-orders/index'); + let categoryColumn = controller.columns.find((column) => column.valuePath === 'category'); + + assert.true(controller.queryParams.includes('category')); + assert.ok(categoryColumn); + assert.true(categoryColumn.filterable); + assert.strictEqual(categoryColumn.filterParam, 'category'); + assert.strictEqual(categoryColumn.filterComponent, 'filter/select'); + assert.strictEqual(categoryColumn.filterOptionValue, 'value'); + assert.ok(categoryColumn.filterOptions.find((option) => option.value === 'preventive_maintenance')); + }); }); diff --git a/tests/unit/routes/connectivity/devices/index/details/events-test.js b/tests/unit/routes/connectivity/devices/index/details/events-test.js index fc662329c..afe658c99 100644 --- a/tests/unit/routes/connectivity/devices/index/details/events-test.js +++ b/tests/unit/routes/connectivity/devices/index/details/events-test.js @@ -8,4 +8,45 @@ module('Unit | Route | connectivity/devices/index/details/events', function (hoo let route = this.owner.lookup('route:connectivity/devices/index/details/events'); assert.ok(route); }); + + test('it maps namespaced query params to device-event API params', async function (assert) { + let route = this.owner.lookup('route:connectivity/devices/index/details/events'); + let query; + + route.modelFor = () => ({ id: 'device-1' }); + route.store = { + query(modelName, params) { + query = { modelName, params }; + return []; + }, + }; + + await route.model({ + events_page: 3, + events_limit: 50, + events_sort: '-created_at', + events_query: 'fault', + events_event_type: 'diagnostic', + events_severity: 'warning', + events_processed: 'unprocessed', + events_occurred_at: '2026-06-18', + events_created_at: '2026-06-17', + }); + + assert.deepEqual(query, { + modelName: 'device-event', + params: { + page: 3, + limit: 50, + sort: '-created_at', + query: 'fault', + event_type: 'diagnostic', + severity: 'warning', + processed: 'unprocessed', + occurred_at: '2026-06-18', + created_at: '2026-06-17', + device_uuid: 'device-1', + }, + }); + }); }); diff --git a/tests/unit/routes/connectivity/devices/index/details/sensors-test.js b/tests/unit/routes/connectivity/devices/index/details/sensors-test.js new file mode 100644 index 000000000..f0e297465 --- /dev/null +++ b/tests/unit/routes/connectivity/devices/index/details/sensors-test.js @@ -0,0 +1,48 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Route | connectivity/devices/index/details/sensors', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup('route:connectivity/devices/index/details/sensors'); + assert.ok(route); + }); + + test('it maps namespaced query params to sensor API params', async function (assert) { + let route = this.owner.lookup('route:connectivity/devices/index/details/sensors'); + let query; + + route.modelFor = () => ({ id: 'device-1' }); + route.store = { + query(modelName, params) { + query = { modelName, params }; + return []; + }, + }; + + await route.model({ + sensors_page: 2, + sensors_limit: 25, + sensors_sort: '-updated_at', + sensors_query: 'temperature', + sensors_status: 'active', + sensors_type: 'temperature', + sensors_last_reading_at: '2026-06-18', + }); + + assert.deepEqual(query, { + modelName: 'sensor', + params: { + page: 2, + limit: 25, + sort: '-updated_at', + query: 'temperature', + status: 'active', + type: 'temperature', + last_reading_at: '2026-06-18', + device_uuid: 'device-1', + }, + }); + }); +}); diff --git a/tests/unit/routes/connectivity/devices/index/details/vehicle-test.js b/tests/unit/routes/connectivity/devices/index/details/vehicle-test.js new file mode 100644 index 000000000..75bd21ef0 --- /dev/null +++ b/tests/unit/routes/connectivity/devices/index/details/vehicle-test.js @@ -0,0 +1,11 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Route | connectivity/devices/index/details/vehicle', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + let route = this.owner.lookup('route:connectivity/devices/index/details/vehicle'); + assert.ok(route); + }); +}); diff --git a/tests/unit/routes/connectivity/telematics/details/attachments-test.js b/tests/unit/routes/connectivity/telematics/details/attachments-test.js new file mode 100644 index 000000000..e3d145963 --- /dev/null +++ b/tests/unit/routes/connectivity/telematics/details/attachments-test.js @@ -0,0 +1,137 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Route | connectivity/telematics/details/attachments', function (hooks) { + setupTest(hooks); + + test('loads all device pages for the selected telematic connection', async function (assert) { + assert.expect(7); + + class StoreStub extends Service { + queries = []; + + query(modelName, params) { + this.queries.push({ modelName, params }); + + if (params.page === 1) { + return makePage(100, { total: 233, current_page: 1, last_page: 3, per_page: 100 }, 'page-1'); + } + + if (params.page === 2) { + return makePage(100, { total: 233, current_page: 2, last_page: 3, per_page: 100 }, 'page-2'); + } + + return makePage(33, { total: 233, current_page: 3, last_page: 3, per_page: 100 }, 'page-3'); + } + + findRecord() { + throw new Error('vehicle should not be loaded without a filter'); + } + } + + this.owner.register('service:store', StoreStub); + + const route = this.owner.lookup('route:connectivity/telematics/details/attachments'); + const store = this.owner.lookup('service:store'); + + route.modelFor = () => ({ id: 'telematic_1' }); + + const model = await route.model({ sort: '-updated_at' }); + + assert.strictEqual(model.devices.length, 233, 'all pages are flattened into one device list'); + assert.strictEqual(model.meta.total, 233, 'total is preserved from pagination meta'); + assert.strictEqual(model.meta.loaded, 233, 'loaded count reflects the flattened list'); + assert.strictEqual(store.queries.length, 3, 'route queries until the final page'); + assert.true( + store.queries.every((query) => query.modelName === 'device'), + 'route only queries devices' + ); + assert.true( + store.queries.every((query) => query.params.telematic_uuid === 'telematic_1'), + 'every page is scoped to the telematic' + ); + assert.deepEqual( + store.queries.map((query) => query.params.page), + [1, 2, 3], + 'pages are requested in order' + ); + }); + + test('does not declare a mapping-state query param', function (assert) { + const route = this.owner.lookup('route:connectivity/telematics/details/attachments'); + + assert.notOk(route.queryParams.attachment_state, 'mapping-state query param is not part of the attachments workspace'); + }); + + test('hydrates selected vehicle from the vehicle query param', async function (assert) { + assert.expect(6); + const selectedVehicle = { id: 'vehicle_1', displayName: 'Truck 100' }; + + class StoreStub extends Service { + findRecord(modelName, id) { + assert.strictEqual(modelName, 'vehicle', 'vehicle model is loaded'); + assert.strictEqual(id, 'vehicle_1', 'selected vehicle id is loaded from the query param'); + + return Promise.resolve(selectedVehicle); + } + + query(modelName, params) { + assert.strictEqual(modelName, 'device', 'devices are still loaded'); + assert.strictEqual(params.telematic_uuid, 'telematic_1', 'device load is scoped to the telematic'); + + return makePage(1, { total: 1, current_page: 1, last_page: 1, per_page: 100 }, 'page-1'); + } + } + + this.owner.register('service:store', StoreStub); + + const route = this.owner.lookup('route:connectivity/telematics/details/attachments'); + + route.modelFor = () => ({ id: 'telematic_1' }); + + const model = await route.model({ sort: '-updated_at', vehicle: 'vehicle_1' }); + + assert.strictEqual(model.selectedVehicle, selectedVehicle, 'selected vehicle is exposed on the route model'); + + const controller = {}; + route.setupController(controller, model); + + assert.strictEqual(controller.selectedVehicle, selectedVehicle, 'setupController assigns selected vehicle to the controller'); + }); + + test('failed selected vehicle hydration does not fail device loading', async function (assert) { + assert.expect(3); + + class StoreStub extends Service { + findRecord() { + return Promise.reject(new Error('Vehicle missing')); + } + + query(modelName) { + assert.strictEqual(modelName, 'device', 'devices are still loaded after vehicle hydration fails'); + + return makePage(2, { total: 2, current_page: 1, last_page: 1, per_page: 100 }, 'page-1'); + } + } + + this.owner.register('service:store', StoreStub); + + const route = this.owner.lookup('route:connectivity/telematics/details/attachments'); + + route.modelFor = () => ({ id: 'telematic_1' }); + + const model = await route.model({ sort: '-updated_at', vehicle: 'missing_vehicle' }); + + assert.strictEqual(model.selectedVehicle, null, 'selected vehicle falls back to null'); + assert.strictEqual(model.devices.length, 2, 'device results are preserved'); + }); +}); + +function makePage(count, meta, prefix) { + const records = Array.from({ length: count }, (_value, index) => ({ id: `${prefix}-${index + 1}` })); + + records.meta = meta; + + return Promise.resolve(records); +} 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 149e41acf..7d1b65428 100644 --- a/tests/unit/routes/connectivity/telematics/index/details/devices-test.js +++ b/tests/unit/routes/connectivity/telematics/index/details/devices-test.js @@ -8,4 +8,13 @@ module('Unit | Route | connectivity/telematics/index/details/devices', function let route = this.owner.lookup('route:connectivity/telematics/index/details/devices'); assert.ok(route); }); + + test('device filter query params refresh the model', function (assert) { + const route = this.owner.lookup('route:connectivity/telematics/index/details/devices'); + + 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.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/driver-actions-test.js b/tests/unit/services/driver-actions-test.js index 073512a02..aafdb64dc 100644 --- a/tests/unit/services/driver-actions-test.js +++ b/tests/unit/services/driver-actions-test.js @@ -9,6 +9,52 @@ module('Unit | Service | driver-actions', function (hooks) { assert.ok(service); }); + test('assignVehicle posts only the selected vehicle and reloads the driver', async function (assert) { + const service = this.owner.lookup('service:driver-actions'); + const options = {}; + const posted = {}; + const driver = { + id: 'driver-1', + name: 'Alex Driver', + vehicle_uuid: 'vehicle-1', + license_expiry: null, + reload: async () => assert.step('driver reloaded'), + save: () => assert.ok(false, 'assignVehicle should not save the driver model'), + rollbackAttributes: () => assert.ok(false, 'assignVehicle should not roll back on success'), + }; + + service.intl = { t: (key) => key }; + service.refresh = () => assert.step('refreshed'); + service.notifications = { + serverError: () => assert.ok(false, 'unexpected call'), + success: (message) => assert.strictEqual(message, 'driver.prompts.assign-vehicle-success'), + warning: () => assert.ok(false, 'unexpected call'), + }; + service.fetch = { + post: async (url, payload) => { + posted.url = url; + posted.payload = payload; + }, + }; + service.modalsManager = { + show: (_name, modalOptions) => Object.assign(options, modalOptions), + }; + + service.assignVehicle(driver); + await options.confirm({ + startLoading: () => assert.step('modal loading'), + stopLoading: () => assert.ok(false, 'modal should not stop loading on success'), + done: () => assert.step('modal closed'), + }); + + assert.deepEqual(posted, { + url: 'drivers/driver-1/assign-vehicle', + payload: { vehicle: 'vehicle-1' }, + }); + assert.false(Object.prototype.hasOwnProperty.call(posted.payload, 'license_expiry'), 'does not post license_expiry'); + assert.verifySteps(['modal loading', 'driver reloaded', 'modal closed', 'refreshed']); + }); + test('unassignOrders loads assigned orders, highlights the current job, and posts selected orders', async function (assert) { const service = this.owner.lookup('service:driver-actions'); const options = {}; diff --git a/tests/unit/services/order-creation-test.js b/tests/unit/services/order-creation-test.js index 2932e2464..d26d19dd0 100644 --- a/tests/unit/services/order-creation-test.js +++ b/tests/unit/services/order-creation-test.js @@ -1,12 +1,28 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { SERVICE_QUOTE_REFRESH_REQUESTED } from '@fleetbase/fleetops-engine/services/order-creation'; module('Unit | Service | order-creation', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { let service = this.owner.lookup('service:order-creation'); assert.ok(service); }); + + test('requestServiceQuoteRefresh triggers an event payload', function (assert) { + assert.expect(3); + + const service = this.owner.lookup('service:order-creation'); + const order = { id: 'order-1' }; + + service.on(SERVICE_QUOTE_REFRESH_REQUESTED, (event) => { + assert.strictEqual(event.reason, 'entity.added'); + assert.strictEqual(event.order, order); + }); + + service.requestServiceQuoteRefresh('entity.added', order); + + assert.ok(true, 'event was triggered'); + }); }); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 8239318e8..bd27d73a3 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -668,6 +668,7 @@ order: invalid-coordinates: 'Invalid coordinates!' items-drop: Items drop at loading-message: Loading service quotes... + updating-service-quotes-message: Updating service quotes... notes-placeholder: Enter order notes here.... notes-title: Notes optimize-route: Optimize Route