diff --git a/RELEASE.md b/RELEASE.md index af8b70063..820f96c41 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,15 +1,20 @@ -> v0.6.65 ~ "First-class trailer operations" +> v0.6.66 ~ "Inspections" --- ## What's New -- **Trailers become a first-class Fleet-Ops resource.** Create and manage trailers independently, attach them to vehicles, connect equipment and telematics devices, and review operational, maintenance, location, and towing details from dedicated Trailer screens. -- **Vehicle and equipment relationships are easier to understand.** Vehicle details expose attached trailers and equipment, while Trailer details show the current towing vehicle and installed equipment. -- **Trailer APIs support complete integrations.** Internal and public endpoints cover trailer lifecycle, filtering, towing connections, equipment, devices, telemetry, imports, and exports. +- **Inspection forms are built from typed fields.** A form is groups of fields laid out on a grid: pass/fail checks with severity and on-fail settings, text, numbers, selections, dates, photo uploads and signatures. Forms are built and published from a form builder in the console. +- **One inspection sheet everywhere.** The same sheet is used to fill in an inspection in the console, to read one back, and on a public link. A failed check opens its severity, unsafe flag, comment and photos in place, and a defects tray summarises what needs attention. It works on phones and tablets. +- **Drivers file inspections through the API.** The `v1` inspection endpoints list published forms, file an inspection against one, and read back submissions and a vehicle's history, for the Navigator app and other integrations. +- **Inspection links for anyone in the organisation.** A link can be assigned to any user, protected by a six-digit PIN, and emailed or texted to them. A failed inspection can raise an issue and open a work order, and every submission records who filed it. + +--- +## Fixes +- Place, zone and service area details, and the place and point map modals, no longer fail to render with "A resolved helper cannot be passed as a named argument". --- ## Testing -- Added frontend, backend, API-contract, attachment, telematics, spatial-data, permission, and serialization coverage for first-class trailers. -- Expanded the Fleetbase Postman collection and documentation alongside the Fleet-Ops implementation. +- Model and controller contract tests cover inspection forms, submissions, links and the PIN lockout. +- The Fleetbase Postman collection documents the `v1` inspection endpoints. --- ## Need help? diff --git a/addon/components/ai/create-order-preview.hbs b/addon/components/ai/create-order-preview.hbs index edcba9b17..9126e3bca 100644 --- a/addon/components/ai/create-order-preview.hbs +++ b/addon/components/ai/create-order-preview.hbs @@ -46,6 +46,7 @@ {{#if this.isEditingOrderType}} - {{or model.name model.key}} + {{else}} {{this.orderTypeLabel}} @@ -105,6 +106,7 @@ {{#if this.isEditingPickup}} -
{{this.addressLabel place}}
+
{{else}} {{this.pickupLabel}} @@ -136,6 +138,7 @@ {{#if this.isEditingDropoff}} -
{{this.addressLabel place}}
+
{{else}} {{this.dropoffLabel}} @@ -167,6 +170,7 @@ {{#if this.isEditingDriver}} - {{driver.name}} + {{else}} {{this.driverLabel}} @@ -196,6 +200,7 @@ {{#if this.isEditingVehicle}} - {{or vehicle.display_name vehicle.name vehicle.plate_number}} + {{else}} {{this.vehicleLabel}} diff --git a/addon/components/asset-connection/pill.hbs b/addon/components/asset-connection/pill.hbs new file mode 100644 index 000000000..d6b6d4b70 --- /dev/null +++ b/addon/components/asset-connection/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/asset-connection/summary.hbs b/addon/components/asset-connection/summary.hbs new file mode 100644 index 000000000..9e10aef5c --- /dev/null +++ b/addon/components/asset-connection/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/asset/pill.hbs b/addon/components/asset/pill.hbs new file mode 100644 index 000000000..d6268ab22 --- /dev/null +++ b/addon/components/asset/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/asset/summary.hbs b/addon/components/asset/summary.hbs new file mode 100644 index 000000000..2019fc35b --- /dev/null +++ b/addon/components/asset/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/attachable/pill.hbs b/addon/components/attachable/pill.hbs new file mode 100644 index 000000000..3a05a6edf --- /dev/null +++ b/addon/components/attachable/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/attachable/summary.hbs b/addon/components/attachable/summary.hbs new file mode 100644 index 000000000..7a4478d57 --- /dev/null +++ b/addon/components/attachable/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/asset-connection-identity.hbs b/addon/components/cell/asset-connection-identity.hbs new file mode 100644 index 000000000..c7874d58e --- /dev/null +++ b/addon/components/cell/asset-connection-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/asset-identity.hbs b/addon/components/cell/asset-identity.hbs new file mode 100644 index 000000000..372abe816 --- /dev/null +++ b/addon/components/cell/asset-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/attachable-identity.hbs b/addon/components/cell/attachable-identity.hbs new file mode 100644 index 000000000..ddf6137e8 --- /dev/null +++ b/addon/components/cell/attachable-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/attached-vehicle.hbs b/addon/components/cell/attached-vehicle.hbs deleted file mode 100644 index a79290f88..000000000 --- a/addon/components/cell/attached-vehicle.hbs +++ /dev/null @@ -1,10 +0,0 @@ -
- {{#if this.hasVehicle}} - - {{else}} -
- - {{t "trailer.attachment.detached"}} -
- {{/if}} -
diff --git a/addon/components/cell/attached-vehicle.js b/addon/components/cell/attached-vehicle.js deleted file mode 100644 index b34175b44..000000000 --- a/addon/components/cell/attached-vehicle.js +++ /dev/null @@ -1,58 +0,0 @@ -import Component from '@glimmer/component'; -import { action } from '@ember/object'; - -export default class CellAttachedVehicleComponent extends Component { - get device() { - return this.args.row; - } - - get vehicle() { - return this.device?.attachable; - } - - get vehicleResource() { - return ( - this.vehicle ?? { - id: this.device?.attachable_uuid, - displayName: this.device?.attached_to_name, - display_name: this.device?.attached_to_name, - name: this.device?.attached_to_name, - public_id: this.device?.attachable_uuid, - } - ); - } - - get identityColumn() { - return { - ...(this.args.column ?? {}), - action: null, - showStatusBadge: false, - }; - } - - get hasVehicle() { - return Boolean(this.device?.attachable_uuid && this.isSupportedAttachment); - } - - get isSupportedAttachment() { - const attachableType = `${this.device?.attachable_type ?? ''}`.toLowerCase(); - - return !attachableType || attachableType.includes('vehicle') || attachableType.includes('trailer'); - } - - @action onClick(_vehicle, event) { - const { column, onClick } = this.args; - - if (!this.hasVehicle) { - return; - } - - if (typeof onClick === 'function') { - onClick(this.device, event); - } - - if (typeof column?.action === 'function') { - column.action(this.device, event); - } - } -} diff --git a/addon/components/cell/contact-identity.hbs b/addon/components/cell/contact-identity.hbs new file mode 100644 index 000000000..853719f46 --- /dev/null +++ b/addon/components/cell/contact-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/customer-identity.hbs b/addon/components/cell/customer-identity.hbs new file mode 100644 index 000000000..2a25fec37 --- /dev/null +++ b/addon/components/cell/customer-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/device-event-identity.hbs b/addon/components/cell/device-event-identity.hbs new file mode 100644 index 000000000..2e973e81f --- /dev/null +++ b/addon/components/cell/device-event-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/device-identity.hbs b/addon/components/cell/device-identity.hbs index ddcf5a921..70c585d30 100644 --- a/addon/components/cell/device-identity.hbs +++ b/addon/components/cell/device-identity.hbs @@ -1,24 +1 @@ -{{#if this.resource}} - {{#if this.compact}} - - {{else}} - - {{/if}} -{{else}} -
{{this.emptyText}}
-{{/if}} + diff --git a/addon/components/cell/device-identity.js b/addon/components/cell/device-identity.js deleted file mode 100644 index 4e97068fb..000000000 --- a/addon/components/cell/device-identity.js +++ /dev/null @@ -1,129 +0,0 @@ -import Component from '@glimmer/component'; -import { action, get } from '@ember/object'; -import config from 'ember-get-config'; -import { resolveIdentityCellResource } from '../../utils/identity-cell-resource'; - -const DEFAULT_STATUS_TONES = { - online: 'text-green-500', - active: 'text-green-500', - recently_offline: 'text-yellow-500', - offline: 'text-gray-400', - long_offline: 'text-gray-400', - never_connected: 'text-gray-400', - inactive: 'text-gray-400', - error: 'text-red-500', -}; - -export default class CellDeviceIdentityComponent extends Component { - get resource() { - return resolveIdentityCellResource(this.args); - } - - get emptyText() { - return this.args.column?.emptyText ?? '-'; - } - - get showStatus() { - return this.args.column?.showStatus ?? true; - } - - get compact() { - return this.args.column?.compact ?? false; - } - - get label() { - const device = this.resource; - - return get(device, 'displayName') ?? get(device, 'display_name') ?? get(device, 'name') ?? get(device, 'device_id') ?? get(device, 'imei') ?? get(device, 'serial_number'); - } - - get mediaUrl() { - return get(this.resource, 'photo_url'); - } - - get fallbackImage() { - return config?.defaultValues?.placeholderImage; - } - - get hasCompactStatusDot() { - return this.args.column?.showStatusDot ?? this.args.column?.showOnlineIndicator ?? true; - } - - get compactStatusValue() { - const device = this.resource; - - return get(device, 'is_online') ?? get(device, 'connection_status') ?? get(device, 'status'); - } - - get compactStatusToneClass() { - const value = this.compactStatusValue; - const statusToneMap = { - ...DEFAULT_STATUS_TONES, - ...(this.args.column?.statusToneMap ?? {}), - }; - - if (typeof this.args.column?.statusToneClass === 'function') { - return this.args.column.statusToneClass(value, this.resource, this.args.column); - } - - if (typeof value === 'boolean') { - return value ? 'text-green-500' : 'text-yellow-200'; - } - - return statusToneMap[value] ?? statusToneMap[String(value ?? '').toLowerCase()] ?? 'text-gray-400'; - } - - get compactStatusDotClass() { - return this.compactStatusToneClass; - } - - get column() { - return { - ...(this.args.column ?? {}), - labelPath: (device) => - get(device, 'displayName') ?? get(device, 'display_name') ?? get(device, 'name') ?? get(device, 'device_id') ?? get(device, 'imei') ?? get(device, 'serial_number'), - mediaPath: 'photo_url', - fallbackImage: config?.defaultValues?.placeholderImage, - statusPath: this.showStatus ? (device) => get(device, 'connection_status') ?? get(device, 'status') : undefined, - onlinePath: 'is_online', - showStatusBadge: this.showStatus ? (this.args.column?.showStatusBadge ?? true) : false, - statusBadgeSize: this.args.column?.statusBadgeSize ?? 'xxs', - statusBadgeWrapperClass: this.args.column?.statusBadgeWrapperClass ?? 'resource-identity-status-badge device-identity-status-badge', - metaPaths: [ - { - value: (device) => get(device, 'imei') ?? get(device, 'device_id') ?? get(device, 'ident') ?? get(device, 'serial_number'), - icon: 'microchip', - style: 'badge', - class: 'max-w-[12rem]', - }, - ], - statusToneMap: { - online: 'text-green-500', - active: 'text-green-500', - recently_offline: 'text-yellow-500', - offline: 'text-gray-400', - long_offline: 'text-gray-400', - never_connected: 'text-gray-400', - inactive: 'text-gray-400', - error: 'text-red-500', - }, - }; - } - - @action onClick(event) { - const { column, onClick } = this.args; - const resource = this.resource; - - if (typeof onClick === 'function') { - onClick(resource, event); - } - - if (typeof column?.onClick === 'function') { - column.onClick(resource, event); - } - - if (typeof column?.action === 'function') { - column.action(resource, event); - } - } -} diff --git a/addon/components/cell/driver-identity.hbs b/addon/components/cell/driver-identity.hbs index 0b03a862e..24624a8bd 100644 --- a/addon/components/cell/driver-identity.hbs +++ b/addon/components/cell/driver-identity.hbs @@ -1,33 +1 @@ -{{#if this.resource}} - {{#if this.compact}} - - {{else}} - - {{/if}} -{{else}} -
{{this.emptyText}}
-{{/if}} + diff --git a/addon/components/cell/driver-identity.js b/addon/components/cell/driver-identity.js deleted file mode 100644 index a58ad0509..000000000 --- a/addon/components/cell/driver-identity.js +++ /dev/null @@ -1,143 +0,0 @@ -import Component from '@glimmer/component'; -import { action, get } from '@ember/object'; -import { getPlaceholderImage, resolveResourceImage } from '../../utils/placeholder-images'; -import { resolveIdentityCellResource } from '../../utils/identity-cell-resource'; - -const DEFAULT_STATUS_TONES = { - available: 'text-green-500', - active: 'text-green-500', - on_duty: 'text-green-500', - busy: 'text-yellow-500', - assigned: 'text-yellow-500', - unavailable: 'text-gray-400', - offline: 'text-gray-400', - suspended: 'text-red-500', -}; - -export default class CellDriverIdentityComponent extends Component { - get resource() { - return resolveIdentityCellResource(this.args); - } - - get emptyText() { - return this.args.column?.emptyText ?? '-'; - } - - get compact() { - return this.args.column?.compact ?? false; - } - - get label() { - const driver = this.resource; - - return get(driver, 'name') ?? get(driver, 'displayName') ?? get(driver, 'display_name'); - } - - get mediaUrl() { - return resolveResourceImage(get(this.resource, 'photo_url'), 'driver'); - } - - get fallbackImage() { - return getPlaceholderImage('driver'); - } - - get hasCompactStatusDot() { - return this.args.column?.showStatusDot ?? this.args.column?.showOnlineIndicator ?? true; - } - - get compactStatusValue() { - const driver = this.resource; - - return get(driver, 'online') ?? get(driver, 'status'); - } - - get compactStatusToneClass() { - const value = this.compactStatusValue; - const statusToneMap = { - ...DEFAULT_STATUS_TONES, - ...(this.args.column?.statusToneMap ?? {}), - }; - - if (typeof this.args.column?.statusToneClass === 'function') { - return this.args.column.statusToneClass(value, this.resource, this.args.column); - } - - if (typeof value === 'boolean') { - return value ? 'text-green-500' : 'text-yellow-200'; - } - - return statusToneMap[value] ?? statusToneMap[String(value ?? '').toLowerCase()] ?? 'text-gray-400'; - } - - get compactStatusDotClass() { - return this.compactStatusToneClass; - } - - get assignedVehicleLabel() { - const column = this.args.column ?? {}; - const driver = this.resource; - - if (typeof column.assignedVehicleLabel === 'function') { - return column.assignedVehicleLabel(driver, this.args.row, column); - } - - if (column.assignedVehicleLabel !== undefined) { - return column.assignedVehicleLabel; - } - - if (typeof column.assignedVehiclePath === 'string') { - return get(this.args.row, column.assignedVehiclePath) ?? get(driver, column.assignedVehiclePath); - } - - return get(driver, 'vehicle_assigned.display_name') ?? get(driver, 'vehicle.display_name') ?? get(driver, 'vehicle_name'); - } - - get column() { - return { - ...(this.args.column ?? {}), - labelPath: 'name', - mediaPath: 'photo_url', - fallbackImage: getPlaceholderImage('driver'), - statusPath: 'status', - onlinePath: 'online', - showStatusBadge: this.args.column?.showStatusBadge ?? true, - statusBadgeSize: this.args.column?.statusBadgeSize ?? 'xxs', - statusBadgeWrapperClass: this.args.column?.statusBadgeWrapperClass ?? 'resource-identity-status-badge driver-identity-status-badge order-first', - metaPaths: [ - { - value: (driver) => get(driver, 'vehicle_assigned.display_name') ?? get(driver, 'vehicle.display_name') ?? get(driver, 'vehicle_name'), - icon: 'car', - style: 'badge', - class: 'max-w-[12rem]', - }, - ], - statusToneMap: { - available: 'text-green-500', - active: 'text-green-500', - on_duty: 'text-green-500', - busy: 'text-yellow-500', - assigned: 'text-yellow-500', - unavailable: 'text-gray-400', - offline: 'text-gray-400', - suspended: 'text-red-500', - }, - }; - } - - @action onClick(event) { - const { column, onClick } = this.args; - const resource = this.resource; - - if (typeof onClick === 'function') { - onClick(resource, event); - } - - if (typeof column?.onClick === 'function') { - column.onClick(resource, event); - } - - if (typeof column?.action === 'function') { - column.action(resource, event); - } - } -} diff --git a/addon/components/cell/driver-name.hbs b/addon/components/cell/driver-name.hbs deleted file mode 100644 index dda1e4b83..000000000 --- a/addon/components/cell/driver-name.hbs +++ /dev/null @@ -1,26 +0,0 @@ -{{#let (or @row.driver this.driver) as |driver|}} - {{#if driver}} - - {{else}} -
- {{t "common.no-driver-assigned"}} -
- {{/if}} -{{/let}} \ No newline at end of file diff --git a/addon/components/cell/driver-name.js b/addon/components/cell/driver-name.js deleted file mode 100644 index 403b47113..000000000 --- a/addon/components/cell/driver-name.js +++ /dev/null @@ -1,30 +0,0 @@ -import Component from '@glimmer/component'; -import { action, get, computed } from '@ember/object'; - -export default class CellDriverNameComponent extends Component { - @computed('args.{column.modelPath,row,row.driver_assigned}') get driver() { - const { column, row } = this.args; - - if (typeof column?.modelPath === 'string') { - return get(row, column.modelPath); - } - - return row; - } - - @action onClick(driver) { - const { row, column, onClick } = this.args; - - if (typeof onClick === 'function') { - onClick(driver, row); - } - - if (typeof column?.action === 'function') { - column.action(driver, row); - } - - if (typeof column?.onClick === 'function') { - column.onClick(driver, row); - } - } -} diff --git a/addon/components/cell/entity-identity.hbs b/addon/components/cell/entity-identity.hbs new file mode 100644 index 000000000..c09148a66 --- /dev/null +++ b/addon/components/cell/entity-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/equipment-identity.hbs b/addon/components/cell/equipment-identity.hbs index 4755c976c..d12fe4ccc 100644 --- a/addon/components/cell/equipment-identity.hbs +++ b/addon/components/cell/equipment-identity.hbs @@ -1,5 +1 @@ -{{#if this.resource}} - -{{else}} -
{{this.emptyText}}
-{{/if}} + diff --git a/addon/components/cell/equipment-identity.js b/addon/components/cell/equipment-identity.js deleted file mode 100644 index 8c1e5b6b8..000000000 --- a/addon/components/cell/equipment-identity.js +++ /dev/null @@ -1,48 +0,0 @@ -import Component from '@glimmer/component'; -import { get } from '@ember/object'; -import config from 'ember-get-config'; -import { resolveIdentityCellResource } from '../../utils/identity-cell-resource'; - -export default class CellEquipmentIdentityComponent extends Component { - get resource() { - return resolveIdentityCellResource(this.args); - } - - get emptyText() { - return this.args.column?.emptyText ?? '-'; - } - - get column() { - return { - ...(this.args.column ?? {}), - labelPath: 'name', - mediaPath: 'photo_url', - fallbackImage: config?.defaultValues?.equipmentImage ?? config?.defaultValues?.placeholderImage, - statusPath: (equipment) => (get(equipment, 'is_equipped') ? 'equipped' : (get(equipment, 'status') ?? 'unequipped')), - statusFormatter: () => null, - metaPaths: [ - { - value: (equipment) => get(equipment, 'type'), - formatter: (type) => type, - icon: 'toolbox', - style: 'badge', - }, - { - value: (equipment) => get(equipment, 'serial_number') ?? get(equipment, 'code') ?? get(equipment, 'public_id'), - icon: 'barcode', - style: 'badge', - class: 'max-w-[12rem]', - }, - ], - statusToneMap: { - active: 'text-green-500', - equipped: 'text-green-500', - available: 'text-green-500', - maintenance: 'text-yellow-500', - unequipped: 'text-gray-400', - inactive: 'text-gray-400', - retired: 'text-red-500', - }, - }; - } -} diff --git a/addon/components/cell/facilitator-identity.hbs b/addon/components/cell/facilitator-identity.hbs new file mode 100644 index 000000000..42bd4a598 --- /dev/null +++ b/addon/components/cell/facilitator-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/fleet-driver-identity.hbs b/addon/components/cell/fleet-driver-identity.hbs new file mode 100644 index 000000000..75eaee5e1 --- /dev/null +++ b/addon/components/cell/fleet-driver-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/fleet-identity.hbs b/addon/components/cell/fleet-identity.hbs new file mode 100644 index 000000000..2efe4afc3 --- /dev/null +++ b/addon/components/cell/fleet-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/fuel-provider-connection-identity.hbs b/addon/components/cell/fuel-provider-connection-identity.hbs new file mode 100644 index 000000000..c87f0d05b --- /dev/null +++ b/addon/components/cell/fuel-provider-connection-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/fuel-provider-transaction-identity.hbs b/addon/components/cell/fuel-provider-transaction-identity.hbs new file mode 100644 index 000000000..92a95a390 --- /dev/null +++ b/addon/components/cell/fuel-provider-transaction-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/fuel-report-identity.hbs b/addon/components/cell/fuel-report-identity.hbs new file mode 100644 index 000000000..b77486721 --- /dev/null +++ b/addon/components/cell/fuel-report-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/inspection-form-identity.hbs b/addon/components/cell/inspection-form-identity.hbs new file mode 100644 index 000000000..6bd1fff04 --- /dev/null +++ b/addon/components/cell/inspection-form-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/inspection-item-result-identity.hbs b/addon/components/cell/inspection-item-result-identity.hbs new file mode 100644 index 000000000..f4fedb5f0 --- /dev/null +++ b/addon/components/cell/inspection-item-result-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/inspection-submission-identity.hbs b/addon/components/cell/inspection-submission-identity.hbs new file mode 100644 index 000000000..9fee9cc20 --- /dev/null +++ b/addon/components/cell/inspection-submission-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/integrated-vendor-identity.hbs b/addon/components/cell/integrated-vendor-identity.hbs new file mode 100644 index 000000000..6662cd343 --- /dev/null +++ b/addon/components/cell/integrated-vendor-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/issue-identity.hbs b/addon/components/cell/issue-identity.hbs new file mode 100644 index 000000000..6f0922910 --- /dev/null +++ b/addon/components/cell/issue-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/maintenance-identity.hbs b/addon/components/cell/maintenance-identity.hbs new file mode 100644 index 000000000..c84fbfc81 --- /dev/null +++ b/addon/components/cell/maintenance-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/maintenance-schedule-identity.hbs b/addon/components/cell/maintenance-schedule-identity.hbs new file mode 100644 index 000000000..264438f7a --- /dev/null +++ b/addon/components/cell/maintenance-schedule-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/maintenance-subject-identity.hbs b/addon/components/cell/maintenance-subject-identity.hbs new file mode 100644 index 000000000..b85f50ed6 --- /dev/null +++ b/addon/components/cell/maintenance-subject-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/manifest-identity.hbs b/addon/components/cell/manifest-identity.hbs new file mode 100644 index 000000000..919af9277 --- /dev/null +++ b/addon/components/cell/manifest-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/manifest-stop-identity.hbs b/addon/components/cell/manifest-stop-identity.hbs new file mode 100644 index 000000000..a52f39401 --- /dev/null +++ b/addon/components/cell/manifest-stop-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/order-config-identity.hbs b/addon/components/cell/order-config-identity.hbs new file mode 100644 index 000000000..fed63f4f0 --- /dev/null +++ b/addon/components/cell/order-config-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/order-identity.hbs b/addon/components/cell/order-identity.hbs new file mode 100644 index 000000000..f6e6ddebb --- /dev/null +++ b/addon/components/cell/order-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/part-identity.hbs b/addon/components/cell/part-identity.hbs index 4755c976c..ebe467fcf 100644 --- a/addon/components/cell/part-identity.hbs +++ b/addon/components/cell/part-identity.hbs @@ -1,5 +1 @@ -{{#if this.resource}} - -{{else}} -
{{this.emptyText}}
-{{/if}} + diff --git a/addon/components/cell/part-identity.js b/addon/components/cell/part-identity.js deleted file mode 100644 index e0ff3de1d..000000000 --- a/addon/components/cell/part-identity.js +++ /dev/null @@ -1,63 +0,0 @@ -import Component from '@glimmer/component'; -import { get } from '@ember/object'; -import config from 'ember-get-config'; -import { resolveIdentityCellResource } from '../../utils/identity-cell-resource'; - -function inventoryStatus(part) { - if (get(part, 'is_low_stock')) { - return 'low_stock'; - } - - if (get(part, 'is_in_stock')) { - return 'in_stock'; - } - - return 'out_of_stock'; -} - -function inventoryStatusLabel(part) { - return inventoryStatus(part) - .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -} - -export default class CellPartIdentityComponent extends Component { - get resource() { - return resolveIdentityCellResource(this.args); - } - - get emptyText() { - return this.args.column?.emptyText ?? '-'; - } - - get column() { - return { - ...(this.args.column ?? {}), - labelPath: 'name', - mediaPath: 'photo_url', - fallbackImage: config?.defaultValues?.partImage ?? config?.defaultValues?.placeholderImage, - statusPath: inventoryStatus, - statusFormatter: () => null, - metaPaths: [ - { - value: (part) => get(part, 'type'), - icon: 'tag', - style: 'badge', - }, - { - value: inventoryStatusLabel, - icon: 'boxes-stacked', - style: 'badge', - }, - ], - statusToneMap: { - in_stock: 'text-green-500', - low_stock: 'text-yellow-500', - out_of_stock: 'text-red-500', - active: 'text-green-500', - inactive: 'text-gray-400', - }, - }; - } -} diff --git a/addon/components/cell/payload-identity.hbs b/addon/components/cell/payload-identity.hbs new file mode 100644 index 000000000..8d79065cd --- /dev/null +++ b/addon/components/cell/payload-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/place-identity.hbs b/addon/components/cell/place-identity.hbs new file mode 100644 index 000000000..eea619a5e --- /dev/null +++ b/addon/components/cell/place-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/position-identity.hbs b/addon/components/cell/position-identity.hbs new file mode 100644 index 000000000..84563e1f9 --- /dev/null +++ b/addon/components/cell/position-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/purchase-rate-identity.hbs b/addon/components/cell/purchase-rate-identity.hbs new file mode 100644 index 000000000..783e91189 --- /dev/null +++ b/addon/components/cell/purchase-rate-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/recurring-order-schedule-identity.hbs b/addon/components/cell/recurring-order-schedule-identity.hbs new file mode 100644 index 000000000..8ac413870 --- /dev/null +++ b/addon/components/cell/recurring-order-schedule-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/resource-list.hbs b/addon/components/cell/resource-list.hbs new file mode 100644 index 000000000..4e87484e9 --- /dev/null +++ b/addon/components/cell/resource-list.hbs @@ -0,0 +1,10 @@ +{{! A column whose value is a list of related records: one compact pill per record. }} +{{#if this.items.length}} +
+ {{#each this.items as |item|}} + + {{/each}} +
+{{else}} +
{{or @column.emptyText "-"}}
+{{/if}} diff --git a/addon/components/cell/resource-list.js b/addon/components/cell/resource-list.js new file mode 100644 index 000000000..5679971b3 --- /dev/null +++ b/addon/components/cell/resource-list.js @@ -0,0 +1,28 @@ +import Component from '@glimmer/component'; +import { get } from '@ember/object'; + +/** + * Renders a has-many (or any array-like value) as a row of pills. Reads the + * column's `resourcePath` when given, otherwise the cell value, otherwise the + * row attribute named by `valuePath`; proxies and ManyArrays are unwrapped. + */ +export default class CellResourceListComponent extends Component { + get items() { + const { column = {}, row, value } = this.args; + let list = typeof column.resourcePath === 'function' ? column.resourcePath(row, value, column) : (value ?? (column.valuePath ? get(row ?? {}, column.valuePath) : null)); + + if (list && typeof list === 'object' && 'content' in list && typeof list.then === 'function') { + list = list.content; + } + + if (!list) { + return []; + } + + if (typeof list.slice === 'function') { + return Array.from(list.slice()).filter(Boolean); + } + + return Array.isArray(list) ? list.filter(Boolean) : [list]; + } +} diff --git a/addon/components/cell/route-identity.hbs b/addon/components/cell/route-identity.hbs new file mode 100644 index 000000000..b0bad9b70 --- /dev/null +++ b/addon/components/cell/route-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/sensor-identity.hbs b/addon/components/cell/sensor-identity.hbs new file mode 100644 index 000000000..1f082a952 --- /dev/null +++ b/addon/components/cell/sensor-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/service-area-identity.hbs b/addon/components/cell/service-area-identity.hbs new file mode 100644 index 000000000..d40376510 --- /dev/null +++ b/addon/components/cell/service-area-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/service-quote-identity.hbs b/addon/components/cell/service-quote-identity.hbs new file mode 100644 index 000000000..1a5deaa54 --- /dev/null +++ b/addon/components/cell/service-quote-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/service-quote-item-identity.hbs b/addon/components/cell/service-quote-item-identity.hbs new file mode 100644 index 000000000..92329c943 --- /dev/null +++ b/addon/components/cell/service-quote-item-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/service-rate-fee-identity.hbs b/addon/components/cell/service-rate-fee-identity.hbs new file mode 100644 index 000000000..f240cdddf --- /dev/null +++ b/addon/components/cell/service-rate-fee-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/service-rate-identity.hbs b/addon/components/cell/service-rate-identity.hbs new file mode 100644 index 000000000..a90f6493d --- /dev/null +++ b/addon/components/cell/service-rate-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/service-rate-parcel-fee-identity.hbs b/addon/components/cell/service-rate-parcel-fee-identity.hbs new file mode 100644 index 000000000..abd81e23e --- /dev/null +++ b/addon/components/cell/service-rate-parcel-fee-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/telematic-device.hbs b/addon/components/cell/telematic-device.hbs deleted file mode 100644 index 2497b1f57..000000000 --- a/addon/components/cell/telematic-device.hbs +++ /dev/null @@ -1,38 +0,0 @@ -
- -
diff --git a/addon/components/cell/telematic-device.js b/addon/components/cell/telematic-device.js deleted file mode 100644 index 1627ab284..000000000 --- a/addon/components/cell/telematic-device.js +++ /dev/null @@ -1,44 +0,0 @@ -import Component from '@glimmer/component'; -import { action } from '@ember/object'; - -export default class CellTelematicDeviceComponent extends Component { - get device() { - return this.args.row; - } - - get name() { - return this.device?.displayName ?? this.device?.display_name ?? this.device?.name ?? this.device?.device_id ?? this.device?.imei ?? this.device?.serial_number; - } - - get identifier() { - return this.device?.imei ?? this.device?.device_id ?? this.device?.internal_id ?? this.device?.serial_number ?? this.device?.public_id; - } - - get imageUrl() { - return this.device?.photo_url; - } - - get connectionStatus() { - return this.device?.connection_status ?? (this.device?.is_online ? 'online' : 'offline'); - } - - get isOnline() { - return this.device?.is_online || this.connectionStatus === 'online'; - } - - @action onClick(event) { - const { column, onClick } = this.args; - - if (typeof onClick === 'function') { - onClick(this.device, event); - } - - if (typeof column?.action === 'function') { - column.action(this.device, event); - } - - if (typeof column?.onClick === 'function') { - column.onClick(this.device, event); - } - } -} diff --git a/addon/components/cell/telematic-identity.hbs b/addon/components/cell/telematic-identity.hbs new file mode 100644 index 000000000..3758ded4a --- /dev/null +++ b/addon/components/cell/telematic-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/telematic-provider.hbs b/addon/components/cell/telematic-provider.hbs deleted file mode 100644 index 462f0610f..000000000 --- a/addon/components/cell/telematic-provider.hbs +++ /dev/null @@ -1,33 +0,0 @@ -{{#if this.telematic}} - {{#if this.compact}} - - {{else}} - - {{/if}} -{{else}} -
{{this.emptyText}}
-{{/if}} diff --git a/addon/components/cell/telematic-provider.js b/addon/components/cell/telematic-provider.js deleted file mode 100644 index 3d6213a36..000000000 --- a/addon/components/cell/telematic-provider.js +++ /dev/null @@ -1,78 +0,0 @@ -import Component from '@glimmer/component'; -import { action, get } from '@ember/object'; - -const DEFAULT_PROVIDER_ICON = '/engines-dist/images/telematics/providers/default.webp'; - -export default class CellTelematicProviderComponent extends Component { - get telematic() { - const resourcePath = this.args.column?.resourcePath; - - if (typeof resourcePath === 'function') { - const resource = resourcePath(this.args.row, this.args.value, this.args.column); - - return resource ?? null; - } - - if (typeof resourcePath === 'string') { - const resource = get(this.args.row, resourcePath); - - return resource ?? null; - } - - if (this.args.row?.telematic) { - return this.args.row.telematic; - } - - if (this.args.row?.telematic_uuid || this.args.row?.telematic_name) { - return { - id: this.args.row.telematic_uuid, - name: this.args.row.telematic_name, - provider: this.args.row.provider, - provider_descriptor: this.args.row.provider_descriptor, - }; - } - - return this.args.row; - } - - get emptyText() { - return this.args.column?.emptyText ?? '-'; - } - - get compact() { - return this.args.column?.compact ?? false; - } - - get descriptor() { - return this.telematic?.provider_descriptor ?? this.args.row?.provider_descriptor ?? {}; - } - - get name() { - return this.telematic?.name ?? this.descriptor.label ?? this.telematic?.provider ?? this.args.row?.provider ?? this.args.row?.telematic_name; - } - - get description() { - return this.descriptor.description ?? this.telematic?.provider ?? this.args.row?.provider; - } - - get icon() { - return this.descriptor.icon ?? DEFAULT_PROVIDER_ICON; - } - - @action onClick(event) { - const { row, column, onClick } = this.args; - const resource = this.telematic ?? row; - - if (typeof onClick === 'function') { - onClick(resource, event); - } - - if (typeof column?.action === 'function') { - column.action(resource, event); - } - - if (typeof column?.onClick === 'function') { - column.onClick(resource, event); - } - } -} diff --git a/addon/components/cell/tracking-number-identity.hbs b/addon/components/cell/tracking-number-identity.hbs new file mode 100644 index 000000000..5865f046e --- /dev/null +++ b/addon/components/cell/tracking-number-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/tracking-status-identity.hbs b/addon/components/cell/tracking-status-identity.hbs new file mode 100644 index 000000000..e0e8c20c9 --- /dev/null +++ b/addon/components/cell/tracking-status-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/trailer-identity.hbs b/addon/components/cell/trailer-identity.hbs new file mode 100644 index 000000000..ce7ff45bc --- /dev/null +++ b/addon/components/cell/trailer-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/vehicle-device-identity.hbs b/addon/components/cell/vehicle-device-identity.hbs new file mode 100644 index 000000000..be47b1b8e --- /dev/null +++ b/addon/components/cell/vehicle-device-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/vehicle-identity.hbs b/addon/components/cell/vehicle-identity.hbs index 6abea09e9..3374e7e79 100644 --- a/addon/components/cell/vehicle-identity.hbs +++ b/addon/components/cell/vehicle-identity.hbs @@ -1,33 +1 @@ -{{#if this.resource}} - {{#if this.compact}} - - {{else}} - - {{/if}} -{{else}} -
{{this.emptyText}}
-{{/if}} + diff --git a/addon/components/cell/vehicle-identity.js b/addon/components/cell/vehicle-identity.js deleted file mode 100644 index f1724abeb..000000000 --- a/addon/components/cell/vehicle-identity.js +++ /dev/null @@ -1,138 +0,0 @@ -import Component from '@glimmer/component'; -import { action, get } from '@ember/object'; -import config from 'ember-get-config'; -import { resolveIdentityCellResource } from '../../utils/identity-cell-resource'; - -const DEFAULT_STATUS_TONES = { - available: 'text-green-500', - active: 'text-green-500', - in_service: 'text-green-500', - maintenance: 'text-yellow-500', - unavailable: 'text-gray-400', - inactive: 'text-gray-400', - out_of_service: 'text-red-500', -}; - -export default class CellVehicleIdentityComponent extends Component { - get resource() { - return resolveIdentityCellResource(this.args); - } - - get emptyText() { - return this.args.column?.emptyText ?? '-'; - } - - get compact() { - return this.args.column?.compact ?? false; - } - - get showStatus() { - return this.args.column?.showStatus ?? true; - } - - get label() { - const vehicle = this.resource; - - return get(vehicle, 'displayName') ?? get(vehicle, 'display_name') ?? get(vehicle, 'name'); - } - - get mediaUrl() { - return get(this.resource, 'photo_url'); - } - - get fallbackImage() { - return config?.defaultValues?.vehicleAvatar; - } - - get hasCompactStatusDot() { - return this.args.column?.showStatusDot ?? this.args.column?.showOnlineIndicator ?? true; - } - - get compactStatusValue() { - const vehicle = this.resource; - - return get(vehicle, 'online') ?? get(vehicle, 'status'); - } - - get compactStatusToneClass() { - const value = this.compactStatusValue; - const statusToneMap = { - ...DEFAULT_STATUS_TONES, - ...(this.args.column?.statusToneMap ?? {}), - }; - - if (typeof this.args.column?.statusToneClass === 'function') { - return this.args.column.statusToneClass(value, this.resource, this.args.column); - } - - if (typeof value === 'boolean') { - return value ? 'text-green-500' : 'text-yellow-200'; - } - - return statusToneMap[value] ?? statusToneMap[String(value ?? '').toLowerCase()] ?? 'text-gray-400'; - } - - get compactStatusDotClass() { - return this.compactStatusToneClass; - } - - get assignedDriverLabel() { - const vehicle = this.resource; - - return get(vehicle, 'driver.displayName') ?? get(vehicle, 'driver.display_name') ?? get(vehicle, 'driver.name') ?? get(vehicle, 'driver_name'); - } - - get column() { - return { - ...(this.args.column ?? {}), - labelPath: (vehicle) => get(vehicle, 'displayName') ?? get(vehicle, 'display_name') ?? get(vehicle, 'name'), - mediaPath: 'photo_url', - fallbackImage: config?.defaultValues?.vehicleAvatar, - statusPath: this.showStatus ? 'status' : undefined, - onlinePath: 'online', - showStatusBadge: this.showStatus ? (this.args.column?.showStatusBadge ?? true) : false, - statusBadgeSize: this.args.column?.statusBadgeSize ?? 'xxs', - statusBadgeWrapperClass: this.args.column?.statusBadgeWrapperClass ?? 'resource-identity-status-badge vehicle-identity-status-badge', - metaPaths: [ - { - value: (vehicle) => get(vehicle, 'plate_number') ?? get(vehicle, 'call_sign') ?? get(vehicle, 'vehicle_number') ?? get(vehicle, 'public_id'), - icon: 'id-card', - style: 'badge', - class: 'max-w-[12rem]', - }, - { - value: (vehicle) => get(vehicle, 'driver.displayName') ?? get(vehicle, 'driver.display_name') ?? get(vehicle, 'driver.name') ?? get(vehicle, 'driver_name'), - icon: 'user', - style: 'badge', - class: 'max-w-[12rem]', - }, - ], - statusToneMap: { - available: 'text-green-500', - active: 'text-green-500', - in_service: 'text-green-500', - maintenance: 'text-yellow-500', - unavailable: 'text-gray-400', - inactive: 'text-gray-400', - out_of_service: 'text-red-500', - }, - }; - } - - @action onClick(event) { - const { column, onClick } = this.args; - const resource = this.resource; - - if (typeof onClick === 'function') { - onClick(resource, event); - } - - if (typeof column?.onClick === 'function') { - column.onClick(resource, event); - } - - if (typeof column?.action === 'function') { - column.action(resource, event); - } - } -} diff --git a/addon/components/cell/vehicle-name.hbs b/addon/components/cell/vehicle-name.hbs deleted file mode 100644 index 78618eb0b..000000000 --- a/addon/components/cell/vehicle-name.hbs +++ /dev/null @@ -1,23 +0,0 @@ -
- {{this.altText}} - - - {{#if (has-block)}} - {{yield}} - {{else}} - - {{n-a @value}} - - {{/if}} - - - {{#if @column.showOnlineIndicator}} - - {{/if}} -
\ No newline at end of file diff --git a/addon/components/cell/vehicle-name.js b/addon/components/cell/vehicle-name.js deleted file mode 100644 index 6cad6218a..000000000 --- a/addon/components/cell/vehicle-name.js +++ /dev/null @@ -1,3 +0,0 @@ -import TableCellMediaNameComponent from '@fleetbase/ember-ui/components/table/cell/media-name'; - -export default class CellVehicleNameComponent extends TableCellMediaNameComponent {} diff --git a/addon/components/cell/vendor-identity.hbs b/addon/components/cell/vendor-identity.hbs new file mode 100644 index 000000000..364f72b01 --- /dev/null +++ b/addon/components/cell/vendor-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/warranty-identity.hbs b/addon/components/cell/warranty-identity.hbs new file mode 100644 index 000000000..07b5ac1ca --- /dev/null +++ b/addon/components/cell/warranty-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/waypoint-identity.hbs b/addon/components/cell/waypoint-identity.hbs new file mode 100644 index 000000000..b62999716 --- /dev/null +++ b/addon/components/cell/waypoint-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/work-order-identity.hbs b/addon/components/cell/work-order-identity.hbs new file mode 100644 index 000000000..6c1880f0e --- /dev/null +++ b/addon/components/cell/work-order-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/cell/zone-identity.hbs b/addon/components/cell/zone-identity.hbs new file mode 100644 index 000000000..7236b175e --- /dev/null +++ b/addon/components/cell/zone-identity.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/contact/form.hbs b/addon/components/contact/form.hbs index 1020c94f2..3c9bf157b 100644 --- a/addon/components/contact/form.hbs +++ b/addon/components/contact/form.hbs @@ -59,6 +59,7 @@ -
-
{{n-a model.address}}
- {{model.public_id}} -
+
diff --git a/addon/components/contact/pill.hbs b/addon/components/contact/pill.hbs new file mode 100644 index 000000000..43118e203 --- /dev/null +++ b/addon/components/contact/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/contact/summary.hbs b/addon/components/contact/summary.hbs new file mode 100644 index 000000000..b27212aac --- /dev/null +++ b/addon/components/contact/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/customer/create-order-form.hbs b/addon/components/customer/create-order-form.hbs index 8a39ea046..4ea041da4 100644 --- a/addon/components/customer/create-order-form.hbs +++ b/addon/components/customer/create-order-form.hbs @@ -74,6 +74,7 @@
- {{model.address}} + {{#if waypoint.place.hasInvalidCoordinates}}
@@ -121,6 +122,7 @@
- {{model.address}} + {{#if this.payload.pickup.hasInvalidCoordinates}}
@@ -157,6 +159,7 @@
- {{model.address}} + {{#if this.payload.dropoff.hasInvalidCoordinates}}
@@ -186,6 +189,7 @@
- {{model.address}} + {{#if this.payload.return.hasInvalidCoordinates}}
diff --git a/addon/components/customer/form.hbs b/addon/components/customer/form.hbs index 469d50fdb..94a7ef342 100644 --- a/addon/components/customer/form.hbs +++ b/addon/components/customer/form.hbs @@ -66,6 +66,7 @@
-
-
{{place-address-html model}}
- {{model.public_id}} -
+
{{#if this.showWelcomeEmailOption}} diff --git a/addon/components/customer/order-form.hbs b/addon/components/customer/order-form.hbs index cb699d5fb..bde2cedba 100644 --- a/addon/components/customer/order-form.hbs +++ b/addon/components/customer/order-form.hbs @@ -18,16 +18,14 @@ -
-
{{orderConfig.name}}
-
{{n-a orderConfig.description}}
-
+
@@ -83,6 +81,7 @@
- {{model.address}} + {{#if waypoint.place.hasInvalidCoordinates}}
@@ -130,6 +129,7 @@
- {{model.address}} + {{#if this.payload.pickup.hasInvalidCoordinates}}
@@ -166,6 +166,7 @@
- {{model.address}} + {{#if this.payload.dropoff.hasInvalidCoordinates}}
@@ -195,6 +196,7 @@
- {{model.address}} + {{#if this.payload.return.hasInvalidCoordinates}}
diff --git a/addon/components/customer/pill.hbs b/addon/components/customer/pill.hbs new file mode 100644 index 000000000..160ea62ba --- /dev/null +++ b/addon/components/customer/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/customer/summary.hbs b/addon/components/customer/summary.hbs new file mode 100644 index 000000000..6c168790d --- /dev/null +++ b/addon/components/customer/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/device-event/details.hbs b/addon/components/device-event/details.hbs index 3e658b6d9..f3ab50d1c 100644 --- a/addon/components/device-event/details.hbs +++ b/addon/components/device-event/details.hbs @@ -92,18 +92,9 @@
Device
- {{#if this.deviceRouteModel}} - - {{else}} - {{n-a this.deviceName}} - {{/if}} + {{#let (resource-relation this.resource "device") as |device|}} + + {{/let}}
@@ -129,18 +120,9 @@
Provider
- {{#if this.telematicRouteModel}} - - {{else}} - {{n-a this.providerLabel}} - {{/if}} + {{#let (resource-relation this.resource "telematic") as |telematic|}} + + {{/let}}
diff --git a/addon/components/device-event/pill.hbs b/addon/components/device-event/pill.hbs new file mode 100644 index 000000000..5ca6703c8 --- /dev/null +++ b/addon/components/device-event/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/device-event/summary.hbs b/addon/components/device-event/summary.hbs new file mode 100644 index 000000000..2bb26fb32 --- /dev/null +++ b/addon/components/device-event/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/device/details.hbs b/addon/components/device/details.hbs index e59e465dd..8c8854cd0 100644 --- a/addon/components/device/details.hbs +++ b/addon/components/device/details.hbs @@ -111,7 +111,9 @@
{{t "device.fields.warranty"}}
-
{{n-a (or @resource.warranty.name @resource.warranty_name)}}
+
+ +
{{t "device.fields.notes"}}
diff --git a/addon/components/device/form.hbs b/addon/components/device/form.hbs index 93936107c..dacb79402 100644 --- a/addon/components/device/form.hbs +++ b/addon/components/device/form.hbs @@ -4,6 +4,7 @@ -
-
-
- -
-
-
{{or model.name model.public_id}}
-
{{n-a model.provider_descriptor.label (titleize model.provider)}}
-
-
+
@@ -189,6 +181,7 @@ - {{model.policy_number}} - - - {{model.provider}} +
diff --git a/addon/components/device/panel-header.hbs b/addon/components/device/panel-header.hbs index d7ac26ab3..95090e729 100644 --- a/addon/components/device/panel-header.hbs +++ b/addon/components/device/panel-header.hbs @@ -40,8 +40,8 @@ {{/if}} {{#if this.attachedVehicle}} - - + + {{this.attachedVehicle}} {{/if}} @@ -65,4 +65,5 @@ />
+ {{#if @resource.meta.telemetry}}{{/if}}
diff --git a/addon/components/device/panel-header.js b/addon/components/device/panel-header.js index 928b55d42..759484f7f 100644 --- a/addon/components/device/panel-header.js +++ b/addon/components/device/panel-header.js @@ -37,11 +37,7 @@ export default class DevicePanelHeaderComponent extends Component { get attachedVehicle() { return ( - get(this.resource, 'attached_to_name') ?? - get(this.resource, 'attachable.displayName') ?? - get(this.resource, 'attachable.display_name') ?? - get(this.resource, 'attachable.name') ?? - get(this.resource, 'attachable_uuid') + get(this.resource, 'attached_to_name') ?? get(this.resource, 'attachable.displayName') ?? get(this.resource, 'attachable.display_name') ?? get(this.resource, 'attachable.name') ); } diff --git a/addon/components/device/panel-tabs/vehicle.hbs b/addon/components/device/panel-tabs/vehicle.hbs index b591cdf5d..3af176ba6 100644 --- a/addon/components/device/panel-tabs/vehicle.hbs +++ b/addon/components/device/panel-tabs/vehicle.hbs @@ -40,11 +40,11 @@
{{t "resource.driver"}}
-
{{n-a this.vehicleDriverName}}
+
{{t "device.attachment.attached-device"}}
-
{{n-a this.device.displayName this.device.name}}
+
{{t "device.attachment.last-seen"}}
diff --git a/addon/components/device/pill.hbs b/addon/components/device/pill.hbs index 5ff90be5f..ed8719334 100644 --- a/addon/components/device/pill.hbs +++ b/addon/components/device/pill.hbs @@ -1,16 +1,24 @@ -{{#let (or @device @resource) as |resource|}} - -{{/let}} \ No newline at end of file + + {{yield resource}} + diff --git a/addon/components/device/pill.js b/addon/components/device/pill.js deleted file mode 100644 index db944ddde..000000000 --- a/addon/components/device/pill.js +++ /dev/null @@ -1,3 +0,0 @@ -import Component from '@glimmer/component'; - -export default class DevicePillComponent extends Component {} diff --git a/addon/components/device/summary.hbs b/addon/components/device/summary.hbs new file mode 100644 index 000000000..1a454adc9 --- /dev/null +++ b/addon/components/device/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/device/telemetry.hbs b/addon/components/device/telemetry.hbs new file mode 100644 index 000000000..fe4005635 --- /dev/null +++ b/addon/components/device/telemetry.hbs @@ -0,0 +1,17 @@ +
+

{{if this.stale "Position stale" "Position current"}} · {{this.ageLabel}}

+

Device position: {{this.positionTime}}

+

Provider transmission: {{this.providerTime}}

+
Telemetry timing (UTC) +

Device: {{n-a @resource.meta.telemetry.position_at}}

+

Provider: {{n-a @resource.meta.telemetry.provider_at}}

+

Received: {{n-a @resource.meta.telemetry.received_at}}

+

Processed: {{n-a @resource.meta.telemetry.processed_at}}

+

Source: + {{n-a @resource.meta.telemetry.source}} + · Queue delay: + {{n-a @resource.meta.telemetry.queue_delay_seconds}}s · Source delay: + {{n-a @resource.meta.telemetry.source_delay_seconds}}s

+
+ {{#if this.connectionError}}

Live updates interrupted. Displaying the last received position.

{{/if}} +
\ No newline at end of file diff --git a/addon/components/device/telemetry.js b/addon/components/device/telemetry.js new file mode 100644 index 000000000..b6072630e --- /dev/null +++ b/addon/components/device/telemetry.js @@ -0,0 +1,117 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { registerDestructor } from '@ember/destroyable'; +import { task } from 'ember-concurrency'; +import { action, get } from '@ember/object'; + +export default class DeviceTelemetryComponent extends Component { + @service socket; + @tracked clock = Date.now(); + @tracked connectionError = false; + timer; + channel; + reconnect; + consumer; + + constructor() { + super(...arguments); + this.watch.perform(); + this.tick.perform(); + registerDestructor(this, () => this.stop()); + } + + stop() { + clearTimeout(this.timer); + this.consumer?.close(); + this.reconnect?.close(); + this.channel?.unsubscribe()?.catch(() => {}); + } + + @action resourceChanged() { + this.watch.cancelAll(); + this.watchReconnect.cancelAll(); + this.consumer?.close(); + this.reconnect?.close(); + this.channel?.unsubscribe()?.catch(() => {}); + this.watch.perform(); + this.reload.perform(); + } + + get telemetry() { + return this.args.resource ? (get(this.args.resource, 'meta.telemetry') ?? {}) : {}; + } + + get ageSeconds() { + const time = Date.parse(this.telemetry.position_at); + return Number.isFinite(time) ? Math.max(0, Math.floor((this.clock - time) / 1000)) : null; + } + + get stale() { + const threshold = this.telemetry.stale_after_seconds ?? (this.telemetry.ignition === true ? 120 : 600); + return this.ageSeconds === null || this.ageSeconds > threshold; + } + + get ageLabel() { + return this.ageSeconds === null ? 'No valid position' : `${this.ageSeconds}s ago`; + } + + get positionTime() { + return this.localTime(this.telemetry.position_at); + } + + get providerTime() { + return this.localTime(this.telemetry.provider_at); + } + + localTime(value) { + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toLocaleString(undefined, { timeZoneName: 'short' }) : 'Unknown'; + } + + @task *tick() { + while (true) { + this.clock = Date.now(); + yield new Promise((resolve) => { + this.timer = setTimeout(resolve, 15000); + }); + } + } + + @task({ drop: true }) *reload() { + try { + yield this.args.resource?.reload?.(); + this.connectionError = false; + } catch { + this.connectionError = true; + } + } + + @task *watch() { + const id = this.args.resource?.id ?? this.args.resource?.uuid; + if (!id) return; + try { + const socket = this.socket.instance(); + this.channel = socket.subscribe(`device.${id}`); + this.consumer = this.channel.createConsumer(); + this.reconnect = socket.listener('connect').createConsumer(); + this.watchReconnect.perform(); + if (this.channel.state !== 'subscribed') yield this.channel.subscribe(); + while (true) { + const { value, done } = yield this.consumer.next(); + if (done) break; + if (value?.event === 'device.telemetry_updated') yield this.reload.perform(); + } + } catch { + this.connectionError = true; + } + } + + @task *watchReconnect() { + while (true) { + const { done } = yield this.reconnect.next(); + if (done) break; + yield this.reload.perform(); + } + } +} diff --git a/addon/components/driver/details.hbs b/addon/components/driver/details.hbs index f4c8e004c..0df91effc 100644 --- a/addon/components/driver/details.hbs +++ b/addon/components/driver/details.hbs @@ -36,7 +36,15 @@
{{t "resource.vendor"}}
-
{{n-a @resource.vendor_name}}
+
+ +
+
+
+
{{t "resource.vehicle"}}
+
+ +
diff --git a/addon/components/driver/form.hbs b/addon/components/driver/form.hbs index aa44096ea..870936552 100644 --- a/addon/components/driver/form.hbs +++ b/addon/components/driver/form.hbs @@ -4,6 +4,7 @@ -
-
- {{model.name}} -
-
-
{{model.name}}
-
- - {{n-a model.email}} -
-
- - {{n-a model.phone}} -
-
-
+
{{#if @resource.user}} @@ -106,6 +92,7 @@ - {{model.name}} + - {{model.display_name}} + diff --git a/addon/components/driver/panel-header.hbs b/addon/components/driver/panel-header.hbs index a5e4d3999..20afd0e3c 100644 --- a/addon/components/driver/panel-header.hbs +++ b/addon/components/driver/panel-header.hbs @@ -17,14 +17,13 @@

{{@resource.name}}

- {{#if @resource.vehicle}} + {{#if this.vehicleName}}
- {{@resource.vehicle.displayName}} + {{this.vehicleName}}
{{else}}
{{t "driver.fields.no-vehicle-assigned"}} - {{!--
{{/if}}
@@ -44,4 +43,4 @@ />
-
\ No newline at end of file + diff --git a/addon/components/driver/panel-header.js b/addon/components/driver/panel-header.js index 09e74c676..558f1a7b5 100644 --- a/addon/components/driver/panel-header.js +++ b/addon/components/driver/panel-header.js @@ -1,3 +1,10 @@ import Component from '@glimmer/component'; +import relationValue from '../../utils/relation-value'; -export default class DriverPanelHeaderComponent extends Component {} +export default class DriverPanelHeaderComponent extends Component { + get vehicleName() { + const vehicle = relationValue(this.args.resource, 'vehicle'); + + return vehicle?.displayName || vehicle?.display_name || vehicle?.name || this.args.resource?.vehicle_name || vehicle?.plate_number; + } +} diff --git a/addon/components/driver/pill.hbs b/addon/components/driver/pill.hbs index 19a1176c2..cefeed497 100644 --- a/addon/components/driver/pill.hbs +++ b/addon/components/driver/pill.hbs @@ -1,16 +1,24 @@ - \ No newline at end of file + as |resource| +> + {{yield resource}} + diff --git a/addon/components/driver/pill.js b/addon/components/driver/pill.js deleted file mode 100644 index f1bda68bb..000000000 --- a/addon/components/driver/pill.js +++ /dev/null @@ -1,7 +0,0 @@ -import Component from '@glimmer/component'; - -export default class DriverPillComponent extends Component { - get resource() { - return this.args.driver ?? this.args.resource; - } -} diff --git a/addon/components/driver/summary.hbs b/addon/components/driver/summary.hbs new file mode 100644 index 000000000..2f2834fa9 --- /dev/null +++ b/addon/components/driver/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/entity/pill.hbs b/addon/components/entity/pill.hbs new file mode 100644 index 000000000..1c0beab21 --- /dev/null +++ b/addon/components/entity/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/entity/summary.hbs b/addon/components/entity/summary.hbs new file mode 100644 index 000000000..5051e3197 --- /dev/null +++ b/addon/components/entity/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/equipment/details.hbs b/addon/components/equipment/details.hbs index 5cd6540b6..35e46d093 100644 --- a/addon/components/equipment/details.hbs +++ b/addon/components/equipment/details.hbs @@ -83,7 +83,9 @@ {{#if @resource.is_equipped}}
Equipped To
-
{{n-a @resource.equipped_to_name}}
+
+ +
{{/if}} @@ -121,7 +123,9 @@
Warranty
-
{{n-a @resource.warranty_name}}
+
+ +
diff --git a/addon/components/equipment/form.hbs b/addon/components/equipment/form.hbs index 9ab6ffb08..1ce04d6be 100644 --- a/addon/components/equipment/form.hbs +++ b/addon/components/equipment/form.hbs @@ -116,6 +116,7 @@ - {{or model.name model.public_id}} + {{#let (resource-component "select-option" this.equipableModelName) as |selectOption|}} + {{#if selectOption}} + {{component selectOption option=model}} + {{else}} + {{or model.displayName model.name model.public_id}} + {{/if}} + {{/let}} {{/if}} @@ -165,6 +172,7 @@ - {{model.policy_number}} - - - {{model.provider}} + diff --git a/addon/components/equipment/pill.hbs b/addon/components/equipment/pill.hbs index 4c002ec22..a6767ffe1 100644 --- a/addon/components/equipment/pill.hbs +++ b/addon/components/equipment/pill.hbs @@ -1,20 +1,24 @@ -{{#let (or @equipment @resource) as |resource|}} - -
{{or resource.name resource.public_id}}
-
- {{#if resource.type}}{{smart-humanize resource.type}}{{/if}} - {{#if (or resource.serial_number resource.code)}}·{{or resource.serial_number resource.code}}{{/if}} -
-
-{{/let}} + + {{yield resource}} + diff --git a/addon/components/equipment/pill.js b/addon/components/equipment/pill.js deleted file mode 100644 index 8636ecd1e..000000000 --- a/addon/components/equipment/pill.js +++ /dev/null @@ -1,3 +0,0 @@ -import Component from '@glimmer/component'; - -export default class EquipmentPillComponent extends Component {} diff --git a/addon/components/equipment/summary.hbs b/addon/components/equipment/summary.hbs new file mode 100644 index 000000000..c4ec654bc --- /dev/null +++ b/addon/components/equipment/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/facilitator/pill.hbs b/addon/components/facilitator/pill.hbs new file mode 100644 index 000000000..9f831b395 --- /dev/null +++ b/addon/components/facilitator/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/facilitator/summary.hbs b/addon/components/facilitator/summary.hbs new file mode 100644 index 000000000..4a51df844 --- /dev/null +++ b/addon/components/facilitator/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/filter/multi-model.hbs b/addon/components/filter/multi-model.hbs deleted file mode 100644 index 6440f2754..000000000 --- a/addon/components/filter/multi-model.hbs +++ /dev/null @@ -1,19 +0,0 @@ -
- - {{get model (or @filter.modelNamePath "name")}} - - -
diff --git a/addon/components/filter/multi-model.js b/addon/components/filter/multi-model.js deleted file mode 100644 index fe06883eb..000000000 --- a/addon/components/filter/multi-model.js +++ /dev/null @@ -1,90 +0,0 @@ -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; -import { inject as service } from '@ember/service'; -import { isArray } from '@ember/array'; -import { task } from 'ember-concurrency'; - -/** - * Table filter that selects several records of one model (drivers, trailers, - * devices). The filter value travels as a comma-separated list of ids so it - * survives the URL round trip; the matching records are re-fetched on init so - * the chips show names rather than ids after a reload. - */ -export default class FilterMultiModelComponent extends Component { - @service store; - @tracked selectedModels = []; - - constructor() { - super(...arguments); - this.restoreSelection.perform(this.args.value); - } - - get modelName() { - return this.args.filter?.model; - } - - parseIds(value) { - if (isArray(value)) { - return value.filter(Boolean); - } - - if (typeof value === 'string' && value.trim()) { - return value - .split(',') - .map((id) => id.trim()) - .filter(Boolean); - } - - return []; - } - - @task *restoreSelection(value) { - const ids = this.parseIds(value); - - if (!ids.length || !this.modelName) { - this.selectedModels = []; - return; - } - - const records = []; - - for (const id of ids) { - try { - records.push(this.store.peekRecord(this.modelName, id) ?? (yield this.store.findRecord(this.modelName, id))); - } catch { - // An id that no longer resolves simply drops out of the chips. - } - } - - this.selectedModels = records.filter(Boolean); - } - - @action onChange(selection) { - const { onChange, onClear, filter } = this.args; - const models = isArray(selection) ? selection : []; - - this.selectedModels = models; - - if (!models.length) { - if (typeof onClear === 'function') { - onClear(filter); - } - return; - } - - if (typeof onChange === 'function') { - onChange(filter, models.map((model) => model.id).join(',')); - } - } - - @action clear() { - const { onClear, filter } = this.args; - - this.selectedModels = []; - - if (typeof onClear === 'function') { - onClear(filter); - } - } -} diff --git a/addon/components/fleet-driver-listing.hbs b/addon/components/fleet-driver-listing.hbs index 0f7817b4a..4624b5458 100644 --- a/addon/components/fleet-driver-listing.hbs +++ b/addon/components/fleet-driver-listing.hbs @@ -15,6 +15,7 @@ {{#if @onAddDriver}} - {{model.name}} + {{/if}} diff --git a/addon/components/fleet-driver/pill.hbs b/addon/components/fleet-driver/pill.hbs new file mode 100644 index 000000000..9e18e6dbe --- /dev/null +++ b/addon/components/fleet-driver/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/fleet-driver/summary.hbs b/addon/components/fleet-driver/summary.hbs new file mode 100644 index 000000000..7c3e65b63 --- /dev/null +++ b/addon/components/fleet-driver/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/fleet-panel/details.hbs b/addon/components/fleet-panel/details.hbs index 7d03bf298..de71896ff 100644 --- a/addon/components/fleet-panel/details.hbs +++ b/addon/components/fleet-panel/details.hbs @@ -7,11 +7,15 @@
{{t "common.service-area"}}
-
{{n-a @fleet.service_area.name}}
+
+ +
{{t "common.zone"}}
-
{{n-a @fleet.zone.name}}
+
+ +
{{t "common.task"}}
diff --git a/addon/components/fleet-panel/driver-listing.hbs b/addon/components/fleet-panel/driver-listing.hbs index cdda2c6d7..91cd77856 100644 --- a/addon/components/fleet-panel/driver-listing.hbs +++ b/addon/components/fleet-panel/driver-listing.hbs @@ -14,6 +14,7 @@
- {{model.name}} +
diff --git a/addon/components/fleet-panel/vehicle-listing.hbs b/addon/components/fleet-panel/vehicle-listing.hbs index ae1d1067a..2948a9fd4 100644 --- a/addon/components/fleet-panel/vehicle-listing.hbs +++ b/addon/components/fleet-panel/vehicle-listing.hbs @@ -14,6 +14,7 @@
- {{model.displayName}} +
diff --git a/addon/components/fleet-vehicle-listing.hbs b/addon/components/fleet-vehicle-listing.hbs index a39e29a93..9e6ebecdc 100644 --- a/addon/components/fleet-vehicle-listing.hbs +++ b/addon/components/fleet-vehicle-listing.hbs @@ -14,6 +14,7 @@ {{#if @onAddVehicle}} - {{model.display_name}} + {{/if}} diff --git a/addon/components/fleet/details.hbs b/addon/components/fleet/details.hbs index c57af317d..e21ac3ccb 100644 --- a/addon/components/fleet/details.hbs +++ b/addon/components/fleet/details.hbs @@ -7,11 +7,15 @@
{{t "resource.service-area"}}
-
{{n-a this.resource.service_area.name}}
+
+ +
{{t "resource.zone"}}
-
{{n-a this.resource.zone.name}}
+
+ +
{{t "fleet.fields.task"}}
diff --git a/addon/components/fleet/driver-listing.hbs b/addon/components/fleet/driver-listing.hbs index 2cdc53d84..c64a389f3 100644 --- a/addon/components/fleet/driver-listing.hbs +++ b/addon/components/fleet/driver-listing.hbs @@ -14,6 +14,7 @@
- {{model.name}} +
@@ -56,17 +57,7 @@ {{/if}}
-
-
-
- {{driver.name}} - -
-
-
-
{{driver.name}}
-
-
+
diff --git a/addon/components/fleet/form.hbs b/addon/components/fleet/form.hbs index ce781018f..ab773fbd4 100644 --- a/addon/components/fleet/form.hbs +++ b/addon/components/fleet/form.hbs @@ -5,6 +5,7 @@ - {{model.name}} + - {{model.name}} + - {{model.name}} + {{#if @resource.service_area}} - {{model.name}} + {{/if}} diff --git a/addon/components/fleet/pill.hbs b/addon/components/fleet/pill.hbs new file mode 100644 index 000000000..68f25548d --- /dev/null +++ b/addon/components/fleet/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/fleet/summary.hbs b/addon/components/fleet/summary.hbs new file mode 100644 index 000000000..fc035f943 --- /dev/null +++ b/addon/components/fleet/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/fleet/vehicle-listing.hbs b/addon/components/fleet/vehicle-listing.hbs index e01f6a548..329899a81 100644 --- a/addon/components/fleet/vehicle-listing.hbs +++ b/addon/components/fleet/vehicle-listing.hbs @@ -14,6 +14,7 @@
- {{model.displayName}} +
@@ -56,17 +57,7 @@ {{/if}}
-
-
-
- {{vehicle.name}} - -
-
-
-
{{vehicle.displayName}}
-
-
+
diff --git a/addon/components/fuel-integration/form.hbs b/addon/components/fuel-integration/form.hbs index db142c56e..35151f443 100644 --- a/addon/components/fuel-integration/form.hbs +++ b/addon/components/fuel-integration/form.hbs @@ -2,11 +2,31 @@
-
+
{{#each this.setupSteps as |step index|}} -
- {{#if (eq this.activeStep 0)}} + {{#if (eq this.activeStepKey "provider")}}

Choose Fuel Integration

-

Select the system to connect for fuel card bills, purchase transactions, station locations, or vehicle fuel identifiers.

+

Select the system to connect for fuel card bills, purchase transactions, station locations, or vehicle fuel + identifiers.

{{#if this.loadProviders.isRunning}} @@ -31,7 +52,16 @@ {{else}}
{{#each this.providerCards as |provider|}} -
- {{else if (eq this.activeStep 1)}} + {{else if (eq this.activeStepKey "credentials")}}
@@ -67,10 +97,36 @@ {{#if this.selectedProvider}}
- {{#each this.selectedProvider.required_fields as |field|}} - + + + + {{#each this.credentialFields as |field|}} + {{#if (eq field.type "select")}} + + + + {{else}} + + {{/if}} {{/each}}
+ {{#if this.baseUrl}} +

Web Service URL: {{this.baseUrl}}

+ {{/if}} {{else}}
Choose a fuel integration before entering credentials. @@ -97,26 +153,42 @@ {{/if}}
- {{else if (eq this.activeStep 2)}} + {{else if (eq this.activeStepKey "test")}}

Test Connection

Confirm the provider accepts the credentials before saving this fuel integration.

-
+
-
- {{#if (eq this.connectionState "testing")}}{{else if (eq this.connectionState "success")}}{{else if (eq this.connectionState "failed")}}{{else}}{{/if}} +
+ {{#if (eq this.connectionState "testing")}}{{else if (eq this.connectionState "success")}}{{else if + (eq this.connectionState "failed") + }}{{else}}{{/if}}
-
{{this.connectionStateTitle}}
+
{{this.connectionStateTitle}}

{{this.connectionStateMessage}}

{{#if this.connectionMetadataEntries.length}}
{{#each this.connectionMetadataEntries as |item|}} - + {{item.label}} {{item.value}} @@ -126,12 +198,25 @@
-
- @@ -142,10 +227,17 @@
{{#if this.showDiagnostics}} -
+
{{#each this.diagnosticEntries as |entry|}} -
- [{{entry.time}}] +
+ [{{entry.time}}] {{entry.text}}
{{/each}} @@ -154,7 +246,7 @@
- {{else if (eq this.activeStep 3)}} + {{else if (eq this.activeStepKey "sync")}}

Sync Settings

@@ -167,8 +259,15 @@
- -

Provider bills remain in Fuel Transactions. Fuel Reports are created only when a transaction is matched to a vehicle.

+ +

Provider bills remain in Fuel Transactions. Fuel Reports are created only when a transaction is + matched to a vehicle.

@@ -176,7 +275,8 @@

Vehicle Matching Priority

-

Choose which provider identifiers should be used for automatic matching and put the most reliable identifier first.

+

Choose which provider identifiers should be used for automatic matching and put the most reliable + identifier first.

@@ -189,7 +289,7 @@
{{@footerHelp}}
@@ -23,12 +25,16 @@
{{#each this.kpiWidgets as |widget|}} -
+
{{widget.label}}
-
{{widget.value}}
+
{{widget.value}}
@@ -37,7 +43,11 @@
{{widget.help}} - @@ -47,71 +57,60 @@ {{/each}}
- {{#if this.hasConnections}} -
-
-
-

Connected Fuel Systems

-

Manage credentials, connection health, sync windows, and imported transaction status for each fuel integration.

-
-
- -
+
+
+
+

Connected Fuel Systems

+

Manage credentials, connection health, sync windows, and imported transaction status for each fuel + integration.

- -
- {{else}} -
-
- +
+
-

No fuel integrations connected yet

-

- Start by connecting PetroApp or another fuel card/fuel management system. Provider bills import into Fuel Transactions, match to vehicles and trips, and create linked Fuel Reports once matched. -

-
- {{/if}} +
+ +

Available Fuel Integrations

-

Connect providers that supply fuel card bills, purchase transactions, vehicle identifiers, station locations, or trip references.

+

Connect providers that supply fuel card bills, purchase transactions, vehicle identifiers, station + locations, or trip references.

{{#if @providersLoading}}
Loading integrations
@@ -120,7 +119,11 @@
{{#each this.providerCards as |provider|}} -
-
+
\ No newline at end of file diff --git a/addon/components/fuel-integration/hub.js b/addon/components/fuel-integration/hub.js index 1ae0e6e65..14686674c 100644 --- a/addon/components/fuel-integration/hub.js +++ b/addon/components/fuel-integration/hub.js @@ -3,6 +3,8 @@ import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import { fuelDate, fuelNumber, fuelMoney } from '../../utils/fuel-integration-format'; + const WARNING_STATUSES = ['error', 'disabled']; export default class FuelIntegrationHubComponent extends Component { @@ -62,13 +64,12 @@ export default class FuelIntegrationHubComponent extends Component { } get lastSyncLabel() { - const syncedAt = this.connections + const dates = this.connections .map((connection) => connection.last_synced_at) .filter(Boolean) - .sort() - .pop(); - - return syncedAt ? new Date(syncedAt).toLocaleString() : 'Not synced'; + .map((value) => new Date(value).getTime()) + .filter(Number.isFinite); + return dates.length ? fuelDate(Math.max(...dates)) : 'Not synced'; } get kpiWidgets() { @@ -93,7 +94,7 @@ export default class FuelIntegrationHubComponent extends Component { }, { icon: 'triangle-exclamation', - label: 'Unmatched transactions', + label: 'Unmatched in latest runs', value: this.unmatchedTransactionsCount, help: this.unmatchedTransactionsCount > 0 ? 'Need vehicle or trip matching' : 'No unmatched count', action: 'Review matches', @@ -102,7 +103,7 @@ export default class FuelIntegrationHubComponent extends Component { }, { icon: 'receipt', - label: 'Imported spend', + label: 'Latest import spend', value: this.formatSpend, help: `${this.formatLiters} imported`, action: 'Open ledger', @@ -113,21 +114,16 @@ export default class FuelIntegrationHubComponent extends Component { } get formatSpend() { - const currency = this.currentUser?.company?.currency || this.currentUser?.currency || 'USD'; - - if (!this.totalSpend) { - return `${currency} 0`; + const currencies = new Map(); + for (const connection of this.connections) { + const currency = connection.last_sync_state?.summary?.currency || (connection.provider === 'petroapp' ? 'SAR' : null); + if (currency) currencies.set(currency, (currencies.get(currency) || 0) + Number(connection.last_sync_state?.summary?.amount || 0)); } - - return `${currency} ${(this.totalSpend / 100).toLocaleString(undefined, { maximumFractionDigits: 2 })}`; + return [...currencies].map(([currency, amount]) => fuelMoney(amount, currency)).join(' · ') || 'No spend recorded'; } get formatLiters() { - if (!this.totalLiters) { - return '0 L'; - } - - return `${Number(this.totalLiters).toLocaleString(undefined, { maximumFractionDigits: 1 })} L`; + return `${fuelNumber(this.totalLiters)} L`; } @action openProvider(provider) { @@ -153,7 +149,7 @@ export default class FuelIntegrationHubComponent extends Component { const connection = this.connections[0]; if (connection) { - return this.fuelIntegrationActions.transition.view(connection); + return this.fuelIntegrationActions.transitionTo('connectivity.fuel-providers.details.sync', connection); } return this.fuelIntegrationActions.transition.create(); diff --git a/addon/components/fuel-provider-connection/pill.hbs b/addon/components/fuel-provider-connection/pill.hbs new file mode 100644 index 000000000..c65d89cb0 --- /dev/null +++ b/addon/components/fuel-provider-connection/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/fuel-provider-connection/summary.hbs b/addon/components/fuel-provider-connection/summary.hbs new file mode 100644 index 000000000..72ca1eb91 --- /dev/null +++ b/addon/components/fuel-provider-connection/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/fuel-provider-transaction/pill.hbs b/addon/components/fuel-provider-transaction/pill.hbs new file mode 100644 index 000000000..a16d98149 --- /dev/null +++ b/addon/components/fuel-provider-transaction/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/fuel-provider-transaction/summary.hbs b/addon/components/fuel-provider-transaction/summary.hbs new file mode 100644 index 000000000..281f8533a --- /dev/null +++ b/addon/components/fuel-provider-transaction/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/fuel-report/details.hbs b/addon/components/fuel-report/details.hbs index 267567296..29ba79cc6 100644 --- a/addon/components/fuel-report/details.hbs +++ b/addon/components/fuel-report/details.hbs @@ -23,15 +23,21 @@ {{/if}}
{{t "fuel-report.fields.reporter"}}
-
{{n-a @resource.reporter_name}}
+
+ +
{{t "resource.driver"}}
-
{{n-a @resource.driver_name}}
+
+ +
{{t "resource.vehicle"}}
-
{{n-a @resource.vehicle_name}}
+
+ +
{{t "fuel-report.fields.odometer"}}
diff --git a/addon/components/fuel-report/form.hbs b/addon/components/fuel-report/form.hbs index 9b012023d..435636cfa 100644 --- a/addon/components/fuel-report/form.hbs +++ b/addon/components/fuel-report/form.hbs @@ -4,6 +4,7 @@ - {{model.name}} + - {{model.name}} + - {{model.display_name}} + diff --git a/addon/components/fuel-report/pill.hbs b/addon/components/fuel-report/pill.hbs new file mode 100644 index 000000000..495a62784 --- /dev/null +++ b/addon/components/fuel-report/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/fuel-report/summary.hbs b/addon/components/fuel-report/summary.hbs new file mode 100644 index 000000000..c361c6878 --- /dev/null +++ b/addon/components/fuel-report/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/fuel-transaction-status.hbs b/addon/components/fuel-transaction-status.hbs new file mode 100644 index 000000000..42fef3007 --- /dev/null +++ b/addon/components/fuel-transaction-status.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addon/components/fuel-transaction-status.js b/addon/components/fuel-transaction-status.js new file mode 100644 index 000000000..8ce31913c --- /dev/null +++ b/addon/components/fuel-transaction-status.js @@ -0,0 +1,8 @@ +import Component from '@glimmer/component'; +import { transactionStatuses } from '../utils/fuel-transaction'; + +export default class FuelTransactionStatusComponent extends Component { + get status() { + return transactionStatuses[this.args.status ?? this.args.value] ?? { label: 'Unknown', tone: 'default' }; + } +} diff --git a/addon/components/geofence-map.hbs b/addon/components/geofence-map.hbs new file mode 100644 index 000000000..bd2f68649 --- /dev/null +++ b/addon/components/geofence-map.hbs @@ -0,0 +1,19 @@ + + + {{#each this.polygons as |locations index|}} + + {{#if (eq index 0)}} + {{@label}} + {{/if}} + + {{/each}} + diff --git a/addon/components/geofence-map.js b/addon/components/geofence-map.js new file mode 100644 index 000000000..e1c488bac --- /dev/null +++ b/addon/components/geofence-map.js @@ -0,0 +1,83 @@ +import Component from '@glimmer/component'; +import { action, get } from '@ember/object'; +import { later, cancel } from '@ember/runloop'; + +/** + * A service area's or zone's boundary on a map, fitted to the whole shape. + * + * The details panels open inside a sliding overlay, so Leaflet first measures + * a container with no size: the tiles stay grey and the view is centred on + * the wrong point. The map is re-measured and re-fitted once the overlay has + * settled, and again whenever its container changes size. + */ +export default class GeofenceMapComponent extends Component { + map = null; + resizeObserver = null; + timer = null; + + /** Each polygon's outer ring as Leaflet `[lat, lng]` pairs. */ + get polygons() { + const border = this.args.resource ? get(this.args.resource, 'border') : null; + const type = border?.type; + const coordinates = border?.coordinates ?? []; + const rings = type === 'MultiPolygon' ? coordinates.map((polygon) => polygon?.[0] ?? []) : [coordinates[0] ?? []]; + + return rings.map((ring) => ring.filter((pair) => Array.isArray(pair) && pair.length >= 2).map(([lng, lat]) => [lat, lng])).filter((ring) => ring.length > 0); + } + + /** `[[south, west], [north, east]]` around every polygon, or null. */ + get bounds() { + const points = this.polygons.flat(); + if (!points.length) { + return null; + } + + const lats = points.map(([lat]) => lat); + const lngs = points.map(([, lng]) => lng); + + return [ + [Math.min(...lats), Math.min(...lngs)], + [Math.max(...lats), Math.max(...lngs)], + ]; + } + + get center() { + const bounds = this.bounds; + if (!bounds) { + return [0, 0]; + } + + return [(bounds[0][0] + bounds[1][0]) / 2, (bounds[0][1] + bounds[1][1]) / 2]; + } + + @action didLoadMap({ target: map }) { + this.map = map; + this.fit(); + requestAnimationFrame(() => this.fit()); + this.timer = later(this, this.fit, 350); + + if (typeof ResizeObserver !== 'undefined' && typeof map.getContainer === 'function') { + this.resizeObserver = new ResizeObserver(() => this.fit()); + this.resizeObserver.observe(map.getContainer()); + } + } + + fit() { + if (this.isDestroying || this.isDestroyed || !this.map) { + return; + } + + this.map.invalidateSize(); + + if (this.bounds) { + this.map.fitBounds(this.bounds, { padding: [24, 24] }); + } + } + + willDestroy() { + super.willDestroy(...arguments); + cancel(this.timer); + this.resizeObserver?.disconnect(); + this.map = null; + } +} diff --git a/addon/components/inspection-field/form.hbs b/addon/components/inspection-field/form.hbs new file mode 100644 index 000000000..94342955d --- /dev/null +++ b/addon/components/inspection-field/form.hbs @@ -0,0 +1,103 @@ +
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+ + {{#if this.hasOptions}} + +
+ {{#each this.options as |option index|}} +
+ +
+ {{else}} +
{{t "inspection.field.no-options"}}
+ {{/each}} +
+ +
+
+
+ {{/if}} + + {{#if this.isNumber}} + + + + + {{/if}} + + {{#if this.isPassFail}} +
+
{{t "inspection.field.on-fail"}}
+
{{t "inspection.field.on-fail-help"}}
+ + +
+ + {{t (concat "inspection.severity." severity)}} + +
+
+ + + + + + + + +
+ {{/if}} + + +
+ {{#each this.colSpanOptions as |size|}} +
+
+
diff --git a/addon/components/inspection-field/form.js b/addon/components/inspection-field/form.js new file mode 100644 index 000000000..e1215755b --- /dev/null +++ b/addon/components/inspection-field/form.js @@ -0,0 +1,211 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { INSPECTION_FIELD_TYPES, INSPECTION_SEVERITIES, componentForFieldType, isOptionFieldType } from '../../utils/inspection-field-types'; + +/** + * The editor for one inspection field. + * + * It is the platform's custom-field editor plus what an inspection needs: the + * eleven types an inspection form may be built from, and — only for + * `pass-fail` — the *On fail* rules the driver app enforces and the server + * re-checks (`InspectionSubmitter::normalizeValue()`). + * + * The field is a plain object owned by the builder's draft, not an Ember Data + * record: a form is laid out before the form record exists and the whole + * structure is written on the first save. Nothing here mutates `@field` — each + * change builds a new object and hands it to `@onChange`, so no write ever + * happens during render. + */ +export default class InspectionFieldFormComponent extends Component { + @tracked newOption = ''; + + fieldTypes = INSPECTION_FIELD_TYPES; + severityOptions = INSPECTION_SEVERITIES; + colSpanOptions = [1, 2, 3]; + + /** + * The field being edited, held locally so an edit re-renders. + * + * Invoked directly by the builder the field arrives as `@field`; rendered + * inside the resource context panel it arrives on the overlay + * definition's shared `state`, which is a plain object — mutating it + * would never re-render, so the component owns a tracked copy and writes + * through on every change. That shared handle is what the builder reads + * back when the author saves. + */ + @tracked localField = this.args.field ?? this.args.overlay?.state?.field ?? {}; + + get field() { + return this.localField; + } + + get isDisabled() { + return this.args.disabled ?? this.args.overlay?.disabled ?? false; + } + + get meta() { + const meta = this.field.meta; + return meta && typeof meta === 'object' ? meta : {}; + } + + get isPassFail() { + return this.field.type === 'pass-fail'; + } + + get isNumber() { + return this.field.type === 'number'; + } + + get hasOptions() { + return isOptionFieldType(this.field.type); + } + + get options() { + return Array.isArray(this.field.options) ? this.field.options : []; + } + + get isOdometer() { + return this.meta.role === 'odometer'; + } + + /** Every change to the field goes through here, and only from an action. */ + change(attributes) { + const next = { ...this.field, ...attributes }; + this.localField = next; + + if (this.args.overlay?.state) { + this.args.overlay.state.field = next; + } + + if (typeof this.args.onChange === 'function') { + this.args.onChange(next); + } + } + + changeMeta(attributes) { + this.change({ meta: { ...this.meta, ...attributes } }); + } + + /** + * The name a label derives to. The label names the field; this machine + * name follows it until the author types one of their own. + * + * Not `dasherize`: it only rewrites spaces and underscores, so a label + * like "Sidewall condition, offside rear" kept its comma and produced + * `sidewall-condition,-offside-rear`. The name is an identifier — it + * travels as an item result's `item_key` and is what a report groups on — + * so anything that is not a letter or a digit becomes a separator, and + * runs of separators collapse. + */ + slugify(value) { + return String(value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + + @action setLabel(event) { + const label = event.target.value; + const derived = this.slugify(this.field.label); + const current = (this.field.name ?? '').trim(); + + // The name follows the label until an author types their own. + const follows = current === '' || current === derived; + + this.change({ + label, + name: follows ? this.slugify(label) : current, + }); + } + + @action setName(event) { + this.change({ name: this.slugify(event.target.value) }); + } + + @action setDescription(event) { + this.change({ description: event.target.value }); + } + + @action setHelpText(event) { + this.change({ help_text: event.target.value }); + } + + @action setType(event) { + const type = event.target.value; + const attributes = { type, component: componentForFieldType(type) }; + + // A field that has just become pass-fail needs the defaults its rules + // are read from; one that has stopped being pass-fail keeps its meta, + // because the author may be switching back. + if (type === 'pass-fail' && this.meta.severity === undefined) { + attributes.meta = { ...this.meta, severity: 'medium', require_photo_on_fail: false, require_comment_on_fail: false, unsafe_on_fail: false }; + } + + this.change(attributes); + } + + @action setRequired(required) { + this.change({ required: Boolean(required) }); + } + + @action setEditable(editable) { + this.change({ editable: Boolean(editable) }); + } + + @action setColSpan(colSpan) { + this.changeMeta({ colSpan }); + } + + @action setUnit(event) { + this.changeMeta({ unit: event.target.value }); + } + + @action toggleOdometerRole(isOdometer) { + this.changeMeta({ role: isOdometer ? 'odometer' : null }); + } + + @action setSeverity(severity) { + this.changeMeta({ severity }); + } + + @action setRequirePhotoOnFail(value) { + this.changeMeta({ require_photo_on_fail: Boolean(value) }); + } + + @action setRequireCommentOnFail(value) { + this.changeMeta({ require_comment_on_fail: Boolean(value) }); + } + + @action setUnsafeOnFail(value) { + this.changeMeta({ unsafe_on_fail: Boolean(value) }); + } + + @action setInstructions(event) { + this.changeMeta({ instructions: event.target.value }); + } + + @action setNewOption(event) { + this.newOption = event.target.value; + } + + @action addOption() { + const option = this.newOption.trim(); + if (option === '') { + return; + } + + this.newOption = ''; + this.change({ options: [...this.options, option] }); + } + + @action updateOption(index, event) { + const value = event.target.value; + this.change({ options: this.options.map((option, optionIndex) => (optionIndex === index ? value : option)) }); + } + + @action removeOption(index) { + this.change({ options: this.options.filter((_, optionIndex) => optionIndex !== index) }); + } +} diff --git a/addon/components/inspection-field/input.hbs b/addon/components/inspection-field/input.hbs new file mode 100644 index 000000000..e5fcc810c --- /dev/null +++ b/addon/components/inspection-field/input.hbs @@ -0,0 +1,270 @@ +{{! + One field of an inspection, being answered. + + A note, an upload or a signature is a full-width band, because the form + decides its size. Everything else is a cell in its group's grid, label + above control, and stays that size whatever is answered. + + A failed check keeps its cell. Its detail — severity, unsafe, comment, + photos — opens in a flyout anchored to the cell, and a chip left in the + cell summarises it once the flyout is closed. Answering a field can + therefore never change the layout of the sheet. +}} +{{#if this.isRoomy}} +
+
+ + {{this.label}} + {{#if this.field.required}}{{/if}} + + + {{#unless this.isStackedBand}} +
+ {{#if this.file.url}} + {{or + {{else if this.file.reference}} + + {{else}} + {{this.emptyFileNote}} + {{/if}} + + {{#if this.canUpload}} + + + {{this.uploadLabel}} + + + {{#if this.file.reference}} +
+ {{/unless}} +
+ + {{#if this.isStackedBand}} +
+ {{#if this.field.description}} + {{this.field.description}} + {{/if}} + +
+ {{/if}} +
+ +{{else}} +
+ + {{this.label}} + {{#if this.field.required}}{{/if}} + + + {{#if this.field.description}} + {{this.field.description}} + {{/if}} + {{#if this.instructions}} + {{this.instructions}} + {{/if}} + +
+ {{#if (eq this.field.type "pass-fail")}} +
+ {{#each this.passFailOptions key="value" as |option|}} + + {{/each}} +
+ + {{else if (eq this.field.type "boolean")}} + + + {{else if (eq this.field.type "number")}} + + {{#if this.unit}}{{this.unit}}{{/if}} + + {{else if (eq this.field.type "select")}} +
+ + {{option}} + +
+ + {{else if (eq this.field.type "radio-button")}} + {{#if this.choiceOptions}} +
+ {{#each this.choiceOptions key="@index" as |option|}} + + {{/each}} +
+ {{else}} + {{t "inspection.answer.no-options"}} + {{/if}} + + {{else}} + + {{/if}} +
+ + {{#if this.isDefect}} + + + {{#if this.isFlyoutOpen}} + +
+
+ {{#each this.severityOptions key="value" as |option|}} + + {{/each}} +
+ + + {{t "inspection.answer.unsafe"}} + +
+ + + +
+ {{#each this.answerPhotos key="reference" as |photo index|}} +
+ {{#if photo.url}} + {{or + {{else}} + + {{/if}} + {{#unless @disabled}} +
+ {{/each}} + + {{#if this.canUpload}} + + + + + {{/if}} + + {{#if this.uploadProgress}} + {{round this.uploadProgress.progress}}% + {{/if}} + + {{#if this.uploadsBlocked}} + {{t "inspection.answer.uploads-unavailable"}} + {{/if}} +
+
+ {{/if}} + {{/if}} +
+{{/if}} diff --git a/addon/components/inspection-field/input.js b/addon/components/inspection-field/input.js new file mode 100644 index 000000000..36eb727c7 --- /dev/null +++ b/addon/components/inspection-field/input.js @@ -0,0 +1,472 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; +import { answerState, isUnsafeAnswer, isBlank, defectSummary, ROOMY_FIELD_TYPES } from '../../utils/inspection-answers'; + +/** The date-ish types that are still one compact control. */ +const INPUT_TYPES = { 'date-picker': 'date', 'date-time-input': 'datetime-local' }; + +const PASS_FAIL_DEFAULT = { passed: true, not_applicable: false, severity: null, comments: '', photos: [], unsafe: false }; + +/** + * One inspection field, being answered. + * + * Every one of the eleven field types is rendered here rather than some being + * handed to the platform's `custom-field/input`. Delegating made the sheet + * read as two different forms interleaved — its own label chrome, its own + * spacing, its own idea of what a control looks like — and an inspection is + * one list that an inspector reads straight down. Owning them all is what + * makes every row the same shape. + * + * The component owns no copy of the answer. `@value` in, `@onChange` out — + * the answering screen holds the values, so nothing is written during render. + */ +export default class InspectionFieldInputComponent extends Component { + @service fetch; + @service intl; + @tracked uploadProgress = null; + + /** Freshly uploaded files, so a photo can be shown before it is saved. */ + @tracked previews = {}; + + get field() { + return this.args.field ?? {}; + } + + get meta() { + const meta = this.field.meta; + return meta && typeof meta === 'object' ? meta : {}; + } + + /** A failed check, which owes a severity, and maybe a comment and photos. */ + get isDefect() { + return this.field.type === 'pass-fail' && this.answerState === 'fail'; + } + + /** This field's flyout is the one open on the sheet — only ever one is. */ + get isFlyoutOpen() { + return this.isDefect && Boolean(this.field.uuid) && this.args.openFieldId === this.field.uuid; + } + + /** What the failure has recorded, for the chip it leaves in the cell. */ + get defect() { + return defectSummary(this.field, this.args.value); + } + + get severityLabel() { + const severity = this.defect.severity; + + if (!severity) { + return null; + } + + return INSPECTION_SEVERITIES.includes(severity) ? this.intl.t(`inspection.severity.${severity}`) : severity; + } + + /** What a closed failure still owes, said on its chip in amber. */ + get defectStatus() { + const { needsComment, needsPhoto } = this.defect; + + if (needsComment && needsPhoto) { + return this.intl.t('inspection.defect.needs-both'); + } + + if (needsComment) { + return this.intl.t('inspection.defect.needs-comment'); + } + + return needsPhoto ? this.intl.t('inspection.defect.needs-photo') : null; + } + + get flyoutTitle() { + return this.intl.t('inspection.flyout.title', { label: this.label }); + } + + /** A note, an upload or a signature — never a column, whatever the answer. */ + get isRoomy() { + return ROOMY_FIELD_TYPES.includes(this.field.type); + } + + /** A note puts its control under the label; a file puts it beside. */ + get isStackedBand() { + return this.field.type === 'textarea'; + } + + get isTargeted() { + return Boolean(this.field.uuid) && this.args.targetId === this.field.uuid; + } + + /** A required answer still missing, which its own edge says in amber. */ + get isOutstanding() { + return Boolean(this.field.required) && isBlank(this.field, this.args.value); + } + + get passFailOptions() { + return [ + { value: 'pass', label: this.intl.t('inspection.answer.pass') }, + { value: 'fail', label: this.intl.t('inspection.answer.fail') }, + { value: 'na', label: this.intl.t('inspection.answer.not-applicable') }, + ]; + } + + get severityOptions() { + return INSPECTION_SEVERITIES.map((severity) => ({ + value: severity, + label: this.intl.t(`inspection.severity.${severity}`), + })); + } + + get inputType() { + return INPUT_TYPES[this.field.type] ?? 'text'; + } + + get fileIcon() { + return this.field.type === 'signature' ? 'signature' : 'upload'; + } + + get uploadLabel() { + return this.field.type === 'signature' ? this.intl.t('inspection.answer.upload-signature') : this.intl.t('inspection.answer.upload-photo'); + } + + get emptyFileNote() { + return this.field.type === 'signature' ? this.intl.t('inspection.answer.no-signature') : this.intl.t('inspection.answer.no-photo'); + } + + /** What this failure still owes, said once beside the photo slots. */ + get defectRequirement() { + if (this.requiresComment && this.requiresPhoto) { + return this.intl.t('inspection.answer.comment-and-photo-required'); + } + + if (this.requiresPhoto) { + return this.intl.t('inspection.answer.photo-required'); + } + + return this.requiresComment ? this.intl.t('inspection.answer.comment-required') : null; + } + + /** A field with no label still needs something to click on. */ + get label() { + return this.field.label || this.field.name || this.intl.t('inspection.builder.untitled-field'); + } + + get instructions() { + return this.meta.instructions ?? null; + } + + get unit() { + return this.meta.unit ?? null; + } + + /** What this row currently says, for the row's own `data-answer`. */ + get answerState() { + return answerState(this.field, this.args.value); + } + + /** + * What an empty control should suggest. An author can write their own; a + * number otherwise shows a zero rather than nothing at all, which is what + * an inspector reaches for on a tread depth or a pressure. + */ + get placeholder() { + if (this.meta.placeholder) { + return this.meta.placeholder; + } + + switch (this.field.type) { + case 'number': + return '0'; + case 'select': + return this.intl.t('inspection.answer.select-placeholder'); + case 'textarea': + return this.intl.t('inspection.answer.note-placeholder'); + default: + return this.intl.t('inspection.answer.text-placeholder'); + } + } + + /** + * Whether this row may offer an upload. + * + * A public link runs unauthenticated, and the file endpoint the uploader + * posts to does not. So a link renders the field and says the photo has + * to come from the console or the driver app, rather than showing a + * button that can only fail. + */ + get canUpload() { + return this.args.allowUploads !== false && !this.args.disabled; + } + + get uploadsBlocked() { + return this.args.allowUploads === false; + } + + /** The answers a `select` or `radio-button` field offers. */ + get choiceOptions() { + const options = this.field.options; + return Array.isArray(options) && options.length ? options : null; + } + + // ---------- pass-fail ---------- + + get answer() { + const value = this.args.value; + if (value && typeof value === 'object' && !Array.isArray(value)) { + return { ...PASS_FAIL_DEFAULT, ...value, photos: Array.isArray(value.photos) ? value.photos : [] }; + } + + if (typeof value === 'boolean') { + return { ...PASS_FAIL_DEFAULT, passed: value }; + } + + return { ...PASS_FAIL_DEFAULT }; + } + + get severity() { + return this.answer.severity ?? this.meta.severity ?? 'medium'; + } + + get isUnsafe() { + return isUnsafeAnswer(this.field, this.args.value); + } + + get comments() { + return this.answer.comments ?? ''; + } + + /** The photos on a failed pass-fail answer, ready to render. */ + get answerPhotos() { + return this.answer.photos.map((photo) => this.#describeFile(photo)); + } + + get requiresComment() { + return this.isDefect && this.meta.require_comment_on_fail === true; + } + + get requiresPhoto() { + return this.isDefect && this.meta.require_photo_on_fail === true; + } + + // ---------- file / signature ---------- + + get file() { + const value = this.args.value; + if (!value) { + return null; + } + + return this.#describeFile(value); + } + + get booleanValue() { + const value = this.args.value; + if (typeof value === 'boolean') { + return value; + } + + return value === 'true' || value === 1 || value === '1'; + } + + // ---------- actions ---------- + + emit(value) { + if (typeof this.args.onChange === 'function') { + this.args.onChange(value, this.field); + } + } + + @action setText(event) { + this.emit(event.target.value); + } + + @action setNumber(event) { + const value = event.target.value; + this.emit(value === '' ? null : Number(value)); + } + + @action setBoolean(value) { + this.emit(Boolean(value)); + } + + @action setChoice(option) { + this.emit(option ?? null); + } + + /** + * One of pass, fail or n/a. Failing seeds the severity and the unsafe flag + * from what the field's author set as its default, so the common case is + * already answered; passing or marking n/a clears both, because a check + * that did not fail cannot carry a severity. + */ + @action setPassFail(choice) { + if (choice === 'fail') { + this.emit({ + ...this.answer, + passed: false, + not_applicable: false, + severity: this.answer.severity ?? this.meta.severity ?? 'medium', + unsafe: this.answer.unsafe ?? Boolean(this.meta.unsafe_on_fail), + }); + + // Choosing Fail already means "record a defect": no second click. + this.openFlyout(); + + return; + } + + // The comment and photos survive a switch away, so an accidental Pass + // followed by Fail again brings them back. + this.emit({ + ...this.answer, + passed: true, + not_applicable: choice === 'na', + severity: null, + unsafe: false, + }); + + if (typeof this.args.onCloseFlyout === 'function') { + this.args.onCloseFlyout(this.field); + } + } + + @action openFlyout() { + if (typeof this.args.onOpenFlyout === 'function') { + this.args.onOpenFlyout(this.field); + } + } + + /** + * Close this field's flyout. Focus goes back to its Fail button when the + * inspector closed it themselves, but not when they pressed somewhere + * else on the page — their attention is already there. + */ + @action closeFlyout(reason) { + if (typeof this.args.onCloseFlyout === 'function') { + this.args.onCloseFlyout(this.field); + } + + if (reason === 'outside') { + return; + } + + document.querySelector(`#inspection-field-${this.field.uuid} [data-answer="fail"]`)?.focus({ preventScroll: true }); + } + + @action setSeverity(severity) { + this.emit({ ...this.answer, severity }); + } + + @action setUnsafe(unsafe) { + this.emit({ ...this.answer, unsafe: Boolean(unsafe) }); + } + + @action setComments(event) { + this.emit({ ...this.answer, comments: event.target.value }); + } + + @action removePhoto(index) { + this.emit({ ...this.answer, photos: this.answer.photos.filter((_, photoIndex) => photoIndex !== index) }); + } + + @action clearFile() { + this.emit(null); + } + + /** + * A photo or signature picked in the console is uploaded straight away and + * the answer keeps `file:`, the platform's own convention. The + * server claims any file referenced this way when the submission is saved + * (`InspectionFileStore::attachReferenced`). + */ + @action addPhoto(file) { + return this.#upload(file, 'inspection_photo', (uploaded) => { + this.emit({ ...this.answer, photos: [...this.answer.photos, `file:${uploaded.id}`] }); + }); + } + + @action setFile(file) { + const type = this.field.type === 'signature' ? 'inspection_signature' : 'inspection_photo'; + + return this.#upload(file, type, (uploaded) => { + this.emit(`file:${uploaded.id}`); + }); + } + + #upload(file, type, onUploaded) { + if (['queued', 'failed', 'timed_out', 'aborted'].indexOf(file.state) === -1) { + return; + } + + this.uploadProgress = file; + + const done = (uploaded) => { + this.uploadProgress = null; + this.previews = { ...this.previews, [`file:${uploaded.id}`]: { url: uploaded.url, filename: uploaded.original_filename ?? uploaded.filename } }; + onUploaded(uploaded); + }; + + const failed = () => { + this.uploadProgress = null; + + if (file.queue && typeof file.queue.remove === 'function') { + file.queue.remove(file); + } + }; + + // A public link has no session to upload with, so it hands in an + // uploader of its own that posts through the link's token. It answers + // in the same shape, so nothing after this point knows the difference. + if (typeof this.args.uploader === 'function') { + return Promise.resolve() + .then(() => this.args.uploader(file, type)) + .then(done, failed); + } + + return this.fetch.uploadFile.perform( + file, + { + path: `uploads/inspections/${this.field.uuid ?? 'field'}`, + type, + ...this.#subjectParams(), + }, + done, + failed + ); + } + + #subjectParams() { + const subject = this.args.subject; + const subjectUuid = subject?.uuid ?? subject?.id; + if (!subjectUuid || subject.isNew) { + return {}; + } + + return { subject_uuid: subjectUuid, subject_type: 'fleet-ops:inspection-submission' }; + } + + /** + * A file value in one shape, whichever way it arrived: a `file:` + * reference just uploaded here, or the `{ id, url, filename }` the + * submission resource resolves a stored reference to. + */ + #describeFile(value) { + if (value && typeof value === 'object') { + return { reference: value.id, url: value.url, filename: value.filename, contentType: value.content_type }; + } + + if (typeof value !== 'string' || value === '') { + return { reference: null, url: null, filename: null, contentType: null }; + } + + const preview = this.previews[value]; + + return { + reference: value, + url: preview?.url ?? (value.startsWith('http') ? value : null), + filename: preview?.filename ?? null, + contentType: null, + }; + } +} diff --git a/addon/components/inspection-field/value.hbs b/addon/components/inspection-field/value.hbs new file mode 100644 index 000000000..66ad416d7 --- /dev/null +++ b/addon/components/inspection-field/value.hbs @@ -0,0 +1,90 @@ +{{! + One stored answer, read-only. + + Every answer keeps the column the form gave it: a pass or a fail is a + coloured card in its cell, and only a note, an upload or a signature — + which need the room — spans the grid. +}} +{{#if this.isResult}} + {{! Pass, fail and N/A share one card, coloured by the answer, sitting in + the column the form gave it. A fail carries its severity, its unsafe + flag, the comment and the photos beneath the head. }} +
+
+ {{t this.resultLabel}} + {{this.label}} +
+ {{#if this.hasResultDetail}} +
+ {{#if this.hasResultMeta}} +
+ {{#if this.severityLabel}}{{t this.severityLabel}}{{/if}} + {{#if this.answer.unsafe}}{{t "inspection.answer.unsafe"}}{{/if}} +
+ {{/if}} + {{#if this.answer.comments}} +

{{this.answer.comments}}

+ {{/if}} + {{#if this.photos.length}} +
+ {{#each this.photos key="reference" as |photo|}} + {{#if photo.url}} + + {{or + + {{else}} + + {{/if}} + {{/each}} +
+ {{/if}} +
+ {{/if}} +
+ +{{else if this.isRoomy}} +
+
+ {{this.label}} + {{#unless this.isStackedBand}} + + {{#if this.file.url}} + + {{or + + {{else if this.file.reference}} + + {{else}} + {{t "inspection.answer.unanswered"}} + {{/if}} + + {{/unless}} +
+ {{#if this.isStackedBand}} +
+ {{#if this.isAnswered}} +

{{@value}}

+ {{else}} + {{t "inspection.answer.unanswered"}} + {{/if}} +
+ {{/if}} +
+ +{{else}} +
+ {{this.label}} +
+ {{#if this.isBoolean}} + {{if this.booleanValue (t "common.yes") (t "common.no")}} + {{else}} + {{#if this.isAnswered}} + {{@value}} + {{#if this.meta.unit}}{{this.meta.unit}}{{/if}} + {{else}} + {{t "inspection.answer.unanswered"}} + {{/if}} + {{/if}} +
+
+{{/if}} diff --git a/addon/components/inspection-field/value.js b/addon/components/inspection-field/value.js new file mode 100644 index 000000000..b1790a2f4 --- /dev/null +++ b/addon/components/inspection-field/value.js @@ -0,0 +1,158 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; +import { answerState, ROOMY_FIELD_TYPES } from '../../utils/inspection-answers'; + +/** + * One stored answer, read-only — what the record's Overview shows. + * + * The submission resource hands the console a value already projected: a file + * value resolved to `{ id, url, filename, content_type }`, and a pass-fail + * answer as an object with its photos resolved the same way. This renders + * that, whichever shape the value arrived in; it never fetches and never + * writes. + */ +export default class InspectionFieldValueComponent extends Component { + @service intl; + + get field() { + return this.args.field ?? {}; + } + + get meta() { + const meta = this.field.meta; + return meta && typeof meta === 'object' ? meta : {}; + } + + get label() { + return this.field.label || this.field.name || this.intl.t('inspection.builder.untitled-field'); + } + + get answerState() { + return answerState(this.field, this.args.value); + } + + /** Pass, fail and N/A all read as the same card. */ + get isResult() { + return this.isPassFail; + } + + /** A fail shows what it was marked with; a pass has nothing to add. */ + get hasResultMeta() { + return this.answerState === 'fail' && Boolean(this.severityLabel || this.answer.unsafe); + } + + get hasResultDetail() { + return this.hasResultMeta || Boolean(this.answer.comments) || this.photos.length > 0; + } + + get isDefect() { + return this.isPassFail && this.answerState === 'fail'; + } + + /** Whether anything was actually answered, so an empty one can say so. */ + get isAnswered() { + const value = this.args.value; + + return value !== null && value !== undefined && String(value).trim() !== ''; + } + + get isRoomy() { + return ROOMY_FIELD_TYPES.includes(this.field.type); + } + + get isStackedBand() { + return this.field.type === 'textarea'; + } + + get isTargeted() { + return Boolean(this.field.uuid) && this.args.targetId === this.field.uuid; + } + + /** A failure's comment and photos, shown only when there is something to show. */ + get hasDefectDetail() { + return this.isPassFail && (Boolean(this.answer.comments) || this.photos.length > 0); + } + + get isPassFail() { + return this.field.type === 'pass-fail'; + } + + get isFile() { + return this.field.type === 'file-upload' || this.field.type === 'signature'; + } + + get isBoolean() { + return this.field.type === 'boolean'; + } + + get answer() { + const value = this.args.value; + if (value && typeof value === 'object' && !Array.isArray(value)) { + return { ...value, photos: Array.isArray(value.photos) ? value.photos : [] }; + } + + if (typeof value === 'boolean') { + return { passed: value, not_applicable: false, photos: [] }; + } + + return { passed: null, not_applicable: false, photos: [] }; + } + + get resultLabel() { + const answer = this.answer; + if (answer.not_applicable === true) { + return 'inspection.answer.not-applicable'; + } + + if (answer.passed === false) { + return 'inspection.answer.fail'; + } + + if (answer.passed === true) { + return 'inspection.answer.pass'; + } + + return 'inspection.answer.unanswered'; + } + + get resultStatus() { + const answer = this.answer; + if (answer.not_applicable === true) { + return 'info'; + } + + return answer.passed === false ? 'failed' : 'passed'; + } + + /** The severity's own label, or the raw value when it is not one of ours. */ + get severityLabel() { + const severity = this.answer.severity; + return INSPECTION_SEVERITIES.includes(severity) ? `inspection.severity.${severity}` : null; + } + + get photos() { + return this.answer.photos.map((photo) => this.#describeFile(photo)); + } + + get file() { + return this.#describeFile(this.args.value); + } + + get booleanValue() { + const value = this.args.value; + return value === true || value === 'true' || value === 1 || value === '1'; + } + + #describeFile(value) { + if (value && typeof value === 'object') { + return { reference: value.id, url: value.url, filename: value.filename }; + } + + if (typeof value !== 'string' || value === '') { + return { reference: null, url: null, filename: null }; + } + + return { reference: value, url: value.startsWith('http') ? value : null, filename: null }; + } +} diff --git a/addon/components/inspection-flyout.hbs b/addon/components/inspection-flyout.hbs new file mode 100644 index 000000000..b525a2d26 --- /dev/null +++ b/addon/components/inspection-flyout.hbs @@ -0,0 +1,34 @@ +{{#if this.mount}} + {{#in-element this.mount insertBefore=null}} + {{#if this.isSheet}} + + {{/if}} + + {{/in-element}} +{{/if}} diff --git a/addon/components/inspection-flyout.js b/addon/components/inspection-flyout.js new file mode 100644 index 000000000..ea8b4aeb7 --- /dev/null +++ b/addon/components/inspection-flyout.js @@ -0,0 +1,66 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +/** + * Below this sheet width a floating panel would crowd the page and fight the + * on-screen keyboard, so the same content slides up as a bottom sheet. + */ +const SHEET_BREAKPOINT = 520; + +/** + * The panel a failed check opens, anchored to its field. + * + * It renders outside the field so the layout never changes when a check + * fails. Floating, it lives in the sheet's own flyout layer, so it scrolls + * with its field and is never clipped by the sheet's rounded card. As a bottom + * sheet on a phone it lives in the application's root wormhole instead, + * because a fixed panel inside a container-query element would be pinned to + * that element rather than to the screen. + * + * It is non-modal and traps nothing. It closes on Done, on its close button, + * on Escape, and on a press anywhere outside it and its field — and closing is + * always safe, because every answer inside it is saved as it is typed. + */ +export default class InspectionFlyoutComponent extends Component { + /** Decided once, when it opens: a panel should not change shape under the user. */ + presentation = this.measurePresentation(); + + get anchor() { + return document.getElementById(`inspection-field-${this.args.fieldId}`); + } + + get isSheet() { + return this.presentation === 'sheet'; + } + + get mount() { + if (this.isSheet) { + return document.getElementById('application-root-wormhole') ?? document.body; + } + + return this.anchor?.closest('.inspection-sheet')?.querySelector(':scope > .inspection-sheet__flyouts') ?? null; + } + + /** + * Where focus lands on opening. Floating, it is the comment — usually the + * first thing a failure still owes. On a phone it is the panel itself: + * focusing the comment would throw the keyboard up over the sheet before + * the inspector has read it. + */ + get focusTarget() { + return this.isSheet ? true : '[data-flyout-focus]'; + } + + measurePresentation() { + const anchor = document.getElementById(`inspection-field-${this.args.fieldId}`); + const width = anchor?.closest('.inspection-sheet')?.clientWidth ?? window.innerWidth; + + return width < SHEET_BREAKPOINT ? 'sheet' : 'floating'; + } + + @action dismiss(reason) { + if (typeof this.args.onClose === 'function') { + this.args.onClose(reason); + } + } +} diff --git a/addon/components/inspection-form/builder.hbs b/addon/components/inspection-form/builder.hbs new file mode 100644 index 000000000..bd89035f1 --- /dev/null +++ b/addon/components/inspection-form/builder.hbs @@ -0,0 +1,88 @@ +
+
+
{{t "inspection.builder.help"}}
+
+ + {{#if this.load.isRunning}} +
+ +
+ {{else}} +
+ {{#each this.groups key="uuid" as |group groupIndex|}} + +
+ + + + + + +
+ +
+
+
+
+
+
+ +
+ {{#each group.fields key="uuid" as |field fieldIndex|}} + {{! + The name reads first, on its own line. Sharing a + row with four buttons left every label truncated + to a few characters, which is unusable for + keeping track of what is what while building. + }} +
+
+
{{or field.label (t "inspection.builder.untitled-field")}}
+ {{#if field.required}} + * + {{/if}} +
+ {{field.type}} +
+
+
+
+
+ {{else}} +
{{t "inspection.builder.no-fields"}}
+ {{/each}} +
+
+ {{else}} +
+
+
+ +
{{t "inspection.builder.empty-title"}}
+
{{t "inspection.builder.empty-description"}}
+
+
+
+ {{/each}} +
+ {{/if}} +
diff --git a/addon/components/inspection-form/builder.js b/addon/components/inspection-form/builder.js new file mode 100644 index 000000000..efc67c64b --- /dev/null +++ b/addon/components/inspection-form/builder.js @@ -0,0 +1,211 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { next } from '@ember/runloop'; +import { task } from 'ember-concurrency'; +import inlineTask from '@fleetbase/ember-core/utils/inline-task'; +import { createField, createFieldGroup } from '../../utils/inspection-form-structure'; + +/** + * The form builder: field groups, each with a grid size and its own typed + * fields. + * + * A form is laid out before the form record exists, so the structure is a + * draft of plain objects rather than Ember Data records — the `inspection-form` + * model belongs to `@fleetbase/fleetops-data` and declares no attribute for it. + * The controller posts it with the save, and `InspectionFormSync` writes it in + * one go. + * + * **The draft lives on the controller, not here.** `ContentPanel` unrenders its + * body when it is collapsed, so this component is destroyed and rebuilt every + * time the author folds the builder away; state held here went with it and the + * form came back empty. So the component is controlled: it renders `@groups` + * and reports every change through `@onChange`, and owns nothing that a + * collapse can take. + * + * Nothing here mutates a group or a field in place. Every change builds new + * objects and assigns them from an action — never during render. + */ +export default class InspectionFormBuilderComponent extends Component { + @service inspectionFormActions; + @service modalsManager; + @service resourceContextPanel; + @service notifications; + @service intl; + + gridSizeOptions = [1, 2, 3]; + + /** The draft, owned by the controller so it survives a panel collapse. */ + get groups() { + return Array.isArray(this.args.groups) ? this.args.groups : []; + } + + constructor() { + super(...arguments); + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.load.perform(); + }); + } + + get isDraft() { + const resource = this.args.resource; + return !resource?.id || resource?.isNew === true; + } + + @task *load() { + // A form that does not exist yet has nothing to read back. + if (this.isDraft) { + return; + } + + // Already held by the controller — either loaded once before, or + // carrying edits the author has not saved. Re-reading here would + // throw those away every time the panel was reopened. + if (Array.isArray(this.args.groups)) { + return; + } + + try { + this.write(yield this.inspectionFormActions.loadStructure(this.args.resource)); + } catch (error) { + this.notifications.serverError(error); + } + } + + /** The one place the draft is announced. The controller stores it. */ + write(groups) { + if (typeof this.args.onChange === 'function') { + this.args.onChange(groups); + } + } + + replaceGroup(uuid, attributes) { + this.write(this.groups.map((group) => (group.uuid === uuid ? { ...group, ...attributes } : group))); + } + + /** + * Paints an input's starting value without binding it. + * + * The iteration is keyed, so the node survives an edit — but a bound + * `value` is rewritten on every render, and assigning to `value` mid-word + * moves the caret to the end. Setting it once on insert leaves the DOM to + * own the text, and `input` reports each change back. + */ + @action setInitialValue(value, element) { + element.value = value ?? ''; + } + + @action addGroup() { + this.write([...this.groups, createFieldGroup({ name: this.intl.t('inspection.builder.untitled-group'), order: this.groups.length + 1 })]); + } + + @action setGroupName(uuid, event) { + this.replaceGroup(uuid, { name: event.target.value }); + } + + @action setGroupDescription(uuid, event) { + this.replaceGroup(uuid, { description: event.target.value }); + } + + @action setGridSize(group, size) { + this.replaceGroup(group.uuid, { meta: { ...(group.meta ?? {}), grid_size: size } }); + } + + @action moveGroup(index, offset) { + const target = index + offset; + if (target < 0 || target >= this.groups.length) { + return; + } + + const groups = [...this.groups]; + const [moved] = groups.splice(index, 1); + groups.splice(target, 0, moved); + this.write(groups); + } + + @action deleteGroup(group) { + this.modalsManager.confirm({ + title: this.intl.t('inspection.builder.delete-group-title'), + body: this.intl.t('inspection.builder.delete-group-body'), + acceptButtonText: this.intl.t('inspection.builder.delete'), + acceptButtonType: 'danger', + confirm: (modal) => { + this.write(this.groups.filter((candidate) => candidate.uuid !== group.uuid)); + modal.done(); + }, + }); + } + + @action addField(group) { + this.editField(group, createField('pass-fail', { label: this.intl.t('inspection.builder.untitled-field'), order: (group.fields?.length ?? 0) + 1 }), true); + } + + /** + * The field editor opens as a right-side overlay over the form's own + * panel rather than as a modal: a modal covers the form the author is + * building, and the two are read together. `xs` keeps it narrower than + * the form panel behind it, so the form stays visible alongside. + * + * The field is a plain object in the builder's draft, so there is nothing + * for the panel's default save to persist — `state` is the handle both + * sides hold, and the inline task applies whatever the editor last + * produced when the author saves. + */ + @action editField(group, field, isNew = false) { + const state = { field }; + + this.resourceContextPanel.open({ + content: 'inspection-field/form', + title: isNew ? this.intl.t('inspection.builder.new-field') : this.intl.t('inspection.builder.edit-field', { label: field.label }), + size: 'xs', + panelContentClass: 'py-2 px-4', + // The field is a plain object, not an Ember Data record. Without + // this the header's save falls through to `cannot-write` on a + // resource it cannot resolve, which denies by default and leaves + // the button disabled for good. + pojoResource: true, + state, + disabled: this.args.disabled, + saveTask: inlineTask((resource, { overlay } = {}) => { + this.applyField(group, state.field, isNew); + this.resourceContextPanel.close(overlay?.id); + }), + }); + } + + applyField(group, field, isNew) { + const fields = group.fields ?? []; + const nextFields = isNew ? [...fields, field] : fields.map((candidate) => (candidate.uuid === field.uuid ? field : candidate)); + + this.replaceGroup(group.uuid, { fields: nextFields }); + } + + @action moveField(group, index, offset) { + const fields = [...(group.fields ?? [])]; + const target = index + offset; + if (target < 0 || target >= fields.length) { + return; + } + + const [moved] = fields.splice(index, 1); + fields.splice(target, 0, moved); + this.replaceGroup(group.uuid, { fields }); + } + + @action deleteField(group, field) { + this.modalsManager.confirm({ + title: this.intl.t('inspection.builder.delete-field-title'), + body: this.intl.t('inspection.builder.delete-field-body'), + acceptButtonText: this.intl.t('inspection.builder.delete'), + acceptButtonType: 'danger', + confirm: (modal) => { + this.replaceGroup(group.uuid, { fields: (group.fields ?? []).filter((candidate) => candidate.uuid !== field.uuid) }); + modal.done(); + }, + }); + } +} diff --git a/addon/components/inspection-form/details.hbs b/addon/components/inspection-form/details.hbs new file mode 100644 index 000000000..7574a80d3 --- /dev/null +++ b/addon/components/inspection-form/details.hbs @@ -0,0 +1,97 @@ +
+ +
+
+
{{t "inspection.form.name"}}
+
{{n-a @resource.name}}
+
+
+
{{t "inspection.form.status"}}
+
{{or (get-fleet-ops-option-label "inspectionFormStatuses" @resource.status) (smart-humanize @resource.status)}}
+
+
+
{{t "inspection.form.type"}}
+
{{or (get-fleet-ops-option-label "inspectionFormTypes" @resource.type) (n-a (smart-humanize @resource.type))}}
+
+
+
{{t "inspection.form.fields"}}
+
{{this.fieldCount}}
+
+
+
{{t "inspection.form.published"}}
+
{{n-a (format-date-fns @resource.published_at "dd MMM yyyy, HH:mm")}}
+
+
+
{{t "inspection.form.description"}}
+
{{n-a @resource.description}}
+
+
+
+ + + + + + + {{#if this.load.isRunning}} +
+ +
+ {{else}} +
+ {{#each this.groups as |group|}} +
+
{{or group.name (t "inspection.builder.untitled-group")}}
+ {{#if group.description}} +
{{group.description}}
+ {{/if}} +
+ {{#each group.fields as |field|}} +
+
+ {{or field.label (t "inspection.builder.untitled-field")}} + {{#if field.required}}*{{/if}} +
+
+ {{#if (and (eq field.type "pass-fail") field.meta.severity)}} + + {{#let (get this.severityLabels field.meta.severity) as |severityLabel|}} + {{#if severityLabel}}{{t severityLabel}}{{else}}{{smart-humanize field.meta.severity}}{{/if}} + {{/let}} + + {{/if}} + {{field.type}} +
+
+ {{else}} +
{{t "inspection.builder.no-fields"}}
+ {{/each}} +
+
+ {{else}} +
{{t "inspection.form.no-structure"}}
+ {{/each}} +
+ {{/if}} +
+ + {{#if this.legacyItems.length}} + +
{{t "inspection.form.legacy-checklist-help"}}
+
+ {{#each this.legacyItems as |item|}} +
+
+
{{n-a item.label}}
+
{{n-a item.category}}
+
+ {{smart-humanize item.severity}} +
+ {{/each}} +
+
+ {{/if}} + + + +
diff --git a/addon/components/inspection-form/details.js b/addon/components/inspection-form/details.js new file mode 100644 index 000000000..ac044629b --- /dev/null +++ b/addon/components/inspection-form/details.js @@ -0,0 +1,57 @@ +import Component from '@glimmer/component'; +import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { debug } from '@ember/debug'; +import { next } from '@ember/runloop'; +import { task } from 'ember-concurrency'; + +/** + * An inspection form, read-only: what it is, and the groups of fields it is + * built from, in the order a driver answers them. + */ +export default class InspectionFormDetailsComponent extends Component { + @service inspectionFormActions; + + @tracked groups = []; + + constructor() { + super(...arguments); + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.load.perform(); + }); + } + + /** + * The four severities that have a label of their own, as a lookup. A field + * converted from a hand-written first-cut item can carry anything, and + * asking for a translation of that would put "Missing translation" on the + * screen. + */ + severityLabels = INSPECTION_SEVERITIES.reduce((carry, severity) => ({ ...carry, [severity]: `inspection.severity.${severity}` }), {}); + + get fieldCount() { + return this.groups.reduce((count, group) => count + (group.fields?.length ?? 0), 0); + } + + get legacyItems() { + const items = this.args.resource?.items; + return Array.isArray(items) ? items : []; + } + + @task *load() { + if (!this.args.resource?.id) { + return; + } + + try { + this.groups = yield this.inspectionFormActions.loadStructure(this.args.resource); + } catch (error) { + debug('Unable to load inspection form structure: ' + error.message); + } + } +} diff --git a/addon/components/inspection-form/details/submissions.hbs b/addon/components/inspection-form/details/submissions.hbs new file mode 100644 index 000000000..543f77b73 --- /dev/null +++ b/addon/components/inspection-form/details/submissions.hbs @@ -0,0 +1,48 @@ +
+ {{#if this.loadSubmissions.isRunning}} +
+ +
+ {{else if this.submissions.length}} +
+ {{#each this.submissions as |submission|}} + + {{/each}} +
+ {{else}} +
+ +

{{t "inspection.form.no-submissions"}}

+

{{t "inspection.form.no-submissions-description"}}

+
+ {{/if}} +
diff --git a/addon/components/inspection-form/details/submissions.js b/addon/components/inspection-form/details/submissions.js new file mode 100644 index 000000000..67292f9fd --- /dev/null +++ b/addon/components/inspection-form/details/submissions.js @@ -0,0 +1,42 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; + +/** + * What has been filed against this form, newest first. + * + * The mirror of the vehicle's Inspections tab: there you ask what happened to + * one truck, here what a form has collected. Every row shares the form, so the + * vehicle leads instead and the form name is left out entirely. + */ +export default class InspectionFormDetailsSubmissionsComponent extends Component { + @service inspectionSubmissionActions; + @service notifications; + @service store; + @tracked submissions = []; + + get form() { + return this.args.resource ?? this.args.form; + } + + constructor() { + super(...arguments); + this.loadSubmissions.perform(); + } + + @task *loadSubmissions() { + try { + // `inspection_form_uuid` is fillable, which is what makes the index + // filter bind to it — the same route `vehicle_uuid` takes. + const submissions = yield this.store.query('inspection-submission', { + inspection_form_uuid: this.form.uuid ?? this.form.id, + sort: '-created_at', + }); + + this.submissions = Array.from(submissions ?? []); + } catch (error) { + this.notifications.serverError(error); + } + } +} diff --git a/addon/components/inspection-form/form.hbs b/addon/components/inspection-form/form.hbs new file mode 100644 index 000000000..47cce3609 --- /dev/null +++ b/addon/components/inspection-form/form.hbs @@ -0,0 +1,93 @@ +
+ +
+ + + + +
+ +
+
{{option.label}}
+
{{option.description}}
+
+
+
+
+ +
+ +
+
{{option.label}}
+
{{option.description}}
+
+
+
+
+ + + +
+
+ + +
+ {{#each this.settingOptions as |setting|}} + + + + {{/each}} +
+
+ + + + + + {{#if this.legacyItems.length}} + +
{{t "inspection.form.legacy-checklist-help"}}
+
+ {{#each this.legacyItems as |item|}} +
+
+
{{n-a item.label}}
+
{{n-a item.category}}
+
+ {{get-fleet-ops-option-label "inspectionSeverities" item.severity}} +
+ {{/each}} +
+
+ {{/if}} + + +
diff --git a/addon/components/inspection-form/form.js b/addon/components/inspection-form/form.js new file mode 100644 index 000000000..62164f766 --- /dev/null +++ b/addon/components/inspection-form/form.js @@ -0,0 +1,79 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; + +/** + * The inspection form screen: what the form is, and what it is built from. + * + * The structure itself belongs to `inspection-form/builder`, which holds it as + * a draft so a form can be laid out before the record exists; this component + * only passes that draft up to the controller, which posts it with the save. + * + * Nothing writes to `@resource` during render — text inputs update from the + * DOM event, and every other change arrives from an action. + */ +export default class InspectionFormFormComponent extends Component { + @service intl; + + /** + * The three switches a form actually has. Two are read by the server when + * a submission has failures (`InspectionSubmitter`); the third is read by + * the driver app before it will let a driver submit. + */ + get settingOptions() { + return [ + { + key: 'create_issue_on_failure', + label: this.intl.t('inspection.form.setting-create-issue'), + description: this.intl.t('inspection.form.setting-create-issue-help'), + }, + { + key: 'create_work_order_on_failure', + label: this.intl.t('inspection.form.setting-create-work-order'), + description: this.intl.t('inspection.form.setting-create-work-order-help'), + }, + { + key: 'require_signature', + label: this.intl.t('inspection.form.setting-require-signature'), + description: this.intl.t('inspection.form.setting-require-signature-help'), + }, + ]; + } + + get settings() { + const settings = this.args.resource?.settings; + return settings && typeof settings === 'object' ? settings : {}; + } + + /** The first cut's checklist, kept read-only until it has been migrated. */ + get legacyItems() { + const items = this.args.resource?.items; + return Array.isArray(items) ? items : []; + } + + @action setName(event) { + this.args.resource.name = event.target.value; + } + + @action setDescription(event) { + this.args.resource.description = event.target.value; + } + + @action setType(option) { + this.args.resource.type = option?.value ?? null; + } + + @action setStatus(option) { + this.args.resource.status = option?.value ?? null; + } + + @action setSetting(key, event) { + this.args.resource.settings = { ...this.settings, [key]: event.target.checked }; + } + + @action setStructure(groups) { + if (typeof this.args.onStructureChange === 'function') { + this.args.onStructureChange(groups); + } + } +} diff --git a/addon/components/inspection-form/pill.hbs b/addon/components/inspection-form/pill.hbs new file mode 100644 index 000000000..51404dc1d --- /dev/null +++ b/addon/components/inspection-form/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/inspection-form/summary.hbs b/addon/components/inspection-form/summary.hbs new file mode 100644 index 000000000..d221b34f9 --- /dev/null +++ b/addon/components/inspection-form/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/inspection-item-result/pill.hbs b/addon/components/inspection-item-result/pill.hbs new file mode 100644 index 000000000..129e2ad96 --- /dev/null +++ b/addon/components/inspection-item-result/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/inspection-item-result/summary.hbs b/addon/components/inspection-item-result/summary.hbs new file mode 100644 index 000000000..de104a958 --- /dev/null +++ b/addon/components/inspection-item-result/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/inspection-link/list.hbs b/addon/components/inspection-link/list.hbs new file mode 100644 index 000000000..79875f6ab --- /dev/null +++ b/addon/components/inspection-link/list.hbs @@ -0,0 +1,94 @@ + diff --git a/addon/components/inspection-link/list.js b/addon/components/inspection-link/list.js new file mode 100644 index 000000000..1302b364b --- /dev/null +++ b/addon/components/inspection-link/list.js @@ -0,0 +1,129 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard'; + +/** + * The public links minted for one inspection form. + * + * Generating a link used to leave nothing behind but a toast: the URL went to + * the clipboard and, once that clipboard was overwritten, there was no way to + * find out what had been handed out, to whom, or whether it still worked. So + * every link is listed — who and what it was for, when it was made, whether it + * has been opened, and whether it is still live — with the link itself there + * to copy again and a way to take it out of use. + * + * It reloads whenever a link is generated anywhere in the console, by + * watching the form actions service, so a list on the details panel stays + * current while the generate modal is used on top of it. + */ +export default class InspectionLinkListComponent extends Component { + @service fetch; + @service notifications; + @service intl; + @service inspectionFormActions; + + @tracked links = []; + @tracked error = null; + + /** Which link is being revoked, so only its own button spins. */ + @tracked revokingId = null; + + /** Which link's PIN is being sent, and how: `:email` or `:sms`. */ + @tracked sendingKey = null; + + constructor() { + super(...arguments); + this.load.perform(); + } + + get formId() { + const form = this.args.form; + return form?.id ?? form?.public_id ?? form ?? null; + } + + get hasLinks() { + return this.links.length > 0; + } + + /** Who and what a link is for, in one line: its assignee, vehicle and driver. */ + labelFor(link) { + const names = [link?.assignee?.name, link?.vehicle?.name, link?.driver?.name].filter(Boolean); + return [...new Set(names)].join(' · '); + } + + /** The absolute URL for a link, which the server returns only as a path. */ + urlFor(link) { + return link?.path ? `${window.location.origin}${link.path}` : null; + } + + @task({ restartable: true }) *load() { + this.error = null; + + if (!this.formId) { + this.links = []; + return; + } + + try { + const response = yield this.fetch.get(`inspection-forms/${this.formId}/links`); + this.links = (response?.links ?? []).map((link) => ({ ...link, url: this.urlFor(link), label: this.labelFor(link) })); + } catch (error) { + this.error = error?.payload?.error ?? error?.message ?? this.intl.t('inspection.link.load-failed'); + } + } + + /** Reload whenever the caller says it has minted one. */ + @action reload() { + return this.load.perform(); + } + + @action copy(link) { + if (!link.url) { + return; + } + + copyToClipboard(link.url); + this.notifications.success(this.intl.t('inspection.link.copied')); + } + + @action copyPin(link) { + if (!link.pin) { + return; + } + + copyToClipboard(link.pin); + this.notifications.success(this.intl.t('inspection.link.copied-pin')); + } + + /** Send the PIN again, to whoever the link is for. */ + @task({ drop: true }) *sendPin(link, via) { + this.sendingKey = `${link.id}:${via}`; + + try { + const response = yield this.fetch.post(`inspection-forms/${this.formId}/links/${link.id}/send-pin`, { via }); + this.inspectionFormActions.notifyPinDelivery(response?.pin_delivery); + yield this.load.perform(); + } catch (error) { + this.notifications.serverError(error); + } finally { + this.sendingKey = null; + } + } + + @task({ drop: true }) *revoke(link) { + this.revokingId = link.id; + + try { + yield this.fetch.delete(`inspection-forms/${this.formId}/links/${link.id}`); + this.notifications.success(this.intl.t('inspection.link.revoked')); + yield this.load.perform(); + } catch (error) { + this.notifications.serverError(error); + } finally { + this.revokingId = null; + } + } +} diff --git a/addon/components/inspection-sheet.hbs b/addon/components/inspection-sheet.hbs new file mode 100644 index 000000000..a6d0a64a5 --- /dev/null +++ b/addon/components/inspection-sheet.hbs @@ -0,0 +1,77 @@ +
+ {{! Floating flyouts render here: inside the sheet so they scroll with their field, outside its card so they are never clipped. }} +
+ +
+
+ {{#each this.groups key="uuid" as |group|}} + + {{/each}} +
+ + {{#if this.hasFields}} +
+
+
+ {{this.summary.passed}} + {{t "inspection.answer.pass"}} +
+
+ {{this.summary.failed}} + {{t "inspection.answer.fail"}} +
+
+ {{this.summary.notApplicable}} + {{t "inspection.answer.not-applicable"}} +
+
+ {{this.summary.outstanding}} + {{t "inspection.record.outstanding"}} +
+
+ + {{#if this.defects}} +
+
+ {{t "inspection.tray.title"}} + {{this.defects.length}} +
+ {{#each this.defects key="field.uuid" as |defect|}} + + {{/each}} +
+ {{/if}} + + {{#if this.summary.firstOutstanding}} +
+ {{this.outstandingDescription}} + +
+ {{/if}} +
+ {{/if}} +
+
diff --git a/addon/components/inspection-sheet.js b/addon/components/inspection-sheet.js new file mode 100644 index 000000000..d0321e9de --- /dev/null +++ b/addon/components/inspection-sheet.js @@ -0,0 +1,152 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { flattenFields } from '../utils/inspection-form-structure'; +import { summarize, listDefects } from '../utils/inspection-answers'; +import { INSPECTION_SEVERITIES } from '../utils/inspection-field-types'; + +/** + * An inspection form, being filled in. + * + * One component renders the sheet wherever it is answered — the console's + * submission screen, the read-only record, and the public link a driver opens + * on a phone — so the three cannot drift apart. `@values` in, `@onChange` + * out; the screen that owns the answers holds them, and nothing is written + * here during render. + * + * The foot is a tally and, when something is wrong, a banner that names the + * field rather than counting it: an inspector is told what to go and fix, and + * can jump straight to it. + */ +export default class InspectionSheetComponent extends Component { + @service intl; + + /** The field a banner last jumped to, held for a moment so the eye finds it. */ + @tracked targetId = null; + + /** The one field whose defect flyout is open. Opening another closes it. */ + @tracked openFieldId = null; + + get groups() { + return Array.isArray(this.args.groups) ? this.args.groups : []; + } + + get fields() { + return flattenFields(this.groups); + } + + get summary() { + return summarize(this.fields, this.args.values ?? {}); + } + + get hasFields() { + return this.fields.length > 0; + } + + /** + * Every failure on the sheet, for the tray at its foot: the severity, the + * field, and what evidence it has or still owes. It is the record of a + * defect once its flyout is closed, and the review step before submitting. + */ + get defects() { + return listDefects(this.fields, this.args.values ?? {}).map((defect) => ({ + ...defect, + label: defect.field.label || this.intl.t('inspection.builder.untitled-field'), + severityLabel: this.severityLabel(defect.severity), + evidence: this.evidenceOf(defect), + })); + } + + severityLabel(severity) { + if (!severity) { + return this.intl.t('inspection.answer.fail'); + } + + return INSPECTION_SEVERITIES.includes(severity) ? this.intl.t(`inspection.severity.${severity}`) : severity; + } + + /** "2 photos · comment", or what is still owed, in the order it is owed. */ + evidenceOf(defect) { + if (defect.needsComment && defect.needsPhoto) { + return this.intl.t('inspection.defect.needs-both'); + } + + if (defect.needsComment) { + return this.intl.t('inspection.defect.needs-comment'); + } + + if (defect.needsPhoto) { + return this.intl.t('inspection.defect.needs-photo'); + } + + const parts = []; + + if (defect.photoCount) { + parts.push(this.intl.t('inspection.defect.photos', { count: defect.photoCount })); + } + + if (defect.hasComment) { + parts.push(this.intl.t('inspection.defect.comment')); + } + + return parts.length ? parts.join(' · ') : this.intl.t('inspection.defect.no-evidence'); + } + + get outstandingDescription() { + const field = this.summary.firstOutstanding; + + if (!field) { + return null; + } + + return this.intl.t('inspection.record.outstanding-field', { + label: field.label || this.intl.t('inspection.builder.untitled-field'), + }); + } + + /** + * Scroll a named field into view and mark it. + * + * By id rather than by a held element reference: a promoted field moves + * between the grid and its band as the answer changes, so the element the + * banner points at is not the one that existed when the banner rendered. + */ + @action openFlyout(field) { + this.openFieldId = field?.uuid ?? null; + } + + /** + * Close a field's flyout — only if it is still the open one, so a close + * that arrives after another field has opened cannot shut the new one. + */ + @action closeFlyout(field) { + if (!field || this.openFieldId === field.uuid) { + this.openFieldId = null; + } + } + + /** From the tray: bring the defect into view and open it to be edited. */ + @action reviewDefect(field) { + this.jumpTo(field); + + if (!this.args.readonly && !this.args.disabled) { + this.openFieldId = field?.uuid ?? null; + } + } + + @action jumpTo(field) { + if (!field?.uuid) { + return; + } + + this.targetId = field.uuid; + + const element = document.getElementById(`inspection-field-${field.uuid}`); + + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + element.querySelector('input, textarea, button')?.focus({ preventScroll: true }); + } + } +} diff --git a/addon/components/inspection-sheet/group.hbs b/addon/components/inspection-sheet/group.hbs new file mode 100644 index 000000000..7d2368bce --- /dev/null +++ b/addon/components/inspection-sheet/group.hbs @@ -0,0 +1,45 @@ +
+
+ {{this.title}} + {{#if this.markers}} + + {{/if}} + {{#if this.hasOutstanding}} + {{this.outstanding}} + {{/if}} +
+ + {{#if @group.description}} +

{{@group.description}}

+ {{/if}} + + {{#if this.fields}} +
+ {{#each this.fields key="uuid" as |field|}} + {{#if @readonly}} + + {{else}} + + {{/if}} + {{/each}} +
+ {{else}} +
{{t "inspection.record.group-has-no-fields"}}
+ {{/if}} +
diff --git a/addon/components/inspection-sheet/group.js b/addon/components/inspection-sheet/group.js new file mode 100644 index 000000000..c0c5dbcf3 --- /dev/null +++ b/addon/components/inspection-sheet/group.js @@ -0,0 +1,75 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { summarize, fieldMarker } from '../../utils/inspection-answers'; + +const MAX_COLUMNS = 4; + +/** + * One group of an inspection form. + * + * The author's `grid_size` is honoured for fields that stay compact. A field + * that needs room keeps its place in the order and spans the full width of + * the grid instead, which is what keeps one answer from changing the shape of + * another: alone on its row, it has no neighbouring cell to stretch. + * + * It spans in place rather than moving to the end of the group. Moving it + * re-sorted the group the moment a check failed, and the fields after it + * jumped up past it. + * + * The header carries one dot per field, in the order they are answered, so an + * inspector can see what is still open in a group without reading a label. + */ +export default class InspectionSheetGroupComponent extends Component { + @service intl; + + get group() { + return this.args.group ?? {}; + } + + get title() { + return this.group.name || this.intl.t('inspection.builder.untitled-group'); + } + + get fields() { + return Array.isArray(this.group.fields) ? this.group.fields : []; + } + + get values() { + return this.args.values ?? {}; + } + + /** What the author asked for, within what a panel can actually show. */ + get columns() { + const size = Number(this.group.meta?.grid_size); + + if (!Number.isFinite(size) || size < 1) { + return 1; + } + + return Math.min(Math.round(size), MAX_COLUMNS); + } + + get markers() { + return this.fields.map((field) => ({ + uuid: field.uuid, + marker: fieldMarker(field, this.values[field.uuid]), + })); + } + + get summary() { + return summarize(this.fields, this.values); + } + + /** + * The one thing the header says on the right, and only when there is + * something to say. How the group is laid out is not news to whoever is + * filling it in. + */ + get outstanding() { + return this.intl.t('inspection.record.section-outstanding', { count: this.summary.outstanding }); + } + + get hasOutstanding() { + return this.summary.outstanding > 0; + } +} diff --git a/addon/components/inspection-submission/details.hbs b/addon/components/inspection-submission/details.hbs new file mode 100644 index 000000000..4e2c38ccc --- /dev/null +++ b/addon/components/inspection-submission/details.hbs @@ -0,0 +1,149 @@ +
+ +
+
+
{{t "inspection.record.inspection"}}
+ {{n-a @resource.public_id}} +
+
+
{{t "inspection.record.result"}}
+
{{smart-humanize @resource.result}}
+
+
+
{{t "inspection.record.form"}}
+
+ +
+
+
+
{{t "inspection.record.status"}}
+
{{smart-humanize @resource.status}}
+
+
+
{{t "inspection.record.vehicle"}}
+
+
+
+
{{t "inspection.record.driver"}}
+
+
+
+
{{t "inspection.record.submitted-by"}}
+
+ + {{#if this.submitterNote}} +
{{this.submitterNote}}
+ {{/if}} +
+
+
+
{{t "inspection.record.odometer"}}
+
{{n-a @resource.odometer}}
+
+
+
{{t "inspection.record.engine-hours"}}
+
{{n-a @resource.engine_hours}}
+
+
+
{{t "inspection.record.submitted"}}
+
{{n-a (format-date-fns @resource.submitted_at "dd MMM yyyy, HH:mm")}}
+
+
+
{{t "inspection.record.resolved"}}
+
{{n-a (format-date-fns @resource.resolved_at "dd MMM yyyy, HH:mm")}}
+
+
+
+ + {{#if this.load.isRunning}} + +
+ +
+
+ {{else if this.hasAnswers}} +
+ +
+ {{/if}} + + {{! Derived from the pass/fail answers above, so it is shown only for a + submission filed before typed fields, which has nothing else to read. }} + {{#unless this.hasAnswers}} + +
+ {{#each @resource.item_results as |item|}} +
+
+
+
{{n-a item.label}}
+
{{n-a item.category}}
+ {{#if item.comments}} +
{{item.comments}}
+ {{/if}} +
+
+ {{if item.passed (t "inspection.answer.pass") (t "inspection.answer.fail")}} + {{#if item.severity}} + {{smart-humanize item.severity}} + {{/if}} +
+
+
+ {{else}} +
{{t "inspection.record.no-item-results"}}
+ {{/each}} +
+
+ {{/unless}} + + + {{! A bare id was a dead end: each follow-up says what it is and opens it. }} +
+ {{#if @resource.issue}} + + {{else}} +
+
{{t "inspection.record.linked-issue"}}
+
{{n-a null}}
+
+ {{/if}} + + {{#if @resource.work_order}} + + {{else}} +
+
{{t "inspection.record.linked-work-order"}}
+
{{n-a @resource.work_order_uuid}}
+
+ {{/if}} +
+
+ + + +
diff --git a/addon/components/inspection-submission/details.js b/addon/components/inspection-submission/details.js new file mode 100644 index 000000000..a1bdd38d4 --- /dev/null +++ b/addon/components/inspection-submission/details.js @@ -0,0 +1,130 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { debug } from '@ember/debug'; +import { next } from '@ember/runloop'; +import { task } from 'ember-concurrency'; + +/** + * The Overview tab of an inspection record. + * + * The answers are read from the submission's own payload — the resource + * projects them with each field's identity and every file reference resolved — + * and laid out against the form's groups, so the record reads the way the form + * was built. Nothing is written here. + */ +export default class InspectionSubmissionDetailsComponent extends Component { + @service inspectionFormActions; + @service inspectionSubmissionActions; + @service intl; + @service hostRouter; + @service vehicleActions; + @service driverActions; + + @tracked groups = []; + @tracked values = {}; + + constructor() { + super(...arguments); + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.load.perform(); + }); + } + + /** + * Who filed it: the account it is credited to — whoever signed in to the + * console, or whoever a public link was for — or else the name typed on + * the link, when the link was for nobody in particular. + */ + get submitter() { + const submission = this.args.resource; + return submission?.submitted_by?.name ?? submission?.meta?.completed_by_name ?? null; + } + + /** + * How that was established, for a submission that came through a public + * link: whether a PIN stood in the way, and what name was typed when it + * differs from the account or there is no account to check it against. + */ + get submitterNote() { + const submission = this.args.resource; + if (submission?.source !== 'public_link') { + return null; + } + + const typed = (submission.meta?.completed_by_name ?? '').trim(); + const account = (submission.submitted_by?.name ?? '').trim(); + const notes = [this.intl.t(submission.meta?.pin_verified ? 'inspection.record.via-link-pin' : 'inspection.record.via-link')]; + + if (typed && account && typed.toLowerCase() !== account.toLowerCase()) { + notes.push(this.intl.t('inspection.record.signed-as', { name: typed })); + } else if (typed && !account) { + notes.push(this.intl.t('inspection.record.name-unverified')); + } + + return notes.join(' · '); + } + + /** Follow-ups open where they live, the way the rest of the console navigates. */ + @action openForm() { + return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', this.args.resource.form); + } + + /** + * A linked record opens beside what you were reading, which is what the + * console does everywhere else; the route transition is the fallback for + * a resource whose panel is not registered. + */ + @action viewVehicle() { + const vehicle = this.args.resource?.vehicle; + if (!vehicle) { + return; + } + + return this.vehicleActions.panel?.view ? this.vehicleActions.panel.view(vehicle) : this.vehicleActions.transition.view(vehicle); + } + + @action viewDriver() { + const driver = this.args.resource?.driver; + if (!driver) { + return; + } + + return this.driverActions.panel?.view ? this.driverActions.panel.view(driver) : this.driverActions.transition.view(driver); + } + + @action openIssue() { + return this.hostRouter.transitionTo('console.fleet-ops.management.issues.index.details', this.args.resource.issue); + } + + @action openWorkOrder() { + return this.hostRouter.transitionTo('console.fleet-ops.maintenance.work-orders.index.details', this.args.resource.work_order); + } + + get hasAnswers() { + return this.groups.some((group) => (group.fields ?? []).length > 0); + } + + @task *load() { + const submission = this.args.resource; + if (!submission?.id) { + return; + } + + try { + this.values = yield this.inspectionSubmissionActions.loadAnswers(submission); + + const form = submission.form; + if (form?.id) { + this.groups = yield this.inspectionFormActions.loadStructure(form); + } + } catch (error) { + debug('Unable to load inspection answers: ' + error.message); + } + } +} diff --git a/addon/components/inspection-submission/form.hbs b/addon/components/inspection-submission/form.hbs new file mode 100644 index 000000000..211f2e3dc --- /dev/null +++ b/addon/components/inspection-submission/form.hbs @@ -0,0 +1,114 @@ +{{! + An inspection being filled in, in the console. + + The record's own details stay in a content panel, like every other + resource form in the console. Only the selected form's field groups are + rendered differently, as an `inspection-sheet` — the same sheet a public + link renders, so the two cannot drift. +}} +
+ +
+ + + + + + +
+ +
+
{{option.label}}
+
{{option.description}}
+
+
+
+
+ + + + + + + + + + + + + + + + +
+
+ +
+ {{#if this.load.isRunning}} +
+
+
+ +
+
+
+ {{else if this.hasStructure}} + + {{else}} +
+
+
+ {{#if @resource.form}}{{t "inspection.record.form-has-no-fields"}}{{else}}{{t "inspection.record.choose-a-form"}}{{/if}} +
+
+
+ {{/if}} +
+ + + + + + +
diff --git a/addon/components/inspection-submission/form.js b/addon/components/inspection-submission/form.js new file mode 100644 index 000000000..7305dd394 --- /dev/null +++ b/addon/components/inspection-submission/form.js @@ -0,0 +1,129 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { next } from '@ember/runloop'; +import { task } from 'ember-concurrency'; +import { flattenFields } from '../../utils/inspection-form-structure'; +import { answerRows, seedAnswers } from '../../utils/inspection-answers'; + +const STATUS_OPTIONS = ['draft', 'submitted', 'needs_review', 'resolved']; + +/** + * An inspection being filled in. + * + * The details of the inspection come first, then the selected form's field + * groups as an `inspection-sheet` — the same sheet a public link renders, so + * the two cannot drift. The answers live here, keyed by field uuid, and are + * handed up through `@onAnswersChange` as the rows the server accepts — the + * same `custom_field_values` body the driver API takes, so the console, a + * link and the app all write the same thing and the item results are derived + * from the pass-fail answers among them. + * + * Nothing writes to `@resource` during render: the structure and the stored + * answers are loaded in tasks, and every value change arrives from an event. + */ +export default class InspectionSubmissionFormComponent extends Component { + @service inspectionFormActions; + @service inspectionSubmissionActions; + @service notifications; + + @tracked groups = []; + @tracked values = {}; + + statusOptions = STATUS_OPTIONS; + + constructor() { + super(...arguments); + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.load.perform(this.args.resource?.form); + }); + } + + get fields() { + return flattenFields(this.groups); + } + + get hasStructure() { + return this.fields.length > 0; + } + + @task *load(form) { + this.groups = []; + + if (!form?.id) { + return; + } + + try { + const groups = yield this.inspectionFormActions.loadStructure(form); + const stored = this.args.resource?.id && !this.args.resource?.isNew ? yield this.inspectionSubmissionActions.loadAnswers(this.args.resource) : {}; + + this.groups = groups; + this.values = seedAnswers(groups, stored); + this.announce(); + } catch (error) { + this.notifications.serverError(error); + } + } + + /** The answers, as the server accepts them. */ + get rows() { + return answerRows(this.fields, this.values); + } + + announce() { + if (typeof this.args.onAnswersChange === 'function') { + this.args.onAnswersChange(this.rows); + } + } + + @action setValue(value, field) { + this.values = { ...this.values, [field.uuid]: value }; + + // A meter field marked as the odometer keeps the submission's own + // odometer column in step, so the vehicle's reading follows the + // inspection without the inspector typing it twice. + if (field.type === 'number' && field.meta?.role === 'odometer' && value !== null && value !== '') { + this.args.resource.odometer = Number(value); + } + + this.announce(); + } + + @action assignForm(form) { + this.args.resource.form = form; + // The relationship is what the save sends; the column is kept in step + // with the form's own uuid, never the id the console addresses it by. + this.args.resource.inspection_form_uuid = form?.uuid ?? form?.id ?? null; + this.args.resource.type = form?.type || this.args.resource.type || 'dvir'; + + return this.load.perform(form); + } + + @action assignVehicle(vehicle) { + this.args.resource.vehicle = vehicle; + } + + @action assignDriver(driver) { + this.args.resource.driver = driver; + } + + @action setStatus(option) { + this.args.resource.status = option?.value ?? null; + } + + @action setOdometer(event) { + const value = event.target.value; + this.args.resource.odometer = value === '' ? null : Number(value); + } + + @action setEngineHours(event) { + const value = event.target.value; + this.args.resource.engine_hours = value === '' ? null : Number(value); + } +} diff --git a/addon/components/inspection-submission/photos.hbs b/addon/components/inspection-submission/photos.hbs new file mode 100644 index 000000000..cc7c54ffb --- /dev/null +++ b/addon/components/inspection-submission/photos.hbs @@ -0,0 +1,20 @@ +
+ + {{#if this.load.isRunning}} +
+ +
+ {{else}} + + {{/if}} +
+ +
diff --git a/addon/components/inspection-submission/photos.js b/addon/components/inspection-submission/photos.js new file mode 100644 index 000000000..7ca3e0bcc --- /dev/null +++ b/addon/components/inspection-submission/photos.js @@ -0,0 +1,57 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { debug } from '@ember/debug'; +import { next } from '@ember/runloop'; +import { task } from 'ember-concurrency'; + +const PHOTO_TYPE = 'inspection_photo'; + +/** + * The Photos tab of an inspection record. + * + * Every photo filed against the submission — the ones the driver sent with a + * failed pass-fail answer, stored by `InspectionFileStore`, and the ones added + * here — is a platform file whose subject is the submission, so one query + * finds them all and `ModelMultiFileUpload` adds to the same pile. + */ +export default class InspectionSubmissionPhotosComponent extends Component { + @service store; + + @tracked files = []; + + photoType = PHOTO_TYPE; + + constructor() { + super(...arguments); + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.load.perform(); + }); + } + + @task *load() { + const submission = this.args.resource; + // Files are filed against the submission's uuid, which is what the + // internal resource answers beside the id the console addresses it by. + const subjectUuid = submission?.uuid ?? submission?.id; + if (!subjectUuid) { + return; + } + + try { + const files = yield this.store.query('file', { subject_uuid: subjectUuid, type: PHOTO_TYPE, limit: -1 }); + this.files = files.toArray(); + } catch (error) { + debug('Unable to load inspection photos: ' + error.message); + } + } + + @action addFile(file) { + this.files = [...this.files, file]; + } +} diff --git a/addon/components/inspection-submission/pill.hbs b/addon/components/inspection-submission/pill.hbs new file mode 100644 index 000000000..cb2f81ecb --- /dev/null +++ b/addon/components/inspection-submission/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/inspection-submission/summary.hbs b/addon/components/inspection-submission/summary.hbs new file mode 100644 index 000000000..fb2b028e1 --- /dev/null +++ b/addon/components/inspection-submission/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/integrated-vendor/pill.hbs b/addon/components/integrated-vendor/pill.hbs new file mode 100644 index 000000000..0e0f3ed11 --- /dev/null +++ b/addon/components/integrated-vendor/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/integrated-vendor/summary.hbs b/addon/components/integrated-vendor/summary.hbs new file mode 100644 index 000000000..00150e33c --- /dev/null +++ b/addon/components/integrated-vendor/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/issue/details.hbs b/addon/components/issue/details.hbs index 2b7d0e5a7..9ca7d4a3d 100644 --- a/addon/components/issue/details.hbs +++ b/addon/components/issue/details.hbs @@ -138,6 +138,18 @@
+ {{! Only when there is one: most issues were never raised from an inspection. }} + {{#if this.inspections.length}} +
+
{{t "inspection.record.linked-inspections" count=this.inspections.length}}
+
+ {{#each this.inspections as |inspection|}} + + {{/each}} +
+
+ {{/if}} +
{{t "order.card.linked-order"}}
{{#if this.order}} diff --git a/addon/components/issue/details.js b/addon/components/issue/details.js index 6ddc0b009..d2deeccc8 100644 --- a/addon/components/issue/details.js +++ b/addon/components/issue/details.js @@ -1,11 +1,43 @@ import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import { debug } from '@ember/debug'; +import { task } from 'ember-concurrency'; import { formatDistanceStrict, isValid } from 'date-fns'; export default class IssueDetailsComponent extends Component { @service hostRouter; @service issueActions; + @service store; + @service inspectionSubmissionActions; + @tracked inspections = []; + + constructor() { + super(...arguments); + this.loadInspections.perform(); + } + + /** + * An inspection that failed raises an issue and names it; the issue never + * looked back, so what caused it was invisible from the issue. Looked up + * rather than read from meta, so an inspection attached by hand shows too. + */ + @task *loadInspections() { + const id = this.args.resource?.uuid ?? this.args.resource?.id; + if (!id) { + return; + } + + try { + const inspections = yield this.store.query('inspection-submission', { issue_uuid: id, sort: '-created_at' }); + this.inspections = Array.from(inspections ?? []); + } catch (error) { + // Supplementary to the issue: a failed lookup should not put an error + // in front of someone who opened the issue to read it. + debug(`Could not load the inspections linked to issue ${id}: ${error.message}`); + } + } get resource() { return this.args.resource; @@ -146,4 +178,8 @@ export default class IssueDetailsComponent extends Component { @action viewDriver() { return this.issueActions.viewDriver(this.resource); } + + @action viewInspection(submission) { + return this.inspectionSubmissionActions.transition.view(submission); + } } diff --git a/addon/components/issue/form.hbs b/addon/components/issue/form.hbs index c4b6942d4..fb87fe250 100644 --- a/addon/components/issue/form.hbs +++ b/addon/components/issue/form.hbs @@ -5,6 +5,7 @@ - {{model.name}} + - {{model.name}} + - {{model.name}} + - {{model.displayName}} + - {{or model.tracking model.public_id}} + diff --git a/addon/components/issue/pill.hbs b/addon/components/issue/pill.hbs new file mode 100644 index 000000000..13fbeebbd --- /dev/null +++ b/addon/components/issue/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/issue/summary.hbs b/addon/components/issue/summary.hbs new file mode 100644 index 000000000..b79db4b8e --- /dev/null +++ b/addon/components/issue/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js index 1258c5df0..0abf9056f 100644 --- a/addon/components/layout/fleet-ops-sidebar.js +++ b/addon/components/layout/fleet-ops-sidebar.js @@ -135,10 +135,13 @@ export default class LayoutFleetOpsSidebarComponent extends Component { get resourcesItems() { return this.withRegistryItems('management', [ - this.createHubItem('Resources Hub', 'layer-group', 'management.index', 'fleet-ops list driver', 'fleet-ops see driver', [ + this.createHubItem(this.intl.t('menu.radar'), 'satellite-dish', 'management.index', 'fleet-ops list driver', 'fleet-ops see driver', [ + 'radar', + 'needs attention', 'resources hub', - 'resource dashboard', - 'resource readiness', + 'triage', + 'handover', + 'shift changes', ]), this.createItem('menu.drivers', 'id-card', 'management.drivers', 'fleet-ops list driver', 'fleet-ops see driver', ['driver', 'online drivers']), this.createItem('menu.vehicles', 'truck', 'management.vehicles', 'fleet-ops list vehicle', 'fleet-ops see vehicle', ['vehicle', 'track vehicles', 'online vehicles']), @@ -160,6 +163,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component { 'service readiness', 'maintenance control panel', ]), + this.createItem('menu.inspection-forms', 'clipboard-check', 'maintenance.inspection-forms', 'fleet-ops list inspection-form', 'fleet-ops see inspection-form', [ + 'dvir', + 'checklist', + ]), + this.createItem('menu.inspections', 'list-check', 'maintenance.inspection-submissions', 'fleet-ops list inspection-submission', 'fleet-ops see inspection-submission', [ + 'dvir', + 'defects', + ]), this.createItem('menu.schedules', 'calendar-alt', 'maintenance.schedules', 'fleet-ops list maintenance-schedule', 'fleet-ops see maintenance-schedule'), this.createItem('menu.work-orders', 'clipboard-list', 'maintenance.work-orders', 'fleet-ops list work-order', 'fleet-ops see work-order'), this.createItem('menu.maintenances', 'history', 'maintenance.maintenances', 'fleet-ops list maintenance', 'fleet-ops see maintenance'), diff --git a/addon/components/maintenance-schedule/details.hbs b/addon/components/maintenance-schedule/details.hbs index 87ab608d3..839ca93e2 100644 --- a/addon/components/maintenance-schedule/details.hbs +++ b/addon/components/maintenance-schedule/details.hbs @@ -33,12 +33,16 @@
Asset
-
{{n-a (or @resource.subject.displayName @resource.subject.name @resource.subject_name)}}
+
+ +
- {{#if (or @resource.default_assignee.displayName @resource.default_assignee.name)}} + {{#if (or @resource.default_assignee @resource.default_assignee_name)}}
Default Assignee
-
{{n-a (or @resource.default_assignee.displayName @resource.default_assignee.name)}}
+
+ +
{{/if}}
diff --git a/addon/components/maintenance-schedule/form.hbs b/addon/components/maintenance-schedule/form.hbs index 583ae7b6d..971aae801 100644 --- a/addon/components/maintenance-schedule/form.hbs +++ b/addon/components/maintenance-schedule/form.hbs @@ -57,6 +57,7 @@ @selected={{this.selectedSubjectType}} @onChange={{this.onSubjectTypeChange}} @placeholder="Select Asset Type" + @allowClear={{true}} @triggerClass="form-select form-input" @disabled={{cannot-write @resource}} as |option| @@ -70,8 +71,10 @@ - {{or model.displayName model.name model.public_id}} + {{#let (resource-component "select-option" this.subjectModelName) as |selectOption|}} + {{#if selectOption}} + {{component selectOption option=model}} + {{else}} + {{or model.displayName model.name model.public_id}} + {{/if}} + {{/let}} {{/if}} @@ -207,6 +216,7 @@ @selected={{this.selectedAssigneeType}} @onChange={{this.onAssigneeTypeChange}} @placeholder="Select Assignee Type" + @allowClear={{true}} @triggerClass="form-select form-input" @disabled={{cannot-write @resource}} as |option| @@ -220,8 +230,10 @@ - {{or model.displayName model.name model.public_id}} + {{#let (resource-component "select-option" this.assigneeModelName) as |selectOption|}} + {{#if selectOption}} + {{component selectOption option=model}} + {{else}} + {{or model.displayName model.name model.public_id}} + {{/if}} + {{/let}} {{/if}} diff --git a/addon/components/maintenance-schedule/form.js b/addon/components/maintenance-schedule/form.js index a9aeca8a2..306fb2458 100644 --- a/addon/components/maintenance-schedule/form.js +++ b/addon/components/maintenance-schedule/form.js @@ -141,25 +141,25 @@ export default class MaintenanceScheduleFormComponent extends Component { } @action onSubjectTypeChange(option) { - this.selectedSubjectType = option; + this.selectedSubjectType = option ?? null; // Clear the subject relationship — user must re-select the asset this.args.resource.subject = null; - this.subjectModelName = TYPE_TO_MODEL[option.value] ?? null; + this.subjectModelName = TYPE_TO_MODEL[option?.value] ?? null; } @action assignSubject(model) { - this.args.resource.subject = model; + this.args.resource.subject = model ?? null; } @action onAssigneeTypeChange(option) { - this.selectedAssigneeType = option; + this.selectedAssigneeType = option ?? null; // Clear the default_assignee relationship — user must re-select this.args.resource.default_assignee = null; - this.assigneeModelName = TYPE_TO_MODEL[option.value] ?? null; + this.assigneeModelName = TYPE_TO_MODEL[option?.value] ?? null; } @action assignDefaultAssignee(model) { - this.args.resource.default_assignee = model; + this.args.resource.default_assignee = model ?? null; } @action addReminderOffset(value) { diff --git a/addon/components/maintenance-schedule/pill.hbs b/addon/components/maintenance-schedule/pill.hbs new file mode 100644 index 000000000..c04bff71d --- /dev/null +++ b/addon/components/maintenance-schedule/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/maintenance-schedule/summary.hbs b/addon/components/maintenance-schedule/summary.hbs new file mode 100644 index 000000000..5cd8ee6ec --- /dev/null +++ b/addon/components/maintenance-schedule/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/maintenance-schedule/work-orders.hbs b/addon/components/maintenance-schedule/work-orders.hbs new file mode 100644 index 000000000..55748a129 --- /dev/null +++ b/addon/components/maintenance-schedule/work-orders.hbs @@ -0,0 +1,39 @@ +
+ {{#if this.load.isRunning}} +
+ {{else if this.workOrders.length}} +
+ {{#each this.workOrders as |workOrder|}} + + {{/each}} +
+ {{else}} +
+ +

No work orders yet

+

Work orders will appear here when this schedule triggers.

+
+ {{/if}} +
diff --git a/addon/components/maintenance-schedule/work-orders.js b/addon/components/maintenance-schedule/work-orders.js new file mode 100644 index 000000000..d503bc53b --- /dev/null +++ b/addon/components/maintenance-schedule/work-orders.js @@ -0,0 +1,37 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +/** + * The work orders a maintenance schedule has raised, for the schedule's + * context panel. The details route loads the same list in its own route. + */ +export default class MaintenanceScheduleWorkOrdersComponent extends Component { + @service store; + @service workOrderActions; + @tracked workOrders = []; + + constructor() { + super(...arguments); + this.load.perform(); + } + + get schedule() { + return this.args.resource ?? this.args.model; + } + + @task *load() { + if (!this.schedule?.id) { + return; + } + + const workOrders = yield this.store.query('work-order', { schedule_uuid: this.schedule.id }); + this.workOrders = workOrders?.toArray?.() ?? [...(workOrders ?? [])]; + } + + @action view(workOrder) { + return this.workOrderActions.panel.view(workOrder); + } +} diff --git a/addon/components/maintenance-subject/pill.hbs b/addon/components/maintenance-subject/pill.hbs new file mode 100644 index 000000000..a514de004 --- /dev/null +++ b/addon/components/maintenance-subject/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/maintenance-subject/summary.hbs b/addon/components/maintenance-subject/summary.hbs new file mode 100644 index 000000000..dbd28e18d --- /dev/null +++ b/addon/components/maintenance-subject/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/maintenance/details.hbs b/addon/components/maintenance/details.hbs index 2a5de8ecc..d741d5da1 100644 --- a/addon/components/maintenance/details.hbs +++ b/addon/components/maintenance/details.hbs @@ -43,16 +43,22 @@
Maintainable Asset
-
{{n-a (or @resource.maintainable.displayName @resource.maintainable.name @resource.maintainable_name)}}
+
+ +
Performed By
-
{{n-a (or @resource.performed_by.displayName @resource.performed_by.name @resource.performed_by_name)}}
+
+ +
{{#if (or @resource.work_order_uuid @resource.work_order_subject)}}
Linked Work Order
-
{{n-a @resource.work_order_subject}}
+
+ +
{{/if}} diff --git a/addon/components/maintenance/form.hbs b/addon/components/maintenance/form.hbs index 0e2522c27..221731c88 100644 --- a/addon/components/maintenance/form.hbs +++ b/addon/components/maintenance/form.hbs @@ -74,6 +74,7 @@ @selected={{this.selectedMaintainableType}} @onChange={{this.onMaintainableTypeChange}} @placeholder="Select asset type (Vehicle, Equipment…)" + @allowClear={{true}} @triggerClass="form-select form-input" @disabled={{cannot-write @resource}} as |option| @@ -86,8 +87,10 @@ - {{or model.name model.public_id}} + {{#let (resource-component "select-option" this.maintainableModelName) as |selectOption|}} + {{#if selectOption}} + {{component selectOption option=model}} + {{else}} + {{or model.displayName model.name model.public_id}} + {{/if}} + {{/let}} {{/if}} @@ -110,6 +119,7 @@ @selected={{this.selectedPerformedByType}} @onChange={{this.onPerformedByTypeChange}} @placeholder="Select performer type (Vendor, Driver, User…)" + @allowClear={{true}} @triggerClass="form-select form-input" @disabled={{cannot-write @resource}} as |option| @@ -122,8 +132,10 @@ - {{or model.name model.public_id}} + {{#let (resource-component "select-option" this.performedByModelName) as |selectOption|}} + {{#if selectOption}} + {{component selectOption option=model}} + {{else}} + {{or model.displayName model.name model.public_id}} + {{/if}} + {{/let}} {{/if}} @@ -142,6 +160,7 @@ - {{or model.subject model.code model.public_id}} + diff --git a/addon/components/maintenance/form.js b/addon/components/maintenance/form.js index d5339843c..2bf04e198 100644 --- a/addon/components/maintenance/form.js +++ b/addon/components/maintenance/form.js @@ -122,7 +122,7 @@ export default class MaintenanceFormComponent extends Component { * maintainable relationship so a stale association is not persisted. */ @action onMaintainableTypeChange(option) { - this.selectedMaintainableType = option; + this.selectedMaintainableType = option ?? null; // Clear the maintainable relationship — user must re-select the asset this.args.resource.maintainable = null; this.maintainableModelName = TYPE_TO_MODEL[option?.value] ?? null; @@ -130,7 +130,7 @@ export default class MaintenanceFormComponent extends Component { /** Assigns the selected maintainable model to the resource. */ @action assignMaintainable(model) { - this.args.resource.maintainable = model; + this.args.resource.maintainable = model ?? null; } /** @@ -138,7 +138,7 @@ export default class MaintenanceFormComponent extends Component { * performed-by relationship so a stale association is not persisted. */ @action onPerformedByTypeChange(option) { - this.selectedPerformedByType = option; + this.selectedPerformedByType = option ?? null; // Clear the performed_by relationship — user must re-select this.args.resource.performed_by = null; this.performedByModelName = TYPE_TO_MODEL[option?.value] ?? null; @@ -146,6 +146,6 @@ export default class MaintenanceFormComponent extends Component { /** Assigns the selected performer model to the resource. */ @action assignPerformedBy(model) { - this.args.resource.performed_by = model; + this.args.resource.performed_by = model ?? null; } } diff --git a/addon/components/maintenance/panel-header.hbs b/addon/components/maintenance/panel-header.hbs index 7e096ed84..790b4402e 100644 --- a/addon/components/maintenance/panel-header.hbs +++ b/addon/components/maintenance/panel-header.hbs @@ -1,21 +1,23 @@ -
-
-
-
-

{{n-a @resource.summary}}

- {{smart-humanize @resource.status}} +
+
+
+
+

{{n-a @resource.summary}}

+ {{smart-humanize @resource.status}}
-
-
{{smart-humanize @resource.type}}
+
+ {{#if @resource.type}} + {{smart-humanize @resource.type}} + {{/if}} {{#if @resource.priority}} -
{{t "column.priority"}}: {{smart-humanize @resource.priority}}
+ {{t "column.priority"}}: {{smart-humanize @resource.priority}} {{/if}} {{#if @resource.scheduled_at}} -
{{t "column.scheduled-at"}}: {{format-date @resource.scheduled_at}}
+ {{t "column.scheduled-at"}}: {{format-date @resource.scheduled_at}} {{/if}}
-
+
-
\ No newline at end of file +
diff --git a/addon/components/maintenance/pill.hbs b/addon/components/maintenance/pill.hbs new file mode 100644 index 000000000..68f4c9b37 --- /dev/null +++ b/addon/components/maintenance/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/maintenance/summary.hbs b/addon/components/maintenance/summary.hbs new file mode 100644 index 000000000..50002ece8 --- /dev/null +++ b/addon/components/maintenance/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/manifest-stop/pill.hbs b/addon/components/manifest-stop/pill.hbs new file mode 100644 index 000000000..ba48adcce --- /dev/null +++ b/addon/components/manifest-stop/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/manifest-stop/summary.hbs b/addon/components/manifest-stop/summary.hbs new file mode 100644 index 000000000..733b97589 --- /dev/null +++ b/addon/components/manifest-stop/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/manifest/pill.hbs b/addon/components/manifest/pill.hbs new file mode 100644 index 000000000..e72d70b86 --- /dev/null +++ b/addon/components/manifest/pill.hbs @@ -0,0 +1,24 @@ + + {{yield resource}} + diff --git a/addon/components/manifest/summary.hbs b/addon/components/manifest/summary.hbs new file mode 100644 index 000000000..336137802 --- /dev/null +++ b/addon/components/manifest/summary.hbs @@ -0,0 +1 @@ + diff --git a/addon/components/map/drawer/device-event-listing.hbs b/addon/components/map/drawer/device-event-listing.hbs index 8c04220dc..8b75012ca 100644 --- a/addon/components/map/drawer/device-event-listing.hbs +++ b/addon/components/map/drawer/device-event-listing.hbs @@ -3,6 +3,7 @@
-
-
-
- -
-
-
{{or model.name model.public_id}}
-
{{n-a model.provider_descriptor.label (titleize model.provider)}}
-
-
+
-
-
{{model.name}}
-
{{n-a model.serial_number}}
-
+
diff --git a/addon/components/map/drawer/device-event-listing.js b/addon/components/map/drawer/device-event-listing.js index ca8ca6628..a29bc29d1 100644 --- a/addon/components/map/drawer/device-event-listing.js +++ b/addon/components/map/drawer/device-event-listing.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import relationValue from '../../../utils/relation-value'; import { task } from 'ember-concurrency'; import { isArray } from '@ember/array'; import { startOfWeek, endOfWeek, format } from 'date-fns'; @@ -38,7 +39,8 @@ export default class MapDrawerDeviceEventListingComponent extends Component { { label: 'Device', valuePath: 'device.displayName', - cellComponent: 'table/cell/anchor', + cellComponent: 'cell/device-identity', + resourcePath: (event) => relationValue(event, 'device'), action: this.deviceActions.panel.view, permission: 'fleet-ops view device', resizable: true, diff --git a/addon/components/map/drawer/driver-listing.js b/addon/components/map/drawer/driver-listing.js index 42be9e225..e2bb6ccc2 100644 --- a/addon/components/map/drawer/driver-listing.js +++ b/addon/components/map/drawer/driver-listing.js @@ -2,6 +2,8 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import { buildIdentityStub } from '../../../utils/identity-cell-resource'; +import relationValue from '../../../utils/relation-value'; import calculateMapDrawerDropdownPosition from '../../../utils/map-drawer-dropdown-position'; export default class MapDrawerDriverListingComponent extends Component { @@ -35,7 +37,7 @@ export default class MapDrawerDriverListingComponent extends Component { valuePath: 'name', photoPath: 'photo_url', width: '100px', - cellComponent: 'cell/driver-name', + cellComponent: 'cell/driver-identity', onClick: this.view, }, { @@ -49,7 +51,9 @@ export default class MapDrawerDriverListingComponent extends Component { label: this.intl.t('column.current-job'), valuePath: 'current_job_id', width: '80px', - cellComponent: 'table/cell/anchor', + cellComponent: 'cell/order-identity', + resourcePath: (driver) => + relationValue(driver, 'current_job') ?? buildIdentityStub(driver, { type: 'order', nameKey: 'current_job_id', load: () => driver.get('current_job') }), onClick: this.job, }, { diff --git a/addon/components/map/drawer/position-listing.hbs b/addon/components/map/drawer/position-listing.hbs index 42d3808bf..b5bda30b0 100644 --- a/addon/components/map/drawer/position-listing.hbs +++ b/addon/components/map/drawer/position-listing.hbs @@ -8,26 +8,12 @@ @onChange={{this.onResourceSelected}} @searchEnabled={{true}} @searchField="trackableSearchText" + @selectedItemComponent={{component "select-option/trackable" compact=true}} @placeholder={{t "common.select-field" field="trackable"}} @triggerClass="form-select form-input form-input-sm w-48" as |option| > -
-
- {{#if (eq option.modelName "driver")}} - - {{else}} - - {{/if}} -
-
-
{{option.primaryLabel}}
-
{{option.secondaryLabel}}
- {{#if option.deviceLabel}} -
{{option.deviceLabel}}
- {{/if}} -
-
+
diff --git a/addon/components/map/drawer/vehicle-listing.js b/addon/components/map/drawer/vehicle-listing.js index e67f749d6..7a008d02b 100644 --- a/addon/components/map/drawer/vehicle-listing.js +++ b/addon/components/map/drawer/vehicle-listing.js @@ -35,9 +35,8 @@ export default class MapDrawerVehicleListingComponent extends Component { valuePath: 'display_name', photoPath: 'photo_url', width: '100px', - cellComponent: 'table/cell/vehicle-name', + cellComponent: 'cell/vehicle-identity', onClick: this.view, - showOnlineIndicator: true, }, { label: this.intl.t('column.location'), diff --git a/addon/components/modals/add-driver-shift.hbs b/addon/components/modals/add-driver-shift.hbs index 7771fb352..46f31839a 100644 --- a/addon/components/modals/add-driver-shift.hbs +++ b/addon/components/modals/add-driver-shift.hbs @@ -8,6 +8,7 @@ - {{driver.name}} +
{{/if}} diff --git a/addon/components/modals/assign-driver.hbs b/addon/components/modals/assign-driver.hbs index 5c3ce5ac3..a6f7ddc2e 100644 --- a/addon/components/modals/assign-driver.hbs +++ b/addon/components/modals/assign-driver.hbs @@ -3,6 +3,7 @@ -
-
- -
-
- {{model.name}} - {{model.phone}} -
- {{#if @options.order.facilitator}} -
- - {{@options.order.facilitator.name}} - -
- {{/if}} -
+
diff --git a/addon/components/modals/attach-device.hbs b/addon/components/modals/attach-device.hbs index 3623b2024..a0feed7e0 100644 --- a/addon/components/modals/attach-device.hbs +++ b/addon/components/modals/attach-device.hbs @@ -3,6 +3,7 @@ - +
diff --git a/addon/components/modals/attach-equipment.hbs b/addon/components/modals/attach-equipment.hbs index 35c0e7e1d..fc5cae67d 100644 --- a/addon/components/modals/attach-equipment.hbs +++ b/addon/components/modals/attach-equipment.hbs @@ -3,6 +3,7 @@ -
- {{or equipment.name equipment.public_id}} - {{n-a (or equipment.type equipment.serial_number equipment.code)}} -
+
diff --git a/addon/components/modals/attach-telematic-device.hbs b/addon/components/modals/attach-telematic-device.hbs index 0ae518173..b1006e787 100644 --- a/addon/components/modals/attach-telematic-device.hbs +++ b/addon/components/modals/attach-telematic-device.hbs @@ -14,6 +14,7 @@ - {{or asset.displayName asset.display_name asset.name asset.public_id}} + {{#let (resource-component "select-option" @options.selectedAssetType.model) as |selectOption|}} + {{#if selectOption}} + {{component selectOption option=asset}} + {{else}} + {{or asset.displayName asset.name asset.public_id}} + {{/if}} + {{/let}} {{/if}} diff --git a/addon/components/modals/attach-trailer.hbs b/addon/components/modals/attach-trailer.hbs index 6d36be2eb..981b22e79 100644 --- a/addon/components/modals/attach-trailer.hbs +++ b/addon/components/modals/attach-trailer.hbs @@ -11,6 +11,7 @@ - + diff --git a/addon/components/modals/bulk-assign-driver.hbs b/addon/components/modals/bulk-assign-driver.hbs index b58a98e3e..5c75c5639 100644 --- a/addon/components/modals/bulk-assign-driver.hbs +++ b/addon/components/modals/bulk-assign-driver.hbs @@ -3,6 +3,7 @@ -
-
- -
-
- {{model.name}} - {{model.phone}} -
-
+
diff --git a/addon/components/modals/bulk-assign-orders.hbs b/addon/components/modals/bulk-assign-orders.hbs index 63293d051..22f4736c0 100644 --- a/addon/components/modals/bulk-assign-orders.hbs +++ b/addon/components/modals/bulk-assign-orders.hbs @@ -27,6 +27,7 @@ -
-
- -
-
- {{driver.name}} - {{driver.phone}} -
-
+
diff --git a/addon/components/modals/driver-assign-order.hbs b/addon/components/modals/driver-assign-order.hbs index 43d6ed79b..035bb4869 100644 --- a/addon/components/modals/driver-assign-order.hbs +++ b/addon/components/modals/driver-assign-order.hbs @@ -5,6 +5,7 @@ - +
diff --git a/addon/components/modals/driver-assign-vehicle.hbs b/addon/components/modals/driver-assign-vehicle.hbs index 4ff866155..77b65b883 100644 --- a/addon/components/modals/driver-assign-vehicle.hbs +++ b/addon/components/modals/driver-assign-vehicle.hbs @@ -4,24 +4,16 @@ - + diff --git a/addon/components/modals/driver-assign-vehicle.js b/addon/components/modals/driver-assign-vehicle.js new file mode 100644 index 000000000..18f93a500 --- /dev/null +++ b/addon/components/modals/driver-assign-vehicle.js @@ -0,0 +1,30 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action, set } from '@ember/object'; + +/** + * Picks the vehicle for a driver. + * + * The select is handed the chosen vehicle record, not its uuid. ModelSelect + * only resolves a uuid into a record when it is first rendered; a uuid that + * arrives later is shown as-is, which is why a picked vehicle used to read + * "-" in the trigger. The driver still receives both the vehicle and its uuid, + * which is what the confirm handlers read. + */ +export default class ModalsDriverAssignVehicleComponent extends Component { + @tracked vehicle = null; + + get selectedVehicle() { + return this.vehicle ?? this.args.options?.driver?.vehicle_uuid ?? null; + } + + @action selectVehicle(vehicle) { + this.vehicle = vehicle; + + const driver = this.args.options?.driver; + if (driver) { + set(driver, 'vehicle', vehicle); + set(driver, 'vehicle_uuid', vehicle?.id ?? null); + } + } +} diff --git a/addon/components/modals/driver-assign-vendor.hbs b/addon/components/modals/driver-assign-vendor.hbs index f917242b2..e473544bd 100644 --- a/addon/components/modals/driver-assign-vendor.hbs +++ b/addon/components/modals/driver-assign-vendor.hbs @@ -4,6 +4,7 @@ - {{model.name}} + diff --git a/addon/components/modals/driver-form.hbs b/addon/components/modals/driver-form.hbs index b9a0a9fad..bd9db1807 100644 --- a/addon/components/modals/driver-form.hbs +++ b/addon/components/modals/driver-form.hbs @@ -57,6 +57,7 @@ - {{model.name}} + @@ -75,6 +76,7 @@ - {{model.display_name}} + diff --git a/addon/components/modals/fleet-assign-driver.hbs b/addon/components/modals/fleet-assign-driver.hbs index 8211434cd..3c86db22f 100644 --- a/addon/components/modals/fleet-assign-driver.hbs +++ b/addon/components/modals/fleet-assign-driver.hbs @@ -3,6 +3,7 @@ - + diff --git a/addon/components/modals/fleet-assign-vehicle.hbs b/addon/components/modals/fleet-assign-vehicle.hbs index 06dcf03a9..22ae99f4d 100644 --- a/addon/components/modals/fleet-assign-vehicle.hbs +++ b/addon/components/modals/fleet-assign-vehicle.hbs @@ -3,6 +3,7 @@ - + diff --git a/addon/components/modals/fleet-form.hbs b/addon/components/modals/fleet-form.hbs index 0de1a06da..bfc56a3a5 100644 --- a/addon/components/modals/fleet-form.hbs +++ b/addon/components/modals/fleet-form.hbs @@ -7,6 +7,7 @@ - {{model.name}} + {{#if @options.fleet.service_area}} - {{model.name}} + {{/if}} diff --git a/addon/components/modals/fuel-connection-diagnostics.hbs b/addon/components/modals/fuel-connection-diagnostics.hbs new file mode 100644 index 000000000..e6bc87f62 --- /dev/null +++ b/addon/components/modals/fuel-connection-diagnostics.hbs @@ -0,0 +1,35 @@ + + + \ No newline at end of file diff --git a/addon/components/modals/fuel-connection-diagnostics.js b/addon/components/modals/fuel-connection-diagnostics.js new file mode 100644 index 000000000..41932148f --- /dev/null +++ b/addon/components/modals/fuel-connection-diagnostics.js @@ -0,0 +1,87 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard'; + +const METADATA_LABELS = { environment: 'Environment', host: 'Host', auth_type: 'Authentication', status: 'HTTP status', total_vehicles: 'Vehicles available' }; + +export default class FuelConnectionDiagnosticsComponent extends Component { + @service fetch; + @service notifications; + @tracked result; + @tracked entries = []; + @tracked refreshWarning; + + constructor() { + super(...arguments); + // Keep the result visible until the operator closes the dialog. + this.args.options.keepOpen = true; + this.args.options.confirm = () => this.runTest.perform(); + } + + get connection() { + return this.args.options.connection; + } + get environment() { + return this.connection.environment === 'sandbox' ? 'Sandbox / test account' : 'Production account'; + } + get modalOptions() { + return { ...this.args.options, acceptButtonText: this.result ? 'Test Again' : 'Run Test', isLoading: this.runTest.isRunning, acceptButtonDisabled: this.runTest.isRunning }; + } + get state() { + return this.runTest.isRunning ? 'testing' : !this.result ? 'ready' : this.result.success ? 'success' : 'failed'; + } + get title() { + return { ready: 'Ready to test', testing: 'Testing connection…', success: 'Connection verified', failed: 'Connection failed' }[this.state]; + } + get message() { + if (this.state === 'ready') return 'Run a connection test using this integration’s saved credentials. This checks provider access without importing transactions.'; + if (this.state === 'testing') return 'Waiting for the provider to respond…'; + return this.result.message; + } + get metadata() { + return Object.entries(METADATA_LABELS).flatMap(([key, label]) => { + const value = this.result?.metadata?.[key]; + return ['string', 'number', 'boolean'].includes(typeof value) ? [{ label, value: String(value) }] : []; + }); + } + get diagnosticsText() { + return this.entries.map((entry) => `[${entry.time}] ${entry.text}`).join('\n'); + } + log(text) { + this.entries = [...this.entries, { time: new Date().toISOString(), text }]; + } + + @task({ drop: true }) *runTest() { + this.result = null; + this.refreshWarning = null; + this.entries = []; + this.log(`Testing ${this.connection.name || this.connection.provider} · ${this.environment}`); + this.log('Sending connection test with saved credentials.'); + try { + const result = yield this.fetch.post(`fuel-provider-connections/${this.connection.id}/test-connection`, {}, { rawError: true }); + this.result = { ...result, success: result.success === true }; + } catch (error) { + this.result = { success: false, message: error.message || 'Unable to complete the connection test. Please retry.', metadata: error.metadata ?? {} }; + } + this.log(this.result.success ? 'Connection verified.' : `Connection failed: ${this.result.message}`); + for (const entry of this.metadata) this.log(`${entry.label}: ${entry.value}`); + // Updating the summary must not refresh the router or dismiss the result. + try { + yield this.args.options.onTested?.(this.result); + } catch { + this.refreshWarning = 'The test result is available, but the integration summary could not be refreshed.'; + } + } + + @action async copyDiagnostics() { + try { + await copyToClipboard(this.diagnosticsText); + this.notifications.success('Connection diagnostics copied.'); + } catch { + this.notifications.error('Unable to copy diagnostics.'); + } + } +} diff --git a/addon/components/modals/fuel-report-form.hbs b/addon/components/modals/fuel-report-form.hbs index b7790a5cd..a14048761 100644 --- a/addon/components/modals/fuel-report-form.hbs +++ b/addon/components/modals/fuel-report-form.hbs @@ -4,6 +4,7 @@ - {{model.name}} + - {{model.display_name}} + diff --git a/addon/components/modals/fuel-transaction-action.hbs b/addon/components/modals/fuel-transaction-action.hbs new file mode 100644 index 000000000..7c8e814c6 --- /dev/null +++ b/addon/components/modals/fuel-transaction-action.hbs @@ -0,0 +1,48 @@ + + + \ No newline at end of file diff --git a/addon/components/modals/fuel-transaction-action.js b/addon/components/modals/fuel-transaction-action.js new file mode 100644 index 000000000..519104e23 --- /dev/null +++ b/addon/components/modals/fuel-transaction-action.js @@ -0,0 +1,98 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import { loadTransactionOrder, transactionActions } from '../../utils/fuel-transaction'; +import { fuelDate, fuelMoney } from '../../utils/fuel-integration-format'; + +export default class FuelTransactionActionComponent extends Component { + @service fetch; + @service notifications; + @service store; + @tracked selected; + @tracked error; + @tracked completed = []; + + constructor() { + super(...arguments); + this.args.options.keepOpen = true; + this.args.options.confirm = (_modal, done) => this.save.perform(done); + } + get mode() { + return this.args.options.mode; + } + get transactions() { + return this.args.options.transactions; + } + get description() { + return transactionActions[this.mode].description; + } + get isMatching() { + return ['vehicle', 'order'].includes(this.mode); + } + get isVehicle() { + return this.mode === 'vehicle'; + } + get selectionLabel() { + return this.isVehicle ? 'Vehicle' : 'Order / Trip'; + } + get selectedName() { + if (this.mode === 'order') { + return this.selected?.tracking || this.selected?.public_id || this.selected?.internal_id; + } + + return this.selected?.displayName || this.selected?.public_id || this.selected?.internal_id; + } + get preview() { + return this.transactions.map((record) => ({ + reference: record.provider_transaction_id, + provider: record.provider, + status: record.sync_status, + date: fuelDate(record.transaction_at, 'Date unavailable'), + amount: fuelMoney(record.amount, record.currency), + station: record.station_name || 'Station unavailable', + plate: record.plate_number || 'Not provided', + vehicle: record.vehicle_name || (record.vehicle_uuid ? 'Linked vehicle' : 'Not matched'), + order: record.order_uuid ? record.trip_number || 'Linked order' : 'Not matched', + report: record.fuel_report_id || 'Not created', + })); + } + get modalOptions() { + return { ...this.args.options, isLoading: this.save.isRunning, acceptButtonDisabled: this.save.isRunning || (this.isMatching && !this.selected?.id) }; + } + @action select(record) { + this.selected = record; + } + + @task({ drop: true }) *save(done) { + if (this.isMatching && !this.selected?.id) return; + this.error = null; + const endpoint = this.isMatching ? `match-${this.mode}` : this.mode === 'reprocess' ? 'reprocess' : 'review'; + const body = this.isMatching ? { [this.mode]: this.selected.id } : this.mode === 'reprocess' ? {} : { status: this.mode }; + for (const transaction of this.transactions) { + // A bulk retry must not repeat requests that already succeeded. + if (this.completed.includes(transaction.id)) continue; + try { + yield this.fetch.post(`fuel-provider-transactions/${transaction.id}/${endpoint}`, body); + } catch { + this.error = `Could not update transaction ${transaction.provider_transaction_id}. ${this.completed.length ? `${this.completed.length} already updated. ` : ''}Retry to continue, or cancel and refresh the ledger to check its current state.`; + return; + } + this.completed = [...this.completed, transaction.id]; + try { + yield transaction.reload(); + yield loadTransactionOrder(this.store, transaction); + } catch { + this.notifications.warning('The change was saved, but the displayed transaction could not be refreshed. Refresh the ledger to see its latest state.'); + } + } + this.notifications.success(`${this.transactions.length === 1 ? 'Fuel transaction' : `${this.transactions.length} fuel transactions`} updated.`); + done?.(); + try { + yield this.args.options.onSaved?.(); + } catch { + this.notifications.warning('Refresh the ledger to see the latest transactions.'); + } + } +} diff --git a/addon/components/modals/group-form.hbs b/addon/components/modals/group-form.hbs index f426eb137..134d66659 100644 --- a/addon/components/modals/group-form.hbs +++ b/addon/components/modals/group-form.hbs @@ -15,6 +15,7 @@
- {{model.name}} +
diff --git a/addon/components/modals/inspection-follow-up.hbs b/addon/components/modals/inspection-follow-up.hbs new file mode 100644 index 000000000..b28310bd0 --- /dev/null +++ b/addon/components/modals/inspection-follow-up.hbs @@ -0,0 +1,31 @@ + + + diff --git a/addon/components/modals/inspection-follow-up.js b/addon/components/modals/inspection-follow-up.js new file mode 100644 index 000000000..b9da7c0d5 --- /dev/null +++ b/addon/components/modals/inspection-follow-up.js @@ -0,0 +1,39 @@ +import Component from '@glimmer/component'; + +/** + * What an action is about to do to an inspection, shown before it happens. + * + * Creating an issue, creating a work order and resolving all used to fire on + * click: the first a dispatcher knew of it was a toast and a new id. Each one + * now states what it will make, from which failed items, and — where it + * matters — what else it does on the way. + */ +export default class ModalsInspectionFollowUpComponent extends Component { + get submission() { + return this.args.options.submission; + } + + /** The failures the follow-up is built from, worst first. */ + get failures() { + const order = { critical: 0, high: 1, medium: 2, low: 3 }; + const results = this.submission?.item_results ?? []; + + return results + .filter((item) => item.passed === false) + .slice() + .sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9)); + } + + get unsafeCount() { + return this.failures.filter((item) => item.meta?.unsafe || item.severity === 'critical').length; + } + + get highestSeverity() { + return this.failures[0]?.severity ?? null; + } + + /** Nothing failed, so there is nothing to raise: the server would say so after the fact. */ + get hasNothingToDo() { + return this.args.options.kind !== 'resolve' && this.failures.length === 0; + } +} diff --git a/addon/components/modals/inspection-link.hbs b/addon/components/modals/inspection-link.hbs new file mode 100644 index 000000000..489b052d5 --- /dev/null +++ b/addon/components/modals/inspection-link.hbs @@ -0,0 +1,100 @@ + + + diff --git a/addon/components/modals/inspection-link.js b/addon/components/modals/inspection-link.js new file mode 100644 index 000000000..1ac7ad70d --- /dev/null +++ b/addon/components/modals/inspection-link.js @@ -0,0 +1,83 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action, get, set } from '@ember/object'; +import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard'; +import { toDatetimeLocal } from '../../services/inspection-form-actions'; + +export default class ModalsInspectionLinkComponent extends Component { + @service intl; + @service notifications; + + get formState() { + return this.args.options.formState; + } + + /** A link cannot be made to expire in the past; the server refuses it too. */ + get minExpiry() { + return toDatetimeLocal(new Date()); + } + + /** + * Who the PIN would be sent to: whoever the link is assigned to, or else + * the driver's own account. The server makes the same choice. + * + * Read with `get`: the form state is a plain object changed with `set`, + * and a native read of it is not tracked, so a getter reading it directly + * never recomputed and email and SMS stayed disabled after a pick. + */ + get recipientName() { + const assignee = get(this.formState, 'assignee'); + const driver = get(this.formState, 'driver'); + return assignee?.name ?? driver?.name ?? null; + } + + get deliveryHelp() { + const name = this.recipientName; + return name ? this.intl.t('inspection.link.pin-delivery-help', { name }) : this.intl.t('inspection.link.pin-delivery-no-recipient'); + } + + /** With nobody left to send it to, the PIN goes back to being shared by hand. */ + keepDeliveryPossible() { + if (!this.recipientName && get(this.formState, 'pin_delivery') !== 'none') { + set(this.formState, 'pin_delivery', 'none'); + } + } + + @action assignAssignee(user) { + set(this.formState, 'assignee', user); + this.keepDeliveryPossible(); + } + + @action assignDriver(driver) { + set(this.formState, 'driver', driver); + this.keepDeliveryPossible(); + } + + @action assignVehicle(vehicle) { + set(this.formState, 'vehicle', vehicle); + } + + @action updateExpiry(event) { + set(this.formState, 'expires_at', event.target.value); + } + + @action setPinDelivery(event) { + set(this.formState, 'pin_delivery', event.target.value); + } + + @action copyLink() { + const url = this.formState.generated?.url; + if (url) { + copyToClipboard(url); + this.notifications.success(this.intl.t('inspection.link.copied')); + } + } + + @action copyPin() { + const pin = this.formState.generated?.pin; + if (pin) { + copyToClipboard(pin); + this.notifications.success(this.intl.t('inspection.link.copied-pin')); + } + } +} diff --git a/addon/components/modals/issue-assign.hbs b/addon/components/modals/issue-assign.hbs index f5d087945..f9b63b66f 100644 --- a/addon/components/modals/issue-assign.hbs +++ b/addon/components/modals/issue-assign.hbs @@ -3,6 +3,7 @@ - {{model.name}} + diff --git a/addon/components/modals/issue-from.hbs b/addon/components/modals/issue-from.hbs index 979a94e2c..a699c306d 100644 --- a/addon/components/modals/issue-from.hbs +++ b/addon/components/modals/issue-from.hbs @@ -4,6 +4,7 @@ - {{model.name}} + - {{model.name}} + - {{model.name}} + - {{model.displayName}} + diff --git a/addon/components/modals/map-layer-form.hbs b/addon/components/modals/map-layer-form.hbs index 169de1b0b..427c2ad23 100644 --- a/addon/components/modals/map-layer-form.hbs +++ b/addon/components/modals/map-layer-form.hbs @@ -16,6 +16,7 @@ - {{model.name}} + {{/if}} diff --git a/addon/components/modals/order-assign-driver.hbs b/addon/components/modals/order-assign-driver.hbs index dbd07636f..665ea1a9c 100644 --- a/addon/components/modals/order-assign-driver.hbs +++ b/addon/components/modals/order-assign-driver.hbs @@ -3,6 +3,7 @@ -
-
- -
-
- {{model.name}} - {{model.phone}} -
- {{#if @options.order.facilitator}} -
- - {{@options.order.facilitator.name}} - -
- {{/if}} -
+
diff --git a/addon/components/modals/order-form.hbs b/addon/components/modals/order-form.hbs index 7175894c2..1ae876941 100644 --- a/addon/components/modals/order-form.hbs +++ b/addon/components/modals/order-form.hbs @@ -18,6 +18,7 @@ -
-
- -
- - {{model.name}} - -
+
-
-
- {{#if model.isIntegratedVendor}} - {{model.name}} - {{else}} - - {{/if}} -
- - {{model.name}} - -
+
-
-
- -
-
- {{model.name}} - {{model.phone}} -
- {{#if this.order.facilitator}} -
- - {{this.order.facilitator.name}} - -
- {{/if}} -
+
-
-
- -
-
- {{model.display_name}} -
-
+
diff --git a/addon/components/modals/order-route-form.hbs b/addon/components/modals/order-route-form.hbs index f2e26b57e..6a218775e 100644 --- a/addon/components/modals/order-route-form.hbs +++ b/addon/components/modals/order-route-form.hbs @@ -32,6 +32,7 @@
- {{model.address}} + {{#if waypoint.place.hasInvalidCoordinates}} @@ -76,6 +77,7 @@ - {{model.address}} + {{#if @options.order.payload.pickup.hasInvalidCoordinates}} @@ -108,6 +110,7 @@ - {{model.address}} + {{#if @options.order.payload.dropoff.hasInvalidCoordinates}} @@ -140,6 +143,7 @@ - {{model.address}} + {{#if @options.order.payload.return.hasInvalidCoordinates}} diff --git a/addon/components/modals/place-assign-vendor.hbs b/addon/components/modals/place-assign-vendor.hbs index 2b3e9809b..3a04a5d58 100644 --- a/addon/components/modals/place-assign-vendor.hbs +++ b/addon/components/modals/place-assign-vendor.hbs @@ -4,15 +4,16 @@ - {{model.name}} +
diff --git a/addon/components/modals/place-details.hbs b/addon/components/modals/place-details.hbs index 6be51d8a1..ca2f96b78 100644 --- a/addon/components/modals/place-details.hbs +++ b/addon/components/modals/place-details.hbs @@ -3,7 +3,7 @@
- + diff --git a/addon/components/modals/point-map.hbs b/addon/components/modals/point-map.hbs index 308f5e2b7..b756dcf5c 100644 --- a/addon/components/modals/point-map.hbs +++ b/addon/components/modals/point-map.hbs @@ -1,7 +1,7 @@
- + diff --git a/addon/components/modals/radar-notice.hbs b/addon/components/modals/radar-notice.hbs new file mode 100644 index 000000000..49333e0f3 --- /dev/null +++ b/addon/components/modals/radar-notice.hbs @@ -0,0 +1,24 @@ + +