@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
@@ -0,0 +1,25 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Database\Schema\Blueprint;
5
+ use Illuminate\Support\Facades\Schema;
6
+
7
+ return new class extends Migration {
8
+ public function up(): void
9
+ {
10
+ Schema::table('issues', function (Blueprint $table) {
11
+ if (!Schema::hasColumn('issues', 'order_uuid')) {
12
+ $table->uuid('order_uuid')->nullable()->index()->after('vehicle_uuid');
13
+ }
14
+ });
15
+ }
16
+
17
+ public function down(): void
18
+ {
19
+ Schema::table('issues', function (Blueprint $table) {
20
+ if (Schema::hasColumn('issues', 'order_uuid')) {
21
+ $table->dropColumn('order_uuid');
22
+ }
23
+ });
24
+ }
25
+ };
@@ -0,0 +1,39 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Models\Order;
4
+ use Fleetbase\FleetOps\Models\PurchaseRate;
5
+ use Illuminate\Database\Migrations\Migration;
6
+ use Illuminate\Support\Facades\DB;
7
+
8
+ return new class extends Migration {
9
+ public function up(): void
10
+ {
11
+ DB::table('orders')
12
+ ->whereNotNull('transaction_uuid')
13
+ ->orderBy('id')
14
+ ->select(['id', 'uuid', 'transaction_uuid', 'purchase_rate_uuid'])
15
+ ->chunkById(500, function ($orders) {
16
+ foreach ($orders as $order) {
17
+ $updates = [
18
+ 'subject_uuid' => $order->uuid,
19
+ 'subject_type' => Order::class,
20
+ 'updated_at' => now(),
21
+ ];
22
+
23
+ if ($order->purchase_rate_uuid) {
24
+ $updates['context_uuid'] = $order->purchase_rate_uuid;
25
+ $updates['context_type'] = PurchaseRate::class;
26
+ }
27
+
28
+ DB::table('transactions')
29
+ ->where('uuid', $order->transaction_uuid)
30
+ ->update($updates);
31
+ }
32
+ });
33
+ }
34
+
35
+ public function down(): void
36
+ {
37
+ // Intentionally no-op: this migration corrects broken order transaction linkage.
38
+ }
39
+ };
@@ -0,0 +1,24 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Support\Facades\DB;
5
+
6
+ return new class extends Migration {
7
+ /**
8
+ * Backfill null directions for transactions linked to FleetOps orders.
9
+ */
10
+ public function up(): void
11
+ {
12
+ DB::table('transactions')
13
+ ->join('orders', 'orders.transaction_uuid', '=', 'transactions.uuid')
14
+ ->whereNull('transactions.direction')
15
+ ->update(['transactions.direction' => 'credit']);
16
+ }
17
+
18
+ /**
19
+ * Direction backfills are not reversible without losing real data.
20
+ */
21
+ public function down(): void
22
+ {
23
+ }
24
+ };
@@ -1,23 +1,26 @@
1
1
  <x-mail-layout>
2
2
  <h2 style="font-size: 18px; font-weight: 600;">
3
- @if($currentHour < 12)
4
- Good Morning, {{ $customer->name }}!
5
- @elseif($currentHour < 18)
6
- Good Afternoon, {{ $customer->name }}!
7
- @else
8
- Good Evening, {{ $customer->name }}!
9
- @endif
3
+ Your customer portal access is ready
10
4
  </h2>
11
5
 
12
- Your login credentials:
6
+ Hi {{ $customer->name }},
13
7
  <br />
14
8
  <br />
15
- Your Email: {{ $customer->user->email }}
9
+ {{ $customer->company->name }} has created customer portal access for you. You can use the portal to view orders, invoices, support requests, and account details when available.
16
10
  <br />
17
- Your Password: {{ $plaintextPassword }}
18
- @if($customerPortalUrl)
19
11
  <br />
20
- Customer Portal URL: {{ $customerPortalUrl}}
21
- @endif
12
+ <a href="{{ $customerPortalUrl }}" style="display:inline-block; background:#2563eb; color:#ffffff; text-decoration:none; border-radius:6px; padding:12px 18px; font-size:14px; line-height:20px; font-weight:700;">Sign in to customer portal</a>
13
+ <br />
14
+ <br />
15
+ <strong>Your sign-in details</strong>
16
+ <br />
17
+ Email: {{ $customer->user->email }}
18
+ <br />
19
+ Temporary password: {{ $plaintextPassword }}
20
+ <br />
21
+ Portal URL: {{ $customerPortalUrl }}
22
+ <br />
23
+ <br />
24
+ You can change your password after signing in.
22
25
 
23
26
  </x-mail-layout>
@@ -47,6 +47,21 @@ class UserFilterExpansion implements Expansion
47
47
  };
48
48
  }
49
49
 
50
+ /**
51
+ * Filter where a user is not a customer.
52
+ *
53
+ * @return void
54
+ */
55
+ public static function isNotCustomer()
56
+ {
57
+ return function () {
58
+ /** @var \Fleetbase\Http\Filter\UserFilter|\Fleetbase\Http\Filter\Filter $this */
59
+ $this->builder->where(function ($query) {
60
+ $query->whereNull('type')->orWhere('type', '!=', 'customer');
61
+ });
62
+ };
63
+ }
64
+
50
65
  /**
51
66
  * Filter where doesnt have driver within the CURRENT organizations.
52
67
  *
@@ -62,6 +77,19 @@ class UserFilterExpansion implements Expansion
62
77
  };
63
78
  }
64
79
 
80
+ /**
81
+ * Filter where users can be driver.
82
+ *
83
+ * @return void
84
+ */
85
+ public static function canBeDriver()
86
+ {
87
+ return function () {
88
+ /** @var \Fleetbase\Http\Filter\UserFilter|\Fleetbase\Http\Filter\Filter $this */
89
+ $this->builder->whereIn('type', ['user', 'admin']);
90
+ };
91
+ }
92
+
65
93
  /**
66
94
  * @return void
67
95
  */
@@ -30,6 +30,7 @@ class IssueController extends Controller
30
30
  'type',
31
31
  'report',
32
32
  'priority',
33
+ 'tags',
33
34
  'status',
34
35
  ]);
35
36
 
@@ -85,6 +86,7 @@ class IssueController extends Controller
85
86
  'type',
86
87
  'report',
87
88
  'priority',
89
+ 'tags',
88
90
  'status',
89
91
  ]);
90
92
 
@@ -1013,11 +1013,10 @@ class OrderController extends Controller
1013
1013
  $driver->current_job_uuid = $order->uuid;
1014
1014
  $driver->save();
1015
1015
 
1016
- /** @var \Fleetbase\LaravelMysqlSpatial\Types\Point */
1017
- $location = $order->getLastLocation();
1018
-
1019
- // set first destination for payload
1020
- $payload->setFirstWaypoint($activity, $location);
1016
+ // set first service stop destination for multi-stop routes
1017
+ if ($this->payloadUsesServiceStopActivity($payload)) {
1018
+ $this->ensurePayloadCurrentServiceStop($payload);
1019
+ }
1021
1020
  $order->setRelation('payload', $payload);
1022
1021
 
1023
1022
  // update order activity
@@ -1108,37 +1107,35 @@ class OrderController extends Controller
1108
1107
  /** @var \Fleetbase\LaravelMysqlSpatial\Types\Point */
1109
1108
  $location = $order->getLastLocation();
1110
1109
 
1111
- // Check if multiple waypoint order to update activity for.
1112
- $isMultipleWaypointOrder = $this->payloadHasWaypoints($order->payload);
1110
+ $usesServiceStopActivity = $this->payloadUsesServiceStopActivity($order->payload);
1111
+ $isLifecycleActivity = Utils::isActivity($activity) && in_array($activity->code, ['created', 'dispatched', 'started'], true);
1113
1112
 
1114
- // also update for each order entities if not multiple drop order
1115
- // all entities will share the same activity status as is one drop order
1116
- if (!$isMultipleWaypointOrder) {
1113
+ // Classic pickup/dropoff orders and static lifecycle statuses update the order.
1114
+ if (!$usesServiceStopActivity || $isLifecycleActivity) {
1117
1115
  // Update order activity
1118
1116
  $order->updateActivity($activity, $proof);
1119
1117
 
1120
- // Only update entities belonging to the waypoint
1121
- foreach ($order->payload->entities as $entity) {
1122
- $entity->insertActivity($activity, $location, $proof);
1118
+ if (!$usesServiceStopActivity) {
1119
+ foreach ($order->payload->entities as $entity) {
1120
+ $entity->insertActivity($activity, $location, $proof);
1121
+ }
1122
+ }
1123
+
1124
+ if ($usesServiceStopActivity && $activity->is('started')) {
1125
+ $this->ensurePayloadCurrentServiceStop($order->payload);
1123
1126
  }
1127
+
1124
1128
  // Handle order completion
1125
1129
  if (Utils::isActivity($activity) && $activity->completesOrder()) {
1126
1130
  $order->driverAssigned?->unassignCurrentJob();
1127
1131
  $order->complete($this->resolveProof($proof));
1128
1132
  }
1129
1133
 
1130
- return new OrderResource($order);
1134
+ return new OrderResource($order->refresh());
1131
1135
  }
1132
1136
 
1133
- if ($activity->is('started')) {
1134
- $order->updateActivity($activity, $proof);
1135
-
1136
- $this->ensurePayloadCurrentServiceStop($order->payload);
1137
- if (!$this->payloadHasCurrentServiceStopActivity($order->payload, $activity)) {
1138
- $this->updateCurrentServiceStopActivity($order, $activity, $location, $proof);
1139
- }
1140
-
1141
- return new OrderResource($order->refresh());
1137
+ if (!$order->started && in_array($order->status, ['created', 'dispatched'], true)) {
1138
+ return response()->apiError('Order must be started before waypoint activity can be updated.', 422);
1142
1139
  }
1143
1140
 
1144
1141
  $currentStop = $this->ensurePayloadCurrentServiceStop($order->payload);
@@ -1192,9 +1189,13 @@ class OrderController extends Controller
1192
1189
  );
1193
1190
  }
1194
1191
 
1192
+ $canUpdateServiceStopActivity = $this->payloadUsesServiceStopActivity($order->payload)
1193
+ && ($order->started || $order->started_at || !in_array($order->status, ['created', 'dispatched'], true));
1195
1194
  $stop = null;
1196
- if ($waypointId) {
1197
- $stop = $this->resolveServiceStopFromKey($order->payload, $waypointId);
1195
+ if ($canUpdateServiceStopActivity) {
1196
+ $stop = $waypointId
1197
+ ? $this->resolveServiceStopFromKey($order->payload, $waypointId)
1198
+ : $this->payloadCurrentServiceStop($order->payload);
1198
1199
  }
1199
1200
 
1200
1201
  $orderConfig = $order->ensureOrderConfig();
@@ -7,15 +7,11 @@ use Fleetbase\FleetOps\Http\Requests\UpdatePlaceRequest;
7
7
  use Fleetbase\FleetOps\Http\Resources\v1\DeletedResource;
8
8
  use Fleetbase\FleetOps\Http\Resources\v1\Place as PlaceResource;
9
9
  use Fleetbase\FleetOps\Models\Place;
10
+ use Fleetbase\FleetOps\Support\PlaceSearch;
10
11
  use Fleetbase\FleetOps\Support\Utils;
11
12
  use Fleetbase\Http\Controllers\Controller;
12
13
  use Fleetbase\LaravelMysqlSpatial\Types\Point;
13
- use Geocoder\Laravel\Facades\Geocoder;
14
- use Geocoder\Provider\GoogleMapsPlaces\GoogleMapsPlaces;
15
- use Geocoder\Query\GeocodeQuery;
16
- use Http\Adapter\Guzzle7\Client;
17
14
  use Illuminate\Http\Request;
18
- use Illuminate\Support\Facades\DB;
19
15
  use Illuminate\Support\Str;
20
16
 
21
17
  class PlaceController extends Controller
@@ -283,63 +279,19 @@ class PlaceController extends Controller
283
279
  {
284
280
  $searchQuery = strtolower($request->input('query'));
285
281
  $limit = $request->input('limit', 10);
286
- $geo = $request->input('geo', false);
282
+ $geo = $request->boolean('geo');
287
283
  $latitude = $request->input('latitude', false);
288
284
  $longitude = $request->input('longitude', false);
289
285
 
290
- $results = DB::table('places')
291
- ->where('company_uuid', session('company'))
292
- ->whereNull('deleted_at')
293
- ->where(function ($q) use ($searchQuery) {
294
- if (Utils::notEmpty($searchQuery)) {
295
- $q->orWhere(DB::raw('lower(name)'), 'like', '%' . $searchQuery . '%');
296
- $q->orWhere(DB::raw('lower(street1)'), 'like', '%' . $searchQuery . '%');
297
- $q->orWhere(DB::raw('lower(postal_code)'), 'like', '%' . $searchQuery . '%');
298
- }
299
- })
300
- ->limit($limit)
301
- ->orderBy('name', 'desc')
302
- ->get()
303
- ->map(function ($place) {
304
- $place = (array) $place;
305
- $place['location'] = Utils::mysqlPointAsGeometry($place['location']);
306
- $place = new Place($place);
307
- $place->address = $place->toAddressString();
308
-
309
- return $place;
310
- })
311
- ->values();
312
-
313
- if ($geo && Utils::notEmpty($searchQuery)) {
314
- $httpClient = new Client();
315
- $provider = new \Geocoder\Provider\GoogleMaps\GoogleMaps($httpClient, null, env('GOOGLE_MAPS_API_KEY'));
316
- $geocoder = new \Geocoder\StatefulGeocoder($provider, 'en');
317
-
318
- if ($latitude && $longitude) {
319
- $geoResults = $geocoder->geocodeQuery(
320
- GeocodeQuery::create($searchQuery)
321
- ->withData('mode', GoogleMapsPlaces::GEOCODE_MODE_SEARCH)
322
- ->withData('location', "$latitude, $longitude")
323
- );
324
-
325
- $geoResults = collect($geoResults->all());
326
- } else {
327
- $geoResults = Geocoder::geocode($searchQuery)->get();
328
- }
286
+ $query = Place::where('company_uuid', session('company'))->whereNull('deleted_at');
329
287
 
330
- $geoResults = $geoResults
331
- ->map(function ($googleAddress) {
332
- return Place::createFromGoogleAddress($googleAddress);
333
- })
334
- ->values();
335
-
336
- $results = $results->merge($geoResults);
337
- }
338
-
339
- $results = $results
340
- ->sortBy('name')
341
- ->values()
342
- ->toArray();
288
+ $results = PlaceSearch::search($query, $searchQuery, [
289
+ 'limit' => $limit,
290
+ 'geo' => $geo,
291
+ 'latitude' => $latitude,
292
+ 'longitude' => $longitude,
293
+ 'no_query_order' => 'name_desc',
294
+ ]);
343
295
 
344
296
  return PlaceResource::collection($results);
345
297
  }
@@ -4,12 +4,23 @@ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
4
 
5
5
  use Fleetbase\FleetOps\Exports\ContactExport;
6
6
  use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
7
+ use Fleetbase\FleetOps\Http\Resources\v1\Vendor as VendorResource;
7
8
  use Fleetbase\FleetOps\Imports\ContactImport;
9
+ use Fleetbase\FleetOps\Mail\CustomerCredentialsMail;
8
10
  use Fleetbase\FleetOps\Models\Contact;
11
+ use Fleetbase\FleetOps\Models\Entity;
12
+ use Fleetbase\FleetOps\Models\Issue;
13
+ use Fleetbase\FleetOps\Models\Order;
14
+ use Fleetbase\FleetOps\Models\PurchaseRate;
15
+ use Fleetbase\FleetOps\Models\Vendor;
16
+ use Fleetbase\FleetOps\Models\VendorPersonnel;
17
+ use Fleetbase\FleetOps\Support\Utils;
9
18
  use Fleetbase\Http\Requests\ExportRequest;
10
19
  use Fleetbase\Http\Requests\ImportRequest;
11
20
  use Fleetbase\Models\User;
12
21
  use Illuminate\Http\Request;
22
+ use Illuminate\Support\Facades\DB;
23
+ use Illuminate\Support\Facades\Mail;
13
24
  use Illuminate\Support\Str;
14
25
  use Maatwebsite\Excel\Facades\Excel;
15
26
 
@@ -29,6 +40,7 @@ class ContactController extends FleetOpsController
29
40
  {
30
41
  $this->resolveUserInput($request, $input);
31
42
  $this->assertCustomerIdentityIsAvailable($input);
43
+ $this->assertCustomerPortalCanSendWelcomeEmail($input);
32
44
  }
33
45
 
34
46
  /**
@@ -51,6 +63,7 @@ class ContactController extends FleetOpsController
51
63
  {
52
64
  if ($contact->type === 'customer') {
53
65
  $contact->normalizeCustomerUser();
66
+ $this->sendCustomerPortalWelcomeEmail($contact);
54
67
  }
55
68
 
56
69
  $customFieldValues = $request->array('contact.custom_field_values');
@@ -95,6 +108,57 @@ class ContactController extends FleetOpsController
95
108
  ]);
96
109
  }
97
110
 
111
+ public function convertToVendor(Request $request, string $id)
112
+ {
113
+ $contact = Contact::where('company_uuid', session('company'))
114
+ ->where(function ($query) use ($id) {
115
+ $query->where('uuid', $id)->orWhere('public_id', $id)->orWhere('id', $id);
116
+ })
117
+ ->firstOrFail();
118
+
119
+ $vendor = DB::transaction(function () use ($contact, $request) {
120
+ $originalType = $contact->type;
121
+ $vendor = Vendor::create([
122
+ 'company_uuid' => $contact->company_uuid,
123
+ 'place_uuid' => $contact->place_uuid,
124
+ 'name' => $request->input('name', $contact->name),
125
+ 'email' => $request->input('email', $contact->email),
126
+ 'phone' => $request->input('phone', $contact->phone),
127
+ 'status' => 'active',
128
+ 'type' => 'customer',
129
+ 'meta' => [
130
+ 'converted_from_contact_uuid' => $contact->uuid,
131
+ 'converted_from_contact_type' => $originalType,
132
+ 'converted_by_uuid' => session('user'),
133
+ 'converted_at' => now()->toISOString(),
134
+ ],
135
+ ]);
136
+
137
+ VendorPersonnel::updateOrCreate(
138
+ ['vendor_uuid' => $vendor->uuid, 'contact_uuid' => $contact->uuid],
139
+ ['role' => 'admin', 'status' => 'active', 'invited_by_uuid' => session('user')]
140
+ );
141
+
142
+ $this->migrateContactCustomerContextToVendor($contact, $vendor);
143
+
144
+ $contact->update([
145
+ 'type' => 'customer',
146
+ 'meta' => array_merge((array) $contact->meta, [
147
+ 'converted_from_type' => $originalType,
148
+ 'converted_to_vendor_uuid' => $vendor->uuid,
149
+ 'converted_to_vendor_public_id' => $vendor->public_id,
150
+ 'converted_to_vendor_at' => now()->toISOString(),
151
+ ]),
152
+ ]);
153
+
154
+ return $vendor;
155
+ });
156
+
157
+ return response()->json([
158
+ 'vendor' => (new VendorResource($vendor->load('personnels')))->resolve(),
159
+ ]);
160
+ }
161
+
98
162
  /**
99
163
  * Export the contacts to excel or csv.
100
164
  *
@@ -133,6 +197,29 @@ class ContactController extends FleetOpsController
133
197
  return response()->json(['status' => 'ok', 'message' => 'Import completed', 'imported' => $importedCount]);
134
198
  }
135
199
 
200
+ private function migrateContactCustomerContextToVendor(Contact $contact, Vendor $vendor): void
201
+ {
202
+ $contactType = Utils::getMutationType($contact);
203
+ $vendorType = Utils::getMutationType($vendor);
204
+ $filter = ['customer_uuid' => $contact->uuid, 'customer_type' => $contactType];
205
+ $replacement = ['customer_uuid' => $vendor->uuid, 'customer_type' => $vendorType];
206
+
207
+ Order::where($filter)->update($replacement);
208
+ PurchaseRate::where($filter)->update($replacement);
209
+ Entity::where($filter)->update($replacement);
210
+
211
+ Issue::where('company_uuid', $contact->company_uuid)
212
+ ->where('meta->customer_portal->customer_uuid', $contact->uuid)
213
+ ->where('meta->customer_portal->customer_type', 'contact')
214
+ ->get()
215
+ ->each(function (Issue $issue) use ($vendor) {
216
+ $meta = (array) $issue->meta;
217
+ data_set($meta, 'customer_portal.customer_uuid', $vendor->uuid);
218
+ data_set($meta, 'customer_portal.customer_type', 'vendor');
219
+ $issue->update(['meta' => $meta]);
220
+ });
221
+ }
222
+
136
223
  private function resolveUserInput(Request $request, array &$input): void
137
224
  {
138
225
  $user = data_get($input, 'user_uuid') ?? data_get($input, 'user') ?? $request->input('contact.user_uuid') ?? $request->input('contact.user');
@@ -149,6 +236,54 @@ class ContactController extends FleetOpsController
149
236
  unset($input['user']);
150
237
  }
151
238
 
239
+ private function assertCustomerPortalCanSendWelcomeEmail(array $input): void
240
+ {
241
+ $type = data_get($input, 'type', 'contact');
242
+ if ($type !== 'customer' || !data_get($input, 'meta.customer_portal.send_welcome_email')) {
243
+ return;
244
+ }
245
+
246
+ if (!$this->isCustomerPortalInstalled()) {
247
+ throw new \Exception('Customer portal must be installed before sending a customer welcome email.');
248
+ }
249
+ }
250
+
251
+ private function sendCustomerPortalWelcomeEmail(Contact $contact): void
252
+ {
253
+ if (!data_get($contact->meta, 'customer_portal.send_welcome_email')) {
254
+ return;
255
+ }
256
+
257
+ if (!$this->isCustomerPortalInstalled()) {
258
+ throw new \Exception('Customer portal must be installed before sending a customer welcome email.');
259
+ }
260
+
261
+ $user = $contact->getUser() ?? Contact::createUserFromContact($contact, false, true);
262
+ if (!$user) {
263
+ throw new \Exception('Unable to create customer portal login.');
264
+ }
265
+
266
+ $password = Str::random(16);
267
+ $user->changePassword($password);
268
+
269
+ if ($user->status !== 'active') {
270
+ $user->activate();
271
+ }
272
+
273
+ Mail::to($user)->send(new CustomerCredentialsMail($password, $contact));
274
+
275
+ $meta = (array) $contact->meta;
276
+ data_forget($meta, 'customer_portal.send_welcome_email');
277
+ $contact->forceFill(['meta' => $meta])->saveQuietly();
278
+ $contact->setAttribute('meta', $meta);
279
+ }
280
+
281
+ private function isCustomerPortalInstalled(): bool
282
+ {
283
+ return collect(Utils::getInstalledFleetbaseExtensions())
284
+ ->contains(fn ($package) => data_get($package, 'name') === 'fleetbase/customer-portal-api');
285
+ }
286
+
152
287
  private function assertCustomerIdentityIsAvailable(array $input, ?Contact $contact = null): void
153
288
  {
154
289
  $type = data_get($input, 'type', $contact?->type);