@fleetbase/fleetops-engine 0.6.43 → 0.6.44

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 (45) hide show
  1. package/addon/components/map/drawer/geofence-event-listing.hbs +123 -0
  2. package/addon/components/map/drawer/geofence-event-listing.js +100 -0
  3. package/addon/components/map/drawer.js +1 -0
  4. package/addon/components/map/leaflet-live-map.js +83 -0
  5. package/addon/components/modals/orchestrator-import.js +2 -78
  6. package/addon/components/service-area/form.hbs +59 -0
  7. package/addon/components/zone/form.hbs +60 -1
  8. package/addon/services/geofence-event-bus.js +223 -0
  9. package/app/components/map/drawer/geofence-event-listing.js +1 -0
  10. package/app/services/geofence-event-bus.js +1 -0
  11. package/composer.json +1 -1
  12. package/extension.json +1 -1
  13. package/package.json +2 -2
  14. package/server/migrations/2026_04_05_000001_add_geofence_config_to_zones_table.php +48 -0
  15. package/server/migrations/2026_04_05_000002_add_geofence_config_to_service_areas_table.php +38 -0
  16. package/server/migrations/2026_04_05_000003_create_driver_geofence_states_table.php +62 -0
  17. package/server/migrations/2026_04_05_000004_create_geofence_events_log_table.php +66 -0
  18. package/server/migrations/2026_04_22_000001_add_vehicle_support_to_geofence_events_log.php +31 -0
  19. package/server/migrations/2026_04_22_000002_create_vehicle_geofence_states_table.php +39 -0
  20. package/server/src/Console/Commands/SimulateGeofenceEvents.php +278 -0
  21. package/server/src/Events/GeofenceDwelled.php +208 -0
  22. package/server/src/Events/GeofenceEntered.php +207 -0
  23. package/server/src/Events/GeofenceExited.php +213 -0
  24. package/server/src/Http/Controllers/Api/v1/DriverController.php +100 -0
  25. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +263 -0
  26. package/server/src/Http/Controllers/Api/v1/OrderController.php +8 -3
  27. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  28. package/server/src/Http/Controllers/Api/v1/VehicleController.php +88 -0
  29. package/server/src/Http/Controllers/Api/v1/ZoneController.php +2 -2
  30. package/server/src/Http/Controllers/Internal/v1/GeofenceController.php +7 -0
  31. package/server/src/Http/Requests/CreateServiceAreaRequest.php +8 -4
  32. package/server/src/Http/Requests/CreateZoneRequest.php +8 -4
  33. package/server/src/Jobs/CheckGeofenceDwell.php +139 -0
  34. package/server/src/Listeners/HandleGeofenceDwelled.php +56 -0
  35. package/server/src/Listeners/HandleGeofenceEntered.php +187 -0
  36. package/server/src/Listeners/HandleGeofenceExited.php +56 -0
  37. package/server/src/Models/GeofenceEventLog.php +155 -0
  38. package/server/src/Models/ServiceArea.php +21 -2
  39. package/server/src/Models/ServiceQuote.php +3 -1
  40. package/server/src/Models/Zone.php +20 -2
  41. package/server/src/Notifications/DriverArrivedAtGeofence.php +86 -0
  42. package/server/src/Providers/EventServiceProvider.php +19 -0
  43. package/server/src/Providers/FleetOpsServiceProvider.php +10 -0
  44. package/server/src/Support/GeofenceIntersectionService.php +206 -0
  45. package/server/src/routes.php +16 -0
@@ -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,100 @@
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
+
6
+ export default class MapDrawerGeofenceEventListingComponent extends Component {
7
+ @service fetch;
8
+ @service geofenceEventBus;
9
+ @service currentUser;
10
+
11
+ @tracked isLoading = false;
12
+
13
+ constructor() {
14
+ super(...arguments);
15
+ this.geofenceEventBus.subscribe(this.currentUser.companyId);
16
+ this.loadRecentEvents();
17
+ }
18
+
19
+ get events() {
20
+ return this.geofenceEventBus.events;
21
+ }
22
+
23
+ @action
24
+ async loadRecentEvents() {
25
+ this.isLoading = true;
26
+
27
+ try {
28
+ const response = await this.fetch.get('geofences/events', { per_page: 20 });
29
+
30
+ if (response?.data) {
31
+ this.geofenceEventBus.seedEvents(response.data.map((event) => this.geofenceEventBus.normalizeEvent(event.event_type, event)));
32
+ }
33
+ } catch (error) {
34
+ // The live socket feed will still populate events if history loading fails.
35
+ } finally {
36
+ this.isLoading = false;
37
+ }
38
+ }
39
+
40
+ @action
41
+ badgeColorFor(eventType) {
42
+ const colors = {
43
+ 'geofence.entered': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
44
+ 'geofence.exited': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
45
+ 'geofence.dwelled': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
46
+ };
47
+
48
+ return colors[eventType] ?? 'bg-gray-100 text-gray-800';
49
+ }
50
+
51
+ @action
52
+ labelFor(eventType) {
53
+ const labels = {
54
+ 'geofence.entered': 'Entered',
55
+ 'geofence.exited': 'Exited',
56
+ 'geofence.dwelled': 'Dwelled',
57
+ };
58
+
59
+ return labels[eventType] ?? eventType;
60
+ }
61
+
62
+ @action
63
+ iconFor(eventType) {
64
+ const icons = {
65
+ 'geofence.entered': 'arrow-right-to-bracket',
66
+ 'geofence.exited': 'arrow-right-from-bracket',
67
+ 'geofence.dwelled': 'clock',
68
+ };
69
+
70
+ return icons[eventType] ?? 'map-pin';
71
+ }
72
+
73
+ @action
74
+ subjectIconFor(subjectType) {
75
+ return subjectType === 'vehicle' ? 'car' : 'id-card';
76
+ }
77
+
78
+ @action
79
+ geofenceTypeLabel(type) {
80
+ if (type === 'service_area') {
81
+ return 'Service Area';
82
+ }
83
+
84
+ if (type === 'zone') {
85
+ return 'Zone';
86
+ }
87
+
88
+ return type ?? 'Geofence';
89
+ }
90
+
91
+ @action
92
+ hasSecondaryMeta(event) {
93
+ return Boolean(event.vehiclePlate || event.orderPublicId || event.dwellMinutes || event.latitude || event.longitude);
94
+ }
95
+
96
+ @action
97
+ clearFeed() {
98
+ this.geofenceEventBus.clearFeed();
99
+ }
100
+ }
@@ -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
- // Build a dynamic XLSX template from the current field list + a sample row
799
- // so the template always stays in sync with the importer field definitions.
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
  }
@@ -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>
@@ -30,4 +30,63 @@
30
30
  </InputGroup>
31
31
  </div>
32
32
  </ContentPanel>
33
- </div>
33
+
34
+ <ContentPanel @title="Geofence Triggers" @open={{true}} @wrapperClass="bordered-top">
35
+ <div class="space-y-4">
36
+ <p class="text-sm text-gray-500 dark:text-gray-400">
37
+ Configure when this zone fires active geofence events. Entry and exit events are dispatched to webhooks,
38
+ WebSocket channels, and the notification system whenever a driver crosses the zone boundary.
39
+ </p>
40
+
41
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
42
+ {{! Entry trigger toggle }}
43
+ <div class="flex items-start space-x-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700">
44
+ <div class="flex-shrink-0 mt-0.5">
45
+ <Toggle
46
+ @isToggled={{@resource.trigger_on_entry}}
47
+ @onToggle={{fn (mut @resource.trigger_on_entry)}}
48
+ />
49
+ </div>
50
+ <div>
51
+ <p class="text-sm font-medium text-gray-900 dark:text-white">Trigger on Entry</p>
52
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
53
+ Fire a <code class="font-mono">geofence.entered</code> event when a driver enters this zone.
54
+ </p>
55
+ </div>
56
+ </div>
57
+
58
+ {{! Exit trigger toggle }}
59
+ <div class="flex items-start space-x-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700">
60
+ <div class="flex-shrink-0 mt-0.5">
61
+ <Toggle
62
+ @isToggled={{@resource.trigger_on_exit}}
63
+ @onToggle={{fn (mut @resource.trigger_on_exit)}}
64
+ />
65
+ </div>
66
+ <div>
67
+ <p class="text-sm font-medium text-gray-900 dark:text-white">Trigger on Exit</p>
68
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
69
+ Fire a <code class="font-mono">geofence.exited</code> event when a driver leaves this zone.
70
+ </p>
71
+ </div>
72
+ </div>
73
+ </div>
74
+
75
+ <InputGroup
76
+ @name="Dwell Threshold (minutes)"
77
+ @value={{@resource.dwell_threshold_minutes}}
78
+ @type="number"
79
+ @placeholder="e.g. 10"
80
+ @helpText="Fire a geofence.dwelled event after a driver has been inside this zone for this many minutes. Leave blank to disable dwell tracking."
81
+ />
82
+
83
+ <InputGroup
84
+ @name="Speed Limit (km/h)"
85
+ @value={{@resource.speed_limit_kmh}}
86
+ @type="number"
87
+ @placeholder="e.g. 50"
88
+ @helpText="Optional speed limit enforced within this zone. Leave blank for no limit."
89
+ />
90
+ </div>
91
+ </ContentPanel>
92
+ </div>