@fleetbase/fleetops-engine 0.6.47 → 0.6.49

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 (223) hide show
  1. package/addon/components/admin/routing-settings.hbs +71 -0
  2. package/addon/components/admin/routing-settings.js +124 -0
  3. package/addon/components/customer/orders.js +1 -1
  4. package/addon/components/display-place.hbs +1 -1
  5. package/addon/components/driver/details.hbs +5 -1
  6. package/addon/components/driver/form.hbs +11 -1
  7. package/addon/components/fleet-listing-panel.hbs +2 -2
  8. package/addon/components/fleet-listing-panel.js +10 -0
  9. package/addon/components/map/google-live-map.js +12 -73
  10. package/addon/components/map/leaflet-live-map.hbs +149 -33
  11. package/addon/components/map/order-list-overlay/order.hbs +18 -3
  12. package/addon/components/map/toolbar.hbs +2 -2
  13. package/addon/components/map/toolbar.js +10 -2
  14. package/addon/components/orchestrator/phase-builder.hbs +49 -5
  15. package/addon/components/orchestrator/phase-builder.js +34 -1
  16. package/addon/components/orchestrator/resource-panel.hbs +19 -1
  17. package/addon/components/orchestrator/resource-panel.js +4 -0
  18. package/addon/components/orchestrator-workbench.js +4 -1
  19. package/addon/components/order/details/purchase-rate.hbs +19 -22
  20. package/addon/components/order/details/route.hbs +26 -1
  21. package/addon/components/order/details/route.js +50 -0
  22. package/addon/components/order/details/tracking.hbs +171 -27
  23. package/addon/components/order/details/tracking.js +457 -0
  24. package/addon/components/order/form.hbs +2 -2
  25. package/addon/components/order/kanban-card.hbs +18 -3
  26. package/addon/components/order-list-overlay/order.hbs +18 -3
  27. package/addon/components/order-progress-card.hbs +21 -6
  28. package/addon/components/order-tracking-lookup.hbs +7 -7
  29. package/addon/components/order-tracking-lookup.js +1 -1
  30. package/addon/components/route-list.hbs +78 -8
  31. package/addon/components/route-list.js +48 -6
  32. package/addon/components/tracking-stop-progress.hbs +45 -0
  33. package/addon/components/tracking-stop-progress.js +79 -0
  34. package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
  35. package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
  36. package/addon/components/widget/fuel-efficiency.hbs +26 -0
  37. package/addon/components/widget/fuel-efficiency.js +43 -0
  38. package/addon/components/widget/geofence-violations.hbs +44 -0
  39. package/addon/components/widget/geofence-violations.js +55 -0
  40. package/addon/components/widget/issues-insights.hbs +43 -0
  41. package/addon/components/widget/issues-insights.js +62 -0
  42. package/addon/components/widget/kpi-active-orders.hbs +10 -0
  43. package/addon/components/widget/kpi-active-orders.js +5 -0
  44. package/addon/components/widget/kpi-aov.hbs +10 -0
  45. package/addon/components/widget/kpi-aov.js +5 -0
  46. package/addon/components/widget/kpi-distance.hbs +10 -0
  47. package/addon/components/widget/kpi-distance.js +5 -0
  48. package/addon/components/widget/kpi-drivers-online.hbs +10 -0
  49. package/addon/components/widget/kpi-drivers-online.js +5 -0
  50. package/addon/components/widget/kpi-earnings.hbs +10 -0
  51. package/addon/components/widget/kpi-earnings.js +5 -0
  52. package/addon/components/widget/kpi-open-issues.hbs +11 -0
  53. package/addon/components/widget/kpi-open-issues.js +5 -0
  54. package/addon/components/widget/kpi-tile.hbs +59 -0
  55. package/addon/components/widget/kpi-tile.js +168 -0
  56. package/addon/components/widget/live-fleet.hbs +122 -0
  57. package/addon/components/widget/live-fleet.js +365 -0
  58. package/addon/components/widget/maintenance-overview.hbs +45 -0
  59. package/addon/components/widget/maintenance-overview.js +40 -0
  60. package/addon/components/widget/on-time-delivery.hbs +27 -0
  61. package/addon/components/widget/on-time-delivery.js +73 -0
  62. package/addon/components/widget/operations-pulse.hbs +51 -0
  63. package/addon/components/widget/operations-pulse.js +173 -0
  64. package/addon/components/widget/orders-by-status.hbs +26 -0
  65. package/addon/components/widget/orders-by-status.js +54 -0
  66. package/addon/components/widget/revenue-trend.hbs +34 -0
  67. package/addon/components/widget/revenue-trend.js +67 -0
  68. package/addon/components/widget/top-drivers.hbs +44 -0
  69. package/addon/components/widget/top-drivers.js +54 -0
  70. package/addon/controllers/settings/routing.js +124 -1
  71. package/addon/extension.js +172 -3
  72. package/addon/helpers/format-duration.js +38 -0
  73. package/addon/routes/application.js +6 -0
  74. package/addon/services/map-adapter/google.js +13 -2
  75. package/addon/services/order-list-overlay.js +2 -2
  76. package/addon/styles/fleetops-engine.css +806 -46
  77. package/addon/templates/maintenance/equipment/index.hbs +1 -1
  78. package/addon/templates/maintenance/parts/index.hbs +1 -1
  79. package/addon/templates/maintenance/schedules/index.hbs +1 -1
  80. package/addon/templates/management/vehicles/index.hbs +1 -1
  81. package/addon/templates/settings/avatars.hbs +1 -1
  82. package/addon/templates/settings/custom-fields.hbs +1 -1
  83. package/addon/templates/settings/map.hbs +1 -1
  84. package/addon/templates/settings/navigator-app.hbs +1 -1
  85. package/addon/templates/settings/notifications.hbs +1 -1
  86. package/addon/templates/settings/orchestrator.hbs +1 -1
  87. package/addon/templates/settings/payments/index.hbs +1 -1
  88. package/addon/templates/settings/payments/onboard.hbs +1 -1
  89. package/addon/templates/settings/routing.hbs +82 -4
  90. package/addon/templates/settings/scheduling.hbs +1 -1
  91. package/addon/utils/live-map-card-content.js +171 -0
  92. package/app/components/tracking-stop-progress.js +1 -0
  93. package/app/components/widget/fuel-efficiency.js +1 -0
  94. package/app/components/widget/geofence-violations.js +1 -0
  95. package/app/components/widget/issues-insights.js +1 -0
  96. package/app/components/widget/kpi-active-orders.js +1 -0
  97. package/app/components/widget/kpi-aov.js +1 -0
  98. package/app/components/widget/kpi-distance.js +1 -0
  99. package/app/components/widget/kpi-drivers-online.js +1 -0
  100. package/app/components/widget/kpi-earnings.js +1 -0
  101. package/app/components/widget/kpi-open-issues.js +1 -0
  102. package/app/components/widget/kpi-tile.js +1 -0
  103. package/app/components/widget/live-fleet.js +1 -0
  104. package/app/components/widget/maintenance-overview.js +1 -0
  105. package/app/components/widget/on-time-delivery.js +1 -0
  106. package/app/components/widget/operations-pulse.js +1 -0
  107. package/app/components/widget/orders-by-status.js +1 -0
  108. package/app/components/widget/revenue-trend.js +1 -0
  109. package/app/components/widget/top-drivers.js +1 -0
  110. package/app/helpers/format-duration.js +1 -0
  111. package/app/utils/live-map-card-content.js +1 -0
  112. package/composer.json +1 -1
  113. package/extension.json +1 -1
  114. package/package.json +3 -3
  115. package/pnpm-workspace.yaml +1 -0
  116. package/server/config/fleetops.php +32 -11
  117. package/server/config/telematics.php +111 -1
  118. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +86 -0
  119. package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
  120. package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
  121. package/server/seeders/Testing/ConnectivitySeeder.php +212 -0
  122. package/server/seeders/Testing/FleetSeeder.php +341 -0
  123. package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
  124. package/server/seeders/Testing/NetworkSeeder.php +250 -0
  125. package/server/seeders/Testing/OrdersSeeder.php +214 -0
  126. package/server/seeders/Testing/TestingSeeder.php +40 -0
  127. package/server/seeders/Testing/VroomOrchestrationSeeder.php +409 -0
  128. package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
  129. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +64 -0
  130. package/server/src/Http/Controllers/Api/v1/OrderController.php +26 -26
  131. package/server/src/Http/Controllers/Api/v1/PayloadController.php +10 -10
  132. package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +1 -1
  133. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
  134. package/server/src/Http/Controllers/Internal/v1/ContactController.php +41 -0
  135. package/server/src/Http/Controllers/Internal/v1/LiveController.php +6 -42
  136. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
  137. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +1 -1
  138. package/server/src/Http/Controllers/Internal/v1/OrderController.php +52 -21
  139. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  140. package/server/src/Http/Controllers/Internal/v1/SettingController.php +164 -12
  141. package/server/src/Http/Resources/v1/Driver.php +2 -0
  142. package/server/src/Http/Resources/v1/Fleet.php +4 -0
  143. package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
  144. package/server/src/Http/Resources/v1/Index/Payload.php +2 -2
  145. package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
  146. package/server/src/Models/Contact.php +122 -13
  147. package/server/src/Models/Driver.php +2 -0
  148. package/server/src/Models/Order.php +1 -1
  149. package/server/src/Models/OrderConfig.php +14 -11
  150. package/server/src/Models/Payload.php +8 -8
  151. package/server/src/Models/Place.php +0 -3
  152. package/server/src/Models/ServiceRate.php +1 -1
  153. package/server/src/Notifications/LateDeparture.php +48 -0
  154. package/server/src/Notifications/ProlongedStoppage.php +48 -0
  155. package/server/src/Notifications/RouteDeviation.php +48 -0
  156. package/server/src/Observers/ContactObserver.php +8 -0
  157. package/server/src/Orchestration/Engines/CapacityAllocationEngine.php +220 -0
  158. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +357 -38
  159. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +259 -51
  160. package/server/src/Providers/FleetOpsServiceProvider.php +32 -0
  161. package/server/src/Providers/NotificationServiceProvider.php +3 -0
  162. package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
  163. package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
  164. package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
  165. package/server/src/Support/Analytics/IssuesInsights.php +67 -0
  166. package/server/src/Support/Analytics/LiveFleet.php +127 -0
  167. package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
  168. package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
  169. package/server/src/Support/Analytics/OperationsPulse.php +86 -0
  170. package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
  171. package/server/src/Support/Analytics/RevenueTrend.php +79 -0
  172. package/server/src/Support/Analytics/TopDrivers.php +92 -0
  173. package/server/src/Support/LiveOrderQuery.php +75 -0
  174. package/server/src/Support/Metrics/AbstractMetric.php +170 -0
  175. package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
  176. package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
  177. package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
  178. package/server/src/Support/Metrics/EarningsMetric.php +30 -0
  179. package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
  180. package/server/src/Support/Metrics/MoneyMetric.php +30 -0
  181. package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
  182. package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
  183. package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
  184. package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
  185. package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
  186. package/server/src/Support/Metrics/Registry.php +46 -0
  187. package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
  188. package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
  189. package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
  190. package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
  191. package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
  192. package/server/src/Support/Metrics.php +46 -234
  193. package/server/src/Support/OSRM.php +11 -6
  194. package/server/src/Support/OrderTracker.php +7 -582
  195. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
  196. package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
  197. package/server/src/Support/Utils.php +1 -1
  198. package/server/src/Tracking/Contracts/TrackingProviderInterface.php +19 -0
  199. package/server/src/Tracking/Providers/CalculatedTrackingProvider.php +65 -0
  200. package/server/src/Tracking/Providers/GoogleRoutesTrackingProvider.php +118 -0
  201. package/server/src/Tracking/Providers/OsrmTrackingProvider.php +71 -0
  202. package/server/src/Tracking/README.md +25 -0
  203. package/server/src/Tracking/Support/FakeTrackingProvider.php +42 -0
  204. package/server/src/Tracking/TrackingContext.php +63 -0
  205. package/server/src/Tracking/TrackingContextBuilder.php +204 -0
  206. package/server/src/Tracking/TrackingIntelligenceService.php +180 -0
  207. package/server/src/Tracking/TrackingOptions.php +49 -0
  208. package/server/src/Tracking/TrackingProviderCapabilities.php +30 -0
  209. package/server/src/Tracking/TrackingProviderManager.php +89 -0
  210. package/server/src/Tracking/TrackingProviderRegistry.php +35 -0
  211. package/server/src/Tracking/TrackingProviderResult.php +20 -0
  212. package/server/src/Tracking/TrackingStop.php +59 -0
  213. package/server/src/routes.php +27 -0
  214. package/server/tests/AnalyticsRoutesTest.php +41 -0
  215. package/server/tests/CapacityAllocationEngineTest.php +108 -0
  216. package/server/tests/LiveOrderQueryTest.php +34 -0
  217. package/server/tests/MetricsRegistryTest.php +88 -0
  218. package/server/tests/OrchestrationPayloadBuilderTest.php +132 -0
  219. package/server/tests/OrchestratorConsumableApiTest.php +74 -0
  220. package/server/tests/PingDriverEndpointTest.php +19 -0
  221. package/server/tests/TrackingIntelligenceTest.php +176 -0
  222. package/server/tests/VroomOrchestrationEngineTest.php +250 -0
  223. package/translations/en-us.yaml +41 -0
@@ -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 = '© OpenStreetMap contributors © 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
+ }
@@ -0,0 +1,45 @@
1
+ <div class="fleet-ops-widget fleet-ops-maintenance-overview 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.maintenance-overview.title"}}</span>
4
+ <FaIcon @icon="wrench" class="text-gray-400" />
5
+ </div>
6
+
7
+ <div class="flex-1 flex flex-col p-2.5 border border-t-0 dark:border-gray-700 border-gray-200 rounded-b-md bg-white dark:bg-gray-900 min-h-0">
8
+ {{#if this.load.isRunning}}
9
+ <div class="flex items-center justify-center h-full"><Spinner /></div>
10
+ {{else if this.error}}
11
+ <div class="flex items-center justify-center h-full text-xs text-red-500">{{this.error}}</div>
12
+ {{else if this.data}}
13
+ <div class="grid grid-cols-3 gap-1.5 mb-2">
14
+ <div class="rounded-md border dark:border-gray-700 border-gray-200 p-1.5 text-center">
15
+ <div class="text-[9px] uppercase text-gray-500 dark:text-gray-400 leading-none">Overdue</div>
16
+ <div class="text-lg font-bold leading-tight mt-0.5 {{if this.data.overdue 'text-red-500' 'text-gray-400'}}">{{this.data.overdue}}</div>
17
+ </div>
18
+ <div class="rounded-md border dark:border-gray-700 border-gray-200 p-1.5 text-center">
19
+ <div class="text-[9px] uppercase text-gray-500 dark:text-gray-400 leading-none">Next 7d</div>
20
+ <div class="text-lg font-bold text-amber-500 leading-tight mt-0.5">{{this.data.scheduled_next_7d}}</div>
21
+ </div>
22
+ <div class="rounded-md border dark:border-gray-700 border-gray-200 p-1.5 text-center">
23
+ <div class="text-[9px] uppercase text-gray-500 dark:text-gray-400 leading-none">MTD</div>
24
+ <div class="text-sm font-bold text-black dark:text-gray-100 leading-tight mt-0.5 truncate">{{format-currency this.data.cost_this_month this.data.currency}}</div>
25
+ </div>
26
+ </div>
27
+
28
+ <div class="flex-1 min-h-0 overflow-auto divide-y dark:divide-gray-700 border-t dark:border-gray-700 pt-1">
29
+ {{#each this.data.upcoming as |item|}}
30
+ <div class="flex items-center justify-between py-1 hover:bg-gray-50 dark:hover:bg-gray-800 px-1">
31
+ <div class="min-w-0 flex-1">
32
+ <div class="text-[11px] font-medium text-black dark:text-gray-100 truncate">{{or item.type "Maintenance"}}</div>
33
+ <div class="text-[10px] text-gray-500 dark:text-gray-400">{{format-date-fns item.scheduled_at "MMM d"}}</div>
34
+ </div>
35
+ {{#if item.priority}}
36
+ <Badge @status={{this.priorityStatus item.priority}} @text={{item.priority}} @hideIcon={{true}} @wrapperClass="text-[9px] px-1 py-0 ml-2" />
37
+ {{/if}}
38
+ </div>
39
+ {{else}}
40
+ <div class="text-[10px] text-gray-500 text-center py-3">No upcoming maintenance.</div>
41
+ {{/each}}
42
+ </div>
43
+ {{/if}}
44
+ </div>
45
+ </div>
@@ -0,0 +1,40 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { inject as service } from '@ember/service';
4
+ import { task } from 'ember-concurrency';
5
+
6
+ export default class WidgetMaintenanceOverviewComponent extends Component {
7
+ static widgetId = 'fleet-ops-maintenance-overview-widget';
8
+
9
+ @service fetch;
10
+
11
+ @tracked data = null;
12
+ @tracked error = null;
13
+
14
+ constructor() {
15
+ super(...arguments);
16
+ this.load.perform();
17
+ }
18
+
19
+ priorityStatus(priority) {
20
+ switch (priority) {
21
+ case 'high':
22
+ case 'urgent':
23
+ return 'danger';
24
+ case 'medium':
25
+ case 'normal':
26
+ return 'warning';
27
+ default:
28
+ return 'info';
29
+ }
30
+ }
31
+
32
+ @task *load() {
33
+ try {
34
+ this.data = yield this.fetch.get('fleet-ops/analytics/maintenance-overview');
35
+ this.error = null;
36
+ } catch (e) {
37
+ this.error = e?.message ?? 'Failed to load maintenance overview';
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,27 @@
1
+ <div class="fleet-ops-widget fleet-ops-on-time-delivery flex flex-col h-full rounded-md border dark:border-gray-700 border-gray-200 dark:bg-gray-800 bg-white shadow-sm {{@class}}" ...attributes>
2
+ <div class="flex items-center justify-between px-2.5 pt-2">
3
+ <span class="text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400 font-semibold">
4
+ {{t "widget.on-time-delivery.title"}}
5
+ </span>
6
+ <FaIcon @icon="clock" class="text-gray-400 text-[11px]" />
7
+ </div>
8
+
9
+ <div class="flex-1 flex flex-col items-center justify-center px-2.5 pb-2 min-h-0">
10
+ {{#if this.load.isRunning}}
11
+ <Spinner />
12
+ {{else if this.error}}
13
+ <div class="text-[10px] text-red-500">{{this.error}}</div>
14
+ {{else if this.data}}
15
+ <div class="relative w-full max-w-24 aspect-square">
16
+ <Chart @type="doughnut" @labels={{this.chartLabels}} @datasets={{this.chartDatasets}} @options={{this.chartOptions}} />
17
+ <div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
18
+ <span class="text-base font-bold text-black dark:text-gray-100 leading-none">{{this.data.on_time_pct}}%</span>
19
+ <span class="text-[9px] text-gray-500 dark:text-gray-400 mt-0.5">{{this.data.on_time}}/{{this.data.total}}</span>
20
+ </div>
21
+ </div>
22
+ {{#if this.deltaStatus}}
23
+ <Badge @status={{this.deltaStatus}} @text={{this.deltaText}} @hideIcon={{true}} @disableHumanize={{true}} @wrapperClass="mt-1 text-[9px] px-1 py-0" />
24
+ {{/if}}
25
+ {{/if}}
26
+ </div>
27
+ </div>
@@ -0,0 +1,73 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { inject as service } from '@ember/service';
4
+ import { task } from 'ember-concurrency';
5
+
6
+ export default class WidgetOnTimeDeliveryComponent extends Component {
7
+ static widgetId = 'fleet-ops-on-time-delivery-widget';
8
+
9
+ @service fetch;
10
+
11
+ @tracked data = null;
12
+ @tracked error = null;
13
+
14
+ constructor() {
15
+ super(...arguments);
16
+ this.load.perform();
17
+ }
18
+
19
+ get gaugeColor() {
20
+ const pct = this.data?.on_time_pct ?? 0;
21
+ if (pct >= 95) return '#22c55e';
22
+ if (pct >= 85) return '#84cc16';
23
+ if (pct >= 70) return '#f59e0b';
24
+ return '#ef4444';
25
+ }
26
+
27
+ get chartLabels() {
28
+ return ['On Time', 'Late'];
29
+ }
30
+
31
+ get chartDatasets() {
32
+ return [
33
+ {
34
+ data: [this.data?.on_time ?? 0, this.data?.late ?? 0],
35
+ backgroundColor: [this.gaugeColor, 'rgba(148,163,184,0.25)'],
36
+ borderWidth: 0,
37
+ },
38
+ ];
39
+ }
40
+
41
+ get chartOptions() {
42
+ return {
43
+ responsive: true,
44
+ maintainAspectRatio: false,
45
+ cutout: '75%',
46
+ plugins: {
47
+ legend: { display: false },
48
+ tooltip: { enabled: true },
49
+ },
50
+ };
51
+ }
52
+
53
+ get deltaStatus() {
54
+ const d = this.data?.delta_pct;
55
+ if (typeof d !== 'number' || d === 0) return null;
56
+ return d > 0 ? 'success' : 'danger';
57
+ }
58
+
59
+ get deltaText() {
60
+ const d = this.data?.delta_pct;
61
+ if (typeof d !== 'number') return null;
62
+ return `${d > 0 ? '+' : ''}${d} pp`;
63
+ }
64
+
65
+ @task *load() {
66
+ try {
67
+ this.data = yield this.fetch.get('fleet-ops/analytics/on-time-delivery', { period: '30d' });
68
+ this.error = null;
69
+ } catch (e) {
70
+ this.error = e?.message ?? 'Failed to load on-time delivery';
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,51 @@
1
+ <div class="fleet-ops-widget fleet-ops-operations-pulse 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">
4
+ {{t "widget.operations-pulse.title"}}
5
+ </span>
6
+ <button type="button" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-[10px]" {{on "click" this.refresh}} title={{t "widget.refresh"}}>
7
+ <FaIcon @icon="rotate" class={{if this.load.isRunning "animate-spin"}} />
8
+ </button>
9
+ </div>
10
+
11
+ <div class="flex-1 p-2.5 border border-t-0 dark:border-gray-700 border-gray-200 rounded-b-md bg-white dark:bg-gray-900">
12
+ {{#if this.load.isRunning}}
13
+ <div class="flex items-center justify-center h-full">
14
+ <Spinner />
15
+ </div>
16
+ {{else if this.error}}
17
+ <div class="flex items-center justify-center h-full text-xs text-red-500">{{this.error}}</div>
18
+ {{else}}
19
+ <div class="grid grid-cols-2 lg:grid-cols-5 gap-2 h-full">
20
+ {{#each this.tiles as |tile|}}
21
+ <div class="fleet-ops-pulse-tile {{tile.meta.accent}} relative rounded-md border p-2 flex flex-col justify-between min-h-0 overflow-hidden">
22
+ {{!-- Accent stripe along the left edge for instant status read --}}
23
+ <span class="fleet-ops-pulse-stripe absolute inset-y-0 left-0"></span>
24
+
25
+ <div class="flex items-center justify-between relative z-10">
26
+ <span class="text-[9px] uppercase tracking-wider text-gray-600 dark:text-gray-300 font-bold truncate">{{tile.title}}</span>
27
+ {{#if tile.meta.icon}}
28
+ <div class="fleet-ops-pulse-icon flex-shrink-0">
29
+ <FaIcon @icon={{tile.meta.icon}} class="text-[10px]" />
30
+ </div>
31
+ {{/if}}
32
+ </div>
33
+
34
+ <div class="flex items-baseline gap-1 mt-1 relative z-10 fleet-ops-pulse-value-row">
35
+ <span class="fleet-ops-pulse-value text-2xl font-bold leading-none tabular-nums">{{tile.value}}</span>
36
+ {{#if tile.of}}
37
+ <span class="text-[10px] text-gray-500 dark:text-gray-400">/ {{tile.of}}</span>
38
+ {{/if}}
39
+ </div>
40
+
41
+ {{#let (this.deltaStatus tile) as |status|}}
42
+ {{#if status}}
43
+ <Badge @status={{status}} @text={{this.deltaText tile}} @hideIcon={{true}} @disableHumanize={{true}} @wrapperClass="text-[9px] py-0 px-1 leading-tight mt-1 self-start font-bold relative z-10" />
44
+ {{/if}}
45
+ {{/let}}
46
+ </div>
47
+ {{/each}}
48
+ </div>
49
+ {{/if}}
50
+ </div>
51
+ </div>