@fleetbase/fleetops-engine 0.6.19 → 0.6.20

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.
@@ -1,10 +1,12 @@
1
- <Kanban
2
- @columns={{this.columns}}
3
- @onCardMove={{this.handleCardMove}}
4
- @headerOffset={{@headerOffset}}
5
- @columnIdPath="status"
6
- @subject="order"
7
- @cardTemplate="order/kanban-card"
8
- @onCreateCard={{transition-to "operations.orders.index.new"}}
9
- ...attributes
10
- />
1
+ <div {{did-update this.handleArgsChange @orderConfig @orders}}>
2
+ <Kanban
3
+ @columns={{this.columns}}
4
+ @onCardMove={{this.handleCardMove}}
5
+ @headerOffset={{@headerOffset}}
6
+ @columnIdPath="status"
7
+ @subject="order"
8
+ @cardTemplate="order/kanban-card"
9
+ @onCreateCard={{transition-to "operations.orders.index.new"}}
10
+ ...attributes
11
+ />
12
+ </div>
@@ -6,6 +6,8 @@ import { isArray } from '@ember/array';
6
6
  import { debug } from '@ember/debug';
7
7
  import { task } from 'ember-concurrency';
8
8
  import titleize from 'ember-cli-string-helpers/utils/titleize';
9
+ import smartHumanize from '@fleetbase/ember-ui/utils/smart-humanize';
10
+ import isUuid from '@fleetbase/ember-core/utils/is-uuid';
9
11
 
10
12
  export default class OrderKanbanComponent extends Component {
11
13
  @service fetch;
@@ -13,6 +15,7 @@ export default class OrderKanbanComponent extends Component {
13
15
  @service intl;
14
16
  @tracked statuses = [];
15
17
  @tracked orders = this.args.orders ?? [];
18
+ @tracked orderConfig = this.args.orderConfig ?? null;
16
19
 
17
20
  #defaultStatuses = {
18
21
  start: ['created', 'dispatched', 'started'],
@@ -48,7 +51,7 @@ export default class OrderKanbanComponent extends Component {
48
51
 
49
52
  return final.map((status, index) => ({
50
53
  id: status,
51
- title: titleize(status),
54
+ title: titleize(smartHumanize(status)),
52
55
  position: index,
53
56
  cards: this.#getOrdersByStatus(status, this.orders),
54
57
  }));
@@ -96,13 +99,34 @@ export default class OrderKanbanComponent extends Component {
96
99
  }
97
100
  }
98
101
 
102
+ @action handleArgsChange(el, [orderConfig, orders = []]) {
103
+ if (isArray(orders)) {
104
+ this.orders = orders;
105
+ }
106
+
107
+ if (isUuid(orderConfig)) {
108
+ this.orderConfig = orderConfig;
109
+ } else {
110
+ this.orderConfig = null;
111
+ }
112
+ this.loadStatuses.perform();
113
+ }
114
+
99
115
  #getOrdersByStatus(status, orders = []) {
100
- return orders.filter((order) => order.status === status);
116
+ let filteredOrders = orders.filter((order) => order.status === status);
117
+ if (this.orderConfig) {
118
+ filteredOrders = filteredOrders.filter((order) => order.order_config_uuid === this.orderConfig);
119
+ }
120
+
121
+ return filteredOrders;
101
122
  }
102
123
 
103
124
  @task *loadStatuses() {
125
+ const params = {};
126
+ if (this.orderConfig) params.order_config_uuid = this.orderConfig;
127
+
104
128
  try {
105
- const statuses = yield this.fetch.get('orders/statuses');
129
+ const statuses = yield this.fetch.get('orders/statuses', params);
106
130
  this.statuses = isArray(statuses) ? statuses : [];
107
131
  } catch (err) {
108
132
  debug('Unable to load order statuses: ' + err.message);
@@ -66,8 +66,10 @@ export default class OperationsOrdersIndexNewController extends Controller {
66
66
  this.universe.trigger('fleet-ops.order.creating', order);
67
67
 
68
68
  // Save custom field values
69
- const { created: customFieldValues } = yield order.cfManager.saveTo(order);
70
- order.custom_field_values.pushObjects(customFieldValues);
69
+ if (order.cfManager) {
70
+ const { created: customFieldValues } = yield order.cfManager.saveTo(order);
71
+ order.custom_field_values.pushObjects(customFieldValues);
72
+ }
71
73
 
72
74
  // Save order
73
75
  const createdOrder = yield order.save();
@@ -63,57 +63,62 @@ export default class OperationsOrdersIndexController extends Controller {
63
63
  @tracked without_driver;
64
64
  @tracked status;
65
65
  @tracked type;
66
+ @tracked orderConfig;
66
67
  @tracked bulkSearchValue = '';
67
68
  @tracked bulk_query = '';
68
69
  @tracked layout = 'map';
69
70
 
70
71
  /** action buttons */
71
- @tracked actionButtons = [
72
- {
73
- icon: 'refresh',
74
- onClick: this.orderActions.refresh,
75
- helpText: this.intl.t('common.refresh'),
76
- },
77
- {
78
- text: this.intl.t('common.new'),
79
- type: 'primary',
80
- icon: 'plus',
81
- onClick: this.orderActions.transition.create,
82
- },
83
- {
84
- text: this.intl.t('common.export'),
85
- icon: 'long-arrow-up',
86
- iconClass: 'rotate-icon-45',
87
- wrapperClass: 'hidden md:flex',
88
- onClick: this.orderActions.export,
89
- },
90
- ];
72
+ get actionButtons() {
73
+ return [
74
+ {
75
+ icon: 'refresh',
76
+ onClick: this.orderActions.refresh,
77
+ helpText: this.intl.t('common.refresh'),
78
+ },
79
+ {
80
+ text: this.intl.t('common.new'),
81
+ type: 'primary',
82
+ icon: 'plus',
83
+ onClick: this.orderActions.transition.create,
84
+ },
85
+ {
86
+ text: this.intl.t('common.export'),
87
+ icon: 'long-arrow-up',
88
+ iconClass: 'rotate-icon-45',
89
+ wrapperClass: 'hidden md:flex',
90
+ onClick: this.orderActions.export,
91
+ },
92
+ ];
93
+ }
91
94
 
92
95
  /** bulk actions */
93
- @tracked bulkActions = [
94
- {
95
- label: this.intl.t('common.cancel-resource', { resource: this.intl.t('resource.orders') }),
96
- icon: 'ban',
97
- fn: this.orderActions.bulkCancel,
98
- },
99
- {
100
- label: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.orders') }),
101
- icon: 'trash',
102
- class: 'text-red-500',
103
- fn: this.orderActions.bulkDelete,
104
- },
105
- { separator: true },
106
- {
107
- label: this.intl.t('common.dispatch-orders'),
108
- icon: 'rocket',
109
- fn: this.orderActions.bulkDispatch,
110
- },
111
- {
112
- label: this.intl.t('common.assign-driver'),
113
- icon: 'user-plus',
114
- fn: this.orderActions.bulkAssignDriver,
115
- },
116
- ];
96
+ get bulkActions() {
97
+ return [
98
+ {
99
+ label: this.intl.t('common.cancel-resource', { resource: this.intl.t('resource.orders') }),
100
+ icon: 'ban',
101
+ fn: this.orderActions.bulkCancel,
102
+ },
103
+ {
104
+ label: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.orders') }),
105
+ icon: 'trash',
106
+ class: 'text-red-500',
107
+ fn: this.orderActions.bulkDelete,
108
+ },
109
+ { separator: true },
110
+ {
111
+ label: this.intl.t('common.dispatch-orders'),
112
+ icon: 'rocket',
113
+ fn: this.orderActions.bulkDispatch,
114
+ },
115
+ {
116
+ label: this.intl.t('common.assign-driver'),
117
+ icon: 'user-plus',
118
+ fn: this.orderActions.bulkAssignDriver,
119
+ },
120
+ ];
121
+ }
117
122
 
118
123
  /** columns */
119
124
  get columns() {
@@ -366,7 +371,7 @@ export default class OperationsOrdersIndexController extends Controller {
366
371
  ddButtonText: false,
367
372
  ddButtonIcon: 'ellipsis-h',
368
373
  ddButtonIconPrefix: 'fas',
369
- ddMenuLabel: this.intl.t('common.resource-actions', { resource: this.intl.t('resource.Order') }),
374
+ ddMenuLabel: this.intl.t('common.resource-actions', { resource: this.intl.t('resource.order') }),
370
375
  cellClassNames: 'overflow-visible',
371
376
  wrapperClass: 'flex items-center justify-end mx-2',
372
377
  width: '12%',
@@ -30,9 +30,6 @@ export default class OperationsOrdersIndexRoute extends Route {
30
30
  before: { refreshModel: true },
31
31
  type: { refreshModel: true },
32
32
  layout: { refreshModel: false },
33
- drawerOpen: { refreshModel: false },
34
- drawerTab: { refreshModel: false },
35
- orderPanelOpen: { refreshModel: false },
36
33
  };
37
34
 
38
35
  model(params) {
@@ -1,6 +1,6 @@
1
1
  import Service, { inject as service } from '@ember/service';
2
2
  import { tracked } from '@glimmer/tracking';
3
- import { later } from '@ember/runloop';
3
+ import { next } from '@ember/runloop';
4
4
 
5
5
  export default class OrderCreationService extends Service {
6
6
  @service orderActions;
@@ -12,13 +12,9 @@ export default class OrderCreationService extends Service {
12
12
  const order = this.orderActions.createNewInstance(attrs);
13
13
  this.order = order;
14
14
 
15
- later(
16
- this,
17
- () => {
18
- this.addContext('order', order);
19
- },
20
- 0
21
- );
15
+ next(() => {
16
+ this.addContext('order', order);
17
+ });
22
18
 
23
19
  return order;
24
20
  }
@@ -23,7 +23,7 @@ export default class OrderValidationService extends Service {
23
23
  const hasWaypoints = order.payload.waypoints.length >= 2;
24
24
  const hasPickup = isNotEmpty(order.payload.pickup);
25
25
  const hasDropoff = isNotEmpty(order.payload.dropoff);
26
- const hasValidCustomFields = cfManager ? this.isCustomFieldsValid(cfManager) : false;
26
+ const hasValidCustomFields = cfManager ? this.isCustomFieldsValid(cfManager) : true;
27
27
 
28
28
  if (hasWaypoints) {
29
29
  return hasOrderConfig && hasOrderType && hasValidCustomFields;
@@ -37,13 +37,13 @@ export default class OrderValidationService extends Service {
37
37
  }
38
38
 
39
39
  validateCustomFields(cfManager) {
40
- if (!cfManager) return false;
40
+ if (!cfManager) return true;
41
41
 
42
42
  return cfManager.validateRequired();
43
43
  }
44
44
 
45
45
  isCustomFieldsValid(cfManager) {
46
- if (!cfManager) return false;
46
+ if (!cfManager) return true;
47
47
 
48
48
  const { isValid } = this.validateCustomFields(cfManager);
49
49
  return isValid;
@@ -1654,3 +1654,38 @@ button.fleetops-btn-xxs,
1654
1654
  padding-top: 0.2rem !important;
1655
1655
  padding-bottom: 0.2rem !important;
1656
1656
  }
1657
+
1658
+ /** css fix for operations index/kanban */
1659
+ main.console-fleet-ops-operations-orders-index-index section.next-view-section {
1660
+ max-width: 100vw;
1661
+ min-width: 0;
1662
+ }
1663
+
1664
+ main.console-fleet-ops-operations-orders-index-index section.next-view-section > .next-view-section-container {
1665
+ display: flex;
1666
+ flex-direction: column;
1667
+ max-width: 100vw;
1668
+ min-width: 0;
1669
+ }
1670
+
1671
+ main.console-fleet-ops-operations-orders-index-index section.next-view-section > .next-view-section-container > .next-view-section-subheader {
1672
+ flex: 0 0 auto;
1673
+ width: 100%;
1674
+ }
1675
+
1676
+ main.console-fleet-ops-operations-orders-index-index section.next-view-section > .next-view-section-container > .next-view-section-body {
1677
+ flex: 1 1 auto;
1678
+ min-width: 0;
1679
+ overflow: auto;
1680
+ }
1681
+
1682
+ main.console-fleet-ops-operations-orders-index-index section.next-view-section > .next-view-section-container > .next-view-section-body > .kanban-board {
1683
+ display: flex;
1684
+ }
1685
+
1686
+ .next-view-section-subheader .next-view-section-subheader-actions .order-board-type-filter.ember-power-select-trigger.ember-basic-dropdown-trigger {
1687
+ height: 2rem;
1688
+ align-items: center;
1689
+ padding-left: 0.5rem;
1690
+ padding-right: 2rem;
1691
+ }
@@ -53,9 +53,33 @@
53
53
  {{/if}}
54
54
 
55
55
  {{#if (eq this.layout "kanban")}}
56
- <Layout::Section::Header @title={{t "menu.order-board"}} @actionsWrapperClass="space-x-1" />
56
+ <Layout::Section::Header @title={{t "menu.order-board"}} @subtitle={{@model.length}} @subtitleClass="text-xs text-center font-semibold ml-2 w-6 h-6 rounded-full bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-100 flex items-center justify-center" @actionsWrapperClass="space-x-1">
57
+ <div class="flex flex-row items-center space-x-2">
58
+ <div class="w-64">
59
+ <div class="fleetbase-model-select fleetbase-power-select ember-model-select">
60
+ <ModelSelect
61
+ @modelName="order-config"
62
+ @selectedModel={{or this.orderConfig this.type}}
63
+ @placeholder={{t "order.fields.order-type-placeholder"}}
64
+ @triggerClass="form-select form-input order-board-type-filter"
65
+ @infiniteScroll={{false}}
66
+ @renderInPlace={{true}}
67
+ @allowClear={{true}}
68
+ @onChange={{fn (mut this.orderConfig)}}
69
+ @onChangeId={{fn (mut this.type)}}
70
+ as |orderConfig|
71
+ >
72
+ <div class="text-sm">
73
+ <div class="font-semibold normalize-in-trigger">{{orderConfig.name}}</div>
74
+ <div class="hide-from-trigger">{{n-a orderConfig.description}}</div>
75
+ </div>
76
+ </ModelSelect>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </Layout::Section::Header>
57
81
  <Layout::Section::Body>
58
- <Order::Kanban @orders={{@model}} @headerOffset={{160}} />
82
+ <Order::Kanban @orders={{@model}} @headerOffset={{160}} @orderConfig={{this.type}} />
59
83
  </Layout::Section::Body>
60
84
  {{/if}}
61
85
 
@@ -4,6 +4,8 @@ import { setOwner } from '@ember/application';
4
4
  import { debug } from '@ember/debug';
5
5
 
6
6
  export default function setupCustomerPortal(app, engine, universe) {
7
+ if (!customerPortalInstalled(app)) return;
8
+
7
9
  universe.afterBoot(function (u) {
8
10
  const portal = u.getEngineInstance('@fleetbase/customer-portal-engine');
9
11
  if (!portal) {
@@ -58,3 +60,8 @@ function createEngineBoundComponent(engineInstance, ComponentClass) {
58
60
  }
59
61
  };
60
62
  }
63
+
64
+ function customerPortalInstalled(app) {
65
+ const extensions = app.extensions ?? [];
66
+ return extensions.find(({ name }) => name === '@fleetbase/customer-portal-engine');
67
+ }
package/composer.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fleetbase/fleetops-api",
3
- "version": "0.6.19",
3
+ "version": "0.6.20",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "keywords": [
6
6
  "fleetbase-extension",
package/extension.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Fleet-Ops",
3
- "version": "0.6.19",
3
+ "version": "0.6.20",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "repository": "https://github.com/fleetbase/fleetops",
6
6
  "license": "AGPL-3.0-or-later",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/fleetops-engine",
3
- "version": "0.6.19",
3
+ "version": "0.6.20",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "fleetbase": {
6
6
  "route": "fleet-ops"
@@ -42,8 +42,8 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@babel/core": "^7.23.2",
45
- "@fleetbase/ember-core": "latest",
46
- "@fleetbase/ember-ui": "latest",
45
+ "@fleetbase/ember-core": "^0.3.4",
46
+ "@fleetbase/ember-ui": "^0.3.6",
47
47
  "@fleetbase/fleetops-data": "^0.1.20",
48
48
  "@fleetbase/leaflet-routing-machine": "^3.2.17",
49
49
  "@fortawesome/ember-fontawesome": "^2.0.0",
@@ -774,55 +774,30 @@ class OrderController extends FleetOpsController
774
774
  }
775
775
 
776
776
  /**
777
- * Retrieve all distinct order statuses for the authenticated company.
778
- *
779
- * This endpoint compiles:
780
- * 1. All unique `status` values from the `orders` table for the current company.
781
- * 2. Optionally, all `Activity` codes defined in `OrderConfig` records that are
782
- * actually referenced by existing orders (via `order_config_uuid`).
783
- *
784
- * ---
785
- * ### Query Parameters
786
- * - `include_order_config_activities` (bool, optional)
787
- * When true, includes `Activity` codes from relevant `OrderConfig` instances.
788
- *
789
- * - `order_config_key` (string, optional)
790
- * Restricts both `orders` and `order_configs` to a specific configuration key.
791
- *
792
- * ---
793
- * ### Behavior
794
- * - Only includes order configs that are actually used by orders.
795
- * - Uses the `activities()` method on each `OrderConfig` to extract all activity codes.
796
- * - Merges and deduplicates both order statuses and activity codes.
797
- *
798
- * ---
799
- * ### Example Response
800
- * ```json
801
- * [
802
- * "created",
803
- * "dispatched",
804
- * "completed",
805
- * "canceled",
806
- * "pickup_ready",
807
- * "awaiting_payment"
808
- * ]
809
- * ```
810
- *
811
- * @return \Illuminate\Http\JsonResponse
777
+ * Return distinct order statuses (and optionally activity codes) for a company,
778
+ * filtered by order_config_uuid or order_config_key if provided.
812
779
  */
813
780
  public function statuses(Request $request)
814
781
  {
815
- $companyUuid = $request->user()->company_uuid ?? session('company');
816
-
782
+ $companyUuid = $request->user()->company_uuid ?? session('company');
817
783
  $includeActivities = $request->boolean('include_order_config_activities', true);
818
- $orderConfigKey = trim((string) $request->string('order_config_key'));
819
784
 
820
- // Get distinct statuses from orders
785
+ // Use input() + trim to get plain strings (Request::string() returns Stringable in newer Laravel)
786
+ $orderConfigKey = trim((string) $request->input('order_config_key', ''));
787
+ $orderConfigId = trim((string) $request->input('order_config_uuid', ''));
788
+
789
+ // ---------------------------
790
+ // Build base orders query
791
+ // ---------------------------
821
792
  $ordersQuery = DB::table('orders')
822
793
  ->where('company_uuid', $companyUuid)
823
- ->whereNotNull('status');
794
+ ->whereNotNull('status')
795
+ ->whereNull('deleted_at');
824
796
 
825
- if ($orderConfigKey !== '') {
797
+ // Prefer filtering by UUID (most precise), else by key
798
+ if ($orderConfigId !== '') {
799
+ $ordersQuery->where('order_config_uuid', $orderConfigId);
800
+ } elseif ($orderConfigKey !== '') {
826
801
  $ordersQuery->whereExists(function ($q) use ($companyUuid, $orderConfigKey) {
827
802
  $q->select(DB::raw(1))
828
803
  ->from('order_configs as oc')
@@ -832,34 +807,38 @@ class OrderController extends FleetOpsController
832
807
  });
833
808
  }
834
809
 
810
+ // Distinct order statuses
835
811
  $orderStatuses = $ordersQuery->distinct()->pluck('status')->filter();
836
812
 
837
- // Optionally include activity codes from used order configs
813
+ // ---------------------------------------
814
+ // Optionally include activity codes
815
+ // (must use the SAME target config set)
816
+ // ---------------------------------------
838
817
  $activityCodes = collect();
839
818
 
840
819
  if ($includeActivities) {
841
- $configUuidsOnOrders = DB::table('orders')
842
- ->where('company_uuid', $companyUuid)
843
- ->when($orderConfigKey !== '', function ($q) use ($companyUuid, $orderConfigKey) {
844
- $q->whereExists(function ($sub) use ($companyUuid, $orderConfigKey) {
845
- $sub->select(DB::raw(1))
846
- ->from('order_configs as oc')
847
- ->whereColumn('oc.uuid', 'orders.order_config_uuid')
848
- ->where('oc.company_uuid', $companyUuid)
849
- ->where('oc.key', $orderConfigKey);
850
- });
851
- })
852
- ->whereNotNull('order_config_uuid')
853
- ->distinct()
854
- ->pluck('order_config_uuid')
855
- ->filter();
820
+ // Determine target config UUIDs once, honoring UUID > key > all-on-company
821
+ if ($orderConfigId !== '') {
822
+ $targetConfigUuids = collect([$orderConfigId]);
823
+ } elseif ($orderConfigKey !== '') {
824
+ $targetConfigUuids = DB::table('order_configs')
825
+ ->where('company_uuid', $companyUuid)
826
+ ->where('key', $orderConfigKey)
827
+ ->pluck('uuid');
828
+ } else {
829
+ // No filter given; derive from orders in this company
830
+ $targetConfigUuids = DB::table('orders')
831
+ ->where('company_uuid', $companyUuid)
832
+ ->whereNotNull('order_config_uuid')
833
+ ->distinct()
834
+ ->pluck('order_config_uuid');
835
+ }
856
836
 
857
- if ($configUuidsOnOrders->isNotEmpty()) {
837
+ if ($targetConfigUuids->isNotEmpty()) {
858
838
  $orderConfigs = OrderConfig::where('company_uuid', $companyUuid)
859
- ->whereIn('uuid', $configUuidsOnOrders)
839
+ ->whereIn('uuid', $targetConfigUuids)
860
840
  ->get();
861
841
 
862
- /** @var OrderConfig $config */
863
842
  foreach ($orderConfigs as $config) {
864
843
  if (!method_exists($config, 'activities')) {
865
844
  continue;
@@ -867,19 +846,22 @@ class OrderController extends FleetOpsController
867
846
 
868
847
  $activities = $config->activities();
869
848
 
870
- if ($activities instanceof Collection) {
871
- $codes = $activities
872
- ->map(fn ($activity) => $activity->code ?? null)
873
- ->filter()
874
- ->values();
849
+ // Handle Collection/array gracefully
850
+ $codes = collect($activities)
851
+ ->map(function ($activity) {
852
+ return data_get($activity, 'code');
853
+ })
854
+ ->filter()
855
+ ->values();
875
856
 
876
- $activityCodes = $activityCodes->merge($codes);
877
- }
857
+ $activityCodes = $activityCodes->merge($codes);
878
858
  }
879
859
  }
880
860
  }
881
861
 
882
- // Merge & deduplicate everything
862
+ // ---------------------------------------
863
+ // Merge & return
864
+ // ---------------------------------------
883
865
  $result = $orderStatuses
884
866
  ->merge($activityCodes)
885
867
  ->unique()
@@ -368,7 +368,15 @@ class Payload extends Model
368
368
  ) {
369
369
  $placeUuid = $attributes['place_uuid'];
370
370
 
371
- // Path 2: public_id under "id" -> resolve to uuid
371
+ // Path 2: public_id under "uuid" -> resolve to uuid
372
+ } elseif (
373
+ is_array($attributes)
374
+ && isset($attributes['uuid'])
375
+ && ($resolvedUuid = Place::where('uuid', $attributes['uuid'])->value('uuid'))
376
+ ) {
377
+ $placeUuid = $resolvedUuid;
378
+
379
+ // Path 3: public_id under "id" -> resolve to uuid
372
380
  } elseif (
373
381
  is_array($attributes)
374
382
  && isset($attributes['id'])
@@ -376,10 +384,11 @@ class Payload extends Model
376
384
  ) {
377
385
  $placeUuid = $resolvedUuid;
378
386
 
379
- // Path 3: create from mixed payload
387
+ // Path 4: create from mixed payload
380
388
  } else {
381
389
  $place = Place::createFromMixed($attributes);
382
390
 
391
+
383
392
  // Store temp search UUID for traceability if present and different
384
393
  if ($place instanceof Place && isset($attributes['uuid']) && $place->uuid !== $attributes['uuid']) {
385
394
  $place->updateMeta('search_uuid', $attributes['uuid']);
@@ -420,7 +429,7 @@ class Payload extends Model
420
429
  // -------- Upsert Waypoint --------
421
430
  // Uniqueness: payload + place + order for deterministic row per position.
422
431
  $unique = [
423
- 'payload_uuid' => $this->payload_uuid,
432
+ 'payload_uuid' => $this->uuid,
424
433
  'place_uuid' => $placeUuid,
425
434
  'order' => $index,
426
435
  ];
@@ -362,7 +362,11 @@ class Place extends Model
362
362
  $results = \Geocoder\Laravel\Facades\Geocoder::geocode($address)->get();
363
363
 
364
364
  if ($results->isEmpty() || !$results->first()) {
365
- return (new static())->newInstance(['street1' => $address]);
365
+ $place = (new static())->newInstance(['street1' => $address, 'location' => new SpatialPoint(0, 0)]);
366
+ if ($saveInstance) {
367
+ $place->save();
368
+ }
369
+ return $place;
366
370
  }
367
371
 
368
372
  return static::createFromGoogleAddress($results->first(), $saveInstance);
@@ -8,6 +8,7 @@ use Fleetbase\FleetOps\Casts\Point;
8
8
  use Fleetbase\FleetOps\Support\Utils;
9
9
  use Fleetbase\FleetOps\Support\VehicleData;
10
10
  use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait;
11
+ use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint;
11
12
  use Fleetbase\Models\Category;
12
13
  use Fleetbase\Models\File;
13
14
  use Fleetbase\Models\Model;
@@ -23,6 +24,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
23
24
  use Illuminate\Database\Eloquent\Relations\HasManyThrough;
24
25
  use Illuminate\Database\Eloquent\Relations\HasOne;
25
26
  use Illuminate\Database\Eloquent\Relations\MorphMany;
27
+ use Illuminate\Support\Arr;
26
28
  use Illuminate\Support\Str;
27
29
  use Spatie\Activitylog\LogOptions;
28
30
  use Spatie\Activitylog\Traits\LogsActivity;
@@ -61,7 +63,7 @@ class Vehicle extends Model
61
63
  *
62
64
  * @var array
63
65
  */
64
- protected $searchableColumns = ['make', 'model', 'year', 'plate_number', 'vin', 'public_id'];
66
+ protected $searchableColumns = ['name', 'description', 'make', 'model', 'trim', 'model_type', 'body_type', 'body_sub_type', 'year', 'plate_number', 'vin', 'call_sign', 'public_id'];
65
67
 
66
68
  /**
67
69
  * Attributes that is filterable on this model.
@@ -533,6 +535,34 @@ class Vehicle extends Model
533
535
  return ($isFirstPosition || $isPast50Meters) ? Position::create($positionData) : null;
534
536
  }
535
537
 
538
+ /**
539
+ * Creates a new position for the vehicle
540
+ *
541
+ * @param array $attributes
542
+ * @return Position|null
543
+ */
544
+ public function createPosition(array $attributes = [], Model|string|null $destination = null): ?Position
545
+ {
546
+ if (!isset($attributes['coordinates']) && isset($attributes['location'])) {
547
+ $attributes['coordinates'] = $attributes['location'];
548
+ }
549
+
550
+ if (!isset($attributes['coordinates']) && isset($attributes['latitude']) && isset($attributes['longitude'])) {
551
+ $attributes['coordinates'] = new SpatialPoint($attributes['latitude'], $attributes['longitude']);
552
+ }
553
+
554
+ // handle destination if set
555
+ $destinationUuid = Str::isUuid($destination) ? $destination : data_get($destination, 'uuid');
556
+
557
+ return Position::create([
558
+ ...Arr::only($attributes, ['coordinates', 'heading', 'bearing', 'speed', 'altitude']),
559
+ 'subject_uuid' => $this->uuid,
560
+ 'subject_type' => $this->getMorphClass(),
561
+ 'company_uuid' => $this->company_uuid,
562
+ 'destination_uuid' => $destinationUuid
563
+ ]);
564
+ }
565
+
536
566
  public static function createFromImport(array $row, bool $saveInstance = false): Vehicle
537
567
  {
538
568
  // Filter array for null key values
@@ -660,4 +690,80 @@ class Vehicle extends Model
660
690
 
661
691
  return $details;
662
692
  }
693
+
694
+ /**
695
+ * Set or update a single key/value pair in the `specs` JSON column.
696
+ *
697
+ * Uses Laravel's `data_set` helper to allow dot notation for nested keys.
698
+ *
699
+ * @param string|array $key the key (or array path) to set within the specs
700
+ * @param mixed $value the value to assign to the given key
701
+ *
702
+ * @return array the updated specs array
703
+ */
704
+ public function setSpec(string|array $key, mixed $value): array
705
+ {
706
+ $specs = is_array($this->specs) ? $this->specs : (array) $this->specs;
707
+ data_set($specs, $key, $value);
708
+ $this->specs = $specs;
709
+
710
+ return $specs;
711
+ }
712
+
713
+ /**
714
+ * Merge multiple values into the `specs` JSON column.
715
+ *
716
+ * By default this performs a shallow merge (overwrites duplicate keys).
717
+ * Use `array_replace_recursive` if you need nested merges.
718
+ *
719
+ * @param array $newSpecs key/value pairs to merge into specs
720
+ *
721
+ * @return array the updated specs array
722
+ */
723
+ public function setSpecs(array $newSpecs = []): array
724
+ {
725
+ $specs = is_array($this->specs) ? $this->specs : (array) $this->specs;
726
+ $specs = array_merge($specs, $newSpecs);
727
+ $this->specs = $specs;
728
+
729
+ return $specs;
730
+ }
731
+
732
+ /**
733
+ * Set or update a single key/value pair in the `vin_data` JSON column.
734
+ *
735
+ * Uses Laravel's `data_set` helper to allow dot notation for nested keys.
736
+ *
737
+ * @param string|array $key the key (or array path) to set within the VIN data
738
+ * @param mixed $value the value to assign to the given key
739
+ *
740
+ * @return array the updated vin_data array
741
+ */
742
+ public function setVinData(string|array $key, mixed $value): array
743
+ {
744
+ $vinData = is_array($this->vin_data) ? $this->vin_data : (array) $this->vin_data;
745
+ data_set($vinData, $key, $value);
746
+ $this->vin_data = $vinData;
747
+
748
+ return $vinData;
749
+ }
750
+
751
+ /**
752
+ * Merge multiple values into the `vin_data` JSON column.
753
+ *
754
+ * By default this performs a shallow merge (overwrites duplicate keys).
755
+ * Use `array_replace_recursive` if you need nested merges.
756
+ *
757
+ * @param array $newVinData key/value pairs to merge into vin_data
758
+ *
759
+ * @return array the updated vin_data array
760
+ */
761
+ public function setVinDatas(array $newVinData = []): array
762
+ {
763
+ $vinData = is_array($this->vin_data) ? $this->vin_data : (array) $this->vin_data;
764
+ $vinData = array_merge($vinData, $newVinData);
765
+ $this->vin_data = $vinData;
766
+
767
+ return $vinData;
768
+ }
663
769
  }