gps_system/resources/views/livewire/live-tracking.blade.php
sackey 6b878bb0a0
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
Initial commit
2025-09-12 16:19:56 +00:00

558 lines
26 KiB
PHP

<div class="min-h-screen bg-gray-50" wire:key="live-tracking-component">
{{-- Page Header --}}
<div class="bg-white shadow-sm border-b border-gray-200 px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<flux:button wire:click="toggleSidebar" variant="ghost" size="sm" icon="bars-3">
{{ $sidebarCollapsed ? 'Show' : 'Hide' }} Panel
</flux:button>
<div>
<flux:heading size="lg">Live Tracking</flux:heading>
<flux:subheading>Real-time GPS tracking Last updated: {{ $lastUpdate }}</flux:subheading>
</div>
</div>
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-2 text-sm">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
{{ $onlineCount }} Online
</span>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
{{ $offlineCount }} Offline
</span>
</div>
<flux:button wire:click="refreshData" variant="primary" size="sm" icon="arrow-path">
Refresh Now
</flux:button>
</div>
</div>
</div>
<div class="flex h-[calc(100vh-100px)]">
{{-- Sidebar Panel --}}
<div class="bg-white shadow-lg border-r border-gray-200 transition-all duration-300 {{ $sidebarCollapsed ? 'w-0 overflow-hidden' : 'w-80' }}">
<div class="p-4 h-full overflow-y-auto">
{{-- Device List --}}
<div class="mb-6">
<div class="flex items-center justify-between mb-4">
<flux:heading size="base">Devices ({{ $totalCount }})</flux:heading>
<flux:button wire:click="toggleAllDevices" variant="ghost" size="xs">
{{ $allDevicesSelected ? 'Deselect All' : 'Select All' }}
</flux:button>
</div>
<div class="space-y-2 max-h-64 overflow-y-auto">
@foreach($devices as $device)
@php
$deviceData = collect($deviceDetails)->firstWhere('device_id', $device->id);
$status = $deviceData['status'] ?? 'offline';
$isSelected = in_array($device->id, $selectedDevices);
@endphp
<div class="p-3 rounded-lg border cursor-pointer {{ $isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300' }}"
wire:click="toggleDevice({{ $device->id }})">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<input type="checkbox"
{{ $isSelected ? 'checked' : '' }}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<div class="flex-1">
<div class="flex items-center space-x-2">
<span class="font-medium text-sm">{{ $device->name }}</span>
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium
{{ $status === 'online' ? 'bg-green-100 text-green-800' :
($status === 'idle' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800') }}">
{{ ucfirst($status) }}
</span>
</div>
<div class="text-xs text-gray-500 mt-1">
{{ $device->unique_id }}
@if($deviceData && $deviceData['speed'])
{{ $deviceData['speed'] }} km/h
@endif
</div>
</div>
</div>
@if($isSelected && $deviceData && $deviceData['latitude'])
<div class="flex space-x-1">
<flux:button wire:click.stop="selectDevice({{ $device->id }})" variant="ghost" size="xs" icon="map-pin">
</flux:button>
<flux:button wire:click.stop="followDevice({{ $device->id }})"
variant="{{ $followDevice === $device->id ? 'primary' : 'ghost' }}"
size="xs" icon="eye">
</flux:button>
</div>
@endif
</div>
@if($isSelected && $deviceData)
<div class="mt-2 pt-2 border-t border-gray-100 text-xs text-gray-600">
@if($deviceData['latitude'])
<div>📍 {{ $deviceData['address'] }}</div>
<div>🕒 {{ \Carbon\Carbon::parse($deviceData['last_update'])->diffForHumans() }}</div>
@else
<div class="text-red-500">No GPS signal</div>
@endif
</div>
@endif
</div>
@endforeach
</div>
</div>
{{-- Map Controls --}}
<div class="space-y-4">
<div>
<flux:label>Map Provider</flux:label>
<flux:select wire:model.live="mapProvider" wire:change="changeMapProvider($event.target.value)" size="sm" class="w-full">
@foreach($availableProviders as $key => $provider)
<option value="{{ $key }}">{{ $provider['name'] }} {{ $provider['free'] ? '(Free)' : '(Premium)' }}</option>
@endforeach
</flux:select>
</div>
<div>
<flux:label>Map Style</flux:label>
<flux:select wire:model.live="mapStyle" wire:change="changeMapStyle($event.target.value)" size="sm" class="w-full">
@foreach($availableStyles as $key => $style)
<option value="{{ $key }}">{{ $style }}</option>
@endforeach
</flux:select>
</div>
<div>
<div class="flex items-center justify-between mb-2">
<flux:label>Show Trails</flux:label>
<flux:button wire:click="toggleShowTrails"
variant="{{ $showTrails ? 'primary' : 'outline' }}"
size="xs">{{ $showTrails ? 'ON' : 'OFF' }}</flux:button>
</div>
@if($showTrails)
<flux:select wire:model.live="trailDuration" size="sm" class="w-full">
<option value="1">1 Hour</option>
<option value="6">6 Hours</option>
<option value="12">12 Hours</option>
<option value="24">24 Hours</option>
<option value="48">48 Hours</option>
</flux:select>
@endif
</div>
<div>
<flux:label>Auto Refresh Interval</flux:label>
<flux:select wire:model.live="refreshInterval" size="sm" class="w-full">
<option value="5">5 seconds</option>
<option value="10">10 seconds</option>
<option value="15">15 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="300">5 minutes</option>
</flux:select>
</div>
<div class="flex items-center justify-between">
<flux:label>Show Offline Devices</flux:label>
<flux:button wire:click="toggleOfflineDevices"
variant="{{ $showOfflineDevices ? 'primary' : 'outline' }}"
size="xs">{{ $showOfflineDevices ? 'ON' : 'OFF' }}</flux:button>
</div>
<flux:button wire:click="autoCenter" variant="outline" size="sm" class="w-full" icon="map">
Center Map
</flux:button>
</div>
</div>
</div>
{{-- Map Area --}}
<div class="flex-1 relative">
<div id="liveTrackingMap" class="w-full h-full"></div>
{{-- Map Loading Overlay --}}
<div wire:loading.flex class="absolute inset-0 bg-white bg-opacity-75 items-center justify-center">
<div class="text-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<div class="mt-2 text-sm text-gray-600">Updating positions...</div>
</div>
</div>
{{-- Device Details Panel --}}
@if($selectedDevice)
@php
$deviceData = collect($deviceDetails)->firstWhere('device_id', $selectedDevice);
@endphp
@if($deviceData)
<div class="absolute top-4 right-4 bg-white shadow-lg rounded-lg p-4 border border-gray-200 max-w-sm">
<div class="flex items-center justify-between mb-3">
<flux:heading size="sm">{{ $deviceData['device_name'] }}</flux:heading>
<flux:button wire:click="$set('selectedDevice', null)" variant="ghost" size="xs" icon="x-mark">
</flux:button>
</div>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Status:</span>
<span class="font-medium {{ $deviceData['status'] === 'online' ? 'text-green-600' : 'text-red-600' }}">
{{ ucfirst($deviceData['status']) }}
</span>
</div>
@if($deviceData['latitude'])
<div class="flex justify-between">
<span class="text-gray-600">Speed:</span>
<span class="font-medium">{{ $deviceData['speed'] }} km/h</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Accuracy:</span>
<span class="font-medium">{{ $deviceData['accuracy'] }}m</span>
</div>
<div class="pt-2 border-t border-gray-100">
<div class="text-gray-600">Location:</div>
<div class="font-medium text-xs">{{ $deviceData['address'] }}</div>
</div>
<div class="pt-1">
<div class="text-gray-600">Coordinates:</div>
<div class="font-mono text-xs">{{ number_format($deviceData['latitude'], 6) }}, {{ number_format($deviceData['longitude'], 6) }}</div>
</div>
<div class="pt-1">
<div class="text-gray-600">Last Update:</div>
<div class="text-xs">{{ \Carbon\Carbon::parse($deviceData['last_update'])->format('M j, Y H:i:s') }}</div>
</div>
@else
<div class="text-center py-4 text-gray-500">
No GPS data available
</div>
@endif
</div>
</div>
@endif
@endif
</div>
</div>
</div>
{{-- JavaScript for Map and Auto-refresh --}}
<script>
let map;
let markers = {};
let markersLayer;
let autoRefreshInterval;
let currentProvider = '{{ $mapProvider }}';
let currentStyle = '{{ $mapStyle }}';
document.addEventListener('DOMContentLoaded', function() {
// Small delay to ensure DOM is fully ready
setTimeout(() => {
initializeMap();
setupAutoRefresh();
setupEventListeners();
}, 100);
});
function initializeMap() {
// Wait for the map container to be available
const mapContainer = document.getElementById('liveTrackingMap');
if (!mapContainer) {
console.error('Map container not found, retrying...');
setTimeout(initializeMap, 100);
return;
}
// Check if map is already initialized
if (map) {
map.remove();
map = null;
}
try {
// Initialize Leaflet map
map = L.map('liveTrackingMap').setView([{{ $mapCenter['lat'] }}, {{ $mapCenter['lng'] }}], {{ $zoomLevel }});
// Create markers layer group
markersLayer = L.layerGroup().addTo(map);
// Load appropriate tile layer based on provider
loadMapProvider(currentProvider, currentStyle);
updateDevicesOnMap();
console.log('Map initialized successfully');
} catch (error) {
console.error('Failed to initialize map:', error);
setTimeout(initializeMap, 500);
}
}
function loadMapProvider(provider, style = null) {
if (!map) {
console.error('Map not initialized');
return;
}
// Remove existing tile layers
map.eachLayer(function(layer) {
if (layer instanceof L.TileLayer) {
map.removeLayer(layer);
}
});
let tileLayer;
switch(provider) {
case 'openstreetmap':
tileLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
});
break;
case 'cartodb':
let cartoUrl;
switch(style) {
case 'dark':
cartoUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
break;
case 'voyager':
cartoUrl = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png';
break;
default:
cartoUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
}
tileLayer = L.tileLayer(cartoUrl, {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
});
break;
case 'satellite':
tileLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
});
break;
case 'mapbox':
@if(config('services.maps.providers.mapbox.enabled'))
const mapboxToken = '{{ config("services.maps.providers.mapbox.api_key") }}';
const mapboxStyle = style || 'streets-v11';
tileLayer = L.tileLayer(`https://api.mapbox.com/styles/v1/mapbox/${mapboxStyle}/tiles/{z}/{x}/{y}?access_token=${mapboxToken}`, {
attribution: '&copy; <a href="https://www.mapbox.com/">Mapbox</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
tileSize: 512,
zoomOffset: -1
});
@else
console.warn('Mapbox API key not configured, falling back to OpenStreetMap');
loadMapProvider('openstreetmap');
return;
@endif
break;
case 'google':
@if(config('services.maps.providers.google.enabled'))
// For Google Maps, we would need to switch to Google Maps API
console.log('Google Maps integration would require separate implementation');
// Fall back to OpenStreetMap for now
loadMapProvider('openstreetmap');
return;
@else
console.warn('Google Maps API key not configured, falling back to OpenStreetMap');
loadMapProvider('openstreetmap');
return;
@endif
break;
default:
console.warn(`Unknown provider: ${provider}, falling back to OpenStreetMap`);
loadMapProvider('openstreetmap');
return;
}
try {
if (tileLayer && map) {
tileLayer.addTo(map);
console.log(`Map provider ${provider} loaded successfully`);
} else {
console.error('Failed to create tile layer or map not available');
}
} catch (error) {
console.error('Error adding tile layer to map:', error);
// Fallback to OpenStreetMap
if (provider !== 'openstreetmap') {
loadMapProvider('openstreetmap');
}
}
}
function setupAutoRefresh() {
clearInterval(autoRefreshInterval);
autoRefreshInterval = setInterval(() => {
@this.call('refreshData');
}, {{ $refreshInterval }} * 1000);
}
function setupEventListeners() {
// Listen for Livewire events
window.addEventListener('map-updated', event => {
updateDevicesOnMap();
});
window.addEventListener('positions-updated', event => {
updateDevicesOnMap();
});
window.addEventListener('map-style-changed', event => {
currentStyle = event.detail;
loadMapProvider(currentProvider, currentStyle);
});
window.addEventListener('map-provider-changed', event => {
if (event.detail && event.detail.provider) {
currentProvider = event.detail.provider;
// Check if config exists and has styles, otherwise fallback
if (event.detail.config && event.detail.config.styles) {
currentStyle = Object.keys(event.detail.config.styles)[0];
} else if (event.detail.styles) {
currentStyle = Object.keys(event.detail.styles)[0];
} else {
currentStyle = 'standard'; // fallback
}
loadMapProvider(currentProvider, currentStyle);
}
});
window.addEventListener('sidebar-toggled', event => {
// Trigger map resize after sidebar animation
setTimeout(() => {
if (map) {
map.invalidateSize();
}
}, 300);
});
window.addEventListener('trails-toggled', event => {
// Implementation for trails
console.log('Trails toggled:', event.detail);
});
// Update auto-refresh when interval changes
Livewire.on('refreshIntervalChanged', () => {
setupAutoRefresh();
});
// Reinitialize map after Livewire updates
document.addEventListener('livewire:navigated', () => {
setTimeout(initializeMap, 100);
});
// Handle Livewire morphing
document.addEventListener('livewire:updated', () => {
// Only reinitialize if map container exists and map is not initialized
const mapContainer = document.getElementById('liveTrackingMap');
if (mapContainer && !map) {
console.log('Reinitializing map after Livewire update');
setTimeout(initializeMap, 100);
} else if (map) {
// Just update the devices if map exists
console.log('Updating devices on existing map');
updateDevicesOnMap();
}
});
}
function updateDevicesOnMap() {
if (!map || !markersLayer) {
console.warn('Map or markers layer not initialized, skipping device update');
return;
}
try {
// Clear existing markers
markersLayer.clearLayers();
// Add new markers
const devices = @json($deviceDetails);
if (!devices || !Array.isArray(devices)) {
console.warn('No device data available');
return;
}
devices.forEach(device => {
if (device.latitude && device.longitude && !isNaN(device.latitude) && !isNaN(device.longitude)) {
try {
// Create custom icon based on device status
const iconColor = device.status === 'online' ? '#10B981' : device.status === 'idle' ? '#F59E0B' : '#EF4444';
const icon = L.divIcon({
className: 'custom-device-marker',
html: `<div style="background-color: ${iconColor}; width: 16px; height: 16px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 4px rgba(0,0,0,0.3);"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
const marker = L.marker([device.latitude, device.longitude], { icon: icon })
.bindPopup(`
<div class="p-2 min-w-48">
<h3 class="font-bold text-base mb-2">${device.device_name}</h3>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-600">Status:</span>
<span class="font-medium" style="color: ${iconColor}">${device.status.charAt(0).toUpperCase() + device.status.slice(1)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Speed:</span>
<span class="font-medium">${device.speed} km/h</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Accuracy:</span>
<span class="font-medium">${device.accuracy}m</span>
</div>
<div class="pt-2 border-t border-gray-100">
<div class="text-gray-600 mb-1">Location:</div>
<div class="font-medium text-xs">${device.address}</div>
</div>
<div class="pt-1">
<div class="text-gray-600 mb-1">Last Update:</div>
<div class="text-xs">${new Date(device.last_update).toLocaleString()}</div>
</div>
</div>
</div>
`, {
maxWidth: 300
});
marker.on('click', () => {
@this.call('selectDevice', device.device_id);
});
markersLayer.addLayer(marker);
} catch (markerError) {
console.error('Error adding marker for device:', device.device_name, markerError);
}
}
});
// Update map center if following a device
@if($followDevice)
const followedDevice = devices.find(d => d.device_id === {{ $followDevice }});
if (followedDevice && followedDevice.latitude && followedDevice.longitude) {
map.setView([followedDevice.latitude, followedDevice.longitude], map.getZoom());
}
@endif
} catch (error) {
console.error('Error updating devices on map:', error);
}
}
// Listen for Livewire property updates
document.addEventListener('livewire:updated', () => {
setupAutoRefresh();
});
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
clearInterval(autoRefreshInterval);
});
</script>