diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 24e38fe88..aaa9d9f5f 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -33,4 +33,8 @@ jobs: # published image, not the branch under review. The workflow checks this # repository out at the commit under test and swaps it into the container. overlay-package: fleetbase/fleetops-api + # This coordinated PR adds the Trailer lifecycle to fleetbase/postman#58. + # Exercise that immutable collection revision during PR review; once merged, + # normal main-branch runs continue following the Postman main branch. + postman-ref: ${{ github.event_name == 'pull_request' && '0590548fefea68dd873b04f67d3f598c401d3af7' || 'main' }} secrets: inherit diff --git a/addon/components/cell/attached-vehicle.hbs b/addon/components/cell/attached-vehicle.hbs index 6ad15e212..a79290f88 100644 --- a/addon/components/cell/attached-vehicle.hbs +++ b/addon/components/cell/attached-vehicle.hbs @@ -4,7 +4,7 @@ {{else}}
- Unattached + {{t "trailer.attachment.detached"}}
{{/if}} diff --git a/addon/components/cell/attached-vehicle.js b/addon/components/cell/attached-vehicle.js index 42c1645a8..b34175b44 100644 --- a/addon/components/cell/attached-vehicle.js +++ b/addon/components/cell/attached-vehicle.js @@ -31,13 +31,13 @@ export default class CellAttachedVehicleComponent extends Component { } get hasVehicle() { - return Boolean(this.device?.attachable_uuid && this.isVehicleAttachment); + return Boolean(this.device?.attachable_uuid && this.isSupportedAttachment); } - get isVehicleAttachment() { + get isSupportedAttachment() { const attachableType = `${this.device?.attachable_type ?? ''}`.toLowerCase(); - return !attachableType || attachableType.includes('vehicle'); + return !attachableType || attachableType.includes('vehicle') || attachableType.includes('trailer'); } @action onClick(_vehicle, event) { diff --git a/addon/components/cell/translated-value.hbs b/addon/components/cell/translated-value.hbs new file mode 100644 index 000000000..86e8aff09 --- /dev/null +++ b/addon/components/cell/translated-value.hbs @@ -0,0 +1,9 @@ +{{#if this.label}} + {{#if this.isBadge}} + + {{else}} + {{this.label}} + {{/if}} +{{else}} + {{this.emptyText}} +{{/if}} diff --git a/addon/components/cell/translated-value.js b/addon/components/cell/translated-value.js new file mode 100644 index 000000000..ccb38d798 --- /dev/null +++ b/addon/components/cell/translated-value.js @@ -0,0 +1,57 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { get } from '@ember/object'; + +/** + * Table cell that renders a stable enum value (`dry_van`, `never_connected`, ...) + * through its localized label. Configure the column with: + * + * translationPrefix: 'trailer.types' // required, joined with the raw value + * badge: true // optional, render as a status badge + * badgeIconPath / badgeIcons // optional badge icon per value + */ +export default class CellTranslatedValueComponent extends Component { + @service intl; + + get value() { + const row = this.args.row; + const valuePath = this.args.column?.valuePath; + + if (this.args.value !== undefined && this.args.value !== null) { + return this.args.value; + } + + return valuePath ? get(row, valuePath) : null; + } + + get label() { + const value = this.value; + const prefix = this.args.column?.translationPrefix; + + if (value === null || value === undefined || value === '') { + return null; + } + + if (!prefix) { + return String(value); + } + + const key = `${prefix}.${value}`; + + return this.intl.exists(key) ? this.intl.t(key) : String(value); + } + + get isBadge() { + return Boolean(this.args.column?.badge); + } + + get icon() { + const icons = this.args.column?.badgeIcons; + + return icons && this.value ? icons[this.value] : undefined; + } + + get emptyText() { + return this.args.column?.emptyText ?? '-'; + } +} diff --git a/addon/components/device/manager.hbs b/addon/components/device/manager.hbs index 3b734a4f3..cae375766 100644 --- a/addon/components/device/manager.hbs +++ b/addon/components/device/manager.hbs @@ -10,7 +10,7 @@
-
{{else}} diff --git a/addon/components/device/manager.js b/addon/components/device/manager.js index a82f0f329..be0146f1d 100644 --- a/addon/components/device/manager.js +++ b/addon/components/device/manager.js @@ -5,6 +5,8 @@ import { inject as service } from '@ember/service'; import { debug } from '@ember/debug'; import { task } from 'ember-concurrency'; import getModelName from '@fleetbase/ember-core/utils/get-model-name'; +import { pluralize } from 'ember-inflector'; +import { dasherize } from '@ember/string'; export default class DeviceManagerComponent extends Component { @service store; @@ -28,6 +30,21 @@ export default class DeviceManagerComponent extends Component { ); } + /** + * The internal attach/detach endpoints are namespaced by the attachable resource + * (`vehicles/{id}/attach-device`, `trailers/{id}/attach-device`). Derive the prefix + * from the resource's model name unless an explicit `@endpoint` is given. + */ + get endpoint() { + if (this.args.endpoint) { + return this.args.endpoint; + } + + const modelName = getModelName(this.args.resource); + + return modelName ? dasherize(pluralize(modelName)) : 'vehicles'; + } + constructor() { super(...arguments); this.loadDevices.perform(); @@ -45,7 +62,7 @@ export default class DeviceManagerComponent extends Component { modal.startLoading(); try { - await this.fetch.post(`vehicles/${this.args.resource.id}/attach-device`, { device: selectedDevice.id }); + await this.fetch.post(`${this.endpoint}/${this.args.resource.id}/attach-device`, { device: selectedDevice.id }); await this.loadDevices.perform(); this.notifications.success(this.intl.t('device.prompts.attach-device-success')); modal.done(); @@ -67,7 +84,7 @@ export default class DeviceManagerComponent extends Component { modal.startLoading(); try { - await this.fetch.post(`vehicles/${this.args.resource.id}/detach-device`, { device: device.id }); + await this.fetch.post(`${this.endpoint}/${this.args.resource.id}/detach-device`, { device: device.id }); await this.loadDevices.perform(); this.notifications.success(this.intl.t('device.prompts.detach-from-resource-success', { deviceName, resourceName: this.resourceName })); modal.done(); diff --git a/addon/components/device/panel-tabs/vehicle.hbs b/addon/components/device/panel-tabs/vehicle.hbs index 292b514a8..b591cdf5d 100644 --- a/addon/components/device/panel-tabs/vehicle.hbs +++ b/addon/components/device/panel-tabs/vehicle.hbs @@ -1,21 +1,21 @@
-

Vehicle Attachment

-

Fleet context for telemetry from this device.

+

{{t "device.attachment.title"}}

+

{{t "device.attachment.description"}}

{{#if this.hasVehicle}} {{#if this.canOpenVehicle}} -
@@ -39,15 +39,15 @@
{{n-a this.vehicleSubtitle}}
-
Driver
+
{{t "resource.driver"}}
{{n-a this.vehicleDriverName}}
-
Attached Device
+
{{t "device.attachment.attached-device"}}
{{n-a this.device.displayName this.device.name}}
-
Device Last Seen
+
{{t "device.attachment.last-seen"}}
{{n-a (format-date-fns this.device.last_online_at "dd MMM yyyy, HH:mm")}}
@@ -58,9 +58,9 @@ diff --git a/addon/components/device/panel-tabs/vehicle.js b/addon/components/device/panel-tabs/vehicle.js index 49c1e8d28..05862f2bc 100644 --- a/addon/components/device/panel-tabs/vehicle.js +++ b/addon/components/device/panel-tabs/vehicle.js @@ -7,6 +7,7 @@ export default class DevicePanelTabsVehicleComponent extends Component { @service hostRouter; @service mapManager; @service vehicleActions; + @service trailerActions; get device() { return this.args.resource ?? this.args.model; @@ -16,6 +17,10 @@ export default class DevicePanelTabsVehicleComponent extends Component { return this.device?.attachable; } + get isTrailer() { + return `${this.device?.attachable_type ?? ''}`.toLowerCase().includes('trailer'); + } + get vehicleName() { return this.device?.attached_to_name ?? this.vehicle?.displayName ?? this.vehicle?.display_name ?? this.vehicle?.name; } @@ -58,6 +63,9 @@ export default class DevicePanelTabsVehicleComponent extends Component { @action openVehicle() { if (this.vehicle?.id) { + if (this.isTrailer) { + return this.trailerActions.transition.view(this.vehicle); + } return this.vehicleActions.panel?.view ? this.vehicleActions.panel.view(this.vehicle) : this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index.details', this.vehicle); diff --git a/addon/components/equipment/form.js b/addon/components/equipment/form.js index cb855a906..d7b369569 100644 --- a/addon/components/equipment/form.js +++ b/addon/components/equipment/form.js @@ -11,20 +11,24 @@ import { task } from 'ember-concurrency'; const TYPE_TO_MODEL = { 'fleet-ops:vehicle': 'vehicle', 'fleet-ops:driver': 'driver', + 'fleet-ops:trailer': 'trailer', 'Fleetbase\\FleetOps\\Models\\Vehicle': 'vehicle', 'Fleetbase\\FleetOps\\Models\\Driver': 'driver', + 'Fleetbase\\FleetOps\\Models\\Trailer': 'trailer', 'fleet-ops:equipment': 'equipment', }; const TYPE_TO_OPTION_VALUE = { 'Fleetbase\\FleetOps\\Models\\Vehicle': 'fleet-ops:vehicle', 'Fleetbase\\FleetOps\\Models\\Driver': 'fleet-ops:driver', + 'Fleetbase\\FleetOps\\Models\\Trailer': 'fleet-ops:trailer', }; export default class EquipmentFormComponent extends Component { @service fetch; @service currentUser; @service notifications; + @service intl; /** Equipment type options. */ equipmentTypeOptions = ['ppe', 'refrigeration_unit', 'tool', 'liftgate', 'ramp', 'container', 'pallet_jack', 'forklift', 'safety_equipment', 'communication_device', 'other']; @@ -36,10 +40,13 @@ export default class EquipmentFormComponent extends Component { * Polymorphic equipable type options — the asset this equipment is attached to. * Each entry has a `value` (stored on the model) and a `label` (displayed in the UI). */ - equipableTypeOptions = [ - { value: 'fleet-ops:vehicle', label: 'Vehicle' }, - { value: 'fleet-ops:driver', label: 'Driver' }, - ]; + get equipableTypeOptions() { + return [ + { value: 'fleet-ops:vehicle', label: this.intl.t('resource.vehicle') }, + { value: 'fleet-ops:trailer', label: this.intl.t('resource.trailer') }, + { value: 'fleet-ops:driver', label: this.intl.t('resource.driver') }, + ]; + } /** Derived Ember Data model name for the currently selected equipable type. */ @tracked equipableModelName = null; diff --git a/addon/components/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js index f045dd88f..1258c5df0 100644 --- a/addon/components/layout/fleet-ops-sidebar.js +++ b/addon/components/layout/fleet-ops-sidebar.js @@ -142,6 +142,7 @@ export default class LayoutFleetOpsSidebarComponent extends Component { ]), 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']), + this.createItem('menu.trailers', 'trailer', 'management.trailers', 'fleet-ops list trailer', 'fleet-ops see trailer', ['trailer', 'towed asset', 'reefer', 'flatbed']), this.createItem('menu.fleets', 'user-group', 'management.fleets', 'fleet-ops list fleet', 'fleet-ops see fleet', ['fleet', 'teams']), this.createItem('menu.vendors', 'warehouse', 'management.vendors', 'fleet-ops list vendor', 'fleet-ops see vendor'), this.createItem('menu.contacts', 'address-book', 'management.contacts', 'fleet-ops list contact', 'fleet-ops see contact'), @@ -346,13 +347,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component { 'management.index': 0, 'management.drivers': 1, 'management.vehicles': 2, - 'management.fleets': 3, - 'management.vendors': 4, - 'management.contacts': 5, - 'management.places': 6, - 'management.fuel-reports': 7, - 'management.fuel-transactions': 8, - 'management.issues': 9, + 'management.trailers': 3, + 'management.fleets': 4, + 'management.vendors': 5, + 'management.contacts': 6, + 'management.places': 7, + 'management.fuel-reports': 8, + 'management.fuel-transactions': 9, + 'management.issues': 10, 'maintenance.index': 0, 'maintenance.schedules': 1, 'maintenance.work-orders': 2, diff --git a/addon/components/maintenance-schedule/form.js b/addon/components/maintenance-schedule/form.js index 7b957b306..a9aeca8a2 100644 --- a/addon/components/maintenance-schedule/form.js +++ b/addon/components/maintenance-schedule/form.js @@ -2,12 +2,14 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { isArray } from '@ember/array'; import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; /** * Maps a polymorphic type string to the Ember Data model name used by ModelSelect. */ const TYPE_TO_MODEL = { 'fleet-ops:vehicle': 'vehicle', + 'fleet-ops:trailer': 'trailer', 'fleet-ops:equipment': 'equipment', 'fleet-ops:vendor': 'vendor', 'fleet-ops:contact': 'contact', @@ -21,9 +23,11 @@ const TYPE_TO_MODEL = { */ const MODEL_TO_TYPE = { 'maintenance-subject-vehicle': 'fleet-ops:vehicle', + 'maintenance-subject-trailer': 'fleet-ops:trailer', 'maintenance-subject-equipment': 'fleet-ops:equipment', // Fall-through for raw vehicle/equipment models passed from vehicle-actions.js vehicle: 'fleet-ops:vehicle', + trailer: 'fleet-ops:trailer', equipment: 'fleet-ops:equipment', vendor: 'fleet-ops:vendor', contact: 'fleet-ops:contact', @@ -44,11 +48,6 @@ const ASSIGNEE_MODEL_TO_TYPE = { driver: 'fleet-ops:driver', }; -const SUBJECT_TYPE_OPTIONS = [ - { label: 'Vehicle', value: 'fleet-ops:vehicle' }, - { label: 'Equipment', value: 'fleet-ops:equipment' }, -]; - const ASSIGNEE_TYPE_OPTIONS = [ { label: 'Vendor', value: 'fleet-ops:vendor' }, { label: 'Contact', value: 'fleet-ops:contact' }, @@ -62,6 +61,7 @@ const INTERVAL_METHOD_OPTIONS = [ ]; export default class MaintenanceScheduleFormComponent extends Component { + @service intl; @tracked selectedSubjectType = null; @tracked selectedAssigneeType = null; @tracked subjectModelName = null; @@ -70,7 +70,13 @@ export default class MaintenanceScheduleFormComponent extends Component { @tracked reminderOffsets = []; @tracked newReminderOffset = ''; - subjectTypeOptions = SUBJECT_TYPE_OPTIONS; + get subjectTypeOptions() { + return [ + { label: this.intl.t('resource.vehicle'), value: 'fleet-ops:vehicle' }, + { label: this.intl.t('resource.trailer'), value: 'fleet-ops:trailer' }, + { label: this.intl.t('resource.equipment'), value: 'fleet-ops:equipment' }, + ]; + } assigneeTypeOptions = ASSIGNEE_TYPE_OPTIONS; intervalMethodOptions = INTERVAL_METHOD_OPTIONS; @@ -103,7 +109,7 @@ export default class MaintenanceScheduleFormComponent extends Component { const modelName = subject.constructor?.modelName ?? subject.modelName; const typeValue = MODEL_TO_TYPE[modelName] ?? null; if (typeValue) { - this.selectedSubjectType = SUBJECT_TYPE_OPTIONS.find((o) => o.value === typeValue) ?? null; + this.selectedSubjectType = this.subjectTypeOptions.find((o) => o.value === typeValue) ?? null; this.subjectModelName = TYPE_TO_MODEL[typeValue] ?? null; } } diff --git a/addon/components/maintenance/form.js b/addon/components/maintenance/form.js index cd633049e..d5339843c 100644 --- a/addon/components/maintenance/form.js +++ b/addon/components/maintenance/form.js @@ -1,12 +1,14 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; /** * Maps a polymorphic type value to the Ember Data model name used by ModelSelect. */ const TYPE_TO_MODEL = { 'fleet-ops:vehicle': 'vehicle', + 'fleet-ops:trailer': 'trailer', 'fleet-ops:driver': 'driver', 'fleet-ops:equipment': 'equipment', 'fleet-ops:vendor': 'vendor', @@ -20,9 +22,11 @@ const TYPE_TO_MODEL = { */ const MAINTAINABLE_MODEL_TO_TYPE = { 'maintenance-subject-vehicle': 'fleet-ops:vehicle', + 'maintenance-subject-trailer': 'fleet-ops:trailer', 'maintenance-subject-equipment': 'fleet-ops:equipment', // Fall-through for raw models passed from vehicle-actions.js vehicle: 'fleet-ops:vehicle', + trailer: 'fleet-ops:trailer', equipment: 'fleet-ops:equipment', }; @@ -40,6 +44,7 @@ const PERFORMED_BY_MODEL_TO_TYPE = { }; export default class MaintenanceFormComponent extends Component { + @service intl; /** Maintenance type options — the category of maintenance activity. */ maintenanceTypeOptions = ['preventive', 'corrective', 'predictive', 'routine', 'emergency', 'inspection', 'repair', 'replacement', 'calibration']; @@ -54,10 +59,13 @@ export default class MaintenanceFormComponent extends Component { * Uses label/value objects so the PowerSelect trigger shows a human-readable * label instead of the raw model type string. */ - maintainableTypeOptions = [ - { value: 'fleet-ops:vehicle', label: 'Vehicle' }, - { value: 'fleet-ops:equipment', label: 'Equipment' }, - ]; + get maintainableTypeOptions() { + return [ + { value: 'fleet-ops:vehicle', label: this.intl.t('resource.vehicle') }, + { value: 'fleet-ops:trailer', label: this.intl.t('resource.trailer') }, + { value: 'fleet-ops:equipment', label: this.intl.t('resource.equipment') }, + ]; + } /** * Polymorphic performed-by type options — who carried out the maintenance. diff --git a/addon/components/modals/attach-equipment.hbs b/addon/components/modals/attach-equipment.hbs new file mode 100644 index 000000000..35c0e7e1d --- /dev/null +++ b/addon/components/modals/attach-equipment.hbs @@ -0,0 +1,22 @@ + + + diff --git a/addon/components/modals/attach-equipment.js b/addon/components/modals/attach-equipment.js new file mode 100644 index 000000000..197f863c3 --- /dev/null +++ b/addon/components/modals/attach-equipment.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class ModalsAttachEquipmentComponent extends Component {} diff --git a/addon/components/modals/attach-telematic-device.hbs b/addon/components/modals/attach-telematic-device.hbs index 933de41a2..0ae518173 100644 --- a/addon/components/modals/attach-telematic-device.hbs +++ b/addon/components/modals/attach-telematic-device.hbs @@ -1,25 +1,30 @@ diff --git a/addon/components/modals/attach-telematic-device.js b/addon/components/modals/attach-telematic-device.js index a975b9960..21daea432 100644 --- a/addon/components/modals/attach-telematic-device.js +++ b/addon/components/modals/attach-telematic-device.js @@ -1,3 +1,13 @@ import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; -export default class ModalsAttachTelematicDeviceComponent extends Component {} +export default class ModalsAttachTelematicDeviceComponent extends Component { + @service intl; + + get assetTypeOptions() { + return [ + { value: 'fleet-ops:vehicle', model: 'vehicle', label: this.intl.t('resource.vehicle') }, + { value: 'fleet-ops:trailer', model: 'trailer', label: this.intl.t('resource.trailer') }, + ]; + } +} diff --git a/addon/components/modals/attach-trailer.hbs b/addon/components/modals/attach-trailer.hbs new file mode 100644 index 000000000..6d36be2eb --- /dev/null +++ b/addon/components/modals/attach-trailer.hbs @@ -0,0 +1,30 @@ + + + diff --git a/addon/components/modals/attach-trailer.js b/addon/components/modals/attach-trailer.js new file mode 100644 index 000000000..5a8babe10 --- /dev/null +++ b/addon/components/modals/attach-trailer.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class ModalsAttachTrailerComponent extends Component {} diff --git a/addon/components/modals/select-trailer.hbs b/addon/components/modals/select-trailer.hbs new file mode 100644 index 000000000..5df7bf971 --- /dev/null +++ b/addon/components/modals/select-trailer.hbs @@ -0,0 +1,35 @@ + + + diff --git a/addon/components/modals/select-trailer.js b/addon/components/modals/select-trailer.js new file mode 100644 index 000000000..5cde808b2 --- /dev/null +++ b/addon/components/modals/select-trailer.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class ModalsSelectTrailerComponent extends Component {} diff --git a/addon/components/trailer/card.hbs b/addon/components/trailer/card.hbs new file mode 100644 index 000000000..57ae3395b --- /dev/null +++ b/addon/components/trailer/card.hbs @@ -0,0 +1,48 @@ + + + + + +
{{@resource.displayName}}
+
+ {{#if @resource.type}}{{t (concat "trailer.types." @resource.type)}}{{/if}} + {{#if (or @resource.plate_number @resource.vin @resource.code)}}·{{or @resource.plate_number @resource.vin @resource.code}}{{/if}} +
+ {{#if (has-block "header")}} + {{yield to="header"}} + {{/if}} + + + +
+ + {{@resource.displayName}} +
+ + +
+ {{#if (has-block "body")}} + {{yield to="body"}} + {{/if}} +
+ + + + +
+
+ + + + {{#if (has-block "footer")}} + {{yield to="footer"}} + {{/if}} +
+
{{t "trailer.fields.updated-at"}}: {{@resource.updatedAt}}
+
+
+
+{{yield}} diff --git a/addon/components/trailer/card.js b/addon/components/trailer/card.js new file mode 100644 index 000000000..5956fb3f2 --- /dev/null +++ b/addon/components/trailer/card.js @@ -0,0 +1,6 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; + +export default class TrailerCardComponent extends Component { + @service trailerActions; +} diff --git a/addon/components/trailer/details.hbs b/addon/components/trailer/details.hbs new file mode 100644 index 000000000..dfc066397 --- /dev/null +++ b/addon/components/trailer/details.hbs @@ -0,0 +1,399 @@ +
+ {{! CORE DETAILS }} + +
+
+ {{t "trailer.sections.identification"}} +
+ +
+
{{t "trailer.fields.name"}}
+
{{n-a @resource.name}}
+
+ +
+
{{t "trailer.fields.id"}}
+
{{n-a @resource.public_id}}
+
+ +
+
{{t "trailer.fields.code"}}
+
{{n-a @resource.code}}
+
+ +
+
{{t "trailer.fields.type"}}
+
{{n-a this.typeLabel}}
+
+ +
+
{{t "trailer.fields.status"}}
+
+
+ +
+
{{t "trailer.fields.body-type"}}
+
{{n-a @resource.body_type}}
+
+ +
+
{{t "trailer.fields.color"}}
+
{{n-a @resource.color}}
+
+ +
+
{{t "trailer.fields.category"}}
+
{{n-a (or @resource.category.name @resource.category_name)}}
+
+ +
+
{{t "trailer.fields.description"}}
+
{{n-a @resource.description}}
+
+ +
+ {{t "trailer.sections.registration"}} +
+ +
+
{{t "trailer.fields.vin"}}
+
{{n-a @resource.vin}}
+
+ +
+
{{t "trailer.fields.plate-number"}}
+
{{n-a @resource.plate_number}}
+
+ +
+
{{t "trailer.fields.serial-number"}}
+
{{n-a @resource.serial_number}}
+
+ +
+
{{t "trailer.fields.make"}}
+
{{n-a @resource.make}}
+
+ +
+
{{t "trailer.fields.model"}}
+
{{n-a @resource.model}}
+
+ +
+
{{t "trailer.fields.year"}}
+
{{n-a @resource.year}}
+
+
+
+ + {{! TOWING CONNECTION }} + +
+
+
{{t "trailer.fields.attachment-state"}}
+
+ +
+
+ +
+
{{t "trailer.fields.vehicle"}}
+
+ {{#if this.isAttached}} + {{n-a this.currentVehicleName}} + {{else}} + {{t "trailer.attachment.no-vehicle"}} + {{/if}} +
+
+ + {{#if this.isAttached}} +
+
{{t "trailer.fields.attached-at"}}
+
{{n-a (format-date-fns @resource.attached_at "dd MMM yyyy, HH:mm")}}
+
+ +
+
{{t "trailer.fields.position"}}
+
{{n-a @resource.current_connection.position}}
+
+ {{/if}} + +
+ {{#if this.isAttached}} +
+
+
+ + {{! LOCATION & TELEMATICS }} + +
+
+
{{t "trailer.fields.connectivity"}}
+
+
+ +
+
{{t "trailer.fields.last-online"}}
+
+ {{#if @resource.last_online_at}} + {{format-date-fns @resource.last_online_at "dd MMM yyyy, HH:mm"}} + ({{@resource.lastOnlineAgo}}) + {{else}} + {{t "trailer.connectivity.never_connected"}} + {{/if}} +
+
+ +
+
{{t "trailer.fields.coordinates"}}
+
+ {{#if @resource.hasValidCoordinates}} + {{point-coordinates @resource.location}} +
+
+ +
+
{{t "trailer.fields.speed"}}
+
{{n-a @resource.speed}}
+
+ +
+
{{t "trailer.fields.heading"}}
+
{{n-a @resource.heading}}
+
+ +
+
{{t "trailer.fields.altitude"}}
+
{{n-a @resource.altitude}}
+
+ +
+
{{t "trailer.fields.devices-count"}}
+
{{or @resource.devices_count 0}}
+
+ +
+
{{t "trailer.fields.equipment-count"}}
+
{{or @resource.equipment_count 0}}
+
+ +
+
{{t "trailer.fields.last-provider"}}
+
{{n-a this.lastProvider}}
+
+ +
+
{{t "trailer.fields.last-event-at"}}
+
{{n-a (format-date-fns this.lastEventAt "dd MMM yyyy, HH:mm")}}
+
+
+
+ + {{! DIMENSIONS & CAPACITY }} + +
+
+
{{t "trailer.fields.measurement-system"}}
+
{{n-a this.measurementLabel}}
+
+ +
+
{{t "trailer.fields.odometer" unit=(or @resource.odometer_unit "")}}
+
{{n-a @resource.odometer}}
+
+ +
+
{{t "trailer.fields.length" unit=this.units.length}}
+
{{n-a @resource.length}}
+
+ +
+
{{t "trailer.fields.width" unit=this.units.length}}
+
{{n-a @resource.width}}
+
+ +
+
{{t "trailer.fields.height" unit=this.units.length}}
+
{{n-a @resource.height}}
+
+ +
+
{{t "trailer.fields.cargo-volume" unit=this.units.volume}}
+
{{n-a @resource.cargo_volume}}
+
+ +
+
{{t "trailer.fields.tare-weight" unit=this.units.weight}}
+
{{n-a @resource.tare_weight}}
+
+ +
+
{{t "trailer.fields.gvwr" unit=this.units.weight}}
+
{{n-a @resource.gvwr}}
+
+ +
+
{{t "trailer.fields.payload-capacity" unit=this.units.weight}}
+
{{n-a @resource.payload_capacity}}
+
+
+
+ + {{! RUNNING GEAR }} + +
+
+
{{t "trailer.fields.axle-count"}}
+
{{n-a @resource.axle_count}}
+
+ +
+
{{t "trailer.fields.tire-count"}}
+
{{n-a @resource.tire_count}}
+
+ +
+
{{t "trailer.fields.door-count"}}
+
{{n-a @resource.door_count}}
+
+ +
+
{{t "trailer.fields.coupling-type"}}
+
{{n-a this.couplingLabel}}
+
+ +
+
{{t "trailer.fields.brake-type"}}
+
{{n-a this.brakeLabel}}
+
+ +
+
{{t "trailer.fields.abs"}}
+
{{if @resource.abs_equipped (t "common.yes") (t "common.no")}}
+
+ +
+
{{t "trailer.fields.ebs"}}
+
{{if @resource.ebs_equipped (t "common.yes") (t "common.no")}}
+
+
+
+ + {{! REFRIGERATION }} + {{#if this.showRefrigeration}} + +
+
+
{{t "trailer.fields.refrigerated"}}
+
{{if @resource.refrigerated (t "common.yes") (t "common.no")}}
+
+ +
+
{{t "trailer.fields.reefer-hours"}}
+
{{n-a @resource.reefer_engine_hours}}
+
+ +
+
{{t "trailer.fields.temperature-min" unit=this.units.temperature}}
+
{{n-a @resource.temperature_min}}
+
+ +
+
{{t "trailer.fields.temperature-max" unit=this.units.temperature}}
+
{{n-a @resource.temperature_max}}
+
+
+
+ {{/if}} + + {{! OWNERSHIP & FINANCE }} + +
+
+
{{t "trailer.fields.ownership-type"}}
+
{{n-a this.ownershipLabel}}
+
+ +
+
{{t "trailer.fields.financing-status"}}
+
{{n-a @resource.financing_status}}
+
+ +
+
{{t "trailer.fields.vendor"}}
+
{{n-a (or @resource.vendor.name @resource.vendor_name)}}
+
+ +
+
{{t "trailer.fields.warranty"}}
+
{{n-a (or @resource.warranty.name @resource.warranty_name)}}
+
+ +
+
{{t "trailer.fields.purchased-at"}}
+
{{n-a (format-date-fns @resource.purchased_at "dd MMM yyyy")}}
+
+ +
+
{{t "trailer.fields.lease-expires-at"}}
+
{{n-a (format-date-fns @resource.lease_expires_at "dd MMM yyyy")}}
+
+ +
+
{{t "trailer.fields.acquisition-cost"}}
+
{{#if @resource.acquisition_cost}}{{format-currency @resource.acquisition_cost @resource.currency}}{{else}}-{{/if}}
+
+ +
+
{{t "trailer.fields.current-value"}}
+
{{#if @resource.current_value}}{{format-currency @resource.current_value @resource.currency}}{{else}}-{{/if}}
+
+ +
+
{{t "trailer.fields.insurance-value"}}
+
{{#if @resource.insurance_value}}{{format-currency @resource.insurance_value @resource.currency}}{{else}}-{{/if}}
+
+ +
+
{{t "trailer.fields.depreciation-rate"}}
+
{{#if @resource.depreciation_rate}}{{@resource.depreciation_rate}}%{{else}}-{{/if}}
+
+
+
+ + {{! NOTES & TIMESTAMPS }} + +
+
+
{{t "trailer.fields.notes"}}
+
{{n-a @resource.notes}}
+
+ +
+
{{t "trailer.fields.created-at"}}
+
{{n-a (format-date-fns @resource.created_at "dd MMM yyyy, HH:mm")}}
+
+ +
+
{{t "trailer.fields.updated-at"}}
+
{{n-a (format-date-fns @resource.updated_at "dd MMM yyyy, HH:mm")}}
+
+
+
+ + + + + + +
diff --git a/addon/components/trailer/details.js b/addon/components/trailer/details.js new file mode 100644 index 000000000..0905b84ba --- /dev/null +++ b/addon/components/trailer/details.js @@ -0,0 +1,118 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class TrailerDetailsComponent extends Component { + @service trailerActions; + @service vehicleActions; + @service intl; + + get trailer() { + return this.args.resource; + } + + get isAttached() { + return this.trailer?.isAttached ?? this.trailer?.attachment_state === 'attached'; + } + + get currentVehicle() { + return this.trailer?.current_vehicle; + } + + get currentVehicleName() { + return this.trailer?.current_vehicle_name ?? this.currentVehicle?.displayName ?? this.currentVehicle?.display_name ?? this.currentVehicle?.name; + } + + get typeLabel() { + const type = this.trailer?.type; + + return type ? this.intl.t(`trailer.types.${type}`, { default: type }) : null; + } + + get statusLabel() { + const status = this.trailer?.status; + + return status ? this.intl.t(`trailer.statuses.${status}`, { default: status }) : null; + } + + get attachmentLabel() { + return this.intl.t(`trailer.attachment.${this.isAttached ? 'attached' : 'detached'}`); + } + + get connectivityLabel() { + const status = this.trailer?.connectivity_status ?? 'never_connected'; + + return this.intl.t(`trailer.connectivity.${status}`, { default: status }); + } + + get ownershipLabel() { + const value = this.trailer?.ownership_type; + + return value ? this.intl.t(`trailer.ownership-types.${value}`, { default: value }) : null; + } + + get couplingLabel() { + const value = this.trailer?.coupling_type; + + return value ? this.intl.t(`trailer.coupling-types.${value}`, { default: value }) : null; + } + + get brakeLabel() { + const value = this.trailer?.brake_type; + + return value ? this.intl.t(`trailer.brake-types.${value}`, { default: value }) : null; + } + + get measurementLabel() { + const value = this.trailer?.measurement_system; + + return value ? this.intl.t(`trailer.measurement.${value}`, { default: value }) : null; + } + + get units() { + const system = this.trailer?.measurement_system === 'imperial' ? 'imperial' : 'metric'; + + return { + length: this.intl.t(`trailer.units.length-${system}`), + weight: this.intl.t(`trailer.units.weight-${system}`), + volume: this.intl.t(`trailer.units.volume-${system}`), + temperature: this.intl.t(`trailer.units.temperature-${system}`), + }; + } + + get showRefrigeration() { + return Boolean(this.trailer?.refrigerated) || this.trailer?.type === 'reefer'; + } + + get lastProvider() { + return this.trailer?.telematics?.last_provider ?? null; + } + + get lastEventAt() { + return this.trailer?.telematics?.last_event_at ?? null; + } + + @action viewVehicle() { + if (!this.currentVehicle) { + return; + } + + if (this.vehicleActions.panel?.view) { + return this.vehicleActions.panel.view(this.currentVehicle); + } + + return this.vehicleActions.transition.view(this.currentVehicle); + } + + @action attachVehicle() { + return this.trailerActions.attachVehicle(this.trailer); + } + + @action detachVehicle() { + return this.trailerActions.detachVehicle(this.trailer); + } + + @action locate() { + return this.trailerActions.locate(this.trailer); + } +} diff --git a/addon/components/trailer/details/connections.hbs b/addon/components/trailer/details/connections.hbs new file mode 100644 index 000000000..b3ecbe6e3 --- /dev/null +++ b/addon/components/trailer/details/connections.hbs @@ -0,0 +1,68 @@ +
+
+
+

{{t "trailer.tabs.connections"}}

+

{{t "trailer.connections.description"}}

+
+
+ {{#if this.isAttached}} +
+
+ + {{#if this.loadConnections.isRunning}} +
+ +
+ {{else if this.connections.length}} +
+ + + + + + + + + + + + + + {{#each this.connections as |connection|}} + + + + + + + + + + {{/each}} + +
{{t "trailer.fields.vehicle"}}{{t "trailer.fields.position"}}{{t "trailer.fields.connected-at"}}{{t "trailer.fields.disconnected-at"}}{{t "trailer.fields.duration"}}{{t "trailer.fields.source"}}{{t "trailer.fields.status"}}
+ {{#if connection.vehicle}} + {{or connection.vehicle.displayName connection.vehicle.display_name connection.vehicle.name}} + {{#if connection.vehicle.plate_number}}({{connection.vehicle.plate_number}}){{/if}} + {{else}} + {{t "trailer.attachment.no-vehicle"}} + {{/if}} + {{n-a connection.position}}{{n-a (format-date-fns connection.connected_at "dd MMM yyyy, HH:mm")}}{{#if connection.disconnected_at}}{{format-date-fns connection.disconnected_at "dd MMM yyyy, HH:mm"}}{{else}}{{t "trailer.connections.ongoing"}}{{/if}}{{n-a connection.duration}}{{n-a (smart-humanize connection.source)}} + +
+
+ {{else}} +
+ +

{{t "trailer.empty.connections"}}

+

{{t "trailer.empty.connections-description"}}

+ {{#unless this.isAttached}} +
+ {{/if}} +
diff --git a/addon/components/trailer/details/connections.js b/addon/components/trailer/details/connections.js new file mode 100644 index 000000000..2a5bd1b55 --- /dev/null +++ b/addon/components/trailer/details/connections.js @@ -0,0 +1,59 @@ +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'; + +/** + * Effective-dated towing history for a trailer, newest first. The active + * connection is highlighted and can be ended from here. + */ +export default class TrailerDetailsConnectionsComponent extends Component { + @service store; + @service notifications; + @service trailerActions; + @service vehicleActions; + @tracked connections = []; + + get trailer() { + return this.args.resource; + } + + get isAttached() { + return this.trailer?.isAttached ?? this.trailer?.attachment_state === 'attached'; + } + + constructor() { + super(...arguments); + this.loadConnections.perform(); + } + + @task *loadConnections() { + try { + const trailer = yield this.store.queryRecord('trailer', { public_id: this.trailer.public_id ?? this.trailer.id, single: true, with: ['connections.vehicle'] }); + this.connections = Array.from(trailer?.connections ?? []); + } catch (error) { + this.notifications.serverError(error); + } + } + + @action viewVehicle(vehicle) { + if (!vehicle) { + return; + } + + if (this.vehicleActions.panel?.view) { + return this.vehicleActions.panel.view(vehicle); + } + + return this.vehicleActions.transition.view(vehicle); + } + + @action attachVehicle() { + return this.trailerActions.attachVehicle(this.trailer, { callback: () => this.loadConnections.perform() }); + } + + @action detachVehicle() { + return this.trailerActions.detachVehicle(this.trailer, { callback: () => this.loadConnections.perform() }); + } +} diff --git a/addon/components/trailer/details/equipment.hbs b/addon/components/trailer/details/equipment.hbs new file mode 100644 index 000000000..b93e0c88d --- /dev/null +++ b/addon/components/trailer/details/equipment.hbs @@ -0,0 +1,52 @@ +
+
+
+

{{t "trailer.tabs.equipment"}}

+

{{t "trailer.equipment.description"}}

+
+
+ + {{#if this.loadEquipment.isRunning}} +
+ +
+ {{else if this.equipment.length}} +
+ + + + + + + + + + + + {{#each this.equipment as |item|}} + + + + + + + + {{/each}} + +
{{t "common.name"}}{{t "common.type"}}{{t "trailer.fields.serial-number"}}{{t "common.status"}}
{{or item.name item.public_id}}{{n-a (smart-humanize item.type)}}{{n-a (or item.serial_number item.code)}} +
+
+
+
+ {{else}} +
+ +

{{t "trailer.empty.equipment"}}

+

{{t "trailer.empty.equipment-description"}}

+
+ {{/if}} +
diff --git a/addon/components/trailer/details/equipment.js b/addon/components/trailer/details/equipment.js new file mode 100644 index 000000000..d9ec921da --- /dev/null +++ b/addon/components/trailer/details/equipment.js @@ -0,0 +1,52 @@ +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'; + +export default class TrailerDetailsEquipmentComponent extends Component { + @service store; + @service notifications; + @service equipmentActions; + @service trailerActions; + @tracked equipment = []; + + get trailer() { + return this.args.resource; + } + + constructor() { + super(...arguments); + this.loadEquipment.perform(); + } + + @task *loadEquipment() { + try { + const equipment = yield this.store.query('equipment', { + equipable_type: 'fleet-ops:trailer', + equipable: this.trailer.public_id ?? this.trailer.id, + sort: '-created_at', + }); + + this.equipment = Array.from(equipment ?? []); + } catch (error) { + this.notifications.serverError(error); + } + } + + @action attach() { + return this.trailerActions.attachEquipment(this.trailer, { callback: () => this.loadEquipment.perform() }); + } + + @action detach(equipment) { + return this.trailerActions.detachEquipment(this.trailer, equipment, { callback: () => this.loadEquipment.perform() }); + } + + @action view(equipment) { + if (this.equipmentActions.panel?.view) { + return this.equipmentActions.panel.view(equipment); + } + + return this.equipmentActions.transition.view(equipment); + } +} diff --git a/addon/components/trailer/details/maintenance-history.hbs b/addon/components/trailer/details/maintenance-history.hbs new file mode 100644 index 000000000..31f5ae01a --- /dev/null +++ b/addon/components/trailer/details/maintenance-history.hbs @@ -0,0 +1,47 @@ +
+
+
+ + {{#if this.loadMaintenanceHistory.isRunning}} +
+ +
+ {{else if this.maintenanceHistory.length}} +
+ + + + + + + + + + + + + {{#each this.maintenanceHistory as |record|}} + + + + + + + + + {{/each}} + +
{{t "trailer.maintenance.summary"}}{{t "common.type"}}{{t "trailer.maintenance.odometer"}}{{t "trailer.maintenance.total-cost"}}{{t "trailer.maintenance.date"}}
{{record.summary}}{{n-a (smart-humanize record.type)}}{{n-a record.odometer_reading}} {{record.odometer_unit}}{{#if record.total_cost}}{{format-currency record.total_cost record.currency}}{{else}}-{{/if}}{{n-a (format-date-fns record.completed_at "dd MMM yyyy")}} +
+
+ {{else}} +
+ +

{{t "trailer.empty.maintenance"}}

+

{{t "trailer.empty.maintenance-description"}}

+
+ {{/if}} +
diff --git a/addon/components/trailer/details/maintenance-history.js b/addon/components/trailer/details/maintenance-history.js new file mode 100644 index 000000000..67cd0f336 --- /dev/null +++ b/addon/components/trailer/details/maintenance-history.js @@ -0,0 +1,40 @@ +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'; + +export default class TrailerDetailsMaintenanceHistoryComponent extends Component { + @service maintenanceActions; + @service notifications; + @service store; + @service trailerActions; + @tracked maintenanceHistory = []; + + get trailer() { + return this.args.resource; + } + + constructor() { + super(...arguments); + this.loadMaintenanceHistory.perform(); + } + + @task *loadMaintenanceHistory() { + try { + const records = yield this.store.query('maintenance', { + maintainable_uuid: this.trailer.id, + maintainable_type: 'trailer', + sort: '-created_at', + }); + + this.maintenanceHistory = Array.from(records ?? []); + } catch (error) { + this.notifications.serverError(error); + } + } + + @action logMaintenance() { + return this.trailerActions.logMaintenance(this.trailer, {}, { refresh: false, callback: () => this.loadMaintenanceHistory.perform() }); + } +} diff --git a/addon/components/trailer/details/schedules.hbs b/addon/components/trailer/details/schedules.hbs new file mode 100644 index 000000000..f1f19f1b6 --- /dev/null +++ b/addon/components/trailer/details/schedules.hbs @@ -0,0 +1,45 @@ +
+
+
+ + {{#if this.loadSchedules.isRunning}} +
+ +
+ {{else if this.schedules.length}} +
+ + + + + + + + + + + + {{#each this.schedules as |schedule|}} + + + + + + + + {{/each}} + +
{{t "common.name"}}{{t "trailer.maintenance.interval"}}{{t "trailer.maintenance.next-due"}}{{t "common.status"}}
{{schedule.name}}{{schedule.interval_value}} {{schedule.interval_unit}}{{n-a (format-date-fns schedule.next_due_date "dd MMM yyyy")}} +
+
+ {{else}} +
+ +

{{t "trailer.empty.schedules"}}

+

{{t "trailer.empty.schedules-description"}}

+
+ {{/if}} +
diff --git a/addon/components/trailer/details/schedules.js b/addon/components/trailer/details/schedules.js new file mode 100644 index 000000000..369bae95c --- /dev/null +++ b/addon/components/trailer/details/schedules.js @@ -0,0 +1,40 @@ +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'; + +export default class TrailerDetailsSchedulesComponent extends Component { + @service maintenanceScheduleActions; + @service notifications; + @service store; + @service trailerActions; + @tracked schedules = []; + + get trailer() { + return this.args.resource; + } + + constructor() { + super(...arguments); + this.loadSchedules.perform(); + } + + @task *loadSchedules() { + try { + const schedules = yield this.store.query('maintenance-schedule', { + subject_uuid: this.trailer.id, + subject_type: 'fleet-ops:trailer', + sort: '-created_at', + }); + + this.schedules = Array.from(schedules ?? []); + } catch (error) { + this.notifications.serverError(error); + } + } + + @action createSchedule() { + return this.trailerActions.scheduleMaintenance(this.trailer, {}, { refresh: false, callback: () => this.loadSchedules.perform() }); + } +} diff --git a/addon/components/trailer/details/work-orders.hbs b/addon/components/trailer/details/work-orders.hbs new file mode 100644 index 000000000..ad6623800 --- /dev/null +++ b/addon/components/trailer/details/work-orders.hbs @@ -0,0 +1,47 @@ +
+
+
+ + {{#if this.loadWorkOrders.isRunning}} +
+ +
+ {{else if this.workOrders.length}} +
+ + + + + + + + + + + + + {{#each this.workOrders as |workOrder|}} + + + + + + + + + {{/each}} + +
{{t "trailer.maintenance.code"}}{{t "trailer.maintenance.subject"}}{{t "trailer.maintenance.priority"}}{{t "common.status"}}{{t "trailer.maintenance.due"}}
{{workOrder.code}}{{workOrder.subject}}{{n-a (smart-humanize workOrder.priority)}}{{n-a (format-date-fns workOrder.due_at "dd MMM yyyy")}} +
+
+ {{else}} +
+ +

{{t "trailer.empty.work-orders"}}

+

{{t "trailer.empty.work-orders-description"}}

+
+ {{/if}} +
diff --git a/addon/components/trailer/details/work-orders.js b/addon/components/trailer/details/work-orders.js new file mode 100644 index 000000000..aeb87dcc7 --- /dev/null +++ b/addon/components/trailer/details/work-orders.js @@ -0,0 +1,40 @@ +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'; + +export default class TrailerDetailsWorkOrdersComponent extends Component { + @service workOrderActions; + @service notifications; + @service store; + @service trailerActions; + @tracked workOrders = []; + + get trailer() { + return this.args.resource; + } + + constructor() { + super(...arguments); + this.loadWorkOrders.perform(); + } + + @task *loadWorkOrders() { + try { + const workOrders = yield this.store.query('work-order', { + target_uuid: this.trailer.id, + target_type: 'trailer', + sort: '-created_at', + }); + + this.workOrders = Array.from(workOrders ?? []); + } catch (error) { + this.notifications.serverError(error); + } + } + + @action createWorkOrder() { + return this.trailerActions.createWorkOrder(this.trailer, {}, { refresh: false, callback: () => this.loadWorkOrders.perform() }); + } +} diff --git a/addon/components/trailer/form.hbs b/addon/components/trailer/form.hbs new file mode 100644 index 000000000..62ef3f864 --- /dev/null +++ b/addon/components/trailer/form.hbs @@ -0,0 +1,447 @@ +
+ + + + + {{! DETAILS / IDENTIFICATION }} + +
+
+ {{@resource.name}} + + + +
+
+ +
+ {{t "common.upload-image-supported"}} +
+
+
+ +
+
+ {{t "trailer.sections.identification"}} +
+ + + + + + + + + + +