@fleetbase/fleetops-engine 0.6.48 → 0.6.50

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 (266) hide show
  1. package/addon/components/customer/create-order-form.hbs +2 -2
  2. package/addon/components/customer/form.hbs +2 -2
  3. package/addon/components/customer/order-form.hbs +2 -2
  4. package/addon/components/customer/orders.hbs +2 -2
  5. package/addon/components/display-place.hbs +2 -2
  6. package/addon/components/driver/details.hbs +5 -1
  7. package/addon/components/driver/form.hbs +11 -1
  8. package/addon/components/fleet-listing-panel.hbs +2 -2
  9. package/addon/components/fleet-listing-panel.js +10 -0
  10. package/addon/components/map/google-live-map.js +18 -75
  11. package/addon/components/map/leaflet-live-map.hbs +187 -37
  12. package/addon/components/map/leaflet-live-map.js +184 -20
  13. package/addon/components/modals/order-change-destination.hbs +46 -0
  14. package/addon/components/modals/order-change-destination.js +106 -0
  15. package/addon/components/modals/stop-activity.hbs +28 -0
  16. package/addon/components/modals/stop-activity.js +52 -0
  17. package/addon/components/modals/update-order-activity.hbs +45 -3
  18. package/addon/components/modals/update-order-activity.js +143 -5
  19. package/addon/components/orchestrator/phase-builder.hbs +21 -0
  20. package/addon/components/orchestrator/phase-builder.js +13 -0
  21. package/addon/components/orchestrator/resource-panel.hbs +0 -10
  22. package/addon/components/orchestrator/resource-panel.js +2 -4
  23. package/addon/components/orchestrator-workbench.js +1 -0
  24. package/addon/components/order/activity-list.hbs +2 -2
  25. package/addon/components/order/activity-timeline.hbs +31 -29
  26. package/addon/components/order/details/activity.hbs +3 -3
  27. package/addon/components/order/details/activity.js +11 -0
  28. package/addon/components/order/details/documents.js +2 -0
  29. package/addon/components/order/details/purchase-rate.hbs +2 -2
  30. package/addon/components/order/details/route.hbs +11 -3
  31. package/addon/components/order/details/route.js +91 -14
  32. package/addon/components/order/details/tracking.hbs +101 -72
  33. package/addon/components/order/details/tracking.js +149 -2
  34. package/addon/components/order/form/details.hbs +3 -3
  35. package/addon/components/order/form/documents.js +11 -4
  36. package/addon/components/order/form/route.hbs +108 -104
  37. package/addon/components/order/form/route.js +37 -1
  38. package/addon/components/order/form/service-rate.hbs +2 -2
  39. package/addon/components/order/form.hbs +2 -2
  40. package/addon/components/order-config-manager/activity-flow.hbs +4 -1
  41. package/addon/components/order-config-manager/activity-flow.js +178 -12
  42. package/addon/components/place/address.js +1 -1
  43. package/addon/components/route-list.hbs +17 -17
  44. package/addon/components/route-list.js +42 -10
  45. package/addon/components/service-rate/details.hbs +41 -0
  46. package/addon/components/service-rate/form.hbs +118 -0
  47. package/addon/components/service-rate/form.js +55 -0
  48. package/addon/components/vehicle/form.js +1 -1
  49. package/addon/components/vehicle/pill.hbs +1 -1
  50. package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
  51. package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
  52. package/addon/components/widget/fuel-efficiency.hbs +26 -0
  53. package/addon/components/widget/fuel-efficiency.js +43 -0
  54. package/addon/components/widget/geofence-violations.hbs +44 -0
  55. package/addon/components/widget/geofence-violations.js +55 -0
  56. package/addon/components/widget/issues-insights.hbs +43 -0
  57. package/addon/components/widget/issues-insights.js +62 -0
  58. package/addon/components/widget/kpi-active-orders.hbs +10 -0
  59. package/addon/components/widget/kpi-active-orders.js +5 -0
  60. package/addon/components/widget/kpi-aov.hbs +10 -0
  61. package/addon/components/widget/kpi-aov.js +5 -0
  62. package/addon/components/widget/kpi-distance.hbs +10 -0
  63. package/addon/components/widget/kpi-distance.js +5 -0
  64. package/addon/components/widget/kpi-drivers-online.hbs +10 -0
  65. package/addon/components/widget/kpi-drivers-online.js +5 -0
  66. package/addon/components/widget/kpi-earnings.hbs +10 -0
  67. package/addon/components/widget/kpi-earnings.js +5 -0
  68. package/addon/components/widget/kpi-open-issues.hbs +11 -0
  69. package/addon/components/widget/kpi-open-issues.js +5 -0
  70. package/addon/components/widget/kpi-tile.hbs +59 -0
  71. package/addon/components/widget/kpi-tile.js +168 -0
  72. package/addon/components/widget/live-fleet.hbs +122 -0
  73. package/addon/components/widget/live-fleet.js +365 -0
  74. package/addon/components/widget/maintenance-overview.hbs +45 -0
  75. package/addon/components/widget/maintenance-overview.js +40 -0
  76. package/addon/components/widget/on-time-delivery.hbs +27 -0
  77. package/addon/components/widget/on-time-delivery.js +73 -0
  78. package/addon/components/widget/operations-pulse.hbs +51 -0
  79. package/addon/components/widget/operations-pulse.js +173 -0
  80. package/addon/components/widget/orders-by-status.hbs +26 -0
  81. package/addon/components/widget/orders-by-status.js +54 -0
  82. package/addon/components/widget/revenue-trend.hbs +34 -0
  83. package/addon/components/widget/revenue-trend.js +67 -0
  84. package/addon/components/widget/top-drivers.hbs +44 -0
  85. package/addon/components/widget/top-drivers.js +54 -0
  86. package/addon/controllers/management/vehicles/index/new.js +1 -1
  87. package/addon/controllers/operations/orders/index/details.js +4 -4
  88. package/addon/controllers/operations/orders/index.js +14 -0
  89. package/addon/controllers/settings/routing.js +26 -0
  90. package/addon/extension.js +172 -3
  91. package/addon/helpers/place-address-html.js +2 -2
  92. package/addon/routes/operations/service-rates/index.js +1 -1
  93. package/addon/services/geofence.js +163 -19
  94. package/addon/services/leaflet-contextmenu-manager.js +27 -0
  95. package/addon/services/map-adapter/google.js +13 -2
  96. package/addon/services/vehicle-actions.js +1 -1
  97. package/addon/styles/fleetops-engine.css +205 -88
  98. package/addon/templates/maintenance/equipment/index.hbs +1 -1
  99. package/addon/templates/maintenance/parts/index.hbs +1 -1
  100. package/addon/templates/maintenance/schedules/index.hbs +1 -1
  101. package/addon/templates/management/vehicles/index.hbs +1 -1
  102. package/addon/templates/settings/avatars.hbs +1 -1
  103. package/addon/templates/settings/custom-fields.hbs +1 -1
  104. package/addon/templates/settings/map.hbs +1 -1
  105. package/addon/templates/settings/navigator-app.hbs +1 -1
  106. package/addon/templates/settings/notifications.hbs +1 -1
  107. package/addon/templates/settings/orchestrator.hbs +1 -1
  108. package/addon/templates/settings/payments/index.hbs +1 -1
  109. package/addon/templates/settings/payments/onboard.hbs +1 -1
  110. package/addon/templates/settings/routing.hbs +22 -2
  111. package/addon/templates/settings/scheduling.hbs +1 -1
  112. package/addon/utils/fleet-ops-options.js +2 -1
  113. package/addon/utils/live-map-card-content.js +171 -0
  114. package/addon/utils/normalize-order-config-flow.js +41 -0
  115. package/addon/utils/place-address-html.js +3 -2
  116. package/addon/utils/route-visualization.js +54 -7
  117. package/app/components/modals/order-change-destination.js +1 -0
  118. package/app/components/modals/stop-activity.js +1 -0
  119. package/app/components/widget/fuel-efficiency.js +1 -0
  120. package/app/components/widget/geofence-violations.js +1 -0
  121. package/app/components/widget/issues-insights.js +1 -0
  122. package/app/components/widget/kpi-active-orders.js +1 -0
  123. package/app/components/widget/kpi-aov.js +1 -0
  124. package/app/components/widget/kpi-distance.js +1 -0
  125. package/app/components/widget/kpi-drivers-online.js +1 -0
  126. package/app/components/widget/kpi-earnings.js +1 -0
  127. package/app/components/widget/kpi-open-issues.js +1 -0
  128. package/app/components/widget/kpi-tile.js +1 -0
  129. package/app/components/widget/live-fleet.js +1 -0
  130. package/app/components/widget/maintenance-overview.js +1 -0
  131. package/app/components/widget/on-time-delivery.js +1 -0
  132. package/app/components/widget/operations-pulse.js +1 -0
  133. package/app/components/widget/orders-by-status.js +1 -0
  134. package/app/components/widget/revenue-trend.js +1 -0
  135. package/app/components/widget/top-drivers.js +1 -0
  136. package/app/utils/live-map-card-content.js +1 -0
  137. package/composer.json +1 -1
  138. package/extension.json +1 -1
  139. package/package.json +3 -3
  140. package/pnpm-workspace.yaml +1 -0
  141. package/server/config/telematics.php +111 -1
  142. package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
  143. package/server/migrations/2026_05_21_000001_add_geographic_rules_to_service_rate_fees_table.php +37 -0
  144. package/server/migrations/2026_05_25_000001_add_endpoint_tracking_refs_to_payloads_table.php +50 -0
  145. package/server/migrations/2026_05_27_000001_default_driver_vehicle_statuses_to_available.php +48 -0
  146. package/server/resources/views/labels/default.php +57 -49
  147. package/server/resources/views/labels/waypoint-label.php +27 -20
  148. package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
  149. package/server/seeders/Testing/ConnectivitySeeder.php +213 -0
  150. package/server/seeders/Testing/FleetSeeder.php +341 -0
  151. package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
  152. package/server/seeders/Testing/NetworkSeeder.php +250 -0
  153. package/server/seeders/Testing/OrdersSeeder.php +198 -0
  154. package/server/seeders/Testing/TestingSeeder.php +40 -0
  155. package/server/seeders/{VroomOrchestrationSeeder.php → Testing/VroomOrchestrationSeeder.php} +29 -22
  156. package/server/src/Console/Commands/AuditCustomerUserConflicts.php +75 -0
  157. package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
  158. package/server/src/Exceptions/CustomerUserConflictException.php +13 -0
  159. package/server/src/Http/Controllers/Api/v1/ContactController.php +17 -2
  160. package/server/src/Http/Controllers/Api/v1/CustomerController.php +876 -0
  161. package/server/src/Http/Controllers/Api/v1/DriverController.php +1 -1
  162. package/server/src/Http/Controllers/Api/v1/OrderConfigController.php +54 -0
  163. package/server/src/Http/Controllers/Api/v1/OrderController.php +98 -70
  164. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  165. package/server/src/Http/Controllers/Api/v1/VehicleController.php +20 -3
  166. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
  167. package/server/src/Http/Controllers/Internal/v1/ContactController.php +19 -0
  168. package/server/src/Http/Controllers/Internal/v1/LiveController.php +101 -66
  169. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
  170. package/server/src/Http/Controllers/Internal/v1/OrderController.php +441 -32
  171. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  172. package/server/src/Http/Controllers/Internal/v1/SettingController.php +46 -1
  173. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +54 -1
  174. package/server/src/Http/Middleware/AuthenticateCustomerToken.php +34 -0
  175. package/server/src/Http/Requests/CreateCustomerOrderRequest.php +63 -0
  176. package/server/src/Http/Requests/CreateCustomerRequest.php +48 -0
  177. package/server/src/Http/Requests/CreateDriverRequest.php +1 -1
  178. package/server/src/Http/Requests/CreateVehicleRequest.php +25 -1
  179. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -1
  180. package/server/src/Http/Requests/VerifyCreateCustomerRequest.php +29 -0
  181. package/server/src/Http/Resources/Internal/v1/OrderConfig.php +41 -0
  182. package/server/src/Http/Resources/v1/Customer.php +79 -0
  183. package/server/src/Http/Resources/v1/Driver.php +2 -0
  184. package/server/src/Http/Resources/v1/Fleet.php +4 -0
  185. package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
  186. package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
  187. package/server/src/Http/Resources/v1/OrderConfig.php +74 -0
  188. package/server/src/Http/Resources/v1/Payload.php +23 -20
  189. package/server/src/Http/Resources/v1/ServiceArea.php +33 -19
  190. package/server/src/Http/Resources/v1/ServiceRateFee.php +7 -0
  191. package/server/src/Listeners/HandleOrderDispatched.php +2 -2
  192. package/server/src/Models/Contact.php +92 -15
  193. package/server/src/Models/Customer.php +64 -0
  194. package/server/src/Models/Driver.php +14 -3
  195. package/server/src/Models/Entity.php +2 -2
  196. package/server/src/Models/Order.php +29 -4
  197. package/server/src/Models/OrderConfig.php +14 -11
  198. package/server/src/Models/Payload.php +17 -1
  199. package/server/src/Models/ServiceRate.php +273 -1
  200. package/server/src/Models/ServiceRateFee.php +39 -9
  201. package/server/src/Models/TrackingNumber.php +8 -2
  202. package/server/src/Models/Vehicle.php +18 -2
  203. package/server/src/Models/Waypoint.php +2 -2
  204. package/server/src/Notifications/LateDeparture.php +48 -0
  205. package/server/src/Notifications/ProlongedStoppage.php +48 -0
  206. package/server/src/Notifications/RouteDeviation.php +48 -0
  207. package/server/src/Observers/ContactObserver.php +2 -0
  208. package/server/src/Observers/ServiceRateObserver.php +6 -6
  209. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +29 -2
  210. package/server/src/Providers/FleetOpsServiceProvider.php +6 -0
  211. package/server/src/Providers/NotificationServiceProvider.php +3 -0
  212. package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
  213. package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
  214. package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
  215. package/server/src/Support/Analytics/IssuesInsights.php +67 -0
  216. package/server/src/Support/Analytics/LiveFleet.php +127 -0
  217. package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
  218. package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
  219. package/server/src/Support/Analytics/OperationsPulse.php +86 -0
  220. package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
  221. package/server/src/Support/Analytics/RevenueTrend.php +78 -0
  222. package/server/src/Support/Analytics/TopDrivers.php +92 -0
  223. package/server/src/Support/CustomerAuth.php +98 -0
  224. package/server/src/Support/LabelPdf.php +52 -0
  225. package/server/src/Support/Metrics/AbstractMetric.php +170 -0
  226. package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
  227. package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
  228. package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
  229. package/server/src/Support/Metrics/EarningsMetric.php +30 -0
  230. package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
  231. package/server/src/Support/Metrics/MoneyMetric.php +30 -0
  232. package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
  233. package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
  234. package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
  235. package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
  236. package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
  237. package/server/src/Support/Metrics/Registry.php +46 -0
  238. package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
  239. package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
  240. package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
  241. package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
  242. package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
  243. package/server/src/Support/Metrics.php +46 -249
  244. package/server/src/Support/ResolvesOrderServiceStops.php +512 -0
  245. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
  246. package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
  247. package/server/src/Tracking/TrackingContextBuilder.php +41 -38
  248. package/server/src/Tracking/TrackingIntelligenceService.php +109 -9
  249. package/server/src/Tracking/TrackingStop.php +13 -11
  250. package/server/src/routes.php +50 -0
  251. package/server/tests/AnalyticsRoutesTest.php +41 -0
  252. package/server/tests/CapacityAllocationEngineTest.php +10 -10
  253. package/server/tests/CustomerContactIdentitySafetyTest.php +54 -0
  254. package/server/tests/CustomerEndpointTest.php +199 -0
  255. package/server/tests/DriverVehicleStatusDefaultTest.php +39 -0
  256. package/server/tests/LabelPdfRenderingTest.php +23 -0
  257. package/server/tests/LiveControllerViewportTest.php +60 -0
  258. package/server/tests/MetricsRegistryTest.php +84 -0
  259. package/server/tests/OrchestrationPayloadBuilderTest.php +2 -2
  260. package/server/tests/OrderConfigEndpointTest.php +86 -0
  261. package/server/tests/OrderFileAttachmentTest.php +20 -0
  262. package/server/tests/ServiceAreaZoneApiShapeTest.php +34 -0
  263. package/server/tests/VehicleDriverAssignmentTest.php +18 -0
  264. package/server/tests/VroomOrchestrationEngineTest.php +91 -0
  265. package/translations/en-us.yaml +38 -0
  266. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +0 -86
@@ -0,0 +1,168 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+ import { inject as service } from '@ember/service';
5
+ import { task } from 'ember-concurrency';
6
+
7
+ /**
8
+ * <Widget::KpiTile @slug="earnings" @title="..." @format="money" @showDelta={{true}}
9
+ * @showSparkline={{true}} @deltaInverse={{false}} @period="30d" />
10
+ *
11
+ * Renders a single KPI tile backed by `/fleet-ops/metrics/{slug}`. Hosts the
12
+ * format-specific value rendering, the optional period-over-period delta
13
+ * badge (with `deltaInverse` to flip semantics for inverse-good metrics like
14
+ * open_issues), and the optional sparkline line chart.
15
+ */
16
+ export default class WidgetKpiTileComponent extends Component {
17
+ @service fetch;
18
+ @tracked data = null;
19
+ @tracked error = null;
20
+
21
+ get period() {
22
+ return this.args.period ?? '30d';
23
+ }
24
+
25
+ get showDelta() {
26
+ return this.args.showDelta ?? true;
27
+ }
28
+
29
+ get showSparkline() {
30
+ return this.args.showSparkline ?? true;
31
+ }
32
+
33
+ get deltaInverse() {
34
+ return this.args.deltaInverse ?? false;
35
+ }
36
+
37
+ get format() {
38
+ return this.args.format ?? this.data?.format ?? 'count';
39
+ }
40
+
41
+ get currency() {
42
+ return this.data?.currency ?? 'USD';
43
+ }
44
+
45
+ get deltaPct() {
46
+ return this.data?.delta_pct;
47
+ }
48
+
49
+ get hasDelta() {
50
+ return this.showDelta && typeof this.deltaPct === 'number';
51
+ }
52
+
53
+ get deltaStatus() {
54
+ const pct = this.deltaPct;
55
+ if (typeof pct !== 'number' || pct === 0) return 'info';
56
+ const positive = pct > 0;
57
+ const isGood = this.deltaInverse ? !positive : positive;
58
+ return isGood ? 'success' : 'danger';
59
+ }
60
+
61
+ get deltaText() {
62
+ const pct = this.deltaPct;
63
+ const sign = pct > 0 ? '+' : '';
64
+ return `${sign}${pct}%`;
65
+ }
66
+
67
+ /** Tints the card subtly based on trend direction so the dashboard pops. */
68
+ get cardAccentClass() {
69
+ const pct = this.deltaPct;
70
+ if (typeof pct !== 'number' || pct === 0) {
71
+ return 'kpi-accent-neutral';
72
+ }
73
+ const positive = pct > 0;
74
+ const isGood = this.deltaInverse ? !positive : positive;
75
+ return isGood ? 'kpi-accent-good' : 'kpi-accent-bad';
76
+ }
77
+
78
+ get valueTextClass() {
79
+ const pct = this.deltaPct;
80
+ if (typeof pct !== 'number' || pct === 0) {
81
+ return 'text-black dark:text-gray-100';
82
+ }
83
+ const positive = pct > 0;
84
+ const isGood = this.deltaInverse ? !positive : positive;
85
+ return isGood ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400';
86
+ }
87
+
88
+ /** Neon palette per delta direction. Drives line + fill + value highlight. */
89
+ get accentColors() {
90
+ // Bright neon-ish trio that pops on the dark theme without being garish in light.
91
+ const good = { line: '#22c55e', fill: 'rgba(34, 197, 94, 0.22)', glow: 'rgba(34, 197, 94, 0.35)' };
92
+ const bad = { line: '#ef4444', fill: 'rgba(239, 68, 68, 0.22)', glow: 'rgba(239, 68, 68, 0.35)' };
93
+ const neutral = { line: '#3485e2', fill: 'rgba(52, 133, 226, 0.18)', glow: 'rgba(52, 133, 226, 0.30)' };
94
+
95
+ const pct = this.deltaPct;
96
+ if (typeof pct !== 'number' || pct === 0) return neutral;
97
+ const positive = pct > 0;
98
+ const isGood = this.deltaInverse ? !positive : positive;
99
+ return isGood ? good : bad;
100
+ }
101
+
102
+ get sparklineDatasets() {
103
+ const points = this.data?.sparkline?.data;
104
+ if (!points || points.length === 0) return null;
105
+
106
+ const { line, fill } = this.accentColors;
107
+
108
+ return [
109
+ {
110
+ data: points,
111
+ borderColor: line,
112
+ backgroundColor: fill,
113
+ pointRadius: 0,
114
+ borderWidth: 1.75,
115
+ tension: 0.4,
116
+ // Anchor the area fill to the chart's y=0 axis so the sparkline
117
+ // visually sits at the BOTTOM of the card regardless of the data range.
118
+ fill: 'origin',
119
+ },
120
+ ];
121
+ }
122
+
123
+ get sparklineLabels() {
124
+ return this.data?.sparkline?.labels ?? [];
125
+ }
126
+
127
+ get sparklineOptions() {
128
+ return {
129
+ responsive: true,
130
+ maintainAspectRatio: false,
131
+ animation: { duration: 600, easing: 'easeOutQuart' },
132
+ plugins: {
133
+ legend: { display: false },
134
+ tooltip: { enabled: false },
135
+ },
136
+ scales: {
137
+ x: { display: false },
138
+ // Lock min to 0 so flat-zero data sits flush along the bottom edge.
139
+ // suggestedMin/Max keep the line off the very top/bottom for non-zero data.
140
+ y: { display: false, min: 0, beginAtZero: true, grace: '5%' },
141
+ },
142
+ elements: { line: { borderJoinStyle: 'round' } },
143
+ };
144
+ }
145
+
146
+ constructor() {
147
+ super(...arguments);
148
+ this.load.perform();
149
+ }
150
+
151
+ @task *load() {
152
+ try {
153
+ this.data = yield this.fetch.get(`fleet-ops/metrics/${this.args.slug}`, {
154
+ period: this.period,
155
+ sparkline: this.showSparkline,
156
+ compare: this.showDelta,
157
+ });
158
+ this.error = null;
159
+ } catch (e) {
160
+ this.error = e?.message ?? 'Failed to load metric';
161
+ }
162
+ }
163
+
164
+ @action
165
+ refresh() {
166
+ this.load.perform();
167
+ }
168
+ }
@@ -0,0 +1,122 @@
1
+ <div class="fleet-ops-widget fleet-ops-live-fleet flex flex-col h-full {{@class}}" ...attributes>
2
+ <div class="flex items-center justify-between px-3 py-1.5 border dark:border-gray-700 border-gray-200 dark:bg-gray-800 bg-gray-50 rounded-t-md shadow-sm">
3
+ <span class="text-xs font-bold text-black dark:text-gray-100">{{t "widget.live-fleet.title"}}</span>
4
+ <div class="flex items-center gap-3 text-[10px] text-gray-500 dark:text-gray-400">
5
+ <span class="flex items-center" title="Drivers"><FaIcon @icon="id-card" class="mr-1" />{{this.drivers.length}}</span>
6
+ <span class="flex items-center" title="Vehicles"><FaIcon @icon="truck" class="mr-1" />{{this.vehicles.length}}</span>
7
+ <span class="flex items-center" title="Active orders"><FaIcon @icon="bolt" class="mr-1" />{{this.activeOrderCount}}</span>
8
+ <button type="button" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" {{on "click" this.refresh}}>
9
+ <FaIcon @icon="rotate" class={{if this.load.isRunning "animate-spin"}} />
10
+ </button>
11
+ </div>
12
+ </div>
13
+
14
+ <div class="flex-1 relative border border-t-0 dark:border-gray-700 border-gray-200 rounded-b-md overflow-hidden min-h-0 bg-gray-100 dark:bg-gray-900">
15
+ <div class="fleetbase-leaflet-map-container w-full h-full" {{set-container-dimensions}}>
16
+ <LeafletMap
17
+ class="w-full h-full"
18
+ @lat={{get this.center 0}}
19
+ @lng={{get this.center 1}}
20
+ @zoom={{this.zoom}}
21
+ @zoomControl={{true}}
22
+ @onLoad={{this.onMapLoad}}
23
+ {{set-container-dimensions}}
24
+ as |layers|
25
+ >
26
+ <layers.tile @url={{this.tileUrl}} />
27
+
28
+ {{#if this.load.isIdle}}
29
+ {{! Drivers — fleetops' rotating tracking-marker matches the operational
30
+ live-map's look and animates on heading changes. }}
31
+ {{#each this.drivers as |driver|}}
32
+ <layers.tracking-marker
33
+ @id={{driver.uuid}}
34
+ @publicId={{driver.public_id}}
35
+ @location={{array driver.lat driver.lng}}
36
+ @rotationAngle={{driver.heading}}
37
+ @icon={{icon iconUrl=(or driver.vehicle_avatar driver.avatar_url "/engines-dist/images/map-marker.png") iconSize=(array 28 28) iconAnchor=(array 14 28)}}
38
+ @draggable={{false}}
39
+ as |marker|
40
+ >
41
+ <marker.popup @maxWidth="240" @minWidth="180">
42
+ <div class="flex flex-row gap-2">
43
+ <div class="w-9 h-9 flex-shrink-0">
44
+ <img src={{or driver.avatar_url driver.vehicle_avatar}} alt={{driver.name}} class="rounded-md w-9 h-9 object-cover shadow-sm" />
45
+ </div>
46
+ <div class="flex-1 min-w-0">
47
+ <div class="text-xs font-semibold truncate">{{or driver.name "Driver"}}</div>
48
+ <div class="text-[11px]">
49
+ {{#if driver.current_order_uuid}}
50
+ <span class="text-blue-500"><FaIcon @icon="bolt" class="mr-1" />On order</span>
51
+ {{else if driver.online}}
52
+ <span class="text-green-500"><FaIcon @icon="circle" @size="2xs" class="mr-1" />Online</span>
53
+ {{else}}
54
+ <span class="text-gray-400"><FaIcon @icon="circle" @size="2xs" class="mr-1" />Offline</span>
55
+ {{/if}}
56
+ </div>
57
+ <div class="text-[10px] text-gray-400 mt-0.5 truncate">{{driver.lat}}, {{driver.lng}}</div>
58
+ </div>
59
+ </div>
60
+ </marker.popup>
61
+ <marker.tooltip @permanent={{false}} @sticky={{true}} @direction="top">
62
+ <div class="text-[11px] font-semibold">{{or driver.name "Driver"}}</div>
63
+ </marker.tooltip>
64
+ </layers.tracking-marker>
65
+ {{/each}}
66
+
67
+ {{! Vehicles — also rendered via tracking-marker so the heading
68
+ animation applies on telematics-driven position updates. }}
69
+ {{#each this.vehicles as |vehicle|}}
70
+ <layers.tracking-marker
71
+ @id={{vehicle.uuid}}
72
+ @publicId={{vehicle.public_id}}
73
+ @location={{array vehicle.lat vehicle.lng}}
74
+ @rotationAngle={{vehicle.heading}}
75
+ @icon={{icon iconUrl=(or vehicle.avatar_url vehicle.photo_url "/engines-dist/images/map-marker.png") iconSize=(array 28 28) iconAnchor=(array 14 28)}}
76
+ @draggable={{false}}
77
+ as |marker|
78
+ >
79
+ <marker.popup @maxWidth="240" @minWidth="180">
80
+ <div class="flex flex-row gap-2">
81
+ <div class="w-9 h-9 flex-shrink-0">
82
+ <img src={{or vehicle.photo_url vehicle.avatar_url}} alt={{vehicle.name}} class="rounded-md w-9 h-9 object-cover shadow-sm" />
83
+ </div>
84
+ <div class="flex-1 min-w-0">
85
+ <div class="text-xs font-semibold truncate">{{or vehicle.name "Vehicle"}}</div>
86
+ {{#if vehicle.plate_number}}
87
+ <div class="text-[10px] text-gray-500 truncate">{{vehicle.plate_number}}</div>
88
+ {{/if}}
89
+ {{#if vehicle.driver_name}}
90
+ <div class="text-[11px] text-gray-600 dark:text-gray-300 truncate">
91
+ <FaIcon @icon="id-card" class="mr-1 text-gray-400" />{{vehicle.driver_name}}
92
+ </div>
93
+ {{/if}}
94
+ <div class="text-[10px] text-gray-400 mt-0.5 truncate">{{vehicle.lat}}, {{vehicle.lng}}</div>
95
+ </div>
96
+ </div>
97
+ </marker.popup>
98
+ <marker.tooltip @permanent={{false}} @sticky={{true}} @direction="top">
99
+ <div class="text-[11px] font-semibold">{{or vehicle.name "Vehicle"}}</div>
100
+ </marker.tooltip>
101
+ </layers.tracking-marker>
102
+ {{/each}}
103
+ {{/if}}
104
+ </LeafletMap>
105
+ </div>
106
+
107
+ {{#if this.load.isRunning}}
108
+ <div class="absolute inset-0 flex items-center justify-center bg-white/70 dark:bg-gray-900/70 z-[400]">
109
+ <Spinner />
110
+ </div>
111
+ {{else if this.error}}
112
+ <div class="absolute inset-0 flex items-center justify-center text-xs text-red-500 z-[400]">{{this.error}}</div>
113
+ {{else if (not this.totalMarkerCount)}}
114
+ <div class="absolute inset-0 flex items-center justify-center pointer-events-none">
115
+ <div class="text-center text-[11px] text-gray-500 dark:text-gray-400 bg-white/90 dark:bg-gray-900/90 rounded-md px-3 py-2 shadow-sm">
116
+ <FaIcon @icon="map-location-dot" class="text-xl block mb-1 opacity-50" />
117
+ No active drivers or vehicles to display.
118
+ </div>
119
+ </div>
120
+ {{/if}}
121
+ </div>
122
+ </div>
@@ -0,0 +1,365 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+ import { inject as service } from '@ember/service';
5
+ import { getOwner } from '@ember/application';
6
+ import { debug } from '@ember/debug';
7
+ import { task } from 'ember-concurrency';
8
+ import { renderCompleted, waitForInsertedAndSized } from '@fleetbase/ember-ui/utils/dom';
9
+ import LeafletTrackingMarkerComponent from '../leaflet-tracking-marker';
10
+
11
+ const DEFAULT_CENTER = [1.31, 103.85];
12
+ const DEFAULT_ZOOM = 11;
13
+ // Match the rest of fleetops' Leaflet maps (light Carto basemap), which keeps the
14
+ // dashboard styling consistent with the operational live map. Falls back gracefully
15
+ // in dark mode via the next-leaflet-container-map theming.
16
+ const TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png';
17
+ const TILE_URL_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png';
18
+ const TILE_ATTRIBUTION = '&copy; OpenStreetMap contributors &copy; CARTO';
19
+ const RECONCILE_INTERVAL_MS = 5 * 60_000;
20
+
21
+ /**
22
+ * Real-time fleet map. Initial GET seeds driver positions; the widget then
23
+ * subscribes to `company.{uuid}` and applies driver.location_changed deltas
24
+ * directly to the markers list. Full reconciliation every 5 minutes.
25
+ */
26
+ export default class WidgetLiveFleetComponent extends Component {
27
+ static widgetId = 'fleet-ops-live-fleet-widget';
28
+
29
+ @service fetch;
30
+ @service socket;
31
+ @service currentUser;
32
+
33
+ @tracked data = null;
34
+ @tracked error = null;
35
+
36
+ tileAttribution = TILE_ATTRIBUTION;
37
+
38
+ get tileUrl() {
39
+ const isDark = typeof document !== 'undefined' && document.documentElement?.dataset?.theme === 'dark';
40
+ return isDark ? TILE_URL_DARK : TILE_URL;
41
+ }
42
+
43
+ socketChannel = null;
44
+ socketActive = false;
45
+ reconcileTimer = null;
46
+ /** Watches the map container for size changes (gridstack settle, sibling widgets
47
+ * loading, theme/font swaps) and re-runs Leaflet's invalidateSize so the tile
48
+ * layer always fills the actual painted bounds, not the bounds Leaflet measured
49
+ * at first paint. */
50
+ #resizeObserver = null;
51
+ #resizeRaf = 0;
52
+
53
+ /**
54
+ * Deferred that resolves once <LeafletMap @onLoad> fires. Mirrors the
55
+ * leaflet-map-manager service's #mapSetPromise so we can synchronously
56
+ * wait for the map instance from anywhere in this component.
57
+ */
58
+ #map = null;
59
+ #mapSetPromise = null;
60
+ #resolveMapSet = null;
61
+
62
+ constructor() {
63
+ super(...arguments);
64
+ // Important: register tracking-marker against THIS owner's ember-leaflet
65
+ // service before any <LeafletMap> in our template is constructed.
66
+ //
67
+ // The fleetops `register-leaflet-tracking-marker` instance-initializer
68
+ // registers `tracking-marker` on the fleetops ENGINE's ember-leaflet
69
+ // service. When this widget is rendered from the host dashboard route
70
+ // (e.g. console.home), LazyEngineComponent resolves the component class
71
+ // from the engine but renders it under the HOST owner — so the
72
+ // <LeafletMap> inside this template injects the host's ember-leaflet
73
+ // service, which never received the registration. The yield for
74
+ // `layers.tracking-marker` silently resolves to undefined and no marker
75
+ // ever paints. Doing the registration on whatever owner this widget
76
+ // happens to live under closes that gap.
77
+ this.#ensureTrackingMarkerRegistered();
78
+ this.#patchLeafletDrawInitHook();
79
+ this.#resetMapDeferred();
80
+ this.load.perform();
81
+ this.subscribe();
82
+ this.reconcileTimer = setInterval(() => this.load.perform(), RECONCILE_INTERVAL_MS);
83
+ }
84
+
85
+ /**
86
+ * leaflet-draw's `Edit.Marker.js` registers an init hook on `L.Marker` that
87
+ * does `if (L.Edit.Marker) { this.editing = new L.Edit.Marker(this); … }`.
88
+ * The check is unguarded — it reads `.Marker` off `L.Edit` directly. When
89
+ * leaflet-draw is loaded but the dashboard route's window.L doesn't carry
90
+ * the `Edit` namespace (load-order race in the host route, or two leaflet
91
+ * copies in the runtime caused by engine/host module duplication), every
92
+ * marker creation throws a TypeError and our tracking-marker churn during
93
+ * dashboard switching repeats it thousands of times.
94
+ *
95
+ * Stubbing `L.Edit` to `{}` keeps the leaflet-draw hook's check (`if
96
+ * (L.Edit.Marker)`) safely falsy when the namespace is missing, so it
97
+ * becomes a no-op. If leaflet-draw HAS fully loaded, `L.Edit.Marker` is
98
+ * already set and the `||` keeps it.
99
+ *
100
+ * We don't need draw/editing handlers on dashboard markers anyway — this
101
+ * widget is read-only.
102
+ */
103
+ #patchLeafletDrawInitHook() {
104
+ const L = typeof window !== 'undefined' ? (window.L ?? window.leaflet) : null;
105
+ if (L && !L.Edit) {
106
+ L.Edit = {};
107
+ }
108
+ if (L && !L.Marker) {
109
+ L.Marker = {};
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Idempotent: registers the fleetops `tracking-marker` component on the
115
+ * `ember-leaflet` service belonging to this widget's owner if it isn't
116
+ * already there. Safe to call multiple times across widget instances
117
+ * because we guard on `components.find(...)` before calling
118
+ * `registerComponent` (which throws on duplicate registration).
119
+ */
120
+ #ensureTrackingMarkerRegistered() {
121
+ const owner = getOwner(this);
122
+ const emberLeaflet = owner?.lookup?.('service:ember-leaflet');
123
+ if (!emberLeaflet) return;
124
+
125
+ const already = emberLeaflet.components?.find((c) => c.name === 'leaflet-tracking-marker' || c.as === 'tracking-marker');
126
+ if (already) return;
127
+
128
+ emberLeaflet.registerComponent('leaflet-tracking-marker', {
129
+ as: 'tracking-marker',
130
+ component: LeafletTrackingMarkerComponent,
131
+ });
132
+ }
133
+
134
+ willDestroy() {
135
+ super.willDestroy(...arguments);
136
+ this.unsubscribe();
137
+ if (this.reconcileTimer) clearInterval(this.reconcileTimer);
138
+ if (this.#resizeRaf) cancelAnimationFrame(this.#resizeRaf);
139
+ this.#resizeObserver?.disconnect();
140
+ this.#resizeObserver = null;
141
+ }
142
+
143
+ get drivers() {
144
+ return this.data?.drivers ?? [];
145
+ }
146
+
147
+ get vehicles() {
148
+ return this.data?.vehicles ?? [];
149
+ }
150
+
151
+ get activeOrderCount() {
152
+ return this.data?.active_orders?.length ?? 0;
153
+ }
154
+
155
+ /** Both drivers and vehicles are renderable map subjects — total chip count. */
156
+ get totalMarkerCount() {
157
+ return this.drivers.length + this.vehicles.length;
158
+ }
159
+
160
+ /** Center on the first available marker (driver, then vehicle), else default. */
161
+ get center() {
162
+ const first = this.drivers[0] ?? this.vehicles[0];
163
+ if (first?.lat && first?.lng) return [first.lat, first.lng];
164
+ return DEFAULT_CENTER;
165
+ }
166
+
167
+ get zoom() {
168
+ return DEFAULT_ZOOM;
169
+ }
170
+
171
+ statusColor(status) {
172
+ switch (status) {
173
+ case 'in_progress':
174
+ case 'enroute':
175
+ case 'dispatched':
176
+ return '#3485e2';
177
+ case 'completed':
178
+ return '#22c55e';
179
+ default:
180
+ return '#9ca3af';
181
+ }
182
+ }
183
+
184
+ @task *load() {
185
+ try {
186
+ // Wait for the LeafletMap to mount, finish loading, and have a non-zero
187
+ // container before we add markers. Without this, markers added to a
188
+ // not-yet-sized map silently no-op (the data arrives, count chip ticks
189
+ // up, but nothing paints).
190
+ yield this.ensureInteractive();
191
+ this.data = yield this.fetch.get('fleet-ops/analytics/live-fleet');
192
+ this.error = null;
193
+ } catch (e) {
194
+ this.error = e?.message ?? 'Failed to load live fleet';
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Mirrors LeafletMapManagerService#ensureInteractive: waits for the
200
+ * Leaflet instance to be available + loaded, for the host DOM container
201
+ * to be inserted and sized, then forces a re-measure. Returns the map.
202
+ */
203
+ async ensureInteractive({ timeoutMs = 8000 } = {}) {
204
+ const map = await this.#whenMapLoaded({ timeoutMs });
205
+
206
+ const getContainer = () => map?.getContainer?.() || map?._container || null;
207
+
208
+ await renderCompleted();
209
+ await waitForInsertedAndSized(getContainer, { timeoutMs });
210
+
211
+ map.invalidateSize(false);
212
+
213
+ // Subsequent size changes (gridstack settling, sibling widgets loading,
214
+ // theme/font swaps, widget resize handles) need invalidateSize() again
215
+ // or the tile layer ends up clipped to the cached bounding box.
216
+ this.#attachResizeObserver(map, getContainer());
217
+
218
+ return map;
219
+ }
220
+
221
+ /**
222
+ * Watch the Leaflet container with a ResizeObserver and re-run invalidateSize
223
+ * on any dimension change. rAF-debounced so a stream of resize callbacks during
224
+ * a gridstack drag/animation collapses into one Leaflet recompute per frame.
225
+ */
226
+ #attachResizeObserver(map, container) {
227
+ if (!container || typeof ResizeObserver === 'undefined') return;
228
+ this.#resizeObserver?.disconnect();
229
+
230
+ this.#resizeObserver = new ResizeObserver(() => {
231
+ if (this.#resizeRaf) cancelAnimationFrame(this.#resizeRaf);
232
+ this.#resizeRaf = requestAnimationFrame(() => {
233
+ this.#resizeRaf = 0;
234
+ try {
235
+ map.invalidateSize(false);
236
+ } catch (e) {
237
+ debug(`[live-fleet] invalidateSize failed: ${e?.message ?? e}`);
238
+ }
239
+ });
240
+ });
241
+ this.#resizeObserver.observe(container);
242
+ }
243
+
244
+ /** Hook fired by <LeafletMap @onLoad>. Stores the instance + resolves the deferred. */
245
+ @action onMapLoad({ target: map }) {
246
+ this.#map = map;
247
+ this.#resolveMapSet?.(map);
248
+ }
249
+
250
+ /** ──────────────────────────────────────────────
251
+ * Internal map-readiness deferred (same shape as
252
+ * leaflet-map-manager.js).
253
+ * ────────────────────────────────────────────── */
254
+
255
+ #resetMapDeferred() {
256
+ this.#mapSetPromise = new Promise((resolve) => {
257
+ this.#resolveMapSet = resolve;
258
+ });
259
+ }
260
+
261
+ async #waitForMap({ timeoutMs = 8000 } = {}) {
262
+ if (this.#map) return this.#map;
263
+
264
+ let to;
265
+ try {
266
+ return await Promise.race([
267
+ this.#mapSetPromise,
268
+ new Promise((_, rej) => {
269
+ if (timeoutMs != null) {
270
+ to = setTimeout(() => rej(new Error('waitForMap timed out')), timeoutMs);
271
+ }
272
+ }),
273
+ ]);
274
+ } finally {
275
+ if (to) clearTimeout(to);
276
+ }
277
+ }
278
+
279
+ async #whenMapLoaded({ timeoutMs } = {}) {
280
+ const map = await this.#waitForMap({ timeoutMs });
281
+ if (map._loaded) return map;
282
+
283
+ return new Promise((resolve, reject) => {
284
+ let t;
285
+ const onLoad = () => {
286
+ clearTimeout(t);
287
+ map.off('load', onLoad);
288
+ resolve(map);
289
+ };
290
+ map.on('load', onLoad);
291
+ if (timeoutMs != null) {
292
+ t = setTimeout(() => {
293
+ map.off('load', onLoad);
294
+ reject(new Error('whenMapLoaded timed out'));
295
+ }, timeoutMs);
296
+ }
297
+ });
298
+ }
299
+
300
+ @action
301
+ refresh() {
302
+ this.load.perform();
303
+ }
304
+
305
+ async subscribe() {
306
+ const companyId = this.currentUser?.companyId;
307
+ if (!companyId) return;
308
+
309
+ try {
310
+ const sc = this.socket.instance();
311
+ const channel = sc.subscribe(`company.${companyId}`);
312
+ this.socketChannel = channel;
313
+ this.socketActive = true;
314
+
315
+ if (channel.state !== 'subscribed') {
316
+ await channel.subscribe();
317
+ }
318
+
319
+ for await (const msg of channel) {
320
+ if (!this.socketActive) break;
321
+ this.applyEvent(msg);
322
+ }
323
+ } catch (e) {
324
+ debug(`[live-fleet] socket error: ${e?.message ?? e}`);
325
+ }
326
+ }
327
+
328
+ async unsubscribe() {
329
+ this.socketActive = false;
330
+ try {
331
+ await this.socketChannel?.unsubscribe();
332
+ } catch (e) {
333
+ debug(`[live-fleet] unsubscribe error: ${e?.message ?? e}`);
334
+ }
335
+ this.socketChannel = null;
336
+ }
337
+
338
+ applyEvent(msg) {
339
+ if (!this.data || !msg) return;
340
+
341
+ switch (msg.event) {
342
+ case 'driver.location_changed':
343
+ this.applyLocationDelta(msg.data);
344
+ break;
345
+ case 'driver.online':
346
+ case 'driver.offline':
347
+ // Presence flips are reconciled on the next 5-min refetch.
348
+ break;
349
+ default:
350
+ return;
351
+ }
352
+ }
353
+
354
+ applyLocationDelta(data) {
355
+ if (!data?.uuid && !data?.driver_uuid) return;
356
+ const targetUuid = data.uuid ?? data.driver_uuid;
357
+ const lat = data.lat ?? data.latitude;
358
+ const lng = data.lng ?? data.longitude;
359
+ if (typeof lat !== 'number' || typeof lng !== 'number') return;
360
+
361
+ const drivers = (this.data.drivers ?? []).map((d) => (d.uuid === targetUuid ? { ...d, lat, lng, heading: data.heading ?? d.heading } : d));
362
+
363
+ this.data = { ...this.data, drivers };
364
+ }
365
+ }