diff --git a/addon/components/geofence-map.hbs b/addon/components/geofence-map.hbs new file mode 100644 index 000000000..bd2f68649 --- /dev/null +++ b/addon/components/geofence-map.hbs @@ -0,0 +1,19 @@ + + + {{#each this.polygons as |locations index|}} + + {{#if (eq index 0)}} + {{@label}} + {{/if}} + + {{/each}} + diff --git a/addon/components/geofence-map.js b/addon/components/geofence-map.js new file mode 100644 index 000000000..e1c488bac --- /dev/null +++ b/addon/components/geofence-map.js @@ -0,0 +1,83 @@ +import Component from '@glimmer/component'; +import { action, get } from '@ember/object'; +import { later, cancel } from '@ember/runloop'; + +/** + * A service area's or zone's boundary on a map, fitted to the whole shape. + * + * The details panels open inside a sliding overlay, so Leaflet first measures + * a container with no size: the tiles stay grey and the view is centred on + * the wrong point. The map is re-measured and re-fitted once the overlay has + * settled, and again whenever its container changes size. + */ +export default class GeofenceMapComponent extends Component { + map = null; + resizeObserver = null; + timer = null; + + /** Each polygon's outer ring as Leaflet `[lat, lng]` pairs. */ + get polygons() { + const border = this.args.resource ? get(this.args.resource, 'border') : null; + const type = border?.type; + const coordinates = border?.coordinates ?? []; + const rings = type === 'MultiPolygon' ? coordinates.map((polygon) => polygon?.[0] ?? []) : [coordinates[0] ?? []]; + + return rings.map((ring) => ring.filter((pair) => Array.isArray(pair) && pair.length >= 2).map(([lng, lat]) => [lat, lng])).filter((ring) => ring.length > 0); + } + + /** `[[south, west], [north, east]]` around every polygon, or null. */ + get bounds() { + const points = this.polygons.flat(); + if (!points.length) { + return null; + } + + const lats = points.map(([lat]) => lat); + const lngs = points.map(([, lng]) => lng); + + return [ + [Math.min(...lats), Math.min(...lngs)], + [Math.max(...lats), Math.max(...lngs)], + ]; + } + + get center() { + const bounds = this.bounds; + if (!bounds) { + return [0, 0]; + } + + return [(bounds[0][0] + bounds[1][0]) / 2, (bounds[0][1] + bounds[1][1]) / 2]; + } + + @action didLoadMap({ target: map }) { + this.map = map; + this.fit(); + requestAnimationFrame(() => this.fit()); + this.timer = later(this, this.fit, 350); + + if (typeof ResizeObserver !== 'undefined' && typeof map.getContainer === 'function') { + this.resizeObserver = new ResizeObserver(() => this.fit()); + this.resizeObserver.observe(map.getContainer()); + } + } + + fit() { + if (this.isDestroying || this.isDestroyed || !this.map) { + return; + } + + this.map.invalidateSize(); + + if (this.bounds) { + this.map.fitBounds(this.bounds, { padding: [24, 24] }); + } + } + + willDestroy() { + super.willDestroy(...arguments); + cancel(this.timer); + this.resizeObserver?.disconnect(); + this.map = null; + } +} diff --git a/addon/components/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js index 442955818..0abf9056f 100644 --- a/addon/components/layout/fleet-ops-sidebar.js +++ b/addon/components/layout/fleet-ops-sidebar.js @@ -135,10 +135,13 @@ export default class LayoutFleetOpsSidebarComponent extends Component { get resourcesItems() { return this.withRegistryItems('management', [ - this.createHubItem('Resources Hub', 'layer-group', 'management.index', 'fleet-ops list driver', 'fleet-ops see driver', [ + this.createHubItem(this.intl.t('menu.radar'), 'satellite-dish', 'management.index', 'fleet-ops list driver', 'fleet-ops see driver', [ + 'radar', + 'needs attention', 'resources hub', - 'resource dashboard', - 'resource readiness', + 'triage', + 'handover', + 'shift changes', ]), this.createItem('menu.drivers', 'id-card', 'management.drivers', 'fleet-ops list driver', 'fleet-ops see driver', ['driver', 'online drivers']), this.createItem('menu.vehicles', 'truck', 'management.vehicles', 'fleet-ops list vehicle', 'fleet-ops see vehicle', ['vehicle', 'track vehicles', 'online vehicles']), diff --git a/addon/components/maintenance-schedule/form.hbs b/addon/components/maintenance-schedule/form.hbs index b4d3ac508..971aae801 100644 --- a/addon/components/maintenance-schedule/form.hbs +++ b/addon/components/maintenance-schedule/form.hbs @@ -57,6 +57,7 @@ @selected={{this.selectedSubjectType}} @onChange={{this.onSubjectTypeChange}} @placeholder="Select Asset Type" + @allowClear={{true}} @triggerClass="form-select form-input" @disabled={{cannot-write @resource}} as |option| @@ -73,6 +74,7 @@ @selectedItemComponent={{if (resource-component "select-option" this.subjectModelName) (component (resource-component "select-option" this.subjectModelName) compact=true)}} @selectedModel={{@resource.subject}} @placeholder="Select Asset" + @allowClear={{true}} @triggerClass="form-select form-input" @infiniteScroll={{false}} @renderInPlace={{true}} @@ -214,6 +216,7 @@ @selected={{this.selectedAssigneeType}} @onChange={{this.onAssigneeTypeChange}} @placeholder="Select Assignee Type" + @allowClear={{true}} @triggerClass="form-select form-input" @disabled={{cannot-write @resource}} as |option| @@ -230,6 +233,7 @@ @selectedItemComponent={{if (resource-component "select-option" this.assigneeModelName) (component (resource-component "select-option" this.assigneeModelName) compact=true)}} @selectedModel={{@resource.default_assignee}} @placeholder="Select Default Assignee" + @allowClear={{true}} @triggerClass="form-select form-input" @infiniteScroll={{false}} @renderInPlace={{true}} diff --git a/addon/components/maintenance-schedule/form.js b/addon/components/maintenance-schedule/form.js index a9aeca8a2..306fb2458 100644 --- a/addon/components/maintenance-schedule/form.js +++ b/addon/components/maintenance-schedule/form.js @@ -141,25 +141,25 @@ export default class MaintenanceScheduleFormComponent extends Component { } @action onSubjectTypeChange(option) { - this.selectedSubjectType = option; + this.selectedSubjectType = option ?? null; // Clear the subject relationship — user must re-select the asset this.args.resource.subject = null; - this.subjectModelName = TYPE_TO_MODEL[option.value] ?? null; + this.subjectModelName = TYPE_TO_MODEL[option?.value] ?? null; } @action assignSubject(model) { - this.args.resource.subject = model; + this.args.resource.subject = model ?? null; } @action onAssigneeTypeChange(option) { - this.selectedAssigneeType = option; + this.selectedAssigneeType = option ?? null; // Clear the default_assignee relationship — user must re-select this.args.resource.default_assignee = null; - this.assigneeModelName = TYPE_TO_MODEL[option.value] ?? null; + this.assigneeModelName = TYPE_TO_MODEL[option?.value] ?? null; } @action assignDefaultAssignee(model) { - this.args.resource.default_assignee = model; + this.args.resource.default_assignee = model ?? null; } @action addReminderOffset(value) { diff --git a/addon/components/maintenance-schedule/work-orders.hbs b/addon/components/maintenance-schedule/work-orders.hbs new file mode 100644 index 000000000..55748a129 --- /dev/null +++ b/addon/components/maintenance-schedule/work-orders.hbs @@ -0,0 +1,39 @@ +
+ {{#if this.load.isRunning}} +
+ {{else if this.workOrders.length}} +
+ {{#each this.workOrders as |workOrder|}} + + {{/each}} +
+ {{else}} +
+ +

No work orders yet

+

Work orders will appear here when this schedule triggers.

+
+ {{/if}} +
diff --git a/addon/components/maintenance-schedule/work-orders.js b/addon/components/maintenance-schedule/work-orders.js new file mode 100644 index 000000000..d503bc53b --- /dev/null +++ b/addon/components/maintenance-schedule/work-orders.js @@ -0,0 +1,37 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +/** + * The work orders a maintenance schedule has raised, for the schedule's + * context panel. The details route loads the same list in its own route. + */ +export default class MaintenanceScheduleWorkOrdersComponent extends Component { + @service store; + @service workOrderActions; + @tracked workOrders = []; + + constructor() { + super(...arguments); + this.load.perform(); + } + + get schedule() { + return this.args.resource ?? this.args.model; + } + + @task *load() { + if (!this.schedule?.id) { + return; + } + + const workOrders = yield this.store.query('work-order', { schedule_uuid: this.schedule.id }); + this.workOrders = workOrders?.toArray?.() ?? [...(workOrders ?? [])]; + } + + @action view(workOrder) { + return this.workOrderActions.panel.view(workOrder); + } +} diff --git a/addon/components/maintenance/form.hbs b/addon/components/maintenance/form.hbs index 56b355799..221731c88 100644 --- a/addon/components/maintenance/form.hbs +++ b/addon/components/maintenance/form.hbs @@ -74,6 +74,7 @@ @selected={{this.selectedMaintainableType}} @onChange={{this.onMaintainableTypeChange}} @placeholder="Select asset type (Vehicle, Equipment…)" + @allowClear={{true}} @triggerClass="form-select form-input" @disabled={{cannot-write @resource}} as |option| @@ -89,6 +90,7 @@ @selectedItemComponent={{if (resource-component "select-option" this.maintainableModelName) (component (resource-component "select-option" this.maintainableModelName) compact=true)}} @selectedModel={{@resource.maintainable}} @placeholder="Select asset" + @allowClear={{true}} @triggerClass="form-select form-input" @infiniteScroll={{false}} @renderInPlace={{true}} @@ -117,6 +119,7 @@ @selected={{this.selectedPerformedByType}} @onChange={{this.onPerformedByTypeChange}} @placeholder="Select performer type (Vendor, Driver, User…)" + @allowClear={{true}} @triggerClass="form-select form-input" @disabled={{cannot-write @resource}} as |option| @@ -132,6 +135,7 @@ @selectedItemComponent={{if (resource-component "select-option" this.performedByModelName) (component (resource-component "select-option" this.performedByModelName) compact=true)}} @selectedModel={{@resource.performed_by}} @placeholder="Select performer" + @allowClear={{true}} @triggerClass="form-select form-input" @infiniteScroll={{false}} @renderInPlace={{true}} diff --git a/addon/components/maintenance/form.js b/addon/components/maintenance/form.js index d5339843c..2bf04e198 100644 --- a/addon/components/maintenance/form.js +++ b/addon/components/maintenance/form.js @@ -122,7 +122,7 @@ export default class MaintenanceFormComponent extends Component { * maintainable relationship so a stale association is not persisted. */ @action onMaintainableTypeChange(option) { - this.selectedMaintainableType = option; + this.selectedMaintainableType = option ?? null; // Clear the maintainable relationship — user must re-select the asset this.args.resource.maintainable = null; this.maintainableModelName = TYPE_TO_MODEL[option?.value] ?? null; @@ -130,7 +130,7 @@ export default class MaintenanceFormComponent extends Component { /** Assigns the selected maintainable model to the resource. */ @action assignMaintainable(model) { - this.args.resource.maintainable = model; + this.args.resource.maintainable = model ?? null; } /** @@ -138,7 +138,7 @@ export default class MaintenanceFormComponent extends Component { * performed-by relationship so a stale association is not persisted. */ @action onPerformedByTypeChange(option) { - this.selectedPerformedByType = option; + this.selectedPerformedByType = option ?? null; // Clear the performed_by relationship — user must re-select this.args.resource.performed_by = null; this.performedByModelName = TYPE_TO_MODEL[option?.value] ?? null; @@ -146,6 +146,6 @@ export default class MaintenanceFormComponent extends Component { /** Assigns the selected performer model to the resource. */ @action assignPerformedBy(model) { - this.args.resource.performed_by = model; + this.args.resource.performed_by = model ?? null; } } diff --git a/addon/components/maintenance/panel-header.hbs b/addon/components/maintenance/panel-header.hbs index 7e096ed84..790b4402e 100644 --- a/addon/components/maintenance/panel-header.hbs +++ b/addon/components/maintenance/panel-header.hbs @@ -1,21 +1,23 @@ -
-
-
-
-

{{n-a @resource.summary}}

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

{{n-a @resource.summary}}

+ {{smart-humanize @resource.status}}
-
-
{{smart-humanize @resource.type}}
+
+ {{#if @resource.type}} + {{smart-humanize @resource.type}} + {{/if}} {{#if @resource.priority}} -
{{t "column.priority"}}: {{smart-humanize @resource.priority}}
+ {{t "column.priority"}}: {{smart-humanize @resource.priority}} {{/if}} {{#if @resource.scheduled_at}} -
{{t "column.scheduled-at"}}: {{format-date @resource.scheduled_at}}
+ {{t "column.scheduled-at"}}: {{format-date @resource.scheduled_at}} {{/if}}
-
+
-
\ No newline at end of file +
diff --git a/addon/components/modals/driver-assign-vehicle.hbs b/addon/components/modals/driver-assign-vehicle.hbs index 2cc2b6009..77b65b883 100644 --- a/addon/components/modals/driver-assign-vehicle.hbs +++ b/addon/components/modals/driver-assign-vehicle.hbs @@ -5,13 +5,12 @@ diff --git a/addon/components/modals/driver-assign-vehicle.js b/addon/components/modals/driver-assign-vehicle.js new file mode 100644 index 000000000..18f93a500 --- /dev/null +++ b/addon/components/modals/driver-assign-vehicle.js @@ -0,0 +1,30 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action, set } from '@ember/object'; + +/** + * Picks the vehicle for a driver. + * + * The select is handed the chosen vehicle record, not its uuid. ModelSelect + * only resolves a uuid into a record when it is first rendered; a uuid that + * arrives later is shown as-is, which is why a picked vehicle used to read + * "-" in the trigger. The driver still receives both the vehicle and its uuid, + * which is what the confirm handlers read. + */ +export default class ModalsDriverAssignVehicleComponent extends Component { + @tracked vehicle = null; + + get selectedVehicle() { + return this.vehicle ?? this.args.options?.driver?.vehicle_uuid ?? null; + } + + @action selectVehicle(vehicle) { + this.vehicle = vehicle; + + const driver = this.args.options?.driver; + if (driver) { + set(driver, 'vehicle', vehicle); + set(driver, 'vehicle_uuid', vehicle?.id ?? null); + } + } +} diff --git a/addon/components/modals/radar-notice.hbs b/addon/components/modals/radar-notice.hbs new file mode 100644 index 000000000..49333e0f3 --- /dev/null +++ b/addon/components/modals/radar-notice.hbs @@ -0,0 +1,24 @@ + +