@fleetbase/fleetops-engine 0.6.48 → 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 (151) hide show
  1. package/addon/components/driver/details.hbs +5 -1
  2. package/addon/components/driver/form.hbs +11 -1
  3. package/addon/components/fleet-listing-panel.hbs +2 -2
  4. package/addon/components/fleet-listing-panel.js +10 -0
  5. package/addon/components/map/google-live-map.js +12 -73
  6. package/addon/components/map/leaflet-live-map.hbs +149 -33
  7. package/addon/components/orchestrator/phase-builder.hbs +21 -0
  8. package/addon/components/orchestrator/phase-builder.js +13 -0
  9. package/addon/components/orchestrator-workbench.js +1 -0
  10. package/addon/components/order/form.hbs +2 -2
  11. package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
  12. package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
  13. package/addon/components/widget/fuel-efficiency.hbs +26 -0
  14. package/addon/components/widget/fuel-efficiency.js +43 -0
  15. package/addon/components/widget/geofence-violations.hbs +44 -0
  16. package/addon/components/widget/geofence-violations.js +55 -0
  17. package/addon/components/widget/issues-insights.hbs +43 -0
  18. package/addon/components/widget/issues-insights.js +62 -0
  19. package/addon/components/widget/kpi-active-orders.hbs +10 -0
  20. package/addon/components/widget/kpi-active-orders.js +5 -0
  21. package/addon/components/widget/kpi-aov.hbs +10 -0
  22. package/addon/components/widget/kpi-aov.js +5 -0
  23. package/addon/components/widget/kpi-distance.hbs +10 -0
  24. package/addon/components/widget/kpi-distance.js +5 -0
  25. package/addon/components/widget/kpi-drivers-online.hbs +10 -0
  26. package/addon/components/widget/kpi-drivers-online.js +5 -0
  27. package/addon/components/widget/kpi-earnings.hbs +10 -0
  28. package/addon/components/widget/kpi-earnings.js +5 -0
  29. package/addon/components/widget/kpi-open-issues.hbs +11 -0
  30. package/addon/components/widget/kpi-open-issues.js +5 -0
  31. package/addon/components/widget/kpi-tile.hbs +59 -0
  32. package/addon/components/widget/kpi-tile.js +168 -0
  33. package/addon/components/widget/live-fleet.hbs +122 -0
  34. package/addon/components/widget/live-fleet.js +365 -0
  35. package/addon/components/widget/maintenance-overview.hbs +45 -0
  36. package/addon/components/widget/maintenance-overview.js +40 -0
  37. package/addon/components/widget/on-time-delivery.hbs +27 -0
  38. package/addon/components/widget/on-time-delivery.js +73 -0
  39. package/addon/components/widget/operations-pulse.hbs +51 -0
  40. package/addon/components/widget/operations-pulse.js +173 -0
  41. package/addon/components/widget/orders-by-status.hbs +26 -0
  42. package/addon/components/widget/orders-by-status.js +54 -0
  43. package/addon/components/widget/revenue-trend.hbs +34 -0
  44. package/addon/components/widget/revenue-trend.js +67 -0
  45. package/addon/components/widget/top-drivers.hbs +44 -0
  46. package/addon/components/widget/top-drivers.js +54 -0
  47. package/addon/controllers/settings/routing.js +26 -0
  48. package/addon/extension.js +172 -3
  49. package/addon/services/map-adapter/google.js +13 -2
  50. package/addon/styles/fleetops-engine.css +114 -87
  51. package/addon/templates/maintenance/equipment/index.hbs +1 -1
  52. package/addon/templates/maintenance/parts/index.hbs +1 -1
  53. package/addon/templates/maintenance/schedules/index.hbs +1 -1
  54. package/addon/templates/management/vehicles/index.hbs +1 -1
  55. package/addon/templates/settings/avatars.hbs +1 -1
  56. package/addon/templates/settings/custom-fields.hbs +1 -1
  57. package/addon/templates/settings/map.hbs +1 -1
  58. package/addon/templates/settings/navigator-app.hbs +1 -1
  59. package/addon/templates/settings/notifications.hbs +1 -1
  60. package/addon/templates/settings/orchestrator.hbs +1 -1
  61. package/addon/templates/settings/payments/index.hbs +1 -1
  62. package/addon/templates/settings/payments/onboard.hbs +1 -1
  63. package/addon/templates/settings/routing.hbs +22 -2
  64. package/addon/templates/settings/scheduling.hbs +1 -1
  65. package/addon/utils/live-map-card-content.js +171 -0
  66. package/app/components/widget/fuel-efficiency.js +1 -0
  67. package/app/components/widget/geofence-violations.js +1 -0
  68. package/app/components/widget/issues-insights.js +1 -0
  69. package/app/components/widget/kpi-active-orders.js +1 -0
  70. package/app/components/widget/kpi-aov.js +1 -0
  71. package/app/components/widget/kpi-distance.js +1 -0
  72. package/app/components/widget/kpi-drivers-online.js +1 -0
  73. package/app/components/widget/kpi-earnings.js +1 -0
  74. package/app/components/widget/kpi-open-issues.js +1 -0
  75. package/app/components/widget/kpi-tile.js +1 -0
  76. package/app/components/widget/live-fleet.js +1 -0
  77. package/app/components/widget/maintenance-overview.js +1 -0
  78. package/app/components/widget/on-time-delivery.js +1 -0
  79. package/app/components/widget/operations-pulse.js +1 -0
  80. package/app/components/widget/orders-by-status.js +1 -0
  81. package/app/components/widget/revenue-trend.js +1 -0
  82. package/app/components/widget/top-drivers.js +1 -0
  83. package/app/utils/live-map-card-content.js +1 -0
  84. package/composer.json +1 -1
  85. package/extension.json +1 -1
  86. package/package.json +3 -3
  87. package/pnpm-workspace.yaml +1 -0
  88. package/server/config/telematics.php +111 -1
  89. package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
  90. package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
  91. package/server/seeders/Testing/ConnectivitySeeder.php +212 -0
  92. package/server/seeders/Testing/FleetSeeder.php +341 -0
  93. package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
  94. package/server/seeders/Testing/NetworkSeeder.php +250 -0
  95. package/server/seeders/Testing/OrdersSeeder.php +214 -0
  96. package/server/seeders/Testing/TestingSeeder.php +40 -0
  97. package/server/seeders/{VroomOrchestrationSeeder.php → Testing/VroomOrchestrationSeeder.php} +8 -1
  98. package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
  99. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
  100. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
  101. package/server/src/Http/Controllers/Internal/v1/OrderController.php +12 -6
  102. package/server/src/Http/Controllers/Internal/v1/SettingController.php +46 -1
  103. package/server/src/Http/Resources/v1/Driver.php +2 -0
  104. package/server/src/Http/Resources/v1/Fleet.php +4 -0
  105. package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
  106. package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
  107. package/server/src/Models/Driver.php +2 -0
  108. package/server/src/Models/OrderConfig.php +14 -11
  109. package/server/src/Notifications/LateDeparture.php +48 -0
  110. package/server/src/Notifications/ProlongedStoppage.php +48 -0
  111. package/server/src/Notifications/RouteDeviation.php +48 -0
  112. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +28 -1
  113. package/server/src/Providers/FleetOpsServiceProvider.php +5 -0
  114. package/server/src/Providers/NotificationServiceProvider.php +3 -0
  115. package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
  116. package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
  117. package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
  118. package/server/src/Support/Analytics/IssuesInsights.php +67 -0
  119. package/server/src/Support/Analytics/LiveFleet.php +127 -0
  120. package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
  121. package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
  122. package/server/src/Support/Analytics/OperationsPulse.php +86 -0
  123. package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
  124. package/server/src/Support/Analytics/RevenueTrend.php +79 -0
  125. package/server/src/Support/Analytics/TopDrivers.php +92 -0
  126. package/server/src/Support/Metrics/AbstractMetric.php +170 -0
  127. package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
  128. package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
  129. package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
  130. package/server/src/Support/Metrics/EarningsMetric.php +30 -0
  131. package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
  132. package/server/src/Support/Metrics/MoneyMetric.php +30 -0
  133. package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
  134. package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
  135. package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
  136. package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
  137. package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
  138. package/server/src/Support/Metrics/Registry.php +46 -0
  139. package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
  140. package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
  141. package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
  142. package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
  143. package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
  144. package/server/src/Support/Metrics.php +46 -249
  145. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
  146. package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
  147. package/server/src/routes.php +16 -0
  148. package/server/tests/AnalyticsRoutesTest.php +41 -0
  149. package/server/tests/MetricsRegistryTest.php +88 -0
  150. package/server/tests/VroomOrchestrationEngineTest.php +91 -0
  151. package/translations/en-us.yaml +37 -0
@@ -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>
@@ -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
+ }
@@ -20,6 +20,12 @@ export default class SettingsRoutingController extends Controller {
20
20
  @tracked trackingRouteCacheTtlSeconds = 600;
21
21
  @tracked trackingStaleLocationThresholdSeconds = 300;
22
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;
23
29
  @tracked showTrackingAdvancedSettings = false;
24
30
  @tracked trackingProviderOptions = [
25
31
  { value: 'google_routes', label: 'Google Routes' },
@@ -89,6 +95,12 @@ export default class SettingsRoutingController extends Controller {
89
95
  this.trackingRouteCacheTtlSeconds = trackingSettings.route_cache_ttl_seconds ?? 600;
90
96
  this.trackingStaleLocationThresholdSeconds = trackingSettings.stale_location_threshold_seconds ?? 300;
91
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;
92
104
  this.trackingProviderOptions = this.normalizeProviderOptions(trackingSettings.providers ?? this.trackingProviderOptions);
93
105
  } catch (error) {
94
106
  this.notifications.serverError(error);
@@ -110,6 +122,20 @@ export default class SettingsRoutingController extends Controller {
110
122
  route_cache_ttl_seconds: Number(this.trackingRouteCacheTtlSeconds) || 600,
111
123
  stale_location_threshold_seconds: Number(this.trackingStaleLocationThresholdSeconds) || 300,
112
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
+ },
113
139
  };
114
140
  }
115
141