diff --git a/RELEASE.md b/RELEASE.md index 754087ea9..af8b70063 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,13 +1,15 @@ -> v0.6.64 ~ "Order responses no longer fail when a vehicle is assigned" +> v0.6.65 ~ "First-class trailer operations" --- -## Bug Fixes -- **Orders with an assigned vehicle serialize again when expansions are requested.** The Vehicle and Fleet resources read the request's `with` list and loaded it onto the model they wrap. That is correct on their own endpoints, where the controller has already allowlisted the list, but both resources are also rendered nested inside Order and Driver responses. There the same request carries the parent's expansions, so `GET /v1/orders?with=payload` on an order with a vehicle assigned failed during serialisation with `RelationNotFoundException: Call to undefined relationship [payload] on model Vehicle`. Navigator order fetches and the console scheduler both hit this. Nested resources now keep only the relations the wrapped model actually defines; direct endpoint behaviour is unchanged. +## 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. --- ## Testing -- Added resource-level regressions for parent expansions reaching a nested Vehicle or Fleet resource, for string and empty `with` forms, and for resources wrapping plain data. -- Added end-to-end checks resolving a real vehicle and a real fleet against an order-shaped request. +- 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. --- ## Need help? diff --git a/addon/components/attachment-list.hbs b/addon/components/attachment-list.hbs new file mode 100644 index 000000000..a9a4e3f3a --- /dev/null +++ b/addon/components/attachment-list.hbs @@ -0,0 +1,65 @@ +
+
+
+

{{@title}}

+ {{#if @description}} +

{{@description}}

+ {{/if}} +
+
+ {{yield to="actions"}} + {{#if @actionText}} +
+
+ + {{#if @isLoading}} +
+ +
+ {{else if @items.length}} +
+ {{#each @items as |item index|}} +
+
+ {{yield item index to="item"}} +
+ {{#if (has-block "itemActions")}} +
+ {{yield item index to="itemActions"}} +
+ {{/if}} +
+ {{/each}} +
+ {{else}} +
+ +

{{@emptyTitle}}

+ {{#if @emptyDescription}} +

{{@emptyDescription}}

+ {{/if}} + {{#if (and @actionText (not @hideEmptyAction))}} +
+ {{/if}} +
diff --git a/addon/components/attachment-list.js b/addon/components/attachment-list.js new file mode 100644 index 000000000..9068b0524 --- /dev/null +++ b/addon/components/attachment-list.js @@ -0,0 +1,8 @@ +import Component from '@glimmer/component'; + +/** + * Shared layout for "things attached to an asset" tabs (devices, equipment, + * trailers, towing history) so every attachment tab reads the same way: a + * header with a description and primary action, uniform rows, one empty state. + */ +export default class AttachmentListComponent extends Component {} 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/driver-identity.js b/addon/components/cell/driver-identity.js index 8b07e4e94..a58ad0509 100644 --- a/addon/components/cell/driver-identity.js +++ b/addon/components/cell/driver-identity.js @@ -1,6 +1,6 @@ import Component from '@glimmer/component'; import { action, get } from '@ember/object'; -import config from 'ember-get-config'; +import { getPlaceholderImage, resolveResourceImage } from '../../utils/placeholder-images'; import { resolveIdentityCellResource } from '../../utils/identity-cell-resource'; const DEFAULT_STATUS_TONES = { @@ -34,11 +34,11 @@ export default class CellDriverIdentityComponent extends Component { } get mediaUrl() { - return get(this.resource, 'photo_url'); + return resolveResourceImage(get(this.resource, 'photo_url'), 'driver'); } get fallbackImage() { - return config?.defaultValues?.driverImage; + return getPlaceholderImage('driver'); } get hasCompactStatusDot() { @@ -97,7 +97,7 @@ export default class CellDriverIdentityComponent extends Component { ...(this.args.column ?? {}), labelPath: 'name', mediaPath: 'photo_url', - fallbackImage: config?.defaultValues?.driverImage, + fallbackImage: getPlaceholderImage('driver'), statusPath: 'status', onlinePath: 'online', showStatusBadge: this.args.column?.showStatusBadge ?? true, diff --git a/addon/components/cell/driver-name.hbs b/addon/components/cell/driver-name.hbs index e8455dea0..dda1e4b83 100644 --- a/addon/components/cell/driver-name.hbs +++ b/addon/components/cell/driver-name.hbs @@ -1,7 +1,7 @@ {{#let (or @row.driver this.driver) as |driver|}} {{#if driver}}
- {{driver.name}} + {{driver.name}} {{get-default-value driver.name}} {{#if @row.vehicle_assigned}} 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/contact/card.hbs b/addon/components/contact/card.hbs new file mode 100644 index 000000000..df0a98cd5 --- /dev/null +++ b/addon/components/contact/card.hbs @@ -0,0 +1,58 @@ + + + + + +
+
{{n-a @resource.name}}
+
+
{{n-a @resource.title @resource.internal_id @resource.public_id}}
+ {{#if (has-block "header")}} + {{yield to="header"}} + {{/if}} + + + +
+ + {{@resource.name}} + {{#if (has-block "body")}} + {{yield to="body"}} + {{/if}} + + + + + +
+ +
+
+
+ + + + {{#if (has-block "footer")}} + {{yield to="footer"}} + {{/if}} +
+ +
+ + {{#if @resource.phone}}{{@resource.phone}}{{else}}-{{/if}} +
+
+ + {{n-a @resource.address @resource.place.address}} +
+
{{t "common.last-modified"}}: {{@resource.updatedAt}}
+
+ + +{{yield}} diff --git a/addon/components/contact/card.js b/addon/components/contact/card.js new file mode 100644 index 000000000..774b60525 --- /dev/null +++ b/addon/components/contact/card.js @@ -0,0 +1,19 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; + +/** + * Card for contacts and customers. Customers are contacts driven by the customer + * actions service, so the actions service can be passed in as `@resourceActions`. + */ +export default class ContactCardComponent extends Component { + @service contactActions; + + get resourceActions() { + return this.args.resourceActions ?? this.contactActions; + } + + /** Customers get their own silhouette; plain contacts the generic one. */ + get placeholderType() { + return this.args.placeholderType ?? 'contact'; + } +} diff --git a/addon/components/contact/form.hbs b/addon/components/contact/form.hbs index 3b9d77ca4..1020c94f2 100644 --- a/addon/components/contact/form.hbs +++ b/addon/components/contact/form.hbs @@ -2,7 +2,7 @@
- {{@resource.name}} + {{@resource.name}} diff --git a/addon/components/contact/panel-header.hbs b/addon/components/contact/panel-header.hbs index 790bd6769..617544271 100644 --- a/addon/components/contact/panel-header.hbs +++ b/addon/components/contact/panel-header.hbs @@ -2,7 +2,7 @@
- {{@resource.name}} + {{@resource.name}} diff --git a/addon/components/customer/form.hbs b/addon/components/customer/form.hbs index 4dd63de78..469d50fdb 100644 --- a/addon/components/customer/form.hbs +++ b/addon/components/customer/form.hbs @@ -3,8 +3,8 @@
{{@resource.name}} -
-
-
-
-
-
- {{#each this.devices as |device|}} -
- -
-
-
- {{else}} -
- -

- {{t "device.prompts.no-devices-attached-title"}} -

-

- {{t "device.prompts.no-devices-attached-body" resourceType=(get-model-name @resource)}} -

-
- {{/each}} -
-
+ + <:item as |device|> + + {{#if device.status}} + + {{/if}} + + <:itemActions as |device|> +
@@ -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/driver/card.hbs b/addon/components/driver/card.hbs index 4b3059080..e990df5fa 100644 --- a/addon/components/driver/card.hbs +++ b/addon/components/driver/card.hbs @@ -1,11 +1,11 @@ - + -
+
{{or @resource.name @resource.display_name}}
- +
{{n-a @resource.phone @resource.public_id}}
{{#if (has-block "header")}} @@ -16,7 +16,7 @@ - + {{or {{#if (has-block "body")}} {{yield to="body"}} {{/if}} @@ -26,11 +26,11 @@
- {{this.statusLabel}} +
-
@@ -39,11 +39,22 @@ {{#if (has-block "footer")}} {{yield to="footer"}} {{/if}} -
-
- {{n-a this.assignedVehicleLabel}} +
+
+ + + {{#if this.assignedVehicleLabel}} + {{#if this.hasVehicle}} + {{this.assignedVehicleLabel}} + {{else}} + {{this.assignedVehicleLabel}} + {{/if}} + {{else}} + {{t "common.no-vehicle-assigned"}} + {{/if}} +
-
Last Modified: {{@resource.updatedAt}}
+
{{t "common.last-modified"}}: {{@resource.updatedAt}}
- \ No newline at end of file + diff --git a/addon/components/driver/card.js b/addon/components/driver/card.js index 829b921ad..d0a281eb6 100644 --- a/addon/components/driver/card.js +++ b/addon/components/driver/card.js @@ -1,19 +1,15 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; -import { get } from '@ember/object'; -import config from 'ember-get-config'; +import { action, get } from '@ember/object'; export default class DriverCardComponent extends Component { @service driverActions; + @service vehicleActions; get resource() { return this.args.resource; } - get fallbackImage() { - return config?.defaultValues?.driverImage; - } - get statusValue() { return get(this.resource, 'status') ?? ''; } @@ -27,4 +23,22 @@ export default class DriverCardComponent extends Component { return get(driver, 'vehicle_assigned.display_name') ?? get(driver, 'vehicle.display_name') ?? get(driver, 'vehicle_name'); } + + get hasVehicle() { + return Boolean(get(this.resource, 'vehicle_uuid') ?? get(this.resource, 'vehicle_assigned') ?? get(this.resource, 'vehicle')); + } + + @action async viewVehicle() { + const vehicle = (await get(this.resource, 'vehicle')) ?? get(this.resource, 'vehicle_assigned'); + + if (!vehicle) { + return; + } + + if (this.vehicleActions.panel?.view) { + return this.vehicleActions.panel.view(vehicle); + } + + return this.vehicleActions.transition.view(vehicle); + } } diff --git a/addon/components/driver/form.hbs b/addon/components/driver/form.hbs index 1c884db96..aa44096ea 100644 --- a/addon/components/driver/form.hbs +++ b/addon/components/driver/form.hbs @@ -16,7 +16,7 @@ >
- {{model.name}} + {{model.name}}
{{model.name}}
@@ -52,8 +52,8 @@
{{@resource.name}}
{{@resource.name}} +
{{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}} diff --git a/addon/components/equipment/pill.js b/addon/components/equipment/pill.js new file mode 100644 index 000000000..8636ecd1e --- /dev/null +++ b/addon/components/equipment/pill.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class EquipmentPillComponent extends Component {} diff --git a/addon/components/filter/multi-model.hbs b/addon/components/filter/multi-model.hbs new file mode 100644 index 000000000..6440f2754 --- /dev/null +++ b/addon/components/filter/multi-model.hbs @@ -0,0 +1,19 @@ +
+ + {{get model (or @filter.modelNamePath "name")}} + + +
diff --git a/addon/components/filter/multi-model.js b/addon/components/filter/multi-model.js new file mode 100644 index 000000000..fe06883eb --- /dev/null +++ b/addon/components/filter/multi-model.js @@ -0,0 +1,90 @@ +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-panel/driver-listing.hbs b/addon/components/fleet-panel/driver-listing.hbs index d3986ab61..cdda2c6d7 100644 --- a/addon/components/fleet-panel/driver-listing.hbs +++ b/addon/components/fleet-panel/driver-listing.hbs @@ -57,7 +57,7 @@
- {{driver.name}} + {{driver.name}}
diff --git a/addon/components/fleet-panel/vehicle-listing.hbs b/addon/components/fleet-panel/vehicle-listing.hbs index 23eb56acf..ae1d1067a 100644 --- a/addon/components/fleet-panel/vehicle-listing.hbs +++ b/addon/components/fleet-panel/vehicle-listing.hbs @@ -57,7 +57,7 @@
- {{vehicle.name}} + {{vehicle.name}}
diff --git a/addon/components/fleet/driver-listing.hbs b/addon/components/fleet/driver-listing.hbs index 052d0256a..2cdc53d84 100644 --- a/addon/components/fleet/driver-listing.hbs +++ b/addon/components/fleet/driver-listing.hbs @@ -59,7 +59,7 @@
- {{driver.name}} + {{driver.name}}
diff --git a/addon/components/fleet/vehicle-listing.hbs b/addon/components/fleet/vehicle-listing.hbs index 0df50f61a..e01f6a548 100644 --- a/addon/components/fleet/vehicle-listing.hbs +++ b/addon/components/fleet/vehicle-listing.hbs @@ -59,7 +59,7 @@
- {{vehicle.name}} + {{vehicle.name}}
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/map/leaflet-live-map.hbs b/addon/components/map/leaflet-live-map.hbs index 44f3f7735..a82b064b5 100644 --- a/addon/components/map/leaflet-live-map.hbs +++ b/addon/components/map/leaflet-live-map.hbs @@ -229,6 +229,32 @@
Driver
{{n-a vehicle.driver_name}}
+
+
{{t "resource.trailers"}}
+
+ {{#each vehicle.trailers as |trailer|}} +
+ + {{or trailer.displayName trailer.display_name trailer.name trailer.public_id}} +
+ {{else}} + - + {{/each}} +
+
+
+
{{t "resource.devices"}}
+
+ {{#each vehicle.devices as |device|}} +
+ + {{or device.displayName device.display_name device.name device.device_id device.public_id}} +
+ {{else}} + - + {{/each}} +
+
Order
{{n-a vehicle.meta.current_order_reference}}
@@ -276,6 +302,32 @@
Driver
{{n-a vehicle.driver_name}}
+
+
{{t "resource.trailers"}}
+
+ {{#each vehicle.trailers as |trailer|}} +
+ + {{or trailer.displayName trailer.display_name trailer.name trailer.public_id}} +
+ {{else}} + - + {{/each}} +
+
+
+
{{t "resource.devices"}}
+
+ {{#each vehicle.devices as |device|}} +
+ + {{or device.displayName device.display_name device.name device.device_id device.public_id}} +
+ {{else}} + - + {{/each}} +
+
Order
{{n-a vehicle.meta.current_order_reference}}
diff --git a/addon/components/map/order-list-overlay/order.hbs b/addon/components/map/order-list-overlay/order.hbs index cbd511b07..dbacfdd0d 100644 --- a/addon/components/map/order-list-overlay/order.hbs +++ b/addon/components/map/order-list-overlay/order.hbs @@ -75,8 +75,8 @@
+ + 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/contact-form.hbs b/addon/components/modals/contact-form.hbs index 9b59c0baf..f40fcb425 100644 --- a/addon/components/modals/contact-form.hbs +++ b/addon/components/modals/contact-form.hbs @@ -3,8 +3,8 @@
{{@options.contact.name}}
- {{this.customer.name}} + {{this.customer.name}}
{{this.customer.name}}
{{n-a this.customer.email}}
diff --git a/addon/components/modals/driver-form.hbs b/addon/components/modals/driver-form.hbs index acaa7eb5f..b9a0a9fad 100644 --- a/addon/components/modals/driver-form.hbs +++ b/addon/components/modals/driver-form.hbs @@ -3,8 +3,8 @@
{{@options.driver.name}} + + 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/modals/vehicle-form.hbs b/addon/components/modals/vehicle-form.hbs index ec6669400..df1e112e2 100644 --- a/addon/components/modals/vehicle-form.hbs +++ b/addon/components/modals/vehicle-form.hbs @@ -3,8 +3,8 @@
{{@options.vehicle.name}} {{or
{{or
{{group.waypoint.customer.name}} diff --git a/addon/components/order/kanban-card.hbs b/addon/components/order/kanban-card.hbs index 6dc102093..d3b6b1cfc 100644 --- a/addon/components/order/kanban-card.hbs +++ b/addon/components/order/kanban-card.hbs @@ -64,8 +64,8 @@
{{@card.customer.name}} @@ -81,8 +81,8 @@
{{@card.driver_assigned.name}} diff --git a/addon/components/trailer/card.hbs b/addon/components/trailer/card.hbs new file mode 100644 index 000000000..e46f17ecb --- /dev/null +++ b/addon/components/trailer/card.hbs @@ -0,0 +1,68 @@ + + + + + +
+
{{@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}} +
+
+ + + {{#if @resource.isAttached}} + {{#if @resource.current_vehicle}} + {{n-a @resource.current_vehicle_name}} + {{else}} + {{n-a @resource.current_vehicle_name}} + {{/if}} + {{else}} + {{t "trailer.attachment.no-vehicle"}} + {{/if}} + +
+
+ + {{t (concat "trailer.connectivity." (or @resource.connectivity_status "never_connected"))}} +
+
{{t "common.last-modified"}}: {{@resource.updatedAt}}
+
+
+
+{{yield}} diff --git a/addon/components/trailer/card.js b/addon/components/trailer/card.js new file mode 100644 index 000000000..22acd4f18 --- /dev/null +++ b/addon/components/trailer/card.js @@ -0,0 +1,22 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +export default class TrailerCardComponent extends Component { + @service trailerActions; + @service vehicleActions; + + @action viewVehicle() { + const vehicle = this.args.resource?.current_vehicle; + + if (!vehicle) { + return; + } + + if (this.vehicleActions.panel?.view) { + return this.vehicleActions.panel.view(vehicle); + } + + return this.vehicleActions.transition.view(vehicle); + } +} 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..53bbf7382 --- /dev/null +++ b/addon/components/trailer/details/connections.hbs @@ -0,0 +1,57 @@ + + <:item as |connection|> +
+
+ {{#if connection.vehicle}} + + {{else}} + {{t "trailer.attachment.no-vehicle"}} + {{/if}} +
+
+
+ + {{t "trailer.connections.connected" date=(format-date-fns connection.connected_at "dd MMM yyyy, HH:mm")}} +
+
+ {{#if connection.disconnected_at}} + + {{t "trailer.connections.disconnected" date=(format-date-fns connection.disconnected_at "dd MMM yyyy, HH:mm")}} + {{else}} + + {{t "trailer.connections.still-attached"}} + {{/if}} +
+
+ {{#if connection.position}} + {{t "trailer.attachment.position" position=connection.position}} + {{/if}} + {{#if connection.duration}} + {{connection.duration}} + {{/if}} + {{#if connection.source}} + {{smart-humanize connection.source}} + {{/if}} +
+
+
+ + <:itemActions as |connection|> + + +
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..1ea958f0f --- /dev/null +++ b/addon/components/trailer/details/equipment.hbs @@ -0,0 +1,25 @@ + + <:item as |item|> + + {{#if item.status}} + + {{/if}} + + <:itemActions as |item|> +
+ + {{#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..f923278ed --- /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"}} +
+ + + + + + + + + + +