@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
@@ -8,9 +8,78 @@ use Fleetbase\Http\Controllers\Controller;
8
8
  use Fleetbase\Models\User;
9
9
  use Illuminate\Http\Request;
10
10
  use Illuminate\Support\Facades\Mail;
11
+ use Illuminate\Support\Str;
11
12
 
12
13
  class CustomerController extends Controller
13
14
  {
15
+ public function createPortalLogin(Request $request)
16
+ {
17
+ $customer = $this->resolveCustomer($request);
18
+ $user = $this->resolveCustomerUser($customer);
19
+
20
+ if (!$user) {
21
+ return response()->error('Unable to create customer portal login.');
22
+ }
23
+
24
+ if ($user->status !== 'active') {
25
+ $user->activate();
26
+ }
27
+
28
+ return response()->json([
29
+ 'customer' => $this->customerPayload($customer->fresh()),
30
+ ]);
31
+ }
32
+
33
+ public function sendCredentials(Request $request)
34
+ {
35
+ $customer = $this->resolveCustomer($request);
36
+ $user = $this->resolveCustomerUser($customer);
37
+
38
+ if (!$user) {
39
+ return response()->error('Unable to send customer portal credentials.');
40
+ }
41
+
42
+ $password = Str::random(16);
43
+ $user->changePassword($password);
44
+ Mail::to($user)->send(new CustomerCredentialsMail($password, $customer));
45
+
46
+ return response()->json([
47
+ 'customer' => $this->customerPayload($customer->fresh()),
48
+ ]);
49
+ }
50
+
51
+ public function deactivatePortalLogin(Request $request)
52
+ {
53
+ $customer = $this->resolveCustomer($request);
54
+ $user = $customer->user_uuid ? User::where('uuid', $customer->user_uuid)->first() : null;
55
+
56
+ if (!$user) {
57
+ return response()->error('Customer portal login not found.');
58
+ }
59
+
60
+ $user->deactivate();
61
+
62
+ return response()->json([
63
+ 'customer' => $this->customerPayload($customer->fresh()),
64
+ ]);
65
+ }
66
+
67
+ public function reactivatePortalLogin(Request $request)
68
+ {
69
+ $customer = $this->resolveCustomer($request);
70
+ $user = $customer->user_uuid ? User::where('uuid', $customer->user_uuid)->first() : null;
71
+
72
+ if (!$user) {
73
+ return response()->error('Customer portal login not found.');
74
+ }
75
+
76
+ $user->activate();
77
+
78
+ return response()->json([
79
+ 'customer' => $this->customerPayload($customer->fresh()),
80
+ ]);
81
+ }
82
+
14
83
  /**
15
84
  * Resets the password for a specified customer and optionally sends the new credentials via email.
16
85
  *
@@ -72,13 +141,10 @@ class CustomerController extends Controller
72
141
  return response()->error('Passwords do not match.');
73
142
  }
74
143
 
75
- $customer = Contact::where(['uuid' => $customerId, 'company_uuid' => session('company'), 'type' => 'customer'])->first();
76
- if (!$customer) {
77
- return response()->error('Customer not found to change password for.');
78
- }
144
+ $customer = $this->resolveCustomer($request);
79
145
 
80
146
  // Load customer user
81
- $user = $customer->user_uuid ? User::where('uuid', $customer->user_uuid)->first() : $customer->createUser();
147
+ $user = $this->resolveCustomerUser($customer);
82
148
  if (!$user) {
83
149
  return response()->error('Unable to reset customer credentials');
84
150
  }
@@ -91,6 +157,50 @@ class CustomerController extends Controller
91
157
  Mail::to($user)->send(new CustomerCredentialsMail($password, $customer));
92
158
  }
93
159
 
94
- return response()->json(['status' => 'ok']);
160
+ return response()->json([
161
+ 'status' => 'ok',
162
+ 'customer' => $this->customerPayload($customer->fresh()),
163
+ ]);
164
+ }
165
+
166
+ protected function resolveCustomer(Request $request): Contact
167
+ {
168
+ $customerId = $request->input('customer');
169
+
170
+ abort_if(!$customerId, 400, 'No customer specified.');
171
+
172
+ return Contact::where('company_uuid', session('company'))
173
+ ->where(function ($query) use ($customerId) {
174
+ $query->where('uuid', $customerId)
175
+ ->orWhere('public_id', $customerId)
176
+ ->orWhere('id', $customerId);
177
+ })
178
+ ->firstOrFail();
179
+ }
180
+
181
+ protected function resolveCustomerUser(Contact $customer): ?User
182
+ {
183
+ return $customer->user_uuid ? User::where('uuid', $customer->user_uuid)->first() : Contact::createUserFromContact($customer, false, true);
184
+ }
185
+
186
+ protected function customerPayload(Contact $customer): array
187
+ {
188
+ $customer->loadMissing('user');
189
+
190
+ return [
191
+ 'id' => $customer->public_id,
192
+ 'uuid' => $customer->uuid,
193
+ 'user_uuid' => $customer->user_uuid,
194
+ 'user' => $customer->user ? [
195
+ 'id' => $customer->user->public_id,
196
+ 'uuid' => $customer->user->uuid,
197
+ 'name' => $customer->user->name,
198
+ 'email' => $customer->user->email,
199
+ 'phone' => $customer->user->phone,
200
+ 'status' => $customer->user->status,
201
+ 'session_status' => $customer->user->session_status,
202
+ 'avatar_url' => $customer->user->avatar_url,
203
+ ] : null,
204
+ ];
95
205
  }
96
206
  }
@@ -0,0 +1,17 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
+
5
+ use Fleetbase\FleetOps\Support\GettingStarted;
6
+ use Fleetbase\Http\Controllers\Controller;
7
+ use Illuminate\Http\Request;
8
+
9
+ class GettingStartedController extends Controller
10
+ {
11
+ public function status(Request $request)
12
+ {
13
+ return response()->json(
14
+ GettingStarted::forCompany($request->user()->company)->get()
15
+ );
16
+ }
17
+ }
@@ -6,9 +6,15 @@ use Fleetbase\FleetOps\Exports\IssueExport;
6
6
  use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
7
7
  use Fleetbase\FleetOps\Imports\IssueImport;
8
8
  use Fleetbase\FleetOps\Models\Issue;
9
+ use Fleetbase\FleetOps\Models\Order;
9
10
  use Fleetbase\Http\Requests\ExportRequest;
10
11
  use Fleetbase\Http\Requests\ImportRequest;
12
+ use Fleetbase\Models\Activity;
13
+ use Fleetbase\Models\Comment;
14
+ use Fleetbase\Models\File;
15
+ use Fleetbase\Models\User;
11
16
  use Illuminate\Http\Request;
17
+ use Illuminate\Support\Collection;
12
18
  use Illuminate\Support\Str;
13
19
  use Maatwebsite\Excel\Facades\Excel;
14
20
 
@@ -26,10 +32,255 @@ class IssueController extends FleetOpsController
26
32
  */
27
33
  public function afterSave(Request $request, Issue $issue)
28
34
  {
35
+ if ($issue->assigned_to_uuid) {
36
+ $assignee = User::where('uuid', $issue->assigned_to_uuid)->first();
37
+
38
+ if ($assignee && $assignee->type === 'customer') {
39
+ $issue->assigned_to_uuid = null;
40
+ $issue->saveQuietly();
41
+ }
42
+ }
43
+
29
44
  $customFieldValues = $request->array('issue.custom_field_values');
30
45
  if ($customFieldValues) {
31
46
  $issue->syncCustomFieldValues($customFieldValues);
32
47
  }
48
+
49
+ $uploads = $request->array('issue.files');
50
+ if ($uploads) {
51
+ File::whereIn('uuid', $uploads)->get()->each(function (File $file) use ($issue) {
52
+ $file->setKey($issue);
53
+ });
54
+ }
55
+
56
+ if (!$issue->order_uuid && data_get($issue, 'meta.order_uuid')) {
57
+ $order = Order::where('uuid', data_get($issue, 'meta.order_uuid'))->where('company_uuid', $issue->company_uuid)->first();
58
+ if ($order) {
59
+ $issue->order_uuid = $order->uuid;
60
+ $issue->saveQuietly();
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Return curated activity timeline entries for an issue.
67
+ *
68
+ * @return \Illuminate\Http\JsonResponse
69
+ */
70
+ public function timeline($id)
71
+ {
72
+ $issue = Issue::findById($id, ['reporter', 'assignee']);
73
+
74
+ if (!$issue || $issue->company_uuid !== session('company')) {
75
+ return response()->error('Issue not found for this organization.', 404);
76
+ }
77
+
78
+ $events = collect([$this->makeIssueOpenedEvent($issue)])
79
+ ->merge($this->issueActivityEvents($issue))
80
+ ->merge($this->commentEvents($issue))
81
+ ->merge($this->fileEvents($issue))
82
+ ->sortByDesc('created_at')
83
+ ->values();
84
+
85
+ return response()->json(['events' => $events]);
86
+ }
87
+
88
+ protected function issueActivityEvents(Issue $issue): Collection
89
+ {
90
+ return Activity::with(['causer'])
91
+ ->where('subject_type', Issue::class)
92
+ ->where('subject_id', $issue->uuid)
93
+ ->latest()
94
+ ->get()
95
+ ->flatMap(function (Activity $activity) use ($issue) {
96
+ if ($activity->event === 'created') {
97
+ return [];
98
+ }
99
+
100
+ $events = [];
101
+ foreach (['status', 'priority', 'assigned_to_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'type', 'category', 'tags', 'resolved_at'] as $field) {
102
+ $from = data_get($activity->properties, "old.{$field}");
103
+ $to = data_get($activity->properties, "attributes.{$field}");
104
+
105
+ if ($from === $to || $to === null) {
106
+ continue;
107
+ }
108
+
109
+ $events[] = $this->makeFieldChangedEvent($activity, $issue, $field, $from, $to);
110
+ }
111
+
112
+ return $events ?: [$this->makeGenericActivityEvent($activity, $issue)];
113
+ });
114
+ }
115
+
116
+ protected function commentEvents(Issue $issue): Collection
117
+ {
118
+ return Comment::with(['author'])
119
+ ->where('subject_type', Issue::class)
120
+ ->where('subject_uuid', $issue->uuid)
121
+ ->latest()
122
+ ->get()
123
+ ->map(function (Comment $comment) {
124
+ return [
125
+ 'id' => $comment->uuid,
126
+ 'type' => 'correspondence_added',
127
+ 'label' => 'Correspondence added',
128
+ 'description' => Str::limit(strip_tags($comment->content ?? ''), 120),
129
+ 'actor_name' => data_get($comment, 'author.name', 'Someone'),
130
+ 'actor_avatar_url' => data_get($comment, 'author.avatar_url'),
131
+ 'created_at' => $comment->created_at,
132
+ 'icon' => 'comment',
133
+ 'tone' => 'blue',
134
+ 'meta' => [
135
+ 'comment_id' => $comment->public_id ?? $comment->uuid,
136
+ ],
137
+ ];
138
+ });
139
+ }
140
+
141
+ protected function fileEvents(Issue $issue): Collection
142
+ {
143
+ $currentFiles = File::with(['uploader'])
144
+ ->where('subject_type', Issue::class)
145
+ ->where('subject_uuid', $issue->uuid)
146
+ ->latest()
147
+ ->get()
148
+ ->map(function (File $file) {
149
+ return [
150
+ 'id' => $file->uuid,
151
+ 'type' => 'document_uploaded',
152
+ 'label' => 'Document uploaded',
153
+ 'description' => $file->original_filename,
154
+ 'actor_name' => data_get($file, 'uploader.name', 'Someone'),
155
+ 'actor_avatar_url' => data_get($file, 'uploader.avatar_url'),
156
+ 'created_at' => $file->created_at,
157
+ 'icon' => 'file-arrow-up',
158
+ 'tone' => 'purple',
159
+ 'meta' => [
160
+ 'file_id' => $file->public_id ?? $file->uuid,
161
+ 'file_name' => $file->original_filename,
162
+ 'file_url' => $file->url,
163
+ ],
164
+ ];
165
+ });
166
+
167
+ $fileActivities = Activity::with(['causer'])
168
+ ->where('subject_type', File::class)
169
+ ->where(function ($query) use ($issue) {
170
+ $query->where('properties->attributes->subject_uuid', $issue->uuid)
171
+ ->orWhere('properties->old->subject_uuid', $issue->uuid);
172
+ })
173
+ ->latest()
174
+ ->get()
175
+ ->filter(fn (Activity $activity) => $activity->event === 'deleted')
176
+ ->map(function (Activity $activity) {
177
+ $fileName = data_get($activity->properties, 'old.original_filename')
178
+ ?? data_get($activity->properties, 'attributes.original_filename')
179
+ ?? 'Document';
180
+
181
+ return [
182
+ 'id' => $activity->uuid ?? $activity->id,
183
+ 'type' => 'document_removed',
184
+ 'label' => 'Document removed',
185
+ 'description' => $fileName,
186
+ 'actor_name' => data_get($activity, 'causer.name', 'Someone'),
187
+ 'actor_avatar_url' => data_get($activity, 'causer.avatar_url'),
188
+ 'created_at' => $activity->created_at,
189
+ 'icon' => 'file-circle-xmark',
190
+ 'tone' => 'red',
191
+ 'meta' => [
192
+ 'file_name' => $fileName,
193
+ ],
194
+ ];
195
+ });
196
+
197
+ return $currentFiles->merge($fileActivities);
198
+ }
199
+
200
+ protected function makeIssueOpenedEvent(Issue $issue): array
201
+ {
202
+ return [
203
+ 'id' => $issue->uuid . '-opened',
204
+ 'type' => 'issue_opened',
205
+ 'label' => 'Issue opened',
206
+ 'description' => 'Reported by ' . ($issue->reporter_name ?: 'Unknown reporter'),
207
+ 'actor_name' => $issue->reporter_name ?: 'Unknown reporter',
208
+ 'actor_avatar_url' => data_get($issue, 'reporter.avatar_url'),
209
+ 'created_at' => $issue->created_at,
210
+ 'icon' => 'circle-plus',
211
+ 'tone' => 'green',
212
+ 'meta' => [
213
+ 'issue_id' => $issue->public_id,
214
+ ],
215
+ ];
216
+ }
217
+
218
+ protected function makeFieldChangedEvent(Activity $activity, Issue $issue, string $field, $from, $to): array
219
+ {
220
+ $eventMap = [
221
+ 'status' => ['status_changed', 'Status changed', 'arrows-rotate', 'blue'],
222
+ 'priority' => ['priority_changed', 'Priority changed', 'flag', 'orange'],
223
+ 'assigned_to_uuid' => ['assignee_changed', 'Assignee changed', 'user-check', 'indigo'],
224
+ 'vehicle_uuid' => ['vehicle_changed', 'Vehicle linked', 'truck', 'slate'],
225
+ 'driver_uuid' => ['driver_changed', 'Driver linked', 'id-card', 'slate'],
226
+ 'order_uuid' => ['order_changed', 'Order linked', 'box', 'slate'],
227
+ 'resolved_at' => [$to ? 'issue_closed' : 'issue_reopened', $to ? 'Issue closed' : 'Issue re-opened', $to ? 'circle-check' : 'rotate-left', $to ? 'green' : 'orange'],
228
+ ];
229
+
230
+ [$type, $label, $icon, $tone] = $eventMap[$field] ?? ['issue_updated', Str::headline(str_replace('_uuid', '', $field)) . ' changed', 'pen', 'slate'];
231
+
232
+ if ($field === 'status' && in_array($to, ['closed', 'resolved', 'completed'])) {
233
+ [$type, $label, $icon, $tone] = ['issue_closed', 'Issue closed', 'circle-check', 'green'];
234
+ }
235
+
236
+ if ($field === 'status' && $to === 're_opened') {
237
+ [$type, $label, $icon, $tone] = ['issue_reopened', 'Issue re-opened', 'rotate-left', 'orange'];
238
+ }
239
+
240
+ return [
241
+ 'id' => ($activity->uuid ?? $activity->id) . '-' . $field,
242
+ 'type' => $type,
243
+ 'label' => $label,
244
+ 'description' => $this->formatChangeDescription($field, $from, $to),
245
+ 'actor_name' => data_get($activity, 'causer.name', 'Someone'),
246
+ 'actor_avatar_url' => data_get($activity, 'causer.avatar_url'),
247
+ 'created_at' => $activity->created_at,
248
+ 'icon' => $icon,
249
+ 'tone' => $tone,
250
+ 'meta' => [
251
+ 'field' => $field,
252
+ 'from' => $from,
253
+ 'to' => $to,
254
+ 'issue_id' => $issue->public_id,
255
+ ],
256
+ ];
257
+ }
258
+
259
+ protected function makeGenericActivityEvent(Activity $activity, Issue $issue): array
260
+ {
261
+ return [
262
+ 'id' => $activity->uuid ?? $activity->id,
263
+ 'type' => 'issue_updated',
264
+ 'label' => 'Issue updated',
265
+ 'description' => $activity->description,
266
+ 'actor_name' => data_get($activity, 'causer.name', 'Someone'),
267
+ 'actor_avatar_url' => data_get($activity, 'causer.avatar_url'),
268
+ 'created_at' => $activity->created_at,
269
+ 'icon' => 'pen',
270
+ 'tone' => 'slate',
271
+ 'meta' => [
272
+ 'issue_id' => $issue->public_id,
273
+ ],
274
+ ];
275
+ }
276
+
277
+ protected function formatChangeDescription(string $field, $from, $to): string
278
+ {
279
+ $fieldLabel = Str::headline(str_replace('_uuid', '', $field));
280
+ $fromLabel = blank($from) ? 'none' : Str::headline((string) $from);
281
+ $toLabel = blank($to) ? 'none' : Str::headline((string) $to);
282
+
283
+ return "{$fieldLabel} changed from {$fromLabel} to {$toLabel}.";
33
284
  }
34
285
 
35
286
  /**