From 296a7044cb830fcdec76e0fe3b8cff3f356166d7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 5 Oct 2026 12:15:02 +0800 Subject: [PATCH] Order config lifecycle management, full config list, live activity timeline - Order Config: add a Lifecycle tab to view and edit `meta.lifecycle` (initial, completed, canceled and terminal activities, dispatch, strict transitions), with validation against the activity flow. - Order Config: always fetch the config list. `findAll` resolved with the subset the store already held (e.g. an order's embedded config) and only refreshed in the background, so configs created later were missing from the manager. - Order details: reload the activity timeline when the order's status changes outside the Update Activity modal (configured lifecycle advancing on save, extension actions, realtime updates). --- addon/components/order-config-manager.js | 6 +- .../order-config-manager/lifecycle.hbs | 175 ++++++++++++++++ .../order-config-manager/lifecycle.js | 195 ++++++++++++++++++ addon/components/order/details/activity.hbs | 14 +- addon/components/order/details/activity.js | 9 + translations/en-us.yaml | 39 ++++ 6 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 addon/components/order-config-manager/lifecycle.hbs create mode 100644 addon/components/order-config-manager/lifecycle.js diff --git a/addon/components/order-config-manager.js b/addon/components/order-config-manager.js index 9d4fd0b23..7f5aad343 100644 --- a/addon/components/order-config-manager.js +++ b/addon/components/order-config-manager.js @@ -14,6 +14,7 @@ import applyContextComponentArguments from '@fleetbase/ember-core/utils/apply-co import OrderConfigManagerDetailsComponent from './order-config-manager/details'; import OrderConfigManagerCustomFieldsComponent from './order-config-manager/custom-fields'; import OrderConfigManagerActivityFlowComponent from './order-config-manager/activity-flow'; +import OrderConfigManagerLifecycleComponent from './order-config-manager/lifecycle'; import OrderConfigManagerEntitiesComponent from './order-config-manager/entities'; import findActiveTab from '../utils/find-active-tab'; @@ -45,6 +46,7 @@ export default class OrderConfigManagerComponent extends Component { this.universe._createMenuItem(this.intl.t('order-config-manager.tabs.details'), null, { icon: 'circle-info', component: OrderConfigManagerDetailsComponent }), this.universe._createMenuItem(this.intl.t('order-config-manager.tabs.custom-fields'), null, { icon: 'rectangle-list', component: OrderConfigManagerCustomFieldsComponent }), this.universe._createMenuItem(this.intl.t('order-config-manager.tabs.activity-flow'), null, { icon: 'diagram-project', component: OrderConfigManagerActivityFlowComponent }), + this.universe._createMenuItem(this.intl.t('order-config-manager.tabs.lifecycle'), null, { icon: 'arrows-spin', component: OrderConfigManagerLifecycleComponent }), this.universe._createMenuItem(this.intl.t('order-config-manager.tabs.entities'), null, { icon: 'boxes-packing', component: OrderConfigManagerEntitiesComponent }), ]; @@ -106,7 +108,9 @@ export default class OrderConfigManagerComponent extends Component { */ @task *loadOrderConfigs(options = {}) { try { - const configs = yield this.store.findAll('order-config').then(Array.from); + // Always fetch: `findAll` otherwise resolves with whatever subset of configs the store + // already holds (e.g. from an order's embedded config) and only refreshes in the background. + const configs = yield this.store.findAll('order-config', { reload: true }).then(Array.from); let currentConfig; if (isArray(configs) && configs.length > 0) { currentConfig = configs.find((c) => c.id === this.args.orderConfig); diff --git a/addon/components/order-config-manager/lifecycle.hbs b/addon/components/order-config-manager/lifecycle.hbs new file mode 100644 index 000000000..13ecd9175 --- /dev/null +++ b/addon/components/order-config-manager/lifecycle.hbs @@ -0,0 +1,175 @@ +
+ +

{{t "order-config-manager.lifecycle.description"}}

+ +
+ +
+ + {{#if this.enabled}} +
+ +
+ + {{activity.label}} + {{activity.code}} + +
+
+ + +
+ + {{activity.label}} + +
+
+ + +
+ + {{activity.label}} + {{activity.code}} + +
+
+ + +
+ + {{activity.label}} + {{activity.code}} + +
+
+ +
+ +
+
+ +
+
+ + {{#if this.problems.length}} +
+
{{t "order-config-manager.lifecycle.problems-title"}}
+
    + {{#each this.problems as |problem|}}
  • {{problem}}
  • {{/each}} +
+
+ {{/if}} + {{else}} +

{{t "order-config-manager.lifecycle.default-lifecycle"}}

+ {{/if}} +
+ + {{#if this.saved}} + +
+
{{t "order-config-manager.lifecycle.summary-initial"}}
{{or + (get (this.option this.saved.initial) "label") + (t "order-config-manager.lifecycle.none") + }}
+
{{t "order-config-manager.lifecycle.summary-completed"}}
{{or + (get (this.option this.saved.completed) "label") + (t "order-config-manager.lifecycle.none") + }}
+
{{t "order-config-manager.lifecycle.summary-canceled"}}
{{or + (get (this.option this.saved.canceled) "label") + (t "order-config-manager.lifecycle.none") + }}
+
{{t "order-config-manager.lifecycle.summary-terminal"}}
{{#if this.saved.terminal.length}}{{#each + this.saved.terminal + as |code index| + }}{{if index ", "}}{{get (this.option code) "label"}}{{/each}}{{else}}{{t "order-config-manager.lifecycle.none"}}{{/if}}
+
{{t "order-config-manager.lifecycle.summary-dispatch"}}
{{if + this.saved.dispatch + (t "order-config-manager.lifecycle.yes") + (t "order-config-manager.lifecycle.no") + }}
+
{{t "order-config-manager.lifecycle.summary-strict"}}
{{if + this.saved.strict_transitions + (t "order-config-manager.lifecycle.yes") + (t "order-config-manager.lifecycle.no") + }}
+
+
+ {{/if}} + + {{#if this.canEdit}} + +
+
+
+ {{/if}} +
\ No newline at end of file diff --git a/addon/components/order-config-manager/lifecycle.js b/addon/components/order-config-manager/lifecycle.js new file mode 100644 index 000000000..5987e6701 --- /dev/null +++ b/addon/components/order-config-manager/lifecycle.js @@ -0,0 +1,195 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { isArray } from '@ember/array'; +import { task } from 'ember-concurrency'; +import normalizeOrderConfigFlow, { getOrderConfigFlowRootCode } from '../../utils/normalize-order-config-flow'; + +/** Keys of `meta.lifecycle` understood by the server (see OrderConfig::lifecycle()). */ +const DEFAULTS = { initial: null, completed: null, canceled: null, terminal: [], dispatch: true, strict_transitions: false }; + +/** + * Manages an order config's configured lifecycle (`meta.lifecycle`): the activity new orders start + * at, the activities that complete, cancel and end an order, whether orders may be dispatched and + * whether transitions must follow the flow. Without a configured lifecycle the default dispatch + * lifecycle applies. + * + * @extends Component + */ +export default class OrderConfigManagerLifecycleComponent extends Component { + @service modalsManager; + @service notifications; + @service abilities; + @service intl; + + /** The config being managed. */ + @tracked config; + + /** Working copy of `meta.lifecycle`, or null when the config has none. */ + @tracked lifecycle = null; + + /** Last saved lifecycle, to tell whether there is anything to save. */ + @tracked saved = null; + + constructor(owner, { config, configManagerContext }) { + super(...arguments); + this.changeConfig(config); + + configManagerContext.on('onConfigChanged', (newConfig) => { + this.changeConfig(newConfig); + }); + } + + get canEdit() { + return this.abilities.can('fleet-ops update order-config') && !this.config?.core_service; + } + + get enabled() { + return this.lifecycle !== null; + } + + /** Activities of the flow as select options. */ + get activities() { + const flow = normalizeOrderConfigFlow(this.config?.flow ?? {}); + return Object.values(flow) + .filter((activity) => activity && typeof activity === 'object') + .map((activity) => ({ code: activity.code ?? activity.key, label: activity.status || activity.code || activity.key })) + .filter((activity) => activity.code); + } + + get selectedInitial() { + return this.option(this.lifecycle?.initial); + } + + get selectedCompleted() { + return this.option(this.lifecycle?.completed); + } + + get selectedCanceled() { + return this.option(this.lifecycle?.canceled); + } + + get selectedTerminal() { + return (this.lifecycle?.terminal ?? []).map((code) => this.option(code)).filter(Boolean); + } + + get isDirty() { + return JSON.stringify(this.lifecycle) !== JSON.stringify(this.saved); + } + + /** Problems that make the lifecycle unusable server-side. */ + get problems() { + if (!this.lifecycle) { + return []; + } + + const problems = []; + const codes = this.activities.map((activity) => activity.code); + if (!this.lifecycle.initial) { + problems.push(this.intl.t('order-config-manager.lifecycle.problem-initial-required')); + } + + const referenced = [this.lifecycle.initial, this.lifecycle.completed, this.lifecycle.canceled, ...(this.lifecycle.terminal ?? [])].filter(Boolean); + for (const code of new Set(referenced)) { + if (!codes.includes(code)) { + problems.push(this.intl.t('order-config-manager.lifecycle.problem-missing-activity', { code })); + } + } + + return problems; + } + + get canSave() { + return this.canEdit && this.isDirty && this.problems.length === 0; + } + + /** Arrow function so templates can call it as a helper: (this.option code). */ + option = (code) => { + return code ? (this.activities.find((activity) => activity.code === code) ?? { code, label: code }) : null; + }; + + changeConfig(config) { + this.config = config; + this.saved = this.normalize(config?.meta?.lifecycle); + this.lifecycle = this.normalize(config?.meta?.lifecycle); + } + + normalize(lifecycle) { + if (!lifecycle || typeof lifecycle !== 'object' || Object.keys(lifecycle).length === 0) { + return null; + } + + return { + ...DEFAULTS, + ...lifecycle, + terminal: isArray(lifecycle.terminal) ? [...lifecycle.terminal] : [], + dispatch: !['false', '0', 0, false].includes(lifecycle.dispatch), + strict_transitions: Boolean(lifecycle.strict_transitions), + }; + } + + @action toggleEnabled(enabled) { + if (enabled) { + const flow = normalizeOrderConfigFlow(this.config?.flow ?? {}); + this.lifecycle = this.saved ? { ...this.saved, terminal: [...this.saved.terminal] } : { ...DEFAULTS, terminal: [], initial: getOrderConfigFlowRootCode(flow) ?? null }; + return; + } + + if (this.saved) { + this.modalsManager.confirm({ + title: this.intl.t('order-config-manager.lifecycle.remove-title'), + body: this.intl.t('order-config-manager.lifecycle.remove-body'), + acceptButtonText: this.intl.t('order-config-manager.lifecycle.remove-confirm'), + confirm: async (modal) => { + modal.startLoading(); + this.lifecycle = null; + await this.save.perform(); + modal.done(); + }, + decline: (modal) => { + modal.done(); + }, + }); + return; + } + + this.lifecycle = null; + } + + @action setActivity(key, activity) { + this.lifecycle = { ...this.lifecycle, [key]: activity?.code ?? null }; + } + + @action setTerminal(activities) { + this.lifecycle = { ...this.lifecycle, terminal: (activities ?? []).map((activity) => activity.code) }; + } + + @action setFlag(key, value) { + this.lifecycle = { ...this.lifecycle, [key]: Boolean(value) }; + } + + @task *save() { + const meta = { ...(this.config.meta ?? {}) }; + if (this.lifecycle) { + meta.lifecycle = { ...this.lifecycle }; + } else { + delete meta.lifecycle; + } + + try { + this.config.set('meta', meta); + yield this.config.save(); + this.saved = this.normalize(this.lifecycle); + this.lifecycle = this.normalize(this.lifecycle); + this.notifications.success(this.intl.t('order-config-manager.lifecycle.saved', { orderConfigName: this.config.name })); + if (typeof this.args.onConfigUpdated === 'function') { + this.args.onConfigUpdated(this.config); + } + } catch (error) { + this.config.rollbackAttributes(); + this.lifecycle = this.normalize(this.config.meta?.lifecycle); + this.notifications.serverError(error); + } + } +} diff --git a/addon/components/order/details/activity.hbs b/addon/components/order/details/activity.hbs index 7c92c96d7..d5cabef07 100644 --- a/addon/components/order/details/activity.hbs +++ b/addon/components/order/details/activity.hbs @@ -5,9 +5,11 @@ @isLoading={{or @isLoadingActivity this.loadActivity.isRunning @isLoading}} @wrapperClass="bordered-top" > - {{#if (eq this.layout "timeline")}} - - {{else}} - - {{/if}} - +
+ {{#if (eq this.layout "timeline")}} + + {{else}} + + {{/if}} +
+ \ No newline at end of file diff --git a/addon/components/order/details/activity.js b/addon/components/order/details/activity.js index 418d190d6..9ed0f9418 100644 --- a/addon/components/order/details/activity.js +++ b/addon/components/order/details/activity.js @@ -1,6 +1,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; import { task } from 'ember-concurrency'; export default class OrderDetailsActivityComponent extends Component { @@ -64,6 +65,14 @@ export default class OrderDetailsActivityComponent extends Component { ]; } + /** + * A status change recorded outside this panel (a configured lifecycle advancing on save, an + * extension action, a realtime update) means new tracking statuses exist server-side. + */ + @action onStatusChanged() { + this.loadActivity.perform(); + } + @task *loadActivity() { if (!this.args.resource) { return; diff --git a/translations/en-us.yaml b/translations/en-us.yaml index ae122c471..de25dbb7a 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -1256,6 +1256,7 @@ order-config-manager: details: Details custom-fields: Custom Fields activity-flow: Activity Flow + lifecycle: Lifecycle entities: Entities details: details: Details @@ -1272,6 +1273,44 @@ order-config-manager: delete-title: Delete this order config? delete-body-message: Once this order config is deleted it will not be recoverable and you will lose all configurations. confirm-delete: Delete + lifecycle: + title: Lifecycle + description: A configured lifecycle lets this order type start, complete and cancel at its own activities instead of the default dispatch lifecycle (created, dispatched, started, completed, canceled). + default-lifecycle: This order type uses the default dispatch lifecycle. Orders start at "created", can be dispatched to drivers and complete or cancel through the standard activities. + enable: Use a configured lifecycle + enable-help: Orders of this type start at the initial activity you choose and follow the activity flow instead of the dispatch lifecycle. + initial: Initial activity + initial-help: New orders of this type start here. Must be an activity in the flow. + completed: Completed activity + completed-help: Reaching this activity completes the order. + canceled: Canceled activity + canceled-help: Canceling an order moves it to this activity. + terminal: Terminal activities + terminal-help: No further transition is allowed after these. The completed and canceled activities are always terminal. + dispatch: Allow dispatch + dispatch-help: When off, orders of this type are never dispatched to drivers and the dispatch actions are refused. + strict-transitions: Strict transitions + strict-transitions-help: When on, an activity can only move to one of its configured next activities in the flow. + select-activity: Select activity + select-activities: Select activities + problems-title: Fix before saving + problem-initial-required: Choose the initial activity. + problem-missing-activity: '"{code}" is not an activity in the flow.' + summary: Summary + summary-initial: Orders start at + summary-completed: Completed at + summary-canceled: Canceled at + summary-terminal: Terminal + summary-dispatch: Dispatch + summary-strict: Strict transitions + 'yes': 'Yes' + 'no': 'No' + none: None + remove: Remove configured lifecycle + remove-title: Remove the configured lifecycle? + remove-body: Orders of this type will use the default dispatch lifecycle again. Existing orders keep their current status. + remove-confirm: Remove + saved: Lifecycle saved for {orderConfigName}. activity-flow: save-activity-flow: Save Activity Flow edit-activity-unique-code-warning: Activity code must be unique!