@fleetbase/fleetops-engine 0.6.43 → 0.6.45
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.
- package/addon/components/entity/form.hbs +24 -0
- package/addon/components/entity/form.js +30 -0
- package/addon/components/leaflet-draw-control.js +1 -1
- package/addon/components/map/drawer/geofence-event-listing.hbs +123 -0
- package/addon/components/map/drawer/geofence-event-listing.js +103 -0
- package/addon/components/map/drawer.js +1 -0
- package/addon/components/map/leaflet-live-map.js +83 -0
- package/addon/components/modals/orchestrator-import.js +2 -78
- package/addon/components/order/form/service-rate.hbs +1 -1
- package/addon/components/service-area/form.hbs +59 -0
- package/addon/components/service-rate/details.hbs +6 -6
- package/addon/components/service-rate/form.hbs +28 -15
- package/addon/components/zone/form.hbs +60 -1
- package/addon/controllers/operations/orders/index/details/virtual.js +5 -0
- package/addon/controllers/operations/orders/index/details.js +27 -2
- package/addon/controllers/operations/service-rates/index/edit.js +1 -0
- package/addon/controllers/operations/service-rates/index/new.js +1 -0
- package/addon/helpers/f-to-int.js +6 -0
- package/addon/routes/application.js +3 -3
- package/addon/routes/operations/orders/index/details/virtual.js +1 -0
- package/addon/routes/operations/orders/index/details.js +19 -3
- package/addon/services/equipment-actions.js +4 -6
- package/addon/services/geofence-event-bus.js +223 -0
- package/addon/services/leaflet-map-manager.js +4 -0
- package/addon/services/maintenance-actions.js +4 -6
- package/addon/services/part-actions.js +4 -6
- package/addon/services/service-rate-actions.js +8 -3
- package/addon/templates/operations/orders/index/details/virtual.hbs +7 -1
- package/addon/utils/fleet-ops-options.js +33 -0
- package/addon/utils/serialize-payload.js +1 -1
- package/app/components/map/drawer/geofence-event-listing.js +1 -0
- package/app/controllers/operations/orders/index/details/virtual.js +1 -0
- package/app/helpers/f-to-int.js +1 -0
- package/app/services/geofence-event-bus.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +2 -2
- package/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php +48 -0
- package/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php +38 -0
- package/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php +62 -0
- package/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php +66 -0
- package/server/migrations/2026_04_22_000001_add_vehicle_support_to_geofence_events_log.php +31 -0
- package/server/migrations/2026_04_22_000002_create_vehicle_geofence_states_table.php +39 -0
- package/server/src/Console/Commands/SimulateGeofenceEvents.php +278 -0
- package/server/src/Events/GeofenceDwelled.php +208 -0
- package/server/src/Events/GeofenceEntered.php +207 -0
- package/server/src/Events/GeofenceExited.php +213 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +100 -0
- package/server/src/Http/Controllers/Api/v1/GeofenceController.php +263 -0
- package/server/src/Http/Controllers/Api/v1/OrderController.php +13 -8
- package/server/src/Http/Controllers/Api/v1/PurchaseRateController.php +11 -3
- package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
- package/server/src/Http/Controllers/Api/v1/ServiceQuoteController.php +4 -0
- package/server/src/Http/Controllers/Api/v1/ServiceRateController.php +6 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +88 -0
- package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -2
- package/server/src/Http/Controllers/Internal/v1/GeofenceController.php +7 -0
- package/server/src/Http/Requests/CreateServiceAreaRequest.php +8 -4
- package/server/src/Http/Requests/CreateServiceRateRequest.php +2 -2
- package/server/src/Http/Requests/CreateZoneRequest.php +8 -4
- package/server/src/Http/Resources/v1/Order.php +0 -1
- package/server/src/Http/Resources/v1/ServiceQuote.php +2 -2
- package/server/src/Jobs/CheckGeofenceDwell.php +139 -0
- package/server/src/Listeners/HandleGeofenceDwelled.php +56 -0
- package/server/src/Listeners/HandleGeofenceEntered.php +187 -0
- package/server/src/Listeners/HandleGeofenceExited.php +56 -0
- package/server/src/Models/Entity.php +7 -0
- package/server/src/Models/GeofenceEventLog.php +155 -0
- package/server/src/Models/Order.php +39 -7
- package/server/src/Models/ServiceArea.php +21 -2
- package/server/src/Models/ServiceQuote.php +3 -1
- package/server/src/Models/ServiceRate.php +122 -30
- package/server/src/Models/Zone.php +20 -2
- package/server/src/Notifications/DriverArrivedAtGeofence.php +86 -0
- package/server/src/Observers/PurchaseRateObserver.php +3 -5
- package/server/src/Providers/EventServiceProvider.php +19 -0
- package/server/src/Providers/FleetOpsServiceProvider.php +10 -0
- package/server/src/Rules/ComputableAlgo.php +1 -3
- package/server/src/Support/Algo.php +87 -1
- package/server/src/Support/GeofenceIntersectionService.php +206 -0
- package/server/src/Support/Utils.php +35 -0
- package/server/src/routes.php +16 -0
|
@@ -23,6 +23,30 @@
|
|
|
23
23
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
|
24
24
|
<InputGroup @name={{t "common.name"}} @value={{@resource.name}} @helpText={{t "modals.entity-form.name-text"}} />
|
|
25
25
|
<InputGroup @name={{t "common.internal-id"}} @value={{@resource.internal_id}} @helpText={{t "modals.entity-form.id-text"}} />
|
|
26
|
+
<div class="space-y-2">
|
|
27
|
+
<InputGroup @name={{t "common.type"}} @helpText="Choose a common entity type or switch to a custom value.">
|
|
28
|
+
{{#if this.useCustomType}}
|
|
29
|
+
<Input @value={{@resource.type}} @type="text" class="w-full form-input" placeholder="Enter custom entity type" />
|
|
30
|
+
{{else}}
|
|
31
|
+
<div class="fleetbase-model-select fleetbase-power-select ember-model-select">
|
|
32
|
+
<PowerSelect
|
|
33
|
+
@options={{this.entityTypes}}
|
|
34
|
+
@selected={{this.selectedEntityType}}
|
|
35
|
+
@onChange={{this.selectEntityType}}
|
|
36
|
+
@placeholder="Select entity type"
|
|
37
|
+
@triggerClass="form-select"
|
|
38
|
+
as |type|
|
|
39
|
+
>
|
|
40
|
+
<div class="text-sm">
|
|
41
|
+
<div class="font-semibold normalize-in-trigger">{{type.label}}</div>
|
|
42
|
+
<div class="hide-from-trigger">{{type.description}}</div>
|
|
43
|
+
</div>
|
|
44
|
+
</PowerSelect>
|
|
45
|
+
</div>
|
|
46
|
+
{{/if}}
|
|
47
|
+
</InputGroup>
|
|
48
|
+
<Checkbox @value={{this.useCustomType}} @label="Use custom type" @onToggle={{this.toggleCustomType}} @alignItems="center" @labelClass="mb-0i" />
|
|
49
|
+
</div>
|
|
26
50
|
<InputGroup @name={{t "modals.entity-form.sku"}} @value={{@resource.sku}} @helpText={{t "modals.entity-form.sku-text"}} />
|
|
27
51
|
<div></div>
|
|
28
52
|
<InputGroup @name={{t "modals.entity-form.description"}} @helpText={{t "modals.entity-form.description-text"}} @wrapperClass="col-span-2">
|
|
@@ -1,11 +1,41 @@
|
|
|
1
1
|
import Component from '@glimmer/component';
|
|
2
2
|
import { inject as service } from '@ember/service';
|
|
3
|
+
import { action } from '@ember/object';
|
|
4
|
+
import { tracked } from '@glimmer/tracking';
|
|
3
5
|
import { task } from 'ember-concurrency';
|
|
6
|
+
import fleetOpsOptions from '../../utils/fleet-ops-options';
|
|
4
7
|
|
|
5
8
|
export default class EntityFormComponent extends Component {
|
|
6
9
|
@service fetch;
|
|
7
10
|
@service currentUser;
|
|
8
11
|
@service notifications;
|
|
12
|
+
@tracked useCustomType = false;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
super(...arguments);
|
|
16
|
+
this.useCustomType = this.selectedEntityType === undefined && Boolean(this.args.resource?.type);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get entityTypes() {
|
|
20
|
+
return fleetOpsOptions('entityTypes');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get selectedEntityType() {
|
|
24
|
+
return this.entityTypes.find((option) => option.value === this.args.resource?.type);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@action selectEntityType(option) {
|
|
28
|
+
this.useCustomType = false;
|
|
29
|
+
this.args.resource.type = option?.value ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@action toggleCustomType(value) {
|
|
33
|
+
this.useCustomType = value;
|
|
34
|
+
|
|
35
|
+
if (!value && !this.selectedEntityType) {
|
|
36
|
+
this.args.resource.type = null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
9
39
|
|
|
10
40
|
@task *handlePhotoUpload(file) {
|
|
11
41
|
try {
|
|
@@ -27,7 +27,7 @@ export default class LeafletDrawControl extends BaseLayer {
|
|
|
27
27
|
leafletOptions = ['draw', 'edit', 'remove', 'poly', 'position'];
|
|
28
28
|
|
|
29
29
|
@computed('leafletEvents.[]', 'args') get usedLeafletEvents() {
|
|
30
|
-
const leafletEvents = [...this.leafletEvents, ...Object.values(L.Draw
|
|
30
|
+
const leafletEvents = [...this.leafletEvents, ...Object.values(L.Draw?.Event ?? {})];
|
|
31
31
|
return leafletEvents.filter((eventName) => {
|
|
32
32
|
eventName = camelize(eventName.replace(':', ' '));
|
|
33
33
|
let methodName = `_${eventName}`;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<div class="geofence-events-panel flex flex-col h-full" ...attributes>
|
|
2
|
+
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
|
|
3
|
+
<div class="flex items-center space-x-2">
|
|
4
|
+
<FaIcon @icon="map-pin" class="text-blue-500 w-4 h-4" />
|
|
5
|
+
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Geofence Events</h3>
|
|
6
|
+
{{#if this.events.length}}
|
|
7
|
+
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">
|
|
8
|
+
{{this.events.length}}
|
|
9
|
+
</span>
|
|
10
|
+
{{/if}}
|
|
11
|
+
</div>
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
class="text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
|
15
|
+
{{on "click" this.clearFeed}}
|
|
16
|
+
>
|
|
17
|
+
Clear
|
|
18
|
+
</button>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
{{#if this.isLoading}}
|
|
22
|
+
<div class="flex items-center justify-center py-8">
|
|
23
|
+
<Spinner class="w-5 h-5 text-blue-500" />
|
|
24
|
+
<span class="ml-2 text-sm text-gray-500">Loading events…</span>
|
|
25
|
+
</div>
|
|
26
|
+
{{else if (eq this.events.length 0)}}
|
|
27
|
+
<div class="flex flex-col items-center justify-center py-10 px-4 text-center">
|
|
28
|
+
<FaIcon @icon="map-pin" class="w-8 h-8 text-gray-300 dark:text-gray-600 mb-3" />
|
|
29
|
+
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">No geofence events yet</p>
|
|
30
|
+
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
|
31
|
+
Events will appear here in real time as drivers and vehicles cross zone and service area boundaries.
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
{{else}}
|
|
35
|
+
<div class="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
|
36
|
+
{{#each this.events as |event|}}
|
|
37
|
+
<div class="px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors {{if event.isNew 'bg-blue-50 dark:bg-blue-900/10'}}">
|
|
38
|
+
<div class="flex items-start gap-2.5">
|
|
39
|
+
<div class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-300">
|
|
40
|
+
<FaIcon @icon={{this.iconFor event.eventType}} @size="xs" />
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="min-w-0 flex-1">
|
|
44
|
+
<div class="flex items-start justify-between gap-3">
|
|
45
|
+
<div class="min-w-0 flex-1">
|
|
46
|
+
<div class="flex items-center gap-2 text-[11px] leading-4">
|
|
47
|
+
<span class="inline-flex items-center rounded px-1.5 py-0.5 font-medium {{this.badgeColorFor event.eventType}}">
|
|
48
|
+
{{this.labelFor event.eventType}}
|
|
49
|
+
</span>
|
|
50
|
+
<span class="inline-flex items-center gap-1 text-gray-400 dark:text-gray-500">
|
|
51
|
+
<FaIcon @icon={{this.subjectIconFor event.subjectType}} @size="xs" />
|
|
52
|
+
<span class="capitalize">{{event.subjectType}}</span>
|
|
53
|
+
</span>
|
|
54
|
+
<span class="text-gray-300 dark:text-gray-600">•</span>
|
|
55
|
+
<span class="text-gray-500 dark:text-gray-400">{{this.geofenceTypeLabel event.geofenceType}}</span>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="mt-1 flex items-center gap-1.5 text-sm leading-5">
|
|
59
|
+
<span class="truncate font-medium text-gray-900 dark:text-white">{{event.subjectName}}</span>
|
|
60
|
+
<FaIcon @icon="arrow-right" @size="xs" class="text-gray-300 dark:text-gray-600" />
|
|
61
|
+
<span class="truncate text-gray-600 dark:text-gray-300">{{event.geofenceName}}</span>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
|
65
|
+
{{#if event.driverName}}
|
|
66
|
+
{{#unless (eq event.subjectType "driver")}}
|
|
67
|
+
<span class="inline-flex items-center gap-1">
|
|
68
|
+
<FaIcon @icon="id-card" @size="xs" class="text-gray-400" />
|
|
69
|
+
{{event.driverName}}
|
|
70
|
+
</span>
|
|
71
|
+
{{/unless}}
|
|
72
|
+
{{/if}}
|
|
73
|
+
|
|
74
|
+
{{#if event.vehiclePlate}}
|
|
75
|
+
<span class="inline-flex items-center gap-1">
|
|
76
|
+
<FaIcon @icon="car" @size="xs" class="text-gray-400" />
|
|
77
|
+
{{event.vehiclePlate}}
|
|
78
|
+
</span>
|
|
79
|
+
{{/if}}
|
|
80
|
+
|
|
81
|
+
{{#if event.orderPublicId}}
|
|
82
|
+
<span class="inline-flex items-center gap-1">
|
|
83
|
+
<FaIcon @icon="hashtag" @size="xs" class="text-gray-400" />
|
|
84
|
+
{{event.orderPublicId}}
|
|
85
|
+
</span>
|
|
86
|
+
{{/if}}
|
|
87
|
+
|
|
88
|
+
{{#if event.dwellMinutes}}
|
|
89
|
+
<span class="inline-flex items-center gap-1">
|
|
90
|
+
<FaIcon @icon="clock" @size="xs" class="text-gray-400" />
|
|
91
|
+
{{event.dwellMinutes}} min
|
|
92
|
+
</span>
|
|
93
|
+
{{/if}}
|
|
94
|
+
|
|
95
|
+
{{#if event.latitude}}
|
|
96
|
+
{{#if event.longitude}}
|
|
97
|
+
<span class="inline-flex items-center gap-1">
|
|
98
|
+
<FaIcon @icon="location-dot" @size="xs" class="text-gray-400" />
|
|
99
|
+
{{event.latitude}}, {{event.longitude}}
|
|
100
|
+
</span>
|
|
101
|
+
{{/if}}
|
|
102
|
+
{{/if}}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div class="flex-shrink-0 text-right">
|
|
107
|
+
<div class="text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
|
108
|
+
{{format-date-fns event.occurredAt "dd MMM, HH:mm"}}
|
|
109
|
+
</div>
|
|
110
|
+
{{#if event.isNew}}
|
|
111
|
+
<div class="mt-1 text-[10px] font-medium uppercase tracking-wide text-blue-500 dark:text-blue-300">
|
|
112
|
+
Live
|
|
113
|
+
</div>
|
|
114
|
+
{{/if}}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
{{/each}}
|
|
121
|
+
</div>
|
|
122
|
+
{{/if}}
|
|
123
|
+
</div>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import Component from '@glimmer/component';
|
|
2
|
+
import { tracked } from '@glimmer/tracking';
|
|
3
|
+
import { inject as service } from '@ember/service';
|
|
4
|
+
import { action } from '@ember/object';
|
|
5
|
+
import { next } from '@ember/runloop';
|
|
6
|
+
|
|
7
|
+
export default class MapDrawerGeofenceEventListingComponent extends Component {
|
|
8
|
+
@service fetch;
|
|
9
|
+
@service geofenceEventBus;
|
|
10
|
+
@service currentUser;
|
|
11
|
+
|
|
12
|
+
@tracked isLoading = false;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
super(...arguments);
|
|
16
|
+
next(() => {
|
|
17
|
+
this.geofenceEventBus.subscribe(this.currentUser.companyId);
|
|
18
|
+
this.loadRecentEvents();
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get events() {
|
|
23
|
+
return this.geofenceEventBus.events;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@action
|
|
27
|
+
async loadRecentEvents() {
|
|
28
|
+
this.isLoading = true;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const response = await this.fetch.get('geofences/events', { per_page: 20 });
|
|
32
|
+
|
|
33
|
+
if (response?.data) {
|
|
34
|
+
this.geofenceEventBus.seedEvents(response.data.map((event) => this.geofenceEventBus.normalizeEvent(event.event_type, event)));
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// The live socket feed will still populate events if history loading fails.
|
|
38
|
+
} finally {
|
|
39
|
+
this.isLoading = false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@action
|
|
44
|
+
badgeColorFor(eventType) {
|
|
45
|
+
const colors = {
|
|
46
|
+
'geofence.entered': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
|
47
|
+
'geofence.exited': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
|
|
48
|
+
'geofence.dwelled': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return colors[eventType] ?? 'bg-gray-100 text-gray-800';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@action
|
|
55
|
+
labelFor(eventType) {
|
|
56
|
+
const labels = {
|
|
57
|
+
'geofence.entered': 'Entered',
|
|
58
|
+
'geofence.exited': 'Exited',
|
|
59
|
+
'geofence.dwelled': 'Dwelled',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return labels[eventType] ?? eventType;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@action
|
|
66
|
+
iconFor(eventType) {
|
|
67
|
+
const icons = {
|
|
68
|
+
'geofence.entered': 'arrow-right-to-bracket',
|
|
69
|
+
'geofence.exited': 'arrow-right-from-bracket',
|
|
70
|
+
'geofence.dwelled': 'clock',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return icons[eventType] ?? 'map-pin';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@action
|
|
77
|
+
subjectIconFor(subjectType) {
|
|
78
|
+
return subjectType === 'vehicle' ? 'car' : 'id-card';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@action
|
|
82
|
+
geofenceTypeLabel(type) {
|
|
83
|
+
if (type === 'service_area') {
|
|
84
|
+
return 'Service Area';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (type === 'zone') {
|
|
88
|
+
return 'Zone';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return type ?? 'Geofence';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@action
|
|
95
|
+
hasSecondaryMeta(event) {
|
|
96
|
+
return Boolean(event.vehiclePlate || event.orderPublicId || event.dwellMinutes || event.latitude || event.longitude);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@action
|
|
100
|
+
clearFeed() {
|
|
101
|
+
this.geofenceEventBus.clearFeed();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -15,6 +15,7 @@ export default class MapDrawerComponent extends Component {
|
|
|
15
15
|
this.universe._createMenuItem('Drivers', null, { icon: 'id-card', component: 'map/drawer/driver-listing' }),
|
|
16
16
|
this.universe._createMenuItem('Places', null, { icon: 'building', component: 'map/drawer/place-listing' }),
|
|
17
17
|
this.universe._createMenuItem('Positions', null, { icon: 'map-marker', component: 'map/drawer/position-listing' }),
|
|
18
|
+
this.universe._createMenuItem('Geofences', null, { icon: 'map-pin', component: 'map/drawer/geofence-event-listing' }),
|
|
18
19
|
this.universe._createMenuItem('Events', null, { icon: 'stream', component: 'map/drawer/device-event-listing' }),
|
|
19
20
|
...(isArray(registeredTabs) ? registeredTabs : []),
|
|
20
21
|
];
|
|
@@ -29,6 +29,8 @@ export default class MapLeafletLiveMapComponent extends Component {
|
|
|
29
29
|
@service intl;
|
|
30
30
|
@service universe;
|
|
31
31
|
@service('universe/menu-service') menuService;
|
|
32
|
+
@service geofenceEventBus;
|
|
33
|
+
@service currentUser;
|
|
32
34
|
|
|
33
35
|
/** properties */
|
|
34
36
|
id = guidFor(this);
|
|
@@ -57,6 +59,19 @@ export default class MapLeafletLiveMapComponent extends Component {
|
|
|
57
59
|
|
|
58
60
|
// Ensure we have valid coordinates on initialization
|
|
59
61
|
this.#updateCoordinatesFromLocation();
|
|
62
|
+
|
|
63
|
+
if (this.currentUser.companyId) {
|
|
64
|
+
this.geofenceEventBus.subscribe(this.currentUser.companyId);
|
|
65
|
+
} else {
|
|
66
|
+
this._currentUserLoadedHandler = () => this.geofenceEventBus.subscribe(this.currentUser.companyId);
|
|
67
|
+
this.currentUser.on('user.loaded', this._currentUserLoadedHandler);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Subscribe to geofence events so the live map can react to boundary crossings
|
|
71
|
+
this._geofenceEnteredHandler = this.#handleGeofenceEntered.bind(this);
|
|
72
|
+
this._geofenceExitedHandler = this.#handleGeofenceExited.bind(this);
|
|
73
|
+
this.universe.on('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
|
|
74
|
+
this.universe.on('fleet-ops.geofence.exited', this._geofenceExitedHandler);
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
willDestroy() {
|
|
@@ -67,6 +82,18 @@ export default class MapLeafletLiveMapComponent extends Component {
|
|
|
67
82
|
this.universe.off('user.located', this._locationUpdateHandler);
|
|
68
83
|
this._locationUpdateHandler = null;
|
|
69
84
|
}
|
|
85
|
+
if (this._currentUserLoadedHandler) {
|
|
86
|
+
this.currentUser.off('user.loaded', this._currentUserLoadedHandler);
|
|
87
|
+
this._currentUserLoadedHandler = null;
|
|
88
|
+
}
|
|
89
|
+
if (this._geofenceEnteredHandler) {
|
|
90
|
+
this.universe.off('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
|
|
91
|
+
this._geofenceEnteredHandler = null;
|
|
92
|
+
}
|
|
93
|
+
if (this._geofenceExitedHandler) {
|
|
94
|
+
this.universe.off('fleet-ops.geofence.exited', this._geofenceExitedHandler);
|
|
95
|
+
this._geofenceExitedHandler = null;
|
|
96
|
+
}
|
|
70
97
|
}
|
|
71
98
|
|
|
72
99
|
@action didLoad({ target: map }) {
|
|
@@ -259,6 +286,62 @@ export default class MapLeafletLiveMapComponent extends Component {
|
|
|
259
286
|
return 14;
|
|
260
287
|
}
|
|
261
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Handles a geofence.entered event from the GeofenceEventBus.
|
|
291
|
+
* Briefly highlights the geofence layer on the map to provide visual feedback.
|
|
292
|
+
*
|
|
293
|
+
* @param {Object} event - Normalised geofence event object
|
|
294
|
+
*/
|
|
295
|
+
#handleGeofenceEntered(event) {
|
|
296
|
+
debug(`[LiveMap] geofence.entered — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
|
|
297
|
+
this.#flashGeofenceLayer(event.geofenceUuid, '#22c55e'); // green
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Handles a geofence.exited event from the GeofenceEventBus.
|
|
302
|
+
* Briefly highlights the geofence layer on the map to provide visual feedback.
|
|
303
|
+
*
|
|
304
|
+
* @param {Object} event - Normalised geofence event object
|
|
305
|
+
*/
|
|
306
|
+
#handleGeofenceExited(event) {
|
|
307
|
+
debug(`[LiveMap] geofence.exited — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
|
|
308
|
+
this.#flashGeofenceLayer(event.geofenceUuid, '#ef4444'); // red
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Briefly changes the fill colour of a geofence polygon layer on the map
|
|
313
|
+
* to provide visual feedback when a driver enters or exits.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} geofenceUuid - UUID of the zone or service area
|
|
316
|
+
* @param {string} flashColor - Hex colour to flash
|
|
317
|
+
*/
|
|
318
|
+
#flashGeofenceLayer(geofenceUuid, flashColor) {
|
|
319
|
+
if (!geofenceUuid || !this.map) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Iterate over all Leaflet layers to find the matching geofence polygon
|
|
324
|
+
this.map.eachLayer((layer) => {
|
|
325
|
+
const model = layer._model;
|
|
326
|
+
if (model && model.uuid === geofenceUuid && typeof layer.setStyle === 'function') {
|
|
327
|
+
const originalStyle = {
|
|
328
|
+
color: layer.options.color,
|
|
329
|
+
fillColor: layer.options.fillColor,
|
|
330
|
+
weight: layer.options.weight,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Flash to the event colour
|
|
334
|
+
layer.setStyle({ color: flashColor, fillColor: flashColor, weight: 3 });
|
|
335
|
+
|
|
336
|
+
// Restore original style after 2 seconds
|
|
337
|
+
setTimeout(() => {
|
|
338
|
+
if (!layer._map) return; // layer may have been removed
|
|
339
|
+
layer.setStyle(originalStyle);
|
|
340
|
+
}, 2000);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
262
345
|
/**
|
|
263
346
|
* Handles location updates from the location service
|
|
264
347
|
* @param {Object} coordinates - The new coordinates
|
|
@@ -223,76 +223,6 @@ const ALIASES = {
|
|
|
223
223
|
entity_destination: ['entity destination', 'item destination', 'deliver to', 'waypoint'],
|
|
224
224
|
};
|
|
225
225
|
|
|
226
|
-
/** Sample row for the downloadable template. */
|
|
227
|
-
const SAMPLE_ROW = {
|
|
228
|
-
order_type: 'pickup_dropoff',
|
|
229
|
-
order_ref: 'ORD-001',
|
|
230
|
-
internal_id: 'INT-001',
|
|
231
|
-
type: 'default',
|
|
232
|
-
status: 'created',
|
|
233
|
-
scheduled_at: '2026-05-01 09:00',
|
|
234
|
-
notes: 'Leave at front door',
|
|
235
|
-
priority: '50',
|
|
236
|
-
time_window_start: '08:00',
|
|
237
|
-
time_window_end: '12:00',
|
|
238
|
-
service_time_min: '10',
|
|
239
|
-
required_skills: '',
|
|
240
|
-
pickup_name: 'Warehouse A',
|
|
241
|
-
pickup_street1: '123 Warehouse Rd',
|
|
242
|
-
pickup_street2: '',
|
|
243
|
-
pickup_city: 'London',
|
|
244
|
-
pickup_state: 'England',
|
|
245
|
-
pickup_postal_code: 'E1 6RF',
|
|
246
|
-
pickup_country: 'GB',
|
|
247
|
-
pickup_phone: '+44 20 7946 0000',
|
|
248
|
-
pickup_lat: '',
|
|
249
|
-
pickup_lng: '',
|
|
250
|
-
dropoff_name: 'John Smith',
|
|
251
|
-
dropoff_street1: '456 High Street',
|
|
252
|
-
dropoff_street2: 'Apt 3B',
|
|
253
|
-
dropoff_city: 'London',
|
|
254
|
-
dropoff_state: 'England',
|
|
255
|
-
dropoff_postal_code: 'SW1A 1AA',
|
|
256
|
-
dropoff_country: 'GB',
|
|
257
|
-
dropoff_phone: '+44 7700 900000',
|
|
258
|
-
dropoff_lat: '',
|
|
259
|
-
dropoff_lng: '',
|
|
260
|
-
weight_kg: '5.2',
|
|
261
|
-
volume_m3: '0.04',
|
|
262
|
-
parcels: '1',
|
|
263
|
-
cod_amount: '',
|
|
264
|
-
cod_currency: '',
|
|
265
|
-
customer_name: 'John Smith',
|
|
266
|
-
customer_email: 'john.smith@example.com',
|
|
267
|
-
customer_phone: '+44 7700 900000',
|
|
268
|
-
customer_type: 'contact',
|
|
269
|
-
facilitator_name: '',
|
|
270
|
-
facilitator_email: '',
|
|
271
|
-
facilitator_phone: '',
|
|
272
|
-
facilitator_type: 'vendor',
|
|
273
|
-
vehicle_plate: '',
|
|
274
|
-
driver_name: '',
|
|
275
|
-
driver_phone: '',
|
|
276
|
-
driver_email: '',
|
|
277
|
-
entity_name: 'Widget A',
|
|
278
|
-
entity_type: 'parcel',
|
|
279
|
-
entity_description: '1x Widget A in box',
|
|
280
|
-
entity_sku: 'WGT-001',
|
|
281
|
-
entity_barcode: '',
|
|
282
|
-
entity_internal_id: '',
|
|
283
|
-
entity_declared_value: '25.00',
|
|
284
|
-
entity_currency: 'USD',
|
|
285
|
-
entity_price: '25.00',
|
|
286
|
-
entity_sale_price: '',
|
|
287
|
-
entity_weight: '0.5',
|
|
288
|
-
entity_weight_unit: 'kg',
|
|
289
|
-
entity_length: '',
|
|
290
|
-
entity_width: '',
|
|
291
|
-
entity_height: '',
|
|
292
|
-
entity_dimensions_unit: 'cm',
|
|
293
|
-
entity_destination: 'dropoff',
|
|
294
|
-
};
|
|
295
|
-
|
|
296
226
|
export default class OrchestratorImportComponent extends Component {
|
|
297
227
|
@service fetch;
|
|
298
228
|
@service notifications;
|
|
@@ -795,13 +725,7 @@ export default class OrchestratorImportComponent extends Component {
|
|
|
795
725
|
return this.args.options.onImportTemplate();
|
|
796
726
|
}
|
|
797
727
|
|
|
798
|
-
//
|
|
799
|
-
|
|
800
|
-
const headers = TARGET_FIELDS.map((f) => f.key);
|
|
801
|
-
const sampleValues = headers.map((k) => SAMPLE_ROW[k] ?? '');
|
|
802
|
-
const ws = XLSX.utils.aoa_to_sheet([headers, sampleValues]);
|
|
803
|
-
const wb = XLSX.utils.book_new();
|
|
804
|
-
XLSX.utils.book_append_sheet(wb, ws, 'Orders');
|
|
805
|
-
XLSX.writeFile(wb, 'Fleetbase_Order_Import_Template.xlsx');
|
|
728
|
+
// Open the import template URL for download
|
|
729
|
+
window.open('https://flb-assets.s3.ap-southeast-1.amazonaws.com/import-templates/Fleetbase_Order_Import_Template.xlsx');
|
|
806
730
|
}
|
|
807
731
|
}
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
@name="serviceQuote"
|
|
59
59
|
@changed={{fn (mut @resource.service_quote_uuid)}}
|
|
60
60
|
/>
|
|
61
|
-
<label for={{serviceQuote.uuid}} class="ml-3 flex-1">{{serviceQuote.public_id}}</label>
|
|
61
|
+
<label for={{serviceQuote.uuid}} class="ml-3 flex-1 text-sm">{{serviceQuote.public_id}} ({{serviceQuote.service_rate_name}})</label>
|
|
62
62
|
<Badge @hideStatusDot={{true}} @status="info">
|
|
63
63
|
{{serviceQuote.request_id}}
|
|
64
64
|
</Badge>
|
|
@@ -41,4 +41,63 @@
|
|
|
41
41
|
</InputGroup>
|
|
42
42
|
</div>
|
|
43
43
|
</ContentPanel>
|
|
44
|
+
|
|
45
|
+
<ContentPanel @title="Geofence Triggers" @open={{true}} @wrapperClass="bordered-top">
|
|
46
|
+
<div class="space-y-4">
|
|
47
|
+
<p class="text-sm text-gray-500 dark:text-gray-400">
|
|
48
|
+
Configure when this service area fires active geofence events. Entry and exit events are dispatched to
|
|
49
|
+
webhooks, WebSocket channels, and the notification system whenever a driver crosses the service area boundary.
|
|
50
|
+
</p>
|
|
51
|
+
|
|
52
|
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
53
|
+
{{! Entry trigger toggle }}
|
|
54
|
+
<div class="flex items-start space-x-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
55
|
+
<div class="flex-shrink-0 mt-0.5">
|
|
56
|
+
<Toggle
|
|
57
|
+
@isToggled={{@resource.trigger_on_entry}}
|
|
58
|
+
@onToggle={{fn (mut @resource.trigger_on_entry)}}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
<div>
|
|
62
|
+
<p class="text-sm font-medium text-gray-900 dark:text-white">Trigger on Entry</p>
|
|
63
|
+
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
64
|
+
Fire a <code class="font-mono">geofence.entered</code> event when a driver enters this service area.
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{{! Exit trigger toggle }}
|
|
70
|
+
<div class="flex items-start space-x-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
71
|
+
<div class="flex-shrink-0 mt-0.5">
|
|
72
|
+
<Toggle
|
|
73
|
+
@isToggled={{@resource.trigger_on_exit}}
|
|
74
|
+
@onToggle={{fn (mut @resource.trigger_on_exit)}}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
<div>
|
|
78
|
+
<p class="text-sm font-medium text-gray-900 dark:text-white">Trigger on Exit</p>
|
|
79
|
+
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
80
|
+
Fire a <code class="font-mono">geofence.exited</code> event when a driver leaves this service area.
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<InputGroup
|
|
87
|
+
@name="Dwell Threshold (minutes)"
|
|
88
|
+
@value={{@resource.dwell_threshold_minutes}}
|
|
89
|
+
@type="number"
|
|
90
|
+
@placeholder="e.g. 10"
|
|
91
|
+
@helpText="Fire a geofence.dwelled event after a driver has been inside this service area for this many minutes. Leave blank to disable dwell tracking."
|
|
92
|
+
/>
|
|
93
|
+
|
|
94
|
+
<InputGroup
|
|
95
|
+
@name="Speed Limit (km/h)"
|
|
96
|
+
@value={{@resource.speed_limit_kmh}}
|
|
97
|
+
@type="number"
|
|
98
|
+
@placeholder="e.g. 50"
|
|
99
|
+
@helpText="Optional speed limit enforced within this service area. Leave blank for no limit."
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
</ContentPanel>
|
|
44
103
|
</div>
|
|
@@ -106,11 +106,11 @@
|
|
|
106
106
|
</tr>
|
|
107
107
|
</thead>
|
|
108
108
|
<tbody>
|
|
109
|
-
{{#each @resource.
|
|
109
|
+
{{#each @resource.rateFees as |rateFee|}}
|
|
110
110
|
<tr>
|
|
111
111
|
<td>{{n-a rateFee.min}}</td>
|
|
112
112
|
<td>{{n-a rateFee.max}}</td>
|
|
113
|
-
<td>{{format-currency rateFee.fee @resource.currency}}</td>
|
|
113
|
+
<td>{{format-currency (f-to-int rateFee.fee) @resource.currency}}</td>
|
|
114
114
|
</tr>
|
|
115
115
|
{{else}}
|
|
116
116
|
<tr>
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
<div class="field-info-container">
|
|
159
159
|
<div class="field-name">Algorithm Code</div>
|
|
160
160
|
<div class="field-value">
|
|
161
|
-
<pre class="bg-gray-100 dark:bg-gray-
|
|
161
|
+
<pre class="bg-gray-100 dark:bg-gray-900 font-mono p-3 rounded text-xs overflow-x-auto"><code class="font-mono text-xs">{{n-a @resource.algorithm}}</code></pre>
|
|
162
162
|
</div>
|
|
163
163
|
</div>
|
|
164
164
|
</ContentPanel>
|
|
@@ -167,7 +167,7 @@
|
|
|
167
167
|
{{#if @resource.isParcelService}}
|
|
168
168
|
<ContentPanel @title={{t "service-rate.fields.percel-fee-title"}} @open={{true}} @wrapperClass="bordered-top">
|
|
169
169
|
<div class="space-y-2">
|
|
170
|
-
{{#each @resource.
|
|
170
|
+
{{#each @resource.parcelFees as |parcelFee|}}
|
|
171
171
|
<div class="dark:text-gray-100">
|
|
172
172
|
<div class="grid grid-cols-7">
|
|
173
173
|
<div class="flex flex-col items-start justify-start pt-3">
|
|
@@ -242,7 +242,7 @@
|
|
|
242
242
|
{{t "service-rate.fields.additional-fee"}}
|
|
243
243
|
</div>
|
|
244
244
|
<div class="field-value">
|
|
245
|
-
{{n-a (format-currency parcelFee.fee @resource.currency)}}
|
|
245
|
+
{{n-a (format-currency (f-to-int parcelFee.fee) @resource.currency)}}
|
|
246
246
|
</div>
|
|
247
247
|
</div>
|
|
248
248
|
</div>
|
|
@@ -323,4 +323,4 @@
|
|
|
323
323
|
</ContentPanel>
|
|
324
324
|
|
|
325
325
|
<CustomField::Yield @subject={{@resource}} @viewMode={{true}} @wrapperClass="bordered-top" />
|
|
326
|
-
</div>
|
|
326
|
+
</div>
|