@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,173 @@
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 { debug } from '@ember/debug';
6
+ import { task } from 'ember-concurrency';
7
+
8
+ const TILE_ORDER = ['active_orders', 'completed_today', 'drivers_online', 'vehicles_deployed', 'issues_open'];
9
+
10
+ /**
11
+ * Each tile gets:
12
+ * - icon: FA icon name + colored chip
13
+ * - accent: CSS class on the tile root for the gradient + accent stripe palette
14
+ * - deltaInverse: flip success/danger semantics (for "lower-is-better" metrics)
15
+ */
16
+ const TILE_META = {
17
+ active_orders: { icon: 'bolt', accent: 'pulse-accent-blue' },
18
+ completed_today: { icon: 'circle-check', accent: 'pulse-accent-green' },
19
+ drivers_online: { icon: 'id-card', accent: 'pulse-accent-sky' },
20
+ vehicles_deployed: { icon: 'truck', accent: 'pulse-accent-indigo' },
21
+ issues_open: { icon: 'triangle-exclamation', accent: 'pulse-accent-amber', deltaInverse: true },
22
+ };
23
+
24
+ /** Refetch the full snapshot this often as a reconciliation guard against
25
+ * drift between incrementally-applied socket deltas and authoritative state. */
26
+ const RECONCILE_INTERVAL_MS = 60_000;
27
+
28
+ /**
29
+ * Live operational snapshot. Initial GET seeds the tiles, then subscribes to
30
+ * `company.{uuid}` and increments/decrements counters on order.* events
31
+ * without polling. Reconciles via a full refetch every 60s.
32
+ */
33
+ export default class WidgetOperationsPulseComponent extends Component {
34
+ static widgetId = 'fleet-ops-operations-pulse-widget';
35
+
36
+ @service fetch;
37
+ @service socket;
38
+ @service currentUser;
39
+
40
+ @tracked data = null;
41
+ @tracked error = null;
42
+
43
+ socketChannel = null;
44
+ socketActive = false;
45
+ reconcileTimer = null;
46
+
47
+ constructor() {
48
+ super(...arguments);
49
+ this.load.perform();
50
+ this.subscribe();
51
+ this.reconcileTimer = setInterval(() => this.load.perform(), RECONCILE_INTERVAL_MS);
52
+ }
53
+
54
+ willDestroy() {
55
+ super.willDestroy(...arguments);
56
+ this.unsubscribe();
57
+ if (this.reconcileTimer) clearInterval(this.reconcileTimer);
58
+ }
59
+
60
+ get tiles() {
61
+ if (!this.data) return [];
62
+ return TILE_ORDER.filter((key) => this.data[key]).map((key) => ({
63
+ key,
64
+ title: this.titleFor(key),
65
+ ...this.data[key],
66
+ meta: TILE_META[key] ?? {},
67
+ }));
68
+ }
69
+
70
+ titleFor(key) {
71
+ return key
72
+ .split('_')
73
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
74
+ .join(' ');
75
+ }
76
+
77
+ deltaStatus(tile) {
78
+ if (typeof tile.delta_pct !== 'number' || tile.delta_pct === 0) return null;
79
+ const positive = tile.delta_pct > 0;
80
+ const isGood = tile.meta?.deltaInverse ? !positive : positive;
81
+ return isGood ? 'success' : 'danger';
82
+ }
83
+
84
+ deltaText(tile) {
85
+ if (typeof tile.delta_pct !== 'number') return null;
86
+ const sign = tile.delta_pct > 0 ? '+' : '';
87
+ return `${sign}${tile.delta_pct}%`;
88
+ }
89
+
90
+ @task *load() {
91
+ try {
92
+ this.data = yield this.fetch.get('fleet-ops/analytics/operations-pulse');
93
+ this.error = null;
94
+ } catch (e) {
95
+ this.error = e?.message ?? 'Failed to load operations pulse';
96
+ }
97
+ }
98
+
99
+ @action
100
+ refresh() {
101
+ this.load.perform();
102
+ }
103
+
104
+ async subscribe() {
105
+ const companyId = this.currentUser?.companyId;
106
+ if (!companyId) return;
107
+
108
+ try {
109
+ const sc = this.socket.instance();
110
+ const channel = sc.subscribe(`company.${companyId}`);
111
+ this.socketChannel = channel;
112
+ this.socketActive = true;
113
+
114
+ if (channel.state !== 'subscribed') {
115
+ await channel.subscribe();
116
+ }
117
+
118
+ for await (const msg of channel) {
119
+ if (!this.socketActive) break;
120
+ this.applyEvent(msg);
121
+ }
122
+ } catch (e) {
123
+ debug(`[operations-pulse] socket error: ${e?.message ?? e}`);
124
+ }
125
+ }
126
+
127
+ async unsubscribe() {
128
+ this.socketActive = false;
129
+ try {
130
+ await this.socketChannel?.unsubscribe();
131
+ } catch (e) {
132
+ debug(`[operations-pulse] unsubscribe error: ${e?.message ?? e}`);
133
+ }
134
+ this.socketChannel = null;
135
+ }
136
+
137
+ applyEvent(msg) {
138
+ if (!this.data || !msg) return;
139
+ const event = msg.event;
140
+ const next = { ...this.data };
141
+
142
+ const adjust = (key, delta) => {
143
+ if (!next[key]) return;
144
+ next[key] = { ...next[key], value: Math.max(0, (next[key].value ?? 0) + delta) };
145
+ };
146
+
147
+ switch (event) {
148
+ case 'order.created':
149
+ case 'order.dispatched':
150
+ case 'order.driver_assigned':
151
+ adjust('active_orders', +1);
152
+ break;
153
+ case 'order.completed':
154
+ adjust('active_orders', -1);
155
+ adjust('completed_today', +1);
156
+ break;
157
+ case 'order.canceled':
158
+ case 'order.failed':
159
+ adjust('active_orders', -1);
160
+ break;
161
+ case 'issue.created':
162
+ adjust('issues_open', +1);
163
+ break;
164
+ case 'issue.resolved':
165
+ adjust('issues_open', -1);
166
+ break;
167
+ default:
168
+ return;
169
+ }
170
+
171
+ this.data = next;
172
+ }
173
+ }
@@ -0,0 +1,26 @@
1
+ <div class="fleet-ops-widget fleet-ops-orders-by-status 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.orders-by-status.title"}}</span>
4
+ <div class="flex gap-0.5">
5
+ {{#each this.periods as |p|}}
6
+ <button type="button"
7
+ class="text-[10px] px-1.5 py-0.5 rounded {{if (eq this.period p) 'bg-sky-600 text-white' 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-200'}}"
8
+ {{on "click" (fn this.setPeriod p)}}>
9
+ {{p}}
10
+ </button>
11
+ {{/each}}
12
+ </div>
13
+ </div>
14
+
15
+ <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 min-h-0">
16
+ {{#if this.load.isRunning}}
17
+ <div class="flex items-center justify-center h-full"><Spinner /></div>
18
+ {{else if this.error}}
19
+ <div class="flex items-center justify-center h-full text-xs text-red-500">{{this.error}}</div>
20
+ {{else if this.data}}
21
+ <div class="h-full">
22
+ <Chart @type="bar" @labels={{this.data.labels}} @datasets={{this.data.datasets}} @options={{this.chartOptions}} />
23
+ </div>
24
+ {{/if}}
25
+ </div>
26
+ </div>
@@ -0,0 +1,54 @@
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
+ const PERIODS = ['7d', '14d', '30d'];
8
+
9
+ export default class WidgetOrdersByStatusComponent extends Component {
10
+ static widgetId = 'fleet-ops-orders-by-status-widget';
11
+
12
+ @service fetch;
13
+
14
+ @tracked period = '14d';
15
+ @tracked data = null;
16
+ @tracked error = null;
17
+
18
+ periods = PERIODS;
19
+
20
+ constructor() {
21
+ super(...arguments);
22
+ this.load.perform();
23
+ }
24
+
25
+ get chartOptions() {
26
+ return {
27
+ responsive: true,
28
+ maintainAspectRatio: false,
29
+ plugins: {
30
+ legend: { position: 'bottom', labels: { boxWidth: 12, padding: 8 } },
31
+ tooltip: { mode: 'index', intersect: false },
32
+ },
33
+ scales: {
34
+ x: { stacked: true, grid: { display: false } },
35
+ y: { stacked: true, beginAtZero: true, ticks: { precision: 0 } },
36
+ },
37
+ };
38
+ }
39
+
40
+ @task *load() {
41
+ try {
42
+ this.data = yield this.fetch.get('fleet-ops/analytics/orders-by-status', { period: this.period });
43
+ this.error = null;
44
+ } catch (e) {
45
+ this.error = e?.message ?? 'Failed to load orders breakdown';
46
+ }
47
+ }
48
+
49
+ @action
50
+ setPeriod(period) {
51
+ this.period = period;
52
+ this.load.perform();
53
+ }
54
+ }
@@ -0,0 +1,34 @@
1
+ <div class="fleet-ops-widget fleet-ops-revenue-trend 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.revenue-trend.title"}}</span>
4
+ <div class="flex gap-0.5">
5
+ {{#each this.periods as |p|}}
6
+ <button type="button"
7
+ class="text-[10px] px-1.5 py-0.5 rounded {{if (eq this.period p) 'bg-sky-600 text-white' 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-200'}}"
8
+ {{on "click" (fn this.setPeriod p)}}>
9
+ {{p}}
10
+ </button>
11
+ {{/each}}
12
+ </div>
13
+ </div>
14
+
15
+ <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">
16
+ {{#if this.load.isRunning}}
17
+ <div class="flex items-center justify-center h-full"><Spinner /></div>
18
+ {{else if this.error}}
19
+ <div class="flex items-center justify-center h-full text-xs text-red-500">{{this.error}}</div>
20
+ {{else if this.data}}
21
+ <div class="flex items-baseline gap-2 mb-1.5">
22
+ <span class="text-base font-bold text-black dark:text-gray-100 leading-none">
23
+ {{format-currency this.data.summary.total this.data.summary.currency}}
24
+ </span>
25
+ {{#if this.deltaStatus}}
26
+ <Badge @status={{this.deltaStatus}} @text={{this.deltaText}} @hideIcon={{true}} @disableHumanize={{true}} @wrapperClass="text-[9px] px-1 py-0" />
27
+ {{/if}}
28
+ </div>
29
+ <div class="flex-1 min-h-0">
30
+ <Chart @type="line" @labels={{this.data.labels}} @datasets={{this.data.datasets}} @options={{this.chartOptions}} />
31
+ </div>
32
+ {{/if}}
33
+ </div>
34
+ </div>
@@ -0,0 +1,67 @@
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
+ const PERIODS = ['7d', '30d', '90d'];
8
+
9
+ export default class WidgetRevenueTrendComponent extends Component {
10
+ static widgetId = 'fleet-ops-revenue-trend-widget';
11
+
12
+ @service fetch;
13
+
14
+ @tracked period = '30d';
15
+ @tracked data = null;
16
+ @tracked error = null;
17
+
18
+ periods = PERIODS;
19
+
20
+ constructor() {
21
+ super(...arguments);
22
+ this.load.perform();
23
+ }
24
+
25
+ get chartOptions() {
26
+ return {
27
+ responsive: true,
28
+ maintainAspectRatio: false,
29
+ plugins: {
30
+ legend: { display: false },
31
+ tooltip: { mode: 'index', intersect: false },
32
+ },
33
+ scales: {
34
+ x: { grid: { display: false } },
35
+ y: { beginAtZero: true, ticks: { precision: 0 } },
36
+ },
37
+ elements: { point: { radius: 0, hoverRadius: 4 } },
38
+ };
39
+ }
40
+
41
+ get deltaStatus() {
42
+ const pct = this.data?.summary?.delta_pct;
43
+ if (typeof pct !== 'number' || pct === 0) return null;
44
+ return pct > 0 ? 'success' : 'danger';
45
+ }
46
+
47
+ get deltaText() {
48
+ const pct = this.data?.summary?.delta_pct;
49
+ if (typeof pct !== 'number') return null;
50
+ return `${pct > 0 ? '+' : ''}${pct}%`;
51
+ }
52
+
53
+ @task *load() {
54
+ try {
55
+ this.data = yield this.fetch.get('fleet-ops/analytics/revenue-trend', { period: this.period });
56
+ this.error = null;
57
+ } catch (e) {
58
+ this.error = e?.message ?? 'Failed to load revenue trend';
59
+ }
60
+ }
61
+
62
+ @action
63
+ setPeriod(period) {
64
+ this.period = period;
65
+ this.load.perform();
66
+ }
67
+ }
@@ -0,0 +1,44 @@
1
+ <div class="fleet-ops-widget fleet-ops-top-drivers 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.top-drivers.title"}}</span>
4
+ <div class="flex gap-0.5">
5
+ {{#each this.sorts as |s|}}
6
+ <button type="button"
7
+ class="text-[10px] px-1.5 py-0.5 rounded {{if (eq this.sortBy s.key) 'bg-sky-600 text-white' 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-200'}}"
8
+ {{on "click" (fn this.setSort s.key)}}>
9
+ {{s.label}}
10
+ </button>
11
+ {{/each}}
12
+ </div>
13
+ </div>
14
+
15
+ <div class="flex-1 border border-t-0 dark:border-gray-700 border-gray-200 rounded-b-md bg-white dark:bg-gray-900 overflow-auto">
16
+ {{#if this.load.isRunning}}
17
+ <div class="flex items-center justify-center h-full"><Spinner /></div>
18
+ {{else if this.error}}
19
+ <div class="flex items-center justify-center h-full text-xs text-red-500">{{this.error}}</div>
20
+ {{else if this.data.rows.length}}
21
+ <div class="divide-y dark:divide-gray-700">
22
+ {{#each this.data.rows as |row index|}}
23
+ <div class="flex items-center gap-2 px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800">
24
+ <span class="text-[11px] font-bold text-gray-400 w-3 text-right">{{add index 1}}</span>
25
+ <div class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[9px] font-semibold text-gray-600 dark:text-gray-300 flex-shrink-0">
26
+ {{first-char row.name}}
27
+ </div>
28
+ <div class="flex-1 min-w-0">
29
+ <div class="text-[11px] font-medium text-black dark:text-gray-100 truncate leading-tight">{{row.name}}</div>
30
+ <div class="text-[10px] text-gray-500 dark:text-gray-400 truncate leading-tight">
31
+ {{row.orders_completed}} orders &middot; {{format-meters row.distance_m}}
32
+ </div>
33
+ </div>
34
+ {{#if row.on_time_pct}}
35
+ <Badge @status={{this.onTimeStatus row.on_time_pct}} @text={{concat row.on_time_pct "%"}} @hideIcon={{true}} @disableHumanize={{true}} @wrapperClass="text-[9px] px-1 py-0" />
36
+ {{/if}}
37
+ </div>
38
+ {{/each}}
39
+ </div>
40
+ {{else}}
41
+ <div class="flex items-center justify-center h-full text-[10px] text-gray-500">No driver activity in this period.</div>
42
+ {{/if}}
43
+ </div>
44
+ </div>
@@ -0,0 +1,54 @@
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
+ const SORTS = [
8
+ { key: 'orders_completed', label: 'Orders' },
9
+ { key: 'on_time', label: 'On-time' },
10
+ { key: 'distance', label: 'Distance' },
11
+ ];
12
+
13
+ export default class WidgetTopDriversComponent extends Component {
14
+ static widgetId = 'fleet-ops-top-drivers-widget';
15
+
16
+ @service fetch;
17
+
18
+ @tracked sortBy = 'orders_completed';
19
+ @tracked data = null;
20
+ @tracked error = null;
21
+
22
+ sorts = SORTS;
23
+
24
+ constructor() {
25
+ super(...arguments);
26
+ this.load.perform();
27
+ }
28
+
29
+ @task *load() {
30
+ try {
31
+ this.data = yield this.fetch.get('fleet-ops/analytics/top-drivers', {
32
+ period: '30d',
33
+ limit: 10,
34
+ sort_by: this.sortBy,
35
+ });
36
+ this.error = null;
37
+ } catch (e) {
38
+ this.error = e?.message ?? 'Failed to load top drivers';
39
+ }
40
+ }
41
+
42
+ @action
43
+ setSort(key) {
44
+ this.sortBy = key;
45
+ this.load.perform();
46
+ }
47
+
48
+ onTimeStatus(pct) {
49
+ if (typeof pct !== 'number') return 'info';
50
+ if (pct >= 95) return 'success';
51
+ if (pct >= 85) return 'warning';
52
+ return 'danger';
53
+ }
54
+ }
@@ -1,6 +1,7 @@
1
1
  import Controller from '@ember/controller';
2
2
  import { tracked } from '@glimmer/tracking';
3
3
  import { inject as service } from '@ember/service';
4
+ import { action } from '@ember/object';
4
5
  import { task } from 'ember-concurrency';
5
6
 
6
7
  export default class SettingsRoutingController extends Controller {
@@ -12,6 +13,25 @@ export default class SettingsRoutingController extends Controller {
12
13
  @tracked displayEngine = 'osrm';
13
14
  @tracked optimizationEngine = 'osrm';
14
15
  @tracked routingUnit = 'km';
16
+ @tracked trackingProvider = 'google_routes';
17
+ @tracked trackingFallbacks = ['osrm', 'calculated'];
18
+ @tracked trackingTrafficEnabled = true;
19
+ @tracked trackingCacheTtlSeconds = 60;
20
+ @tracked trackingRouteCacheTtlSeconds = 600;
21
+ @tracked trackingStaleLocationThresholdSeconds = 300;
22
+ @tracked trackingDefaultVehicleSpeedKph = 35;
23
+ @tracked lateDepartureAlertsEnabled = true;
24
+ @tracked lateDepartureGracePeriodMinutes = 15;
25
+ @tracked routeDeviationAlertsEnabled = true;
26
+ @tracked routeDeviationDistanceThresholdMeters = 500;
27
+ @tracked prolongedStoppageAlertsEnabled = true;
28
+ @tracked prolongedStoppageDurationThresholdMinutes = 30;
29
+ @tracked showTrackingAdvancedSettings = false;
30
+ @tracked trackingProviderOptions = [
31
+ { value: 'google_routes', label: 'Google Routes' },
32
+ { value: 'osrm', label: 'OSRM' },
33
+ { value: 'calculated', label: 'Calculated' },
34
+ ];
15
35
  @tracked routingUnitOptions = [
16
36
  { label: 'Kilometers', value: 'km' },
17
37
  { label: 'Miles', value: 'mi' },
@@ -39,6 +59,8 @@ export default class SettingsRoutingController extends Controller {
39
59
  optimization_engine: this.optimizationEngine,
40
60
  unit: this.routingUnit,
41
61
  });
62
+ const trackingSettings = this.trackingSettingsPayload;
63
+ yield this.fetch.post('fleet-ops/settings/tracking-settings', trackingSettings);
42
64
  yield this.performAdditionalSaveTasks();
43
65
  // Save in local memory too
44
66
  this.currentUser.setOption('routing', {
@@ -47,7 +69,8 @@ export default class SettingsRoutingController extends Controller {
47
69
  routing_optimization_engine: this.optimizationEngine,
48
70
  unit: this.routingUnit,
49
71
  });
50
- this.notifications.success('Routing setting saved.');
72
+ this.currentUser.setOption('tracking', trackingSettings);
73
+ this.notifications.success('Routing and tracking settings saved.');
51
74
  } catch (error) {
52
75
  this.notifications.serverError(error);
53
76
  }
@@ -64,11 +87,111 @@ export default class SettingsRoutingController extends Controller {
64
87
  this.displayEngine = display_engine ?? router ?? 'osrm';
65
88
  this.optimizationEngine = optimization_engine ?? display_engine ?? router ?? 'osrm';
66
89
  this.routingUnit = unit;
90
+ const trackingSettings = yield this.fetch.get('fleet-ops/settings/tracking-settings');
91
+ this.trackingProvider = trackingSettings.provider ?? 'google_routes';
92
+ this.trackingFallbacks = this.normalizeFallbacks(trackingSettings.fallbacks);
93
+ this.trackingTrafficEnabled = trackingSettings.traffic_enabled ?? true;
94
+ this.trackingCacheTtlSeconds = trackingSettings.cache_ttl_seconds ?? 60;
95
+ this.trackingRouteCacheTtlSeconds = trackingSettings.route_cache_ttl_seconds ?? 600;
96
+ this.trackingStaleLocationThresholdSeconds = trackingSettings.stale_location_threshold_seconds ?? 300;
97
+ this.trackingDefaultVehicleSpeedKph = trackingSettings.default_vehicle_speed_kph ?? 35;
98
+ this.lateDepartureAlertsEnabled = trackingSettings.alerts?.late_departures?.enabled ?? true;
99
+ this.lateDepartureGracePeriodMinutes = trackingSettings.alerts?.late_departures?.grace_period_minutes ?? 15;
100
+ this.routeDeviationAlertsEnabled = trackingSettings.alerts?.route_deviations?.enabled ?? true;
101
+ this.routeDeviationDistanceThresholdMeters = trackingSettings.alerts?.route_deviations?.distance_threshold_meters ?? 500;
102
+ this.prolongedStoppageAlertsEnabled = trackingSettings.alerts?.prolonged_stoppages?.enabled ?? true;
103
+ this.prolongedStoppageDurationThresholdMinutes = trackingSettings.alerts?.prolonged_stoppages?.duration_threshold_minutes ?? 30;
104
+ this.trackingProviderOptions = this.normalizeProviderOptions(trackingSettings.providers ?? this.trackingProviderOptions);
67
105
  } catch (error) {
68
106
  this.notifications.serverError(error);
69
107
  }
70
108
  }
71
109
 
110
+ get selectedTrackingFallbackOptions() {
111
+ const selected = new Set(this.normalizeFallbacks(this.trackingFallbacks));
112
+
113
+ return this.trackingProviderOptions.filter((option) => selected.has(this.optionValue(option)));
114
+ }
115
+
116
+ get trackingSettingsPayload() {
117
+ return {
118
+ provider: this.trackingProvider,
119
+ fallbacks: this.normalizeFallbacks(this.trackingFallbacks),
120
+ traffic_enabled: this.trackingTrafficEnabled,
121
+ cache_ttl_seconds: Number(this.trackingCacheTtlSeconds) || 60,
122
+ route_cache_ttl_seconds: Number(this.trackingRouteCacheTtlSeconds) || 600,
123
+ stale_location_threshold_seconds: Number(this.trackingStaleLocationThresholdSeconds) || 300,
124
+ default_vehicle_speed_kph: Number(this.trackingDefaultVehicleSpeedKph) || 35,
125
+ alerts: {
126
+ late_departures: {
127
+ enabled: this.lateDepartureAlertsEnabled,
128
+ grace_period_minutes: Number(this.lateDepartureGracePeriodMinutes) || 15,
129
+ },
130
+ route_deviations: {
131
+ enabled: this.routeDeviationAlertsEnabled,
132
+ distance_threshold_meters: Number(this.routeDeviationDistanceThresholdMeters) || 500,
133
+ },
134
+ prolonged_stoppages: {
135
+ enabled: this.prolongedStoppageAlertsEnabled,
136
+ duration_threshold_minutes: Number(this.prolongedStoppageDurationThresholdMinutes) || 30,
137
+ },
138
+ },
139
+ };
140
+ }
141
+
142
+ normalizeFallbacks(fallbacks) {
143
+ if (Array.isArray(fallbacks)) {
144
+ return fallbacks
145
+ .map((fallback) => this.optionValue(fallback))
146
+ .map((fallback) => String(fallback).trim())
147
+ .filter(Boolean);
148
+ }
149
+
150
+ return String(fallbacks ?? '')
151
+ .split(',')
152
+ .map((fallback) => fallback.trim())
153
+ .filter(Boolean);
154
+ }
155
+
156
+ normalizeProviderOptions(options = []) {
157
+ return options.map((option) => {
158
+ const value = this.optionValue(option);
159
+ const label = option?.label ?? option?.name ?? this.providerLabel(value);
160
+
161
+ return {
162
+ ...option,
163
+ key: option?.key ?? value,
164
+ name: option?.name ?? label,
165
+ value,
166
+ label,
167
+ };
168
+ });
169
+ }
170
+
171
+ providerLabel(value) {
172
+ if (value === 'osrm') {
173
+ return 'OSRM';
174
+ }
175
+
176
+ return String(value ?? '')
177
+ .replace(/[_-]+/g, ' ')
178
+ .replace(/\s+/g, ' ')
179
+ .trim()
180
+ .replace(/\b\w/g, (character) => character.toUpperCase());
181
+ }
182
+
183
+ optionValue(option) {
184
+ return typeof option === 'object' && option !== null ? (option.value ?? option.key) : option;
185
+ }
186
+
187
+ @action setTrackingFallbacks(options) {
188
+ this.trackingFallbacks = this.normalizeFallbacks(options);
189
+ }
190
+
191
+ @action toggleTrackingAdvancedSettings() {
192
+ this.showTrackingAdvancedSettings = !this.showTrackingAdvancedSettings;
193
+ }
194
+
72
195
  registerSaveTask(key, task) {
73
196
  if (!key || typeof task?.perform !== 'function') {
74
197
  return;