500 lines
23 KiB
PHP
500 lines
23 KiB
PHP
<div class="space-y-6" wire:key="geofence-component">
|
|
{{-- Page Header --}}
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<flux:heading size="lg">Geofence Management</flux:heading>
|
|
<flux:subheading>Create and manage geographical boundaries</flux:subheading>
|
|
</div>
|
|
<div class="flex space-x-3">
|
|
<flux:button wire:click="showGeofenceModal" variant="primary" size="sm" icon="plus">
|
|
Create Geofence
|
|
</flux:button>
|
|
<flux:button wire:click="refreshGeofences" variant="ghost" size="sm" icon="arrow-path">
|
|
Refresh
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
{{-- Geofence List Panel --}}
|
|
<div class="lg:col-span-1">
|
|
<div class="bg-white shadow rounded-lg border border-zinc-200">
|
|
<div class="px-4 py-5 sm:p-6">
|
|
<flux:heading size="base" class="mb-4">Geofences</flux:heading>
|
|
|
|
<div class="space-y-3 max-h-96 overflow-y-auto">
|
|
@foreach($geofences as $geofence)
|
|
<div class="p-3 rounded-lg border border-gray-200 hover:border-blue-300 transition-colors">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="font-medium text-sm">{{ $geofence->name }}</span>
|
|
<div class="flex space-x-1">
|
|
<flux:button wire:click="selectGeofence({{ $geofence->id }})"
|
|
variant="{{ $selectedGeofence === $geofence->id ? 'primary' : 'ghost' }}"
|
|
size="xs" icon="eye">
|
|
</flux:button>
|
|
<flux:button wire:click="editGeofence({{ $geofence->id }})"
|
|
variant="ghost" size="xs" icon="pencil">
|
|
</flux:button>
|
|
<flux:button wire:click="deleteGeofence({{ $geofence->id }})"
|
|
variant="ghost" size="xs" icon="trash"
|
|
wire:confirm="Are you sure you want to delete this geofence?">
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-xs text-gray-500 space-y-1">
|
|
<div>Type: {{ ucfirst($geofence->type) }}</div>
|
|
@if($geofence->description)
|
|
<div>{{ Str::limit($geofence->description, 50) }}</div>
|
|
@endif
|
|
<div class="flex items-center space-x-2">
|
|
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium
|
|
{{ $geofence->is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }}">
|
|
{{ $geofence->is_active ? 'Active' : 'Inactive' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Assigned Devices --}}
|
|
@if($geofence->devices->count() > 0)
|
|
<div class="mt-2 pt-2 border-t border-gray-100">
|
|
<div class="text-xs text-gray-500">
|
|
{{ $geofence->devices->count() }} device(s) assigned
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
@endforeach
|
|
|
|
@if($geofences->isEmpty())
|
|
<div class="text-center py-6 text-gray-500">
|
|
<div class="text-sm">No geofences created yet</div>
|
|
<flux:button wire:click="showGeofenceModal" variant="ghost" size="sm" class="mt-2">
|
|
Create your first geofence
|
|
</flux:button>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Drawing Tools --}}
|
|
<div class="bg-white shadow rounded-lg border border-zinc-200 mt-4">
|
|
<div class="px-4 py-5 sm:p-6">
|
|
<flux:heading size="base" class="mb-4">Drawing Tools</flux:heading>
|
|
|
|
<div class="space-y-3">
|
|
<flux:button id="drawPolygonBtn" variant="outline" size="sm" class="w-full" icon="square-3-stack-3d">
|
|
Draw Polygon
|
|
</flux:button>
|
|
<flux:button id="drawCircleBtn" variant="outline" size="sm" class="w-full" icon="stop">
|
|
Draw Circle
|
|
</flux:button>
|
|
<flux:button id="drawRectangleBtn" variant="outline" size="sm" class="w-full" icon="stop">
|
|
Draw Rectangle
|
|
</flux:button>
|
|
<flux:button id="editGeofenceBtn" variant="outline" size="sm" class="w-full" icon="pencil" disabled>
|
|
Edit Selected
|
|
</flux:button>
|
|
<flux:button id="deleteDrawnBtn" variant="outline" size="sm" class="w-full" icon="trash" disabled>
|
|
Delete Drawn
|
|
</flux:button>
|
|
<flux:button id="clearAllBtn" variant="ghost" size="sm" class="w-full" icon="x-mark">
|
|
Clear All
|
|
</flux:button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Map Area --}}
|
|
<div class="lg:col-span-3">
|
|
<div class="bg-white shadow rounded-lg border border-zinc-200 h-[600px] relative">
|
|
<div id="geofenceMap" class="w-full h-full rounded-lg"></div>
|
|
|
|
{{-- Map Instructions --}}
|
|
<div class="absolute top-4 left-4 bg-white shadow-lg rounded-lg p-3 border border-zinc-200 max-w-xs">
|
|
<flux:heading size="sm" class="mb-2">Instructions</flux:heading>
|
|
<div class="text-xs text-gray-600 space-y-1">
|
|
<div>• Use drawing tools to create geofences</div>
|
|
<div>• Click on existing geofences to select them</div>
|
|
<div>• Drag corners to edit shapes</div>
|
|
<div>• Right-click to finish drawing</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Create/Edit Geofence Modal --}}
|
|
@if($showModal)
|
|
<div class="fixed inset-0 z-50 overflow-y-auto" wire:key="geofence-modal">
|
|
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
|
|
<div class="absolute inset-0 bg-gray-500 opacity-75" wire:click="closeModal"></div>
|
|
</div>
|
|
|
|
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
|
<form wire:submit="saveGeofence">
|
|
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
<div class="space-y-4">
|
|
<flux:heading size="lg">{{ $editingGeofence ? 'Edit' : 'Create' }} Geofence</flux:heading>
|
|
|
|
<div>
|
|
<flux:field>
|
|
<flux:label>Name</flux:label>
|
|
<flux:input wire:model="geofenceName" placeholder="Enter geofence name" required />
|
|
<flux:error name="geofenceName" />
|
|
</flux:field>
|
|
</div>
|
|
|
|
<div>
|
|
<flux:field>
|
|
<flux:label>Description</flux:label>
|
|
<flux:textarea wire:model="geofenceDescription" placeholder="Optional description" rows="3" />
|
|
<flux:error name="geofenceDescription" />
|
|
</flux:field>
|
|
</div>
|
|
|
|
<div>
|
|
<flux:field>
|
|
<flux:label>Type</flux:label>
|
|
<flux:select wire:model.live="geofenceType" required>
|
|
<option value="">Select type</option>
|
|
<option value="entry">Entry Alert</option>
|
|
<option value="exit">Exit Alert</option>
|
|
<option value="both">Entry & Exit Alert</option>
|
|
<option value="zone">Safe Zone</option>
|
|
</flux:select>
|
|
<flux:error name="geofenceType" />
|
|
</flux:field>
|
|
</div>
|
|
|
|
<div>
|
|
<flux:field>
|
|
<flux:label>Assign Devices</flux:label>
|
|
<div class="space-y-2 max-h-32 overflow-y-auto border border-gray-200 rounded-md p-2">
|
|
@foreach($devices as $device)
|
|
<label class="flex items-center space-x-2">
|
|
<input type="checkbox"
|
|
wire:model="selectedDevicesForGeofence"
|
|
value="{{ $device->id }}"
|
|
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
|
<span class="text-sm">{{ $device->name }}</span>
|
|
</label>
|
|
@endforeach
|
|
</div>
|
|
<flux:error name="selectedDevicesForGeofence" />
|
|
</flux:field>
|
|
</div>
|
|
|
|
<div class="flex items-center space-x-2">
|
|
<input type="checkbox"
|
|
wire:model="geofenceActive"
|
|
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
|
<flux:label>Active</flux:label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
<flux:button type="submit" variant="primary" class="sm:ml-3">
|
|
{{ $editingGeofence ? 'Update' : 'Create' }}
|
|
</flux:button>
|
|
<flux:button type="button" wire:click="closeModal" variant="ghost">
|
|
Cancel
|
|
</flux:button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Load Leaflet and Leaflet.draw Assets --}}
|
|
@assets
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
crossorigin=""/>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css"/>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
crossorigin=""></script>
|
|
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
|
|
@endassets
|
|
|
|
@script
|
|
<script>
|
|
let geofenceMap = null;
|
|
let drawnItems = null;
|
|
let drawControl = null;
|
|
let geofencePolygons = {};
|
|
let currentDrawnShape = null;
|
|
|
|
// Initialize the map
|
|
function initGeofenceMap() {
|
|
if (geofenceMap) {
|
|
geofenceMap.remove();
|
|
}
|
|
|
|
const mapCenter = @json($mapCenter);
|
|
const zoomLevel = @json($zoomLevel);
|
|
|
|
geofenceMap = L.map('geofenceMap').setView([mapCenter.lat, mapCenter.lng], zoomLevel);
|
|
|
|
// Add tile layer
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 19
|
|
}).addTo(geofenceMap);
|
|
|
|
// Initialize draw control
|
|
drawnItems = new L.FeatureGroup();
|
|
geofenceMap.addLayer(drawnItems);
|
|
|
|
// Setup custom drawing controls
|
|
setupDrawingControls();
|
|
|
|
// Load existing geofences
|
|
loadGeofences();
|
|
|
|
// Setup event listeners
|
|
setupMapEvents();
|
|
}
|
|
|
|
function setupDrawingControls() {
|
|
// Polygon drawing
|
|
document.getElementById('drawPolygonBtn').addEventListener('click', function() {
|
|
if (currentDrawnShape) {
|
|
drawnItems.removeLayer(currentDrawnShape);
|
|
}
|
|
startPolygonDraw();
|
|
});
|
|
|
|
// Circle drawing
|
|
document.getElementById('drawCircleBtn').addEventListener('click', function() {
|
|
if (currentDrawnShape) {
|
|
drawnItems.removeLayer(currentDrawnShape);
|
|
}
|
|
startCircleDraw();
|
|
});
|
|
|
|
// Rectangle drawing
|
|
document.getElementById('drawRectangleBtn').addEventListener('click', function() {
|
|
if (currentDrawnShape) {
|
|
drawnItems.removeLayer(currentDrawnShape);
|
|
}
|
|
startRectangleDraw();
|
|
});
|
|
|
|
// Clear all
|
|
document.getElementById('clearAllBtn').addEventListener('click', function() {
|
|
drawnItems.clearLayers();
|
|
currentDrawnShape = null;
|
|
updateDrawButtons();
|
|
});
|
|
|
|
// Delete drawn shape
|
|
document.getElementById('deleteDrawnBtn').addEventListener('click', function() {
|
|
if (currentDrawnShape) {
|
|
drawnItems.removeLayer(currentDrawnShape);
|
|
currentDrawnShape = null;
|
|
updateDrawButtons();
|
|
}
|
|
});
|
|
}
|
|
|
|
function startPolygonDraw() {
|
|
const polygon = new L.Draw.Polygon(geofenceMap, {
|
|
shapeOptions: {
|
|
color: '#3b82f6',
|
|
fillColor: '#3b82f6',
|
|
fillOpacity: 0.2,
|
|
weight: 2
|
|
}
|
|
});
|
|
polygon.enable();
|
|
}
|
|
|
|
function startCircleDraw() {
|
|
const circle = new L.Draw.Circle(geofenceMap, {
|
|
shapeOptions: {
|
|
color: '#10b981',
|
|
fillColor: '#10b981',
|
|
fillOpacity: 0.2,
|
|
weight: 2
|
|
}
|
|
});
|
|
circle.enable();
|
|
}
|
|
|
|
function startRectangleDraw() {
|
|
const rectangle = new L.Draw.Rectangle(geofenceMap, {
|
|
shapeOptions: {
|
|
color: '#f59e0b',
|
|
fillColor: '#f59e0b',
|
|
fillOpacity: 0.2,
|
|
weight: 2
|
|
}
|
|
});
|
|
rectangle.enable();
|
|
}
|
|
|
|
function setupMapEvents() {
|
|
// Handle shape creation
|
|
geofenceMap.on(L.Draw.Event.CREATED, function(e) {
|
|
const layer = e.layer;
|
|
|
|
if (currentDrawnShape) {
|
|
drawnItems.removeLayer(currentDrawnShape);
|
|
}
|
|
|
|
drawnItems.addLayer(layer);
|
|
currentDrawnShape = layer;
|
|
updateDrawButtons();
|
|
|
|
// Auto-open modal with shape data
|
|
const coordinates = getShapeCoordinates(layer);
|
|
$wire.set('tempCoordinates', coordinates);
|
|
$wire.showGeofenceModal();
|
|
});
|
|
|
|
// Handle shape editing
|
|
geofenceMap.on(L.Draw.Event.EDITED, function(e) {
|
|
e.layers.eachLayer(function(layer) {
|
|
const coordinates = getShapeCoordinates(layer);
|
|
if (layer === currentDrawnShape) {
|
|
$wire.set('tempCoordinates', coordinates);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function getShapeCoordinates(layer) {
|
|
if (layer instanceof L.Circle) {
|
|
const center = layer.getLatLng();
|
|
return {
|
|
type: 'circle',
|
|
center: [center.lat, center.lng],
|
|
radius: layer.getRadius()
|
|
};
|
|
} else if (layer instanceof L.Polygon || layer instanceof L.Rectangle) {
|
|
const latLngs = layer.getLatLngs()[0];
|
|
return {
|
|
type: 'polygon',
|
|
coordinates: latLngs.map(latlng => [latlng.lat, latlng.lng])
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function loadGeofences() {
|
|
const geofences = @json($geofences);
|
|
|
|
Object.values(geofencePolygons).forEach(polygon => {
|
|
geofenceMap.removeLayer(polygon);
|
|
});
|
|
geofencePolygons = {};
|
|
|
|
geofences.forEach(geofence => {
|
|
if (geofence.coordinates) {
|
|
let layer;
|
|
const coords = JSON.parse(geofence.coordinates);
|
|
|
|
if (coords.type === 'circle') {
|
|
layer = L.circle([coords.center[0], coords.center[1]], {
|
|
radius: coords.radius,
|
|
color: geofence.is_active ? '#10b981' : '#6b7280',
|
|
fillColor: geofence.is_active ? '#10b981' : '#6b7280',
|
|
fillOpacity: 0.1,
|
|
weight: 2
|
|
});
|
|
} else if (coords.type === 'polygon') {
|
|
layer = L.polygon(coords.coordinates, {
|
|
color: geofence.is_active ? '#3b82f6' : '#6b7280',
|
|
fillColor: geofence.is_active ? '#3b82f6' : '#6b7280',
|
|
fillOpacity: 0.1,
|
|
weight: 2
|
|
});
|
|
}
|
|
|
|
if (layer) {
|
|
layer.bindPopup(`
|
|
<div class="p-2">
|
|
<h4 class="font-semibold">${geofence.name}</h4>
|
|
<p class="text-sm text-gray-600">${geofence.description || ''}</p>
|
|
<p class="text-xs text-gray-500">Type: ${geofence.type}</p>
|
|
<p class="text-xs text-gray-500">Status: ${geofence.is_active ? 'Active' : 'Inactive'}</p>
|
|
</div>
|
|
`);
|
|
|
|
layer.on('click', function() {
|
|
$wire.selectGeofence(geofence.id);
|
|
});
|
|
|
|
layer.addTo(geofenceMap);
|
|
geofencePolygons[geofence.id] = layer;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateDrawButtons() {
|
|
const editBtn = document.getElementById('editGeofenceBtn');
|
|
const deleteBtn = document.getElementById('deleteDrawnBtn');
|
|
|
|
if (currentDrawnShape) {
|
|
editBtn.disabled = false;
|
|
editBtn.classList.remove('opacity-50');
|
|
deleteBtn.disabled = false;
|
|
deleteBtn.classList.remove('opacity-50');
|
|
} else {
|
|
editBtn.disabled = true;
|
|
editBtn.classList.add('opacity-50');
|
|
deleteBtn.disabled = true;
|
|
deleteBtn.classList.add('opacity-50');
|
|
}
|
|
}
|
|
|
|
// Initialize map when component loads
|
|
initGeofenceMap();
|
|
|
|
// Listen for Livewire events
|
|
$wire.on('geofencesUpdated', () => {
|
|
loadGeofences();
|
|
});
|
|
|
|
$wire.on('clearDrawnShape', () => {
|
|
if (currentDrawnShape) {
|
|
drawnItems.removeLayer(currentDrawnShape);
|
|
currentDrawnShape = null;
|
|
updateDrawButtons();
|
|
}
|
|
});
|
|
|
|
// Cleanup when component is destroyed
|
|
document.addEventListener('livewire:navigate', function() {
|
|
if (geofenceMap) {
|
|
geofenceMap.remove();
|
|
geofenceMap = null;
|
|
}
|
|
});
|
|
</script>
|
|
@endscript
|
|
|
|
<style>
|
|
.leaflet-draw-toolbar a {
|
|
background-image: url('https://unpkg.com/leaflet-draw@1.0.4/dist/images/spritesheet.png');
|
|
}
|
|
|
|
.leaflet-retina .leaflet-draw-toolbar a {
|
|
background-image: url('https://unpkg.com/leaflet-draw@1.0.4/dist/images/spritesheet-2x.png');
|
|
}
|
|
|
|
#geofenceMap {
|
|
z-index: 1;
|
|
}
|
|
</style>
|
|
</div>
|