Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions addon/components/map/drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default class MapDrawerComponent extends Component {
this.universe._createMenuItem('Drivers', null, { icon: 'id-card', component: 'map/drawer/driver-listing' }),
this.universe._createMenuItem('Places', null, { icon: 'building', component: 'map/drawer/place-listing' }),
this.universe._createMenuItem('Positions', null, { icon: 'map-marker', component: 'map/drawer/position-listing' }),
this.universe._createMenuItem('Geofences', null, { icon: 'map-pin', component: 'map/drawer/geofence-event-listing' }),
this.universe._createMenuItem('Events', null, { icon: 'stream', component: 'map/drawer/device-event-listing' }),
...(isArray(registeredTabs) ? registeredTabs : []),
];
Expand Down
123 changes: 123 additions & 0 deletions addon/components/map/drawer/geofence-event-listing.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<div class="geofence-events-panel flex flex-col h-full" ...attributes>
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-2">
<FaIcon @icon="map-pin" class="text-blue-500 w-4 h-4" />
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Geofence Events</h3>
{{#if this.events.length}}
<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">
{{this.events.length}}
</span>
{{/if}}
</div>
<button
type="button"
class="text-[11px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
{{on "click" this.clearFeed}}
>
Clear
</button>
</div>

{{#if this.isLoading}}
<div class="flex items-center justify-center py-8">
<Spinner class="w-5 h-5 text-blue-500" />
<span class="ml-2 text-sm text-gray-500">Loading events…</span>
</div>
{{else if (eq this.events.length 0)}}
<div class="flex flex-col items-center justify-center py-10 px-4 text-center">
<FaIcon @icon="map-pin" class="w-8 h-8 text-gray-300 dark:text-gray-600 mb-3" />
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">No geofence events yet</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
Events will appear here in real time as drivers and vehicles cross zone and service area boundaries.
</p>
</div>
{{else}}
<div class="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
{{#each this.events as |event|}}
<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'}}">
<div class="flex items-start gap-2.5">
<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">
<FaIcon @icon={{this.iconFor event.eventType}} @size="xs" />
</div>

<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-[11px] leading-4">
<span class="inline-flex items-center rounded px-1.5 py-0.5 font-medium {{this.badgeColorFor event.eventType}}">
{{this.labelFor event.eventType}}
</span>
<span class="inline-flex items-center gap-1 text-gray-400 dark:text-gray-500">
<FaIcon @icon={{this.subjectIconFor event.subjectType}} @size="xs" />
<span class="capitalize">{{event.subjectType}}</span>
</span>
<span class="text-gray-300 dark:text-gray-600">•</span>
<span class="text-gray-500 dark:text-gray-400">{{this.geofenceTypeLabel event.geofenceType}}</span>
</div>

<div class="mt-1 flex items-center gap-1.5 text-sm leading-5">
<span class="truncate font-medium text-gray-900 dark:text-white">{{event.subjectName}}</span>
<FaIcon @icon="arrow-right" @size="xs" class="text-gray-300 dark:text-gray-600" />
<span class="truncate text-gray-600 dark:text-gray-300">{{event.geofenceName}}</span>
</div>

<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">
{{#if event.driverName}}
{{#unless (eq event.subjectType "driver")}}
<span class="inline-flex items-center gap-1">
<FaIcon @icon="id-card" @size="xs" class="text-gray-400" />
{{event.driverName}}
</span>
{{/unless}}
{{/if}}

{{#if event.vehiclePlate}}
<span class="inline-flex items-center gap-1">
<FaIcon @icon="car" @size="xs" class="text-gray-400" />
{{event.vehiclePlate}}
</span>
{{/if}}

{{#if event.orderPublicId}}
<span class="inline-flex items-center gap-1">
<FaIcon @icon="hashtag" @size="xs" class="text-gray-400" />
{{event.orderPublicId}}
</span>
{{/if}}

{{#if event.dwellMinutes}}
<span class="inline-flex items-center gap-1">
<FaIcon @icon="clock" @size="xs" class="text-gray-400" />
{{event.dwellMinutes}} min
</span>
{{/if}}

{{#if event.latitude}}
{{#if event.longitude}}
<span class="inline-flex items-center gap-1">
<FaIcon @icon="location-dot" @size="xs" class="text-gray-400" />
{{event.latitude}}, {{event.longitude}}
</span>
{{/if}}
{{/if}}
</div>
</div>

<div class="flex-shrink-0 text-right">
<div class="text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{{format-date-fns event.occurredAt "dd MMM, HH:mm"}}
</div>
{{#if event.isNew}}
<div class="mt-1 text-[10px] font-medium uppercase tracking-wide text-blue-500 dark:text-blue-300">
Live
</div>
{{/if}}
</div>
</div>
</div>
</div>
</div>
{{/each}}
</div>
{{/if}}
</div>
100 changes: 100 additions & 0 deletions addon/components/map/drawer/geofence-event-listing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';

export default class MapDrawerGeofenceEventListingComponent extends Component {
@service fetch;
@service geofenceEventBus;
@service currentUser;

@tracked isLoading = false;

constructor() {
super(...arguments);
this.geofenceEventBus.subscribe(this.currentUser.companyId);
this.loadRecentEvents();
}

get events() {
return this.geofenceEventBus.events;
}

@action
async loadRecentEvents() {
this.isLoading = true;

try {
const response = await this.fetch.get('geofences/events', { per_page: 20 });

if (response?.data) {
this.geofenceEventBus.seedEvents(response.data.map((event) => this.geofenceEventBus.normalizeEvent(event.event_type, event)));
}
} catch (error) {
// The live socket feed will still populate events if history loading fails.
} finally {
this.isLoading = false;
}
}

@action
badgeColorFor(eventType) {
const colors = {
'geofence.entered': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
'geofence.exited': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
'geofence.dwelled': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
};

return colors[eventType] ?? 'bg-gray-100 text-gray-800';
}

@action
labelFor(eventType) {
const labels = {
'geofence.entered': 'Entered',
'geofence.exited': 'Exited',
'geofence.dwelled': 'Dwelled',
};

return labels[eventType] ?? eventType;
}

@action
iconFor(eventType) {
const icons = {
'geofence.entered': 'arrow-right-to-bracket',
'geofence.exited': 'arrow-right-from-bracket',
'geofence.dwelled': 'clock',
};

return icons[eventType] ?? 'map-pin';
}

@action
subjectIconFor(subjectType) {
return subjectType === 'vehicle' ? 'car' : 'id-card';
}

@action
geofenceTypeLabel(type) {
if (type === 'service_area') {
return 'Service Area';
}

if (type === 'zone') {
return 'Zone';
}

return type ?? 'Geofence';
}

@action
hasSecondaryMeta(event) {
return Boolean(event.vehiclePlate || event.orderPublicId || event.dwellMinutes || event.latitude || event.longitude);
}

@action
clearFeed() {
this.geofenceEventBus.clearFeed();
}
}
83 changes: 83 additions & 0 deletions addon/components/map/leaflet-live-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export default class MapLeafletLiveMapComponent extends Component {
@service intl;
@service universe;
@service('universe/menu-service') menuService;
@service geofenceEventBus;
@service currentUser;

/** properties */
id = guidFor(this);
Expand Down Expand Up @@ -57,6 +59,19 @@ export default class MapLeafletLiveMapComponent extends Component {

// Ensure we have valid coordinates on initialization
this.#updateCoordinatesFromLocation();

if (this.currentUser.companyId) {
this.geofenceEventBus.subscribe(this.currentUser.companyId);
} else {
this._currentUserLoadedHandler = () => this.geofenceEventBus.subscribe(this.currentUser.companyId);
this.currentUser.on('user.loaded', this._currentUserLoadedHandler);
}

// Subscribe to geofence events so the live map can react to boundary crossings
this._geofenceEnteredHandler = this.#handleGeofenceEntered.bind(this);
this._geofenceExitedHandler = this.#handleGeofenceExited.bind(this);
this.universe.on('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
this.universe.on('fleet-ops.geofence.exited', this._geofenceExitedHandler);
}

willDestroy() {
Expand All @@ -67,6 +82,18 @@ export default class MapLeafletLiveMapComponent extends Component {
this.universe.off('user.located', this._locationUpdateHandler);
this._locationUpdateHandler = null;
}
if (this._currentUserLoadedHandler) {
this.currentUser.off('user.loaded', this._currentUserLoadedHandler);
this._currentUserLoadedHandler = null;
}
if (this._geofenceEnteredHandler) {
this.universe.off('fleet-ops.geofence.entered', this._geofenceEnteredHandler);
this._geofenceEnteredHandler = null;
}
if (this._geofenceExitedHandler) {
this.universe.off('fleet-ops.geofence.exited', this._geofenceExitedHandler);
this._geofenceExitedHandler = null;
}
}

@action didLoad({ target: map }) {
Expand Down Expand Up @@ -259,6 +286,62 @@ export default class MapLeafletLiveMapComponent extends Component {
return 14;
}

/**
* Handles a geofence.entered event from the GeofenceEventBus.
* Briefly highlights the geofence layer on the map to provide visual feedback.
*
* @param {Object} event - Normalised geofence event object
*/
#handleGeofenceEntered(event) {
debug(`[LiveMap] geofence.entered — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
this.#flashGeofenceLayer(event.geofenceUuid, '#22c55e'); // green
}

/**
* Handles a geofence.exited event from the GeofenceEventBus.
* Briefly highlights the geofence layer on the map to provide visual feedback.
*
* @param {Object} event - Normalised geofence event object
*/
#handleGeofenceExited(event) {
debug(`[LiveMap] geofence.exited — driver: ${event.driverName}, geofence: ${event.geofenceName}`);
this.#flashGeofenceLayer(event.geofenceUuid, '#ef4444'); // red
}

/**
* Briefly changes the fill colour of a geofence polygon layer on the map
* to provide visual feedback when a driver enters or exits.
*
* @param {string} geofenceUuid - UUID of the zone or service area
* @param {string} flashColor - Hex colour to flash
*/
#flashGeofenceLayer(geofenceUuid, flashColor) {
if (!geofenceUuid || !this.map) {
return;
}

// Iterate over all Leaflet layers to find the matching geofence polygon
this.map.eachLayer((layer) => {
const model = layer._model;
if (model && model.uuid === geofenceUuid && typeof layer.setStyle === 'function') {
const originalStyle = {
color: layer.options.color,
fillColor: layer.options.fillColor,
weight: layer.options.weight,
};

// Flash to the event colour
layer.setStyle({ color: flashColor, fillColor: flashColor, weight: 3 });

// Restore original style after 2 seconds
setTimeout(() => {
if (!layer._map) return; // layer may have been removed
layer.setStyle(originalStyle);
}, 2000);
}
});
}

/**
* Handles location updates from the location service
* @param {Object} coordinates - The new coordinates
Expand Down
Loading
Loading