@fleetbase/fleetops-engine 0.6.50 → 0.6.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +185 -67
  2. package/addon/components/contact/panel-header.hbs +11 -48
  3. package/addon/components/customer/details.hbs +1 -2
  4. package/addon/components/customer/form.hbs +63 -51
  5. package/addon/components/customer/form.js +27 -1
  6. package/addon/components/customer/orders.hbs +48 -0
  7. package/addon/components/customer/orders.js +67 -5
  8. package/addon/components/driver/form.hbs +1 -1
  9. package/addon/components/home/getting-started-guidance.hbs +113 -0
  10. package/addon/components/home/getting-started-guidance.js +107 -0
  11. package/addon/components/issue/details/comments.hbs +3 -0
  12. package/addon/components/issue/details/comments.js +3 -0
  13. package/addon/components/issue/details/correspondence.hbs +9 -0
  14. package/addon/components/issue/details/correspondence.js +83 -0
  15. package/addon/components/issue/details/documents.hbs +69 -0
  16. package/addon/components/issue/details/documents.js +85 -0
  17. package/addon/components/issue/details.hbs +220 -59
  18. package/addon/components/issue/details.js +245 -0
  19. package/addon/components/issue/form.hbs +19 -1
  20. package/addon/components/issue/panel-header.hbs +43 -0
  21. package/addon/components/issue/panel-header.js +22 -0
  22. package/addon/components/issue/timeline.hbs +40 -0
  23. package/addon/components/issue/timeline.js +45 -0
  24. package/addon/components/leaflet-tracking-marker.js +3 -0
  25. package/addon/components/map/drawer/position-listing.js +3 -0
  26. package/addon/components/map/leaflet-live-map.hbs +2 -2
  27. package/addon/components/map/leaflet-live-map.js +17 -0
  28. package/addon/components/map/order-list-overlay.hbs +4 -4
  29. package/addon/components/modals/convert-customer-to-vendor.hbs +30 -0
  30. package/addon/components/modals/convert-customer-to-vendor.js +55 -0
  31. package/addon/components/modals/issue-assign.hbs +20 -0
  32. package/addon/components/modals/issue-assign.js +49 -0
  33. package/addon/components/modals/issue-close.hbs +30 -0
  34. package/addon/components/modals/issue-close.js +73 -0
  35. package/addon/components/modals/issue-status.hbs +20 -0
  36. package/addon/components/modals/issue-status.js +43 -0
  37. package/addon/components/modals/reset-customer-credentials.js +12 -6
  38. package/addon/components/modals/update-order-activity.hbs +51 -17
  39. package/addon/components/modals/update-order-activity.js +59 -5
  40. package/addon/components/order/details/activity.js +2 -2
  41. package/addon/components/order/details/proof.hbs +40 -38
  42. package/addon/components/order/details/proof.js +31 -4
  43. package/addon/components/order/details.hbs +5 -5
  44. package/addon/components/order/details.js +19 -1
  45. package/addon/components/order/header.hbs +1 -1
  46. package/addon/components/route-list.hbs +0 -12
  47. package/addon/components/service-rate/details.hbs +1 -1
  48. package/addon/components/service-rate/form.hbs +1 -1
  49. package/addon/components/service-rate/form.js +4 -0
  50. package/addon/components/vendor/personnel-manager.hbs +166 -0
  51. package/addon/components/vendor/personnel-manager.js +143 -0
  52. package/addon/components/widget/live-fleet.hbs +1 -0
  53. package/addon/components/widget/live-fleet.js +2 -7
  54. package/addon/components/widget/revenue-trend.hbs +1 -1
  55. package/addon/components/widget/revenue-trend.js +75 -2
  56. package/addon/controllers/management/contacts/customers/details.js +31 -13
  57. package/addon/controllers/management/contacts/customers.js +16 -5
  58. package/addon/controllers/management/contacts/index/details.js +31 -13
  59. package/addon/controllers/management/contacts/index.js +7 -0
  60. package/addon/controllers/management/issues/index/details.js +52 -6
  61. package/addon/controllers/management/vendors/index/details.js +4 -0
  62. package/addon/controllers/operations/orders/index/details.js +11 -3
  63. package/addon/controllers/operations/orders/index.js +1 -1
  64. package/addon/extension.js +19 -12
  65. package/addon/helpers/format-duration.js +1 -38
  66. package/addon/initializers/load-leaflet-assets.js +5 -6
  67. package/addon/initializers/patch-ember-leaflet-tooltip-layer.js +51 -0
  68. package/addon/routes/management/issues/index/details.js +1 -1
  69. package/addon/routes.js +1 -0
  70. package/addon/services/contact-actions.js +231 -1
  71. package/addon/services/getting-started.js +44 -0
  72. package/addon/services/issue-actions.js +71 -0
  73. package/addon/services/leaflet-map-manager.js +2 -0
  74. package/addon/services/map-adapter/leaflet.js +19 -0
  75. package/addon/services/service-areas.js +3 -0
  76. package/addon/styles/fleetops-engine.css +1036 -0
  77. package/addon/templates/management/contacts/customers/details.hbs +1 -1
  78. package/addon/templates/management/contacts/index/details.hbs +1 -1
  79. package/addon/templates/management/contacts.hbs +2 -2
  80. package/addon/templates/management/issues/index/details/index.hbs +3 -1
  81. package/addon/templates/management/issues/index/details.hbs +3 -2
  82. package/addon/templates/management/vendors/index/details/personnel.hbs +1 -2
  83. package/addon/templates/operations/orders/index/details/index.hbs +2 -2
  84. package/addon/templates/operations/orders/index.hbs +1 -1
  85. package/addon/templates/operations/service-rates/index/edit.hbs +1 -0
  86. package/addon/templates/operations/service-rates/index/new.hbs +1 -0
  87. package/addon/templates/settings/payments/onboard.hbs +8 -8
  88. package/addon/utils/leaflet-draw-namespace-guard.js +22 -0
  89. package/addon/utils/leaflet-plugin-loader.js +219 -0
  90. package/addon/utils/setup-customer-portal.js +4 -64
  91. package/app/components/home/getting-started-guidance.js +1 -0
  92. package/app/components/issue/details/comments.js +1 -0
  93. package/app/components/issue/details/correspondence.js +1 -0
  94. package/app/components/issue/details/documents.js +1 -0
  95. package/app/components/issue/panel-header.js +1 -0
  96. package/app/components/modals/convert-customer-to-vendor.js +1 -0
  97. package/app/components/modals/issue-assign.js +1 -0
  98. package/app/components/modals/issue-close.js +1 -0
  99. package/app/components/modals/issue-status.js +1 -0
  100. package/app/components/vendor/personnel-manager.js +1 -0
  101. package/app/helpers/format-duration.js +1 -1
  102. package/app/services/getting-started.js +1 -0
  103. package/app/utils/leaflet-draw-namespace-guard.js +1 -0
  104. package/app/utils/leaflet-plugin-loader.js +1 -0
  105. package/composer.json +1 -1
  106. package/extension.json +1 -1
  107. package/package.json +5 -5
  108. package/server/migrations/2026_05_31_000001_add_portal_fields_to_vendor_personnels_table.php +30 -0
  109. package/server/migrations/2026_06_03_000000_ensure_tags_column_on_issues_table.php +31 -0
  110. package/server/migrations/2026_06_03_000001_add_order_uuid_to_issues_table.php +25 -0
  111. package/server/migrations/2026_06_04_000001_backfill_order_transaction_subjects.php +39 -0
  112. package/server/migrations/2026_06_04_000002_backfill_order_transaction_directions.php +24 -0
  113. package/server/resources/views/mail/customer-credentials.blade.php +16 -13
  114. package/server/src/Expansions/UserFilterExpansion.php +28 -0
  115. package/server/src/Http/Controllers/Api/v1/IssueController.php +2 -0
  116. package/server/src/Http/Controllers/Api/v1/OrderController.php +26 -25
  117. package/server/src/Http/Controllers/Api/v1/PlaceController.php +10 -58
  118. package/server/src/Http/Controllers/Internal/v1/ContactController.php +135 -0
  119. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +116 -6
  120. package/server/src/Http/Controllers/Internal/v1/GettingStartedController.php +17 -0
  121. package/server/src/Http/Controllers/Internal/v1/IssueController.php +251 -0
  122. package/server/src/Http/Controllers/Internal/v1/OrderController.php +172 -97
  123. package/server/src/Http/Controllers/Internal/v1/PlaceController.php +10 -68
  124. package/server/src/Http/Controllers/Internal/v1/VendorController.php +89 -0
  125. package/server/src/Http/Requests/CreateIssueRequest.php +4 -0
  126. package/server/src/Http/Requests/UpdateIssueRequest.php +4 -0
  127. package/server/src/Http/Resources/v1/Index/Order.php +12 -9
  128. package/server/src/Http/Resources/v1/Issue.php +25 -0
  129. package/server/src/Http/Resources/v1/Order.php +20 -18
  130. package/server/src/Mail/CustomerCredentialsMail.php +5 -6
  131. package/server/src/Models/Contact.php +30 -51
  132. package/server/src/Models/Issue.php +47 -6
  133. package/server/src/Models/Order.php +33 -6
  134. package/server/src/Models/ServiceArea.php +1 -1
  135. package/server/src/Models/VendorPersonnel.php +10 -1
  136. package/server/src/Models/Zone.php +1 -1
  137. package/server/src/Observers/PurchaseRateObserver.php +20 -0
  138. package/server/src/Observers/ZoneObserver.php +56 -0
  139. package/server/src/Providers/FleetOpsServiceProvider.php +1 -0
  140. package/server/src/Support/Analytics/TopDrivers.php +9 -1
  141. package/server/src/Support/GettingStarted.php +144 -0
  142. package/server/src/Support/PlaceSearch.php +282 -0
  143. package/server/src/Support/ResolvesOrderServiceStops.php +22 -12
  144. package/server/src/routes.php +33 -1
  145. package/server/tests/AnalyticsRoutesTest.php +8 -0
  146. package/server/tests/CustomerContactIdentitySafetyTest.php +41 -0
  147. package/server/tests/DriverVehicleStatusDefaultTest.php +2 -2
  148. package/server/tests/GettingStartedStatusTest.php +21 -0
  149. package/server/tests/OrderActivityFlowRegressionTest.php +116 -0
  150. package/server/tests/ServiceAreaZoneApiShapeTest.php +44 -0
  151. package/translations/ar-ae.yml +9 -0
  152. package/translations/bg-bg.yaml +9 -0
  153. package/translations/en-us.yaml +11 -0
  154. package/translations/es-pa.yaml +9 -0
  155. package/translations/fr-fr.yaml +9 -0
  156. package/translations/mn-mn.yaml +9 -0
  157. package/translations/pt-br.yaml +9 -0
  158. package/translations/ru-ru.yaml +9 -0
  159. package/translations/vi-vn.yaml +9 -0
@@ -4,16 +4,62 @@ import { inject as service } from '@ember/service';
4
4
 
5
5
  export default class ManagementIssuesIndexDetailsController extends Controller {
6
6
  @service hostRouter;
7
+ @service issueActions;
8
+
7
9
  @tracked tabs = [
8
10
  {
9
11
  route: 'management.issues.index.details.index',
10
12
  label: 'Overview',
11
13
  },
12
14
  ];
13
- @tracked actionButtons = [
14
- {
15
- icon: 'pencil',
16
- fn: () => this.hostRouter.transitionTo('console.fleet-ops.management.issues.index.edit', this.model),
17
- },
18
- ];
15
+
16
+ get isClosed() {
17
+ return ['closed', 'resolved', 'completed'].includes(this.model?.status);
18
+ }
19
+
20
+ get actionButtons() {
21
+ const workflowItems = [
22
+ {
23
+ label: 'Change Status',
24
+ icon: 'arrows-rotate',
25
+ fn: () => this.issueActions.openStatusModal(this.model, { onSaved: () => this.refreshIssue() }),
26
+ },
27
+ {
28
+ label: 'Assign Issue',
29
+ icon: 'user-check',
30
+ fn: () => this.issueActions.openAssignModal(this.model, { onSaved: () => this.refreshIssue() }),
31
+ },
32
+ ];
33
+
34
+ if (this.isClosed) {
35
+ workflowItems.push({
36
+ label: 'Re-open Issue',
37
+ icon: 'rotate-left',
38
+ fn: () => this.issueActions.confirmReopenIssue(this.model, { onSaved: () => this.refreshIssue() }),
39
+ });
40
+ } else {
41
+ workflowItems.push({
42
+ label: 'Close Issue',
43
+ icon: 'circle-check',
44
+ class: 'text-green-600 dark:text-green-400',
45
+ fn: () => this.issueActions.openCloseIssueModal(this.model, { onSaved: () => this.refreshIssue() }),
46
+ });
47
+ }
48
+
49
+ return [
50
+ {
51
+ icon: 'pencil',
52
+ fn: () => this.hostRouter.transitionTo('console.fleet-ops.management.issues.index.edit', this.model),
53
+ },
54
+ {
55
+ icon: 'ellipsis',
56
+ type: 'default',
57
+ items: workflowItems,
58
+ },
59
+ ];
60
+ }
61
+
62
+ refreshIssue() {
63
+ return this.model?.reload?.();
64
+ }
19
65
  }
@@ -9,6 +9,10 @@ export default class ManagementVendorsIndexDetailsController extends Controller
9
9
  route: 'management.vendors.index.details.index',
10
10
  label: 'Overview',
11
11
  },
12
+ {
13
+ route: 'management.vendors.index.details.personnel',
14
+ label: 'Personnel',
15
+ },
12
16
  ];
13
17
  @tracked actionButtons = [
14
18
  {
@@ -1,5 +1,6 @@
1
1
  import Controller, { inject as controller } from '@ember/controller';
2
2
  import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
3
4
  import { inject as service } from '@ember/service';
4
5
  import { isArray } from '@ember/array';
5
6
  import { task } from 'ember-concurrency';
@@ -19,6 +20,7 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
19
20
  @tracked routingControl;
20
21
  @tracked routingCompleted = false;
21
22
  @tracked realtimeOrderPublicId = null;
23
+ @tracked proofReloadToken = 0;
22
24
 
23
25
  get routePoints() {
24
26
  return buildRoutePointsFromPayload(this.model?.payload);
@@ -97,9 +99,7 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
97
99
  disabled: this.model.status === 'canceled',
98
100
  fn: () =>
99
101
  this.orderActions.updateActivity(this.model, {
100
- onFinish: () => {
101
- this.refresh.perform();
102
- },
102
+ onFinish: this.handleActivityModalFinish,
103
103
  }),
104
104
  },
105
105
  {
@@ -154,6 +154,14 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
154
154
  ];
155
155
  }
156
156
 
157
+ @action handleActivityModalFinish(options) {
158
+ this.refresh.perform();
159
+
160
+ if (options?.proofCreated) {
161
+ this.proofReloadToken++;
162
+ }
163
+ }
164
+
157
165
  @task *refresh() {
158
166
  try {
159
167
  yield this.hostRouter.refresh();
@@ -271,7 +271,7 @@ export default class OperationsOrdersIndexController extends Controller {
271
271
  },
272
272
  {
273
273
  label: this.intl.t('column.transaction'),
274
- cellComponent: 'table/cell/base',
274
+ cellComponent: 'table/cell/currency',
275
275
  valuePath: 'transaction_amount',
276
276
  resizable: true,
277
277
  hidden: true,
@@ -117,6 +117,9 @@ export default {
117
117
  // Create registries
118
118
  this.createRegistries(registryService);
119
119
 
120
+ // // Register console home guidance
121
+ // this.registerHomeComponents(registryService);
122
+
120
123
  // Setup customer portal
121
124
  const isCustomerPortalInstalled = universe.extensionManager.isInstalled('@fleetbase/customer-portal-engine');
122
125
  if (isCustomerPortalInstalled) {
@@ -216,13 +219,23 @@ export default {
216
219
  category: 'Analytics',
217
220
  default: false,
218
221
  }),
222
+ new Widget({
223
+ id: 'fleet-ops-live-fleet-widget',
224
+ name: 'Live Fleet Map',
225
+ description: 'Real-time driver positions and active routes.',
226
+ icon: 'map-location-dot',
227
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/live-fleet'),
228
+ grid_options: { w: 8, h: 11, minW: 8, minH: 8 },
229
+ category: 'Maps',
230
+ default: true,
231
+ }),
219
232
  new Widget({
220
233
  id: 'fleet-ops-revenue-trend-widget',
221
234
  name: 'Revenue Trend',
222
235
  description: 'Revenue over time with period comparison.',
223
236
  icon: 'chart-line',
224
237
  component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/revenue-trend'),
225
- grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
238
+ grid_options: { w: 4, h: 11, minW: 4, minH: 8 },
226
239
  category: 'Analytics',
227
240
  default: true,
228
241
  }),
@@ -254,7 +267,7 @@ export default {
254
267
  component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/top-drivers'),
255
268
  grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
256
269
  category: 'Analytics',
257
- default: false,
270
+ default: true,
258
271
  }),
259
272
  new Widget({
260
273
  id: 'fleet-ops-fuel-efficiency-widget',
@@ -296,21 +309,15 @@ export default {
296
309
  category: 'Analytics',
297
310
  default: false,
298
311
  }),
299
- new Widget({
300
- id: 'fleet-ops-live-fleet-widget',
301
- name: 'Live Fleet Map',
302
- description: 'Real-time driver positions and active routes.',
303
- icon: 'map-location-dot',
304
- component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/live-fleet'),
305
- grid_options: { w: 12, h: 10, minW: 8, minH: 8 },
306
- category: 'Maps',
307
- default: true,
308
- }),
309
312
  ];
310
313
 
311
314
  widgetService.registerWidgets('dashboard', widgets);
312
315
  },
313
316
 
317
+ registerHomeComponents(registryService) {
318
+ registryService.registerRenderableComponent('console:home:before-dashboard', new ExtensionComponent('@fleetbase/fleetops-engine', 'home/getting-started-guidance'));
319
+ },
320
+
314
321
  createRegistries(registryService) {
315
322
  registryService.createRegistries([
316
323
  'engine:fleet-ops',
@@ -1,38 +1 @@
1
- import { helper } from '@ember/component/helper';
2
-
3
- export function formatDurationValue(seconds) {
4
- const value = Number(seconds);
5
-
6
- if (!Number.isFinite(value)) {
7
- return '0s';
8
- }
9
-
10
- const totalSeconds = Math.max(0, Math.ceil(value));
11
- const days = Math.floor(totalSeconds / 86400);
12
- const hours = Math.floor((totalSeconds % 86400) / 3600);
13
- const minutes = Math.floor((totalSeconds % 3600) / 60);
14
- const remainingSeconds = totalSeconds % 60;
15
- const parts = [];
16
-
17
- if (days) {
18
- parts.push(`${days}d`);
19
- }
20
-
21
- if (hours) {
22
- parts.push(`${hours}h`);
23
- }
24
-
25
- if (!days && minutes) {
26
- parts.push(`${minutes}m`);
27
- }
28
-
29
- if (!days && !hours && parts.length < 2 && remainingSeconds) {
30
- parts.push(`${remainingSeconds}s`);
31
- }
32
-
33
- return parts.length ? parts.join(' ') : '0s';
34
- }
35
-
36
- export default helper(function formatDuration([seconds]) {
37
- return formatDurationValue(seconds);
38
- });
1
+ export { default, formatDurationValue } from '@fleetbase/ember-ui/helpers/format-duration';
@@ -1,15 +1,14 @@
1
- import loadLeafletPlugins from '@fleetbase/ember-ui/utils/load-leaflet-plugins';
1
+ import { debug } from '@ember/debug';
2
+ import ensureLeafletPluginsReady from '../utils/leaflet-plugin-loader';
2
3
 
3
4
  export function initialize() {
4
5
  let waitForLeaflet = setInterval(() => {
5
6
  let leafletLoaded = window.L !== undefined;
6
7
  if (leafletLoaded) {
7
- loadLeafletPlugins({
8
- scripts: ['leaflet.contextmenu.js', 'leaflet.draw-src.js'],
9
- stylesheets: ['leaflet.contextmenu.css', 'leaflet.draw.css'],
10
- globalIndicatorKey: 'fleetopsLeafletPluginsLoaded',
11
- });
12
8
  clearInterval(waitForLeaflet);
9
+ ensureLeafletPluginsReady().catch((error) => {
10
+ debug(error.message);
11
+ });
13
12
  }
14
13
  }, 100);
15
14
  }
@@ -0,0 +1,51 @@
1
+ import TooltipLayer from 'ember-leaflet/components/tooltip-layer';
2
+
3
+ function hasMethod(object, methodName) {
4
+ return typeof object?.[methodName] === 'function';
5
+ }
6
+
7
+ export function initialize() {
8
+ const prototype = TooltipLayer?.prototype;
9
+ if (!prototype || prototype.__fleetopsTooltipLayerPatched) {
10
+ return;
11
+ }
12
+
13
+ const addToContainer = prototype.addToContainer;
14
+ const removeFromContainer = prototype.removeFromContainer;
15
+ const removePopupListeners = prototype._removePopupListeners;
16
+
17
+ prototype.addToContainer = function () {
18
+ const parentLayer = this.args?.parent?._layer;
19
+ if (!this._layer || !hasMethod(parentLayer, 'bindTooltip')) {
20
+ return;
21
+ }
22
+
23
+ return addToContainer.call(this);
24
+ };
25
+
26
+ prototype.removeFromContainer = function () {
27
+ const parentLayer = this.args?.parent?._layer;
28
+ if (!hasMethod(parentLayer, 'unbindTooltip')) {
29
+ return;
30
+ }
31
+
32
+ return removeFromContainer.call(this);
33
+ };
34
+
35
+ prototype._removePopupListeners = function () {
36
+ const parentMap = this.args?.parent?._layer?._map;
37
+ if (!hasMethod(parentMap, 'removeEventListener')) {
38
+ return;
39
+ }
40
+
41
+ return removePopupListeners.call(this);
42
+ };
43
+
44
+ Object.defineProperty(prototype, '__fleetopsTooltipLayerPatched', {
45
+ value: true,
46
+ });
47
+ }
48
+
49
+ export default {
50
+ initialize,
51
+ };
@@ -28,6 +28,6 @@ export default class ManagementIssuesIndexDetailsRoute extends Route {
28
28
  };
29
29
 
30
30
  model({ public_id }) {
31
- return this.store.queryRecord('issue', { public_id, single: true, with: ['driver', 'vehicle', 'assignee', 'reporter'] });
31
+ return this.store.queryRecord('issue', { public_id, single: true, with: ['driver', 'vehicle', 'assignee', 'reporter', 'order', 'files'] });
32
32
  }
33
33
  }
package/addon/routes.js CHANGED
@@ -53,6 +53,7 @@ export default buildRoutes(function () {
53
53
  this.route('new');
54
54
  this.route('details', { path: '/:public_id' }, function () {
55
55
  this.route('index', { path: '/' });
56
+ this.route('personnel');
56
57
  });
57
58
  this.route('edit', { path: '/edit/:public_id' });
58
59
  });
@@ -1,9 +1,15 @@
1
1
  import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
2
2
  import { inject as service } from '@ember/service';
3
- import { action } from '@ember/object';
3
+ import { action, get } from '@ember/object';
4
+
5
+ const INTERNAL_NAMESPACE = 'int/v1';
4
6
 
5
7
  export default class ContactActionsService extends ResourceActionService {
8
+ @service fetch;
6
9
  @service placeActions;
10
+ @service notifications;
11
+ @service hostRouter;
12
+ @service('universe/extension-manager') extensionManager;
7
13
 
8
14
  constructor() {
9
15
  super(...arguments);
@@ -112,4 +118,228 @@ export default class ContactActionsService extends ResourceActionService {
112
118
  }
113
119
  );
114
120
  }
121
+
122
+ hasLinkedUser(contact) {
123
+ const user = contact?.user;
124
+ return Boolean(contact?.user_uuid || (user && (get(user, 'id') || get(user, 'uuid'))));
125
+ }
126
+
127
+ linkedUserStatus(contact) {
128
+ const user = contact?.user;
129
+ return (user ? (get(user, 'status') ?? get(user, 'session_status')) : null) ?? 'active';
130
+ }
131
+
132
+ hasInactiveLogin(contact) {
133
+ return ['inactive', 'disabled', 'suspended'].includes(this.linkedUserStatus(contact));
134
+ }
135
+
136
+ isCustomerPortalInstalled() {
137
+ return this.extensionManager.isInstalled('@fleetbase/customer-portal-engine');
138
+ }
139
+
140
+ accountActionButton(contact, options = {}) {
141
+ if (!this.hasLinkedUser(contact)) {
142
+ return null;
143
+ }
144
+
145
+ return {
146
+ icon: 'ellipsis-h',
147
+ iconPrefix: 'fas',
148
+ renderInPlace: true,
149
+ items: this.accountActionItems(contact, options),
150
+ };
151
+ }
152
+
153
+ accountActionItems(contact, options = {}) {
154
+ const actions = [
155
+ {
156
+ text: 'Reset Password',
157
+ icon: 'key',
158
+ fn: () => this.openResetPasswordModal(contact),
159
+ },
160
+ {
161
+ text: 'Send Credentials',
162
+ icon: 'paper-plane',
163
+ fn: () => this.confirmSendCredentials(contact),
164
+ },
165
+ this.hasInactiveLogin(contact)
166
+ ? {
167
+ text: 'Reactivate Login',
168
+ icon: 'unlock',
169
+ fn: () => this.confirmReactivatePortalLogin(contact),
170
+ }
171
+ : {
172
+ text: 'Deactivate Login',
173
+ icon: 'lock',
174
+ fn: () => this.confirmDeactivatePortalLogin(contact),
175
+ class: 'text-red-500 hover:text-red-600',
176
+ },
177
+ ];
178
+
179
+ if (this.isCustomerPortalInstalled()) {
180
+ actions.push(
181
+ {
182
+ separator: true,
183
+ },
184
+ {
185
+ text: 'Convert to Vendor',
186
+ icon: 'building',
187
+ fn: () => this.openConvertToVendorModal(contact, options),
188
+ }
189
+ );
190
+ }
191
+
192
+ return actions;
193
+ }
194
+
195
+ accountRowActionItems(options = {}) {
196
+ const hasCustomerPortal = this.isCustomerPortalInstalled();
197
+ const hasLinkedUser = (contact) => this.hasLinkedUser(contact);
198
+
199
+ const actions = [
200
+ {
201
+ label: 'Reset Password',
202
+ icon: 'key',
203
+ fn: (contact) => this.openResetPasswordModal(contact),
204
+ isVisible: hasLinkedUser,
205
+ },
206
+ {
207
+ label: 'Send Credentials',
208
+ icon: 'paper-plane',
209
+ fn: (contact) => this.confirmSendCredentials(contact),
210
+ isVisible: hasLinkedUser,
211
+ },
212
+ {
213
+ label: 'Deactivate Login',
214
+ icon: 'lock',
215
+ class: 'text-red-500 hover:text-red-600',
216
+ fn: (contact) => this.confirmDeactivatePortalLogin(contact),
217
+ isVisible: (contact) => hasLinkedUser(contact) && !this.hasInactiveLogin(contact),
218
+ },
219
+ {
220
+ label: 'Reactivate Login',
221
+ icon: 'unlock',
222
+ fn: (contact) => this.confirmReactivatePortalLogin(contact),
223
+ isVisible: (contact) => hasLinkedUser(contact) && this.hasInactiveLogin(contact),
224
+ },
225
+ ];
226
+
227
+ if (hasCustomerPortal) {
228
+ actions.push({
229
+ label: 'Convert to Vendor',
230
+ icon: 'building',
231
+ fn: (contact) => this.openConvertToVendorModal(contact, options),
232
+ isVisible: hasLinkedUser,
233
+ });
234
+ }
235
+
236
+ return actions;
237
+ }
238
+
239
+ openResetPasswordModal(contact) {
240
+ this.modalsManager.show('modals/reset-customer-credentials', {
241
+ customer: contact,
242
+ });
243
+ }
244
+
245
+ openConvertToVendorModal(contact, options = {}) {
246
+ this.modalsManager.show('modals/convert-customer-to-vendor', {
247
+ customer: contact,
248
+ onConverted: (vendor) => {
249
+ if (typeof options.onConverted === 'function') {
250
+ options.onConverted(vendor);
251
+ return;
252
+ }
253
+
254
+ if (vendor?.id) {
255
+ this.hostRouter.transitionTo('console.fleet-ops.management.vendors.index.details', vendor.id);
256
+ }
257
+ },
258
+ });
259
+ }
260
+
261
+ confirmSendCredentials(contact) {
262
+ this.confirmLoginAction(contact, {
263
+ title: 'Send Portal Credentials',
264
+ body: 'Generate a new temporary password and email this contact their portal credentials.',
265
+ acceptButtonText: 'Send Credentials',
266
+ endpoint: 'customers/send-credentials',
267
+ successMessage: 'Portal credentials sent.',
268
+ });
269
+ }
270
+
271
+ confirmDeactivatePortalLogin(contact) {
272
+ this.confirmLoginAction(contact, {
273
+ title: 'Deactivate Login',
274
+ body: 'Deactivate portal access for this contact. Their profile and history will be preserved.',
275
+ acceptButtonText: 'Deactivate Login',
276
+ acceptButtonScheme: 'danger',
277
+ endpoint: 'customers/deactivate-portal-login',
278
+ successMessage: 'Portal login deactivated.',
279
+ });
280
+ }
281
+
282
+ confirmReactivatePortalLogin(contact) {
283
+ this.confirmLoginAction(contact, {
284
+ title: 'Reactivate Login',
285
+ body: 'Reactivate portal access for this contact.',
286
+ acceptButtonText: 'Reactivate Login',
287
+ endpoint: 'customers/reactivate-portal-login',
288
+ successMessage: 'Portal login reactivated.',
289
+ });
290
+ }
291
+
292
+ confirmLoginAction(contact, { title, body, acceptButtonText, acceptButtonScheme, endpoint, successMessage }) {
293
+ this.modalsManager.confirm({
294
+ title,
295
+ body,
296
+ acceptButtonText,
297
+ acceptButtonScheme,
298
+ confirm: async (modal) => {
299
+ modal.startLoading();
300
+
301
+ try {
302
+ const response = await this.fetch.post(endpoint, { customer: contact?.id }, { namespace: INTERNAL_NAMESPACE });
303
+ await this.updateContactFromResponse(contact, response);
304
+ this.notifications.success(successMessage);
305
+ modal.done();
306
+ } catch (error) {
307
+ this.notifications.serverError(error);
308
+ modal.stopLoading();
309
+ }
310
+ },
311
+ });
312
+ }
313
+
314
+ async updateContactFromResponse(contact, response) {
315
+ const payload = response.customer ?? response.contact;
316
+
317
+ if (!payload || typeof contact?.setProperties !== 'function') {
318
+ return;
319
+ }
320
+
321
+ contact.setProperties(
322
+ this.withoutIdentityFields({
323
+ user_uuid: payload.user_uuid,
324
+ })
325
+ );
326
+
327
+ if (payload.user) {
328
+ const user = await contact.user;
329
+
330
+ if (typeof user?.setProperties === 'function') {
331
+ user.setProperties(this.withoutIdentityFields(payload.user));
332
+ }
333
+ }
334
+ }
335
+
336
+ withoutIdentityFields(payload = {}) {
337
+ const attributes = { ...payload };
338
+
339
+ delete attributes.id;
340
+ delete attributes.uuid;
341
+ delete attributes.public_id;
342
+
343
+ return attributes;
344
+ }
115
345
  }
@@ -0,0 +1,44 @@
1
+ import Service from '@ember/service';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { inject as service } from '@ember/service';
4
+ import { task } from 'ember-concurrency';
5
+
6
+ export default class GettingStartedService extends Service {
7
+ @service fetch;
8
+
9
+ @tracked data = null;
10
+ @tracked error = null;
11
+
12
+ get isCompleted() {
13
+ return this.data?.is_completed === true;
14
+ }
15
+
16
+ get steps() {
17
+ return this.data?.steps ?? [];
18
+ }
19
+
20
+ get recommendations() {
21
+ return this.data?.recommendations ?? [];
22
+ }
23
+
24
+ get progress() {
25
+ return this.data?.progress ?? { completed: 0, total: 0, percent: 0 };
26
+ }
27
+
28
+ get nextStepKey() {
29
+ return this.data?.next_step;
30
+ }
31
+
32
+ get nextStep() {
33
+ return this.steps.find((step) => step.key === this.nextStepKey) ?? this.steps.find((step) => !step.completed);
34
+ }
35
+
36
+ @task({ drop: true }) *load() {
37
+ try {
38
+ this.data = yield this.fetch.get('fleet-ops/getting-started/status');
39
+ this.error = null;
40
+ } catch (error) {
41
+ this.error = error?.message ?? 'Failed to load getting started status';
42
+ }
43
+ }
44
+ }