@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
@@ -47,7 +47,6 @@ use Illuminate\Support\Facades\DB;
47
47
  use Illuminate\Support\Facades\Storage;
48
48
  use Illuminate\Support\Facades\Validator;
49
49
  use Illuminate\Support\Str;
50
- use Illuminate\Validation\ValidationException;
51
50
  use Maatwebsite\Excel\Facades\Excel;
52
51
 
53
52
  class OrderController extends FleetOpsController
@@ -676,28 +675,9 @@ class OrderController extends FleetOpsController
676
675
  // get the next order activity
677
676
  $flow = $activity = $order->config()->nextFirstActivity();
678
677
 
679
- /**
680
- * @var \Fleetbase\LaravelMysqlSpatial\Types\Point
681
- */
682
- $location = $order->getLastLocation();
683
-
684
- // if multi drop order set first destination
685
- if ($payload->isMultipleDropOrder) {
686
- $firstDestination = $payload->waypoints->first();
687
-
688
- if ($firstDestination) {
689
- $payload->current_waypoint_uuid = $firstDestination->uuid;
690
- $payload->save();
691
- }
692
-
693
- // update activity for each waypoint and entity
694
- foreach ($payload->waypointMarkers as $waypointMarker) {
695
- $waypointMarker->insertActivity($activity, $location);
696
- }
697
-
698
- foreach ($payload->entities as $entity) {
699
- $entity->insertActivity($activity, $location);
700
- }
678
+ // if multi-stop route set first service stop destination
679
+ if ($this->payloadUsesServiceStopActivity($payload)) {
680
+ $this->ensurePayloadCurrentServiceStop($payload);
701
681
  }
702
682
 
703
683
  // update order activity
@@ -729,9 +709,16 @@ class OrderController extends FleetOpsController
729
709
  return response()->error('No order found.');
730
710
  }
731
711
 
732
- $proof = $request->input('proof');
733
- $activity = $request->array('activity');
734
- $activity = new Activity($activity, $order->getConfigFlow());
712
+ $proof = $request->input('proof');
713
+ $bypassProof = $request->boolean('bypass_proof');
714
+ $activity = $request->array('activity');
715
+ $activity = new Activity($activity, $order->getConfigFlow());
716
+
717
+ $requiresProof = Utils::isActivity($activity)
718
+ && ($activity->get('require_pod') || ($activity->completesOrder() && $order->pod_required));
719
+ if ($requiresProof && !$proof && !$bypassProof) {
720
+ return response()->error('Proof of delivery is required for this activity or must be explicitly bypassed.', 422);
721
+ }
735
722
 
736
723
  // Handle pickup/dropoff order activity update as normal
737
724
  if (Utils::isActivity($activity) && $activity->is('dispatched')) {
@@ -751,15 +738,20 @@ class OrderController extends FleetOpsController
751
738
  * @var \Fleetbase\LaravelMysqlSpatial\Types\Point
752
739
  */
753
740
  $location = $order->getLastLocation();
754
- $isMultipleWaypointOrder = $this->payloadHasWaypoints($order->payload);
741
+ $usesServiceStopActivity = $this->payloadUsesServiceStopActivity($order->payload);
742
+ $isLifecycleActivity = Utils::isActivity($activity) && in_array($activity->code, ['created', 'dispatched', 'started'], true);
755
743
 
756
- if (!$isMultipleWaypointOrder) {
744
+ if (!$usesServiceStopActivity || $isLifecycleActivity) {
757
745
  $order->updateActivity($activity, $proof);
758
746
 
759
- // also update for each order entities if not multiple drop order
760
- // all entities will share the same activity status as is one drop order
761
- foreach ($order->payload->entities as $entity) {
762
- $entity->insertActivity($activity, $location, $proof);
747
+ if (!$usesServiceStopActivity) {
748
+ foreach ($order->payload->entities as $entity) {
749
+ $entity->insertActivity($activity, $location, $proof);
750
+ }
751
+ }
752
+
753
+ if ($usesServiceStopActivity && $activity->is('started')) {
754
+ $this->ensurePayloadCurrentServiceStop($order->payload);
763
755
  }
764
756
 
765
757
  // Handle order completed
@@ -772,23 +764,6 @@ class OrderController extends FleetOpsController
772
764
  return new OrderResource($order->refresh());
773
765
  }
774
766
 
775
- if ($activity->is('dispatched')) {
776
- $order->updateActivity($activity, $proof);
777
-
778
- return new OrderResource($order->refresh());
779
- }
780
-
781
- if ($activity->is('started')) {
782
- $order->updateActivity($activity, $proof);
783
-
784
- $this->ensurePayloadCurrentServiceStop($order->payload);
785
- if (!$this->payloadHasCurrentServiceStopActivity($order->payload, $activity)) {
786
- $this->updateCurrentServiceStopActivity($order, $activity, $location, $proof);
787
- }
788
-
789
- return new OrderResource($order->refresh());
790
- }
791
-
792
767
  if (!$order->started && in_array($order->status, ['created', 'dispatched'])) {
793
768
  return response()->error('Order must be started before waypoint activity can be updated.', 422);
794
769
  }
@@ -836,18 +811,21 @@ class OrderController extends FleetOpsController
836
811
  return response()->error('No order found.');
837
812
  }
838
813
 
839
- $canUpdateWaypointActivity = $order->started || $order->started_at || !in_array($order->status, ['created', 'dispatched']);
814
+ $order->loadMissing([
815
+ 'payload.pickup',
816
+ 'payload.dropoff',
817
+ 'payload.waypoints',
818
+ 'payload.waypointMarkers.place',
819
+ 'payload.waypointMarkers.trackingNumber.status',
820
+ ]);
821
+
822
+ $canUpdateWaypointActivity = $this->payloadUsesServiceStopActivity($order->payload)
823
+ && ($order->started || $order->started_at || !in_array($order->status, ['created', 'dispatched']));
840
824
  $stop = null;
841
- if ($request->filled('waypoint') && $canUpdateWaypointActivity) {
842
- $order->loadMissing([
843
- 'payload.pickup',
844
- 'payload.dropoff',
845
- 'payload.return',
846
- 'payload.waypoints',
847
- 'payload.waypointMarkers.place',
848
- 'payload.waypointMarkers.trackingNumber.status',
849
- ]);
850
- $stop = $this->resolveServiceStopFromKey($order->payload, $request->input('waypoint'));
825
+ if ($canUpdateWaypointActivity) {
826
+ $stop = $request->filled('waypoint')
827
+ ? $this->resolveServiceStopFromKey($order->payload, $request->input('waypoint'))
828
+ : $this->payloadCurrentServiceStop($order->payload);
851
829
  }
852
830
  $orderConfig = $order->ensureOrderConfig();
853
831
  if (!$orderConfig) {
@@ -896,7 +874,7 @@ class OrderController extends FleetOpsController
896
874
  return response()->error('No order found.');
897
875
  }
898
876
 
899
- if (!$this->payloadHasWaypoints($order->payload)) {
877
+ if (!$this->payloadUsesServiceStopActivity($order->payload)) {
900
878
  return response()->error('Destination can only be changed for multi-waypoint orders.', 422);
901
879
  }
902
880
 
@@ -917,35 +895,43 @@ class OrderController extends FleetOpsController
917
895
  */
918
896
  public function capturePhoto(Request $request, string $id, ?string $subjectId = null)
919
897
  {
920
- try {
921
- $request->validate([
922
- 'photos' => 'required|array|min:1',
923
- 'photos.*' => [
924
- function ($attribute, $value, $fail) {
925
- if ($value instanceof UploadedFile) {
926
- if (
927
- !$value->isValid()
928
- || !in_array($value->extension(), ['jpg', 'jpeg', 'png', 'gif', 'webp'])
929
- || $value->getSize() > 10 * 1024 * 1024
930
- ) {
931
- $fail("{$attribute} must be a valid image file <= 10 MB.");
932
- }
933
-
934
- return;
898
+ $incoming = $this->collectProofPhotoInputs($request);
899
+ $metadata = $request->input('data', []);
900
+ $metadata = is_array($metadata) ? $metadata : [];
901
+ $validator = Validator::make([
902
+ 'photos' => $incoming,
903
+ 'remarks' => $request->input('remarks'),
904
+ 'data' => $metadata,
905
+ ], [
906
+ 'photos' => 'required|array|min:1',
907
+ 'photos.*' => [
908
+ function ($attribute, $value, $fail) {
909
+ if ($value instanceof UploadedFile) {
910
+ $extension = strtolower($value->getClientOriginalExtension() ?: $value->extension());
911
+ if (
912
+ !$value->isValid()
913
+ || !in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp'])
914
+ || $value->getSize() > 10 * 1024 * 1024
915
+ ) {
916
+ $fail("{$attribute} must be a valid image file <= 10 MB.");
935
917
  }
936
918
 
937
- if (is_string($value) && base64_decode($value, true) !== false) {
938
- return;
939
- }
919
+ return;
920
+ }
940
921
 
941
- $fail("{$attribute} must be an image file or a valid Base64 string.");
942
- },
943
- ],
944
- 'remarks' => 'sometimes|string|max:255',
945
- 'data' => 'sometimes|array',
946
- ]);
947
- } catch (ValidationException $e) {
948
- $errorMessage = collect($e->errors())->flatten()->first();
922
+ if ($this->isValidBase64ProofPhoto($value)) {
923
+ return;
924
+ }
925
+
926
+ $fail("{$attribute} must be an image file or a valid Base64 string.");
927
+ },
928
+ ],
929
+ 'remarks' => 'sometimes|string|max:255',
930
+ 'data' => 'sometimes|array',
931
+ ]);
932
+
933
+ if ($validator->fails()) {
934
+ $errorMessage = collect($validator->errors()->all())->first();
949
935
 
950
936
  return response()->error($errorMessage, 422);
951
937
  }
@@ -960,15 +946,10 @@ class OrderController extends FleetOpsController
960
946
  return response()->error('Unable to capture photo as proof.', 422);
961
947
  }
962
948
 
963
- $disk = $request->input('disk', config('filesystems.default'));
964
- $bucket = $request->input("filesystems.disks.{$disk}.bucket", config('filesystems.disks.s3.bucket'));
965
- $rawInputs = $request->file('photos', []);
966
- $base64Inputs = array_filter($request->input('photos', []), function ($value) {
967
- return is_string($value) && base64_decode($value, true) !== false;
968
- });
969
- $incoming = array_merge($rawInputs, $base64Inputs);
970
- $remarks = $request->input('remarks', 'Verified by Photo');
971
- $data = $request->input('data', []);
949
+ $disk = $request->input('disk', config('filesystems.default'));
950
+ $bucket = $request->input("filesystems.disks.{$disk}.bucket", config('filesystems.disks.s3.bucket'));
951
+ $remarks = $request->input('remarks', 'Verified by Photo');
952
+ $data = $metadata;
972
953
 
973
954
  if (empty($incoming)) {
974
955
  return response()->error('No photo data to capture.', 422);
@@ -992,13 +973,107 @@ class OrderController extends FleetOpsController
992
973
  return new ProofResource($proof);
993
974
  }
994
975
 
976
+ /**
977
+ * Normalize console proof upload payloads into one photo list before validation.
978
+ */
979
+ protected function collectProofPhotoInputs(Request $request): array
980
+ {
981
+ $incoming = [];
982
+ foreach (['photos', 'photo', 'files', 'file'] as $key) {
983
+ $this->appendProofPhotoInputs($incoming, $request->file($key));
984
+ $this->appendProofPhotoInputs($incoming, $request->input($key));
985
+ }
986
+
987
+ $incoming = array_values(array_filter($incoming, function ($value) {
988
+ return $value instanceof UploadedFile || is_string($value);
989
+ }));
990
+
991
+ return $this->dedupeProofPhotoInputs($incoming);
992
+ }
993
+
994
+ /**
995
+ * Append a nested file/input value to the proof input list.
996
+ */
997
+ protected function appendProofPhotoInputs(array &$incoming, mixed $value): void
998
+ {
999
+ if ($value === null) {
1000
+ return;
1001
+ }
1002
+
1003
+ if (is_array($value)) {
1004
+ foreach ($value as $nested) {
1005
+ $this->appendProofPhotoInputs($incoming, $nested);
1006
+ }
1007
+
1008
+ return;
1009
+ }
1010
+
1011
+ $incoming[] = $value;
1012
+ }
1013
+
1014
+ /**
1015
+ * Remove duplicate photo inputs exposed under multiple multipart aliases.
1016
+ */
1017
+ protected function dedupeProofPhotoInputs(array $incoming): array
1018
+ {
1019
+ $seen = [];
1020
+
1021
+ return array_values(array_filter($incoming, function ($value) use (&$seen) {
1022
+ $fingerprint = $this->proofPhotoInputFingerprint($value);
1023
+ if ($fingerprint === null || isset($seen[$fingerprint])) {
1024
+ return false;
1025
+ }
1026
+
1027
+ $seen[$fingerprint] = true;
1028
+
1029
+ return true;
1030
+ }));
1031
+ }
1032
+
1033
+ /**
1034
+ * Build a stable fingerprint for an uploaded file or Base64 proof payload.
1035
+ */
1036
+ protected function proofPhotoInputFingerprint(mixed $value): ?string
1037
+ {
1038
+ if ($value instanceof UploadedFile) {
1039
+ return hash('sha256', implode('|', [
1040
+ $value->getRealPath(),
1041
+ $value->getSize(),
1042
+ $value->getClientOriginalName(),
1043
+ $value->getClientMimeType(),
1044
+ ]));
1045
+ }
1046
+
1047
+ if (is_string($value)) {
1048
+ $payload = Str::contains($value, 'base64,') ? Str::after($value, 'base64,') : $value;
1049
+
1050
+ return hash('sha256', $payload);
1051
+ }
1052
+
1053
+ return null;
1054
+ }
1055
+
1056
+ /**
1057
+ * Determine whether a proof input is a usable Base64 image payload.
1058
+ */
1059
+ protected function isValidBase64ProofPhoto(mixed $value): bool
1060
+ {
1061
+ if (!is_string($value)) {
1062
+ return false;
1063
+ }
1064
+
1065
+ $payload = Str::contains($value, 'base64,') ? Str::after($value, 'base64,') : $value;
1066
+
1067
+ return base64_decode($payload, true) !== false;
1068
+ }
1069
+
995
1070
  /**
996
1071
  * Store a proof photo and create its file record.
997
1072
  */
998
1073
  protected function storeProofPhoto(Proof $proof, UploadedFile|string $photo, string $disk, string $bucket): File
999
1074
  {
1000
1075
  $isFile = $photo instanceof UploadedFile;
1001
- $contents = $isFile ? file_get_contents($photo->getRealPath()) : base64_decode($photo);
1076
+ $contents = $isFile ? file_get_contents($photo->getRealPath()) : base64_decode(Str::contains($photo, 'base64,') ? Str::after($photo, 'base64,') : $photo);
1002
1077
  $extension = $isFile ? $photo->getClientOriginalExtension() : 'png';
1003
1078
  $contentType = $isFile ? $photo->getClientMimeType() : 'image/png';
1004
1079
  $company = session('company');
@@ -7,10 +7,9 @@ use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
7
7
  use Fleetbase\FleetOps\Http\Resources\v1\Place as PlaceResource;
8
8
  use Fleetbase\FleetOps\Imports\PlaceImport;
9
9
  use Fleetbase\FleetOps\Models\Place;
10
- use Fleetbase\FleetOps\Support\Geocoding;
10
+ use Fleetbase\FleetOps\Support\PlaceSearch;
11
11
  use Fleetbase\Http\Requests\ExportRequest;
12
12
  use Fleetbase\Http\Requests\ImportRequest;
13
- use Fleetbase\LaravelMysqlSpatial\Types\Point;
14
13
  use Illuminate\Http\Request;
15
14
  use Illuminate\Support\Str;
16
15
  use Maatwebsite\Excel\Facades\Excel;
@@ -50,50 +49,15 @@ class PlaceController extends FleetOpsController
50
49
 
51
50
  $query = Place::where('company_uuid', session('company'))
52
51
  ->whereNull('deleted_at')
53
- ->applyDirectivesForPermissions('fleet-ops list place')
54
- ->search($searchQuery);
55
-
56
- if ($latitude && $longitude) {
57
- $point = new Point($latitude, $longitude);
58
- $query->whereNotNull('location')->whereRaw('
59
- ST_Y(location) BETWEEN -90 AND 90
60
- AND ST_X(location) BETWEEN -180 AND 180
61
- AND NOT (ST_X(location) = 0 AND ST_Y(location) = 0)
62
- ');
63
- $query->orderByDistanceSphere('location', $point, 'asc');
64
- } else {
65
- $query->orderBy('name', 'desc');
66
- }
67
-
68
- if ($limit) {
69
- $query->limit($limit);
70
- }
52
+ ->applyDirectivesForPermissions('fleet-ops list place');
71
53
 
72
- $results = $query->get();
73
-
74
- if ($geo && Geocoding::canGoogleGeocode()) {
75
- if ($searchQuery) {
76
- try {
77
- $geocodingResults = Geocoding::query($searchQuery, $latitude, $longitude);
78
-
79
- foreach ($geocodingResults as $result) {
80
- $results->prepend($result);
81
- }
82
- } catch (\Throwable $e) {
83
- return response()->error($e->getMessage());
84
- }
85
- } elseif ($latitude && $longitude) {
86
- try {
87
- $geocodingResults = Geocoding::reverseFromCoordinates($latitude, $longitude, $searchQuery);
88
-
89
- foreach ($geocodingResults as $result) {
90
- $results->prepend($result);
91
- }
92
- } catch (\Throwable $e) {
93
- return response()->error($e->getMessage());
94
- }
95
- }
96
- }
54
+ $results = PlaceSearch::search($query, $searchQuery, [
55
+ 'limit' => $limit,
56
+ 'geo' => $geo,
57
+ 'latitude' => $latitude,
58
+ 'longitude' => $longitude,
59
+ 'no_query_order' => 'name_desc',
60
+ ]);
97
61
 
98
62
  return PlaceResource::collection($results);
99
63
  }
@@ -108,29 +72,7 @@ class PlaceController extends FleetOpsController
108
72
  $searchQuery = $request->searchQuery();
109
73
  $latitude = $request->input('latitude', false);
110
74
  $longitude = $request->input('longitude', false);
111
- $results = collect();
112
-
113
- if ($searchQuery && Geocoding::canGoogleGeocode()) {
114
- try {
115
- $geocodingResults = Geocoding::query($searchQuery, $latitude, $longitude);
116
-
117
- foreach ($geocodingResults as $result) {
118
- $results->push($result);
119
- }
120
- } catch (\Throwable $e) {
121
- return response()->error($e->getMessage());
122
- }
123
- } elseif ($latitude && $longitude) {
124
- try {
125
- $geocodingResults = Geocoding::reverseFromCoordinates($latitude, $longitude, $searchQuery);
126
-
127
- foreach ($geocodingResults as $result) {
128
- $results->push($result);
129
- }
130
- } catch (\Throwable $e) {
131
- return response()->error($e->getMessage());
132
- }
133
- }
75
+ $results = PlaceSearch::geocode($searchQuery, $latitude, $longitude);
134
76
 
135
77
  return response()->json($results)->withHeaders(['Cache-Control' => 'no-cache']);
136
78
  }
@@ -4,9 +4,12 @@ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
4
 
5
5
  use Fleetbase\FleetOps\Exports\VendorExport;
6
6
  use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
7
+ use Fleetbase\FleetOps\Http\Resources\v1\Contact as ContactResource;
7
8
  use Fleetbase\FleetOps\Imports\VendorImport;
9
+ use Fleetbase\FleetOps\Models\Contact;
8
10
  use Fleetbase\FleetOps\Models\Driver;
9
11
  use Fleetbase\FleetOps\Models\Vendor;
12
+ use Fleetbase\FleetOps\Models\VendorPersonnel;
10
13
  use Fleetbase\Http\Requests\ExportRequest;
11
14
  use Fleetbase\Http\Requests\ImportRequest;
12
15
  use Illuminate\Http\Request;
@@ -190,4 +193,90 @@ class VendorController extends FleetOpsController
190
193
  'status' => 'ok',
191
194
  ]);
192
195
  }
196
+
197
+ public function vendorPersonnels(string $vendorId)
198
+ {
199
+ $vendor = Vendor::findByIdOrFail($vendorId);
200
+
201
+ $personnels = VendorPersonnel::where('vendor_uuid', $vendor->uuid)
202
+ ->with('contact')
203
+ ->latest()
204
+ ->get()
205
+ ->map(fn (VendorPersonnel $personnel) => $this->vendorPersonnelPayload($personnel));
206
+
207
+ return response()->json(['personnels' => $personnels->values()]);
208
+ }
209
+
210
+ public function addVendorPersonnel(Request $request, string $vendorId)
211
+ {
212
+ $vendor = Vendor::findByIdOrFail($vendorId);
213
+ $contact = $this->resolveOrCreatePersonnelContact($request);
214
+
215
+ $personnel = VendorPersonnel::updateOrCreate(
216
+ ['vendor_uuid' => $vendor->uuid, 'contact_uuid' => $contact->uuid],
217
+ [
218
+ 'role' => $request->input('role', 'member'),
219
+ 'status' => $request->input('status', 'active'),
220
+ 'invited_by_uuid' => session('user'),
221
+ ]
222
+ );
223
+
224
+ if ($request->boolean('create_login', true)) {
225
+ $contact->createUser();
226
+ }
227
+
228
+ return response()->json([
229
+ 'personnel' => $this->vendorPersonnelPayload($personnel->load('contact')),
230
+ ]);
231
+ }
232
+
233
+ public function removeVendorPersonnel(string $vendorId, string $contactId)
234
+ {
235
+ $vendor = Vendor::findByIdOrFail($vendorId);
236
+ $contact = Contact::findByIdOrFail($contactId);
237
+
238
+ VendorPersonnel::where(['vendor_uuid' => $vendor->uuid, 'contact_uuid' => $contact->uuid])->delete();
239
+
240
+ return response()->json(['status' => 'ok']);
241
+ }
242
+
243
+ protected function resolveOrCreatePersonnelContact(Request $request): Contact
244
+ {
245
+ $contactId = $request->input('contact');
246
+ if ($contactId) {
247
+ return Contact::where('company_uuid', session('company'))
248
+ ->where(function ($query) use ($contactId) {
249
+ $query->where('uuid', $contactId)->orWhere('public_id', $contactId);
250
+ })
251
+ ->firstOrFail();
252
+ }
253
+
254
+ return Contact::create([
255
+ 'company_uuid' => session('company'),
256
+ 'name' => $request->input('name'),
257
+ 'email' => $request->input('email'),
258
+ 'phone' => $request->input('phone'),
259
+ 'type' => 'customer',
260
+ ]);
261
+ }
262
+
263
+ protected function vendorPersonnelPayload(VendorPersonnel $personnel): array
264
+ {
265
+ $contact = $personnel->contact;
266
+
267
+ return [
268
+ 'id' => $contact?->public_id,
269
+ 'uuid' => $contact?->uuid,
270
+ 'contact_uuid' => $contact?->uuid,
271
+ 'public_id' => $contact?->public_id,
272
+ 'name' => $contact?->name,
273
+ 'email' => $contact?->email,
274
+ 'phone' => $contact?->phone,
275
+ 'photo_url' => $contact?->photo_url,
276
+ 'role' => $personnel->role ?? 'member',
277
+ 'status' => $personnel->status ?? 'active',
278
+ 'invited_by_uuid' => $personnel->invited_by_uuid,
279
+ 'contact' => $contact ? (new ContactResource($contact))->resolve() : null,
280
+ ];
281
+ }
193
282
  }
@@ -26,10 +26,14 @@ class CreateIssueRequest extends FleetbaseRequest
26
26
  return [
27
27
  'driver' => ['required'],
28
28
  'location' => ['required'],
29
+ 'order' => ['nullable', 'exists:orders,public_id'],
30
+ 'order_uuid' => ['nullable', 'exists:orders,uuid'],
29
31
  'report' => ['required'],
30
32
  'category' => ['nullable'],
31
33
  'type' => ['nullable'],
32
34
  'priority' => ['nullable'],
35
+ 'tags' => ['nullable', 'array'],
36
+ 'tags.*' => ['string'],
33
37
  ];
34
38
  }
35
39
  }
@@ -13,9 +13,13 @@ class UpdateIssueRequest extends CreateIssueRequest
13
13
  {
14
14
  return [
15
15
  'report' => ['required'],
16
+ 'order' => ['nullable', 'exists:orders,public_id'],
17
+ 'order_uuid' => ['nullable', 'exists:orders,uuid'],
16
18
  'category' => ['nullable'],
17
19
  'type' => ['nullable'],
18
20
  'priority' => ['nullable'],
21
+ 'tags' => ['nullable', 'array'],
22
+ 'tags.*' => ['string'],
19
23
  ];
20
24
  }
21
25
  }
@@ -44,9 +44,10 @@ class Order extends FleetbaseResource
44
44
  $isInternal,
45
45
  $this->whenLoaded('orderConfig', function () {
46
46
  return [
47
- 'id' => $this->orderConfig->public_id,
48
- 'name' => $this->orderConfig->name,
49
- 'key' => $this->orderConfig->key,
47
+ 'uuid' => $this->orderConfig->uuid,
48
+ 'public_id' => $this->orderConfig->public_id,
49
+ 'name' => $this->orderConfig->name,
50
+ 'key' => $this->orderConfig->key,
50
51
  ];
51
52
  })
52
53
  ),
@@ -104,12 +105,14 @@ class Order extends FleetbaseResource
104
105
  }),
105
106
 
106
107
  // Essential scalar fields
107
- 'type' => $this->type,
108
- 'status' => $this->status,
109
- 'adhoc' => (bool) data_get($this, 'adhoc', false),
110
- 'dispatched' => (bool) data_get($this, 'dispatched', false),
111
- 'has_driver_assigned' => $this->when($isInternal, $this->has_driver_assigned),
112
- 'is_scheduled' => $this->when($isInternal, $this->is_scheduled),
108
+ 'type' => $this->type,
109
+ 'status' => $this->status,
110
+ 'adhoc' => (bool) data_get($this, 'adhoc', false),
111
+ 'dispatched' => (bool) data_get($this, 'dispatched', false),
112
+ 'has_driver_assigned' => $this->when($isInternal, $this->has_driver_assigned),
113
+ 'is_scheduled' => $this->when($isInternal, $this->is_scheduled),
114
+ 'transaction_amount' => $this->transaction_amount,
115
+ 'currency' => $this->transaction_currency,
113
116
 
114
117
  // Timestamps
115
118
  'scheduled_at' => $this->scheduled_at,