- {{#if this.maintenanceHistory.isRunning}}
+ {{#if this.loadMaintenanceHistory.isRunning}}
diff --git a/addon/components/vehicle/details/schedules.hbs b/addon/components/vehicle/details/schedules.hbs
index 859a72873..42ea9e16a 100644
--- a/addon/components/vehicle/details/schedules.hbs
+++ b/addon/components/vehicle/details/schedules.hbs
@@ -1,5 +1,5 @@
- {{#if this.schedules.isRunning}}
+ {{#if this.loadSchedules.isRunning}}
@@ -21,7 +21,7 @@
{{schedule.name}}
{{schedule.interval_value}} {{schedule.interval_unit}}
{{n-a (format-date-fns schedule.next_due_date "dd MMM yyyy")}}
-
+
{{smart-humanize schedule.status}}
diff --git a/addon/components/vehicle/details/work-orders.hbs b/addon/components/vehicle/details/work-orders.hbs
index 22a77e6cd..eb5c64fca 100644
--- a/addon/components/vehicle/details/work-orders.hbs
+++ b/addon/components/vehicle/details/work-orders.hbs
@@ -22,7 +22,7 @@
{{workOrder.code}}
{{workOrder.subject}}
{{humanize workOrder.priority}}
-
+
{{smart-humanize workOrder.status}}
{{n-a (format-date-fns workOrder.due_at "dd MMM yyyy")}}
diff --git a/addon/components/vehicle/form.hbs b/addon/components/vehicle/form.hbs
index d0095e9fb..01d2b51d1 100644
--- a/addon/components/vehicle/form.hbs
+++ b/addon/components/vehicle/form.hbs
@@ -83,6 +83,7 @@
- {{model.name}}
+
diff --git a/addon/components/vehicle/panel-header.hbs b/addon/components/vehicle/panel-header.hbs
index 90d2989f2..4c1101811 100644
--- a/addon/components/vehicle/panel-header.hbs
+++ b/addon/components/vehicle/panel-header.hbs
@@ -17,14 +17,13 @@
{{@resource.displayName}}
- {{#if @resource.driver}}
+ {{#if this.driverName}}
{{else}}
{{t "common.no-driver-assigned"}}
- {{!-- --}}
{{/if}}
@@ -46,4 +45,4 @@
-
\ No newline at end of file
+
diff --git a/addon/components/vehicle/panel-header.js b/addon/components/vehicle/panel-header.js
index b3d1269f8..947e08058 100644
--- a/addon/components/vehicle/panel-header.js
+++ b/addon/components/vehicle/panel-header.js
@@ -1,3 +1,10 @@
import Component from '@glimmer/component';
+import relationValue from '../../utils/relation-value';
-export default class VehiclePanelHeaderComponent extends Component {}
+export default class VehiclePanelHeaderComponent extends Component {
+ get driverName() {
+ const driver = relationValue(this.args.resource, 'driver');
+
+ return driver?.name || this.args.resource?.driver_name;
+ }
+}
diff --git a/addon/components/vehicle/pill.hbs b/addon/components/vehicle/pill.hbs
index 24f6afbf6..16e8dc110 100644
--- a/addon/components/vehicle/pill.hbs
+++ b/addon/components/vehicle/pill.hbs
@@ -1,33 +1,24 @@
-
- <:default>
- {{or this.resource.name this.resource.yearMakeModel @titleFallback "No vehicle"}}
- {{or this.resource.plate_number this.resource.vin this.resource.serial_number this.resource.call_sign "-"}}
-
- <:tooltip>
-
-
{{this.resource.name this.resource.yearMakeModel}}
-
{{t "resource.driver"}}: {{n-a this.resource.driver_name}}
-
- {{t "common.status"}}:
-
- {{if this.resource.online (t "common.online") (t "common.offline")}}
-
-
-
Pos: {{point-coordinates this.resource.location}}
-
-
-
\ No newline at end of file
+ {{yield resource}}
+
diff --git a/addon/components/vehicle/pill.js b/addon/components/vehicle/pill.js
deleted file mode 100644
index ccf7b3103..000000000
--- a/addon/components/vehicle/pill.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import Component from '@glimmer/component';
-
-export default class VehiclePillComponent extends Component {
- get resource() {
- return this.args.vehicle ?? this.args.resource;
- }
-}
diff --git a/addon/components/vehicle/summary.hbs b/addon/components/vehicle/summary.hbs
new file mode 100644
index 000000000..577d3938c
--- /dev/null
+++ b/addon/components/vehicle/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/vendor/form.hbs b/addon/components/vendor/form.hbs
index f484cd47d..eca027847 100644
--- a/addon/components/vendor/form.hbs
+++ b/addon/components/vendor/form.hbs
@@ -109,6 +109,7 @@
-
-
{{n-a model.address}}
-
{{model.public_id}}
-
+
diff --git a/addon/components/vendor/personnel-manager.hbs b/addon/components/vendor/personnel-manager.hbs
index c4ca0c9b1..c774aa192 100644
--- a/addon/components/vendor/personnel-manager.hbs
+++ b/addon/components/vendor/personnel-manager.hbs
@@ -33,6 +33,7 @@
-
-
-
-
-
-
{{model.name}}
-
{{n-a model.email}}
-
-
+
diff --git a/addon/components/vendor/personnel-tab.hbs b/addon/components/vendor/personnel-tab.hbs
new file mode 100644
index 000000000..23b356bb7
--- /dev/null
+++ b/addon/components/vendor/personnel-tab.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/vendor/personnel-tab.js b/addon/components/vendor/personnel-tab.js
new file mode 100644
index 000000000..3e733d711
--- /dev/null
+++ b/addon/components/vendor/personnel-tab.js
@@ -0,0 +1,4 @@
+import Component from '@glimmer/component';
+
+/** The vendor details panel's Personnel tab: the personnel manager for `@resource`. */
+export default class VendorPersonnelTabComponent extends Component {}
diff --git a/addon/components/vendor/pill.hbs b/addon/components/vendor/pill.hbs
new file mode 100644
index 000000000..4fcf04ef5
--- /dev/null
+++ b/addon/components/vendor/pill.hbs
@@ -0,0 +1,24 @@
+
+ {{yield resource}}
+
diff --git a/addon/components/vendor/summary.hbs b/addon/components/vendor/summary.hbs
new file mode 100644
index 000000000..c231423b0
--- /dev/null
+++ b/addon/components/vendor/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/warranty/details.hbs b/addon/components/warranty/details.hbs
index 59792deaf..34da25303 100644
--- a/addon/components/warranty/details.hbs
+++ b/addon/components/warranty/details.hbs
@@ -26,7 +26,9 @@
Vendor
-
{{n-a @resource.vendor_name}}
+
+
+
diff --git a/addon/components/warranty/form.hbs b/addon/components/warranty/form.hbs
index 4a0de2f50..094a6d171 100644
--- a/addon/components/warranty/form.hbs
+++ b/addon/components/warranty/form.hbs
@@ -12,6 +12,7 @@
- {{model.name}}
+
diff --git a/addon/components/warranty/pill.hbs b/addon/components/warranty/pill.hbs
new file mode 100644
index 000000000..c5e4ce0ab
--- /dev/null
+++ b/addon/components/warranty/pill.hbs
@@ -0,0 +1,24 @@
+
+ {{yield resource}}
+
diff --git a/addon/components/warranty/summary.hbs b/addon/components/warranty/summary.hbs
new file mode 100644
index 000000000..7b207b867
--- /dev/null
+++ b/addon/components/warranty/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/waypoint/pill.hbs b/addon/components/waypoint/pill.hbs
new file mode 100644
index 000000000..6f12eb668
--- /dev/null
+++ b/addon/components/waypoint/pill.hbs
@@ -0,0 +1,24 @@
+
+ {{yield resource}}
+
diff --git a/addon/components/waypoint/summary.hbs b/addon/components/waypoint/summary.hbs
new file mode 100644
index 000000000..17d247576
--- /dev/null
+++ b/addon/components/waypoint/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/widget/radar.hbs b/addon/components/widget/radar.hbs
new file mode 100644
index 000000000..d0e30bf31
--- /dev/null
+++ b/addon/components/widget/radar.hbs
@@ -0,0 +1,28 @@
+
diff --git a/addon/components/widget/radar.js b/addon/components/widget/radar.js
new file mode 100644
index 000000000..04ec36159
--- /dev/null
+++ b/addon/components/widget/radar.js
@@ -0,0 +1,44 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { task } from 'ember-concurrency';
+
+/**
+ * "Radar · 3 overdue": the dashboard tile that links to the page.
+ */
+export default class WidgetRadarComponent extends Component {
+ @service fetch;
+
+ @tracked summary = null;
+ @tracked error = null;
+
+ constructor() {
+ super(...arguments);
+ this.load.perform();
+ }
+
+ get stats() {
+ return this.summary?.summary ?? { open: 0, overdue: 0, snoozed: 0, critical: 0 };
+ }
+
+ get accentClass() {
+ if (this.stats.critical > 0 || this.stats.overdue > 0) {
+ return 'kpi-accent-bad';
+ }
+
+ return this.stats.open > 0 ? 'kpi-accent-neutral' : 'kpi-accent-good';
+ }
+
+ get valueClass() {
+ return this.stats.overdue > 0 ? 'text-rose-600 dark:text-rose-400' : 'text-gray-900 dark:text-white';
+ }
+
+ @task({ restartable: true }) *load() {
+ this.error = null;
+ try {
+ this.summary = yield this.fetch.get('fleet-ops/radar/summary');
+ } catch (err) {
+ this.error = err?.message ?? 'Failed to load';
+ }
+ }
+}
diff --git a/addon/components/work-order/details.hbs b/addon/components/work-order/details.hbs
index 8113bdafc..1ee3ecfa6 100644
--- a/addon/components/work-order/details.hbs
+++ b/addon/components/work-order/details.hbs
@@ -56,16 +56,32 @@
Target Asset
-
{{n-a (or @resource.target.displayName @resource.target.name @resource.target_name)}}
+
+
+
Assigned Vendor
-
{{n-a (or @resource.assignee.displayName @resource.assignee.name @resource.assignee_name)}}
+
+
+
- {{#if @resource.schedule}}
+ {{#if (or this.schedule @resource.schedule_uuid)}}
Linked Schedule
-
{{n-a @resource.schedule.name}}
+
+
+
+
+ {{/if}}
+ {{#if this.inspections.length}}
+
+
{{t "inspection.record.linked-inspections" count=this.inspections.length}}
+
+ {{#each this.inspections as |inspection|}}
+
+ {{/each}}
+
{{/if}}
diff --git a/addon/components/work-order/details.js b/addon/components/work-order/details.js
index be4c2d096..8062736c9 100644
--- a/addon/components/work-order/details.js
+++ b/addon/components/work-order/details.js
@@ -1,3 +1,66 @@
import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { debug } from '@ember/debug';
+import { task } from 'ember-concurrency';
-export default class WorkOrderDetailsComponent extends Component {}
+/**
+ * A work order raised from a failed inspection is named on that inspection,
+ * and the panel read none of it — the inspection that caused a repair was
+ * invisible from the repair. It is looked up rather than read from meta, so
+ * one attached by hand shows up too.
+ */
+export default class WorkOrderDetailsComponent extends Component {
+ @service store;
+ @service inspectionSubmissionActions;
+ @tracked inspections = [];
+ @tracked schedule = null;
+
+ constructor() {
+ super(...arguments);
+ this.loadInspections.perform();
+ this.loadSchedule.perform();
+ }
+
+ /**
+ * A work order only carries the schedule's uuid; there is no relation on
+ * the model, so the "Linked Schedule" field never rendered. Peek the
+ * store first, then fetch.
+ */
+ @task *loadSchedule() {
+ const id = this.args.resource?.schedule_uuid;
+
+ if (!id) {
+ this.schedule = null;
+ return;
+ }
+
+ try {
+ this.schedule = this.store.peekRecord('maintenance-schedule', id) ?? (yield this.store.findRecord('maintenance-schedule', id));
+ } catch (error) {
+ debug(`Could not load the schedule linked to work order ${id}: ${error.message}`);
+ this.schedule = null;
+ }
+ }
+
+ @task *loadInspections() {
+ const id = this.args.resource?.uuid ?? this.args.resource?.id;
+ if (!id) {
+ return;
+ }
+
+ try {
+ const inspections = yield this.store.query('inspection-submission', { work_order_uuid: id, sort: '-created_at' });
+ this.inspections = Array.from(inspections ?? []);
+ } catch (error) {
+ // Supplementary to the work order: a failed lookup should not put an
+ // error in front of someone who opened the work order to read it.
+ debug(`Could not load the inspections linked to work order ${id}: ${error.message}`);
+ }
+ }
+
+ @action viewInspection(submission) {
+ return this.inspectionSubmissionActions.transition.view(submission);
+ }
+}
diff --git a/addon/components/work-order/form.hbs b/addon/components/work-order/form.hbs
index d9b2afb53..e5cfc85a7 100644
--- a/addon/components/work-order/form.hbs
+++ b/addon/components/work-order/form.hbs
@@ -85,6 +85,7 @@
@selected={{this.selectedTargetType}}
@onChange={{this.onTargetTypeChange}}
@placeholder="Select target type"
+ @allowClear={{true}}
@triggerClass="form-select form-input"
@disabled={{cannot-write @resource}}
as |option|
@@ -97,8 +98,10 @@
- {{or model.name model.subject model.public_id}}
+ {{#let (resource-component "select-option" this.targetModelName) as |selectOption|}}
+ {{#if selectOption}}
+ {{component selectOption option=model}}
+ {{else}}
+ {{or model.displayName model.name model.public_id}}
+ {{/if}}
+ {{/let}}
{{/if}}
@@ -121,6 +130,7 @@
@selected={{this.selectedAssigneeType}}
@onChange={{this.onAssigneeTypeChange}}
@placeholder="Select assignee type"
+ @allowClear={{true}}
@triggerClass="form-select form-input"
@disabled={{cannot-write @resource}}
as |option|
@@ -133,8 +143,10 @@
- {{or model.name model.public_id}}
+ {{#let (resource-component "select-option" this.assigneeModelName) as |selectOption|}}
+ {{#if selectOption}}
+ {{component selectOption option=model}}
+ {{else}}
+ {{or model.displayName model.name model.public_id}}
+ {{/if}}
+ {{/let}}
{{/if}}
diff --git a/addon/components/work-order/form.js b/addon/components/work-order/form.js
index 2a97c047b..81550ccb4 100644
--- a/addon/components/work-order/form.js
+++ b/addon/components/work-order/form.js
@@ -138,33 +138,33 @@ export default class WorkOrderFormComponent extends Component {
}
/**
- * Handles a change to the target type selector. Resets the target
- * relationship so a stale association is not persisted.
+ * Handles a change to the target type selector, including clearing it.
+ * Resets the target relationship so a stale association is not persisted.
*/
@action onTargetTypeChange(option) {
- this.selectedTargetType = option;
+ this.selectedTargetType = option ?? null;
this.args.resource.target = null;
- this.targetModelName = TYPE_TO_MODEL[option.value] ?? null;
+ this.targetModelName = option ? (TYPE_TO_MODEL[option.value] ?? null) : null;
}
/** Assigns the selected target model to the resource. */
@action assignTarget(model) {
- this.args.resource.target = model;
+ this.args.resource.target = model ?? null;
}
/**
- * Handles a change to the assignee type selector. Resets the assignee
- * relationship so a stale association is not persisted.
+ * Handles a change to the assignee type selector, including clearing it.
+ * Resets the assignee relationship so a stale association is not persisted.
*/
@action onAssigneeTypeChange(option) {
- this.selectedAssigneeType = option;
+ this.selectedAssigneeType = option ?? null;
this.args.resource.assignee = null;
- this.assigneeModelName = TYPE_TO_MODEL[option.value] ?? null;
+ this.assigneeModelName = option ? (TYPE_TO_MODEL[option.value] ?? null) : null;
}
/** Assigns the selected assignee model to the resource. */
@action assignAssignee(model) {
- this.args.resource.assignee = model;
+ this.args.resource.assignee = model ?? null;
}
/**
diff --git a/addon/components/work-order/panel-header.hbs b/addon/components/work-order/panel-header.hbs
index 077b97d4f..48cf4d0c8 100644
--- a/addon/components/work-order/panel-header.hbs
+++ b/addon/components/work-order/panel-header.hbs
@@ -1,21 +1,23 @@
-
-
-
-
-
{{or @resource.code @resource.subject}}
-
{{smart-humanize @resource.status}}
+
+
+
+
+
{{or @resource.code @resource.subject}}
+ {{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.assignee_name}}
-
{{t "column.assignee"}}: {{@resource.assignee_name}}
+ {{#if (or @resource.assignee_name @resource.assignee.name)}}
+
{{t "column.assignee"}}: {{or @resource.assignee_name @resource.assignee.name}}
{{/if}}
-
-
\ No newline at end of file
+
diff --git a/addon/components/work-order/pill.hbs b/addon/components/work-order/pill.hbs
new file mode 100644
index 000000000..6e5b0e0b6
--- /dev/null
+++ b/addon/components/work-order/pill.hbs
@@ -0,0 +1,24 @@
+
+ {{yield resource}}
+
diff --git a/addon/components/work-order/summary.hbs b/addon/components/work-order/summary.hbs
new file mode 100644
index 000000000..792e7b9ed
--- /dev/null
+++ b/addon/components/work-order/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/zone/details.hbs b/addon/components/zone/details.hbs
index 284582073..9b831f2ee 100644
--- a/addon/components/zone/details.hbs
+++ b/addon/components/zone/details.hbs
@@ -8,31 +8,12 @@
{{t "zone.fields.description"}}
-
{{n-a @resource.type}}
+
{{n-a @resource.description}}
-
-
-
- {{@resource.name}} {{t "resource.zone"}}
-
-
+
\ No newline at end of file
diff --git a/addon/components/zone/pill.hbs b/addon/components/zone/pill.hbs
new file mode 100644
index 000000000..9d555a687
--- /dev/null
+++ b/addon/components/zone/pill.hbs
@@ -0,0 +1,24 @@
+
+ {{yield resource}}
+
diff --git a/addon/components/zone/summary.hbs b/addon/components/zone/summary.hbs
new file mode 100644
index 000000000..b6143caf0
--- /dev/null
+++ b/addon/components/zone/summary.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/controllers/connectivity/devices/index/details.js b/addon/controllers/connectivity/devices/index/details.js
index 9e4835d45..6068e2804 100644
--- a/addon/controllers/connectivity/devices/index/details.js
+++ b/addon/controllers/connectivity/devices/index/details.js
@@ -42,24 +42,7 @@ export default class ConnectivityDevicesIndexDetailsController extends Controlle
icon: 'ellipsis-h',
iconPrefix: 'fas',
renderInPlace: true,
- items: [
- {
- text: this.intl.t('device.actions.attach-to-asset'),
- icon: 'link',
- fn: () => this.deviceActions.attachToVehicle(this.model),
- permission: 'fleet-ops update device',
- },
- ...(this.model.attachable_uuid || this.model.attached_to_name || this.model.attachable
- ? [
- {
- text: this.intl.t('device.attachment.detach'),
- icon: 'unlink',
- fn: () => this.deviceActions.detachFromVehicle(this.model),
- permission: 'fleet-ops update device',
- },
- ]
- : []),
- ],
+ items: this.deviceActions.attachmentItems(this.model),
},
];
}
diff --git a/addon/controllers/connectivity/devices/index/details/sensors.js b/addon/controllers/connectivity/devices/index/details/sensors.js
index e68f869b5..013603aa0 100644
--- a/addon/controllers/connectivity/devices/index/details/sensors.js
+++ b/addon/controllers/connectivity/devices/index/details/sensors.js
@@ -98,6 +98,8 @@ export default class ConnectivityDevicesIndexDetailsSensorsController extends Co
filterParam: 'sensors_type',
filterComponent: 'filter/multi-option',
filterOptions: fleetOpsOptions('sensorTypes'),
+ filterOptionLabel: 'label',
+ filterOptionValue: 'value',
},
{
label: 'Value',
@@ -128,6 +130,8 @@ export default class ConnectivityDevicesIndexDetailsSensorsController extends Co
filterParam: 'sensors_status',
filterComponent: 'filter/multi-option',
filterOptions: fleetOpsOptions('sensorStatuses'),
+ filterOptionLabel: 'label',
+ filterOptionValue: 'value',
},
{
label: 'Last Reading',
diff --git a/addon/controllers/connectivity/events/index.js b/addon/controllers/connectivity/events/index.js
index 6e2eef8d0..8932f927e 100644
--- a/addon/controllers/connectivity/events/index.js
+++ b/addon/controllers/connectivity/events/index.js
@@ -125,8 +125,6 @@ export default class ConnectivityEventsIndexController extends Controller {
valuePath: 'device.displayName',
cellComponent: 'cell/device-identity',
resourcePath: eventDevice,
- compact: true,
- showStatus: false,
action: this.openDevice,
permission: 'fleet-ops view device',
resizable: true,
@@ -141,9 +139,8 @@ export default class ConnectivityEventsIndexController extends Controller {
{
label: 'Provider',
valuePath: 'provider',
- cellComponent: 'cell/telematic-provider',
+ cellComponent: 'cell/telematic-identity',
resourcePath: eventTelematic,
- compact: true,
action: this.openTelematic,
permission: 'fleet-ops view telematic',
resizable: true,
diff --git a/addon/controllers/connectivity/fuel-providers/details.js b/addon/controllers/connectivity/fuel-providers/details.js
index f1057aef7..b45fc75b5 100644
--- a/addon/controllers/connectivity/fuel-providers/details.js
+++ b/addon/controllers/connectivity/fuel-providers/details.js
@@ -1,11 +1,72 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
-import { task } from 'ember-concurrency';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { syncRunView, fuelNumber, fuelMoney } from '../../../utils/fuel-integration-format';
+import { task, timeout } from 'ember-concurrency';
export default class ConnectivityFuelProvidersIndexDetailsController extends Controller {
@service hostRouter;
@service fetch;
@service notifications;
+ @service modalsManager;
+
+ @tracked activity;
+ @tracked activityError;
+
+ get runs() {
+ return (this.activity?.runs ?? []).map(syncRunView);
+ }
+ get latestRun() {
+ return this.runs[0];
+ }
+ get hasPendingSync() {
+ return this.runs.some((run) => run.pending);
+ }
+ get syncDisabled() {
+ return this.hasPendingSync || this.model?.status === 'disabled' || this.syncTransactions.isRunning;
+ }
+ get totals() {
+ return this.activity?.totals;
+ }
+ get metrics() {
+ if (!this.totals) return [];
+ return [
+ { label: 'Imported transactions', value: fuelNumber(this.totals.transactions), icon: 'receipt' },
+ { label: 'Needs matching', value: fuelNumber(this.totals.unmatched), icon: 'link' },
+ { label: 'Fuel reports', value: fuelNumber(this.totals.fuel_reports), icon: 'file-lines' },
+ { label: 'Fuel volume', value: `${fuelNumber(this.totals.liters)} L`, icon: 'gas-pump' },
+ ];
+ }
+ get spend() {
+ return (this.totals?.spend ?? []).map((row) => fuelMoney(row.amount, row.currency)).join(' · ') || 'No spend recorded';
+ }
+ get environmentLabel() {
+ return this.model?.environment === 'sandbox' ? 'Sandbox / test account' : 'Production account';
+ }
+
+ @action async refreshActivity() {
+ try {
+ const id = this.model.id;
+ const activity = await this.fetch.get(`fuel-provider-connections/${id}/activity`);
+ if (this.isDestroying || this.model.id !== id) return;
+ this.activity = activity;
+ this.activityError = null;
+ const state = { ...activity.connection };
+ for (const field of ['last_synced_at', 'last_tested_at']) state[field] = state[field] ? new Date(state[field]) : null;
+ this.model.setProperties(state);
+ } catch (error) {
+ this.activityError = 'Unable to refresh integration activity. Your current view is preserved; try Refresh.';
+ }
+ }
+
+ @task({ restartable: true }) *watchActivity() {
+ do {
+ yield this.refreshActivity();
+ if (!this.hasPendingSync || this.activityError) return;
+ yield timeout(3000);
+ } while (!this.isDestroying);
+ }
get tabs() {
return [
@@ -13,7 +74,6 @@ export default class ConnectivityFuelProvidersIndexDetailsController extends Con
{ route: 'connectivity.fuel-providers.details.sync', label: 'Sync' },
{ route: 'connectivity.fuel-providers.details.matching', label: 'Matching' },
{ route: 'connectivity.fuel-providers.details.transactions', label: 'Transactions' },
- { route: 'connectivity.fuel-providers.details.settings', label: 'Settings' },
].map((tab) => ({
...tab,
active: this.hostRouter.currentRouteName?.endsWith(tab.route),
@@ -47,21 +107,24 @@ export default class ConnectivityFuelProvidersIndexDetailsController extends Con
return this.model?.last_sync_state?.summary ?? {};
}
- @task *testConnection() {
- try {
- const result = yield this.fetch.post(`fuel-provider-connections/${this.model.id}/test-connection`);
- this.notifications.success(result.message ?? 'Fuel integration connection tested.');
- yield this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- }
+ @action openConnectionTestDialog() {
+ return this.modalsManager.show('modals/fuel-connection-diagnostics', {
+ title: 'Test Fuel Connection',
+ acceptButtonText: 'Run Test',
+ acceptButtonIcon: 'plug',
+ declineButtonText: 'Close',
+ connection: this.model,
+ onTested: this.refreshActivity,
+ });
}
- @task *syncTransactions(options = {}) {
+ @task({ drop: true }) *syncTransactions(options = {}) {
+ if (this.hasPendingSync || this.model?.status === 'disabled') return;
try {
- yield this.fetch.post(`fuel-provider-connections/${this.model.id}/sync`, { async: true, ...options });
+ yield this.fetch.post(`fuel-provider-connections/${this.model.id}/sync`, { async: true, from: options.from, to: options.to });
this.notifications.success('Fuel transaction sync queued.');
- yield this.hostRouter.refresh();
+ yield this.refreshActivity();
+ this.watchActivity.perform();
} catch (error) {
this.notifications.serverError(error);
}
diff --git a/addon/controllers/connectivity/fuel-providers/details/index.js b/addon/controllers/connectivity/fuel-providers/details/index.js
new file mode 100644
index 000000000..222caa63f
--- /dev/null
+++ b/addon/controllers/connectivity/fuel-providers/details/index.js
@@ -0,0 +1,6 @@
+import Controller from '@ember/controller';
+import { inject as controller } from '@ember/controller';
+
+export default class FuelIntegrationOverviewController extends Controller {
+ @controller('connectivity.fuel-providers.details') details;
+}
diff --git a/addon/controllers/connectivity/fuel-providers/details/matching.js b/addon/controllers/connectivity/fuel-providers/details/matching.js
new file mode 100644
index 000000000..d088ddd9b
--- /dev/null
+++ b/addon/controllers/connectivity/fuel-providers/details/matching.js
@@ -0,0 +1,13 @@
+import Controller from '@ember/controller';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+
+export default class ConnectivityFuelProvidersDetailsMatchingController extends Controller {
+ @service hostRouter;
+
+ @action reviewUnmatchedTransactions() {
+ return this.hostRouter.transitionTo('console.fleet-ops.management.fuel-transactions.index', {
+ queryParams: { sync_status: 'unmatched', connection: this.model.id, page: 1 },
+ });
+ }
+}
diff --git a/addon/controllers/connectivity/fuel-providers/details/sync.js b/addon/controllers/connectivity/fuel-providers/details/sync.js
index 2a993ca54..2af247b20 100644
--- a/addon/controllers/connectivity/fuel-providers/details/sync.js
+++ b/addon/controllers/connectivity/fuel-providers/details/sync.js
@@ -1,18 +1,29 @@
import Controller from '@ember/controller';
import { inject as controller } from '@ember/controller';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { fuelDate } from '../../../../utils/fuel-integration-format';
-export default class ConnectivityFuelProvidersIndexDetailsSyncController extends Controller {
+export default class FuelIntegrationSyncController extends Controller {
@controller('connectivity.fuel-providers.details') details;
-
- get syncTransactions() {
- return this.details.syncTransactions;
+ @tracked from;
+ @tracked to;
+ get dateError() {
+ return this.from && this.to && this.from > this.to ? 'Start date must be on or before end date.' : null;
}
-
- get lastSyncStateJson() {
- return JSON.stringify(this.model?.connection?.last_sync_state ?? {}, null, 2);
+ get syncDisabled() {
+ return this.details.syncDisabled || Boolean(this.dateError) || !this.from || !this.to;
}
-
- get syncRuns() {
- return Array.from(this.model?.syncRuns ?? []);
+ @action setDate(field, event) {
+ this[field] = event.target.value;
+ }
+ @action resetDates() {
+ const from = new Date();
+ from.setDate(from.getDate() - Number(this.model?.sync_settings?.window_days || 7));
+ this.from = fuelDate(from).slice(0, 10);
+ this.to = fuelDate(new Date()).slice(0, 10);
+ }
+ @action sync() {
+ if (!this.syncDisabled) return this.details.syncTransactions.perform({ from: this.from, to: this.to });
}
}
diff --git a/addon/controllers/connectivity/fuel-providers/details/transactions.js b/addon/controllers/connectivity/fuel-providers/details/transactions.js
index 48256913d..ecf56185a 100644
--- a/addon/controllers/connectivity/fuel-providers/details/transactions.js
+++ b/addon/controllers/connectivity/fuel-providers/details/transactions.js
@@ -1,24 +1,13 @@
-import Controller from '@ember/controller';
-import { inject as service } from '@ember/service';
+import FuelTransactionsController from '../../../management/fuel-transactions/index';
import { action } from '@ember/object';
-export default class ConnectivityFuelProvidersIndexDetailsTransactionsController extends Controller {
- @service hostRouter;
-
+export default class FuelIntegrationTransactionsController extends FuelTransactionsController {
+ queryParams = ['page', 'limit', 'sort', 'query', 'sync_status', 'vehicle', 'transaction_at'];
get columns() {
- return [
- { sticky: true, label: 'Transaction', valuePath: 'provider_transaction_id', cellComponent: 'click-to-copy', resizable: true },
- { label: 'Status', valuePath: 'sync_status', cellComponent: 'table/cell/status', resizable: true },
- { label: 'Vehicle', valuePath: 'vehicle_name', resizable: true },
- { label: 'Station', valuePath: 'station_name', resizable: true },
- { label: 'Liters', valuePath: 'volume', resizable: true },
- { label: 'Amount', valuePath: 'amount', cellComponent: 'table/cell/currency', resizable: true },
- { label: 'Fuel Report', valuePath: 'fuel_report_id', cellComponent: 'click-to-copy', resizable: true },
- { label: 'Date', valuePath: 'transaction_at', resizable: true },
- ];
+ return super.columns.filter((column) => column.valuePath !== 'provider');
}
@action refresh() {
- return this.hostRouter.refresh();
+ this.target.send('refreshFuelTransactions');
}
}
diff --git a/addon/controllers/connectivity/fuel-providers/index.js b/addon/controllers/connectivity/fuel-providers/index.js
index 1129a2917..f0ef5a0c8 100644
--- a/addon/controllers/connectivity/fuel-providers/index.js
+++ b/addon/controllers/connectivity/fuel-providers/index.js
@@ -10,6 +10,7 @@ export default class ConnectivityFuelProvidersIndexController extends Controller
@service tableContext;
@service fuelIntegrationActions;
@service intl;
+ @service modalsManager;
@tracked queryParams = ['page', 'limit', 'sort', 'query', 'provider', 'status', 'environment'];
@tracked page = 1;
@@ -59,8 +60,10 @@ export default class ConnectivityFuelProvidersIndexController extends Controller
{
sticky: true,
label: 'Integration',
- valuePath: 'name',
- cellComponent: 'click-to-copy',
+ valuePath: 'displayName',
+ sortParam: 'name',
+ cellComponent: 'table/cell/anchor',
+ action: this.openConnection,
resizable: true,
sortable: true,
filterable: true,
@@ -87,19 +90,20 @@ export default class ConnectivityFuelProvidersIndexController extends Controller
},
{
label: 'Last Sync',
- valuePath: 'last_synced_at',
+ valuePath: 'lastSyncedAt',
+ sortParam: 'last_synced_at',
resizable: true,
sortable: true,
},
{
- label: 'Imported',
- valuePath: 'last_sync_state.summary.imported',
+ label: 'New in last sync',
+ valuePath: 'lastImported',
resizable: true,
sortable: false,
},
{
- label: 'Unmatched',
- valuePath: 'last_sync_state.summary.unmatched',
+ label: 'Unmatched in last sync',
+ valuePath: 'lastUnmatched',
resizable: true,
sortable: false,
},
@@ -144,7 +148,7 @@ export default class ConnectivityFuelProvidersIndexController extends Controller
}
@action refresh() {
- this.target.send('refresh');
+ this.target.send('refreshFuelConnections');
}
@action openConnection(connection) {
@@ -155,21 +159,22 @@ export default class ConnectivityFuelProvidersIndexController extends Controller
return this.fuelIntegrationActions.transition.edit(connection);
}
- @action async testConnection(connection) {
- try {
- await this.fetch.post(`fuel-provider-connections/${connection.id}/test-connection`);
- this.notifications.success('Fuel integration connection tested.');
- this.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- }
+ @action testConnection(connection) {
+ return this.modalsManager.show('modals/fuel-connection-diagnostics', {
+ title: 'Test Fuel Connection',
+ acceptButtonText: 'Run Test',
+ acceptButtonIcon: 'plug',
+ declineButtonText: 'Close',
+ connection,
+ onTested: () => connection.reload(),
+ });
}
@action async syncConnection(connection) {
try {
await this.fetch.post(`fuel-provider-connections/${connection.id}/sync`, { async: true });
this.notifications.success('Fuel transaction sync queued.');
- this.refresh();
+ await connection.reload();
} catch (error) {
this.notifications.serverError(error);
}
diff --git a/addon/controllers/connectivity/sensors/index.js b/addon/controllers/connectivity/sensors/index.js
index 166551897..4ed9390a1 100644
--- a/addon/controllers/connectivity/sensors/index.js
+++ b/addon/controllers/connectivity/sensors/index.js
@@ -136,9 +136,8 @@ export default class ConnectivitySensorsIndexController extends Controller {
{
label: 'Telematic',
valuePath: 'telematic.provider',
- cellComponent: 'cell/telematic-provider',
+ cellComponent: 'cell/telematic-identity',
resourcePath: sensorTelematic,
- compact: true,
action: this.openTelematic,
permission: 'fleet-ops view telematic',
resizable: true,
@@ -154,7 +153,6 @@ export default class ConnectivitySensorsIndexController extends Controller {
valuePath: 'device.displayName',
cellComponent: 'cell/device-identity',
resourcePath: sensorDevice,
- showStatus: false,
action: this.openDevice,
permission: 'fleet-ops view device',
resizable: true,
diff --git a/addon/controllers/connectivity/telematics/details/events.js b/addon/controllers/connectivity/telematics/details/events.js
index 3a321579b..c23388244 100644
--- a/addon/controllers/connectivity/telematics/details/events.js
+++ b/addon/controllers/connectivity/telematics/details/events.js
@@ -1,5 +1,7 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
+import { buildIdentityStub } from '../../../../utils/identity-cell-resource';
+import relationValue from '../../../../utils/relation-value';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
@@ -13,6 +15,7 @@ const severityOptions = [
];
export default class ConnectivityTelematicsDetailsEventsController extends Controller {
+ @service store;
@service deviceEventActions;
@service hostRouter;
@service intl;
@@ -142,6 +145,11 @@ export default class ConnectivityTelematicsDetailsEventsController extends Contr
{
label: 'Device',
valuePath: 'device_name',
+ cellComponent: 'cell/device-identity',
+ permission: 'fleet-ops view device',
+ resourcePath: (event) =>
+ relationValue(event, 'device') ??
+ buildIdentityStub(event, { type: 'device', load: () => (event.device_uuid ? this.store.findRecord('device', event.device_uuid) : null) }),
resizable: true,
sortable: true,
filterable: true,
diff --git a/addon/controllers/connectivity/telematics/details/sensors.js b/addon/controllers/connectivity/telematics/details/sensors.js
index bbccef71f..b54852a1b 100644
--- a/addon/controllers/connectivity/telematics/details/sensors.js
+++ b/addon/controllers/connectivity/telematics/details/sensors.js
@@ -1,5 +1,6 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
+import relationValue from '../../../../utils/relation-value';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
@@ -177,8 +178,8 @@ export default class ConnectivityTelematicsDetailsSensorsController extends Cont
{
label: 'Device',
valuePath: 'device.displayName',
- cellComponent: 'table/cell/anchor',
- action: this.deviceActions.transition.view,
+ cellComponent: 'cell/device-identity',
+ resourcePath: (sensor) => relationValue(sensor, 'device'),
permission: 'fleet-ops view device',
resizable: true,
sortable: true,
diff --git a/addon/controllers/connectivity/telematics/index.js b/addon/controllers/connectivity/telematics/index.js
index 045b1dd74..37369823a 100644
--- a/addon/controllers/connectivity/telematics/index.js
+++ b/addon/controllers/connectivity/telematics/index.js
@@ -69,7 +69,7 @@ export default class ConnectivityTelematicsIndexController extends Controller {
sticky: true,
label: 'Provider',
valuePath: 'name',
- cellComponent: 'cell/telematic-provider',
+ cellComponent: 'cell/telematic-identity',
action: this.telematicActions.transition.view,
permission: 'fleet-ops view telematic',
width: 460,
@@ -89,6 +89,8 @@ export default class ConnectivityTelematicsIndexController extends Controller {
filterable: true,
filterComponent: 'filter/multi-option',
filterOptions: fleetOpsOptions('telematicStatuses'),
+ filterOptionLabel: 'label',
+ filterOptionValue: 'value',
},
{
label: this.intl.t('column.created-at'),
diff --git a/addon/controllers/maintenance/inspection-forms/index.js b/addon/controllers/maintenance/inspection-forms/index.js
new file mode 100644
index 000000000..53d812903
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-forms/index.js
@@ -0,0 +1,90 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+export default class MaintenanceInspectionFormsIndexController extends Controller {
+ @service inspectionFormActions;
+ @service intl;
+
+ @tracked queryParams = ['status', 'type', 'page', 'limit', 'sort', 'query', 'public_id', 'created_at', 'updated_at'];
+ @tracked page = 1;
+ @tracked limit;
+ @tracked sort = '-created_at';
+ @tracked public_id;
+ @tracked status;
+ @tracked type;
+
+ get actionButtons() {
+ return [
+ { icon: 'refresh', onClick: this.inspectionFormActions.refresh, helpText: this.intl.t('common.refresh') },
+ { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.inspectionFormActions.transition.create },
+ ];
+ }
+
+ get bulkActions() {
+ return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.inspectionFormActions.bulkDelete }];
+ }
+
+ get columns() {
+ return [
+ {
+ label: 'Name',
+ valuePath: 'name',
+ cellComponent: 'table/cell/anchor',
+ action: this.inspectionFormActions.transition.view,
+ permission: 'fleet-ops view inspection-form',
+ resizable: true,
+ sortable: true,
+ filterable: true,
+ filterParam: 'name',
+ filterComponent: 'filter/string',
+ },
+ {
+ label: 'Type',
+ valuePath: 'type',
+ cellComponent: 'table/cell/fleet-ops-option',
+ optionsKey: 'inspectionFormTypes',
+ resizable: true,
+ sortable: true,
+ filterable: true,
+ filterParam: 'type',
+ filterComponent: 'filter/string',
+ },
+ {
+ label: 'Status',
+ valuePath: 'status',
+ cellComponent: 'table/cell/status',
+ resizable: true,
+ sortable: true,
+ filterable: true,
+ filterParam: 'status',
+ filterComponent: 'filter/string',
+ },
+ { label: 'Items', valuePath: 'item_count', resizable: true, sortable: false },
+ { label: this.intl.t('column.created-at'), valuePath: 'createdAt', sortParam: 'created_at', resizable: true, sortable: true, filterable: true, filterComponent: 'filter/date' },
+ {
+ label: '',
+ cellComponent: 'table/cell/dropdown',
+ ddButtonText: false,
+ ddButtonIcon: 'ellipsis-h',
+ ddButtonIconPrefix: 'fas',
+ cellClassNames: 'overflow-visible',
+ wrapperClass: 'flex items-center justify-end mx-2',
+ actions: [
+ { label: 'View form', fn: this.inspectionFormActions.transition.view, permission: 'fleet-ops view inspection-form' },
+ { label: 'Edit form', fn: this.inspectionFormActions.transition.edit, permission: 'fleet-ops update inspection-form' },
+ { separator: true },
+ { label: 'Publish', fn: this.inspectionFormActions.publish, permission: 'fleet-ops publish inspection-form' },
+ { label: 'Archive', fn: this.inspectionFormActions.archive, permission: 'fleet-ops archive inspection-form' },
+ { label: 'Generate inspection link', fn: this.inspectionFormActions.generateLink, permission: 'fleet-ops view inspection-form' },
+ { separator: true },
+ { label: 'Delete form', fn: this.inspectionFormActions.delete, class: 'text-red-500', permission: 'fleet-ops delete inspection-form' },
+ ],
+ sortable: false,
+ filterable: false,
+ resizable: false,
+ searchable: false,
+ },
+ ];
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-forms/index/details.js b/addon/controllers/maintenance/inspection-forms/index/details.js
new file mode 100644
index 000000000..ae1948df1
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-forms/index/details.js
@@ -0,0 +1,29 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+export default class MaintenanceInspectionFormsIndexDetailsController extends Controller {
+ @service inspectionFormActions;
+ @service hostRouter;
+ @service intl;
+ @tracked overlay;
+
+ get tabs() {
+ return [
+ { id: 'index', route: 'maintenance.inspection-forms.index.details.index', label: this.intl.t('inspection.form.overview') },
+ { id: 'submissions', route: 'maintenance.inspection-forms.index.details.submissions', label: this.intl.t('inspection.form.submissions') },
+ ];
+ }
+
+ /**
+ * Publish disappears once the form is published — leaving it there invites
+ * an author to press a button that can only tell them it is already done.
+ * The public link needs a published form, so it appears at the same moment.
+ */
+ get actionButtons() {
+ return this.inspectionFormActions.headerActionButtons(this.model, {
+ onEdit: () => this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.edit', this.model),
+ onDeleted: () => this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index'),
+ });
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-forms/index/edit.js b/addon/controllers/maintenance/inspection-forms/index/edit.js
new file mode 100644
index 000000000..859e4a7a7
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-forms/index/edit.js
@@ -0,0 +1,39 @@
+import Controller from '@ember/controller';
+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 MaintenanceInspectionFormsIndexEditController extends Controller {
+ @service inspectionFormActions;
+ @service hostRouter;
+ @service notifications;
+ @service intl;
+
+ @tracked overlay;
+
+ /** The builder's draft, set only once the author has changed something. */
+ @tracked structure = null;
+
+ @task *save(inspectionForm) {
+ try {
+ yield inspectionForm.save();
+ yield this.inspectionFormActions.saveStructure(inspectionForm, this.structure);
+
+ this.overlay?.close();
+ yield this.hostRouter.refresh();
+ yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', inspectionForm);
+ this.notifications.success(this.intl.t('inspection.form.updated'));
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @action setStructure(groups) {
+ this.structure = groups;
+ }
+
+ @action cancel() {
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', this.model);
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-forms/index/new.js b/addon/controllers/maintenance/inspection-forms/index/new.js
new file mode 100644
index 000000000..f5c1f29b9
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-forms/index/new.js
@@ -0,0 +1,48 @@
+import Controller from '@ember/controller';
+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 MaintenanceInspectionFormsIndexNewController extends Controller {
+ @service inspectionFormActions;
+ @service hostRouter;
+ @service notifications;
+ @service intl;
+ @service events;
+
+ @tracked overlay;
+ @tracked inspectionForm = this.inspectionFormActions.createNewInstance();
+
+ /** The builder's draft, laid out before the form record exists. */
+ @tracked structure = null;
+
+ @task *save(inspectionForm) {
+ try {
+ yield inspectionForm.save();
+
+ // The structure is posted separately, under the key the server
+ // reads it from — Ember Data cannot carry it, because the
+ // `inspection-form` model declares no attribute for it.
+ yield this.inspectionFormActions.saveStructure(inspectionForm, this.structure);
+
+ this.events.trackResourceCreated(inspectionForm);
+ this.overlay?.close();
+ yield this.hostRouter.refresh();
+ yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', inspectionForm);
+ this.notifications.success(this.intl.t('inspection.form.created'));
+ this.resetForm();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @action setStructure(groups) {
+ this.structure = groups;
+ }
+
+ @action resetForm() {
+ this.structure = null;
+ this.inspectionForm = this.inspectionFormActions.createNewInstance();
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-submissions/index.js b/addon/controllers/maintenance/inspection-submissions/index.js
new file mode 100644
index 000000000..888898dc5
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-submissions/index.js
@@ -0,0 +1,142 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
+import { tracked } from '@glimmer/tracking';
+
+export default class MaintenanceInspectionSubmissionsIndexController extends Controller {
+ @service store;
+ @service inspectionSubmissionActions;
+ @service intl;
+
+ @tracked queryParams = ['status', 'result', 'type', 'page', 'limit', 'sort', 'query', 'public_id', 'vehicle', 'driver', 'created_at', 'updated_at'];
+ @tracked page = 1;
+ @tracked limit;
+ @tracked sort = '-created_at';
+ @tracked public_id;
+ @tracked status;
+ @tracked result;
+ @tracked type;
+ @tracked vehicle;
+ @tracked driver;
+
+ get actionButtons() {
+ return [
+ { icon: 'refresh', onClick: this.inspectionSubmissionActions.refresh, helpText: this.intl.t('common.refresh') },
+ { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.inspectionSubmissionActions.transition.create },
+ ];
+ }
+
+ get bulkActions() {
+ return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.inspectionSubmissionActions.bulkDelete }];
+ }
+
+ get columns() {
+ return [
+ {
+ label: 'Inspection',
+ valuePath: 'public_id',
+ cellComponent: 'table/cell/anchor',
+ action: this.inspectionSubmissionActions.transition.view,
+ permission: 'fleet-ops view inspection-submission',
+ resizable: true,
+ sortable: true,
+ filterable: true,
+ filterParam: 'public_id',
+ filterComponent: 'filter/string',
+ },
+ {
+ label: 'Form',
+ valuePath: 'form_name',
+ cellComponent: 'cell/inspection-form-identity',
+ permission: 'fleet-ops view inspection-form',
+ resourcePath: (submission) => relationValue(submission, 'form') ?? buildIdentityStub(submission, { type: 'inspection-form', nameKey: 'form_name' }),
+ resizable: true,
+ sortable: false,
+ },
+ {
+ label: 'Vehicle',
+ valuePath: 'vehicle_name',
+ cellComponent: 'cell/vehicle-identity',
+ permission: 'fleet-ops view vehicle',
+ resourcePath: (submission) =>
+ relationValue(submission, 'vehicle') ??
+ buildIdentityStub(submission, { type: 'vehicle', load: () => (submission.vehicle_uuid ? this.store.findRecord('vehicle', submission.vehicle_uuid) : null) }),
+ resizable: true,
+ sortable: false,
+ },
+ {
+ label: 'Driver',
+ valuePath: 'driver_name',
+ cellComponent: 'cell/driver-identity',
+ permission: 'fleet-ops view driver',
+ resourcePath: (submission) =>
+ relationValue(submission, 'driver') ??
+ buildIdentityStub(submission, { type: 'driver', load: () => (submission.driver_uuid ? this.store.findRecord('driver', submission.driver_uuid) : null) }),
+ resizable: true,
+ sortable: false,
+ },
+ {
+ label: 'Result',
+ valuePath: 'result',
+ cellComponent: 'table/cell/status',
+ resizable: true,
+ sortable: true,
+ filterable: true,
+ filterParam: 'result',
+ filterComponent: 'filter/string',
+ },
+ { label: 'Failed', valuePath: 'failed_items', resizable: true, sortable: true },
+ { label: 'Submitted', valuePath: 'submittedAt', sortParam: 'submitted_at', resizable: true, sortable: true, filterable: true, filterComponent: 'filter/date' },
+ {
+ label: '',
+ cellComponent: 'table/cell/dropdown',
+ ddButtonText: false,
+ ddButtonIcon: 'ellipsis-h',
+ ddButtonIconPrefix: 'fas',
+ cellClassNames: 'overflow-visible',
+ wrapperClass: 'flex items-center justify-end mx-2',
+ actions: [
+ { label: 'View inspection', fn: this.inspectionSubmissionActions.transition.view, permission: 'fleet-ops view inspection-submission' },
+ { label: 'Edit inspection', fn: this.inspectionSubmissionActions.transition.edit, permission: 'fleet-ops update inspection-submission' },
+ {
+ separator: true,
+ // Everything below this rule is conditional. On a row
+ // with none of it — a resolved inspection that already
+ // raised what it had to — the rule still drew, landing
+ // next to the one above Delete as a double line.
+ isVisible: (row) => row?.status === 'draft' || row?.status !== 'resolved' || Boolean(row?.has_failures && (!row?.issue_uuid || !row?.work_order_uuid)),
+ },
+ {
+ label: 'Submit',
+ fn: this.inspectionSubmissionActions.submit,
+ permission: 'fleet-ops submit inspection-submission',
+ // Submitting files a draft. On anything already filed it
+ // did nothing and still reported success, so it is only
+ // offered where it has something to do.
+ isVisible: (row) => row?.status === 'draft',
+ },
+ {
+ label: 'Create issue',
+ fn: this.inspectionSubmissionActions.createIssue,
+ permission: 'fleet-ops create-issue inspection-submission',
+ isVisible: (row) => row?.has_failures && !row?.issue_uuid,
+ },
+ {
+ label: 'Create work order',
+ fn: this.inspectionSubmissionActions.createWorkOrder,
+ permission: 'fleet-ops create-work-order inspection-submission',
+ isVisible: (row) => row?.has_failures && !row?.work_order_uuid,
+ },
+ { label: 'Resolve', fn: this.inspectionSubmissionActions.resolve, permission: 'fleet-ops resolve inspection-submission', isVisible: (row) => row?.status !== 'resolved' },
+ { separator: true },
+ { label: 'Delete inspection', fn: this.inspectionSubmissionActions.delete, class: 'text-red-500', permission: 'fleet-ops delete inspection-submission' },
+ ],
+ sortable: false,
+ filterable: false,
+ resizable: false,
+ searchable: false,
+ },
+ ];
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-submissions/index/details.js b/addon/controllers/maintenance/inspection-submissions/index/details.js
new file mode 100644
index 000000000..7b1480ede
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-submissions/index/details.js
@@ -0,0 +1,53 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsController extends Controller {
+ @service inspectionSubmissionActions;
+ @service hostRouter;
+ @service intl;
+ @tracked overlay;
+
+ get tabs() {
+ return [
+ { route: 'maintenance.inspection-submissions.index.details.index', label: this.intl.t('inspection.record.overview') },
+ { route: 'maintenance.inspection-submissions.index.details.photos', label: this.intl.t('inspection.record.photos') },
+ { route: 'maintenance.inspection-submissions.index.details.audit', label: this.intl.t('inspection.record.audit') },
+ ];
+ }
+
+ /**
+ * Editing is the one action worth a button of its own; the rest sit behind
+ * the ellipsis, which is what the vehicle panel does. Five buttons
+ * overflowed the overlay header and pushed the title out of sight.
+ */
+ get actionButtons() {
+ return [
+ { icon: 'edit', fn: this.edit, permission: 'fleet-ops update inspection-submission' },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ items: this.followUpItems,
+ },
+ ];
+ }
+
+ /**
+ * A submission raises one issue and one work order, and the server enforces
+ * that — `createIssueFromFailures()` hands back what already exists. The
+ * console kept offering both anyway, so the same click reported success
+ * over and over while creating nothing. What is already raised is linked
+ * from the Follow Up panel instead of offered again here.
+ */
+ get followUpItems() {
+ return this.inspectionSubmissionActions.followUpItems(this.model, {
+ onDeleted: () => this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index'),
+ });
+ }
+
+ @action edit() {
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.edit', this.model);
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-submissions/index/edit.js b/addon/controllers/maintenance/inspection-submissions/index/edit.js
new file mode 100644
index 000000000..40c1d1162
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-submissions/index/edit.js
@@ -0,0 +1,39 @@
+import Controller from '@ember/controller';
+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 MaintenanceInspectionSubmissionsIndexEditController extends Controller {
+ @service inspectionSubmissionActions;
+ @service hostRouter;
+ @service notifications;
+ @service intl;
+
+ @tracked overlay;
+
+ /** The answers, as the server accepts them. */
+ @tracked answers = null;
+
+ @task *save(inspectionSubmission) {
+ try {
+ yield inspectionSubmission.save();
+ yield this.inspectionSubmissionActions.saveAnswers(inspectionSubmission, this.answers);
+
+ this.overlay?.close();
+ yield this.hostRouter.refresh();
+ yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.details', inspectionSubmission);
+ this.notifications.success(this.intl.t('inspection.record.updated'));
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @action setAnswers(rows) {
+ this.answers = rows;
+ }
+
+ @action cancel() {
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.details', this.model);
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-submissions/index/new.js b/addon/controllers/maintenance/inspection-submissions/index/new.js
new file mode 100644
index 000000000..972179434
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-submissions/index/new.js
@@ -0,0 +1,48 @@
+import Controller from '@ember/controller';
+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 MaintenanceInspectionSubmissionsIndexNewController extends Controller {
+ @service inspectionSubmissionActions;
+ @service hostRouter;
+ @service notifications;
+ @service intl;
+ @service events;
+
+ @tracked overlay;
+ @tracked inspectionSubmission = this.inspectionSubmissionActions.createNewInstance();
+
+ /** The answers, as the server accepts them. */
+ @tracked answers = null;
+
+ @task *save(inspectionSubmission) {
+ try {
+ yield inspectionSubmission.save();
+
+ // The answers are posted separately, under the key the server
+ // reads them from — the `inspection-submission` model declares no
+ // `custom_field_values` relationship, so Ember Data drops them.
+ yield this.inspectionSubmissionActions.saveAnswers(inspectionSubmission, this.answers);
+
+ this.events.trackResourceCreated(inspectionSubmission);
+ this.overlay?.close();
+ yield this.hostRouter.refresh();
+ yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.details', inspectionSubmission);
+ this.notifications.success(this.intl.t('inspection.record.saved'));
+ this.resetForm();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @action setAnswers(rows) {
+ this.answers = rows;
+ }
+
+ @action resetForm() {
+ this.answers = null;
+ this.inspectionSubmission = this.inspectionSubmissionActions.createNewInstance();
+ }
+}
diff --git a/addon/controllers/maintenance/maintenances/index.js b/addon/controllers/maintenance/maintenances/index.js
index b6bb25b41..88d817048 100644
--- a/addon/controllers/maintenance/maintenances/index.js
+++ b/addon/controllers/maintenance/maintenances/index.js
@@ -1,5 +1,7 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
import { tracked } from '@glimmer/tracking';
export default class MaintenanceMaintenancesIndexController extends Controller {
@@ -76,14 +78,16 @@ export default class MaintenanceMaintenancesIndexController extends Controller {
{
label: this.intl.t('column.asset'),
valuePath: 'maintainable.name',
- cellComponent: 'table/cell/base',
+ cellComponent: 'cell/maintenance-subject-identity',
+ resourcePath: (maintenance) => relationValue(maintenance, 'maintainable') ?? buildIdentityStub(maintenance, { type: 'maintenance-subject', nameKey: 'maintainable_name' }),
resizable: true,
sortable: true,
},
{
label: this.intl.t('column.performed-by'),
valuePath: 'performed_by.name',
- cellComponent: 'table/cell/base',
+ cellComponent: 'cell/facilitator-identity',
+ resourcePath: (maintenance) => relationValue(maintenance, 'performed_by') ?? buildIdentityStub(maintenance, { type: 'facilitator', nameKey: 'performed_by_name' }),
resizable: true,
sortable: true,
},
diff --git a/addon/controllers/maintenance/schedules/index.js b/addon/controllers/maintenance/schedules/index.js
index de39f357e..094af86ef 100644
--- a/addon/controllers/maintenance/schedules/index.js
+++ b/addon/controllers/maintenance/schedules/index.js
@@ -3,6 +3,8 @@ import { isArray } from '@ember/array';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
const CACHE_KEY = 'fleetops:maintenance-schedules:layout';
@@ -186,9 +188,22 @@ export default class MaintenanceSchedulesIndexController extends Controller {
{
label: this.intl.t('column.subject'),
valuePath: 'subject.name',
+ cellComponent: 'cell/maintenance-subject-identity',
+ resourcePath: (schedule) =>
+ relationValue(schedule, 'subject') ?? buildIdentityStub(schedule, { type: schedule.subject_type ?? 'maintenance-subject', nameKey: 'subject_name' }),
resizable: true,
sortable: false,
},
+ {
+ label: this.intl.t('column.assignee'),
+ valuePath: 'default_assignee_name',
+ cellComponent: 'cell/facilitator-identity',
+ resourcePath: (schedule) =>
+ relationValue(schedule, 'default_assignee') ?? buildIdentityStub(schedule, { type: schedule.default_assignee_type ?? 'facilitator', nameKey: 'default_assignee_name' }),
+ resizable: true,
+ sortable: false,
+ hidden: true,
+ },
{
label: this.intl.t('column.type'),
valuePath: 'type',
diff --git a/addon/controllers/maintenance/schedules/index/details.js b/addon/controllers/maintenance/schedules/index/details.js
index 9ed8b3c30..bcd1e27f4 100644
--- a/addon/controllers/maintenance/schedules/index/details.js
+++ b/addon/controllers/maintenance/schedules/index/details.js
@@ -6,10 +6,8 @@ import { isArray } from '@ember/array';
export default class MaintenanceSchedulesIndexDetailsController extends Controller {
@service maintenanceScheduleActions;
- @service fetch;
@service hostRouter;
@service intl;
- @service abilities;
@service('universe/menu-service') menuService;
@tracked overlay;
@@ -23,93 +21,13 @@ export default class MaintenanceSchedulesIndexDetailsController extends Controll
}
get actionButtons() {
- return [
- { icon: 'edit', fn: this.edit, permission: 'fleet-ops update maintenance-schedule' },
- { icon: 'play', helpText: 'Trigger Work Order Now', fn: this.triggerNow, permission: 'fleet-ops update maintenance-schedule' },
- {
- // Calendar export dropdown — matches the dropdown-button pattern
- icon: 'ellipsis-h',
- iconPrefix: 'fas',
- renderInPlace: true,
- items: [
- {
- text: 'Download .ics',
- icon: 'download',
- iconPrefix: 'far',
- fn: () => this.downloadIcal(this.model),
- },
- {
- text: 'Add to Google Calendar',
- icon: 'calendar-plus',
- iconPrefix: 'fab',
- fn: () => this.addToGoogleCalendar(this.model),
- },
- ],
- },
- { icon: 'trash', fn: this.delete, permission: 'fleet-ops delete maintenance-schedule', type: 'danger' },
- ];
+ return this.maintenanceScheduleActions.panelActionButtons(this.model, {
+ onEdit: this.edit,
+ onDeleted: () => this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index'),
+ });
}
@action edit() {
return this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index.edit', this.model);
}
-
- @action triggerNow() {
- return this.maintenanceScheduleActions.triggerNow(this.model);
- }
-
- @action delete() {
- return this.maintenanceScheduleActions.delete(this.model, {
- onConfirm: () => {
- this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index');
- },
- });
- }
-
- /**
- * Trigger a browser download of the .ics file for this schedule.
- * Uses fetch.download() which automatically attaches auth headers.
- */
- @action downloadIcal(schedule) {
- const id = schedule.public_id ?? schedule.id;
- this.fetch
- .download(
- `maintenance-schedules/${id}/ical`,
- {},
- {
- fileName: `maintenance-schedule-${id}.ics`,
- mimeType: 'text/calendar',
- }
- )
- .catch((error) => {
- // eslint-disable-next-line no-console
- console.error('Failed to download iCal:', error);
- });
- }
-
- /**
- * Open the Google Calendar "add event" URL for this schedule.
- * Includes RRULE recurrence when the schedule has a time-based interval.
- */
- @action addToGoogleCalendar(schedule) {
- const title = encodeURIComponent(schedule.name ?? 'Maintenance Schedule');
- const dueDate = schedule.next_due_date ? new Date(schedule.next_due_date) : new Date();
- const pad = (n) => String(n).padStart(2, '0');
- const dateStr = `${dueDate.getFullYear()}${pad(dueDate.getMonth() + 1)}${pad(dueDate.getDate())}`;
- const details = encodeURIComponent(schedule.description ?? schedule.instructions ?? '');
-
- // Build RRULE if the schedule has a time-based interval
- let recur = '';
- const intervalValue = parseInt(schedule.interval_value, 10);
- const intervalUnit = schedule.interval_unit;
- if (intervalValue > 0 && intervalUnit) {
- const unitMap = { days: 'DAILY', weeks: 'WEEKLY', months: 'MONTHLY', years: 'YEARLY' };
- const freq = unitMap[intervalUnit] ?? 'DAILY';
- const interval = intervalUnit === 'weeks' ? intervalValue : intervalValue;
- recur = `&recur=RRULE:FREQ=${freq};INTERVAL=${interval}`;
- }
-
- const url = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${title}&dates=${dateStr}/${dateStr}&details=${details}${recur}`;
- window.open(url, '_blank', 'noopener,noreferrer');
- }
}
diff --git a/addon/controllers/maintenance/work-orders/index.js b/addon/controllers/maintenance/work-orders/index.js
index 70069061d..fbc789696 100644
--- a/addon/controllers/maintenance/work-orders/index.js
+++ b/addon/controllers/maintenance/work-orders/index.js
@@ -1,5 +1,7 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
import { tracked } from '@glimmer/tracking';
import fleetOpsOptions from '../../../utils/fleet-ops-options';
@@ -81,7 +83,23 @@ export default class MaintenanceWorkOrdersIndexController extends Controller {
filterParam: 'priority',
filterComponent: 'filter/string',
},
- { label: this.intl.t('column.assignee'), valuePath: 'assignee_name', resizable: true, sortable: false },
+ {
+ label: this.intl.t('column.target'),
+ valuePath: 'target_name',
+ cellComponent: 'cell/maintenance-subject-identity',
+ resourcePath: (workOrder) => relationValue(workOrder, 'target') ?? buildIdentityStub(workOrder, { type: 'maintenance-subject', nameKey: 'target_name' }),
+ resizable: true,
+ sortable: false,
+ hidden: true,
+ },
+ {
+ label: this.intl.t('column.assignee'),
+ valuePath: 'assignee_name',
+ cellComponent: 'cell/facilitator-identity',
+ resourcePath: (workOrder) => relationValue(workOrder, 'assignee') ?? buildIdentityStub(workOrder, { type: 'facilitator', nameKey: 'assignee_name' }),
+ resizable: true,
+ sortable: false,
+ },
{ label: this.intl.t('column.due-at'), valuePath: 'dueAt', sortParam: 'due_at', resizable: true, sortable: true, filterable: true, filterComponent: 'filter/date' },
{ label: this.intl.t('column.created-at'), valuePath: 'createdAt', sortParam: 'created_at', resizable: true, sortable: true, filterable: true, filterComponent: 'filter/date' },
{
diff --git a/addon/controllers/management/contacts/customers.js b/addon/controllers/management/contacts/customers.js
index c93a0a24d..91bd0c654 100644
--- a/addon/controllers/management/contacts/customers.js
+++ b/addon/controllers/management/contacts/customers.js
@@ -1,5 +1,7 @@
import ManagementContactsIndexController from './index';
import { inject as service } from '@ember/service';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
export default class ManagementContactsCustomersController extends ManagementContactsIndexController {
@service('customerActions') contactActions;
@@ -20,7 +22,7 @@ export default class ManagementContactsCustomersController extends ManagementCon
label: this.intl.t('column.name'),
valuePath: 'name',
- cellComponent: 'table/cell/media-name',
+ cellComponent: 'cell/customer-identity',
action: this.customerActions.transition.view,
permission: 'fleet-ops view contact',
resizable: true,
@@ -82,9 +84,9 @@ export default class ManagementContactsCustomersController extends ManagementCon
{
label: this.intl.t('column.address'),
valuePath: 'address',
- cellComponent: 'table/cell/anchor',
- action: this.customerActions.viewPlace,
-
+ cellComponent: 'cell/place-identity',
+ permission: 'fleet-ops view place',
+ resourcePath: (customer) => relationValue(customer, 'place') ?? buildIdentityStub(customer, { type: 'place', nameKey: 'address', load: () => customer.get('place') }),
resizable: true,
sortable: true,
filterable: true,
diff --git a/addon/controllers/management/contacts/index.js b/addon/controllers/management/contacts/index.js
index 816d0c48a..0756dd08e 100644
--- a/addon/controllers/management/contacts/index.js
+++ b/addon/controllers/management/contacts/index.js
@@ -2,6 +2,8 @@ import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
export default class ManagementContactsIndexController extends Controller {
@service contactActions;
@@ -97,7 +99,7 @@ export default class ManagementContactsIndexController extends Controller {
sticky: true,
label: this.intl.t('column.name'),
valuePath: 'name',
- cellComponent: 'table/cell/media-name',
+ cellComponent: 'cell/contact-identity',
action: this.contactActions.transition.view,
permission: 'fleet-ops view contact',
resizable: true,
@@ -154,8 +156,9 @@ export default class ManagementContactsIndexController extends Controller {
{
label: this.intl.t('column.address'),
valuePath: 'address',
- cellComponent: 'table/cell/anchor',
- action: this.contactActions.viewPlace,
+ cellComponent: 'cell/place-identity',
+ permission: 'fleet-ops view place',
+ resourcePath: (contact) => relationValue(contact, 'place') ?? buildIdentityStub(contact, { type: 'place', nameKey: 'address', load: () => contact.get('place') }),
resizable: true,
sortable: true,
filterable: true,
diff --git a/addon/controllers/management/drivers/index.js b/addon/controllers/management/drivers/index.js
index 8dd3dd97b..70ff9d2db 100644
--- a/addon/controllers/management/drivers/index.js
+++ b/addon/controllers/management/drivers/index.js
@@ -2,6 +2,8 @@ import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action, get } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
export default class ManagementDriversIndexController extends Controller {
@service driverActions;
@@ -139,7 +141,6 @@ export default class ManagementDriversIndexController extends Controller {
label: this.intl.t('column.name'),
valuePath: 'name',
cellComponent: 'cell/driver-identity',
- compact: true,
permission: 'fleet-ops view driver',
action: this.driverActions.transition.view,
resizable: true,
@@ -179,7 +180,6 @@ export default class ManagementDriversIndexController extends Controller {
{
label: this.intl.t('column.vehicle'),
cellComponent: 'cell/vehicle-identity',
- compact: true,
permission: 'fleet-ops view vehicle',
action: async (vehicle) => {
try {
@@ -191,27 +191,8 @@ export default class ManagementDriversIndexController extends Controller {
},
valuePath: 'vehicle.display_name',
emptyText: '-',
- showStatusBadge: true,
- resourcePath: (driver) => {
- const vehicle = get(driver, 'vehicle_assigned') ?? get(driver, 'vehicle');
-
- if (vehicle) {
- return vehicle;
- }
-
- const vehicleName = get(driver, 'vehicle_name');
-
- if (vehicleName) {
- return {
- display_name: vehicleName,
- name: vehicleName,
- status: 'assigned',
- loadResource: () => driver.loadVehicle?.(),
- };
- }
-
- return null;
- },
+ resourcePath: (driver) =>
+ get(driver, 'vehicle_assigned') ?? relationValue(driver, 'vehicle') ?? buildIdentityStub(driver, { type: 'vehicle', load: () => driver.loadVehicle?.() }),
modelNamePath: 'display_name',
resizable: true,
filterable: true,
@@ -231,20 +212,14 @@ export default class ManagementDriversIndexController extends Controller {
},
{
label: this.intl.t('column.vendor'),
- cellComponent: 'table/cell/anchor',
+ cellComponent: 'cell/vendor-identity',
permission: 'fleet-ops view vendor',
- onClick: async (driver) => {
- try {
- const vendor = await driver.loadVendor();
- if (vendor) this.vendorActions.panel.view(vendor);
- } catch (err) {
- this.notifications.serverError(err);
- }
- },
+ action: this.vendorActions.panel.view,
// Driver list responses already include the vendor_name accessor,
- // while the vendor relationship itself is not eager loaded. Use
- // the scalar so assigned vendors do not render as blank cells.
+ // while the vendor relationship itself is not eager loaded, so a
+ // stub carries the name until the vendor is loaded on demand.
valuePath: 'vendor_name',
+ resourcePath: (driver) => relationValue(driver, 'vendor') ?? buildIdentityStub(driver, { type: 'vendor', load: () => driver.loadVendor?.() }),
modelNamePath: 'name',
resizable: true,
filterable: true,
diff --git a/addon/controllers/management/drivers/index/details.js b/addon/controllers/management/drivers/index/details.js
index 2f6e13ee8..2f9689fac 100644
--- a/addon/controllers/management/drivers/index/details.js
+++ b/addon/controllers/management/drivers/index/details.js
@@ -1,12 +1,10 @@
import Controller from '@ember/controller';
-import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { isArray } from '@ember/array';
export default class ManagementDriverIndexDetailsController extends Controller {
@service('universe/menu-service') menuService;
@service driverActions;
- @service issueActions;
@service hostRouter;
@service intl;
@@ -33,10 +31,6 @@ export default class ManagementDriverIndexDetailsController extends Controller {
];
}
- hasAssignedVehicle(driver) {
- return Boolean(driver?.vehicle_uuid || driver?.vehicle?.id || driver?.vehicle?.uuid || driver?.vehicle_name);
- }
-
get actionButtons() {
return [
{
@@ -48,67 +42,8 @@ export default class ManagementDriverIndexDetailsController extends Controller {
icon: 'ellipsis-h',
iconPrefix: 'fas',
renderInPlace: true,
- items: [
- {
- text: this.intl.t('driver.actions.assign-order'),
- icon: 'clipboard-list',
- fn: () => this.driverActions.assignOrder(this.model),
- permission: 'fleet-ops assign-order-for driver',
- },
- ...(Number(this.model.assigned_orders_count) > 0
- ? [
- {
- text: this.intl.t('driver.actions.unassign-orders'),
- icon: 'user-minus',
- fn: () => this.driverActions.unassignOrders(this.model),
- permission: 'fleet-ops assign-order-for driver',
- },
- ]
- : []),
- {
- separator: true,
- },
- {
- text: this.intl.t('driver.actions.assign-vehicle'),
- icon: 'car',
- fn: () => this.driverActions.assignVehicle(this.model),
- permission: 'fleet-ops assign-vehicle-for driver',
- },
- ...(this.hasAssignedVehicle(this.model)
- ? [
- {
- text: this.intl.t('driver.actions.unassign-vehicle'),
- icon: 'link-slash',
- fn: () => this.driverActions.unassignVehicle(this.model),
- permission: 'fleet-ops assign-vehicle-for driver',
- },
- ]
- : []),
- {
- separator: true,
- },
- {
- text: this.intl.t('driver.actions.locate-driver'),
- icon: 'location-dot',
- fn: () => this.driverActions.locate(this.model),
- permission: 'fleet-ops view driver',
- },
- {
- text: this.intl.t('driver.actions.create-issue'),
- icon: 'triangle-exclamation',
- fn: () => this.createIssue(this.model),
- permission: 'fleet-ops create issue',
- },
- ],
+ items: this.driverActions.detailsMenuItems(this.model),
},
];
}
-
- @action createIssue(driver) {
- this.issueActions.modal.create({
- driver,
- driver_uuid: driver.id,
- title: this.intl.t('driver.prompts.issue-title', { driverName: driver.name }),
- });
- }
}
diff --git a/addon/controllers/management/fleets/index.js b/addon/controllers/management/fleets/index.js
index 9d75cc651..910dbaea3 100644
--- a/addon/controllers/management/fleets/index.js
+++ b/addon/controllers/management/fleets/index.js
@@ -1,5 +1,8 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
+import { get } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
import { tracked } from '@glimmer/tracking';
import fleetOpsOptions from '../../../utils/fleet-ops-options';
@@ -87,12 +90,10 @@ export default class ManagementFleetsIndexController extends Controller {
},
{
label: this.intl.t('column.service-area'),
- cellComponent: 'table/cell/anchor',
- action: async (fleet) => {
- const serviceArea = await fleet.get('service_area');
- this.serviceAreaActions.modal.view(serviceArea);
- },
+ cellComponent: 'cell/service-area-identity',
permission: 'fleet-ops view service-area',
+ resourcePath: (fleet) =>
+ relationValue(fleet, 'service_area') ?? buildIdentityStub(fleet, { type: 'service-area', name: get(fleet, 'service_area.name'), load: () => fleet.get('service_area') }),
valuePath: 'service_area.name',
resizable: true,
filterable: true,
@@ -103,12 +104,9 @@ export default class ManagementFleetsIndexController extends Controller {
},
{
label: this.intl.t('column.parent-fleet'),
- cellComponent: 'table/cell/anchor',
+ cellComponent: 'cell/fleet-identity',
permission: 'fleet-ops view fleet',
- action: async (fleet) => {
- const parentFleet = await fleet.get('parent_fleet');
- this.fleetActions.modal.view(parentFleet);
- },
+ resourcePath: (fleet) => relationValue(fleet, 'parent_fleet'),
valuePath: 'parent_fleet.name',
resizable: true,
filterable: true,
@@ -119,12 +117,9 @@ export default class ManagementFleetsIndexController extends Controller {
},
{
label: this.intl.t('column.vendor'),
- cellComponent: 'table/cell/anchor',
+ cellComponent: 'cell/vendor-identity',
permission: 'fleet-ops view vendor',
- action: async (fleet) => {
- const vendor = await fleet.get('vendor');
- this.vendorActions.modal.view(vendor);
- },
+ resourcePath: (fleet) => relationValue(fleet, 'vendor') ?? buildIdentityStub(fleet, { type: 'vendor', name: get(fleet, 'vendor.name'), load: () => fleet.get('vendor') }),
valuePath: 'vendor.name',
resizable: true,
hidden: true,
@@ -136,12 +131,9 @@ export default class ManagementFleetsIndexController extends Controller {
},
{
label: this.intl.t('column.zone'),
- cellComponent: 'table/cell/anchor',
+ cellComponent: 'cell/zone-identity',
permission: 'fleet-ops view zone',
- action: async (fleet) => {
- const zone = await fleet.get('zone');
- this.zoneActions.modal.view(zone);
- },
+ resourcePath: (fleet) => relationValue(fleet, 'zone') ?? buildIdentityStub(fleet, { type: 'zone', name: get(fleet, 'zone.name'), load: () => fleet.get('zone') }),
valuePath: 'zone.name',
resizable: true,
filterable: true,
diff --git a/addon/controllers/management/fleets/index/details.js b/addon/controllers/management/fleets/index/details.js
index 3ec568dde..56807d64c 100644
--- a/addon/controllers/management/fleets/index/details.js
+++ b/addon/controllers/management/fleets/index/details.js
@@ -9,7 +9,7 @@ export default class ManagementFleetsIndexDetailsController extends Controller {
@service intl;
get tabs() {
- const registeredTabs = this.menuService.getMenuItems('fleet-ops:component:place:details');
+ const registeredTabs = this.menuService.getMenuItems('fleet-ops:component:fleet:details');
return [
{
route: 'management.fleets.index.details.index',
@@ -38,20 +38,7 @@ export default class ManagementFleetsIndexDetailsController extends Controller {
icon: 'ellipsis-h',
iconPrefix: 'fas',
renderInPlace: true,
- items: [
- {
- text: this.intl.t('fleet.actions.assign-driver'),
- icon: 'user-plus',
- fn: () => this.fleetActions.assignDriver(this.model),
- permission: 'fleet-ops assign-driver-for fleet',
- },
- {
- text: this.intl.t('fleet.actions.assign-vehicle'),
- icon: 'car',
- fn: () => this.fleetActions.assignVehicle(this.model),
- permission: 'fleet-ops assign-vehicle-for fleet',
- },
- ],
+ items: this.fleetActions.detailsMenuItems(this.model),
},
];
}
diff --git a/addon/controllers/management/fuel-reports/index.js b/addon/controllers/management/fuel-reports/index.js
index e2a0d56e8..1374cfcaf 100644
--- a/addon/controllers/management/fuel-reports/index.js
+++ b/addon/controllers/management/fuel-reports/index.js
@@ -1,5 +1,6 @@
import Controller from '@ember/controller';
-import { get } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
@@ -103,6 +104,9 @@ export default class ManagementFuelReportsIndexController extends Controller {
{
label: this.intl.t('column.reporter'),
valuePath: 'reporter_name',
+ cellComponent: 'table/cell/user-identity',
+ resourcePath: (fuelReport) =>
+ relationValue(fuelReport, 'reporter') ?? buildIdentityStub(fuelReport, { type: 'user', nameKey: 'reporter_name', load: () => fuelReport.get('reporter') }),
resizable: true,
sortable: true,
filterable: true,
@@ -116,35 +120,9 @@ export default class ManagementFuelReportsIndexController extends Controller {
valuePath: 'driver_name',
cellComponent: 'cell/driver-identity',
permission: 'fleet-ops view driver',
- action: async (driver) => {
- const resolvedDriver = driver?.loadResource ? await driver.loadResource() : driver;
-
- if (resolvedDriver) {
- this.fuelReportActions.driverActions.panel.view(resolvedDriver);
- }
- },
+ action: this.fuelReportActions.driverActions.panel.view,
emptyText: '-',
- showStatusBadge: true,
- resourcePath: (fuelReport) => {
- const driver = get(fuelReport, 'driver');
-
- if (driver) {
- return driver;
- }
-
- const driverName = get(fuelReport, 'driver_name');
-
- if (driverName) {
- return {
- id: get(fuelReport, 'driver_uuid'),
- name: driverName,
- display_name: driverName,
- loadResource: () => fuelReport.loadDriver?.(),
- };
- }
-
- return null;
- },
+ resourcePath: (fuelReport) => relationValue(fuelReport, 'driver') ?? buildIdentityStub(fuelReport, { type: 'driver', load: () => fuelReport.loadDriver?.() }),
resizable: true,
sortable: true,
filterable: true,
@@ -158,37 +136,9 @@ export default class ManagementFuelReportsIndexController extends Controller {
valuePath: 'vehicle_name',
cellComponent: 'cell/vehicle-identity',
permission: 'fleet-ops view vehicle',
- action: async (vehicle) => {
- const resolvedVehicle = vehicle?.loadResource ? await vehicle.loadResource() : vehicle;
-
- if (resolvedVehicle) {
- this.fuelReportActions.vehicleActions.panel.view(resolvedVehicle);
- }
- },
+ action: this.fuelReportActions.vehicleActions.panel.view,
emptyText: '-',
- showStatusBadge: true,
- resourcePath: (fuelReport) => {
- const vehicle = get(fuelReport, 'vehicle');
-
- if (vehicle) {
- return vehicle;
- }
-
- const vehicleName = get(fuelReport, 'vehicle_name');
-
- if (vehicleName) {
- return {
- id: get(fuelReport, 'vehicle_uuid'),
- displayName: vehicleName,
- display_name: vehicleName,
- name: vehicleName,
- vehicle_number: get(fuelReport, 'vehicle_uuid'),
- loadResource: () => fuelReport.loadVehicle?.(),
- };
- }
-
- return null;
- },
+ resourcePath: (fuelReport) => relationValue(fuelReport, 'vehicle') ?? buildIdentityStub(fuelReport, { type: 'vehicle', load: () => fuelReport.loadVehicle?.() }),
resizable: true,
sortable: true,
filterable: true,
diff --git a/addon/controllers/management/fuel-transactions/index.js b/addon/controllers/management/fuel-transactions/index.js
index 78931aa01..e6873351a 100644
--- a/addon/controllers/management/fuel-transactions/index.js
+++ b/addon/controllers/management/fuel-transactions/index.js
@@ -2,11 +2,13 @@ import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
-import { task } from 'ember-concurrency';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
+import { openTransactionAction } from '../../../utils/fuel-transaction';
export default class ManagementFuelTransactionsIndexController extends Controller {
@service tableContext;
- @service fetch;
+ @service modalsManager;
@service notifications;
@service hostRouter;
@@ -22,6 +24,14 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
@tracked transaction_at;
@tracked table;
+ get viewingDetails() {
+ return this.hostRouter.currentRouteName?.endsWith('management.fuel-transactions.index.details');
+ }
+
+ get hasFilters() {
+ return Boolean(this.query || this.provider || this.sync_status || this.vehicle || this.transaction_at);
+ }
+
get hasRecords() {
return Array.from(this.model ?? []).length > 0;
}
@@ -66,7 +76,7 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
}
@action refresh() {
- this.target.send('refresh');
+ this.target.send('refreshTransactions');
}
get bulkActions() {
@@ -75,7 +85,7 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
return [
{
label: `Reprocess ${selected.length} selected`,
- fn: () => selected.forEach((transaction) => this.reprocessTransaction.perform(transaction)),
+ fn: () => this.confirmAction('reprocess', selected),
},
];
}
@@ -86,7 +96,8 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
sticky: true,
label: 'Transaction',
valuePath: 'provider_transaction_id',
- cellComponent: 'click-to-copy',
+ cellComponent: 'cell/fuel-provider-transaction-identity',
+ action: this.openDetails,
resizable: true,
sortable: true,
filterable: true,
@@ -104,7 +115,7 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
{
label: 'Status',
valuePath: 'sync_status',
- cellComponent: 'table/cell/status',
+ cellComponent: 'fuel-transaction-status',
resizable: true,
sortable: true,
filterable: true,
@@ -114,6 +125,9 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
{
label: 'Vehicle',
valuePath: 'vehicle_name',
+ cellComponent: 'cell/vehicle-identity',
+ permission: 'fleet-ops view vehicle',
+ resourcePath: (transaction) => relationValue(transaction, 'vehicle') ?? buildIdentityStub(transaction, { type: 'vehicle', load: () => transaction.get('vehicle') }),
resizable: true,
sortable: false,
filterable: true,
@@ -154,8 +168,10 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
{
label: 'Fuel Report',
valuePath: 'fuel_report_id',
- cellComponent: 'table/cell/anchor',
- action: this.openFuelReport,
+ cellComponent: 'cell/fuel-report-identity',
+ resourcePath: (transaction) =>
+ relationValue(transaction, 'fuel_report') ??
+ buildIdentityStub(transaction, { type: 'fuel-report', nameKey: 'fuel_report_id', load: () => transaction.get('fuel_report') }),
resizable: true,
},
{
@@ -181,13 +197,13 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
width: 60,
actions: [
{ label: 'Review Details', fn: this.openDetails },
- { label: 'Open Fuel Report', fn: this.openFuelReport },
+ { label: 'Open Fuel Report', fn: this.openFuelReport, isVisible: (transaction) => Boolean(transaction.fuel_report_id) },
{ separator: true },
{ label: 'Match to Vehicle', fn: this.matchVehicle },
{ label: 'Match to Order', fn: this.matchOrder },
- { label: 'Reprocess / Rematch', fn: (transaction) => this.reprocessTransaction.perform(transaction) },
- { label: 'Ignore Transaction', fn: (transaction) => this.markReviewed.perform(transaction, 'ignored') },
- { label: 'Mark Reviewed', fn: (transaction) => this.markReviewed.perform(transaction, 'reviewed') },
+ { label: 'Reprocess / Rematch', fn: (transaction) => this.confirmAction('reprocess', transaction) },
+ { label: 'Ignore Transaction', fn: (transaction) => this.confirmAction('ignored', transaction) },
+ { label: 'Mark Reviewed', fn: (transaction) => this.confirmAction('reviewed', transaction) },
],
sortable: false,
filterable: false,
@@ -211,30 +227,14 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
}
@action matchVehicle(transaction) {
- return this.hostRouter.transitionTo('console.fleet-ops.management.fuel-transactions.index.details', transaction);
+ return this.confirmAction('vehicle', transaction);
}
@action matchOrder(transaction) {
- return this.hostRouter.transitionTo('console.fleet-ops.management.fuel-transactions.index.details', transaction);
+ return this.confirmAction('order', transaction);
}
- @task *reprocessTransaction(transaction) {
- try {
- yield this.fetch.post(`fuel-provider-transactions/${transaction.id}/reprocess`);
- this.notifications.success('Fuel transaction reprocessed.');
- this.target.send('refresh');
- } catch (error) {
- this.notifications.serverError(error);
- }
- }
-
- @task *markReviewed(transaction, status) {
- try {
- yield this.fetch.post(`fuel-provider-transactions/${transaction.id}/review`, { status });
- this.notifications.success(status === 'ignored' ? 'Fuel transaction ignored.' : 'Fuel transaction marked reviewed.');
- this.target.send('refresh');
- } catch (error) {
- this.notifications.serverError(error);
- }
+ @action confirmAction(mode, transactions) {
+ return openTransactionAction(this.modalsManager, mode, transactions, this.refresh);
}
}
diff --git a/addon/controllers/management/fuel-transactions/index/details.js b/addon/controllers/management/fuel-transactions/index/details.js
index 4f93384f8..46a44b7c2 100644
--- a/addon/controllers/management/fuel-transactions/index/details.js
+++ b/addon/controllers/management/fuel-transactions/index/details.js
@@ -1,70 +1,73 @@
import Controller from '@ember/controller';
-import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
-import { task } from 'ember-concurrency';
+import { loadTransactionOrder, openTransactionAction, purchaseGroups } from '../../../../utils/fuel-transaction';
+import { buildIdentityStub } from '../../../../utils/identity-cell-resource';
+import relationValue from '../../../../utils/relation-value';
export default class ManagementFuelTransactionsIndexDetailsController extends Controller {
- @service fetch;
- @service notifications;
+ @service modalsManager;
@service hostRouter;
+ @service store;
- @tracked vehicleId;
- @tracked orderId;
-
- get normalizedPayloadJson() {
- return JSON.stringify(this.model?.normalized_payload ?? {}, null, 2);
+ get purchaseGroups() {
+ return purchaseGroups(this.model);
}
- get rawPayloadJson() {
- return JSON.stringify(this.model?.raw_payload ?? {}, null, 2);
- }
+ /**
+ * The linked records as the pills want them: the loaded relation when the
+ * store has it, otherwise a stub that carries the name the transaction
+ * knows and loads the record on hover or click.
+ */
+ get vehicleResource() {
+ const transaction = this.model;
- @action setVehicleId(event) {
- this.vehicleId = event.target.value;
- }
+ if (!transaction?.vehicle_uuid && !transaction?.vehicle_name) {
+ return null;
+ }
- @action setOrderId(event) {
- this.orderId = event.target.value;
+ return (
+ relationValue(transaction, 'vehicle') ??
+ buildIdentityStub(transaction, { type: 'vehicle', name: transaction.vehicle_name ?? 'Linked vehicle', load: () => transaction.get('vehicle') })
+ );
}
- @task *matchVehicle() {
- try {
- yield this.fetch.post(`fuel-provider-transactions/${this.model.id}/match-vehicle`, { vehicle: this.vehicleId });
- this.notifications.success('Fuel transaction matched to vehicle.');
- yield this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- }
- }
+ get orderResource() {
+ const transaction = this.model;
- @task *matchOrder() {
- try {
- yield this.fetch.post(`fuel-provider-transactions/${this.model.id}/match-order`, { order: this.orderId });
- this.notifications.success('Fuel transaction matched to order.');
- yield this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
+ if (!transaction?.order_uuid) {
+ return null;
}
+
+ const order = relationValue(transaction, 'order');
+
+ return (
+ (order?.id === transaction.order_uuid ? order : null) ??
+ this.store.peekRecord('order', transaction.order_uuid) ??
+ buildIdentityStub(transaction, {
+ type: 'order',
+ name: transaction.trip_number ?? 'Linked order',
+ load: () => loadTransactionOrder(this.store, transaction),
+ })
+ );
}
- @task *reprocess() {
- try {
- yield this.fetch.post(`fuel-provider-transactions/${this.model.id}/reprocess`);
- this.notifications.success('Fuel transaction reprocessed.');
- yield this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
+ get fuelReportResource() {
+ const transaction = this.model;
+
+ if (!transaction?.fuel_report_id) {
+ return null;
}
+
+ return (
+ relationValue(transaction, 'fuel_report') ?? buildIdentityStub(transaction, { type: 'fuel-report', name: transaction.fuel_report_id, load: () => transaction.get('fuel_report') })
+ );
}
- @task *review(status) {
- try {
- yield this.fetch.post(`fuel-provider-transactions/${this.model.id}/review`, { status });
- this.notifications.success(status === 'ignored' ? 'Fuel transaction ignored.' : 'Fuel transaction reviewed.');
- yield this.hostRouter.refresh();
- } catch (error) {
- this.notifications.serverError(error);
- }
+ @action confirmAction(mode) {
+ return openTransactionAction(this.modalsManager, mode, this.model);
+ }
+ @action openFuelReport() {
+ if (this.model.fuel_report_id) return this.hostRouter.transitionTo('console.fleet-ops.management.fuel-reports.index.details', this.model.fuel_report_id);
}
}
diff --git a/addon/controllers/management/index.js b/addon/controllers/management/index.js
index e7ef930bd..a5b8f2933 100644
--- a/addon/controllers/management/index.js
+++ b/addon/controllers/management/index.js
@@ -2,87 +2,1405 @@ import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
-import { task } from 'ember-concurrency';
+import { getOwner } from '@ember/application';
+import { task, timeout } from 'ember-concurrency';
+import { format } from 'date-fns';
+import { PANEL_DEFAULTS } from '../../utils/context-panel';
+import { RADAR_PILLS, RADAR_DEFAULT_VIEWS, SNOOZE_PRESETS, snoozePayloadFor, patchPayload, recordPanelFor, recordOf } from '../../utils/radar';
+const VIEWS_CACHE_KEY = 'fleetops:radar:views';
+const VIEW_MODE_CACHE_KEY = 'fleetops:radar:view';
+const BRIEFING_CACHE_KEY = 'fleetops:radar:briefing-collapsed';
+const PAGE_SIZE = 50;
+
+/** A record key Radar holds as a uuid rather than a public id. */
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/**
+ * Radar: the triage list that replaced the Resources Hub.
+ *
+ * The list, its pills, tabs and saved views live in query params so a view
+ * is shareable. Items come from `fleet-ops/radar/items`; every action on an
+ * item posts to the same prefix and patches the row in place, so the list
+ * never reflows under the cursor.
+ */
export default class ManagementIndexController extends Controller {
@service fetch;
- @service docsPanel;
+ @service notifications;
+ @service intl;
+ @service appCache;
+ @service hostRouter;
+ @service store;
+ @service modalsManager;
+ @service currentUser;
+ @service inspectionSubmissionActions;
+ @service inspectionFormActions;
+ @service issueActions;
+ @service resourceContextPanel;
+
+ queryParams = ['view', 'status', 'filters', 'category', 'fleet', 'q', 'saved', 'assigned', 'window'];
- @tracked hub = null;
+ @tracked view = 'list';
+ @tracked status = 'open';
+ @tracked filters = '';
+ @tracked category = '';
+ @tracked fleet = '';
+ @tracked q = '';
+ @tracked saved = '';
+ @tracked assigned = '';
+ @tracked window = '24h';
+ @tracked page = 1;
+
+ @tracked payload = null;
+ @tracked summary = null;
+ @tracked briefing = null;
+ @tracked agenda = null;
+ @tracked handover = null;
+ @tracked strips = [];
+ @tracked briefingCollapsed = false;
+ @tracked busyDecisionKey = null;
+ @tracked selection = [];
+ @tracked selectionAnchor = null;
+ @tracked focusedKey = null;
+ @tracked drawerItem = null;
+ @tracked savedViews = [];
+ @tracked lastLoadedAt = null;
+
+ snoozePresets = SNOOZE_PRESETS;
constructor() {
super(...arguments);
- this.loadHub.perform();
+ this.view = this.appCache.get(VIEW_MODE_CACHE_KEY, 'list') === 'agenda' ? 'agenda' : 'list';
+ this.briefingCollapsed = this.appCache.get(BRIEFING_CACHE_KEY, false) === true;
+ this.savedViews = this.readSavedViews();
}
- get kpis() {
- return (this.hub?.kpis ?? this.loadingKpis).map((kpi) => ({
- ...kpi,
- actionLabel: kpi.actionLabel ?? this.kpiActionLabels[kpi.key] ?? `Open ${kpi.label}`,
- }));
+ // ------------------------------------------------------------------
+ // Derived state
+ // ------------------------------------------------------------------
+
+ get items() {
+ return this.payload?.items ?? [];
+ }
+
+ get groups() {
+ return this.payload?.groups ?? [];
+ }
+
+ get counts() {
+ return this.payload?.counts ?? this.summary?.counts ?? {};
+ }
+
+ get stats() {
+ return this.summary?.summary ?? this.payload?.summary ?? {};
+ }
+
+ get meta() {
+ return this.payload?.meta ?? { total: 0, page: 1, pages: 1, limit: PAGE_SIZE };
+ }
+
+ get snoozeSchedule() {
+ return this.payload?.snooze_schedule ?? [];
+ }
+
+ get sourceErrors() {
+ return Object.keys(this.payload?.sources ?? {});
+ }
+
+ get sourceErrorsText() {
+ return this.sourceErrors.join(', ');
+ }
+
+ get previousPage() {
+ return Math.max(1, (this.meta.page ?? 1) - 1);
}
- get actions() {
- return (this.hub?.actions ?? []).map((action) => this.normalizeAction(action));
+ get nextPage() {
+ return Math.min(this.meta.pages ?? 1, (this.meta.page ?? 1) + 1);
}
- get sections() {
- return this.hub?.sections ?? [];
+ get activeFilters() {
+ return this.filters ? this.filters.split(',').filter(Boolean) : [];
}
- get docs() {
- return (this.hub?.docs ?? []).map((doc) => ({
- ...doc,
- description: doc.description ?? this.docDescriptions[doc.slug] ?? '',
+ get pills() {
+ return RADAR_PILLS.map((key) => ({
+ key,
+ label: this.intl.t(`radar.pills.${key}`),
+ count: this.counts[key] ?? 0,
+ active: this.activeFilters.includes(key),
+ dot: key === 'overdue',
}));
}
- docDescriptions = {
- 'fleet-ops/resources/drivers/overview': 'Create driver profiles, app access, assignment context, and operating status.',
- 'fleet-ops/resources/vehicles/overview': 'Track fleet assets, assignment readiness, and vehicle operating details.',
- 'fleet-ops/resources/fleets/overview': 'Group drivers and vehicles by team, region, or service coverage.',
- 'fleet-ops/resources/contacts/overview': 'Manage people and organizations used across orders and operational records.',
- 'fleet-ops/resources/places/overview': 'Manage reusable pickup, dropoff, hub, and facility locations.',
- 'fleet-ops/resources/issues/overview': 'Track incidents, service exceptions, and operational follow-up.',
- };
+ get isFiltered() {
+ return this.activeFilters.length > 0 || Boolean(this.q) || Boolean(this.category) || this.assigned === 'me';
+ }
+
+ get isLoading() {
+ return this.loadItems.isRunning && !this.payload;
+ }
+
+ get isEmpty() {
+ return !this.loadItems.isRunning && Boolean(this.payload) && this.items.length === 0;
+ }
+
+ get allViews() {
+ return [...RADAR_DEFAULT_VIEWS.map((view) => ({ ...view, label: this.intl.t(view.intl) })), ...this.savedViews];
+ }
+
+ get activeView() {
+ return this.allViews.find((view) => view.id === this.saved) ?? null;
+ }
+
+ get hasSelection() {
+ return this.selection.length > 0;
+ }
+
+ get selectedItems() {
+ return this.items.filter((item) => this.selection.includes(item.key));
+ }
- kpiActionLabels = {
- drivers: 'Manage Drivers',
- vehicles: 'Track Vehicles',
- issues: 'Review Issues',
- fuel_records: 'Review Fuel Records',
- };
+ get isAllSelected() {
+ return this.items.length > 0 && this.items.every((item) => this.selection.includes(item.key));
+ }
- normalizeAction(action = {}) {
- const query = action.query && typeof action.query === 'object' && !Array.isArray(action.query) ? action.query : {};
+ get focusedItem() {
+ return this.items.find((item) => item.key === this.focusedKey) ?? null;
+ }
+ /**
+ * With a selection, the action keys run over the selection; without one
+ * they act on the focused row.
+ */
+ get keyboardHandlers() {
return {
- ...action,
- query,
+ next: this.focusNext,
+ previous: this.focusPrevious,
+ select: this.toggleFocusedSelection,
+ acknowledge: () => this.actOnSelectionOrFocused('acknowledge'),
+ snooze: () => this.actOnSelectionOrFocused('snooze', snoozePayloadFor('1h')),
+ assign: () => this.actOnSelectionOrFocused('assign'),
+ open: this.openFocused,
+ close: this.closeOpen,
+ search: this.focusSearch,
+ };
+ }
+
+ // ------------------------------------------------------------------
+ // Loading
+ // ------------------------------------------------------------------
+
+ @task *reload() {
+ const loads = [this.loadSummary.perform(), this.loadItems.perform(), this.loadBriefing.perform()];
+ if (this.view === 'agenda') {
+ loads.push(this.loadAgenda.perform());
+ }
+ yield Promise.all(loads);
+ }
+
+ @task({ restartable: true }) *loadAgenda() {
+ try {
+ this.agenda = yield this.fetch.get('fleet-ops/radar/agenda', { window: this.window, fleet: this.fleet });
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @task({ restartable: true }) *loadBriefing() {
+ try {
+ this.briefing = yield this.fetch.get('fleet-ops/radar/briefing');
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @task({ restartable: true }) *loadItems(debounce = false) {
+ if (debounce) {
+ yield timeout(250);
+ }
+
+ const params = {
+ status: this.status,
+ filters: this.filters,
+ category: this.category,
+ fleet: this.fleet,
+ query: this.q,
+ assigned: this.assigned,
+ page: this.page,
+ limit: PAGE_SIZE,
+ };
+
+ try {
+ this.payload = yield this.fetch.get('fleet-ops/radar/items', params);
+ this.lastLoadedAt = new Date();
+ this.pruneSelection();
+ if (this.focusedKey && !this.items.some((item) => item.key === this.focusedKey)) {
+ this.focusedKey = null;
+ }
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @task({ restartable: true }) *loadSummary() {
+ try {
+ this.summary = yield this.fetch.get('fleet-ops/radar/summary');
+ } catch {
+ // The header falls back to the list's own summary.
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Filters, tabs, views
+ // ------------------------------------------------------------------
+
+ @action togglePill(key) {
+ const active = new Set(this.activeFilters);
+ if (active.has(key)) {
+ active.delete(key);
+ } else {
+ active.add(key);
+ }
+ this.filters = [...active].join(',');
+ this.saved = '';
+ this.page = 1;
+ this.loadItems.perform();
+ }
+
+ @action clearFilters() {
+ this.filters = '';
+ this.category = '';
+ this.q = '';
+ this.assigned = '';
+ this.saved = '';
+ this.page = 1;
+ this.loadItems.perform();
+ }
+
+ @action setStatus(status) {
+ if (status === this.status) {
+ return;
+ }
+ this.status = status;
+ this.page = 1;
+ this.selection = [];
+ this.loadItems.perform();
+ }
+
+ @action search(query) {
+ this.q = query ?? '';
+ this.page = 1;
+ this.loadItems.perform(true);
+ }
+
+ @action setView(view) {
+ this.view = view === 'agenda' ? 'agenda' : 'list';
+ this.appCache.set(VIEW_MODE_CACHE_KEY, this.view);
+ if (this.view === 'agenda') {
+ this.loadAgenda.perform();
+ }
+ }
+
+ @action setWindow(window) {
+ this.window = window === '7d' ? '7d' : '24h';
+ this.loadAgenda.perform();
+ }
+
+ /** An agenda entry is an item in another coat: open the same drawer. */
+ @action openAgendaEntry(entry) {
+ if (!entry?.key) {
+ return;
+ }
+ const item = this.items.find((row) => row.key === entry.key) ?? {
+ key: entry.key,
+ rule: entry.rule,
+ category: entry.category,
+ severity: entry.severity,
+ title: entry.title,
+ subject: entry.subject,
+ meta_line: entry.meta_line,
+ due_at: entry.at,
+ due_bucket: 'none',
+ due_label: entry.label,
+ window: null,
+ record: entry.record,
+ actions: entry.actions ?? [],
+ state: entry.state ?? { status: 'open' },
+ details: null,
};
+ this.openDrawer(item);
+ }
+
+ @action showAnytimeInList() {
+ this.setView('list');
+ this.clearFilters();
+ }
+
+ /** Dropping a tray item on a lane gives it a time. */
+ @task *planItem(key, plannedAt) {
+ try {
+ yield this.fetch.post(`fleet-ops/radar/items/${encodeURIComponent(key)}/plan`, { planned_at: plannedAt });
+ this.notifications.success(this.intl.t('radar.toasts.planned'));
+ yield Promise.all([this.loadAgenda.perform(), this.loadItems.perform()]);
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Handover
+ // ------------------------------------------------------------------
+
+ @task({ restartable: true }) *openHandover(key) {
+ this.handover = null;
+ try {
+ const response = yield this.fetch.get(`fleet-ops/radar/handovers/${encodeURIComponent(key)}`);
+ this.handover = response?.handover ?? null;
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @action closeHandover() {
+ this.handover = null;
}
- get loadingKpis() {
- return [
- { key: 'drivers', label: 'Drivers', value: '...', caption: 'Loading resource readiness.', tone: 'blue', icon: 'id-card', route: 'management.drivers' },
- { key: 'vehicles', label: 'Vehicles', value: '...', caption: 'Loading fleet asset coverage.', tone: 'green', icon: 'truck', route: 'management.vehicles' },
- { key: 'issues', label: 'Open Issues', value: '...', caption: 'Loading exception health.', tone: 'rose', icon: 'triangle-exclamation', route: 'management.issues' },
- { key: 'fuel_records', label: 'Fuel Records', value: '...', caption: 'Loading fuel activity.', tone: 'amber', icon: 'gas-pump', route: 'management.fuel-reports' },
- ];
+ @task({ drop: true }) *reassignOrders(handover) {
+ const ids = (handover?.orders ?? []).map((order) => order.uuid).filter(Boolean);
+ const driver = handover?.suggested?.driver;
+ if (!ids.length || !driver?.uuid) {
+ return;
+ }
+
+ try {
+ yield this.fetch.patch('orders/bulk-assign-driver', { ids, driver: driver.uuid });
+ this.notifications.success(this.intl.t('radar.handover.reassigned', { name: driver.label }));
+ this.handover = null;
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @task({ drop: true }) *extendShift(handover, minutes = 60) {
+ const shiftId = handover?.shift?.uuid ?? handover?.shift?.public_id;
+ if (!shiftId) {
+ return;
+ }
+
+ try {
+ yield this.fetch.post(`fleet-ops/radar/shifts/${shiftId}/extend`, { minutes });
+ this.notifications.success(this.intl.t('radar.handover.extended'));
+ this.handover = null;
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
}
- @action openDocs(link) {
- if (!link?.slug) {
+ @task({ drop: true }) *snoozeHandover(handover) {
+ if (!handover?.key) {
return;
}
+ yield this.stateAction({ key: handover.key }, 'snooze', { minutes: 60 });
+ this.handover = null;
+ this.loadAgenda.perform();
+ }
- return this.docsPanel.open(link.slug, {
- title: link.title ?? link.label,
- source: 'fleet-ops-resources-hub',
+ @action setPage(page) {
+ this.page = Math.max(1, page);
+ this.loadItems.perform();
+ }
+
+ @action applyView(view) {
+ this.saved = view.id;
+ this.status = view.status ?? 'open';
+ this.filters = view.filters ?? '';
+ this.category = view.category ?? '';
+ this.q = view.q ?? '';
+ this.assigned = view.assigned ?? '';
+ this.page = 1;
+ this.loadItems.perform();
+ }
+
+ @action saveCurrentView() {
+ return this.modalsManager.show('modals/radar-save-view', {
+ title: this.intl.t('radar.saved-views.save-current'),
+ acceptButtonText: this.intl.t('common.save'),
+ acceptButtonIcon: 'save',
+ name: '',
+ confirm: (modal) => {
+ const name = (modal.getOption('name') ?? '').trim();
+ if (!name) {
+ return this.notifications.warning(this.intl.t('radar.saved-views.name-prompt'));
+ }
+
+ const view = {
+ id: `view-${Date.now()}`,
+ label: name,
+ status: this.status,
+ filters: this.filters,
+ category: this.category,
+ q: this.q,
+ assigned: this.assigned,
+ };
+ this.savedViews = [...this.savedViews, view];
+ this.writeSavedViews();
+ this.saved = view.id;
+ this.notifications.success(this.intl.t('radar.saved-views.saved'));
+ modal.done();
+ },
});
}
- @task *loadHub() {
- this.hub = yield this.fetch.get('fleet-ops/hubs/resources');
+ @action deleteView(view) {
+ this.savedViews = this.savedViews.filter((saved) => saved.id !== view.id);
+ this.writeSavedViews();
+ if (this.saved === view.id) {
+ this.saved = '';
+ }
+ this.notifications.info(this.intl.t('radar.saved-views.deleted'));
+ }
+
+ readSavedViews() {
+ const views = this.appCache.get(VIEWS_CACHE_KEY, []);
+
+ return Array.isArray(views) ? views.filter((view) => view && view.id && view.label) : [];
+ }
+
+ writeSavedViews() {
+ this.appCache.set(VIEWS_CACHE_KEY, this.savedViews);
+ }
+
+ // ------------------------------------------------------------------
+ // Selection and focus
+ // ------------------------------------------------------------------
+
+ /**
+ * Toggle one row, or with Shift held set every row between the last row
+ * toggled and this one to this row's new state, the way a mail inbox does.
+ */
+ @action toggleSelect(item, { range = false } = {}) {
+ const key = item.key;
+ const select = !this.selection.includes(key);
+ const keys = this.items.map((row) => row.key);
+ const from = keys.indexOf(this.selectionAnchor);
+ const to = keys.indexOf(key);
+
+ let affected = [key];
+ if (range && from !== -1 && to !== -1) {
+ affected = keys.slice(Math.min(from, to), Math.max(from, to) + 1);
+ }
+
+ const selection = new Set(this.selection);
+ for (const affectedKey of affected) {
+ if (select) {
+ selection.add(affectedKey);
+ } else {
+ selection.delete(affectedKey);
+ }
+ }
+
+ this.selection = keys.filter((rowKey) => selection.has(rowKey));
+ this.selectionAnchor = key;
+ }
+
+ @action selectAll() {
+ this.selection = this.isAllSelected ? [] : this.items.map((item) => item.key);
+ }
+
+ @action clearSelection() {
+ this.selectionAnchor = null;
+ this.selection = [];
+ }
+
+ pruneSelection() {
+ const keys = new Set(this.items.map((item) => item.key));
+ this.selection = this.selection.filter((key) => keys.has(key));
+ }
+
+ @action focusItem(item) {
+ this.focusedKey = item?.key ?? null;
+ }
+
+ @action focusNext() {
+ this.moveFocus(1);
+ }
+
+ @action focusPrevious() {
+ this.moveFocus(-1);
+ }
+
+ moveFocus(step) {
+ const items = this.items;
+ if (!items.length) {
+ return;
+ }
+
+ const index = items.findIndex((item) => item.key === this.focusedKey);
+ const next = index === -1 ? (step > 0 ? 0 : items.length - 1) : Math.min(items.length - 1, Math.max(0, index + step));
+ this.focusedKey = items[next].key;
+ document.querySelector(`[data-radar-key="${CSS.escape(this.focusedKey)}"]`)?.scrollIntoView({ block: 'nearest' });
+ }
+
+ @action toggleFocusedSelection() {
+ if (this.focusedItem) {
+ this.toggleSelect(this.focusedItem);
+ }
+ }
+
+ @action openFocused() {
+ if (this.focusedItem) {
+ this.openDrawer(this.focusedItem);
+ }
+ }
+
+ actOnFocused(actionName, payload = {}) {
+ if (this.focusedItem) {
+ this.act.perform(this.focusedItem, actionName, payload);
+ }
+ }
+
+ actOnSelectionOrFocused(actionName, payload = {}) {
+ if (this.hasSelection) {
+ return this.bulkAct.perform(actionName, payload);
+ }
+
+ return this.actOnFocused(actionName, payload);
+ }
+
+ @action closeOpen() {
+ if (this.drawerItem) {
+ return this.closeDrawer();
+ }
+ if (this.hasSelection) {
+ return this.clearSelection();
+ }
+ }
+
+ @action focusSearch() {
+ document.querySelector('.fleet-ops-radar-search input')?.focus();
+ }
+
+ // ------------------------------------------------------------------
+ // Drawer
+ // ------------------------------------------------------------------
+
+ @action openDrawer(item) {
+ this.drawerItem = item;
+ this.focusedKey = item?.key ?? this.focusedKey;
+ }
+
+ @action closeDrawer() {
+ this.drawerItem = null;
+ }
+
+ // ------------------------------------------------------------------
+ // Acting on items
+ // ------------------------------------------------------------------
+
+ /**
+ * Every action a row, the drawer or a key can fire, by name. State
+ * actions post to Radar; record actions use the record's own endpoint.
+ */
+ @task *act(item, actionName, payload = {}) {
+ switch (actionName) {
+ case 'acknowledge':
+ return yield this.stateAction(item, 'acknowledge', {}, 'radar.toasts.acknowledged');
+ case 'snooze':
+ return yield this.snoozeItem(item, payload);
+ case 'wake':
+ return yield this.stateAction(item, 'wake', {}, 'radar.toasts.woken');
+ case 'assign':
+ return yield this.assignItem(item);
+ case 'unassign':
+ return yield this.stateAction(item, 'assign', {}, 'radar.toasts.unassigned');
+ case 'plan':
+ return yield this.stateAction(item, 'plan', { planned_at: payload.planned_at ?? null }, 'radar.toasts.planned');
+ case 'unplan':
+ return yield this.stateAction(item, 'plan', { planned_at: null }, 'radar.toasts.planned');
+ case 'resolve':
+ return yield this.stateAction(item, 'resolve', { resolution: payload.resolution ?? null }, 'radar.toasts.resolved');
+ case 'open_record':
+ return this.openRecord(item);
+ case 'assign_vehicle':
+ return yield this.assignVehicle(item);
+ case 'assign_driver':
+ return yield this.assignDriver(item);
+ case 'create_work_order':
+ return yield this.createWorkOrderFromSchedule(item);
+ case 'create_work_order_from_inspection':
+ return yield this.inspectionFollowUp(item, 'createWorkOrder');
+ case 'create_issue_from_inspection':
+ return yield this.inspectionFollowUp(item, 'createIssue');
+ case 'resolve_inspection':
+ return yield this.inspectionFollowUp(item, 'resolve');
+ case 'resolve_issue':
+ return yield this.resolveIssue(item);
+ case 'match_vehicle':
+ return yield this.matchVehicle(item);
+ case 'ignore_transaction':
+ return yield this.ignoreTransaction(item);
+ case 'attach_device':
+ return yield this.attachDevice(item);
+ case 'send_pin':
+ return yield this.sendPin(item);
+ case 'revoke_link':
+ return yield this.revokeLink(item);
+ case 'call':
+ return this.callDriver(item);
+ case 'delete_notice':
+ return yield this.deleteNotice(item);
+ case 'cover_shift':
+ case 'handover':
+ return yield this.openHandover.perform(item.key);
+ case 'extend_shift': {
+ const shiftId = item.source?.uuid ?? item.source?.public_id;
+ return shiftId ? yield this.extendShift.perform({ shift: { uuid: shiftId } }, 60) : null;
+ }
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * One state action over the selection: acknowledge, snooze, wake or
+ * assign. Assign asks who first, then posts once for every key.
+ */
+ @task({ drop: true }) *bulkAct(actionName, payload = {}) {
+ const keys = [...this.selection];
+ if (!keys.length) {
+ return;
+ }
+
+ if (actionName === 'assign') {
+ return yield this.bulkAssign(keys);
+ }
+
+ if (actionName === 'revoke_link') {
+ return yield this.bulkRevokeLinks(this.selectedItems.filter((item) => item.actions?.includes('revoke_link')));
+ }
+
+ const body = { keys, action: actionName };
+ if (actionName === 'snooze') {
+ Object.assign(body, payload.minutes || payload.until ? { minutes: payload.minutes, until: payload.until } : snoozePayloadFor(payload.preset ?? '1h'));
+ }
+
+ yield this.postBulk(body);
+ }
+
+ async bulkAssign(keys) {
+ return this.modalsManager.show('modals/radar-select-resource', {
+ title: this.intl.t('radar.prompts.assign-user-title'),
+ helpText: this.intl.t('radar.prompts.assign-user-help'),
+ inputLabel: this.intl.t('radar.prompts.select-user'),
+ placeholder: this.intl.t('radar.prompts.select-user'),
+ modelName: 'user',
+ query: { is_not_customer: true },
+ allowClear: true,
+ selected: null,
+ acceptButtonText: this.intl.t('radar.actions.assign'),
+ acceptButtonIcon: 'user-plus',
+ confirm: async (modal) => {
+ const user = modal.getOption('selected');
+ modal.startLoading();
+ const ok = await this.postBulk({ keys, action: 'assign', user: user?.id ?? null });
+ modal.stopLoading();
+ if (ok) {
+ modal.done();
+ }
+ },
+ });
+ }
+
+ /**
+ * Revoke every selected inspection link at once. Each link is its own
+ * delete, so one failure does not stop the rest; the toast counts both.
+ */
+ async bulkRevokeLinks(items) {
+ if (!items.length) {
+ return;
+ }
+
+ return this.modalsManager.confirm({
+ title: this.intl.t('radar.prompts.bulk-revoke-link-title', { count: items.length }),
+ body: this.intl.t('radar.prompts.bulk-revoke-link-body'),
+ acceptButtonText: this.intl.t('radar.actions.revoke-links', { count: items.length }),
+ acceptButtonIcon: 'link-slash',
+ acceptButtonType: 'danger',
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ const results = await Promise.allSettled(
+ items.map((item) => {
+ const formId = item.source?.form_uuid ?? item.source?.form_public_id;
+ const linkId = item.source?.uuid ?? item.source?.public_id;
+
+ return this.fetch.delete(`inspection-forms/${formId}/links/${linkId}`);
+ })
+ );
+ const revoked = results.filter((result) => result.status === 'fulfilled').length;
+ const failed = results.length - revoked;
+
+ if (revoked) {
+ this.notifications.success(this.intl.t('radar.toasts.links-revoked', { count: revoked }));
+ }
+ if (failed) {
+ this.notifications.error(this.intl.t('radar.toasts.links-revoke-failed', { count: failed }));
+ }
+
+ this.selection = [];
+ modal.done();
+ this.reload.perform();
+ },
+ });
+ }
+
+ async postBulk(body) {
+ try {
+ const response = await this.fetch.post('fleet-ops/radar/items/bulk', body);
+ const results = response?.results ?? [];
+ for (const result of results) {
+ if (result.ok && result.state) {
+ this.applyState({ key: result.key }, result.state);
+ }
+ }
+ const updated = results.filter((result) => result.ok).length;
+ this.selection = [];
+ this.notifications.success(this.intl.t('radar.toasts.bulk-done', { count: updated }));
+ this.loadSummary.perform();
+
+ return true;
+ } catch (err) {
+ this.notifications.serverError(err);
+
+ return false;
+ }
+ }
+
+ async stateAction(item, endpoint, body = {}, toastKey = null, toastParams = {}) {
+ try {
+ const response = await this.fetch.post(`fleet-ops/radar/items/${encodeURIComponent(item.key)}/${endpoint}`, body);
+ this.applyState(item, response?.state ?? response?.item?.state);
+ if (toastKey) {
+ this.notifications.success(this.intl.t(toastKey, toastParams));
+ }
+ this.loadSummary.perform();
+
+ return response;
+ } catch (err) {
+ this.notifications.serverError(err);
+
+ return null;
+ }
+ }
+
+ /**
+ * Put a fresh state on the row, or take the row off this tab when the
+ * state moved it to another one.
+ */
+ applyState(item, state) {
+ if (!state) {
+ return;
+ }
+
+ const leavesTab = (this.status === 'open' && ['snoozed', 'resolved'].includes(state.status)) || (this.status === 'snoozed' && state.status !== 'snoozed');
+ this.payload = patchPayload(this.payload, item.key, (row) => (leavesTab ? null : { ...row, state }));
+
+ if (this.drawerItem?.key === item.key) {
+ this.drawerItem = leavesTab ? null : { ...this.drawerItem, state };
+ }
+ if (leavesTab) {
+ this.selection = this.selection.filter((key) => key !== item.key);
+ if (this.focusedKey === item.key) {
+ this.focusedKey = null;
+ }
+ }
+ }
+
+ async snoozeItem(item, payload = {}) {
+ const body = payload.minutes || payload.until ? payload : snoozePayloadFor(payload.preset ?? '1h');
+ const response = await this.stateAction(item, 'snooze', body);
+ if (response?.state?.snoozed_until) {
+ this.notifications.success(this.intl.t('radar.toasts.snoozed', { time: this.formatTime(response.state.snoozed_until) }));
+ }
+
+ return response;
+ }
+
+ async assignItem(item) {
+ return this.modalsManager.show('modals/radar-select-resource', {
+ title: this.intl.t('radar.prompts.assign-user-title'),
+ helpText: this.intl.t('radar.prompts.assign-user-help'),
+ inputLabel: this.intl.t('radar.prompts.select-user'),
+ placeholder: this.intl.t('radar.prompts.select-user'),
+ modelName: 'user',
+ query: { is_not_customer: true },
+ allowClear: true,
+ selected: null,
+ acceptButtonText: this.intl.t('radar.actions.assign'),
+ acceptButtonIcon: 'user-plus',
+ confirm: async (modal) => {
+ const user = modal.getOption('selected');
+ modal.startLoading();
+ const response = await this.stateAction(item, 'assign', { user: user?.id ?? null }, user ? 'radar.toasts.assigned' : 'radar.toasts.unassigned', { name: user?.name });
+ modal.stopLoading();
+ if (response) {
+ modal.done();
+ }
+ },
+ });
+ }
+
+ async assignVehicle(item) {
+ const driver = await this.findRecord('driver', item.subject?.public_id);
+ if (!driver) {
+ return;
+ }
+
+ return this.modalsManager.show('modals/driver-assign-vehicle', {
+ title: this.intl.t('radar.prompts.assign-vehicle-title', { name: driver.name }),
+ acceptButtonText: this.intl.t('radar.actions.assign-vehicle'),
+ acceptButtonIcon: 'check',
+ hideDeclineButton: true,
+ driver,
+ confirm: async (modal) => {
+ const vehicleId = driver.vehicle_uuid ?? driver.vehicle?.id;
+ if (!vehicleId) {
+ return this.notifications.warning(this.intl.t('radar.prompts.no-selection'));
+ }
+
+ modal.startLoading();
+ try {
+ await this.fetch.post(`drivers/${driver.id}/assign-vehicle`, { vehicle: vehicleId });
+ this.notifications.success(this.intl.t('radar.toasts.vehicle-assigned'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async assignDriver(item) {
+ const vehicleId = item.subject?.uuid ?? item.subject?.public_id;
+
+ return this.modalsManager.show('modals/radar-select-resource', {
+ title: this.intl.t('radar.prompts.assign-driver-title', { name: item.subject?.label }),
+ inputLabel: this.intl.t('radar.prompts.select-user'),
+ placeholder: this.intl.t('radar.actions.assign-driver'),
+ modelName: 'driver',
+ selected: null,
+ acceptButtonText: this.intl.t('radar.actions.assign-driver'),
+ acceptButtonIcon: 'check',
+ confirm: async (modal) => {
+ const driver = modal.getOption('selected');
+ if (!driver) {
+ return this.notifications.warning(this.intl.t('radar.prompts.no-selection'));
+ }
+
+ modal.startLoading();
+ try {
+ await this.fetch.post(`vehicles/${vehicleId}/assign-driver`, { driver: driver.id });
+ this.notifications.success(this.intl.t('radar.toasts.driver-assigned'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async createWorkOrderFromSchedule(item) {
+ const scheduleId = item.source?.uuid ?? item.source?.public_id;
+
+ return this.modalsManager.confirm({
+ title: this.intl.t('radar.prompts.create-work-order-title'),
+ body: this.intl.t('radar.prompts.create-work-order-body'),
+ icon: 'clipboard-list',
+ iconClass: 'text-blue-500',
+ acceptButtonText: this.intl.t('radar.actions.create-work-order'),
+ acceptButtonScheme: 'primary',
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ await this.fetch.post(`maintenance-schedules/${scheduleId}/trigger`);
+ this.notifications.success(this.intl.t('radar.toasts.work-order-created'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async inspectionFollowUp(item, kind) {
+ const submission = await this.findRecord('inspection-submission', item.source?.public_id ?? item.source?.uuid);
+ if (!submission) {
+ return;
+ }
+
+ await this.inspectionSubmissionActions[kind](submission);
+ this.reload.perform();
+ }
+
+ /**
+ * Close an issue the way its details panel does: the close-issue modal,
+ * which asks for the resolution note and records who closed it.
+ */
+ async resolveIssue(item) {
+ const issue = await this.findRecord('issue', item.source?.public_id ?? item.source?.uuid, recordPanelFor('management.issues')?.include);
+ if (!issue) {
+ return;
+ }
+
+ return this.issueActions.openCloseIssueModal(issue, { onSaved: () => this.reload.perform() });
+ }
+
+ async matchVehicle(item) {
+ const transactionId = item.source?.uuid ?? item.source?.public_id;
+ const suggested = item.source?.suggested_vehicle;
+
+ return this.modalsManager.show('modals/radar-select-resource', {
+ title: this.intl.t('radar.prompts.match-vehicle-title'),
+ body: suggested ? `${this.intl.t('radar.actions.match-vehicle')}: ${suggested.label}` : null,
+ inputLabel: this.intl.t('radar.prompts.select-vehicle'),
+ placeholder: this.intl.t('radar.prompts.select-vehicle'),
+ modelName: 'vehicle',
+ selected: null,
+ selectedId: suggested?.uuid ?? null,
+ acceptButtonText: this.intl.t('radar.actions.match-vehicle'),
+ acceptButtonIcon: 'link',
+ confirm: async (modal) => {
+ const vehicle = modal.getOption('selected');
+ const vehicleId = vehicle?.id ?? modal.getOption('selectedId');
+ if (!vehicleId) {
+ return this.notifications.warning(this.intl.t('radar.prompts.no-selection'));
+ }
+
+ modal.startLoading();
+ try {
+ await this.fetch.post(`fuel-provider-transactions/${transactionId}/match-vehicle`, { vehicle: vehicleId });
+ this.notifications.success(this.intl.t('radar.toasts.matched'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async ignoreTransaction(item) {
+ const transactionId = item.source?.uuid ?? item.source?.public_id;
+
+ return this.modalsManager.confirm({
+ title: this.intl.t('radar.prompts.ignore-title'),
+ body: this.intl.t('radar.prompts.ignore-body'),
+ acceptButtonText: this.intl.t('radar.actions.ignore'),
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ await this.fetch.post(`fuel-provider-transactions/${transactionId}/review`, { status: 'ignored' });
+ this.notifications.success(this.intl.t('radar.toasts.ignored'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async attachDevice(item) {
+ const deviceId = item.source?.uuid ?? item.source?.public_id;
+
+ return this.modalsManager.show('modals/radar-select-resource', {
+ title: this.intl.t('radar.prompts.attach-device-title'),
+ inputLabel: this.intl.t('radar.prompts.select-vehicle'),
+ placeholder: this.intl.t('radar.prompts.select-vehicle'),
+ modelName: 'vehicle',
+ selected: null,
+ acceptButtonText: this.intl.t('radar.actions.attach'),
+ acceptButtonIcon: 'link',
+ confirm: async (modal) => {
+ const vehicle = modal.getOption('selected');
+ if (!vehicle) {
+ return this.notifications.warning(this.intl.t('radar.prompts.no-selection'));
+ }
+
+ modal.startLoading();
+ try {
+ await this.fetch.post(`devices/${deviceId}/attach`, { vehicle: vehicle.id });
+ this.notifications.success(this.intl.t('radar.toasts.device-attached'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Send an inspection link's PIN, asking how first: by email or by text,
+ * offering only the channels the link's recipient can receive, the same
+ * choice the inspection form's link list gives.
+ */
+ async sendPin(item) {
+ const formId = item.source?.form_uuid ?? item.source?.form_public_id;
+ const linkId = item.source?.uuid ?? item.source?.public_id;
+
+ let link;
+ try {
+ const response = await this.fetch.get(`inspection-forms/${formId}/links`);
+ link = (response?.links ?? []).find((candidate) => [candidate.id, candidate.uuid, candidate.public_id].includes(linkId));
+ } catch (err) {
+ return this.notifications.serverError(err);
+ }
+
+ if (!link?.has_pin) {
+ return this.notifications.warning(this.intl.t('inspection.link.no-pin'));
+ }
+
+ const channels = ['email', 'sms'].filter((via) => link.can_send_pin?.[via]);
+ if (!channels.length) {
+ return this.notifications.warning(this.intl.t('radar.prompts.send-pin-unavailable'));
+ }
+
+ return this.modalsManager.show('modals/radar-send-pin', {
+ title: this.intl.t('radar.prompts.send-pin-title'),
+ recipient: link.recipient?.name ?? null,
+ channels,
+ via: channels[0],
+ acceptButtonText: this.intl.t('radar.actions.send-pin'),
+ acceptButtonIcon: 'paper-plane',
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ const response = await this.fetch.post(`inspection-forms/${formId}/links/${link.id ?? linkId}/send-pin`, { via: modal.getOption('via') });
+ this.inspectionFormActions.notifyPinDelivery(response?.pin_delivery);
+ modal.done();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async revokeLink(item) {
+ const formId = item.source?.form_uuid ?? item.source?.form_public_id;
+ const linkId = item.source?.uuid ?? item.source?.public_id;
+
+ return this.modalsManager.confirm({
+ title: this.intl.t('radar.prompts.revoke-link-title'),
+ body: this.intl.t('radar.prompts.revoke-link-body'),
+ acceptButtonText: this.intl.t('radar.actions.revoke-link'),
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ await this.fetch.delete(`inspection-forms/${formId}/links/${linkId}`);
+ this.notifications.success(this.intl.t('radar.toasts.link-revoked'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ callDriver(item) {
+ const phone = item.subject?.phone;
+ if (!phone) {
+ return this.openRecord(item);
+ }
+
+ window.open(`tel:${phone}`, '_self');
+ }
+
+ /**
+ * Open the record behind a row, a decision card, a handover or a brief
+ * link in a context panel, so Radar stays on screen. Resources with an
+ * action service use its `panel.view`; the rest open their details
+ * component in a plain panel. Only a record Radar has no panel for falls
+ * back to navigating to it.
+ */
+ @action openRecord(target) {
+ const record = recordOf(target);
+ if (!record?.route) {
+ return;
+ }
+
+ return this.openRecordPanel.perform(record);
+ }
+
+ @task *openRecordPanel(record) {
+ const panel = recordPanelFor(record.route);
+ if (!panel) {
+ return this.hostRouter.transitionTo(`console.fleet-ops.${record.route}`, record.model);
+ }
+
+ const resource = yield this.findRecord(panel.modelName, record.model, panel.include);
+ if (!resource) {
+ return;
+ }
+
+ const actions = panel.service ? getOwner(this).lookup(`service:${panel.service}`) : null;
+ if (typeof actions?.panel?.view === 'function') {
+ return yield actions.panel.view(resource);
+ }
+
+ return this.resourceContextPanel.open({
+ resource,
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: panel.component }],
+ ...PANEL_DEFAULTS,
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // Morning brief
+ // ------------------------------------------------------------------
+
+ @action toggleBriefing() {
+ this.briefingCollapsed = !this.briefingCollapsed;
+ this.appCache.set(BRIEFING_CACHE_KEY, this.briefingCollapsed);
+ }
+
+ /** A category row's "Filter": show exactly the items behind that score. */
+ @action filterCategory(category) {
+ this.category = this.category === category ? '' : category;
+ this.status = 'open';
+ this.saved = '';
+ this.page = 1;
+ this.view = 'list';
+ this.loadItems.perform();
+ }
+
+ /**
+ * Confirm a decision card: make the call it describes, keep a strip with
+ * its undo, and refresh the list the change affects.
+ */
+ @task({ drop: true }) *confirmDecision(decision, call) {
+ if (!call?.endpoint) {
+ return;
+ }
+
+ this.busyDecisionKey = decision.key;
+ try {
+ yield this.performCall(call);
+ this.addStrip({ kind: 'confirmed', key: decision.key, title: decision.title, at: new Date().toISOString(), undo: call.undo ?? null, keys: decision.keys ?? [] });
+ this.removeDecision(decision.key);
+ this.notifications.success(call.label ?? this.intl.t('radar.briefing.confirm'));
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ } finally {
+ this.busyDecisionKey = null;
+ }
+ }
+
+ /** An alternative is either another call or a prompt (pick another vehicle). */
+ @task({ drop: true }) *decisionAlternative(decision, alternative) {
+ if (alternative?.endpoint) {
+ return yield this.confirmDecision.perform(decision, alternative);
+ }
+
+ if (alternative?.action === 'pick_vehicle') {
+ const item = { subject: decision.subject, key: decision.keys?.[0] };
+ yield this.assignVehicle(item);
+ }
+ }
+
+ /** "Not now": snooze the items behind the card for a day. */
+ @task({ drop: true }) *dismissDecision(decision) {
+ const keys = decision.keys ?? [];
+ if (!keys.length) {
+ return this.removeDecision(decision.key);
+ }
+
+ this.busyDecisionKey = decision.key;
+ try {
+ const response = yield this.fetch.post('fleet-ops/radar/items/bulk', { keys, action: 'snooze', minutes: 1440 });
+ const until = (response?.results ?? []).find((result) => result.ok)?.state?.snoozed_until ?? null;
+ for (const result of response?.results ?? []) {
+ if (result.ok && result.state) {
+ this.applyState({ key: result.key }, result.state);
+ }
+ }
+ this.addStrip({ kind: 'snoozed', key: decision.key, title: decision.title, until, keys });
+ this.removeDecision(decision.key);
+ this.loadSummary.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ } finally {
+ this.busyDecisionKey = null;
+ }
+ }
+
+ @task({ drop: true }) *undoStrip(strip) {
+ if (!strip.undo?.endpoint) {
+ return;
+ }
+
+ try {
+ yield this.performCall(strip.undo);
+ this.strips = this.strips.filter((entry) => entry !== strip);
+ this.notifications.info(strip.undo.label ?? this.intl.t('radar.briefing.undo'));
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @task({ drop: true }) *wakeStrip(strip) {
+ try {
+ yield this.fetch.post('fleet-ops/radar/items/bulk', { keys: strip.keys ?? [], action: 'wake' });
+ this.strips = this.strips.filter((entry) => entry !== strip);
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ performCall(call) {
+ const method = String(call.method ?? 'POST').toLowerCase();
+ const body = call.body ?? {};
+
+ switch (method) {
+ case 'patch':
+ return this.fetch.patch(call.endpoint, body);
+ case 'put':
+ return this.fetch.put(call.endpoint, body);
+ case 'delete':
+ return this.fetch.delete(call.endpoint, body);
+ default:
+ return this.fetch.post(call.endpoint, body);
+ }
+ }
+
+ addStrip(strip) {
+ this.strips = [strip, ...this.strips].slice(0, 8);
+ }
+
+ removeDecision(key) {
+ if (!this.briefing) {
+ return;
+ }
+
+ this.briefing = { ...this.briefing, decisions: (this.briefing.decisions ?? []).filter((decision) => decision.key !== key) };
+ }
+
+ // ------------------------------------------------------------------
+ // Notices
+ // ------------------------------------------------------------------
+
+ @action newNotice() {
+ return this.modalsManager.show('modals/radar-notice', {
+ title: this.intl.t('radar.notice.title'),
+ acceptButtonText: this.intl.t('radar.notice.create'),
+ acceptButtonIcon: 'bullhorn',
+ notice: { message: '', severity: 'info', due_at: '', scope: '' },
+ confirm: async (modal) => {
+ const notice = modal.getOption('notice');
+ if (!notice.message?.trim()) {
+ return this.notifications.warning(this.intl.t('radar.notice.message'));
+ }
+
+ modal.startLoading();
+ try {
+ await this.fetch.post('fleet-ops/radar/notices', {
+ message: notice.message,
+ severity: notice.severity,
+ due_at: notice.due_at || null,
+ scope: notice.scope || null,
+ });
+ this.notifications.success(this.intl.t('radar.notice.created'));
+ modal.done();
+ this.reload.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async deleteNotice(item) {
+ const id = item.source?.public_id ?? item.source?.uuid;
+
+ return this.modalsManager.confirm({
+ title: this.intl.t('radar.notice.delete'),
+ acceptButtonText: this.intl.t('common.delete'),
+ acceptButtonScheme: 'danger',
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ await this.fetch.delete(`fleet-ops/radar/notices/${id}`);
+ this.payload = patchPayload(this.payload, item.key, () => null);
+ if (this.drawerItem?.key === item.key) {
+ this.drawerItem = null;
+ }
+ this.notifications.success(this.intl.t('radar.notice.deleted'));
+ modal.done();
+ this.loadSummary.perform();
+ } catch (err) {
+ this.notifications.serverError(err);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ // ------------------------------------------------------------------
+ // Plumbing
+ // ------------------------------------------------------------------
+
+ /**
+ * Load a record Radar points at by its public id (or uuid), the way the
+ * details routes do. `store.findRecord` with a public id registers the
+ * record under an identifier its payload then contradicts, which Ember
+ * Data rejects with "You should not change the
of a
+ * RecordIdentifier" once the record is already in the store.
+ */
+ async findRecord(modelName, id, include = []) {
+ if (!id) {
+ return null;
+ }
+
+ const field = UUID_PATTERN.test(id) ? 'uuid' : 'public_id';
+ const query = { [field]: id, single: true };
+ if (include?.length) {
+ query.with = include;
+ }
+
+ try {
+ return await this.store.queryRecord(modelName, query);
+ } catch (err) {
+ this.notifications.serverError(err);
+
+ return null;
+ }
+ }
+
+ formatTime(value) {
+ const date = value ? new Date(value) : null;
+ if (!date || Number.isNaN(date.getTime())) {
+ return '';
+ }
+
+ return format(date, 'EEE d MMM HH:mm');
}
}
diff --git a/addon/controllers/management/issues/index.js b/addon/controllers/management/issues/index.js
index 9a5a678af..9c3cfaecd 100644
--- a/addon/controllers/management/issues/index.js
+++ b/addon/controllers/management/issues/index.js
@@ -1,5 +1,6 @@
import Controller from '@ember/controller';
-import { get } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import fleetOpsOptions from '../../../utils/fleet-ops-options';
@@ -137,6 +138,8 @@ export default class ManagementIssuesIndexController extends Controller {
{
label: this.intl.t('column.reporter'),
valuePath: 'reporter_name',
+ cellComponent: 'table/cell/user-identity',
+ resourcePath: (issue) => relationValue(issue, 'reporter') ?? buildIdentityStub(issue, { type: 'user', nameKey: 'reporter_name' }),
permission: 'iam view user',
resizable: true,
sortable: true,
@@ -150,6 +153,8 @@ export default class ManagementIssuesIndexController extends Controller {
{
label: this.intl.t('column.assignee'),
valuePath: 'assignee_name',
+ cellComponent: 'table/cell/user-identity',
+ resourcePath: (issue) => relationValue(issue, 'assignee') ?? buildIdentityStub(issue, { type: 'user', nameKey: 'assignee_name' }),
permission: 'iam view user',
hidden: true,
resizable: true,
@@ -165,35 +170,9 @@ export default class ManagementIssuesIndexController extends Controller {
valuePath: 'driver_name',
cellComponent: 'cell/driver-identity',
permission: 'fleet-ops view driver',
- action: async (driver) => {
- const resolvedDriver = driver?.loadResource ? await driver.loadResource() : driver;
-
- if (resolvedDriver) {
- this.issueActions.driverActions.panel.view(resolvedDriver);
- }
- },
+ action: this.issueActions.driverActions.panel.view,
emptyText: '-',
- showStatusBadge: true,
- resourcePath: (issue) => {
- const driver = get(issue, 'driver');
-
- if (driver) {
- return driver;
- }
-
- const driverName = get(issue, 'driver_name');
-
- if (driverName) {
- return {
- id: get(issue, 'driver_uuid'),
- name: driverName,
- display_name: driverName,
- loadResource: () => issue.loadDriver?.(),
- };
- }
-
- return null;
- },
+ resourcePath: (issue) => relationValue(issue, 'driver') ?? buildIdentityStub(issue, { type: 'driver', load: () => issue.loadDriver?.() }),
resizable: true,
sortable: true,
filterable: true,
@@ -207,37 +186,9 @@ export default class ManagementIssuesIndexController extends Controller {
valuePath: 'vehicle_name',
cellComponent: 'cell/vehicle-identity',
permission: 'fleet-ops view vehicle',
- action: async (vehicle) => {
- const resolvedVehicle = vehicle?.loadResource ? await vehicle.loadResource() : vehicle;
-
- if (resolvedVehicle) {
- this.issueActions.vehicleActions.panel.view(resolvedVehicle);
- }
- },
+ action: this.issueActions.vehicleActions.panel.view,
emptyText: '-',
- showStatusBadge: true,
- resourcePath: (issue) => {
- const vehicle = get(issue, 'vehicle');
-
- if (vehicle) {
- return vehicle;
- }
-
- const vehicleName = get(issue, 'vehicle_name');
-
- if (vehicleName) {
- return {
- id: get(issue, 'vehicle_uuid'),
- displayName: vehicleName,
- display_name: vehicleName,
- name: vehicleName,
- vehicle_number: get(issue, 'vehicle_uuid'),
- loadResource: () => issue.loadVehicle?.(),
- };
- }
-
- return null;
- },
+ resourcePath: (issue) => relationValue(issue, 'vehicle') ?? buildIdentityStub(issue, { type: 'vehicle', load: () => issue.loadVehicle?.() }),
resizable: true,
sortable: true,
filterable: true,
diff --git a/addon/controllers/management/issues/index/details.js b/addon/controllers/management/issues/index/details.js
index 3edfaf6da..748d72224 100644
--- a/addon/controllers/management/issues/index/details.js
+++ b/addon/controllers/management/issues/index/details.js
@@ -13,39 +13,7 @@ export default class ManagementIssuesIndexDetailsController extends Controller {
},
];
- get isClosed() {
- return ['closed', 'resolved', 'completed'].includes(this.model?.status);
- }
-
get actionButtons() {
- const workflowItems = [
- {
- label: 'Change Status',
- icon: 'arrows-rotate',
- fn: () => this.issueActions.openStatusModal(this.model, { onSaved: () => this.refreshIssue() }),
- },
- {
- label: 'Assign Issue',
- icon: 'user-check',
- fn: () => this.issueActions.openAssignModal(this.model, { onSaved: () => this.refreshIssue() }),
- },
- ];
-
- if (this.isClosed) {
- workflowItems.push({
- label: 'Re-open Issue',
- icon: 'rotate-left',
- fn: () => this.issueActions.confirmReopenIssue(this.model, { onSaved: () => this.refreshIssue() }),
- });
- } else {
- workflowItems.push({
- label: 'Close Issue',
- icon: 'circle-check',
- class: 'text-green-600 dark:text-green-400',
- fn: () => this.issueActions.openCloseIssueModal(this.model, { onSaved: () => this.refreshIssue() }),
- });
- }
-
return [
{
icon: 'pencil',
@@ -54,7 +22,7 @@ export default class ManagementIssuesIndexDetailsController extends Controller {
{
icon: 'ellipsis',
type: 'default',
- items: workflowItems,
+ items: this.issueActions.workflowItems(this.model, () => this.refreshIssue()),
},
];
}
diff --git a/addon/controllers/management/trailers/index.js b/addon/controllers/management/trailers/index.js
index 923334357..43fc27013 100644
--- a/addon/controllers/management/trailers/index.js
+++ b/addon/controllers/management/trailers/index.js
@@ -1,5 +1,7 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { TRAILER_TYPES, TRAILER_STATUSES, TRAILER_OWNERSHIP_TYPES } from '../../../components/trailer/form';
@@ -163,8 +165,7 @@ export default class ManagementTrailersIndexController extends Controller {
label: this.intl.t('trailer.columns.name'),
valuePath: 'displayName',
photoPath: 'photo_url',
- cellComponent: 'cell/vehicle-identity',
- compact: true,
+ cellComponent: 'cell/trailer-identity',
permission: 'fleet-ops view trailer',
action: this.trailerActions.transition.view,
resizable: true,
@@ -172,8 +173,6 @@ export default class ManagementTrailersIndexController extends Controller {
filterable: true,
filterComponent: 'filter/string',
filterParam: 'name',
- showOnlineIndicator: true,
- showStatus: false,
},
{
label: this.intl.t('trailer.columns.type'),
@@ -214,15 +213,16 @@ export default class ManagementTrailersIndexController extends Controller {
{
label: this.intl.t('trailer.columns.vehicle'),
valuePath: 'current_vehicle_name',
- cellComponent: 'table/cell/anchor',
+ cellComponent: 'cell/vehicle-identity',
permission: 'fleet-ops view vehicle',
- action: async (trailer) => {
- const vehicle = trailer.current_vehicle ?? (trailer.current_vehicle_id ? await this.store.findRecord('vehicle', trailer.current_vehicle_id) : null);
-
- if (vehicle) {
- return this.vehicleActions.panel.view(vehicle);
- }
- },
+ action: this.vehicleActions.panel.view,
+ resourcePath: (trailer) =>
+ relationValue(trailer, 'current_vehicle') ??
+ buildIdentityStub(trailer, {
+ type: 'vehicle',
+ nameKey: 'current_vehicle_name',
+ load: () => (trailer.current_vehicle_id ? this.store.findRecord('vehicle', trailer.current_vehicle_id) : null),
+ }),
emptyText: '-',
resizable: true,
filterable: true,
@@ -386,16 +386,12 @@ export default class ManagementTrailersIndexController extends Controller {
{
label: this.intl.t('trailer.columns.vendor'),
valuePath: 'vendor_name',
- cellComponent: 'table/cell/anchor',
+ cellComponent: 'cell/vendor-identity',
permission: 'fleet-ops view vendor',
- action: async ({ vendor_uuid }) => {
- if (!vendor_uuid) {
- return;
- }
-
- const vendor = await this.store.findRecord('vendor', vendor_uuid);
- this.vendorActions.viewVendor(vendor);
- },
+ action: this.vendorActions.panel.view,
+ resourcePath: (trailer) =>
+ relationValue(trailer, 'vendor') ??
+ buildIdentityStub(trailer, { type: 'vendor', load: () => (trailer.vendor_uuid ? this.store.findRecord('vendor', trailer.vendor_uuid) : null) }),
hidden: true,
resizable: true,
filterable: true,
@@ -568,9 +564,11 @@ export default class ManagementTrailersIndexController extends Controller {
{
label: this.intl.t('resource.devices'),
valuePath: 'devices',
+ cellComponent: 'cell/resource-list',
+ resourceType: 'device',
hidden: true,
filterable: true,
- filterComponent: 'filter/multi-model',
+ filterComponent: 'filter/model-multiple',
filterComponentPlaceholder: this.intl.t('common.select-resource-filter-by', { resource: this.intl.t('resource.device') }),
filterParam: 'device',
model: 'device',
diff --git a/addon/controllers/management/trailers/index/details.js b/addon/controllers/management/trailers/index/details.js
index e072337cd..1f5907c79 100644
--- a/addon/controllers/management/trailers/index/details.js
+++ b/addon/controllers/management/trailers/index/details.js
@@ -24,10 +24,6 @@ export default class ManagementTrailersIndexDetailsController extends Controller
];
}
- get isAttached() {
- return this.model?.isAttached ?? this.model?.attachment_state === 'attached';
- }
-
get actionButtons() {
return [
{
@@ -36,83 +32,13 @@ export default class ManagementTrailersIndexDetailsController extends Controller
permission: 'fleet-ops update trailer',
},
{
- // Actions dropdown — mirrors the row-level actions from the trailers index table
+ // Actions dropdown — shared with the trailer context panel
icon: 'ellipsis-h',
iconPrefix: 'fas',
renderInPlace: true,
- items: [
- {
- text: this.intl.t('trailer.actions.locate'),
- icon: 'location-dot',
- fn: () => this.trailerActions.locate(this.model),
- permission: 'fleet-ops view trailer',
- },
- ...(this.isAttached
- ? [
- {
- text: this.intl.t('trailer.actions.detach-vehicle'),
- icon: 'unlink',
- fn: () => this.trailerActions.detachVehicle(this.model),
- permission: 'fleet-ops detach-vehicle-for trailer',
- },
- ]
- : [
- {
- text: this.intl.t('trailer.actions.attach-vehicle'),
- icon: 'link',
- fn: () => this.trailerActions.attachVehicle(this.model),
- permission: 'fleet-ops attach-vehicle-for trailer',
- },
- ]),
- {
- text: this.intl.t('trailer.actions.attach-device'),
- icon: 'microchip',
- fn: () => this.trailerActions.attachDevice(this.model),
- permission: 'fleet-ops attach-device-for trailer',
- },
- {
- text: this.intl.t('trailer.actions.attach-equipment'),
- icon: 'toolbox',
- fn: () => this.trailerActions.attachEquipment(this.model),
- permission: 'fleet-ops attach-equipment-for trailer',
- },
- {
- separator: true,
- },
- {
- text: this.intl.t('trailer.actions.schedule-maintenance'),
- icon: 'calendar-check',
- fn: () => this.trailerActions.scheduleMaintenance(this.model),
- permission: 'fleet-ops create maintenance-schedule',
- },
- {
- text: this.intl.t('trailer.actions.create-work-order'),
- icon: 'clipboard-list',
- fn: () => this.trailerActions.createWorkOrder(this.model),
- permission: 'fleet-ops create work-order',
- },
- {
- text: this.intl.t('trailer.actions.log-maintenance'),
- icon: 'wrench',
- fn: () => this.trailerActions.logMaintenance(this.model),
- permission: 'fleet-ops create maintenance',
- },
- {
- separator: true,
- },
- {
- text: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.trailer') }),
- icon: 'trash',
- fn: () =>
- this.trailerActions.delete(this.model, {
- onConfirm: () => {
- this.hostRouter.transitionTo('console.fleet-ops.management.trailers.index');
- },
- }),
- permission: 'fleet-ops delete trailer',
- class: 'text-red-500 hover:text-red-600',
- },
- ],
+ items: this.trailerActions.detailsMenuItems(this.model, {
+ onDeleted: () => this.hostRouter.transitionTo('console.fleet-ops.management.trailers.index'),
+ }),
},
];
}
diff --git a/addon/controllers/management/vehicles/index.js b/addon/controllers/management/vehicles/index.js
index 6fa4f6cd4..1662a134a 100644
--- a/addon/controllers/management/vehicles/index.js
+++ b/addon/controllers/management/vehicles/index.js
@@ -1,5 +1,7 @@
import Controller from '@ember/controller';
-import { action, get } from '@ember/object';
+import { action } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
@@ -7,6 +9,8 @@ export default class ManagementVehiclesIndexController extends Controller {
@service vehicleActions;
@service issueActions;
@service driverActions;
+ @service vendorActions;
+ @service store;
@service tableContext;
@service intl;
@service appCache;
@@ -148,7 +152,6 @@ export default class ManagementVehiclesIndexController extends Controller {
valuePath: 'displayName',
photoPath: 'avatar_url',
cellComponent: 'cell/vehicle-identity',
- compact: true,
permission: 'fleet-ops view vehicle',
action: this.vehicleActions.transition.view,
resizable: true,
@@ -156,8 +159,6 @@ export default class ManagementVehiclesIndexController extends Controller {
filterable: true,
filterComponent: 'filter/string',
filterParam: 'name',
- showOnlineIndicator: true,
- showStatus: false,
},
{
label: this.intl.t('column.plate-number'),
@@ -185,41 +186,14 @@ export default class ManagementVehiclesIndexController extends Controller {
{
label: this.intl.t('column.driver-assigned'),
cellComponent: 'cell/driver-identity',
- compact: true,
- assignedVehicleLabel: (_driver, vehicle) => get(vehicle, 'displayName') ?? get(vehicle, 'display_name') ?? get(vehicle, 'name'),
permission: 'fleet-ops view driver',
- action: async (driver) => {
- const resolvedDriver = get(driver, 'id') ? driver : await driver?.loadResource?.();
-
- if (resolvedDriver) {
- return this.driverActions.panel.view(resolvedDriver);
- }
- },
+ action: this.driverActions.panel.view,
valuePath: 'driver_name',
emptyText: '-',
- resourcePath: (vehicle) => {
- const driver = get(vehicle, 'driver');
-
- if (driver) {
- return driver;
- }
-
- const driverName = get(vehicle, 'driver_name');
-
- if (driverName) {
- return {
- name: driverName,
- display_name: driverName,
- status: 'assigned',
- loadResource: () => vehicle.loadDriver?.(),
- };
- }
-
- return null;
- },
+ resourcePath: (vehicle) => relationValue(vehicle, 'driver') ?? buildIdentityStub(vehicle, { type: 'driver', load: () => vehicle.loadDriver?.() }),
resizable: true,
filterable: true,
- filterComponent: 'filter/multi-model',
+ filterComponent: 'filter/model-multiple',
filterComponentPlaceholder: this.intl.t('common.select-resource-filter-by', { resource: this.intl.t('resource.driver') }),
filterParam: 'driver',
model: 'driver',
@@ -227,9 +201,11 @@ export default class ManagementVehiclesIndexController extends Controller {
{
label: this.intl.t('resource.trailers'),
valuePath: 'trailers',
+ cellComponent: 'cell/resource-list',
+ resourceType: 'trailer',
hidden: true,
filterable: true,
- filterComponent: 'filter/multi-model',
+ filterComponent: 'filter/model-multiple',
filterComponentPlaceholder: this.intl.t('common.select-resource-filter-by', { resource: this.intl.t('resource.trailer') }),
filterParam: 'trailer',
model: 'trailer',
@@ -238,44 +214,16 @@ export default class ManagementVehiclesIndexController extends Controller {
{
label: this.intl.t('resource.devices'),
valuePath: 'devices',
+ cellComponent: 'cell/resource-list',
+ resourceType: 'device',
hidden: true,
filterable: true,
- filterComponent: 'filter/multi-model',
+ filterComponent: 'filter/model-multiple',
filterComponentPlaceholder: this.intl.t('common.select-resource-filter-by', { resource: this.intl.t('resource.device') }),
filterParam: 'device',
model: 'device',
modelNamePath: 'displayName',
},
- {
- label: this.intl.t('vehicle.filters.has-trailer'),
- valuePath: 'has_trailer',
- hidden: true,
- filterable: true,
- filterComponent: 'filter/select',
- filterComponentPlaceholder: this.intl.t('vehicle.filters.any'),
- filterOptions: this.presenceOptions('attached'),
- filterParam: 'has_trailer',
- },
- {
- label: this.intl.t('vehicle.filters.has-driver'),
- valuePath: 'has_driver',
- hidden: true,
- filterable: true,
- filterComponent: 'filter/select',
- filterComponentPlaceholder: this.intl.t('vehicle.filters.any'),
- filterOptions: this.presenceOptions('assigned'),
- filterParam: 'has_driver',
- },
- {
- label: this.intl.t('vehicle.filters.has-device'),
- valuePath: 'has_device',
- hidden: true,
- filterable: true,
- filterComponent: 'filter/select',
- filterComponentPlaceholder: this.intl.t('vehicle.filters.any'),
- filterOptions: this.presenceOptions('attached'),
- filterParam: 'has_device',
- },
{
label: this.intl.t('column.id'),
valuePath: 'public_id',
@@ -319,14 +267,13 @@ export default class ManagementVehiclesIndexController extends Controller {
},
{
label: this.intl.t('column.vendor'),
- cellComponent: 'table/cell/anchor',
+ cellComponent: 'cell/vendor-identity',
permission: 'fleet-ops view vendor',
- action: async ({ vendor_uuid }) => {
- const vendor = await this.store.findRecord('vendor', vendor_uuid);
-
- this.vendorActions.viewVendor(vendor);
- },
+ action: this.vendorActions.panel.view,
valuePath: 'vendor_name',
+ resourcePath: (vehicle) =>
+ relationValue(vehicle, 'vendor') ??
+ buildIdentityStub(vehicle, { type: 'vendor', load: () => (vehicle.vendor_uuid ? this.store.findRecord('vendor', vehicle.vendor_uuid) : null) }),
hidden: true,
resizable: true,
filterable: true,
@@ -368,6 +315,37 @@ export default class ManagementVehiclesIndexController extends Controller {
filterable: true,
filterComponent: 'filter/date',
},
+ // Filter-only entries omit a column label so the visible-column picker excludes them.
+ {
+ filterLabel: this.intl.t('vehicle.filters.has-trailer'),
+ valuePath: 'has_trailer',
+ hidden: true,
+ filterable: true,
+ filterComponent: 'filter/radio',
+ filterComponentPlaceholder: this.intl.t('vehicle.filters.any'),
+ filterOptions: this.presenceOptions('attached'),
+ filterParam: 'has_trailer',
+ },
+ {
+ filterLabel: this.intl.t('vehicle.filters.has-driver'),
+ valuePath: 'has_driver',
+ hidden: true,
+ filterable: true,
+ filterComponent: 'filter/radio',
+ filterComponentPlaceholder: this.intl.t('vehicle.filters.any'),
+ filterOptions: this.presenceOptions('assigned'),
+ filterParam: 'has_driver',
+ },
+ {
+ filterLabel: this.intl.t('vehicle.filters.has-device'),
+ valuePath: 'has_device',
+ hidden: true,
+ filterable: true,
+ filterComponent: 'filter/radio',
+ filterComponentPlaceholder: this.intl.t('vehicle.filters.any'),
+ filterOptions: this.presenceOptions('attached'),
+ filterParam: 'has_device',
+ },
{
label: '',
cellComponent: 'table/cell/dropdown',
diff --git a/addon/controllers/management/vehicles/index/details.js b/addon/controllers/management/vehicles/index/details.js
index 0f7cc4be6..a159b35cc 100644
--- a/addon/controllers/management/vehicles/index/details.js
+++ b/addon/controllers/management/vehicles/index/details.js
@@ -1,11 +1,9 @@
import Controller from '@ember/controller';
-import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { isArray } from '@ember/array';
export default class ManagementVehiclesIndexDetailsController extends Controller {
@service vehicleActions;
- @service issueActions;
@service('universe/menu-service') menuService;
@service hostRouter;
@service intl;
@@ -53,6 +51,11 @@ export default class ManagementVehiclesIndexDetailsController extends Controller
route: 'management.vehicles.index.details.maintenance-history',
label: 'Maintenance',
},
+ {
+ id: 'inspections',
+ route: 'management.vehicles.index.details.inspections',
+ label: this.intl.t('resource.inspections'),
+ },
...(isArray(registeredTabs) ? registeredTabs : []),
];
}
@@ -65,85 +68,14 @@ export default class ManagementVehiclesIndexDetailsController extends Controller
permission: 'fleet-ops update vehicle',
},
{
- // Actions dropdown — mirrors the row-level actions from the vehicles index table
+ // Actions dropdown — shared with the vehicle context panel
icon: 'ellipsis-h',
iconPrefix: 'fas',
renderInPlace: true,
- items: [
- {
- text: this.intl.t('vehicle.actions.locate-vehicle'),
- icon: 'location-dot',
- fn: () => this.vehicleActions.locate(this.model),
- permission: 'fleet-ops view vehicle',
- },
- {
- text: this.intl.t('vehicle.actions.attach-device'),
- icon: 'link',
- fn: () => this.vehicleActions.attachDevice(this.model),
- permission: 'fleet-ops update vehicle',
- },
- ...(Number(this.model.assigned_orders_count) > 0
- ? [
- {
- text: this.intl.t('vehicle.actions.unassign-orders'),
- icon: 'truck-ramp-box',
- fn: () => this.vehicleActions.unassignOrders(this.model),
- permission: 'fleet-ops update vehicle',
- },
- ]
- : []),
- {
- separator: true,
- },
- {
- text: this.intl.t('vehicle.actions.schedule-maintenance'),
- icon: 'calendar-check',
- fn: () => this.vehicleActions.scheduleMaintenance(this.model),
- permission: 'fleet-ops create maintenance-schedule',
- },
- {
- text: this.intl.t('vehicle.actions.create-work-order'),
- icon: 'clipboard-list',
- fn: () => this.vehicleActions.createWorkOrder(this.model),
- permission: 'fleet-ops create work-order',
- },
- {
- text: this.intl.t('vehicle.actions.log-maintenance'),
- icon: 'wrench',
- fn: () => this.vehicleActions.logMaintenance(this.model),
- permission: 'fleet-ops create maintenance',
- },
- {
- text: this.intl.t('vehicle.actions.create-issue'),
- icon: 'triangle-exclamation',
- fn: () => this.createIssue(this.model),
- permission: 'fleet-ops create issue',
- },
- {
- separator: true,
- },
- {
- text: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.vehicle') }),
- icon: 'trash',
- fn: () =>
- this.vehicleActions.delete(this.model, {
- onConfirm: () => {
- this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index');
- },
- }),
- permission: 'fleet-ops delete vehicle',
- class: 'text-red-500 hover:text-red-600',
- },
- ],
+ items: this.vehicleActions.detailsMenuItems(this.model, {
+ onDeleted: () => this.hostRouter.transitionTo('console.fleet-ops.management.vehicles.index'),
+ }),
},
];
}
-
- @action createIssue(vehicle) {
- this.issueActions.modal.create({
- vehicle,
- vehicle_uuid: vehicle.id,
- title: this.intl.t('vehicle.prompts.issue-title', { vehicleName: vehicle.displayName ?? vehicle.name }),
- });
- }
}
diff --git a/addon/controllers/management/vendors/index.js b/addon/controllers/management/vendors/index.js
index dbdedf202..03d941bbe 100644
--- a/addon/controllers/management/vendors/index.js
+++ b/addon/controllers/management/vendors/index.js
@@ -2,6 +2,8 @@ import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
import fleetOpsOptions from '../../../utils/fleet-ops-options';
export default class ManagementVendorsIndexController extends Controller {
@@ -118,7 +120,7 @@ export default class ManagementVendorsIndexController extends Controller {
sticky: true,
label: this.intl.t('column.name'),
valuePath: 'name',
- cellComponent: 'table/cell/media-name',
+ cellComponent: 'cell/vendor-identity',
mediaPath: 'logo_url',
action: this.vendorActions.transition.view,
permission: 'fleet-ops view vendor',
@@ -178,8 +180,9 @@ export default class ManagementVendorsIndexController extends Controller {
{
label: this.intl.t('column.address'),
valuePath: 'address',
- cellComponent: 'table/cell/anchor',
- action: this.vendorActions.viewPlace,
+ cellComponent: 'cell/place-identity',
+ permission: 'fleet-ops view place',
+ resourcePath: (vendor) => relationValue(vendor, 'place') ?? buildIdentityStub(vendor, { type: 'place', nameKey: 'address', load: () => vendor.get('place') }),
resizable: true,
sortable: true,
filterable: true,
diff --git a/addon/controllers/operations/orders/index.js b/addon/controllers/operations/orders/index.js
index bd4a4a24d..5b40c7bc8 100644
--- a/addon/controllers/operations/orders/index.js
+++ b/addon/controllers/operations/orders/index.js
@@ -1,10 +1,15 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
-import { action } from '@ember/object';
+import { action, get } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
export default class OperationsOrdersIndexController extends Controller {
@service orderActions;
+ @service driverActions;
+ @service vehicleActions;
+ @service store;
@service orderSocketEvents;
@service leafletMapManager;
@service mapDrawer;
@@ -164,7 +169,8 @@ export default class OperationsOrdersIndexController extends Controller {
{
label: this.intl.t('column.payload'),
valuePath: 'payload.public_id',
- cellComponent: 'click-to-copy',
+ cellComponent: 'cell/payload-identity',
+ resourcePath: (order) => relationValue(order, 'payload'),
resizable: true,
hidden: true,
filterable: true,
@@ -174,9 +180,13 @@ export default class OperationsOrdersIndexController extends Controller {
},
{
label: this.intl.t('column.driver-assigned'),
- cellComponent: 'cell/driver-name',
+ cellComponent: 'cell/driver-identity',
valuePath: 'driver_assigned',
- modelPath: 'driver_assigned',
+ permission: 'fleet-ops view driver',
+ action: this.driverActions.panel.view,
+ resourcePath: (order) =>
+ relationValue(order, 'driver_assigned') ??
+ buildIdentityStub(order, { type: 'driver', load: () => (order.driver_assigned_uuid ? this.store.findRecord('driver', order.driver_assigned_uuid) : null) }),
resizable: true,
sortable: true,
filterable: true,
@@ -188,7 +198,9 @@ export default class OperationsOrdersIndexController extends Controller {
{
label: this.intl.t('column.pickup'),
valuePath: 'pickupName',
- cellComponent: 'table/cell/base',
+ cellComponent: 'cell/place-identity',
+ permission: 'fleet-ops view place',
+ resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'pickup') ?? buildIdentityStub(order, { type: 'place', nameKey: 'pickupName' }),
resizable: true,
sortable: true,
filterable: true,
@@ -202,7 +214,9 @@ export default class OperationsOrdersIndexController extends Controller {
{
label: this.intl.t('column.dropoff'),
valuePath: 'dropoffName',
- cellComponent: 'table/cell/base',
+ cellComponent: 'cell/place-identity',
+ permission: 'fleet-ops view place',
+ resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'dropoff') ?? buildIdentityStub(order, { type: 'place', nameKey: 'dropoffName' }),
resizable: true,
sortable: true,
filterable: true,
@@ -215,7 +229,8 @@ export default class OperationsOrdersIndexController extends Controller {
{
label: this.intl.t('column.customer'),
valuePath: 'customer.name',
- cellComponent: 'table/cell/base',
+ cellComponent: 'cell/customer-identity',
+ resourcePath: (order) => relationValue(order, 'customer') ?? buildIdentityStub(order, { type: order.customer_type ?? 'customer', nameKey: 'customer_name' }),
resizable: true,
sortable: true,
hidden: false,
@@ -227,10 +242,17 @@ export default class OperationsOrdersIndexController extends Controller {
},
{
label: this.intl.t('column.vehicle-assigned'),
- cellComponent: 'cell/vehicle-name',
+ cellComponent: 'cell/vehicle-identity',
valuePath: 'vehicle_assigned.display_name',
- modelPath: 'vehicle_assigned',
- showOnlineIndicator: true,
+ permission: 'fleet-ops view vehicle',
+ action: this.vehicleActions.panel.view,
+ resourcePath: (order) =>
+ relationValue(order, 'vehicle_assigned') ??
+ buildIdentityStub(order, {
+ type: 'vehicle',
+ name: get(order, 'vehicle_assigned.display_name'),
+ load: () => (order.vehicle_assigned_uuid ? this.store.findRecord('vehicle', order.vehicle_assigned_uuid) : null),
+ }),
hidden: true,
resizable: true,
sortable: true,
@@ -244,7 +266,8 @@ export default class OperationsOrdersIndexController extends Controller {
{
label: this.intl.t('column.facilitator'),
valuePath: 'facilitator.name',
- cellComponent: 'table/cell/base',
+ cellComponent: 'cell/facilitator-identity',
+ resourcePath: (order) => relationValue(order, 'facilitator') ?? buildIdentityStub(order, { type: order.facilitator_type ?? 'facilitator', nameKey: 'facilitator_name' }),
resizable: true,
hidden: true,
sortable: true,
@@ -333,6 +356,8 @@ export default class OperationsOrdersIndexController extends Controller {
{
label: this.intl.t('column.created-by'),
valuePath: 'created_by_name',
+ cellComponent: 'table/cell/user-identity',
+ resourcePath: (order) => buildIdentityStub(order, { type: 'user', nameKey: 'created_by_name' }),
resizable: true,
hidden: true,
filterable: true,
@@ -344,6 +369,8 @@ export default class OperationsOrdersIndexController extends Controller {
{
label: this.intl.t('column.updated-by'),
valuePath: 'updated_by_name',
+ cellComponent: 'table/cell/user-identity',
+ resourcePath: (order) => buildIdentityStub(order, { type: 'user', nameKey: 'updated_by_name' }),
resizable: true,
hidden: true,
filterable: true,
diff --git a/addon/controllers/operations/service-rates/index.js b/addon/controllers/operations/service-rates/index.js
index 065479041..cdb91974f 100644
--- a/addon/controllers/operations/service-rates/index.js
+++ b/addon/controllers/operations/service-rates/index.js
@@ -1,5 +1,8 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
+import { get } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
import { tracked } from '@glimmer/tracking';
export default class OperationsServiceRatesIndexController extends Controller {
@@ -74,7 +77,10 @@ export default class OperationsServiceRatesIndexController extends Controller {
{
label: this.intl.t('column.service-area'),
valuePath: 'service_area.name',
- cellComponent: 'table/cell/base',
+ cellComponent: 'cell/service-area-identity',
+ resourcePath: (rate) =>
+ relationValue(rate, 'service_area') ??
+ buildIdentityStub(rate, { type: 'service-area', name: rate.service_area_name ?? get(rate, 'service_area.name'), load: () => rate.get('service_area') }),
resizable: true,
sortable: true,
filterable: true,
@@ -86,7 +92,9 @@ export default class OperationsServiceRatesIndexController extends Controller {
{
label: this.intl.t('column.zone'),
valuePath: 'zone.name',
- cellComponent: 'table/cell/base',
+ cellComponent: 'cell/zone-identity',
+ resourcePath: (rate) =>
+ relationValue(rate, 'zone') ?? buildIdentityStub(rate, { type: 'zone', name: rate.zone_name ?? get(rate, 'zone.name'), load: () => rate.get('zone') }),
resizable: true,
sortable: true,
filterable: true,
diff --git a/addon/controllers/settings/payments/index.js b/addon/controllers/settings/payments/index.js
index 835bf18a7..5923327c5 100644
--- a/addon/controllers/settings/payments/index.js
+++ b/addon/controllers/settings/payments/index.js
@@ -5,8 +5,11 @@ import { isEmpty } from '@ember/utils';
import { task } from 'ember-concurrency';
import config from 'ember-get-config';
import { action } from '@ember/object';
+import { buildIdentityStub } from '../../../utils/identity-cell-resource';
+import relationValue from '../../../utils/relation-value';
export default class SettingsPaymentsIndexController extends Controller {
+ @service store;
@service fetch;
@tracked hasStripeConnectAccount = true;
@tracked table;
@@ -24,16 +27,22 @@ export default class SettingsPaymentsIndexController extends Controller {
{
label: 'Service Quote',
valuePath: 'service_quote_id',
- cellComponent: 'click-to-copy',
+ cellComponent: 'cell/service-quote-identity',
+ resourcePath: (payment) => relationValue(payment, 'service_quote') ?? buildIdentityStub(payment, { type: 'service-quote', nameKey: 'service_quote_id' }),
},
{
label: 'Order',
valuePath: 'order_id',
- cellComponent: 'click-to-copy',
+ cellComponent: 'cell/order-identity',
+ resourcePath: (payment) =>
+ relationValue(payment, 'order') ??
+ buildIdentityStub(payment, { type: 'order', nameKey: 'order_id', load: () => (payment.order_uuid ? this.store.findRecord('order', payment.order_uuid) : null) }),
},
{
label: 'Customer',
valuePath: 'customer.name',
+ cellComponent: 'cell/customer-identity',
+ resourcePath: (payment) => relationValue(payment, 'customer') ?? buildIdentityStub(payment, { type: payment.customer_type ?? 'customer', nameKey: 'customer_name' }),
},
{
label: 'Amount',
diff --git a/addon/extension.js b/addon/extension.js
index e5be926b9..a1a05121f 100644
--- a/addon/extension.js
+++ b/addon/extension.js
@@ -13,6 +13,12 @@ export default {
priority: 0,
description: 'Dispatch, fleet management, driver tracking, and logistics operations.',
shortcuts: [
+ {
+ title: 'Radar',
+ description: 'Everything across resources, maintenance and staffing that needs a decision today.',
+ icon: 'satellite-dish',
+ route: 'console.fleet-ops.management.index',
+ },
{
title: 'Orders',
description: 'Create, dispatch, and track delivery orders in real time.',
@@ -118,6 +124,21 @@ export default {
})
);
+ menuService.registerMenuItem(
+ 'auth:login',
+ new MenuItem({
+ title: 'Inspection',
+ route: 'virtual',
+ slug: 'inspection',
+ type: 'link',
+ wrapperClass: 'hidden',
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'public-inspection'),
+ onClick: (menuItem) => {
+ universe.transitionMenuItem('virtual', menuItem);
+ },
+ })
+ );
+
// Register widgets
this.registerWidgets(widgetService);
@@ -205,6 +226,16 @@ export default {
category: 'KPI Tiles',
default: true,
}),
+ new Widget({
+ id: 'fleet-ops-radar-widget',
+ name: 'Radar',
+ description: 'What needs a decision across the fleet right now: open, overdue and snoozed items, linking to Radar.',
+ icon: 'satellite-dish',
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/radar'),
+ grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
+ category: 'KPI Tiles',
+ default: true,
+ }),
new Widget({
id: 'fleet-ops-kpi-open-issues-widget',
name: 'Open Issues',
@@ -394,6 +425,11 @@ export default {
'fleet-ops:component:maintenance:form',
'fleet-ops:component:maintenance:form:details',
'fleet-ops:component:maintenance:details',
+ 'fleet-ops:component:inspection-form:form',
+ 'fleet-ops:component:inspection-form:details',
+ 'fleet-ops:component:inspection-submission:form',
+ 'fleet-ops:component:inspection-submission:details',
+ 'fleet-ops:component:public-inspection',
'fleet-ops:component:work-order:form',
'fleet-ops:component:work-order:form:details',
'fleet-ops:component:work-order:details',
diff --git a/addon/helpers/initials.js b/addon/helpers/initials.js
new file mode 100644
index 000000000..74507035b
--- /dev/null
+++ b/addon/helpers/initials.js
@@ -0,0 +1,6 @@
+import { helper } from '@ember/component/helper';
+import { initialsOf } from '../utils/radar';
+
+export default helper(function initials([name]) {
+ return initialsOf(name);
+});
diff --git a/addon/helpers/leaflet-tile-url.js b/addon/helpers/leaflet-tile-url.js
index 681cee375..8a0774821 100644
--- a/addon/helpers/leaflet-tile-url.js
+++ b/addon/helpers/leaflet-tile-url.js
@@ -5,9 +5,14 @@ import { inject as service } from '@ember/service';
* Resolves the Leaflet tile URL from Fleet-Ops map settings.
*
* Usage:
- *
+ *
*
*
+ * With no arguments it must be invoked in parentheses. A bare
+ * `@url={{leaflet-tile-url}}` passes the helper by name, which Glimmer refuses
+ * with "A resolved helper cannot be passed as a named argument" and the whole
+ * template fails to render.
+ *
* Recomputes automatically when map settings load or change.
*/
export default class LeafletTileUrlHelper extends Helper {
diff --git a/addon/instance-initializers/register-fleetops-resource-descriptors.js b/addon/instance-initializers/register-fleetops-resource-descriptors.js
new file mode 100644
index 000000000..6420e12b6
--- /dev/null
+++ b/addon/instance-initializers/register-fleetops-resource-descriptors.js
@@ -0,0 +1,29 @@
+import { buildFleetOpsResourceDescriptors } from '../utils/resource-descriptors';
+
+/**
+ * Registers every FleetOps resource descriptor with the resource registry
+ * ember-ui provides. Runs with the engine instance, so the descriptors'
+ * open actions reach the engine's action services and router. A host whose
+ * ember-ui predates the registry has no `resource-registry` service, and
+ * the identity components simply fall back to text.
+ */
+export function initialize(engineInstance) {
+ let registry;
+
+ try {
+ registry = engineInstance.lookup('service:resource-registry');
+ } catch {
+ registry = null;
+ }
+
+ if (!registry || typeof registry.registerDescriptors !== 'function') {
+ return;
+ }
+
+ registry.registerDescriptors(buildFleetOpsResourceDescriptors(engineInstance));
+}
+
+export default {
+ name: 'register-fleetops-resource-descriptors',
+ initialize,
+};
diff --git a/addon/modifiers/inspection-flyout.js b/addon/modifiers/inspection-flyout.js
new file mode 100644
index 000000000..b234dd83f
--- /dev/null
+++ b/addon/modifiers/inspection-flyout.js
@@ -0,0 +1,237 @@
+import { modifier } from 'ember-modifier';
+
+/** Room left between the flyout and its field, and between it and the sheet's edge. */
+const GAP = 8;
+const EDGE = 8;
+
+/** The nearest ancestor that actually scrolls, or null for the window. */
+function scrollParentOf(element) {
+ let node = element?.parentElement;
+
+ while (node && node !== document.body) {
+ const { overflowY } = getComputedStyle(node);
+
+ if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight) {
+ return node;
+ }
+
+ node = node.parentElement;
+ }
+
+ return null;
+}
+
+/**
+ * Place a floating flyout against its field.
+ *
+ * Both live inside the same sheet, so they scroll together and nothing has to
+ * follow the scroll. The flyout goes below the field unless only the visible
+ * space above it can hold it, is kept inside the sheet horizontally, and
+ * points its caret at the field's Fail button.
+ */
+export function placeFlyout(element, anchor, container) {
+ const containerRect = container.getBoundingClientRect();
+ const anchorRect = anchor.getBoundingClientRect();
+ const width = element.offsetWidth;
+ const height = element.offsetHeight;
+
+ const scroller = scrollParentOf(anchor);
+ const viewTop = scroller ? scroller.getBoundingClientRect().top : 0;
+ const viewBottom = scroller ? scroller.getBoundingClientRect().bottom : window.innerHeight;
+
+ const fitsBelow = viewBottom - anchorRect.bottom >= height + GAP;
+ const fitsAbove = anchorRect.top - viewTop >= height + GAP;
+
+ // Below, unless only the space above can hold it. Whatever hangs below a
+ // field can always be scrolled to — an absolutely placed panel extends
+ // the scroll area — but a panel pushed above the start of the sheet
+ // cannot be reached at all, so "more room above" is not reason enough.
+ const below = fitsBelow || !fitsAbove;
+
+ const top = below ? anchorRect.bottom - containerRect.top + GAP : anchorRect.top - containerRect.top - height - GAP;
+
+ const maxLeft = Math.max(EDGE, container.clientWidth - width - EDGE);
+ const left = Math.max(EDGE, Math.min(anchorRect.left - containerRect.left, maxLeft));
+
+ element.style.top = `${Math.round(top)}px`;
+ element.style.left = `${Math.round(left)}px`;
+ element.dataset.placement = below ? 'bottom' : 'top';
+
+ const pointAt = anchor.querySelector('[data-answer="fail"]') ?? anchor;
+ const pointRect = pointAt.getBoundingClientRect();
+ const caret = pointRect.left + pointRect.width / 2 - containerRect.left - left;
+
+ element.style.setProperty('--flyout-caret-x', `${Math.round(Math.max(16, Math.min(caret, width - 16)))}px`);
+
+ // Where it now sits, worked out from layout rather than read off its
+ // rendered box: the open animation is still translating it, and a reveal
+ // measured from the moving box stops exactly that many pixels short.
+ const flyoutTop = containerRect.top + top;
+
+ return { placement: below ? 'bottom' : 'top', flyoutTop, flyoutBottom: flyoutTop + height, viewTop, viewBottom, scroller };
+}
+
+/**
+ * Scroll just enough to bring a newly opened flyout fully into view, keeping
+ * a small margin from the edge. If it is taller than the view, its top — the
+ * title, and the first thing to answer — is what stays in view. Returns how
+ * far it scrolled.
+ */
+export function revealFlyout({ flyoutTop, flyoutBottom, viewTop, viewBottom, scroller }) {
+ let delta = 0;
+
+ if (flyoutBottom + EDGE > viewBottom) {
+ delta = flyoutBottom + EDGE - viewBottom;
+ }
+
+ if (flyoutTop - delta - EDGE < viewTop) {
+ delta = flyoutTop - EDGE - viewTop;
+ }
+
+ if (Math.abs(delta) < 1) {
+ return 0;
+ }
+
+ const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ (scroller ?? window).scrollBy({ top: delta, behavior: reduce ? 'auto' : 'smooth' });
+
+ return delta;
+}
+
+/**
+ * Run something once the flyout's opening animation has finished.
+ *
+ * The animation translates the panel, and a translated box changes the
+ * scrollable area: a reveal worked out mid-animation is clamped against a
+ * scroll area that is briefly too short, and lands short by exactly the
+ * animated offset. Without an animation — reduced motion — it runs at once.
+ * Returns a timer to clear on teardown.
+ */
+function afterOpening(element, callback) {
+ const style = getComputedStyle(element);
+ const seconds = parseFloat(style.animationDuration) || 0;
+
+ if (style.animationName === 'none' || seconds === 0) {
+ callback();
+ return null;
+ }
+
+ let done = false;
+ const finish = () => {
+ if (done) {
+ return;
+ }
+
+ done = true;
+ element.removeEventListener('animationend', finish);
+ callback();
+ };
+
+ element.addEventListener('animationend', finish);
+
+ // In case the event never comes: a hidden tab, an interrupted animation.
+ return setTimeout(finish, seconds * 1000 + 50);
+}
+
+/**
+ * Keep a defect flyout attached to its field, and close it the natural way.
+ *
+ *
+ *
+ * It closes on a press outside itself and outside its own field, and on
+ * Escape. It deliberately does not close on blur or on scroll: focus leaves
+ * the page for the native photo picker, and scrolling moves the flyout with
+ * its field anyway. Nothing is lost by closing, because every answer is saved
+ * as it is typed.
+ *
+ * A bottom sheet is placed by its stylesheet, so it only takes the
+ * dismissal half of this.
+ */
+export default modifier(function inspectionFlyout(element, [anchor], { presentation = 'floating', onDismiss, focus } = {}) {
+ if (!(anchor instanceof Element)) {
+ return;
+ }
+
+ const container = anchor.closest('.inspection-sheet') ?? document.body;
+ const floating = presentation === 'floating';
+ let frame = null;
+ let revealed = false;
+ let revealTimer = null;
+
+ const place = () => {
+ if (!floating || !element.isConnected || !anchor.isConnected) {
+ return;
+ }
+
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(() => {
+ placeFlyout(element, anchor, container);
+
+ // A panel that opens half off the page is not natural. Bring it
+ // into view once, by the least scroll that will do it, as soon as
+ // it has finished opening; after that the inspector is in charge
+ // of the scrolling.
+ if (!revealed) {
+ revealed = true;
+ revealTimer = afterOpening(element, () => {
+ if (element.isConnected && anchor.isConnected) {
+ revealFlyout(placeFlyout(element, anchor, container));
+ }
+ });
+ }
+ });
+ };
+
+ const dismiss = (reason) => {
+ if (typeof onDismiss === 'function') {
+ onDismiss(reason);
+ }
+ };
+
+ // A press anywhere but the flyout or its own field closes it. Pointerdown,
+ // not click, so pressing another field's Fail closes this one first.
+ const onPointerDown = (event) => {
+ const target = event.target;
+
+ if (element.contains(target) || anchor.contains(target)) {
+ return;
+ }
+
+ dismiss('outside');
+ };
+
+ const onKeyDown = (event) => {
+ if (event.key === 'Escape') {
+ event.stopPropagation();
+ dismiss('escape');
+ }
+ };
+
+ document.addEventListener('pointerdown', onPointerDown, true);
+ document.addEventListener('keydown', onKeyDown, true);
+ window.addEventListener('resize', place);
+
+ // Re-place when anything around it changes height: a field above it
+ // gaining a line, or the flyout itself growing as a photo is added.
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(place) : null;
+ observer?.observe(container);
+ observer?.observe(element);
+
+ place();
+
+ if (focus) {
+ requestAnimationFrame(() => {
+ const target = focus === true ? element : element.querySelector(focus);
+ target?.focus({ preventScroll: true });
+ });
+ }
+
+ return () => {
+ cancelAnimationFrame(frame);
+ clearTimeout(revealTimer);
+ document.removeEventListener('pointerdown', onPointerDown, true);
+ document.removeEventListener('keydown', onKeyDown, true);
+ window.removeEventListener('resize', place);
+ observer?.disconnect();
+ };
+});
diff --git a/addon/modifiers/radar-keyboard.js b/addon/modifiers/radar-keyboard.js
new file mode 100644
index 000000000..f34a6e150
--- /dev/null
+++ b/addon/modifiers/radar-keyboard.js
@@ -0,0 +1,78 @@
+import { modifier } from 'ember-modifier';
+
+/**
+ * The Radar keyboard layer: j/k move, x selects, e acknowledges, s snoozes,
+ * a assigns, Enter opens the record, Escape closes what is open.
+ *
+ * Keys are ignored while the user is typing in a field or a modal or
+ * overlay is open, so the list never steals a keystroke from a form.
+ * `handlers` is a hash of key => function; a missing key is ignored.
+ */
+const KEYS = {
+ j: 'next',
+ k: 'previous',
+ ArrowDown: 'next',
+ ArrowUp: 'previous',
+ x: 'select',
+ e: 'acknowledge',
+ s: 'snooze',
+ a: 'assign',
+ Enter: 'open',
+ Escape: 'close',
+ '/': 'search',
+};
+
+export function isTypingTarget(target) {
+ if (!target || typeof target.closest !== 'function') {
+ return false;
+ }
+
+ if (target.isContentEditable) {
+ return true;
+ }
+
+ const tag = (target.tagName || '').toLowerCase();
+ if (['input', 'textarea', 'select'].includes(tag)) {
+ return true;
+ }
+
+ return Boolean(target.closest('.ember-basic-dropdown-content, .modal, .next-content-overlay-panel, [role="dialog"], .ember-power-select-dropdown'));
+}
+
+export default modifier(function radarKeyboard(element, [handlers = {}], { enabled = true } = {}) {
+ const onKeydown = (event) => {
+ if (!enabled || event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) {
+ return;
+ }
+
+ // A letter arrives as typed; the map is lowercase.
+ const key = typeof event.key === 'string' && event.key.length === 1 ? event.key.toLowerCase() : event.key;
+ const name = KEYS[key];
+ if (!name) {
+ return;
+ }
+
+ // "/" focuses the search even from a field; every other key only
+ // fires when the user is not typing.
+ if (name !== 'search' && isTypingTarget(event.target)) {
+ return;
+ }
+ if (name === 'search' && isTypingTarget(event.target)) {
+ return;
+ }
+
+ const handler = handlers[name];
+ if (typeof handler !== 'function') {
+ return;
+ }
+
+ event.preventDefault();
+ handler(event);
+ };
+
+ document.addEventListener('keydown', onKeydown);
+
+ return () => {
+ document.removeEventListener('keydown', onKeydown);
+ };
+});
diff --git a/addon/modifiers/sync-value.js b/addon/modifiers/sync-value.js
new file mode 100644
index 000000000..0618c0cf5
--- /dev/null
+++ b/addon/modifiers/sync-value.js
@@ -0,0 +1,26 @@
+import { modifier } from 'ember-modifier';
+
+/**
+ * Keep an input's value in step with a bound one, without taking the caret.
+ *
+ * Binding `value={{@value}}` on an input whose every keystroke re-renders the
+ * component sends the caret to the end mid-word, which is what made the form
+ * builder's group inputs unusable. Leaving the value unbound instead means an
+ * input never shows a value that arrives after it was rendered — the stored
+ * answers an inspection loads a moment after the sheet appears.
+ *
+ * So write the value in, and only while the field is not being typed in.
+ *
+ *
+ */
+export default modifier(function syncValue(element, [value]) {
+ if (element.ownerDocument?.activeElement === element) {
+ return;
+ }
+
+ const next = value === null || value === undefined ? '' : String(value);
+
+ if (element.value !== next) {
+ element.value = next;
+ }
+});
diff --git a/addon/modifiers/when-changed.js b/addon/modifiers/when-changed.js
new file mode 100644
index 000000000..a3b50458b
--- /dev/null
+++ b/addon/modifiers/when-changed.js
@@ -0,0 +1,25 @@
+import { modifier } from 'ember-modifier';
+
+/** No value has been seen for this element yet — distinct from `undefined`. */
+const NEVER = Symbol('never');
+const seen = new WeakMap();
+
+/**
+ * Run something when a value changes, but not when it first appears.
+ *
+ * `{{did-update}}` does this and is deprecated for it. The distinction it does
+ * not make, and this does, is between the first render and a later change: a
+ * component that already loads its own data on construction must not load it
+ * again the moment it is inserted.
+ *
+ *
+ */
+export default modifier(function whenChanged(element, [value, callback]) {
+ const previous = seen.has(element) ? seen.get(element) : NEVER;
+
+ seen.set(element, value);
+
+ if (previous !== NEVER && previous !== value && typeof callback === 'function') {
+ callback(value);
+ }
+});
diff --git a/addon/routes.js b/addon/routes.js
index a60c70b1a..3454717b5 100644
--- a/addon/routes.js
+++ b/addon/routes.js
@@ -90,6 +90,7 @@ export default buildRoutes(function () {
this.route('schedules');
this.route('work-orders');
this.route('maintenance-history');
+ this.route('inspections');
this.route('virtual', { path: '/:slug' });
});
this.route('edit', { path: '/edit/:public_id' });
@@ -227,6 +228,29 @@ export default buildRoutes(function () {
this.route('tracking');
});
this.route('maintenance', function () {
+ this.route('inspection-forms', function () {
+ this.route('index', { path: '/' }, function () {
+ this.route('new');
+ this.route('edit', { path: '/edit/:public_id' });
+ this.route('details', { path: '/:public_id' }, function () {
+ this.route('index', { path: '/' });
+ this.route('submissions');
+ });
+ });
+ });
+
+ this.route('inspection-submissions', function () {
+ this.route('index', { path: '/' }, function () {
+ this.route('new');
+ this.route('edit', { path: '/edit/:public_id' });
+ this.route('details', { path: '/:public_id' }, function () {
+ this.route('index', { path: '/' });
+ this.route('photos');
+ this.route('audit');
+ });
+ });
+ });
+
this.route('schedules', function () {
this.route('index', { path: '/' }, function () {
this.route('new');
diff --git a/addon/routes/connectivity/fuel-providers/details.js b/addon/routes/connectivity/fuel-providers/details.js
index d8e321974..04be2351a 100644
--- a/addon/routes/connectivity/fuel-providers/details.js
+++ b/addon/routes/connectivity/fuel-providers/details.js
@@ -7,12 +7,25 @@ export default class ConnectivityFuelProvidersIndexDetailsRoute extends Route {
@service notifications;
@service hostRouter;
+ setupController(controller, model) {
+ super.setupController(controller, model);
+ controller.activity = null;
+ controller.activityError = null;
+ controller.watchActivity.perform();
+ }
+
+ deactivate() {
+ this.controller.watchActivity.cancelAll();
+ }
+
@action error(error) {
this.notifications.serverError(error);
- return this.hostRouter.transitionTo('console.fleet-ops.connectivity.fuel-providers.index');
+ if (error?.errors?.some((entry) => entry.status === '404')) return this.hostRouter.transitionTo('console.fleet-ops.connectivity.fuel-providers.index');
+ return false;
}
model({ public_id }) {
- return this.store.findRecord('fuel-provider-connection', public_id);
+ // URLs use public IDs; the store's primary key is the API UUID.
+ return this.store.queryRecord('fuel-provider-connection', { public_id, single: true });
}
}
diff --git a/addon/routes/connectivity/fuel-providers/details/settings.js b/addon/routes/connectivity/fuel-providers/details/settings.js
index a3f0dc733..c0e4405ef 100644
--- a/addon/routes/connectivity/fuel-providers/details/settings.js
+++ b/addon/routes/connectivity/fuel-providers/details/settings.js
@@ -1,7 +1,10 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
export default class ConnectivityFuelProvidersIndexDetailsSettingsRoute extends Route {
- model() {
- return this.modelFor('connectivity.fuel-providers.details');
+ @service hostRouter;
+
+ redirect() {
+ return this.hostRouter.replaceWith('console.fleet-ops.connectivity.fuel-providers.edit', this.modelFor('connectivity.fuel-providers.details'));
}
}
diff --git a/addon/routes/connectivity/fuel-providers/details/sync.js b/addon/routes/connectivity/fuel-providers/details/sync.js
index a3ee134fa..23ace9862 100644
--- a/addon/routes/connectivity/fuel-providers/details/sync.js
+++ b/addon/routes/connectivity/fuel-providers/details/sync.js
@@ -1,18 +1,11 @@
import Route from '@ember/routing/route';
-import { inject as service } from '@ember/service';
-
-export default class ConnectivityFuelProvidersIndexDetailsSyncRoute extends Route {
- @service store;
+export default class FuelIntegrationSyncRoute extends Route {
+ setupController(controller, model) {
+ super.setupController(controller, model);
+ controller.resetDates();
+ }
model() {
- const connection = this.modelFor('connectivity.fuel-providers.details');
-
- return {
- connection,
- syncRuns: this.store.query('fuel-provider-sync-run', {
- connection: connection.uuid,
- sort: '-created_at',
- }),
- };
+ return this.modelFor('connectivity.fuel-providers.details');
}
}
diff --git a/addon/routes/connectivity/fuel-providers/details/transactions.js b/addon/routes/connectivity/fuel-providers/details/transactions.js
index d673145ad..6a457461f 100644
--- a/addon/routes/connectivity/fuel-providers/details/transactions.js
+++ b/addon/routes/connectivity/fuel-providers/details/transactions.js
@@ -1,14 +1,15 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
-export default class ConnectivityFuelProvidersIndexDetailsTransactionsRoute extends Route {
+export default class FuelIntegrationTransactionsRoute extends Route {
@service store;
-
- model() {
+ queryParams = Object.fromEntries(['page', 'limit', 'sort', 'query', 'sync_status', 'vehicle', 'transaction_at'].map((key) => [key, { refreshModel: true }]));
+ model(params) {
const connection = this.modelFor('connectivity.fuel-providers.details');
- return this.store.query('fuel-provider-transaction', {
- connection: connection.uuid,
- sort: '-transaction_at',
- });
+ return this.store.query('fuel-provider-transaction', { ...params, connection: connection.id });
+ }
+ @action refreshFuelTransactions() {
+ this.refresh();
}
}
diff --git a/addon/routes/connectivity/fuel-providers/edit.js b/addon/routes/connectivity/fuel-providers/edit.js
index 8af74d06d..8d72929c4 100644
--- a/addon/routes/connectivity/fuel-providers/edit.js
+++ b/addon/routes/connectivity/fuel-providers/edit.js
@@ -13,6 +13,7 @@ export default class ConnectivityFuelProvidersIndexEditRoute extends Route {
}
model({ public_id }) {
- return this.store.findRecord('fuel-provider-connection', public_id);
+ // URLs use public IDs; the store's primary key is the API UUID.
+ return this.store.queryRecord('fuel-provider-connection', { public_id, single: true });
}
}
diff --git a/addon/routes/connectivity/fuel-providers/index.js b/addon/routes/connectivity/fuel-providers/index.js
index d92b8e42a..6ecf77e86 100644
--- a/addon/routes/connectivity/fuel-providers/index.js
+++ b/addon/routes/connectivity/fuel-providers/index.js
@@ -1,4 +1,5 @@
import Route from '@ember/routing/route';
+import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class ConnectivityFuelProvidersIndexRoute extends Route {
@@ -14,6 +15,10 @@ export default class ConnectivityFuelProvidersIndexRoute extends Route {
environment: { refreshModel: true },
};
+ @action refreshFuelConnections() {
+ this.refresh();
+ }
+
model(params) {
return this.store.query('fuel-provider-connection', { sort: '-updated_at', ...params });
}
diff --git a/addon/routes/maintenance/inspection-forms.js b/addon/routes/maintenance/inspection-forms.js
new file mode 100644
index 000000000..97a8c467a
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionFormsRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-forms/index.js b/addon/routes/maintenance/inspection-forms/index.js
new file mode 100644
index 000000000..3ddda8c95
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index.js
@@ -0,0 +1,22 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class MaintenanceInspectionFormsIndexRoute extends Route {
+ @service store;
+
+ queryParams = {
+ page: { refreshModel: true },
+ limit: { refreshModel: true },
+ sort: { refreshModel: true },
+ query: { refreshModel: true },
+ public_id: { refreshModel: true },
+ status: { refreshModel: true },
+ type: { refreshModel: true },
+ created_at: { refreshModel: true },
+ updated_at: { refreshModel: true },
+ };
+
+ model(params) {
+ return this.store.query('inspection-form', { ...params });
+ }
+}
diff --git a/addon/routes/maintenance/inspection-forms/index/details.js b/addon/routes/maintenance/inspection-forms/index/details.js
new file mode 100644
index 000000000..202e83766
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/details.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionFormsIndexDetailsRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-form', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-forms.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-forms/index/details/index.js b/addon/routes/maintenance/inspection-forms/index/details/index.js
new file mode 100644
index 000000000..b8b2de553
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/details/index.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionFormsIndexDetailsIndexRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-forms/index/details/submissions.js b/addon/routes/maintenance/inspection-forms/index/details/submissions.js
new file mode 100644
index 000000000..a7b227c3a
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/details/submissions.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionFormsIndexDetailsSubmissionsRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-forms/index/edit.js b/addon/routes/maintenance/inspection-forms/index/edit.js
new file mode 100644
index 000000000..f8b15d6dd
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/edit.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionFormsIndexEditRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-form', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-forms.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-forms/index/new.js b/addon/routes/maintenance/inspection-forms/index/new.js
new file mode 100644
index 000000000..96a813892
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/new.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionFormsIndexNewRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-submissions.js b/addon/routes/maintenance/inspection-submissions.js
new file mode 100644
index 000000000..73b235dce
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-submissions/index.js b/addon/routes/maintenance/inspection-submissions/index.js
new file mode 100644
index 000000000..48c87642b
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index.js
@@ -0,0 +1,25 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class MaintenanceInspectionSubmissionsIndexRoute extends Route {
+ @service store;
+
+ queryParams = {
+ page: { refreshModel: true },
+ limit: { refreshModel: true },
+ sort: { refreshModel: true },
+ query: { refreshModel: true },
+ public_id: { refreshModel: true },
+ status: { refreshModel: true },
+ result: { refreshModel: true },
+ type: { refreshModel: true },
+ vehicle: { refreshModel: true },
+ driver: { refreshModel: true },
+ created_at: { refreshModel: true },
+ updated_at: { refreshModel: true },
+ };
+
+ model(params) {
+ return this.store.query('inspection-submission', { ...params });
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details.js b/addon/routes/maintenance/inspection-submissions/index/details.js
new file mode 100644
index 000000000..ebb148013
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-submission', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-submissions.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details/audit.js b/addon/routes/maintenance/inspection-submissions/index/details/audit.js
new file mode 100644
index 000000000..63707bc25
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details/audit.js
@@ -0,0 +1,8 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsAuditRoute extends Route {
+ /** The tab renders the submission the record panel is showing. */
+ model() {
+ return this.modelFor('maintenance.inspection-submissions.index.details');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details/index.js b/addon/routes/maintenance/inspection-submissions/index/details/index.js
new file mode 100644
index 000000000..5ade01403
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details/index.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsIndexRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details/photos.js b/addon/routes/maintenance/inspection-submissions/index/details/photos.js
new file mode 100644
index 000000000..a493628b0
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details/photos.js
@@ -0,0 +1,8 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsPhotosRoute extends Route {
+ /** The tab renders the submission the record panel is showing. */
+ model() {
+ return this.modelFor('maintenance.inspection-submissions.index.details');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/edit.js b/addon/routes/maintenance/inspection-submissions/index/edit.js
new file mode 100644
index 000000000..9e6f0bb2f
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/edit.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionSubmissionsIndexEditRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-submission', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-submissions.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/new.js b/addon/routes/maintenance/inspection-submissions/index/new.js
new file mode 100644
index 000000000..f5c57fe09
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/new.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexNewRoute extends Route {}
diff --git a/addon/routes/management/fuel-transactions/index.js b/addon/routes/management/fuel-transactions/index.js
index 351fa7052..011a6c4e0 100644
--- a/addon/routes/management/fuel-transactions/index.js
+++ b/addon/routes/management/fuel-transactions/index.js
@@ -1,4 +1,5 @@
import Route from '@ember/routing/route';
+import { action } from '@ember/object';
import { inject as service } from '@ember/service';
export default class ManagementFuelTransactionsIndexRoute extends Route {
@@ -12,9 +13,14 @@ export default class ManagementFuelTransactionsIndexRoute extends Route {
provider: { refreshModel: true },
sync_status: { refreshModel: true },
vehicle: { refreshModel: true },
+ connection: { refreshModel: true },
transaction_at: { refreshModel: true },
};
+ @action refreshTransactions() {
+ this.refresh();
+ }
+
model(params) {
return this.store.query('fuel-provider-transaction', { sort: '-transaction_at', ...params });
}
diff --git a/addon/routes/management/fuel-transactions/index/details.js b/addon/routes/management/fuel-transactions/index/details.js
index 59db9f7da..05d202d42 100644
--- a/addon/routes/management/fuel-transactions/index/details.js
+++ b/addon/routes/management/fuel-transactions/index/details.js
@@ -1,6 +1,7 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
+import { loadTransactionOrder } from '../../../../utils/fuel-transaction';
export default class ManagementFuelTransactionsIndexDetailsRoute extends Route {
@service store;
@@ -12,7 +13,16 @@ export default class ManagementFuelTransactionsIndexDetailsRoute extends Route {
return this.hostRouter.transitionTo('console.fleet-ops.management.fuel-transactions.index');
}
- model({ public_id }) {
- return this.store.findRecord('fuel-provider-transaction', public_id);
+ async model({ public_id }) {
+ // URLs use public IDs; the store's primary key is the API UUID.
+ const transaction = await this.store.queryRecord('fuel-provider-transaction', { public_id, single: true });
+
+ try {
+ await loadTransactionOrder(this.store, transaction);
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+
+ return transaction;
}
}
diff --git a/addon/routes/management/index.js b/addon/routes/management/index.js
index 234976b16..0a4dcd62b 100644
--- a/addon/routes/management/index.js
+++ b/addon/routes/management/index.js
@@ -1,3 +1,19 @@
import Route from '@ember/routing/route';
-export default class ManagementIndexRoute extends Route {}
+export default class ManagementIndexRoute extends Route {
+ queryParams = {
+ view: { refreshModel: false },
+ status: { refreshModel: false },
+ filters: { refreshModel: false },
+ fleet: { refreshModel: false },
+ q: { refreshModel: false },
+ saved: { refreshModel: false },
+ assigned: { refreshModel: false },
+ window: { refreshModel: false },
+ };
+
+ setupController(controller, model, transition) {
+ super.setupController(controller, model, transition);
+ controller.reload.perform();
+ }
+}
diff --git a/addon/routes/management/vehicles/index.js b/addon/routes/management/vehicles/index.js
index 695bf461f..104eda46a 100644
--- a/addon/routes/management/vehicles/index.js
+++ b/addon/routes/management/vehicles/index.js
@@ -35,7 +35,7 @@ export default class ManagementVehiclesIndexRoute extends Route {
};
model(params) {
- // Current trailers back the card view's towing line; the internal API camelizes `with`.
- return this.store.query('vehicle', { ...params, with: ['currentTrailers'] });
+ // Include the related records rendered by the vehicle table and cards.
+ return this.store.query('vehicle', { ...params, with: ['currentTrailers', 'devices'] });
}
}
diff --git a/addon/routes/management/vehicles/index/details/inspections.js b/addon/routes/management/vehicles/index/details/inspections.js
new file mode 100644
index 000000000..9a9884c57
--- /dev/null
+++ b/addon/routes/management/vehicles/index/details/inspections.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class ManagementVehiclesIndexDetailsInspectionsRoute extends Route {}
diff --git a/addon/services/contact-actions.js b/addon/services/contact-actions.js
index 4e5ca7a21..b76d2b3e7 100644
--- a/addon/services/contact-actions.js
+++ b/addon/services/contact-actions.js
@@ -1,6 +1,7 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { inject as service } from '@ember/service';
import { action, get } from '@ember/object';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
const INTERNAL_NAMESPACE = 'int/v1';
@@ -9,6 +10,7 @@ export default class ContactActionsService extends ResourceActionService {
@service placeActions;
@service notifications;
@service hostRouter;
+ @service('universe/menu-service') menuService;
@service('universe/extension-manager') extensionManager;
constructor() {
@@ -48,12 +50,14 @@ export default class ContactActionsService extends ResourceActionService {
view: (contact, options = {}) => {
return this.resourceContextPanel.open({
contact,
+ title: contact?.name,
+ header: 'contact/panel-header',
+ actionButtons: this.panelActionButtons(contact),
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'contact/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'contact/details' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:contact:details'),
],
+ ...PANEL_DEFAULTS,
...options,
});
},
@@ -137,6 +141,20 @@ export default class ContactActionsService extends ResourceActionService {
return this.extensionManager.isInstalled('@fleetbase/customer-portal-engine');
}
+ /**
+ * The header buttons of a contact or customer panel, as on the details
+ * route: edit, then the portal account menu when a login is linked.
+ */
+ panelActionButtons(contact) {
+ const buttons = [{ icon: 'pencil', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(contact)) }];
+ const accountActionButton = this.accountActionButton(contact);
+ if (accountActionButton) {
+ buttons.push(accountActionButton);
+ }
+
+ return buttons;
+ }
+
accountActionButton(contact, options = {}) {
if (!this.hasLinkedUser(contact)) {
return null;
diff --git a/addon/services/customer-actions.js b/addon/services/customer-actions.js
index 11c3f95f0..01756a80a 100644
--- a/addon/services/customer-actions.js
+++ b/addon/services/customer-actions.js
@@ -1,4 +1,5 @@
import ContactActionsService from './contact-actions';
+import { PANEL_DEFAULTS, registeredPanelTabs } from '../utils/context-panel';
export default class CustomerActionsService extends ContactActionsService {
constructor() {
@@ -38,12 +39,14 @@ export default class CustomerActionsService extends ContactActionsService {
view: (customer, options = {}) => {
return this.resourceContextPanel.open({
customer,
+ title: customer?.name,
+ header: 'contact/panel-header',
+ actionButtons: this.panelActionButtons(customer),
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'customer/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'customer/details' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:customer:details'),
],
+ ...PANEL_DEFAULTS,
...options,
});
},
diff --git a/addon/services/device-actions.js b/addon/services/device-actions.js
index 0c8fd228e..54abcd788 100644
--- a/addon/services/device-actions.js
+++ b/addon/services/device-actions.js
@@ -1,6 +1,7 @@
import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class DeviceActionsService extends ResourceActionService {
@service fetch;
@@ -44,10 +45,24 @@ export default class DeviceActionsService extends ResourceActionService {
}
const registeredTabs = this.menuService?.getMenuItems?.('fleet-ops:component:device:details');
+ const service = this;
return this.resourceContextPanel.open({
device,
header: 'device/panel-header',
+ title: device.name ?? device.serial_number,
+ actionButtons: [
+ { icon: 'pencil', permission: 'fleet-ops update device', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(device)) },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ get items() {
+ return service.attachmentItems(device);
+ },
+ },
+ ],
+ ...PANEL_DEFAULTS,
tabs: [
{
key: 'overview',
@@ -181,4 +196,17 @@ export default class DeviceActionsService extends ResourceActionService {
@action detachFromVehicle(device, options = {}) {
return this.detachFromAsset(device, options);
}
+
+ /**
+ * The attach and detach menu of a device's header, shared by the details
+ * route and the context panel. Detach only shows while it is attached.
+ */
+ attachmentItems(device) {
+ const attached = Boolean(device?.attachable_uuid || device?.attached_to_name || device?.attachable);
+
+ return [
+ { text: this.intl.t('device.actions.attach-to-asset'), icon: 'link', fn: () => this.attachToVehicle(device), permission: 'fleet-ops update device' },
+ ...(attached ? [{ text: this.intl.t('device.attachment.detach'), icon: 'unlink', fn: () => this.detachFromVehicle(device), permission: 'fleet-ops update device' }] : []),
+ ];
+ }
}
diff --git a/addon/services/driver-actions.js b/addon/services/driver-actions.js
index 7cb0d0042..5252f64cf 100644
--- a/addon/services/driver-actions.js
+++ b/addon/services/driver-actions.js
@@ -4,10 +4,12 @@ import config from 'ember-get-config';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { dasherize } from '@ember/string';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class DriverActionsService extends ResourceActionService {
@service('universe/menu-service') menuService;
@service fetch;
+ @service issueActions;
get registeredTabs() {
const registeredTabs = this.menuService.getMenuItems('fleet-ops:component:driver:details');
@@ -31,7 +33,7 @@ export default class DriverActionsService extends ResourceActionService {
},
{
key: 'positions',
- label: 'Positions',
+ label: this.intl.t('common.positions'),
component: 'positions-replay',
},
{
@@ -39,6 +41,11 @@ export default class DriverActionsService extends ResourceActionService {
label: this.intl.t('common.schedule'),
component: 'driver/schedule',
},
+ {
+ key: 'activity',
+ label: this.intl.t('common.activity'),
+ component: 'resource-activity',
+ },
...this.registeredTabs,
];
}
@@ -48,6 +55,30 @@ export default class DriverActionsService extends ResourceActionService {
this.initialize('driver');
}
+ /**
+ * Accepts a driver record, a promise (or proxy) of one, or an identity
+ * stub with loadResource(), and returns the record.
+ */
+ async resolveDriverResource(driver) {
+ if (typeof driver?.then === 'function') {
+ return await driver;
+ }
+
+ if (typeof driver?.loadResource === 'function') {
+ return (await driver.loadResource()) ?? driver;
+ }
+
+ return driver;
+ }
+
+ async reloadIndexResource(driver) {
+ if (driver?.meta?._index_resource) {
+ await driver.reload();
+ }
+
+ return driver;
+ }
+
transition = {
view: (driver) => this.transitionTo('management.drivers.index.details', driver),
edit: (driver) => this.transitionTo('management.drivers.index.edit', driver),
@@ -69,9 +100,7 @@ export default class DriverActionsService extends ResourceActionService {
});
},
edit: async (driver, options = {}) => {
- if (driver?.meta?._index_resource) {
- await driver.reload();
- }
+ driver = await this.reloadIndexResource(await this.resolveDriverResource(driver));
return this.resourceContextPanel.open({
content: 'driver/form',
@@ -91,9 +120,8 @@ export default class DriverActionsService extends ResourceActionService {
});
},
view: async (driver, options = {}) => {
- if (driver?.meta?._index_resource) {
- await driver.reload();
- }
+ driver = await this.reloadIndexResource(await this.resolveDriverResource(driver));
+ const service = this;
return this.resourceContextPanel.open({
driver,
@@ -101,13 +129,20 @@ export default class DriverActionsService extends ResourceActionService {
actionButtons: [
{
icon: 'pencil',
- fn: async () => {
- await this.resourceContextPanel.closeAll();
- this.panel.edit(driver);
+ permission: 'fleet-ops update driver',
+ fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(driver)),
+ },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ get items() {
+ return service.detailsMenuItems(driver, { onDeleted: () => service.resourceContextPanel.closeAll() });
},
},
],
tabs: this.panelTabs,
+ ...PANEL_DEFAULTS,
...options,
});
},
@@ -126,9 +161,7 @@ export default class DriverActionsService extends ResourceActionService {
});
},
edit: async (driver, options = {}, saveOptions = {}) => {
- if (driver?.meta?._index_resource) {
- await driver.reload();
- }
+ driver = await this.reloadIndexResource(await this.resolveDriverResource(driver));
return this.modalsManager.show('modals/resource', {
resource: driver,
@@ -141,9 +174,7 @@ export default class DriverActionsService extends ResourceActionService {
});
},
view: async (driver, options = {}) => {
- if (driver?.meta?._index_resource) {
- await driver.reload();
- }
+ driver = await this.reloadIndexResource(await this.resolveDriverResource(driver));
return this.modalsManager.show('modals/resource', {
resource: driver,
@@ -369,4 +400,36 @@ export default class DriverActionsService extends ResourceActionService {
});
});
}
+
+ /**
+ * The actions menu of a driver's header, shared by the details route and
+ * the context panel. Unassign items only show when there is something to
+ * unassign.
+ */
+ detailsMenuItems(driver) {
+ const hasVehicle = Boolean(driver?.vehicle_uuid || driver?.vehicle?.id || driver?.vehicle?.uuid || driver?.vehicle_name);
+
+ return [
+ { text: this.intl.t('driver.actions.assign-order'), icon: 'clipboard-list', fn: () => this.assignOrder(driver), permission: 'fleet-ops assign-order-for driver' },
+ ...(Number(driver?.assigned_orders_count) > 0
+ ? [{ text: this.intl.t('driver.actions.unassign-orders'), icon: 'user-minus', fn: () => this.unassignOrders(driver), permission: 'fleet-ops assign-order-for driver' }]
+ : []),
+ { separator: true },
+ { text: this.intl.t('driver.actions.assign-vehicle'), icon: 'car', fn: () => this.assignVehicle(driver), permission: 'fleet-ops assign-vehicle-for driver' },
+ ...(hasVehicle
+ ? [{ text: this.intl.t('driver.actions.unassign-vehicle'), icon: 'link-slash', fn: () => this.unassignVehicle(driver), permission: 'fleet-ops assign-vehicle-for driver' }]
+ : []),
+ { separator: true },
+ { text: this.intl.t('driver.actions.locate-driver'), icon: 'location-dot', fn: () => this.locate(driver), permission: 'fleet-ops view driver' },
+ { text: this.intl.t('driver.actions.create-issue'), icon: 'triangle-exclamation', fn: () => this.createIssue(driver), permission: 'fleet-ops create issue' },
+ ];
+ }
+
+ @action createIssue(driver) {
+ return this.issueActions.modal.create({
+ driver,
+ driver_uuid: driver.id,
+ title: this.intl.t('driver.prompts.issue-title', { driverName: driver.name }),
+ });
+ }
}
diff --git a/addon/services/equipment-actions.js b/addon/services/equipment-actions.js
index 9cc47f9de..bc8533f70 100644
--- a/addon/services/equipment-actions.js
+++ b/addon/services/equipment-actions.js
@@ -1,6 +1,9 @@
-import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class EquipmentActionsService extends ResourceActionService {
+ @service('universe/menu-service') menuService;
+
get defaultCurrency() {
return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
}
@@ -42,15 +45,21 @@ export default class EquipmentActionsService extends ResourceActionService {
equipment,
});
},
- view: (equipment) => {
+ view: (equipment, options = {}) => {
return this.resourceContextPanel.open({
equipment,
+ title: equipment?.name,
+ header: 'equipment/panel-header',
+ actionButtons: [
+ { icon: 'edit', permission: 'fleet-ops update equipment', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(equipment)) },
+ { icon: 'trash', type: 'danger', permission: 'fleet-ops delete equipment', fn: () => this.delete(equipment, { onConfirm: () => this.resourceContextPanel.closeAll() }) },
+ ],
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'equipment/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'equipment/details' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:equipment:details'),
],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/fleet-actions.js b/addon/services/fleet-actions.js
index af3e3f45a..716439bca 100644
--- a/addon/services/fleet-actions.js
+++ b/addon/services/fleet-actions.js
@@ -1,8 +1,10 @@
import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class FleetActionsService extends ResourceActionService {
@service fetch;
+ @service('universe/menu-service') menuService;
constructor() {
super(...arguments);
@@ -41,12 +43,18 @@ export default class FleetActionsService extends ResourceActionService {
view: (fleet, options = {}) => {
return this.resourceContextPanel.open({
fleet,
+ title: fleet?.name,
+ actionButtons: [
+ { icon: 'pencil', permission: 'fleet-ops update fleet', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(fleet)) },
+ { icon: 'ellipsis-h', iconPrefix: 'fas', renderInPlace: true, items: this.detailsMenuItems(fleet) },
+ ],
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'fleet/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'fleet/details' },
+ { key: 'vehicles', label: this.intl.t('menu.vehicles'), component: 'fleet/vehicle-listing' },
+ { key: 'drivers', label: this.intl.t('menu.drivers'), component: 'fleet/driver-listing' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:fleet:details'),
],
+ ...PANEL_DEFAULTS,
...options,
});
},
@@ -146,4 +154,12 @@ export default class FleetActionsService extends ResourceActionService {
...options,
});
}
+
+ /** The actions menu of a fleet's header, shared by the details route and the context panel. */
+ detailsMenuItems(fleet) {
+ return [
+ { text: this.intl.t('fleet.actions.assign-driver'), icon: 'user-plus', fn: () => this.assignDriver(fleet), permission: 'fleet-ops assign-driver-for fleet' },
+ { text: this.intl.t('fleet.actions.assign-vehicle'), icon: 'car', fn: () => this.assignVehicle(fleet), permission: 'fleet-ops assign-vehicle-for fleet' },
+ ];
+ }
}
diff --git a/addon/services/fuel-report-actions.js b/addon/services/fuel-report-actions.js
index 8ffa13913..1fce62ba1 100644
--- a/addon/services/fuel-report-actions.js
+++ b/addon/services/fuel-report-actions.js
@@ -1,6 +1,7 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class FuelReportActionsService extends ResourceActionService {
@service driverActions;
@@ -40,15 +41,14 @@ export default class FuelReportActionsService extends ResourceActionService {
fuelReport,
});
},
- view: (fuelReport) => {
+ view: (fuelReport, options = {}) => {
return this.resourceContextPanel.open({
fuelReport,
- tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'fuel-report/details',
- },
- ],
+ title: fuelReport?.name ?? `Fuel reported on ${fuelReport?.createdAt ?? ''}`.trim(),
+ actionButtons: [{ icon: 'pencil', permission: 'fleet-ops update fuel-report', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(fuelReport)) }],
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'fuel-report/details' }],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/inspection-form-actions.js b/addon/services/inspection-form-actions.js
new file mode 100644
index 000000000..6624a6823
--- /dev/null
+++ b/addon/services/inspection-form-actions.js
@@ -0,0 +1,236 @@
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { action, set } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard';
+import { normalizeFieldGroups, serializeFieldGroups } from '../utils/inspection-form-structure';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
+
+/** A link's life when nobody chooses; the server applies the same when left blank. */
+const DEFAULT_LINK_TTL_HOURS = 72;
+
+/** A date as a `datetime-local` input reads it: local time, to the minute. */
+export function toDatetimeLocal(date) {
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+/** The default expiry, filled into the form so the dispatcher can see it. */
+function defaultLinkExpiry() {
+ return toDatetimeLocal(new Date(Date.now() + DEFAULT_LINK_TTL_HOURS * 60 * 60 * 1000));
+}
+
+export default class InspectionFormActionsService extends ResourceActionService {
+ @service fetch;
+ @service notifications;
+ @service intl;
+
+ /**
+ * When a public link was last generated. Every open link list watches
+ * this — the one inside the generate modal and the one on the form's
+ * details panel — so a new link appears in both without a reload.
+ */
+ @tracked linksChangedAt = 0;
+
+ constructor() {
+ super(...arguments);
+ this.initialize('inspection-form', {
+ defaultAttributes: {
+ type: 'dvir',
+ status: 'draft',
+ items: [],
+ settings: {
+ require_signature: true,
+ create_issue_on_failure: true,
+ create_work_order_on_failure: false,
+ },
+ },
+ });
+ }
+
+ transition = {
+ view: (form) => this.transitionTo('maintenance.inspection-forms.index.details', form),
+ edit: (form) => this.transitionTo('maintenance.inspection-forms.index.edit', form),
+ create: () => this.transitionTo('maintenance.inspection-forms.index.new'),
+ };
+
+ panel = {
+ view: (form, options = {}) => {
+ const service = this;
+
+ return this.resourceContextPanel.open({
+ form,
+ title: form?.name,
+ get actionButtons() {
+ return service.headerActionButtons(form, {
+ onEdit: () => closePanelsThen(service.resourceContextPanel, () => service.transition.edit(form)),
+ onDeleted: () => service.resourceContextPanel.closeAll(),
+ });
+ },
+ tabs: [
+ { key: 'overview', label: this.intl.t('inspection.form.overview'), component: 'inspection-form/details' },
+ { key: 'submissions', label: this.intl.t('inspection.form.submissions'), component: 'inspection-form/details/submissions' },
+ ],
+ ...PANEL_DEFAULTS,
+ ...options,
+ });
+ },
+ };
+
+ /**
+ * The header buttons of a form, shared by the details route and the
+ * context panel: publish a draft or generate a link for a published
+ * form, then edit and delete.
+ */
+ headerActionButtons(form, { onEdit, onDeleted } = {}) {
+ const isPublished = form?.is_published === true || form?.status === 'published';
+
+ return [
+ ...(isPublished ? [] : [{ icon: 'check', fn: () => this.publish(form), text: 'Publish', type: 'success', permission: 'fleet-ops publish inspection-form' }]),
+ ...(isPublished ? [{ icon: 'link', fn: () => this.generateLink(form), text: 'Generate Link', permission: 'fleet-ops view inspection-form' }] : []),
+ { icon: 'edit', fn: () => (onEdit ? onEdit(form) : this.transition.edit(form)), permission: 'fleet-ops update inspection-form' },
+ { icon: 'trash', fn: () => this.delete(form, { onConfirm: onDeleted }), type: 'danger', permission: 'fleet-ops delete inspection-form' },
+ ];
+ }
+
+ /**
+ * A form's structure — its field groups and their fields.
+ *
+ * The `inspection-form` model belongs to `@fleetbase/fleetops-data` and
+ * declares no attribute for the structure, so Ember Data drops it on the
+ * way in and on the way out. Both directions go through the internal
+ * endpoint directly instead: a read carries `field_groups` beside a flat
+ * `fields` list, and a write posts the whole thing back under
+ * `inspection_form.field_groups`, which is the key
+ * `InspectionFormController::syncStructureFromRequest()` reads.
+ */
+ async loadStructure(form) {
+ if (!form?.id) {
+ return [];
+ }
+
+ const response = await this.fetch.get(`inspection-forms/${form.id}`);
+
+ return normalizeFieldGroups(response?.inspection_form ?? response?.inspectionForm ?? response);
+ }
+
+ /**
+ * Writes the whole structure. The builder always posts every group and
+ * every field, so the server prunes what the post no longer lists — a
+ * field the post dropped is a field the author deleted.
+ */
+ async saveStructure(form, groups) {
+ if (!form?.id || !Array.isArray(groups) || groups.length === 0) {
+ return null;
+ }
+
+ return this.fetch.put(`inspection-forms/${form.id}`, {
+ inspection_form: { field_groups: serializeFieldGroups(groups) },
+ });
+ }
+
+ @action async publish(form) {
+ try {
+ await this.fetch.post(`inspection-forms/${form.id}/publish`);
+ this.notifications.success('Inspection form published.');
+ await this.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action async archive(form) {
+ try {
+ await this.fetch.post(`inspection-forms/${form.id}/archive`);
+ this.notifications.success('Inspection form archived.');
+ await this.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ /**
+ * Say whether a PIN went out, when one was asked to be sent. A delivery
+ * that failed is a warning and not an error: the link exists either way,
+ * and its PIN is on screen to share by hand.
+ */
+ notifyPinDelivery(delivery) {
+ if (!delivery) {
+ this.notifications.success(this.intl.t('inspection.link.generated-toast'));
+ return;
+ }
+
+ if (delivery.sent) {
+ this.notifications.success(this.intl.t(`inspection.link.pin-sent-${delivery.via}`, { to: delivery.to }));
+ return;
+ }
+
+ this.notifications.warning(this.intl.t('inspection.link.pin-not-sent', { reason: delivery.error }));
+ }
+
+ @action generateLink(form) {
+ if (form.status !== 'published') {
+ this.notifications.warning(this.intl.t('inspection.link.publish-first'));
+ return;
+ }
+
+ // Who the link is for, and the driver and vehicle being inspected, are
+ // each optional: anyone in the organisation may complete an inspection.
+ const formState = {
+ assignee: null,
+ driver: null,
+ vehicle: null,
+ expires_at: defaultLinkExpiry(),
+ pin_delivery: 'none',
+ generated: null,
+ };
+
+ return this.modalsManager.show('modals/inspection-link', {
+ title: 'Generate Inspection Link',
+ acceptButtonText: 'Generate Link',
+ acceptButtonIcon: 'link',
+ declineButtonText: 'Close',
+ form,
+ formState,
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ const response = await this.fetch.post(`inspection-forms/${form.id}/generate-link`, {
+ assignee: formState.assignee?.id,
+ driver: formState.driver?.id,
+ vehicle: formState.vehicle?.id,
+ // The input holds local time with no zone; sent as it was,
+ // the server read it as its own zone and the link expired
+ // hours early or late. Sent as an instant, it means what
+ // the dispatcher picked.
+ expires_at: formState.expires_at ? new Date(formState.expires_at).toISOString() : null,
+ single_use: true,
+ pin_delivery: formState.pin_delivery,
+ });
+ const link = response?.link;
+ const url = link?.path ? `${window.location.origin}${link.path}` : null;
+
+ // Shown in the modal until it closes: the link, and the PIN to
+ // share with it by some other way.
+ set(formState, 'generated', { url, pin: link?.pin ?? null });
+
+ // Every open link list watches this and reloads, so the link
+ // that was just minted appears to be read, copied again or
+ // revoked — rather than living only in the clipboard.
+ this.linksChangedAt = Date.now();
+
+ if (url) {
+ await copyToClipboard(url);
+ }
+
+ this.notifyPinDelivery(response?.pin_delivery);
+
+ modal.stopLoading();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+}
diff --git a/addon/services/inspection-submission-actions.js b/addon/services/inspection-submission-actions.js
new file mode 100644
index 000000000..6e4597696
--- /dev/null
+++ b/addon/services/inspection-submission-actions.js
@@ -0,0 +1,273 @@
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
+
+export default class InspectionSubmissionActionsService extends ResourceActionService {
+ @service fetch;
+ @service notifications;
+ @service modalsManager;
+ @service intl;
+
+ constructor() {
+ super(...arguments);
+ this.initialize('inspection-submission', {
+ defaultAttributes: {
+ type: 'dvir',
+ status: 'draft',
+ source: 'console',
+ item_results: [],
+ },
+ });
+ }
+
+ transition = {
+ view: (submission) => this.transitionTo('maintenance.inspection-submissions.index.details', submission),
+ edit: (submission) => this.transitionTo('maintenance.inspection-submissions.index.edit', submission),
+ create: () => this.transitionTo('maintenance.inspection-submissions.index.new'),
+ };
+
+ panel = {
+ view: (submission, options = {}) => {
+ const service = this;
+
+ return this.resourceContextPanel.open({
+ submission,
+ title: this.panelTitle(submission),
+ actionButtons: [
+ { icon: 'edit', permission: 'fleet-ops update inspection-submission', fn: () => closePanelsThen(this.resourceContextPanel, () => this.transition.edit(submission)) },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ get items() {
+ return service.followUpItems(submission, { onDeleted: () => service.resourceContextPanel.closeAll() });
+ },
+ },
+ ],
+ tabs: [
+ { key: 'overview', label: this.intl.t('inspection.record.overview'), component: 'inspection-submission/details' },
+ { key: 'photos', label: this.intl.t('inspection.record.photos'), component: 'inspection-submission/photos' },
+ { key: 'audit', label: this.intl.t('inspection.record.audit'), component: 'resource-activity' },
+ ],
+ ...PANEL_DEFAULTS,
+ ...options,
+ });
+ },
+ };
+
+ /** "DVIR inspection" — the heading a submission's details show. */
+ panelTitle(submission) {
+ const formName = submission?.form?.get?.('name') ?? submission?.form?.name ?? submission?.form_name;
+
+ return formName ? this.intl.t('inspection.record.submission-title', { form: formName }) : (submission?.public_id ?? this.intl.t('inspection.record.inspection'));
+ }
+
+ /**
+ * The follow-up menu of a submission, shared by the details route and the
+ * context panel: raise an issue or a work order for failures that have
+ * none, resolve, and delete.
+ */
+ followUpItems(submission, { onDeleted } = {}) {
+ const items = [];
+
+ if (submission?.has_failures && !submission?.issue_uuid) {
+ items.push({
+ text: this.intl.t('inspection.record.create-issue'),
+ icon: 'triangle-exclamation',
+ fn: () => this.createIssue(submission),
+ permission: 'fleet-ops create-issue inspection-submission',
+ });
+ }
+
+ if (submission?.has_failures && !submission?.work_order_uuid) {
+ items.push({
+ text: this.intl.t('inspection.record.create-work-order'),
+ icon: 'clipboard-list',
+ fn: () => this.createWorkOrder(submission),
+ permission: 'fleet-ops create-work-order inspection-submission',
+ });
+ }
+
+ if (submission?.status !== 'resolved') {
+ items.push({ text: this.intl.t('inspection.record.resolve'), icon: 'check', fn: () => this.resolve(submission), permission: 'fleet-ops resolve inspection-submission' });
+ }
+
+ if (items.length) {
+ items.push({ separator: true });
+ }
+
+ items.push({
+ text: this.intl.t('common.delete'),
+ icon: 'trash',
+ class: 'text-red-500',
+ fn: () => this.delete(submission, { onConfirm: onDeleted }),
+ permission: 'fleet-ops delete inspection-submission',
+ });
+
+ return items;
+ }
+
+ /**
+ * The answers filed against a submission, as the resource projects them:
+ * every value beside the field it answers, with `file:` references
+ * resolved to something fetchable.
+ *
+ * The `inspection-submission` model belongs to `@fleetbase/fleetops-data`
+ * and declares no `custom_field_values` relationship, so Ember Data drops
+ * the projection; this reads it from the internal payload directly.
+ *
+ * @return {Object} the answers keyed by the field uuid they answer
+ */
+ async loadAnswers(submission) {
+ if (!submission?.id) {
+ return {};
+ }
+
+ const response = await this.fetch.get(`inspection-submissions/${submission.id}`);
+ const record = response?.inspection_submission ?? response?.inspectionSubmission ?? response;
+ const values = Array.isArray(record?.custom_field_values) ? record.custom_field_values : [];
+
+ return values.reduce((carry, value) => {
+ const key = value?.custom_field;
+ if (key) {
+ carry[key] = value.value;
+ }
+
+ return carry;
+ }, {});
+ }
+
+ /**
+ * Writes the answers. `inspection_submission.custom_field_values` is what
+ * `InspectionSubmissionController::syncAnswersFromRequest()` reads, and it
+ * is the same body the driver API accepts — the console and the app write
+ * the same rows, and the server derives the item results from the
+ * pass-fail answers among them.
+ *
+ * @param {Array} rows [{ custom_field, value, value_type }]
+ */
+ async saveAnswers(submission, rows) {
+ if (!submission?.id || !Array.isArray(rows) || rows.length === 0) {
+ return null;
+ }
+
+ return this.fetch.put(`inspection-submissions/${submission.id}`, {
+ inspection_submission: { custom_field_values: rows },
+ });
+ }
+
+ @action submit(submission) {
+ return this.modalsManager.confirm({
+ title: this.intl.t('inspection.follow-up.submit-title'),
+ body: this.intl.t('inspection.follow-up.submit-summary'),
+ acceptButtonText: this.intl.t('inspection.follow-up.submit-accept'),
+ acceptButtonIcon: 'paper-plane',
+ declineButtonText: this.intl.t('inspection.follow-up.cancel'),
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await this.postAction(submission, 'submit', 'Inspection submitted.');
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Each of these used to fire on click: the first anyone knew of a new
+ * issue was a toast and an id. They ask first, and say what they are about
+ * to make and from which failed checks.
+ */
+ @action createIssue(submission) {
+ return this.confirmFollowUp(submission, {
+ kind: 'issue',
+ title: this.intl.t('inspection.follow-up.create-issue-title'),
+ summary: this.intl.t('inspection.follow-up.create-issue-summary'),
+ acceptButtonText: this.intl.t('inspection.follow-up.create-issue-accept'),
+ acceptButtonIcon: 'triangle-exclamation',
+ endpoint: 'create-issue',
+ message: 'Issue created from failed inspection items.',
+ });
+ }
+
+ @action createWorkOrder(submission) {
+ return this.confirmFollowUp(submission, {
+ kind: 'work-order',
+ title: this.intl.t('inspection.follow-up.create-work-order-title'),
+ summary: this.intl.t('inspection.follow-up.create-work-order-summary'),
+ acceptButtonText: this.intl.t('inspection.follow-up.create-work-order-accept'),
+ acceptButtonIcon: 'clipboard-list',
+ dueNote: this.dueNoteFor(submission),
+ endpoint: 'create-work-order',
+ message: 'Work order created from failed inspection items.',
+ });
+ }
+
+ @action resolve(submission) {
+ return this.confirmFollowUp(submission, {
+ kind: 'resolve',
+ title: this.intl.t('inspection.follow-up.resolve-title'),
+ summary: this.intl.t('inspection.follow-up.resolve-summary'),
+ acceptButtonText: this.intl.t('inspection.follow-up.resolve-accept'),
+ acceptButtonIcon: 'check',
+ endpoint: 'resolve',
+ message: 'Inspection resolved.',
+ });
+ }
+
+ /** When the work order falls due, which the server takes from the worst failure. */
+ dueNoteFor(submission) {
+ const failures = (submission?.item_results ?? []).filter((item) => item.passed === false);
+ const critical = failures.some((item) => item.severity === 'critical');
+ const due = new Date();
+ due.setDate(due.getDate() + (critical ? 1 : 7));
+
+ return this.intl.t('inspection.follow-up.due-note', { due: due.toLocaleDateString() });
+ }
+
+ confirmFollowUp(submission, { kind, title, summary, acceptButtonText, acceptButtonIcon, dueNote, endpoint, message }) {
+ const failures = (submission?.item_results ?? []).filter((item) => item.passed === false);
+ const nothingToDo = kind !== 'resolve' && failures.length === 0;
+
+ return this.modalsManager.show('modals/inspection-follow-up', {
+ title,
+ summary,
+ submission,
+ kind,
+ dueNote,
+ acceptButtonText,
+ acceptButtonIcon,
+ // Nothing failed: the server would only say so once the request was
+ // already made, which is a strange moment to find out.
+ acceptButtonDisabled: nothingToDo,
+ declineButtonText: this.intl.t('inspection.follow-up.cancel'),
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await this.postAction(submission, endpoint, message);
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async postAction(submission, actionName, message) {
+ const response = await this.fetch.post(`inspection-submissions/${submission.id}/${actionName}`);
+
+ // The endpoints answer with what they did, and say so when a submission
+ // had nothing to raise.
+ this.notifications.success(response?.message ?? message);
+ await this.refresh();
+
+ return response;
+ }
+}
diff --git a/addon/services/issue-actions.js b/addon/services/issue-actions.js
index 3d9caa682..8e81357d3 100644
--- a/addon/services/issue-actions.js
+++ b/addon/services/issue-actions.js
@@ -3,6 +3,7 @@ import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { format } from 'date-fns';
import { issueStatuses } from '../utils/fleet-ops-options';
+import { PANEL_DEFAULTS } from '../utils/context-panel';
export default class IssueActionsService extends ResourceActionService {
@service driverActions;
@@ -47,15 +48,15 @@ export default class IssueActionsService extends ResourceActionService {
issue,
});
},
- view: (issue) => {
+ view: (issue, options = {}) => {
return this.resourceContextPanel.open({
issue,
- tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'issue/details',
- },
- ],
+ header: 'issue/panel-header',
+ width: '800px',
+ actionButtons: this.panelActionButtons(issue),
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'issue/details' }],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
@@ -93,6 +94,56 @@ export default class IssueActionsService extends ResourceActionService {
},
};
+ /**
+ * The header buttons of an issue panel, the same as the issue details
+ * route: edit, then the workflow menu. The menu is read when it renders,
+ * so closing or re-opening the issue swaps the last item.
+ */
+ panelActionButtons(issue) {
+ const service = this;
+ const refresh = () => issue?.reload?.();
+
+ return [
+ {
+ icon: 'pencil',
+ permission: 'fleet-ops update issue',
+ fn: async () => {
+ await this.resourceContextPanel.closeAll();
+ this.panel.edit(issue);
+ },
+ },
+ {
+ icon: 'ellipsis',
+ type: 'default',
+ renderInPlace: true,
+ get items() {
+ return service.workflowItems(issue, refresh);
+ },
+ },
+ ];
+ }
+
+ workflowItems(issue, onSaved) {
+ const items = [
+ { label: 'Change Status', text: 'Change Status', icon: 'arrows-rotate', fn: () => this.openStatusModal(issue, { onSaved }) },
+ { label: 'Assign Issue', text: 'Assign Issue', icon: 'user-check', fn: () => this.openAssignModal(issue, { onSaved }) },
+ ];
+
+ if (this.closedStatuses.includes(issue?.status)) {
+ items.push({ label: 'Re-open Issue', text: 'Re-open Issue', icon: 'rotate-left', fn: () => this.confirmReopenIssue(issue, { onSaved }) });
+ } else {
+ items.push({
+ label: 'Close Issue',
+ text: 'Close Issue',
+ icon: 'circle-check',
+ class: 'text-green-600 dark:text-green-400',
+ fn: () => this.openCloseIssueModal(issue, { onSaved }),
+ });
+ }
+
+ return items;
+ }
+
get closedStatuses() {
return ['closed', 'resolved', 'completed'];
}
diff --git a/addon/services/maintenance-actions.js b/addon/services/maintenance-actions.js
index a1759b291..672b382c3 100644
--- a/addon/services/maintenance-actions.js
+++ b/addon/services/maintenance-actions.js
@@ -1,6 +1,9 @@
-import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class MaintenanceActionsService extends ResourceActionService {
+ @service('universe/menu-service') menuService;
+
get defaultCurrency() {
return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
}
@@ -39,10 +42,26 @@ export default class MaintenanceActionsService extends ResourceActionService {
maintenance,
});
},
- view: (maintenance) => {
+ view: (maintenance, options = {}) => {
return this.resourceContextPanel.open({
maintenance,
- tabs: [{ label: this.intl.t('common.overview'), component: 'maintenance/details' }],
+ title: maintenance?.summary,
+ header: 'maintenance/panel-header',
+ actionButtons: [
+ { icon: 'edit', permission: 'fleet-ops update maintenance', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(maintenance)) },
+ {
+ icon: 'trash',
+ type: 'danger',
+ permission: 'fleet-ops delete maintenance',
+ fn: () => this.delete(maintenance, { onConfirm: () => this.resourceContextPanel.closeAll() }),
+ },
+ ],
+ tabs: [
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'maintenance/details' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:maintenance:details'),
+ ],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/maintenance-schedule-actions.js b/addon/services/maintenance-schedule-actions.js
index 131e0f02c..fe4f1a444 100644
--- a/addon/services/maintenance-schedule-actions.js
+++ b/addon/services/maintenance-schedule-actions.js
@@ -1,11 +1,13 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class MaintenanceScheduleActionsService extends ResourceActionService {
@service fetch;
@service notifications;
@service intl;
+ @service('universe/menu-service') menuService;
constructor() {
super(...arguments);
@@ -39,15 +41,18 @@ export default class MaintenanceScheduleActionsService extends ResourceActionSer
schedule,
});
},
- view: (schedule) => {
+ view: (schedule, options = {}) => {
return this.resourceContextPanel.open({
schedule,
+ title: schedule?.name,
+ actionButtons: this.panelActionButtons(schedule, { onDeleted: () => this.resourceContextPanel.closeAll() }),
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'maintenance-schedule/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'maintenance-schedule/details' },
+ { key: 'work-orders', label: this.intl.t('menu.work-orders'), component: 'maintenance-schedule/work-orders' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:schedule:details'),
],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
@@ -115,4 +120,64 @@ export default class MaintenanceScheduleActionsService extends ResourceActionSer
this.notifications.serverError(error);
}
}
+
+ /**
+ * The header buttons of a schedule, shared by the details route and the
+ * context panel: edit, trigger a work order now, calendar export, delete.
+ * `onEdit` replaces the panel's edit step where the route navigates.
+ */
+ panelActionButtons(schedule, { onEdit, onDeleted } = {}) {
+ return [
+ {
+ icon: 'edit',
+ permission: 'fleet-ops update maintenance-schedule',
+ fn: () => (onEdit ? onEdit(schedule) : closePanelsThen(this.resourceContextPanel, () => this.panel.edit(schedule))),
+ },
+ { icon: 'play', helpText: 'Trigger Work Order Now', permission: 'fleet-ops update maintenance-schedule', fn: () => this.triggerNow(schedule) },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ items: [
+ { text: 'Download .ics', icon: 'download', iconPrefix: 'far', fn: () => this.downloadIcal(schedule) },
+ { text: 'Add to Google Calendar', icon: 'calendar-plus', iconPrefix: 'fab', fn: () => this.addToGoogleCalendar(schedule) },
+ ],
+ },
+ { icon: 'trash', type: 'danger', permission: 'fleet-ops delete maintenance-schedule', fn: () => this.delete(schedule, { onConfirm: onDeleted }) },
+ ];
+ }
+
+ /**
+ * Download the schedule as an iCalendar file.
+ */
+ @action downloadIcal(schedule) {
+ const id = schedule.public_id ?? schedule.id;
+
+ return this.fetch.download(`maintenance-schedules/${id}/ical`, {}, { fileName: `maintenance-schedule-${id}.ics`, mimeType: 'text/calendar' }).catch((error) => {
+ this.notifications.serverError(error);
+ });
+ }
+
+ /**
+ * Open Google Calendar with the schedule's next due date filled in,
+ * repeating on the schedule's time interval when it has one.
+ */
+ @action addToGoogleCalendar(schedule) {
+ const title = encodeURIComponent(schedule.name ?? 'Maintenance Schedule');
+ const dueDate = schedule.next_due_date ? new Date(schedule.next_due_date) : new Date();
+ const pad = (n) => String(n).padStart(2, '0');
+ const dateStr = `${dueDate.getFullYear()}${pad(dueDate.getMonth() + 1)}${pad(dueDate.getDate())}`;
+ const details = encodeURIComponent(schedule.description ?? schedule.instructions ?? '');
+
+ let recur = '';
+ const intervalValue = parseInt(schedule.interval_value, 10);
+ const intervalUnit = schedule.interval_unit;
+ if (intervalValue > 0 && intervalUnit) {
+ const unitMap = { days: 'DAILY', weeks: 'WEEKLY', months: 'MONTHLY', years: 'YEARLY' };
+ recur = `&recur=RRULE:FREQ=${unitMap[intervalUnit] ?? 'DAILY'};INTERVAL=${intervalValue}`;
+ }
+
+ const url = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${title}&dates=${dateStr}/${dateStr}&details=${details}${recur}`;
+ window.open(url, '_blank', 'noopener,noreferrer');
+ }
}
diff --git a/addon/services/movement-tracker.js b/addon/services/movement-tracker.js
index 38b2545d6..c213920cc 100644
--- a/addon/services/movement-tracker.js
+++ b/addon/services/movement-tracker.js
@@ -18,6 +18,8 @@ import { inject as service } from '@ember/service';
import { getOwner } from '@ember/application';
import { task, timeout } from 'ember-concurrency';
import { debug } from '@ember/debug';
+import { registerDestructor } from '@ember/destroyable';
+import telemetryTimestamp from '../utils/telemetry-timestamp';
import getModelName from '@fleetbase/ember-core/utils/get-model-name';
import LeafletTrackingMarkerComponent from '../components/leaflet-tracking-marker';
@@ -30,12 +32,15 @@ export class EventBuffer {
/** @type {import('./map-manager').default|null} */
mapManager = null;
+ latestPositionAt = null;
constructor(model, { callback = null, waitTime = 1000 * 3, mapManager = null }) {
this.model = model;
this.callback = callback;
this.waitTime = waitTime;
this.mapManager = mapManager;
+ const initialPosition = telemetryTimestamp(model?.telematics?.last_event_at);
+ this.latestPositionAt = Number.isFinite(initialPosition) ? initialPosition : null;
}
/**
@@ -105,6 +110,12 @@ export class EventBuffer {
for (const output of eventsToProcess) {
const { event, data } = output;
+ if (data?.additionalData?.position_at) {
+ const observedAt = telemetryTimestamp(data.additionalData.position_at);
+ if (!Number.isFinite(observedAt) || (this.latestPositionAt !== null && observedAt < this.latestPositionAt)) continue;
+ this.latestPositionAt = observedAt;
+ this.model?.setProperties?.({ location: data.location, speed: data.speed, heading: data.heading });
+ }
// Resolve marker via adapter (provider-agnostic) or Leaflet fallback
const markerId = this.#getMarkerId();
@@ -176,10 +187,15 @@ export default class MovementTrackerService extends Service {
@tracked channels = [];
@tracked buffers = new Map();
+ reconnectConsumer;
constructor() {
super(...arguments);
this.registerTrackingMarker();
+ registerDestructor(this, () => {
+ this.reconnectConsumer?.close();
+ this.buffers.forEach((buffer) => buffer.stop());
+ });
}
#getOwner(owner = null) {
@@ -213,9 +229,14 @@ export default class MovementTrackerService extends Service {
}
closeChannels() {
+ this.watchReconnect.cancelAll();
+ this.reconnectConsumer?.close();
+ this.buffers.forEach((buffer) => buffer.stop());
+ this.buffers.clear();
this.channels.forEach((channel) => {
channel.close();
});
+ this.channels = [];
}
watch(models = []) {
@@ -224,9 +245,39 @@ export default class MovementTrackerService extends Service {
});
}
+ @task *watchReconnect() {
+ this.reconnectConsumer = this.socket.instance().listener('connect').createConsumer();
+ while (true) {
+ const { done } = yield this.reconnectConsumer.next();
+ if (done) break;
+ // Refresh only the visible telemetry assets, with bounded request concurrency.
+ const buffers = [...this.buffers.values()].filter((buffer) => buffer.model?.telematics?.last_event_at && typeof buffer.model?.reload === 'function');
+ for (let i = 0; i < buffers.length; i += 5) {
+ yield Promise.allSettled(
+ buffers.slice(i, i + 5).map(async (buffer) => {
+ const model = await buffer.model.reload();
+ buffer.clear();
+ buffer.add({
+ event: `${getModelName(model)}.location_changed`,
+ created_at: new Date().toISOString(),
+ data: {
+ id: model.id,
+ location: model.location,
+ speed: model.speed,
+ heading: model.heading,
+ additionalData: { position_at: model.telematics?.last_event_at },
+ },
+ });
+ })
+ );
+ }
+ }
+ }
+
async track(model, options = {}) {
// Create socket instance
const socket = this.socket.instance();
+ if (this.watchReconnect.isIdle) this.watchReconnect.perform();
// Get model type and identifier
const type = getModelName(model);
@@ -246,7 +297,7 @@ export default class MovementTrackerService extends Service {
this.channels = [...this.channels, channel];
// Listen to the channel for events
- await channel.listener('subscribe').once();
+ if (channel.state !== 'subscribed') await channel.listener('subscribe').once();
// Create event buffer for tracking model
const eventBuffer = this.#getBuffer(channelId, model, options);
diff --git a/addon/services/part-actions.js b/addon/services/part-actions.js
index d6d579eb5..a97d45457 100644
--- a/addon/services/part-actions.js
+++ b/addon/services/part-actions.js
@@ -1,6 +1,9 @@
-import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import ResourceActionService, { inject as service } from '@fleetbase/ember-core/services/resource-action';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class PartActionsService extends ResourceActionService {
+ @service('universe/menu-service') menuService;
+
get defaultCurrency() {
return this.currentUser?.company?.currency || this.currentUser.currency || 'USD';
}
@@ -41,15 +44,18 @@ export default class PartActionsService extends ResourceActionService {
part,
});
},
- view: (part) => {
+ view: (part, options = {}) => {
return this.resourceContextPanel.open({
part,
- tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'part/details',
- },
+ title: part?.name,
+ header: 'part/panel-header',
+ actionButtons: [
+ { icon: 'edit', permission: 'fleet-ops update part', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(part)) },
+ { icon: 'trash', type: 'danger', permission: 'fleet-ops delete part', fn: () => this.delete(part, { onConfirm: () => this.resourceContextPanel.closeAll() }) },
],
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'part/details' }, ...registeredPanelTabs(this.menuService, 'fleet-ops:component:part:details')],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/place-actions.js b/addon/services/place-actions.js
index e4527581a..060558c87 100644
--- a/addon/services/place-actions.js
+++ b/addon/services/place-actions.js
@@ -2,9 +2,11 @@ import ResourceActionService from '@fleetbase/ember-core/services/resource-actio
import leafletIcon from '@fleetbase/ember-core/utils/leaflet-icon';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class PlaceActionsService extends ResourceActionService {
@service vendorActions;
+ @service('universe/menu-service') menuService;
constructor() {
super(...arguments);
@@ -44,19 +46,18 @@ export default class PlaceActionsService extends ResourceActionService {
place,
});
},
- view: async (place) => {
+ view: async (place, options = {}) => {
if (place?.meta?._index_resource) {
await place.reload();
}
return this.resourceContextPanel.open({
place,
- tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'place/details',
- },
- ],
+ title: place?.name ?? place?.street1,
+ actionButtons: [{ icon: 'pencil', permission: 'fleet-ops update place', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(place)) }],
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'place/details' }, ...registeredPanelTabs(this.menuService, 'fleet-ops:component:place:details')],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/service-area-actions.js b/addon/services/service-area-actions.js
index 2c2885262..695674521 100644
--- a/addon/services/service-area-actions.js
+++ b/addon/services/service-area-actions.js
@@ -1,4 +1,5 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
import { tracked } from '@glimmer/tracking';
import { debug } from '@ember/debug';
import { task } from 'ember-concurrency';
@@ -38,15 +39,37 @@ export default class ServiceAreaActionsService extends ResourceActionService {
serviceArea,
});
},
- view: (serviceArea) => {
+ view: (serviceArea, options = {}) => {
return this.resourceContextPanel.open({
serviceArea,
- tabs: [
+ title: serviceArea?.name,
+ actionButtons: [
+ { icon: 'pencil', permission: 'fleet-ops update service-area', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(serviceArea)) },
{
- label: this.intl.t('common.overview'),
- component: 'service-area/details',
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ items: [
+ {
+ text: this.intl.t('service-area.actions.edit-boundary'),
+ icon: 'draw-polygon',
+ permission: 'fleet-ops update service-area',
+ fn: () => closePanelsThen(this.resourceContextPanel, () => this.transition.edit(serviceArea)),
+ },
+ { separator: true },
+ {
+ text: this.intl.t('common.delete'),
+ icon: 'trash',
+ class: 'text-red-500',
+ permission: 'fleet-ops delete service-area',
+ fn: () => this.delete(serviceArea, { onConfirm: () => this.resourceContextPanel.closeAll() }),
+ },
+ ],
},
],
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'service-area/details' }],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/trailer-actions.js b/addon/services/trailer-actions.js
index 6c6856414..8349ce056 100644
--- a/addon/services/trailer-actions.js
+++ b/addon/services/trailer-actions.js
@@ -4,6 +4,7 @@ import config from 'ember-get-config';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { dasherize } from '@ember/string';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
/**
* Trailer resource actions.
@@ -60,11 +61,20 @@ export default class TrailerActionsService extends ResourceActionService {
async resolveTrailerResource(trailer) {
if (typeof trailer?.then === 'function') {
- return await trailer;
+ trailer = await trailer;
}
if (typeof trailer?.loadResource === 'function') {
- return (await trailer.loadResource()) ?? trailer;
+ trailer = (await trailer.loadResource()) ?? trailer;
+ }
+
+ // Embedded vehicle rows contain a summary, so hydrate it before opening the trailer.
+ if (trailer?.public_id && !trailer.created_at && !trailer.isNew) {
+ return this.store.queryRecord('trailer', {
+ public_id: trailer.public_id,
+ single: true,
+ with: ['currentConnection.vehicle', 'category', 'vendor', 'warranty'],
+ });
}
return trailer;
@@ -119,6 +129,7 @@ export default class TrailerActionsService extends ResourceActionService {
},
view: async (trailer, options = {}) => {
trailer = await this.resolveTrailerResource(trailer);
+ const service = this;
return this.resourceContextPanel.open({
trailer,
@@ -126,13 +137,20 @@ export default class TrailerActionsService extends ResourceActionService {
actionButtons: [
{
icon: 'pencil',
- fn: async () => {
- await this.resourceContextPanel.closeAll();
- this.panel.edit(trailer);
+ permission: 'fleet-ops update trailer',
+ fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(trailer)),
+ },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ get items() {
+ return service.detailsMenuItems(trailer, { onDeleted: () => service.resourceContextPanel.closeAll() });
},
},
],
tabs: this.panelTabs,
+ ...PANEL_DEFAULTS,
...options,
});
},
@@ -350,13 +368,14 @@ export default class TrailerActionsService extends ResourceActionService {
}
@action locate(trailer, options = {}) {
- const { latitude, longitude, location } = trailer;
const trailerName = this.trailerName(trailer);
- if (!trailer.hasValidCoordinates) {
+ if (!trailer?.location || !trailer.hasValidCoordinates) {
return this.notifications.warning(this.intl.t('trailer.prompts.no-location-warning', { trailerName }));
}
+ const { latitude, longitude, location } = trailer;
+
this.modalsManager.show('modals/point-map', {
title: this.intl.t('common.resource-location', { resource: trailerName }),
acceptButtonText: this.intl.t('common.done'),
@@ -391,4 +410,38 @@ export default class TrailerActionsService extends ResourceActionService {
return this.maintenanceActions.modal.create({ maintainable: trailer }, options, saveOptions);
}
+
+ /**
+ * The actions menu of a trailer's header, shared by the details route and
+ * the context panel. Attach or detach follows the trailer's state.
+ */
+ detailsMenuItems(trailer, { onDeleted } = {}) {
+ const isAttached = trailer?.isAttached ?? trailer?.attachment_state === 'attached';
+
+ return [
+ { text: this.intl.t('trailer.actions.locate'), icon: 'location-dot', fn: () => this.locate(trailer), permission: 'fleet-ops view trailer' },
+ isAttached
+ ? { text: this.intl.t('trailer.actions.detach-vehicle'), icon: 'unlink', fn: () => this.detachVehicle(trailer), permission: 'fleet-ops detach-vehicle-for trailer' }
+ : { text: this.intl.t('trailer.actions.attach-vehicle'), icon: 'link', fn: () => this.attachVehicle(trailer), permission: 'fleet-ops attach-vehicle-for trailer' },
+ { text: this.intl.t('trailer.actions.attach-device'), icon: 'microchip', fn: () => this.attachDevice(trailer), permission: 'fleet-ops attach-device-for trailer' },
+ { text: this.intl.t('trailer.actions.attach-equipment'), icon: 'toolbox', fn: () => this.attachEquipment(trailer), permission: 'fleet-ops attach-equipment-for trailer' },
+ { separator: true },
+ {
+ text: this.intl.t('trailer.actions.schedule-maintenance'),
+ icon: 'calendar-check',
+ fn: () => this.scheduleMaintenance(trailer),
+ permission: 'fleet-ops create maintenance-schedule',
+ },
+ { text: this.intl.t('trailer.actions.create-work-order'), icon: 'clipboard-list', fn: () => this.createWorkOrder(trailer), permission: 'fleet-ops create work-order' },
+ { text: this.intl.t('trailer.actions.log-maintenance'), icon: 'wrench', fn: () => this.logMaintenance(trailer), permission: 'fleet-ops create maintenance' },
+ { separator: true },
+ {
+ text: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.trailer') }),
+ icon: 'trash',
+ fn: () => this.delete(trailer, { onConfirm: onDeleted }),
+ permission: 'fleet-ops delete trailer',
+ class: 'text-red-500 hover:text-red-600',
+ },
+ ];
+ }
}
diff --git a/addon/services/vehicle-actions.js b/addon/services/vehicle-actions.js
index 9af2ba792..f3533b2ec 100644
--- a/addon/services/vehicle-actions.js
+++ b/addon/services/vehicle-actions.js
@@ -4,6 +4,7 @@ import config from 'ember-get-config';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { dasherize } from '@ember/string';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class VehicleActionsService extends ResourceActionService {
@service('universe/menu-service') menuService;
@@ -11,6 +12,7 @@ export default class VehicleActionsService extends ResourceActionService {
@service maintenanceScheduleActions;
@service workOrderActions;
@service maintenanceActions;
+ @service issueActions;
get registeredTabs() {
const registeredTabs = this.menuService.getMenuItems('fleet-ops:component:vehicle:details');
@@ -67,6 +69,11 @@ export default class VehicleActionsService extends ResourceActionService {
label: 'Maintenance',
component: 'vehicle/details/maintenance-history',
},
+ {
+ key: 'inspections',
+ label: this.intl.t('resource.inspections'),
+ component: 'vehicle/details/inspections',
+ },
...this.registeredTabs,
];
}
@@ -138,6 +145,7 @@ export default class VehicleActionsService extends ResourceActionService {
},
view: async (vehicle, options = {}) => {
vehicle = await this.reloadIndexResource(await this.resolveVehicleResource(vehicle));
+ const service = this;
return this.resourceContextPanel.open({
vehicle,
@@ -145,13 +153,20 @@ export default class VehicleActionsService extends ResourceActionService {
actionButtons: [
{
icon: 'pencil',
- fn: async () => {
- await this.resourceContextPanel.closeAll();
- this.panel.edit(vehicle);
+ permission: 'fleet-ops update vehicle',
+ fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(vehicle)),
+ },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ get items() {
+ return service.detailsMenuItems(vehicle, { onDeleted: () => service.resourceContextPanel.closeAll() });
},
},
],
tabs: this.panelTabs,
+ ...PANEL_DEFAULTS,
...options,
});
},
@@ -453,4 +468,44 @@ export default class VehicleActionsService extends ResourceActionService {
});
});
}
+
+ /**
+ * The actions menu of a vehicle's header, shared by the details route and
+ * the context panel so both offer the same actions.
+ */
+ detailsMenuItems(vehicle, { onDeleted } = {}) {
+ return [
+ { text: this.intl.t('vehicle.actions.locate-vehicle'), icon: 'location-dot', fn: () => this.locate(vehicle), permission: 'fleet-ops view vehicle' },
+ { text: this.intl.t('vehicle.actions.attach-device'), icon: 'link', fn: () => this.attachDevice(vehicle), permission: 'fleet-ops update vehicle' },
+ ...(Number(vehicle?.assigned_orders_count) > 0
+ ? [{ text: this.intl.t('vehicle.actions.unassign-orders'), icon: 'truck-ramp-box', fn: () => this.unassignOrders(vehicle), permission: 'fleet-ops update vehicle' }]
+ : []),
+ { separator: true },
+ {
+ text: this.intl.t('vehicle.actions.schedule-maintenance'),
+ icon: 'calendar-check',
+ fn: () => this.scheduleMaintenance(vehicle),
+ permission: 'fleet-ops create maintenance-schedule',
+ },
+ { text: this.intl.t('vehicle.actions.create-work-order'), icon: 'clipboard-list', fn: () => this.createWorkOrder(vehicle), permission: 'fleet-ops create work-order' },
+ { text: this.intl.t('vehicle.actions.log-maintenance'), icon: 'wrench', fn: () => this.logMaintenance(vehicle), permission: 'fleet-ops create maintenance' },
+ { text: this.intl.t('vehicle.actions.create-issue'), icon: 'triangle-exclamation', fn: () => this.createIssue(vehicle), permission: 'fleet-ops create issue' },
+ { separator: true },
+ {
+ text: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.vehicle') }),
+ icon: 'trash',
+ fn: () => this.delete(vehicle, { onConfirm: onDeleted }),
+ permission: 'fleet-ops delete vehicle',
+ class: 'text-red-500 hover:text-red-600',
+ },
+ ];
+ }
+
+ @action createIssue(vehicle) {
+ return this.issueActions.modal.create({
+ vehicle,
+ vehicle_uuid: vehicle.id,
+ title: this.intl.t('vehicle.prompts.issue-title', { vehicleName: vehicle.displayName ?? vehicle.name }),
+ });
+ }
}
diff --git a/addon/services/vendor-actions.js b/addon/services/vendor-actions.js
index e57eb89a4..ab0e9591b 100644
--- a/addon/services/vendor-actions.js
+++ b/addon/services/vendor-actions.js
@@ -2,6 +2,7 @@ import ResourceActionService from '@fleetbase/ember-core/services/resource-actio
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { normalizeProvider, buildIntegrationPayload } from '../utils/vendor-integration';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class VendorActionsService extends ResourceActionService {
@service placeActions;
@@ -40,18 +41,18 @@ export default class VendorActionsService extends ResourceActionService {
vendor,
});
},
- view: (vendor) => {
+ view: (vendor, options = {}) => {
return this.resourceContextPanel.open({
vendor,
+ title: vendor?.name,
header: 'vendor/panel-header',
+ actionButtons: [{ icon: 'pencil', permission: 'fleet-ops update vendor', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(vendor)) }],
tabs: [
- {
- key: 'overview',
- id: 'overview',
- label: this.intl.t('common.overview'),
- component: 'vendor/details',
- },
+ { key: 'overview', id: 'overview', label: this.intl.t('common.overview'), component: 'vendor/details' },
+ { key: 'personnel', id: 'personnel', label: 'Personnel', component: 'vendor/personnel-tab' },
],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/work-order-actions.js b/addon/services/work-order-actions.js
index 3911c24b5..d0cd15c7c 100644
--- a/addon/services/work-order-actions.js
+++ b/addon/services/work-order-actions.js
@@ -1,10 +1,12 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
+import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
export default class WorkOrderActionsService extends ResourceActionService {
@service fetch;
@service notifications;
+ @service('universe/menu-service') menuService;
constructor() {
super(...arguments);
this.initialize('work-order');
@@ -37,15 +39,22 @@ export default class WorkOrderActionsService extends ResourceActionService {
workOrder,
});
},
- view: (workOrder) => {
+ view: (workOrder, options = {}) => {
return this.resourceContextPanel.open({
workOrder,
+ title: workOrder?.code ?? workOrder?.subject,
+ header: 'work-order/panel-header',
+ actionButtons: [
+ { icon: 'paper-plane', text: 'Send to Vendor', permission: 'fleet-ops update work-order', fn: () => this.sendEmail(workOrder) },
+ { icon: 'edit', permission: 'fleet-ops update work-order', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(workOrder)) },
+ { icon: 'trash', type: 'danger', permission: 'fleet-ops delete work-order', fn: () => this.delete(workOrder, { onConfirm: () => this.resourceContextPanel.closeAll() }) },
+ ],
tabs: [
- {
- label: this.intl.t('common.overview'),
- component: 'work-order/details',
- },
+ { key: 'overview', label: this.intl.t('common.overview'), component: 'work-order/details' },
+ ...registeredPanelTabs(this.menuService, 'fleet-ops:component:work-order:details'),
],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/services/zone-actions.js b/addon/services/zone-actions.js
index b0a4d4546..e811ebea6 100644
--- a/addon/services/zone-actions.js
+++ b/addon/services/zone-actions.js
@@ -1,4 +1,5 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
export default class ZoneActionsService extends ResourceActionService {
constructor() {
@@ -33,15 +34,37 @@ export default class ZoneActionsService extends ResourceActionService {
zone,
});
},
- view: (zone) => {
+ view: (zone, options = {}) => {
return this.resourceContextPanel.open({
zone,
- tabs: [
+ title: zone?.name,
+ actionButtons: [
+ { icon: 'pencil', permission: 'fleet-ops update zone', fn: () => closePanelsThen(this.resourceContextPanel, () => this.panel.edit(zone)) },
{
- label: this.intl.t('common.overview'),
- component: 'zone/details',
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ items: [
+ {
+ text: this.intl.t('zone.actions.edit-boundary'),
+ icon: 'draw-polygon',
+ permission: 'fleet-ops update zone',
+ fn: () => closePanelsThen(this.resourceContextPanel, () => this.transition.edit(zone)),
+ },
+ { separator: true },
+ {
+ text: this.intl.t('common.delete'),
+ icon: 'trash',
+ class: 'text-red-500',
+ permission: 'fleet-ops delete zone',
+ fn: () => this.delete(zone, { onConfirm: () => this.resourceContextPanel.closeAll() }),
+ },
+ ],
},
],
+ tabs: [{ key: 'overview', label: this.intl.t('common.overview'), component: 'zone/details' }],
+ ...PANEL_DEFAULTS,
+ ...options,
});
},
};
diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css
index dbc585215..a17422d2d 100644
--- a/addon/styles/fleetops-engine.css
+++ b/addon/styles/fleetops-engine.css
@@ -280,18 +280,6 @@ body[data-theme='dark'] .fleetops-connectivity-kpi-accent-rose .fleetops-connect
right: 0.65rem;
}
-.status-badge.long-offline-status-badge > span,
-.status-badge.long_offline-status-badge > span {
- background-color: #854d0e;
- border-color: #a16207;
- color: #fef9c3;
-}
-
-.status-badge.long-offline-status-badge > span svg,
-.status-badge.long_offline-status-badge > span svg {
- color: #fde047;
-}
-
.fleetops-device-status-badge.status-badge > span {
padding: 0 0.25rem !important;
border-radius: 0.125rem !important;
@@ -4780,7 +4768,9 @@ body[data-theme='dark'] .order-pool-card.selected,
background: #263040 !important;
}
-/* ── Driver card ── */
+/* ── Driver card ──
+ The hover colours were written for the dark orchestrator workbench; the
+ drivers index card shares the class, so light mode gets its own pair. */
.driver-card {
cursor: pointer;
transition:
@@ -4789,6 +4779,12 @@ body[data-theme='dark'] .order-pool-card.selected,
}
.driver-card:hover {
+ background: #f3f4f6; /* gray-100 */
+ border-color: #d1d5db; /* gray-300 */
+}
+
+body[data-theme='dark'] .driver-card:hover,
+.dark .driver-card:hover {
background: #263040;
border-color: #4b5563;
}
@@ -8535,41 +8531,272 @@ body[data-theme='dark'] .fleetbase-ai-create-order-preview__cancelled {
}
}
-/* ── Trailer lifecycle, attachment & towing badges ──
- ember-ui's badge palette has no entries for these values, so they would
- otherwise render as unstyled `x-status-badge` elements. Colors follow the
- ember-ui badge.css convention (800 background / 700 border / 100 text). */
-.status-badge.attached-status-badge > span,
-.status-badge.in-use-status-badge > span {
- background-color: #1e40af;
- border-color: #1d4ed8;
- color: #dbeafe;
+/* ── The follow-up an inspection raises ──
+ What an action will do, previewed before it happens, and what it made,
+ shown afterwards as something you can open rather than a bare id. */
+.inspection-follow-up__heading {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #6b7280;
+ margin-bottom: 0.375rem;
}
-.status-badge.attached-status-badge > span svg,
-.status-badge.in-use-status-badge > span svg {
- color: #93c5fd;
+.inspection-follow-up__list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.inspection-follow-up__item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background-color: #f9fafb;
+ padding: 0.375rem 0.5rem;
+}
+
+.inspection-follow-up__label {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-size: 0.8125rem;
+ font-weight: 500;
+}
+
+.inspection-follow-up__note,
+.inspection-follow-up__empty {
+ font-size: 0.75rem;
+ color: #6b7280;
}
-.status-badge.detached-status-badge > span,
-.status-badge.retired-status-badge > span {
+.inspection-follow-up__empty {
+ border: 1px dashed #e5e7eb;
+ border-radius: 0.375rem;
+ padding: 0.625rem 0.75rem;
+}
+
+body[data-theme='dark'] .inspection-follow-up__item {
+ border-color: #374151;
background-color: #1f2937;
+}
+
+body[data-theme='dark'] .inspection-follow-up__empty {
border-color: #374151;
- color: #f3f4f6;
}
-.status-badge.detached-status-badge > span svg,
-.status-badge.retired-status-badge > span svg {
- color: #d1d5db;
+body[data-theme='dark'] .inspection-follow-up__heading,
+body[data-theme='dark'] .inspection-follow-up__note,
+body[data-theme='dark'] .inspection-follow-up__empty {
+ color: #9ca3af;
+}
+
+/* The record's own follow-ups: a row that opens what it names. */
+.inspection-followup {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ width: 100%;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ background-color: #f9fafb;
+ padding: 0.625rem 0.75rem;
+ text-align: left;
+ cursor: pointer;
+}
+
+.inspection-followup:hover {
+ border-color: #9ca3af;
+}
+
+.inspection-followup__head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.inspection-followup__kind {
+ font-size: 0.625rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #6b7280;
+}
+
+.inspection-followup__id {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.6875rem;
+ color: #6b7280;
+}
+
+.inspection-followup__open {
+ margin-left: auto;
+ font-size: 0.6875rem;
+ color: #9ca3af;
+}
+
+.inspection-followup__title {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: #111827;
+}
+
+.inspection-followup__meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+}
+
+.inspection-followup__type {
+ font-size: 0.6875rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .inspection-followup {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+body[data-theme='dark'] .inspection-followup:hover {
+ border-color: #6b7280;
+}
+
+body[data-theme='dark'] .inspection-followup__title {
+ color: #f9fafb;
+}
+
+/* ── A link out of a details panel to the record it names ──
+ The form name was a bare button with no styling and a full-size icon
+ sitting apart from it; it did not read as a link at all. */
+.field-info-container > .inspection-record__link,
+.inspection-record__link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ color: #3b82f6;
+ cursor: pointer;
+ text-align: left;
+ background: none;
+ border: 0;
+ padding: 0;
+}
+
+.field-info-container > .inspection-record__link:hover,
+.inspection-record__link:hover {
+ color: #2563eb;
+ text-decoration: underline;
+}
+
+.inspection-record__link-icon {
+ flex-shrink: 0;
+ opacity: 0.75;
+}
+
+body[data-theme='dark'] .field-info-container > .inspection-record__link,
+body[data-theme='dark'] .inspection-record__link {
+ color: #60a5fa;
+}
+
+body[data-theme='dark'] .field-info-container > .inspection-record__link:hover,
+body[data-theme='dark'] .inspection-record__link:hover {
+ color: #93c5fd;
+}
+
+/* ── A vehicle's inspection history ──
+ A table could not survive the overlay's width: six columns squeezed into
+ a 580px panel wrapped the form name onto three lines and pushed the date
+ off the edge behind a scrollbar. One block per inspection instead. */
+.inspection-history {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.inspection-history__card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ width: 100%;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ background-color: #f9fafb;
+ padding: 0.75rem;
+ text-align: left;
+ cursor: pointer;
+}
+
+.inspection-history__card:hover {
+ border-color: #9ca3af;
+}
+
+.inspection-history__head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+}
+
+.inspection-history__form {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: #111827;
+}
+
+.inspection-history__meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 0.6875rem;
+ color: #6b7280;
+}
+
+.inspection-history__meta > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.inspection-history__meta-icon {
+ opacity: 0.7;
+}
+
+.inspection-history__failed {
+ color: #b91c1c;
+ font-weight: 500;
+}
+
+.inspection-history__id {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.625rem;
+ color: #9ca3af;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .inspection-history__card {
+ border-color: #374151;
+ background-color: #1f2937;
}
-.status-badge.out-of-service-status-badge > span {
- background-color: #991b1b;
- border-color: #b91c1c;
- color: #fee2e2;
+body[data-theme='dark'] .inspection-history__card:hover {
+ border-color: #6b7280;
}
-.status-badge.out-of-service-status-badge > span svg {
+body[data-theme='dark'] .inspection-history__form {
+ color: #f9fafb;
+}
+
+body[data-theme='dark'] .inspection-history__failed {
color: #fca5a5;
}
@@ -8821,65 +9048,3384 @@ body[data-theme='dark'] .fleetops-card-meta-link {
color: #60a5fa;
}
-/* ── Multi-model table filter (mirrors ember-ui's .filter-multi-input clear button) ── */
-.filter-multi-model {
+/* ==========================================================================
+ Inspection sheet — "Promotion"
+ --------------------------------------------------------------------------
+ The author's grid survives, but only for fields that stay compact. The
+ moment a field needs room — a pass-fail that failed and now owes a
+ severity, a comment and photos, or a note, upload or signature that never
+ fitted a column — it is promoted out of the grid into a full-width band at
+ the end of its group. Nothing stretches its neighbour, because after
+ promotion it has no neighbour.
+
+ Each group header carries one dot per field, so an inspector can see at a
+ glance what is still open without reading a label.
+
+ Rendered identically by the console's submission form, the read-only
+ record, and the public link a driver opens on a phone.
+ ========================================================================== */
+
+.inspection-sheet,
+.inspection-flyout {
+ --ins-bg: #fff;
+ --ins-bg-sunken: #f9fafb;
+ --ins-border: #e5e7eb;
+ --ins-border-strong: #d1d5db;
+ --ins-text: #111827;
+ --ins-text-soft: #374151;
+ --ins-text-muted: #6b7280;
+ --ins-text-faint: #9ca3af;
+ --ins-pass: #16a34a;
+ --ins-pass-text: #15803d;
+ --ins-pass-edge: #bbf7d0;
+ --ins-pass-fill: #f0fdf4;
+ --ins-fail: #dc2626;
+ --ins-fail-text: #b91c1c;
+ --ins-fail-edge: #fecaca;
+ --ins-fail-fill: #fef2f2;
+ --ins-fail-fill-strong: #fee2e2;
+ --ins-fail-field: #fff;
+ --ins-fail-placeholder: #f87171;
+ --ins-fail-ring: rgb(220 38 38 / 22%);
+ --ins-fail-hatch-a: #fecaca;
+ --ins-fail-hatch-b: #fee2e2;
+ --ins-na: #6b7280;
+ --ins-warn: #d97706;
+ --ins-warn-text: #92400e;
+ --ins-warn-strong: #b45309;
+ --ins-hatch-a: #e5e7eb;
+ --ins-hatch-b: #f3f4f6;
+ --ins-mono: ui-monospace, 'IBM Plex Mono', sfmono-regular, menlo, monaco, consolas, monospace;
+}
+
+.inspection-sheet {
+ /* The floating flyouts are positioned against the sheet. */
position: relative;
+ display: flex;
+ flex-direction: column;
+
+ /*
+ * The sheet is measured, not the window. It renders in a ~600px overlay
+ * panel on a wide screen and full width on a phone, so a viewport media
+ * query would collapse the author's columns in exactly the wrong places.
+ */
+ container-type: inline-size;
+}
+
+body[data-theme='dark'] .inspection-sheet,
+body[data-theme='dark'] .inspection-flyout {
+ --ins-bg: #1f2937;
+ --ins-bg-sunken: #1f2937;
+ --ins-border: #374151;
+ --ins-border-strong: #4b5563;
+ --ins-text: #f9fafb;
+ --ins-text-soft: #e5e7eb;
+ --ins-text-muted: #9ca3af;
+ --ins-text-faint: #6b7280;
+ --ins-pass: #16a34a;
+ --ins-pass-text: #86efac;
+ --ins-pass-edge: #2f5a3c;
+ --ins-pass-fill: #1c2e22;
+ --ins-fail: #dc2626;
+ --ins-fail-text: #fca5a5;
+ --ins-fail-edge: #4c2326;
+ --ins-fail-fill: #241b1e;
+ --ins-fail-fill-strong: #3f1d1d;
+ --ins-fail-field: #1b1416;
+ --ins-fail-placeholder: #a86b6e;
+ --ins-fail-ring: rgb(239 68 68 / 28%);
+ --ins-fail-hatch-a: #3b2326;
+ --ins-fail-hatch-b: #2c1b1e;
+ --ins-na: #9ca3af;
+ --ins-warn: #b45309;
+ --ins-warn-text: #fcd34d;
+ --ins-warn-strong: #fbbf24;
+ --ins-hatch-a: #374151;
+ --ins-hatch-b: #2b3644;
+}
+
+/* The sheet is one card, not one card per group. */
+.inspection-sheet__body {
+ border: 1px solid var(--ins-border);
+ border-radius: 0.5rem;
+ background-color: var(--ins-bg);
+ box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
+ overflow: hidden;
}
-.filter-multi-model > .clear-button {
- position: absolute;
- top: 0;
- right: 0;
- padding: 0.5rem 0.75rem 0 0;
- color: #374151;
+body[data-theme='dark'] .inspection-sheet__body {
+ box-shadow: 0 1px 3px rgb(0 0 0 / 35%);
}
-.filter-multi-model > .clear-button:hover {
- opacity: 0.5;
+.inspection-sheet__groups {
+ padding: 1rem;
}
-.filter-multi-model > .clear-button:disabled {
- opacity: 0.3;
- cursor: default;
+/* --- group header ------------------------------------------------------- */
+
+.inspection-group__header {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding-bottom: 0.5625rem;
+ border-bottom: 1px solid var(--ins-border);
}
-body[data-theme='dark'] .filter-multi-model > .clear-button {
- color: #6b7280;
+.inspection-group + .inspection-group {
+ margin-top: 1.75rem;
}
-.filter-multi-model > .fleetbase-model-select,
-.filter-multi-model > .fleetbase-model-select > .ember-basic-dropdown,
-.filter-multi-model .ember-power-select-multiple-trigger {
- flex: 1 1 auto;
- width: 100%;
+.inspection-group__name {
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ins-text-soft);
min-width: 0;
}
-/* The small form-input height clips the multiple trigger and turns it into a scroller
- before anything is selected; let it start at the input height and grow with chips. */
-.filter-multi-model .ember-power-select-multiple-trigger {
+/* One dot per field: what is answered, what failed, what is still open. */
+.inspection-group__dots {
display: flex;
align-items: center;
- height: auto !important;
- min-height: 2.125rem;
- max-height: none !important;
- overflow: visible !important;
- padding-top: 0.125rem;
- padding-bottom: 0.125rem;
- padding-right: 2.5rem;
+ gap: 0.25rem;
+ flex-shrink: 0;
}
-.filter-multi-model .ember-power-select-multiple-options {
- flex: 1 1 auto;
- min-width: 0;
- gap: 0.125rem;
+.inspection-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ box-sizing: border-box;
+ background-color: var(--ins-text-faint);
}
-.filter-multi-model .ember-power-select-multiple-option {
- margin: 0.125rem 0.125rem 0.125rem 0;
+.inspection-dot[data-marker='pass'] {
+ background-color: var(--ins-pass);
}
-.filter-multi-model .ember-power-select-trigger-multiple-input {
- height: 1.625rem;
- min-width: 4rem;
+.inspection-dot[data-marker='fail'] {
+ background-color: var(--ins-fail);
+}
+
+.inspection-dot[data-marker='na'] {
+ background-color: var(--ins-na);
+}
+
+.inspection-dot[data-marker='outstanding'] {
+ background-color: var(--ins-warn-strong);
+}
+
+.inspection-dot[data-marker='empty'] {
+ background-color: transparent;
+ border: 1px solid var(--ins-text-faint);
+}
+
+.inspection-group__meta {
+ margin-left: auto;
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ line-height: 1;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(--ins-text-faint);
+ white-space: nowrap;
+}
+
+.inspection-group__meta[data-outstanding='true'] {
+ color: var(--ins-warn-strong);
+}
+
+.inspection-group__description {
+ margin: 0.5rem 0 0;
+ font-size: 0.75rem;
+ line-height: 1.45;
+ color: var(--ins-text-muted);
+}
+
+.inspection-group__empty {
+ padding: 0.75rem 0;
+ font-size: 0.75rem;
+ color: var(--ins-text-muted);
+}
+
+/* --- the author's grid, for fields that stay compact -------------------- */
+
+.inspection-group__grid {
+ display: grid;
+ gap: 1rem 0.875rem;
+ align-items: start;
+ padding-top: 0.875rem;
+}
+
+.inspection-group__grid > .inspection-band {
+ grid-column: 1 / -1;
+}
+
+.inspection-group__grid[data-columns='1'] {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.inspection-group__grid[data-columns='2'] {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.inspection-group__grid[data-columns='3'] {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.inspection-group__grid[data-columns='4'] {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+/* A narrow sheet cannot hold the wider authored grids. */
+@container (width <= 660px) {
+ .inspection-group__grid[data-columns='3'],
+ .inspection-group__grid[data-columns='4'] {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+/*
+ * A phone-width sheet stacks every grid. Matched to the `[data-columns]`
+ * rules' specificity: as a bare class it lost to them, and an authored two- or
+ * three-column group stayed in two 160px columns on a phone.
+ */
+@container (width <= 420px) {
+ .inspection-group__grid[data-columns] {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* --- a compact field --------------------------------------------------- */
+
+.inspection-cell {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.inspection-cell__label {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.3;
+ color: var(--ins-text);
+}
+
+.inspection-required {
+ color: var(--ins-fail);
+ margin-left: 0.125rem;
+}
+
+.inspection-cell__hint {
+ font-size: 0.75rem;
+ line-height: 1.4;
+ color: var(--ins-text-muted);
+ white-space: pre-wrap;
+}
+
+.inspection-cell__control {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ width: 100%;
+}
+
+.inspection-cell__control > .fleetbase-model-select,
+.inspection-cell__control > .ember-basic-dropdown {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.inspection-unit {
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.75rem;
+ color: var(--ins-text-muted);
+}
+
+.inspection-note {
+ font-size: 0.75rem;
+ color: var(--ins-text-faint);
+}
+
+/* A required answer that is still missing says so on its own edge. */
+.inspection-input--outstanding.form-input,
+.inspection-input--outstanding .ember-power-select-trigger {
+ border-color: var(--ins-warn);
+}
+
+/* --- the pass / fail / n-a and severity segmented controls -------------- */
+
+.inspection-choice {
+ display: flex;
+ align-items: stretch;
+ min-height: 2.25rem;
+ border: 1px solid var(--ins-border);
+ border-radius: 0.375rem;
+ overflow: hidden;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+/* On a promoted band the control is pushed to the right at its natural size. */
+.inspection-choice--auto {
+ flex: 0 0 auto;
+ margin-left: auto;
+}
+
+.inspection-choice__option {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.375rem 0.5rem;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ line-height: 1;
+ color: var(--ins-text-muted);
+ cursor: pointer;
+ white-space: nowrap;
+ transition:
+ background-color 0.12s ease,
+ color 0.12s ease;
+}
+
+.inspection-choice--auto .inspection-choice__option {
+ flex: 0 0 auto;
+ padding: 0 0.75rem;
+}
+
+.inspection-choice__option + .inspection-choice__option {
+ border-left: 1px solid var(--ins-border);
+}
+
+.inspection-choice__option:hover:not(:disabled) {
+ color: var(--ins-text);
+}
+
+.inspection-choice__option:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: -2px;
+}
+
+.inspection-choice__option:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.inspection-choice__option[aria-pressed='true'] {
+ font-weight: 600;
+ color: #fff;
+ background-color: var(--ins-na);
+}
+
+.inspection-choice__option[aria-pressed='true'][data-answer='pass'] {
+ background-color: var(--ins-pass);
+}
+
+.inspection-choice__option[aria-pressed='true'][data-answer='fail'],
+.inspection-choice__option[aria-pressed='true'][data-answer='severity'] {
+ background-color: var(--ins-fail);
+}
+
+/* --- a promoted band ---------------------------------------------------- */
+
+.inspection-band {
+ border: 1px solid var(--ins-border);
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+
+.inspection-band__head {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.6875rem 0.75rem;
+}
+
+.inspection-band__label {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.3;
+ color: var(--ins-text);
+ min-width: 0;
+}
+
+.inspection-band__aside {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.5625rem;
+ flex-shrink: 0;
+}
+
+.inspection-band__body {
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+}
+
+.inspection-band--stacked .inspection-band__body {
+ padding-top: 0;
+}
+
+/* A failure keeps the same band, in red, with its own detail below. */
+.inspection-band--defect {
+ border-color: var(--ins-fail);
+ background-color: var(--ins-fail-fill);
+}
+
+.inspection-band--defect .inspection-band__head {
+ border-bottom: 1px solid var(--ins-fail-edge);
+ padding-right: 0.75rem;
+}
+
+.inspection-chip {
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.625rem;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ins-fail-text);
+ border: 1px solid var(--ins-fail-edge);
+ border-radius: 0.1875rem;
+ padding: 0.25rem 0.3125rem;
+}
+
+.inspection-band--defect .form-input {
+ border-color: var(--ins-fail-edge);
+ background-color: transparent;
+}
+
+.inspection-band--defect .form-input:focus {
+ border-color: var(--ins-fail);
+}
+
+.inspection-band--defect .inspection-choice,
+.inspection-band--defect .inspection-slot {
+ border-color: var(--ins-fail-edge);
+}
+
+.inspection-defect__row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.625rem;
+ align-items: center;
+}
+
+@container (width <= 560px) {
+ .inspection-defect__row {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* The unsafe switch is a pill, not a bare toggle: it is the gravest thing here. */
+.inspection-unsafe {
+ display: flex;
+ align-items: center;
+ gap: 0.4375rem;
+ min-height: 2.25rem;
+ padding: 0 0.625rem;
+ border: 1px solid var(--ins-fail);
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--ins-fail-text);
+ white-space: nowrap;
+}
+
+.inspection-unsafe[data-on='true'] {
+ background-color: var(--ins-fail-fill-strong);
+}
+
+/* --- photo slots -------------------------------------------------------- */
+
+.inspection-slots {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.inspection-slot {
+ display: block;
+ position: relative;
+ width: 60px;
+ height: 44px;
+ border-radius: 0.3125rem;
+ border: 1px solid var(--ins-border);
+ overflow: hidden;
+ flex-shrink: 0;
+ background: repeating-linear-gradient(45deg, var(--ins-hatch-a) 0 6px, var(--ins-hatch-b) 6px 12px);
+}
+
+.inspection-slot img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.inspection-slot__icon {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ color: var(--ins-text-faint);
+}
+
+.inspection-slot__remove {
+ position: absolute;
+ top: 0;
+ right: 0;
+}
+
+/* The empty slot invites the next photo rather than sitting as a button. */
+.inspection-slot--add {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px dashed var(--ins-border-strong);
+ background: none;
+ cursor: pointer;
+ font-size: 1.125rem;
+ line-height: 1;
+ color: var(--ins-text-faint);
+ padding: 0;
+ text-decoration: none;
+}
+
+.inspection-slot--add:hover {
+ color: var(--ins-text);
+ border-color: var(--ins-text-faint);
+}
+
+.inspection-slots__note {
+ margin-left: auto;
+ text-align: right;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ line-height: 1.4;
+ text-transform: uppercase;
+ color: var(--ins-text-faint);
+}
+
+/* --- the foot ----------------------------------------------------------- */
+
+.inspection-foot {
+ border-top: 1px solid var(--ins-border);
+ background-color: var(--ins-bg-sunken);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+}
+
+.inspection-tallies {
+ display: flex;
+ align-items: stretch;
+ gap: 0.5rem;
+}
+
+.inspection-tally {
+ flex: 1 1 0;
+ min-width: 0;
+ background-color: var(--ins-bg);
+ border: 1px solid var(--ins-border);
+ border-left: 3px solid var(--ins-na);
+ border-radius: 0.375rem;
+ padding: 0.625rem 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3125rem;
+}
+
+.inspection-tally[data-kind='passed'] {
+ border-left-color: var(--ins-pass);
+}
+
+.inspection-tally[data-kind='failed'] {
+ border-left-color: var(--ins-fail);
+}
+
+.inspection-tally[data-kind='outstanding'] {
+ border-left-color: var(--ins-warn);
+}
+
+.inspection-tally[data-kind='outstanding'][data-any='true'] {
+ border-color: var(--ins-warn);
+ border-left-color: var(--ins-warn);
+}
+
+.inspection-tally__value {
+ font-size: 1.25rem;
+ font-weight: 700;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+ color: var(--ins-text);
+}
+
+.inspection-tally[data-kind='outstanding'][data-any='true'] .inspection-tally__value {
+ color: var(--ins-warn-text);
+}
+
+.inspection-tally__label {
+ font-size: 0.6875rem;
+ font-weight: 500;
+ line-height: 1;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ins-text-muted);
+}
+
+/*
+ * On a phone, four tallies in a row leave "Outstanding" no room: two by two.
+ * A grid rather than wrapping flex items at half width, which only pairs up
+ * under border-box sizing; with padding added to the basis they stack one high.
+ */
+@container (width <= 420px) {
+ .inspection-tallies {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.inspection-tally[data-kind='outstanding'][data-any='true'] .inspection-tally__label {
+ color: var(--ins-warn-strong);
+}
+
+.inspection-banner {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.6875rem 0.75rem;
+ border: 1px solid var(--ins-warn);
+ border-radius: 0.375rem;
+ background-color: var(--ins-bg);
+ font-size: 0.75rem;
+ line-height: 1.4;
+ color: var(--ins-warn-text);
+}
+
+.inspection-banner--unsafe {
+ border-color: var(--ins-fail);
+ background-color: var(--ins-fail-fill);
+ color: var(--ins-fail-text);
+}
+
+body[data-theme='dark'] .inspection-banner--unsafe {
+ background-color: #3f1d1d;
+}
+
+.inspection-banner__chip {
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #fff;
+ background-color: var(--ins-fail);
+ border-radius: 0.25rem;
+ padding: 0.3125rem 0.4375rem;
+}
+
+.inspection-banner__jump {
+ appearance: none;
+ border: 0;
+ background: none;
+ margin-left: auto;
+ flex-shrink: 0;
+ padding: 0;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.inspection-banner__jump:hover {
+ text-decoration: underline;
+}
+
+/* A jumped-to field is held for a moment so the eye can find it. */
+.inspection-cell--targeted,
+.inspection-band--targeted {
+ animation: inspection-target 1.6s ease-out;
+}
+
+@keyframes inspection-target {
+ 0%,
+ 60% {
+ box-shadow: 0 0 0 2px var(--ins-warn);
+ }
+
+ 100% {
+ box-shadow: 0 0 0 2px transparent;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .inspection-cell--targeted,
+ .inspection-band--targeted {
+ animation: none;
+ box-shadow: 0 0 0 2px var(--ins-warn);
+ }
+}
+
+/*
+ * The sheet sits inside an overlay panel that has its own edge. Without an
+ * inset its borders land on that edge and read as a double rule.
+ */
+.inspection-sheet-inset {
+ padding: 0.75rem;
+}
+
+/* ==========================================================================
+ Inspection links
+ --------------------------------------------------------------------------
+ A generated link used to exist only as a toast. These are the rows that
+ replaced it: one per link, with the link itself, who and what it was for,
+ and whether it still works.
+ ========================================================================== */
+
+.inspection-link-list {
+ --inspection-border: #e5e7eb;
+ --inspection-surface: #fff;
+ --inspection-surface-sunken: #f9fafb;
+ --inspection-text: #111827;
+ --inspection-text-muted: #6b7280;
+}
+
+body[data-theme='dark'] .inspection-link-list {
+ --inspection-border: #374151;
+ --inspection-surface: #1f2937;
+ --inspection-surface-sunken: #1f2937;
+ --inspection-text: #f9fafb;
+ --inspection-text-muted: #9ca3af;
+}
+
+.inspection-link-list__empty {
+ padding: 0.75rem;
+ font-size: 0.75rem;
+ color: var(--inspection-text-muted);
+}
+
+/* The modal shows the list inside its own bordered block. */
+.inspection-link-panel {
+ border: 1px solid var(--inspection-border, #e5e7eb);
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+body[data-theme='dark'] .inspection-link-panel {
+ border-color: #374151;
+}
+
+.inspection-link-panel__header {
+ padding: 0.5rem 0.625rem;
+ border-bottom: 1px solid var(--inspection-border, #e5e7eb);
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #374151;
+}
+
+body[data-theme='dark'] .inspection-link-panel__header {
+ border-bottom-color: #374151;
+ color: #e5e7eb;
+}
+
+.inspection-link {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ padding: 0.5rem 0.625rem;
+}
+
+.inspection-link + .inspection-link {
+ border-top: 1px solid var(--inspection-border);
+}
+
+/* A link that can no longer be used recedes rather than disappearing. */
+.inspection-link:not([data-state='active']) {
+ opacity: 0.6;
+}
+
+.inspection-link__head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.inspection-link__state {
+ flex-shrink: 0;
+ border-radius: 9999px;
+ padding: 0.125rem 0.5rem;
+ font-size: 0.625rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ background-color: #e5e7eb;
+ color: #374151;
+}
+
+.inspection-link__state[data-state='active'] {
+ background-color: #16a34a;
+ color: #fff;
+}
+
+.inspection-link__state[data-state='expired'],
+.inspection-link__state[data-state='revoked'] {
+ background-color: #dc2626;
+ color: #fff;
+}
+
+.inspection-link__state[data-state='used'] {
+ background-color: #2563eb;
+ color: #fff;
+}
+
+/* Locked after too many wrong PINs: stopped, but by a guesser, not by anyone here. */
+.inspection-link__state[data-state='locked'] {
+ background-color: #d97706;
+ color: #fff;
+}
+
+.inspection-link__for {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--inspection-text);
+ min-width: 0;
+}
+
+.inspection-link__when {
+ margin-left: auto;
+ flex-shrink: 0;
+ font-size: 0.6875rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__url {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ border: 1px solid var(--inspection-border);
+ border-radius: 0.375rem;
+ background-color: var(--inspection-surface);
+ box-shadow: inset 0 1px 2px rgb(0 0 0 / 6%);
+ padding: 0.25rem 0.375rem;
+}
+
+body[data-theme='dark'] .inspection-link__url {
+ box-shadow: inset 0 1px 2px rgb(0 0 0 / 25%);
+}
+
+.inspection-link__code {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-x: auto;
+ white-space: nowrap;
+ font-size: 0.6875rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__note {
+ font-size: 0.6875rem;
+ font-style: italic;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__foot {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.6875rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__sep {
+ padding: 0 0.125rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__warn {
+ color: #b45309;
+}
+
+body[data-theme='dark'] .inspection-link__warn {
+ color: #fbbf24;
+}
+
+/* The PIN that goes with a link, with ways to copy it or send it again. */
+.inspection-link__pin {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.6875rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__pin-label {
+ font-size: 0.625rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.inspection-link__pin-code {
+ margin-right: 0.25rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.875rem;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+ color: var(--inspection-text, #111827);
+}
+
+body[data-theme='dark'] .inspection-link__pin-code {
+ color: #f9fafb;
+}
+
+/* The PIN asked for before a public link shows its form: large, spaced, easy to type on a phone. */
+.public-inspection-pin input.public-inspection-pin__input.form-input {
+ height: auto;
+ padding: 0.625rem 0.75rem;
+ text-align: center;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 1.5rem;
+ letter-spacing: 0.5em;
+ text-indent: 0.5em;
+}
+
+/* On a phone the submit button spans the page, where a thumb can reach it. */
+@media (width <= 639px) {
+ .public-inspection-submit__button,
+ .public-inspection-submit__button > button {
+ width: 100%;
+ justify-content: center;
+ }
+}
+
+/* A link just generated, shown above the form until the modal closes. */
+.inspection-link-generated {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ border: 1px solid #bbf7d0;
+ border-radius: 0.5rem;
+ background-color: #f0fdf4;
+ padding: 0.625rem;
+}
+
+body[data-theme='dark'] .inspection-link-generated {
+ border-color: #166534;
+ background-color: rgb(22 101 52 / 20%);
+}
+
+.inspection-link-generated__title {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: #14532d;
+}
+
+body[data-theme='dark'] .inspection-link-generated__title {
+ color: #bbf7d0;
+}
+
+.inspection-link-generated__help {
+ margin: 0;
+ font-size: 0.6875rem;
+ color: #166534;
+}
+
+body[data-theme='dark'] .inspection-link-generated__help {
+ color: #86efac;
+}
+
+.inspection-link-generated .inspection-link__url {
+ background-color: #fff;
+}
+
+body[data-theme='dark'] .inspection-link-generated .inspection-link__url {
+ background-color: #1f2937;
+ border-color: #374151;
+}
+
+/* A stored answer, read back: the same row, with the value where the control was. */
+.inspection-row__answer {
+ font-size: 0.8125rem;
+ color: var(--inspection-text);
+}
+
+/* A stored answer, read back: the value sits where the control was. */
+.inspection-answer {
+ margin: 0;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ color: var(--ins-text-soft);
+ white-space: pre-wrap;
+ min-width: 0;
+}
+
+/*
+ * A read-back answer keeps the shape of the field it was given in. A cell had
+ * no container at all — a bare label with a value loose beneath it — while a
+ * note or an upload drew a bordered card, so one row read as a card and the
+ * next as stray text. Cells take the same card, quieter than a defect's.
+ */
+.inspection-cell--readonly {
+ gap: 0.375rem;
+
+ /* Each card is as tall as its own answer: stretched to the row's tallest,
+ a lone chip sat in half a card of empty space. */
+ align-self: start;
+ border: 1px solid var(--ins-border);
+ border-radius: 0.375rem;
+ background-color: var(--ins-bg-sunken);
+ padding: 0.625rem 0.75rem;
+}
+
+/*
+ * A pass and a fail are the same card, coloured by the answer. The fail used
+ * to be a band, and a band spans the grid — so one answer ran the full width
+ * while its neighbours sat in columns. It keeps its detail; it just keeps it
+ * in the column the form gave it.
+ */
+.inspection-cell--result {
+ gap: 0;
+ padding: 0;
+ overflow: hidden;
+}
+
+.inspection-cell--result .inspection-cell__head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ padding: 0.5rem 0.625rem;
+}
+
+.inspection-cell--result .inspection-cell__label {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.inspection-cell--result .inspection-cell__body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0 0.625rem 0.5rem;
+}
+
+.inspection-cell--result .inspection-cell__meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+
+.inspection-cell--readonly[data-answer='pass'] {
+ border-color: var(--ins-pass-edge);
+ background-color: var(--ins-pass-fill);
+}
+
+.inspection-cell--readonly[data-answer='pass'] .inspection-cell__head {
+ border-bottom: 0;
+}
+
+.inspection-cell--readonly[data-answer='fail'] {
+ border-color: var(--ins-fail);
+ background-color: var(--ins-fail-fill);
+}
+
+.inspection-cell--readonly[data-answer='fail'] .inspection-cell__body {
+ border-top: 1px solid var(--ins-fail-edge);
+ padding-top: 0.5rem;
+}
+
+/* The chip takes the answer's colour: red is not the only state. */
+.inspection-cell--readonly[data-answer='pass'] .inspection-chip {
+ color: var(--ins-pass-text);
+ border-color: var(--ins-pass-edge);
+}
+
+.inspection-cell--readonly[data-answer='na'] .inspection-chip {
+ color: var(--ins-text-muted);
+ border-color: var(--ins-border);
+}
+
+.inspection-cell--readonly .inspection-cell__label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ color: var(--ins-text-muted);
+}
+
+/* The answer itself is what the eye should land on. */
+.inspection-cell--readonly .inspection-answer {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--ins-text);
+}
+
+/* ==========================================================================
+ Defect flyout
+ --------------------------------------------------------------------------
+ A failed check keeps its cell. Its severity, unsafe flag, comment and
+ photos open in a panel anchored to that cell, so failing a check can never
+ change the layout of the sheet. Closing it leaves a chip in the cell, and
+ the defects tray at the foot keeps the record.
+ ========================================================================== */
+
+/* The open field is ringed with an outline, which takes no space: opening a
+ flyout must not move anything either. */
+.inspection-cell--flyout-open {
+ outline: 2px solid var(--ins-fail);
+ outline-offset: 6px;
+ border-radius: 0.375rem;
+}
+
+/* --- the chip a closed failure leaves in its cell ----------------------- */
+
+.inspection-defect-chip {
+ appearance: none;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem 0.5rem;
+ width: 100%;
+ padding: 0.4375rem 0.625rem;
+ border: 1px solid var(--ins-fail-edge);
+ border-left: 3px solid var(--ins-fail);
+ border-radius: 0.375rem;
+ background-color: var(--ins-fail-fill);
+ color: var(--ins-fail-text);
+ font-size: 0.75rem;
+ line-height: 1.3;
+ text-align: left;
+ cursor: pointer;
+}
+
+.inspection-defect-chip:hover:not(:disabled) {
+ border-color: var(--ins-fail);
+}
+
+.inspection-defect-chip:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: 2px;
+}
+
+.inspection-defect-chip:disabled {
+ cursor: default;
+}
+
+.inspection-defect-chip.is-incomplete {
+ border-left-color: var(--ins-warn);
+}
+
+.inspection-defect-chip__severity {
+ font-size: 0.6875rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.inspection-defect-chip__unsafe {
+ font-family: var(--ins-mono);
+ font-size: 0.625rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #fff;
+ background-color: var(--ins-fail);
+ border-radius: 0.1875rem;
+ padding: 0.1875rem 0.3125rem;
+}
+
+.inspection-defect-chip__meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ color: var(--ins-text-muted);
+}
+
+.inspection-defect-chip__status {
+ font-weight: 600;
+ color: var(--ins-warn-strong);
+}
+
+.inspection-defect-chip__edit {
+ margin-left: auto;
+ color: var(--ins-text-muted);
+}
+
+/* --- the flyout -------------------------------------------------------- */
+
+.inspection-flyout {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--ins-fail-fill);
+ color: var(--ins-text);
+ border: 1px solid var(--ins-fail);
+ border-radius: 0.5rem;
+ box-shadow:
+ 0 12px 32px rgb(0 0 0 / 18%),
+ 0 2px 6px rgb(0 0 0 / 8%);
+ outline: none;
+}
+
+body[data-theme='dark'] .inspection-flyout {
+ box-shadow:
+ 0 16px 40px rgb(0 0 0 / 55%),
+ 0 2px 6px rgb(0 0 0 / 30%);
+}
+
+.inspection-flyout--floating {
+ position: absolute;
+ z-index: 40;
+ width: min(30rem, calc(100% - 1rem));
+ animation: inspection-flyout-in 0.14s ease-out;
+}
+
+.inspection-flyout--floating[data-placement='top'] {
+ animation-name: inspection-flyout-in-above;
+}
+
+/*
+ * Room for the reveal's margin at the very end of the page. An absolutely
+ * placed box extends the scroll area, but its margin does not, so without this
+ * a flyout opened below the last field could only ever sit flush against the
+ * bottom edge of the panel.
+ */
+.inspection-flyout--floating::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: -0.5rem;
+ height: 0.5rem;
+ pointer-events: none;
+}
+
+/* A caret points at the Fail button of the field it belongs to. */
+.inspection-flyout__caret {
+ position: absolute;
+ left: var(--flyout-caret-x, 1.5rem);
+ width: 12px;
+ height: 12px;
+ background-color: var(--ins-fail-fill);
+ border: 1px solid var(--ins-fail);
+ transform: translateX(-50%) rotate(45deg);
+ pointer-events: none;
+}
+
+.inspection-flyout[data-placement='bottom'] .inspection-flyout__caret {
+ top: -7px;
+ border-right: 0;
+ border-bottom: 0;
+}
+
+.inspection-flyout[data-placement='top'] .inspection-flyout__caret {
+ bottom: -7px;
+ border-top: 0;
+ border-left: 0;
+}
+
+.inspection-flyout__head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.625rem 0.75rem;
+ border-bottom: 1px solid var(--ins-fail-edge);
+}
+
+.inspection-flyout__title {
+ min-width: 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.inspection-flyout__close {
+ appearance: none;
+ border: 0;
+ background: none;
+ margin-left: auto;
+ padding: 0.25rem 0.375rem;
+ border-radius: 0.25rem;
+ color: var(--ins-text-muted);
+ cursor: pointer;
+}
+
+.inspection-flyout__close:hover {
+ color: var(--ins-text);
+}
+
+.inspection-flyout__close:focus-visible {
+ outline: 2px solid #2563eb;
+}
+
+.inspection-flyout__body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+ padding: 0.75rem;
+ overflow-y: auto;
+}
+
+.inspection-flyout__foot {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ padding: 0.625rem 0.75rem;
+ border-top: 1px solid var(--ins-fail-edge);
+}
+
+.inspection-flyout__note {
+ margin-right: auto;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ line-height: 1.4;
+ text-transform: uppercase;
+ color: var(--ins-warn-strong);
+}
+
+/* Inside a failure, every control takes the failure's colours. */
+.inspection-flyout .inspection-choice {
+ border-color: var(--ins-fail-edge);
+}
+
+.inspection-flyout .inspection-choice__option + .inspection-choice__option {
+ border-left-color: var(--ins-fail-edge);
+}
+
+.inspection-flyout .inspection-choice__option:hover:not(:disabled) {
+ background-color: var(--ins-fail-fill-strong);
+}
+
+/*
+ * The comment box belongs to the failure. ember-ui styles every console
+ * input with `body[data-theme='dark'] .fleetbase-console .form-input`, which
+ * outranks a plain `.inspection-flyout .form-input` — so an earlier attempt
+ * at this left a grey box inside a red panel. This selector is deliberately
+ * heavier than that rule, and takes its colours from the theme tokens.
+ */
+html body .inspection-flyout textarea.inspection-defect-comment.form-input {
+ background-color: var(--ins-fail-field);
+ border-color: var(--ins-fail-edge);
+ color: var(--ins-text);
+ box-shadow: none;
+}
+
+html body .inspection-flyout textarea.inspection-defect-comment.form-input::placeholder {
+ color: var(--ins-fail-placeholder);
+}
+
+html body .inspection-flyout textarea.inspection-defect-comment.form-input:focus {
+ border-color: var(--ins-fail);
+ box-shadow: 0 0 0 3px var(--ins-fail-ring);
+ outline: none;
+}
+
+/* Photos sit in the failure too: red hatching, a red-edged slot for the next. */
+.inspection-flyout .inspection-slot:not(.inspection-slot--add) {
+ border-color: var(--ins-fail-edge);
+ background: repeating-linear-gradient(45deg, var(--ins-fail-hatch-a) 0 6px, var(--ins-fail-hatch-b) 6px 12px);
+}
+
+.inspection-flyout .inspection-slot--add {
+ border-color: var(--ins-fail-edge);
+ color: var(--ins-fail-text);
+}
+
+.inspection-flyout .inspection-slot--add:hover {
+ border-color: var(--ins-fail);
+ color: var(--ins-fail);
+}
+
+/* --- on a phone, the same content as a bottom sheet --------------------- */
+
+.inspection-flyout--sheet {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1100;
+ max-height: 85vh;
+ border-bottom: 0;
+ border-radius: 0.875rem 0.875rem 0 0;
+ padding-bottom: env(safe-area-inset-bottom);
+ animation: inspection-sheet-in 0.2s ease-out;
+}
+
+/* A grab handle, so it reads as something that came up and will go down. */
+.inspection-flyout--sheet::before {
+ content: '';
+ display: block;
+ width: 2.5rem;
+ height: 0.25rem;
+ margin: 0.5rem auto 0;
+ border-radius: 9999px;
+ background-color: var(--ins-border-strong);
+}
+
+.inspection-flyout-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 1099;
+ background-color: rgb(0 0 0 / 40%);
+ animation: inspection-fade-in 0.2s ease-out;
+}
+
+@keyframes inspection-flyout-in {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes inspection-flyout-in-above {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes inspection-sheet-in {
+ from {
+ transform: translateY(100%);
+ }
+
+ to {
+ transform: none;
+ }
+}
+
+@keyframes inspection-fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .inspection-flyout,
+ .inspection-flyout-backdrop {
+ animation: none;
+ }
+}
+
+/* --- the defects tray -------------------------------------------------- */
+
+.inspection-tray {
+ border: 1px solid var(--ins-border);
+ border-radius: 0.375rem;
+ background-color: var(--ins-bg);
+ overflow: hidden;
+}
+
+.inspection-tray__head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--ins-border);
+ font-size: 0.6875rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ins-text-soft);
+}
+
+.inspection-tray__count {
+ min-width: 1.25rem;
+ padding: 0.125rem 0.375rem;
+ border-radius: 9999px;
+ background-color: var(--ins-fail);
+ color: #fff;
+ font-size: 0.625rem;
+ text-align: center;
+ letter-spacing: 0;
+}
+
+.inspection-tray__row {
+ appearance: none;
+ border: 0;
+ background: none;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.625rem 0.75rem;
+ text-align: left;
+ cursor: pointer;
+ color: var(--ins-text);
+ font-size: 0.8125rem;
+}
+
+.inspection-tray__row + .inspection-tray__row {
+ border-top: 1px solid var(--ins-border);
+}
+
+.inspection-tray__row:hover {
+ background-color: rgb(127 127 127 / 8%);
+}
+
+.inspection-tray__row:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: -2px;
+}
+
+.inspection-tray__severity {
+ flex-shrink: 0;
+ min-width: 4.25rem;
+ padding: 0.25rem 0.375rem;
+ border: 1px solid var(--ins-fail-edge);
+ border-radius: 0.25rem;
+ font-size: 0.625rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-align: center;
+ text-transform: uppercase;
+ color: var(--ins-fail-text);
+}
+
+.inspection-tray__severity[data-severity='high'],
+.inspection-tray__severity[data-severity='critical'] {
+ border-color: var(--ins-fail);
+ background-color: var(--ins-fail);
+ color: #fff;
+}
+
+.inspection-tray__label {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-weight: 600;
+}
+
+.inspection-tray__evidence {
+ flex-shrink: 0;
+ font-size: 0.75rem;
+ color: var(--ins-text-muted);
+}
+
+.inspection-tray__evidence[data-incomplete='true'] {
+ font-weight: 600;
+ color: var(--ins-warn-strong);
+}
+
+.inspection-tray__action {
+ flex-shrink: 0;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--ins-fail-text);
+}
+
+@container (width <= 480px) {
+ .inspection-tray__row {
+ flex-wrap: wrap;
+ }
+
+ .inspection-tray__evidence {
+ flex-basis: 100%;
+ order: 5;
+ }
+}
+
+/* ==========================================================================
+ Radar — the triage list that replaced the Resources Hub. Sits on the
+ control-hub shell (header band, body width) and adds the pills, tabs,
+ rows and drawer. Light rules first, dark overrides at the end.
+ ========================================================================== */
+
+.fleet-ops-radar-body {
+ max-width: 88rem;
+ gap: 0.625rem;
+ padding-bottom: 1.25rem;
+}
+
+.fleet-ops-radar-header-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem 1rem;
+ width: 100%;
+ min-width: 0;
+}
+
+.fleet-ops-radar-header-summary {
+ font-size: 0.75rem;
+ color: #6b7280;
+ align-self: flex-end;
+ padding-bottom: 0.2rem;
+}
+
+.fleet-ops-radar-header-actions {
+ margin-left: auto;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.5rem;
+}
+
+/* Every control in the header shares one height. */
+.fleet-ops-radar-header-actions .btn-wrapper,
+.fleet-ops-radar-header-actions .btn-wrapper > .btn {
+ height: 2rem;
+}
+
+.fleet-ops-radar-header-actions .btn-wrapper > .btn {
+ display: inline-flex;
+ align-items: center;
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+.fleet-ops-radar-search {
+ position: relative;
+ width: 12rem;
+ height: 2rem;
+}
+
+.fleet-ops-radar-search svg {
+ position: absolute;
+ left: 0.6rem;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 0.7rem;
+ color: #9ca3af;
+ pointer-events: none;
+}
+
+/* Beats ember-ui's `input.form-input.form-input-sm` padding so the text clears the icon. */
+.fleet-ops-radar-search input.form-input.form-input-sm {
+ height: 2rem;
+ padding-left: 1.85rem;
+ padding-right: 1.85rem;
+}
+
+.fleet-ops-radar-search kbd {
+ position: absolute;
+ right: 0.45rem;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.fleet-ops-radar-view-toggle {
+ display: inline-flex;
+ align-items: stretch;
+ box-sizing: border-box;
+ height: 2rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background: #fff;
+ padding: 2px;
+ gap: 2px;
+}
+
+.fleet-ops-radar-view-toggle-button {
+ border: 0;
+ background: transparent;
+ border-radius: 0.25rem;
+ padding: 0 0.6rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: #6b7280;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ cursor: pointer;
+}
+
+.fleet-ops-radar-view-toggle-button.is-active {
+ background: #3485e2;
+ color: #fff;
+}
+
+.fleet-ops-radar-sources-warning {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border: 1px solid #fde68a;
+ background: #fffbeb;
+ color: #b45309;
+ border-radius: 0.375rem;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.75rem;
+}
+
+/* Morning brief: score, category rows, prose, decision cards. */
+.fleet-ops-radar-briefing {
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
+ padding: 0.75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-briefing-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-briefing-toggle {
+ margin-left: auto;
+ border: 0;
+ background: transparent;
+ color: #6b7280;
+ font-size: 0.72rem;
+ font-weight: 600;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.fleet-ops-radar-briefing-loading {
+ display: flex;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.fleet-ops-radar-briefing-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 1rem;
+}
+
+@media (width >= 1024px) {
+ .fleet-ops-radar-briefing-grid {
+ grid-template-columns: 11rem minmax(0, 1fr);
+ grid-template-areas:
+ 'score categories'
+ 'prose prose'
+ 'decisions decisions';
+ }
+
+ .fleet-ops-radar-briefing-score {
+ grid-area: score;
+ }
+
+ .fleet-ops-radar-briefing-categories {
+ grid-area: categories;
+ }
+
+ .fleet-ops-radar-briefing-prose {
+ grid-area: prose;
+ }
+
+ .fleet-ops-radar-briefing-decisions {
+ grid-area: decisions;
+ }
+}
+
+@media (width >= 1280px) {
+ .fleet-ops-radar-briefing-grid {
+ grid-template-columns: 11rem minmax(0, 1fr) minmax(20rem, 26rem);
+ grid-template-areas:
+ 'score categories decisions'
+ 'prose prose decisions';
+ }
+}
+
+.fleet-ops-radar-briefing-score-value {
+ display: flex;
+ align-items: baseline;
+ gap: 0.25rem;
+}
+
+.fleet-ops-radar-briefing-score-number {
+ font-size: 2.25rem;
+ font-weight: 900;
+ line-height: 1;
+ color: #111827;
+}
+
+.fleet-ops-radar-briefing-score-of {
+ font-size: 0.8rem;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-briefing-score-delta {
+ margin-top: 0.4rem;
+}
+
+.fleet-ops-radar-briefing-categories {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
+ gap: 0.5rem 0.9rem;
+}
+
+.fleet-ops-radar-briefing-category {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.72rem;
+}
+
+.fleet-ops-radar-briefing-category-head {
+ display: flex;
+ justify-content: space-between;
+ font-weight: 700;
+ color: #111827;
+}
+
+.fleet-ops-radar-briefing-category-score {
+ font-weight: 900;
+}
+
+.fleet-ops-radar-briefing-category-bar {
+ height: 6px;
+ border-radius: 3px;
+ background: #f3f4f6;
+ overflow: hidden;
+}
+
+.fleet-ops-radar-briefing-category-bar > span {
+ display: block;
+ height: 100%;
+ border-radius: 3px;
+ background: #059669;
+ transition: width 0.3s ease;
+}
+
+.fleet-ops-radar-briefing-category-bar > span.is-warn {
+ background: #d97706;
+}
+
+.fleet-ops-radar-briefing-category-bar > span.is-bad {
+ background: #e11d48;
+}
+
+.fleet-ops-radar-briefing-category-gaps {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-briefing-link {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ font: inherit;
+ font-weight: 600;
+ color: #1c6cc7;
+ cursor: pointer;
+ display: inline;
+}
+
+.fleet-ops-radar-briefing-link:hover {
+ text-decoration: underline;
+}
+
+.fleet-ops-radar-briefing-link:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+.fleet-ops-radar-briefing-prose p {
+ margin: 0;
+ font-size: 0.8125rem;
+ line-height: 1.65;
+ color: #374151;
+ text-wrap: pretty;
+}
+
+.fleet-ops-radar-briefing-decisions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.fleet-ops-radar-briefing-decisions-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.fleet-ops-radar-decision {
+ border: 1px solid #e5e7eb;
+ border-left-width: 3px;
+ border-radius: 0.375rem;
+ padding: 0.6rem 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ background: #fff;
+}
+
+.fleet-ops-radar-decision.is-critical {
+ border-left-color: #e11d48;
+}
+
+.fleet-ops-radar-decision.is-warning {
+ border-left-color: #d97706;
+}
+
+.fleet-ops-radar-decision.is-info {
+ border-left-color: #3485e2;
+}
+
+.fleet-ops-radar-decision-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.fleet-ops-radar-decision-title {
+ font-size: 0.8125rem;
+ font-weight: 700;
+ color: #111827;
+}
+
+.fleet-ops-radar-decision-reasoning {
+ margin: 0;
+ font-size: 0.75rem;
+ line-height: 1.55;
+ color: #4b5563;
+}
+
+.fleet-ops-radar-decision-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.72rem;
+}
+
+.fleet-ops-radar-briefing-strip {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ padding: 0.4rem 0.75rem;
+ font-size: 0.72rem;
+ color: #374151;
+ background: #f9fafb;
+}
+
+.fleet-ops-radar-briefing-strip.is-confirmed svg {
+ color: #059669;
+}
+
+.fleet-ops-radar-briefing-strip.is-snoozed svg {
+ color: #d97706;
+}
+
+.fleet-ops-radar-briefing-strip-label {
+ font-weight: 700;
+}
+
+.fleet-ops-radar-briefing-strip-title {
+ color: #6b7280;
+ flex: 1 1 auto;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing,
+body[data-theme='dark'] .fleet-ops-radar-decision {
+ background: #1f2937;
+ border-color: #374151;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-strip {
+ background: #111827;
+ border-color: #374151;
+ color: #d1d5db;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-score-number,
+body[data-theme='dark'] .fleet-ops-radar-briefing-category-head,
+body[data-theme='dark'] .fleet-ops-radar-decision-title {
+ color: #f9fafb;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-score-of,
+body[data-theme='dark'] .fleet-ops-radar-briefing-category-gaps,
+body[data-theme='dark'] .fleet-ops-radar-briefing-toggle,
+body[data-theme='dark'] .fleet-ops-radar-briefing-strip-title {
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-prose p,
+body[data-theme='dark'] .fleet-ops-radar-decision-reasoning {
+ color: #d1d5db;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-category-bar {
+ background: #374151;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-briefing-link {
+ color: #93c5fd;
+}
+
+/* Count pills: the only filter UI. */
+.fleet-ops-radar-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+
+.fleet-ops-radar-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ border: 1px solid #e5e7eb;
+ background: #fff;
+ color: #374151;
+ border-radius: 999px;
+ padding: 0.3rem 0.7rem;
+ font-size: 0.75rem;
+ line-height: 1;
+ cursor: pointer;
+ transition:
+ border-color 0.12s ease,
+ background-color 0.12s ease;
+}
+
+.fleet-ops-radar-pill:hover {
+ border-color: #bfdbfe;
+}
+
+.fleet-ops-radar-pill.is-active {
+ background: #3485e2;
+ border-color: #3485e2;
+ color: #fff;
+}
+
+.fleet-ops-radar-pill.is-empty:not(.is-active) {
+ color: #9ca3af;
+}
+
+.fleet-ops-radar-pill-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 3px;
+ background: #e11d48;
+}
+
+.fleet-ops-radar-pill.is-active .fleet-ops-radar-pill-dot {
+ background: #fecdd3;
+}
+
+.fleet-ops-radar-pill-count {
+ font-weight: 800;
+}
+
+.fleet-ops-radar-pill-clear {
+ font-size: 0.7rem;
+ opacity: 0.7;
+}
+
+/* Status tabs and saved views. */
+.fleet-ops-radar-view-bar {
+ display: flex;
+ align-items: flex-end;
+ gap: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.fleet-ops-radar-tabs {
+ display: flex;
+ gap: 1rem;
+}
+
+.fleet-ops-radar-tab {
+ border: 0;
+ background: transparent;
+ padding: 0 1px 0.55rem;
+ border-bottom: 2px solid transparent;
+ font-size: 0.8125rem;
+ color: #6b7280;
+ cursor: pointer;
+ display: inline-flex;
+ gap: 0.35rem;
+}
+
+.fleet-ops-radar-tab.is-active {
+ color: #111827;
+ font-weight: 700;
+ border-bottom-color: #3485e2;
+}
+
+.fleet-ops-radar-tab-count {
+ color: #6b7280;
+ font-weight: 600;
+}
+
+.fleet-ops-radar-view-bar-right {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding-bottom: 0.4rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-view-bar-label {
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.fleet-ops-radar-saved-view {
+ display: inline-flex;
+ align-items: center;
+ box-sizing: border-box;
+ height: 1.625rem;
+ gap: 0.35rem;
+ border: 1px solid #e5e7eb;
+ background: #fff;
+ border-radius: 0.375rem;
+ padding: 0 0.55rem;
+ line-height: 1;
+ font-size: 0.75rem;
+ color: #374151;
+ cursor: pointer;
+}
+
+.fleet-ops-radar-saved-view.is-active {
+ border-color: #3485e2;
+ color: #1c6cc7;
+ background: #eff6ff;
+}
+
+.fleet-ops-radar-saved-view-apply,
+.fleet-ops-radar-saved-view-delete {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.fleet-ops-radar-saved-view-delete {
+ opacity: 0.6;
+ font-size: 0.7rem;
+}
+
+.fleet-ops-radar-saved-view-delete:hover {
+ opacity: 1;
+}
+
+.fleet-ops-radar-saved-view-save {
+ font: inherit;
+ font-size: 0.75rem;
+}
+
+/* The bulk bar: appears above the list while something is selected. */
+.fleet-ops-radar-bulk-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.6rem;
+ border: 1px solid #bfdbfe;
+ background: #eff6ff;
+ border-radius: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.78rem;
+ color: #1e3a8a;
+}
+
+.fleet-ops-radar-bulk-bar .fleet-ops-radar-row-check {
+ color: #3485e2;
+}
+
+.fleet-ops-radar-bulk-copy {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.2;
+}
+
+.fleet-ops-radar-bulk-actions {
+ margin-left: auto;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-bulk-bar {
+ background: rgba(28, 108, 199, 0.18);
+ border-color: #1c6cc7;
+ color: #bfdbfe;
+}
+
+/* The list card, its group headers and rows. */
+.fleet-ops-radar-list {
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
+ overflow: hidden;
+}
+
+.fleet-ops-radar-group-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.45rem 0.75rem;
+ border-bottom: 1px solid #e5e7eb;
+ background: #f9fafb;
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-group-header.is-overdue {
+ background: #fff1f2;
+ border-bottom-color: #fecdd3;
+ color: #be123c;
+}
+
+.fleet-ops-radar-group-header.is-today {
+ background: #fffbeb;
+ border-bottom-color: #fde68a;
+ color: #b45309;
+}
+
+.fleet-ops-radar-group-count {
+ font-weight: 800;
+}
+
+.fleet-ops-radar-row {
+ display: grid;
+ grid-template-columns: 1.25rem 5.25rem 8px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.55rem 0.75rem;
+ border-bottom: 1px solid #f3f4f6;
+ position: relative;
+ outline: none;
+ transition: background-color 0.12s ease;
+}
+
+.fleet-ops-radar-row:last-child {
+ border-bottom: 0;
+}
+
+.fleet-ops-radar-row:hover,
+.fleet-ops-radar-row.is-focused {
+ background: #f9fafb;
+}
+
+.fleet-ops-radar-row.is-focused {
+ box-shadow: inset 3px 0 0 #3485e2;
+}
+
+.fleet-ops-radar-row.is-selected {
+ background: #eff6ff;
+}
+
+.fleet-ops-radar-row-check {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ background: transparent;
+ padding: 0;
+ color: #9ca3af;
+ cursor: pointer;
+ font-size: 0.85rem;
+ line-height: 1;
+}
+
+.fleet-ops-radar-row.is-selected .fleet-ops-radar-row-check {
+ color: #3485e2;
+}
+
+.fleet-ops-radar-row-check > div {
+ align-items: center;
+}
+
+.fleet-ops-radar-row-chip .status-badge > span {
+ width: 100%;
+ justify-content: center;
+ font-size: 0.6rem;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 2px 5px;
+}
+
+.fleet-ops-radar-severity {
+ width: 8px;
+ height: 8px;
+ border-radius: 4px;
+ background: #3485e2;
+}
+
+.fleet-ops-radar-severity.is-critical {
+ background: #e11d48;
+}
+
+.fleet-ops-radar-severity.is-warning {
+ background: #d97706;
+}
+
+.fleet-ops-radar-row.is-acknowledged .fleet-ops-radar-severity {
+ opacity: 0.35;
+}
+
+.fleet-ops-radar-row-main {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.35rem 0.6rem;
+ min-width: 0;
+}
+
+.fleet-ops-radar-row-title {
+ border: 0;
+ background: transparent;
+ padding: 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: #111827;
+ text-align: left;
+ cursor: pointer;
+}
+
+.fleet-ops-radar-row-title:hover {
+ color: #1c6cc7;
+}
+
+.fleet-ops-radar-row.is-acknowledged .fleet-ops-radar-row-title {
+ font-weight: 500;
+ color: #4b5563;
+}
+
+.fleet-ops-radar-subject {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ background: #f3f4f6;
+ border-radius: 999px;
+ padding: 2px 8px 2px 3px;
+ font-size: 0.72rem;
+ color: #374151;
+ max-width: 18rem;
+}
+
+.fleet-ops-radar-subject-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.fleet-ops-radar-avatar {
+ width: 18px;
+ height: 18px;
+ border-radius: 9px;
+ background: #d1d5db;
+ color: #111827;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.55rem;
+ font-weight: 800;
+ overflow: hidden;
+ flex: 0 0 auto;
+}
+
+.fleet-ops-radar-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.fleet-ops-radar-avatar.is-user {
+ background: #dbeafe;
+ color: #1d4ed8;
+}
+
+.fleet-ops-radar-row-meta {
+ font-size: 0.72rem;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-row-state {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ font-size: 0.72rem;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-row-state svg {
+ font-size: 0.65rem;
+}
+
+.fleet-ops-radar-row-side {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ justify-content: flex-end;
+}
+
+.fleet-ops-radar-row-due {
+ font-size: 0.72rem;
+ font-weight: 700;
+ color: #374151;
+ white-space: nowrap;
+}
+
+.fleet-ops-radar-row-due.is-overdue {
+ color: #dc2626;
+}
+
+.fleet-ops-radar-row-due.is-none {
+ color: #9ca3af;
+ font-weight: 500;
+}
+
+.fleet-ops-radar-row-assignee {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.72rem;
+ color: #374151;
+ min-width: 6.5rem;
+}
+
+.fleet-ops-radar-row-assignee.is-empty {
+ color: #9ca3af;
+}
+
+/* The quick-action rail: fixed width so the row never reflows on hover. */
+.fleet-ops-radar-row-rail {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ opacity: 0;
+ transition: opacity 0.12s ease;
+}
+
+.fleet-ops-radar-row:hover .fleet-ops-radar-row-rail,
+.fleet-ops-radar-row.is-focused .fleet-ops-radar-row-rail,
+.fleet-ops-radar-row-rail:focus-within {
+ opacity: 1;
+}
+
+/* The snooze DropdownButton wraps its button in a trigger; flatten it so it lines up. */
+.fleet-ops-radar-row-rail .ember-basic-dropdown,
+.fleet-ops-radar-row-rail .ember-basic-dropdown-trigger,
+.fleet-ops-radar-bulk-actions .ember-basic-dropdown,
+.fleet-ops-radar-bulk-actions .ember-basic-dropdown-trigger {
+ display: inline-flex;
+ align-items: center;
+}
+
+.fleet-ops-radar-row-rail .btn-wrapper,
+.fleet-ops-radar-row-rail .btn-wrapper > .btn,
+.fleet-ops-radar-bulk-actions .btn-wrapper,
+.fleet-ops-radar-bulk-actions .btn-wrapper > .btn {
+ height: 1.75rem;
+}
+
+.fleet-ops-radar-row-rail .btn-wrapper > .btn,
+.fleet-ops-radar-bulk-actions .btn-wrapper > .btn {
+ display: inline-flex;
+ align-items: center;
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+.fleet-ops-radar-row-open {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ background: transparent;
+ color: #9ca3af;
+ cursor: pointer;
+ padding: 0.15rem;
+ font-size: 0.7rem;
+ line-height: 1;
+}
+
+.fleet-ops-radar-row-open svg {
+ width: 0.7rem;
+ height: 0.7rem;
+}
+
+.fleet-ops-radar-row-open:hover {
+ color: #1c6cc7;
+}
+
+@media (width <= 1023px) {
+ .fleet-ops-radar-row {
+ grid-template-columns: 1.25rem minmax(0, 1fr);
+ }
+
+ .fleet-ops-radar-row-chip,
+ .fleet-ops-radar-severity {
+ display: none;
+ }
+
+ .fleet-ops-radar-row-side {
+ grid-column: 2;
+ justify-content: flex-start;
+ }
+
+ .fleet-ops-radar-row-rail {
+ opacity: 1;
+ }
+}
+
+/* Footer: keys and paging, a full-width bar pinned to the bottom of the page while the list scrolls. */
+.fleet-ops-radar-footer {
+ position: sticky;
+ bottom: 0;
+ z-index: 20;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem 1.25rem;
+ width: 100%;
+ box-sizing: border-box;
+
+ /* The sidebar's attribution footer: 0.75rem padding either side of a
+ 0.875rem line plus its 1px top border, so the two borders line up. */
+ min-height: 2.4375rem;
+ margin: 0;
+ padding: 0.25rem 1rem;
+ background: #fff;
+ border-top: 1px solid #e5e7eb;
+ box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
+ font-size: 0.72rem;
+ color: #6b7280;
+}
+
+@media (width >= 768px) {
+ .fleet-ops-radar-footer {
+ flex-wrap: nowrap;
+ height: 2.4375rem;
+ padding: 0 1.5rem;
+ overflow: hidden;
+ }
+}
+
+.fleet-ops-radar-footer-meta {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-footer-meta .fleet-ops-radar-paging {
+ margin-left: 0;
+}
+
+.fleet-ops-radar-footer .btn-wrapper,
+.fleet-ops-radar-footer .btn-wrapper > .btn {
+ height: 1.5rem;
+}
+
+.fleet-ops-radar-footer .btn-wrapper > .btn {
+ display: inline-flex;
+ align-items: center;
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+.fleet-ops-radar-keys {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-keys span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.fleet-ops-radar kbd {
+ border: 1px solid #d1d5db;
+ border-bottom-width: 2px;
+ border-radius: 4px;
+ background: #f9fafb;
+ padding: 0 5px;
+ font-family: inherit;
+ font-size: 0.65rem;
+ font-weight: 700;
+ color: #374151;
+ line-height: 1.3;
+}
+
+.fleet-ops-radar-paging {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+/* Empty states. */
+.fleet-ops-radar-empty {
+ padding: 2.5rem 1.5rem;
+ text-align: center;
+}
+
+.fleet-ops-radar-empty-icon {
+ width: 2.75rem;
+ height: 2.75rem;
+ border-radius: 0.5rem;
+ background: #ecfdf5;
+ color: #047857;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.1rem;
+ margin-bottom: 0.75rem;
+}
+
+.fleet-ops-radar-empty-icon.is-neutral {
+ background: #f3f4f6;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-empty-title {
+ font-size: 0.9375rem;
+ font-weight: 700;
+ color: #111827;
+}
+
+.fleet-ops-radar-empty-hint {
+ font-size: 0.8125rem;
+ color: #6b7280;
+ margin-top: 0.25rem;
+}
+
+.fleet-ops-radar-empty-schedule {
+ margin: 1rem auto 0;
+ max-width: 28rem;
+ text-align: left;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+}
+
+.fleet-ops-radar-empty-schedule > div {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.45rem 0.75rem;
+ border-bottom: 1px solid #f3f4f6;
+ color: #374151;
+}
+
+.fleet-ops-radar-empty-schedule > div:last-child {
+ border-bottom: 0;
+}
+
+.fleet-ops-radar-empty-schedule time {
+ color: #6b7280;
+ white-space: nowrap;
+}
+
+/* Drawer. */
+.fleet-ops-radar-drawer-section {
+ padding: 0.875rem 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.fleet-ops-radar-drawer-label {
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #6b7280;
+ margin-bottom: 0.4rem;
+}
+
+.fleet-ops-radar-drawer-title {
+ font-size: 0.9375rem;
+ font-weight: 700;
+ color: #111827;
+ line-height: 1.35;
+}
+
+.fleet-ops-radar-drawer-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+
+.fleet-ops-radar-drawer-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ font-size: 0.78rem;
+ color: #374151;
+}
+
+.fleet-ops-radar-drawer-list > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-drawer-state {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ font-size: 0.78rem;
+ color: #374151;
+}
+
+/* Agenda: overdue band, four lanes on a time scale, later rail, tray. */
+.fleet-ops-radar-agenda {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-agenda-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.fleet-ops-radar-agenda-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.75rem;
+}
+
+@media (width >= 1024px) {
+ .fleet-ops-radar-agenda-grid {
+ grid-template-columns: 11rem minmax(0, 1fr) 10rem;
+ }
+}
+
+.fleet-ops-radar-agenda-band,
+.fleet-ops-radar-agenda-scale,
+.fleet-ops-radar-agenda-tray {
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
+ padding: 0.6rem 0.75rem;
+}
+
+.fleet-ops-radar-agenda-band {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ min-height: 12rem;
+}
+
+.fleet-ops-radar-agenda-band-title {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.65rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #6b7280;
+}
+
+.fleet-ops-radar-agenda-band-title.is-overdue {
+ color: #be123c;
+}
+
+.fleet-ops-radar-agenda-scale {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ overflow-x: auto;
+}
+
+.fleet-ops-radar-agenda-ruler,
+.fleet-ops-radar-agenda-lane {
+ display: grid;
+ grid-template-columns: 7rem minmax(0, 1fr);
+ gap: 0.5rem;
+ align-items: stretch;
+}
+
+.fleet-ops-radar-agenda-lane-label {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.72rem;
+ font-weight: 700;
+ color: #374151;
+ padding: 0.35rem 0;
+}
+
+.fleet-ops-radar-agenda-track {
+ position: relative;
+ min-height: 2.6rem;
+ border-radius: 0.375rem;
+ background: repeating-linear-gradient(to right, #f3f4f6 0, #f3f4f6 1px, transparent 1px, transparent 8.333%);
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.3rem 0;
+}
+
+.fleet-ops-radar-agenda-ruler .fleet-ops-radar-agenda-track {
+ min-height: 1.4rem;
+ background: transparent;
+ border-bottom: 1px solid #e5e7eb;
+ border-radius: 0;
+}
+
+.fleet-ops-radar-agenda-tick {
+ position: absolute;
+ top: 0;
+ transform: translateX(-50%);
+ font-size: 0.65rem;
+ color: #9ca3af;
+}
+
+.fleet-ops-radar-agenda-now {
+ position: absolute;
+ top: 0;
+ bottom: -100vh;
+ width: 2px;
+ background: #3485e2;
+ z-index: 2;
+ pointer-events: none;
+}
+
+.fleet-ops-radar-agenda-now em {
+ position: absolute;
+ top: 0;
+ left: 4px;
+ font-style: normal;
+ font-size: 0.6rem;
+ font-weight: 800;
+ color: #3485e2;
+ background: #fff;
+ padding: 0 3px;
+ border-radius: 3px;
+}
+
+.fleet-ops-radar-agenda-lane.is-drop-target .fleet-ops-radar-agenda-track {
+ outline: 2px dashed #3485e2;
+ outline-offset: -2px;
+}
+
+.fleet-ops-radar-agenda-row {
+ position: relative;
+ height: 1.9rem;
+}
+
+.fleet-ops-radar-agenda-empty-lane {
+ font-size: 0.72rem;
+ color: #9ca3af;
+ padding: 0.5rem 0.75rem;
+}
+
+.fleet-ops-radar-agenda-shift {
+ position: absolute;
+ top: 0;
+ height: 1.8rem;
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0 0.5rem 0 0.25rem;
+ border: 1px solid #c7d2fe;
+ background: #e0e7ff;
+ color: #3730a3;
+ border-radius: 0.375rem;
+ font-size: 0.7rem;
+ white-space: nowrap;
+ overflow: hidden;
+ cursor: pointer;
+}
+
+.fleet-ops-radar-agenda-shift.is-not_online {
+ border-color: #fecdd3;
+ background: #fff1f2;
+ color: #9f1239;
+}
+
+.fleet-ops-radar-agenda-shift.has-handover {
+ border-color: #fde68a;
+ background: #fffbeb;
+ color: #92400e;
+}
+
+.fleet-ops-radar-agenda-shift.is-upcoming {
+ opacity: 0.75;
+}
+
+.fleet-ops-radar-agenda-shift.is-ended {
+ opacity: 0.45;
+}
+
+.fleet-ops-radar-agenda-shift-label {
+ font-weight: 700;
+}
+
+.fleet-ops-radar-agenda-shift-meta {
+ opacity: 0.8;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.fleet-ops-radar-agenda-entry {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ max-width: 100%;
+ border: 1px solid #e5e7eb;
+ background: #fff;
+ border-radius: 0.375rem;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.7rem;
+ color: #374151;
+ text-align: left;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.fleet-ops-radar-agenda-entry.is-positioned {
+ position: absolute;
+ top: 0;
+ height: 1.8rem;
+ max-width: 22rem;
+}
+
+.fleet-ops-radar-agenda-entry.is-compact {
+ white-space: normal;
+}
+
+.fleet-ops-radar-agenda-entry.is-planned {
+ border-style: dashed;
+}
+
+.fleet-ops-radar-agenda-entry:hover {
+ border-color: #bfdbfe;
+}
+
+.fleet-ops-radar-agenda-entry strong {
+ color: #111827;
+}
+
+.fleet-ops-radar-agenda-entry-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.fleet-ops-radar-agenda-entry-time {
+ color: #6b7280;
+ font-weight: 700;
+}
+
+.fleet-ops-radar-agenda-grip {
+ color: #9ca3af;
+ cursor: grab;
+}
+
+.fleet-ops-radar-agenda-tray-items {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-top: 0.5rem;
+}
+
+/* Handover card: floats above the page bottom-right. */
+.fleet-ops-radar-handover {
+ position: fixed;
+ right: 1.25rem;
+ bottom: 1.25rem;
+ width: min(26rem, calc(100vw - 2.5rem));
+ z-index: 60;
+ background: #fff;
+ border: 1px solid #fde68a;
+ border-radius: 0.5rem;
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
+ padding: 0.75rem 0.9rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ font-size: 0.78rem;
+}
+
+.fleet-ops-radar-handover-head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.fleet-ops-radar-handover-title {
+ font-weight: 700;
+ color: #111827;
+}
+
+.fleet-ops-radar-handover-orders {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.fleet-ops-radar-handover-order {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ align-items: center;
+ padding: 0.3rem 0.5rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ color: #374151;
+}
+
+.fleet-ops-radar-handover-order.is-late {
+ border-color: #fde68a;
+ background: #fffbeb;
+}
+
+.fleet-ops-radar-handover-late {
+ color: #b45309;
+ font-weight: 700;
+ font-size: 0.68rem;
+}
+
+.fleet-ops-radar-handover-cover {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+@media print {
+ .fleet-ops-control-hub-header,
+ .fleet-ops-radar-agenda-toolbar,
+ .fleet-ops-radar-briefing,
+ .fleet-ops-radar-footer,
+ .fleet-ops-radar-handover,
+ .next-content-overlay {
+ display: none !important;
+ }
+
+ .fleet-ops-radar-agenda-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .fleet-ops-radar-agenda-scale {
+ overflow: visible;
+ }
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-band,
+body[data-theme='dark'] .fleet-ops-radar-agenda-scale,
+body[data-theme='dark'] .fleet-ops-radar-agenda-tray,
+body[data-theme='dark'] .fleet-ops-radar-agenda-entry,
+body[data-theme='dark'] .fleet-ops-radar-handover,
+body[data-theme='dark'] .fleet-ops-radar-handover-order {
+ background: #1f2937;
+ border-color: #374151;
+ color: #d1d5db;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-lane-label,
+body[data-theme='dark'] .fleet-ops-radar-agenda-entry strong,
+body[data-theme='dark'] .fleet-ops-radar-handover-title {
+ color: #f9fafb;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-track {
+ background: repeating-linear-gradient(to right, #374151 0, #374151 1px, transparent 1px, transparent 8.333%);
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-ruler .fleet-ops-radar-agenda-track {
+ background: transparent;
+ border-bottom-color: #374151;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-now em {
+ background: #1f2937;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-shift {
+ background: rgba(99, 102, 241, 0.25);
+ border-color: #4f46e5;
+ color: #c7d2fe;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-shift.is-not_online {
+ background: rgba(225, 29, 72, 0.18);
+ border-color: rgba(225, 29, 72, 0.5);
+ color: #fda4af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-agenda-shift.has-handover,
+body[data-theme='dark'] .fleet-ops-radar-handover-order.is-late {
+ background: rgba(217, 119, 6, 0.18);
+ border-color: rgba(217, 119, 6, 0.5);
+ color: #fcd34d;
+}
+
+/* Modals used from Radar. */
+.fleet-ops-radar-modal-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 0.75rem;
+}
+
+@media (width >= 640px) {
+ .fleet-ops-radar-modal-grid.has-two {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+/* Dark mode. */
+body[data-theme='dark'] .fleet-ops-radar-header-summary,
+body[data-theme='dark'] .fleet-ops-radar-tab,
+body[data-theme='dark'] .fleet-ops-radar-tab-count,
+body[data-theme='dark'] .fleet-ops-radar-view-bar-right,
+body[data-theme='dark'] .fleet-ops-radar-row-meta,
+body[data-theme='dark'] .fleet-ops-radar-row-state,
+body[data-theme='dark'] .fleet-ops-radar-footer,
+body[data-theme='dark'] .fleet-ops-radar-empty-hint,
+body[data-theme='dark'] .fleet-ops-radar-drawer-label,
+body[data-theme='dark'] .fleet-ops-radar-empty-schedule time {
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-footer,
+body[data-theme='dark'] .fleet-ops-radar-view-toggle,
+body[data-theme='dark'] .fleet-ops-radar-pill,
+body[data-theme='dark'] .fleet-ops-radar-saved-view,
+body[data-theme='dark'] .fleet-ops-radar-list,
+body[data-theme='dark'] .fleet-ops-radar-empty-schedule {
+ background: #1f2937;
+ border-color: #374151;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-view-toggle-button,
+body[data-theme='dark'] .fleet-ops-radar-pill,
+body[data-theme='dark'] .fleet-ops-radar-saved-view,
+body[data-theme='dark'] .fleet-ops-radar-row-due,
+body[data-theme='dark'] .fleet-ops-radar-row-assignee,
+body[data-theme='dark'] .fleet-ops-radar-drawer-list,
+body[data-theme='dark'] .fleet-ops-radar-drawer-state,
+body[data-theme='dark'] .fleet-ops-radar-empty-schedule > div {
+ color: #d1d5db;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-view-toggle-button.is-active {
+ background: #1c6cc7;
+ color: #fff;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-pill:hover {
+ border-color: #1c6cc7;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-pill.is-active {
+ background: #1c6cc7;
+ border-color: #1c6cc7;
+ color: #fff;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-pill.is-empty:not(.is-active) {
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-saved-view.is-active {
+ background: rgba(28, 108, 199, 0.2);
+ border-color: #1c6cc7;
+ color: #93c5fd;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-view-bar,
+body[data-theme='dark'] .fleet-ops-radar-group-header,
+body[data-theme='dark'] .fleet-ops-radar-row,
+body[data-theme='dark'] .fleet-ops-radar-drawer-section,
+body[data-theme='dark'] .fleet-ops-radar-empty-schedule > div {
+ border-color: #374151;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-tab.is-active,
+body[data-theme='dark'] .fleet-ops-radar-row-title,
+body[data-theme='dark'] .fleet-ops-radar-empty-title,
+body[data-theme='dark'] .fleet-ops-radar-drawer-title {
+ color: #f9fafb;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-tab.is-active {
+ border-bottom-color: #60a5fa;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-group-header {
+ background: #111827;
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-group-header.is-overdue {
+ background: rgba(225, 29, 72, 0.15);
+ border-bottom-color: rgba(225, 29, 72, 0.35);
+ color: #fda4af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-group-header.is-today {
+ background: rgba(217, 119, 6, 0.15);
+ border-bottom-color: rgba(217, 119, 6, 0.35);
+ color: #fcd34d;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-row:hover,
+body[data-theme='dark'] .fleet-ops-radar-row.is-focused {
+ background: #111827;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-row.is-selected {
+ background: rgba(28, 108, 199, 0.18);
+}
+
+body[data-theme='dark'] .fleet-ops-radar-row.is-acknowledged .fleet-ops-radar-row-title {
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-row-title:hover {
+ color: #93c5fd;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-row-due.is-overdue {
+ color: #fb7185;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-subject {
+ background: #374151;
+ color: #e5e7eb;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-avatar {
+ background: #4b5563;
+ color: #f9fafb;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-avatar.is-user {
+ background: rgba(29, 78, 216, 0.35);
+ color: #bfdbfe;
+}
+
+body[data-theme='dark'] .fleet-ops-radar kbd {
+ background: #111827;
+ border-color: #4b5563;
+ color: #d1d5db;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-empty-icon {
+ background: rgba(5, 150, 105, 0.2);
+ color: #6ee7b7;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-empty-icon.is-neutral {
+ background: #374151;
+ color: #9ca3af;
+}
+
+body[data-theme='dark'] .fleet-ops-radar-sources-warning {
+ background: rgba(217, 119, 6, 0.15);
+ border-color: rgba(217, 119, 6, 0.4);
+ color: #fcd34d;
+}
+
+/* ── Resource list cell ──
+ A has-many column (trailers, devices) shows one pill per record, drawn at
+ the identity cell's size: a 1.25rem circle, the name centred beside it,
+ and a hover box that stays inside the cell instead of touching its border. */
+.resource-list-pill > a[href],
+.resource-list-pill > .fleetbase-pill-body {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ padding: 0.125rem 0.375rem;
+ margin: 0;
+ border-radius: 0.375rem;
+}
+
+.resource-list-pill .fleetbase-pill-body > * + * {
+ margin-left: 0;
+}
+
+.resource-list-pill .fleetbase-pill-body > .relative {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+.resource-list-pill .fleetbase-pill-body img.flb--img,
+.resource-list-pill .fleetbase-pill-body .resource-pill-icon {
+ width: 1.25rem;
+ height: 1.25rem;
+ box-shadow: none;
+ border: 1px solid #e5e7eb;
+}
+
+body[data-theme='dark'] .resource-list-pill .fleetbase-pill-body img.flb--img,
+body[data-theme='dark'] .resource-list-pill .fleetbase-pill-body .resource-pill-icon {
+ border-color: #374151;
+}
+
+.resource-list-pill .fleetbase-pill-body .resource-pill-online-dot {
+ width: 0.375rem;
+ height: 0.375rem;
+ margin: -0.125rem 0 0 -0.125rem;
+}
+
+.resource-list-pill .fleetbase-pill-body .text-sm {
+ font-size: 0.875rem;
+ line-height: 1rem;
}
diff --git a/addon/templates/connectivity/devices/index/details/vehicle.hbs b/addon/templates/connectivity/devices/index/details/vehicle.hbs
index 47efefcfd..47ac92263 100644
--- a/addon/templates/connectivity/devices/index/details/vehicle.hbs
+++ b/addon/templates/connectivity/devices/index/details/vehicle.hbs
@@ -52,7 +52,7 @@
Driver
-
{{n-a this.vehicleDriverName}}
+
@@ -63,7 +63,7 @@
{{t "device.attachment.attached-device"}}
-
{{n-a this.device.displayName}}
+
{{t "device.attachment.last-seen"}}
diff --git a/addon/templates/connectivity/events/details.hbs b/addon/templates/connectivity/events/details.hbs
index 0af5db66a..47ef4141a 100644
--- a/addon/templates/connectivity/events/details.hbs
+++ b/addon/templates/connectivity/events/details.hbs
@@ -9,8 +9,8 @@
{{n-a this.event.public_id}}
- Device: {{n-a (or this.event.device_name this.event.device_id this.event.ident)}}
- Provider: {{n-a (or this.event.telematic_name this.event.provider)}}
+ Device:
+ Provider:
Occurred: {{n-a (format-date-fns this.event.occurred_at "dd MMM yyyy, HH:mm")}}
diff --git a/addon/templates/connectivity/fuel-providers/details.hbs b/addon/templates/connectivity/fuel-providers/details.hbs
index 4a9fdc088..2e01a5b99 100644
--- a/addon/templates/connectivity/fuel-providers/details.hbs
+++ b/addon/templates/connectivity/fuel-providers/details.hbs
@@ -2,7 +2,7 @@
-
+
{{@model.provider}}
- {{#if @model.public_id}}
- /
- {{@model.public_id}}
- {{/if}}
+ ·
+ {{this.environmentLabel}}
+
Last test: {{n-a (format-date-fns @model.last_tested_at "dd MMM HH:mm")}}
Last sync: {{n-a (format-date-fns @model.last_synced_at "dd MMM HH:mm")}}
- Imported: {{n-a this.lastSummary.imported}}
- Unmatched: {{n-a this.lastSummary.unmatched}}
+ {{#if this.totals}}
+ Transactions: {{this.totals.transactions}}
+ Needs matching: {{this.totals.unmatched}}
+ {{/if}}
-
{{#each this.tabs as |tab|}}
-
+
{{tab.label}}
{{/each}}
+ {{#if this.activityError}}
+
+ {{this.activityError}}
+
+
+ {{/if}}
diff --git a/addon/templates/connectivity/fuel-providers/details/index.hbs b/addon/templates/connectivity/fuel-providers/details/index.hbs
index 548595608..02c774d03 100644
--- a/addon/templates/connectivity/fuel-providers/details/index.hbs
+++ b/addon/templates/connectivity/fuel-providers/details/index.hbs
@@ -1,38 +1,68 @@
-
-
- How This Integration Works
-
- {{or @model.name @model.provider}} imports provider bills as Fuel Transactions, matches each transaction to vehicles and trips, then creates linked Fuel Reports for operational reporting after a vehicle match.
-
-
-
-
1. Import
-
Provider bills
-
Sync pulls fuel purchase data from the provider API.
-
-
-
2. Match
-
Vehicles and trips
-
Identifiers are matched against vehicles, orders, and trip numbers.
+
+ {{#if this.details.totals}}
+
+ {{#each this.details.metrics as |metric|}}
+
+
+
{{metric.label}}
+
+
+ {{metric.value}}
+
+ {{/each}}
+
+ {{else}}{{#unless this.details.activityError}}
+
Loading integration activity…
+ {{/unless}}{{/if}}
+
+
+
+
Latest sync
+
-
-
3. Report
-
Fuel Reports
-
Matched transactions create visible fuel reports for operations and analytics.
+
+ {{#if this.details.latestRun}}
+
+ {{this.details.latestRun.label}}
+ {{this.details.latestRun.date}}
+
+
{{this.details.latestRun.result}}
+
Requested dates: {{this.details.latestRun.window}}
+ {{#unless this.details.latestRun.received}}
+
If you expected purchases, choose their dates on the Sync tab and confirm that they belong to this
+ PetroApp account.
+ {{/unless}}
+ {{else}}
+
No syncs yet
+
Choose a purchase date range on the Sync tab to import your first transactions.
+ {{/if}}
-
-
-
-
- Latest Sync Summary
-
-
Imported {{n-a @model.last_sync_state.summary.imported}}
-
Matched {{n-a @model.last_sync_state.summary.matched}}
-
Unmatched {{n-a @model.last_sync_state.summary.unmatched}}
-
Fuel Reports {{n-a @model.last_sync_state.summary.fuel_reports_created}}
-
- {{#if @model.last_error}}
- {{@model.last_error}}
- {{/if}}
-
+ {{#if this.details.totals.unmatched}}
+
+ Needs vehicle matching: {{this.details.totals.unmatched}}
+
+
+ {{/if}}
+
+
+ Account & import settings
+
+
Environment {{this.details.environmentLabel}}
+
Default date range Last
+ {{@model.sync_settings.window_days}}
+ days
+
Fuel report creation {{if
+ @model.sync_settings.auto_create_fuel_reports
+ "Automatic after vehicle matching"
+ "Manual"
+ }}
+
Total imported spend {{this.details.spend}}
+
+
+
+
+ {{#if @model.last_error}}
{{@model.last_error}}
{{/if}}
diff --git a/addon/templates/connectivity/fuel-providers/details/matching.hbs b/addon/templates/connectivity/fuel-providers/details/matching.hbs
index 289c2174b..9a0e41646 100644
--- a/addon/templates/connectivity/fuel-providers/details/matching.hbs
+++ b/addon/templates/connectivity/fuel-providers/details/matching.hbs
@@ -6,8 +6,8 @@
-
+
-
+
\ No newline at end of file
diff --git a/addon/templates/connectivity/fuel-providers/details/settings.hbs b/addon/templates/connectivity/fuel-providers/details/settings.hbs
deleted file mode 100644
index 852c3eae7..000000000
--- a/addon/templates/connectivity/fuel-providers/details/settings.hbs
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
Connection Settings
-
Update credentials, sync behavior, matching rules, and status for this fuel integration.
-
-
-
-
-
diff --git a/addon/templates/connectivity/fuel-providers/details/sync.hbs b/addon/templates/connectivity/fuel-providers/details/sync.hbs
index 8ad9ab6d8..d1ee52ed7 100644
--- a/addon/templates/connectivity/fuel-providers/details/sync.hbs
+++ b/addon/templates/connectivity/fuel-providers/details/sync.hbs
@@ -1,48 +1,59 @@
-
-
-
+
+
+
+
-
Manual Sync
-
Queue a provider import for the configured sync window. Imported records appear in Management > Fuel Transactions.
+
Sync history
+
Latest 20 runs · Times shown in your local timezone
-
-
-
-
Configured window
-
{{n-a @model.connection.sync_settings.window_days}} days
+
-
-
-
-
- Status
- Window
- Imported
- Matched
- Finished
-
-
-
- {{#each this.syncRuns as |run|}}
-
- {{run.status}}
- {{n-a (format-date-fns run.from "dd MMM")}} - {{n-a (format-date-fns run.to "dd MMM")}}
- {{run.imported}}
- {{run.matched}}
- {{n-a (format-date-fns run.finished_at "dd MMM HH:mm")}}
-
- {{else}}
-
- No sync runs have been recorded yet.
-
- {{/each}}
-
-
+
+ {{#each this.details.runs as |run|}}
+
+
+
+ {{run.label}}
+ {{run.window}}
+
+
{{run.date}}
+
+ {{run.result}}
+ {{#if run.received}}
+
+ Read: {{run.received}} Vehicle matched: {{run.matched}} Fuel reports created: {{run.fuel_reports_created}}
+
+ {{/if}}
+
+ {{else}}
+
No sync history yet Choose a date range above to start an import.
+ {{/each}}
-
-
-
+
\ No newline at end of file
diff --git a/addon/templates/connectivity/fuel-providers/details/transactions.hbs b/addon/templates/connectivity/fuel-providers/details/transactions.hbs
index d99948111..eb4f261f8 100644
--- a/addon/templates/connectivity/fuel-providers/details/transactions.hbs
+++ b/addon/templates/connectivity/fuel-providers/details/transactions.hbs
@@ -1,16 +1,21 @@
-
\ No newline at end of file
diff --git a/addon/templates/connectivity/fuel-providers/edit.hbs b/addon/templates/connectivity/fuel-providers/edit.hbs
index 5f1a0d3d3..3da102460 100644
--- a/addon/templates/connectivity/fuel-providers/edit.hbs
+++ b/addon/templates/connectivity/fuel-providers/edit.hbs
@@ -15,6 +15,7 @@
+/>
\ No newline at end of file
diff --git a/addon/templates/connectivity/fuel-providers/new.hbs b/addon/templates/connectivity/fuel-providers/new.hbs
index 6ad5ed37d..b0fe47d76 100644
--- a/addon/templates/connectivity/fuel-providers/new.hbs
+++ b/addon/templates/connectivity/fuel-providers/new.hbs
@@ -19,4 +19,4 @@
@onCancel={{this.cancel}}
@onSave={{perform this.save this.connection}}
/>
-
+
\ No newline at end of file
diff --git a/addon/templates/connectivity/telematics/details/attachments.hbs b/addon/templates/connectivity/telematics/details/attachments.hbs
index 8d3e4419d..52fbe08c7 100644
--- a/addon/templates/connectivity/telematics/details/attachments.hbs
+++ b/addon/templates/connectivity/telematics/details/attachments.hbs
@@ -34,6 +34,7 @@
- {{vehicle.displayName}}
+
diff --git a/addon/templates/maintenance/inspection-forms.hbs b/addon/templates/maintenance/inspection-forms.hbs
new file mode 100644
index 000000000..c24cd6895
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms.hbs
@@ -0,0 +1 @@
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-forms/index.hbs b/addon/templates/maintenance/inspection-forms/index.hbs
new file mode 100644
index 000000000..72171f8b5
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index.hbs
@@ -0,0 +1,28 @@
+
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-forms/index/details.hbs b/addon/templates/maintenance/inspection-forms/index/details.hbs
new file mode 100644
index 000000000..e76360366
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/details.hbs
@@ -0,0 +1,14 @@
+
+
+ {{outlet}}
+
+
diff --git a/addon/templates/maintenance/inspection-forms/index/details/index.hbs b/addon/templates/maintenance/inspection-forms/index/details/index.hbs
new file mode 100644
index 000000000..190892683
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/details/index.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-forms/index/details/submissions.hbs b/addon/templates/maintenance/inspection-forms/index/details/submissions.hbs
new file mode 100644
index 000000000..0d24d2846
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/details/submissions.hbs
@@ -0,0 +1,2 @@
+
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-forms/index/edit.hbs b/addon/templates/maintenance/inspection-forms/index/edit.hbs
new file mode 100644
index 000000000..4a6c5e5b4
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/edit.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/templates/maintenance/inspection-forms/index/new.hbs b/addon/templates/maintenance/inspection-forms/index/new.hbs
new file mode 100644
index 000000000..bf5b379e9
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/new.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/templates/maintenance/inspection-submissions.hbs b/addon/templates/maintenance/inspection-submissions.hbs
new file mode 100644
index 000000000..c24cd6895
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions.hbs
@@ -0,0 +1 @@
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-submissions/index.hbs b/addon/templates/maintenance/inspection-submissions/index.hbs
new file mode 100644
index 000000000..19b3d01c7
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index.hbs
@@ -0,0 +1,28 @@
+
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-submissions/index/details.hbs b/addon/templates/maintenance/inspection-submissions/index/details.hbs
new file mode 100644
index 000000000..f35b3c62c
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details.hbs
@@ -0,0 +1,14 @@
+
+
+ {{outlet}}
+
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs b/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs
new file mode 100644
index 000000000..56b77f233
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/details/index.hbs b/addon/templates/maintenance/inspection-submissions/index/details/index.hbs
new file mode 100644
index 000000000..16e7c0516
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details/index.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs b/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs
new file mode 100644
index 000000000..dbd948e04
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/edit.hbs b/addon/templates/maintenance/inspection-submissions/index/edit.hbs
new file mode 100644
index 000000000..03d23e740
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/edit.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/new.hbs b/addon/templates/maintenance/inspection-submissions/index/new.hbs
new file mode 100644
index 000000000..460ad5e82
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/new.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/templates/management/fuel-transactions/index.hbs b/addon/templates/management/fuel-transactions/index.hbs
index 81b5e689a..e05b35ad6 100644
--- a/addon/templates/management/fuel-transactions/index.hbs
+++ b/addon/templates/management/fuel-transactions/index.hbs
@@ -1,22 +1,11 @@
-
-
-
-
-
Fuel transaction ledger
-
{{t "menu.fuel-transactions"}}
-
- Imported fuel card bills and provider purchase records are reviewed here before, during, and after they match to vehicles, trips, and linked Fuel Reports.
-
-
-
-
-
-
- {{#if this.hasRecords}}
+{{#unless this.viewingDetails}}
+
+
- {{else}}
-
-
-
-
- {{this.emptyStateTitle}}
- {{this.emptyStateMessage}}
-
-
-
-
-
- {{/if}}
+
+
-
-{{outlet}}
+{{/unless}}
+{{outlet}}
\ No newline at end of file
diff --git a/addon/templates/management/fuel-transactions/index/details.hbs b/addon/templates/management/fuel-transactions/index/details.hbs
index cb44d3bb8..73255274f 100644
--- a/addon/templates/management/fuel-transactions/index/details.hbs
+++ b/addon/templates/management/fuel-transactions/index/details.hbs
@@ -1,73 +1,75 @@
-
-
-
-
Fuel transaction review
-
{{@model.provider_transaction_id}}
-
- {{@model.sync_status}}
- {{@model.provider}}
- {{n-a (format-date-fns @model.transaction_at "dd MMM yyyy HH:mm")}}
- {{n-a @model.station_name}}
-
-
+