769 lines
30 KiB
JavaScript
769 lines
30 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import api from '../services/api';
|
|
import { format } from 'date-fns';
|
|
import { t } from '../utils/tempTranslations';
|
|
import {
|
|
PlusIcon,
|
|
PencilIcon,
|
|
TrashIcon,
|
|
ServerIcon,
|
|
MapPinIcon,
|
|
SignalIcon,
|
|
BoltIcon
|
|
} from '@heroicons/react/24/outline';
|
|
|
|
const Devices = () => {
|
|
const navigate = useNavigate();
|
|
const [devices, setDevices] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [editingDevice, setEditingDevice] = useState(null);
|
|
const [filter, setFilter] = useState('all'); // 'all', 'approved', 'pending'
|
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
|
const [selectedDevice, setSelectedDevice] = useState(null);
|
|
|
|
useEffect(() => {
|
|
fetchDevices();
|
|
}, []);
|
|
|
|
const fetchDevices = async () => {
|
|
try {
|
|
const response = await api.get('/devices?include_stats=true');
|
|
setDevices(response.data.data);
|
|
} catch (error) {
|
|
console.error('Error fetching devices:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAddDevice = () => {
|
|
setEditingDevice(null);
|
|
setShowAddModal(true);
|
|
};
|
|
|
|
const handleEditDevice = (device) => {
|
|
setEditingDevice(device);
|
|
setShowAddModal(true);
|
|
};
|
|
|
|
const handleApproveDevice = async (deviceId) => {
|
|
try {
|
|
await api.post(`/devices/${deviceId}/approve`, { approved: true });
|
|
fetchDevices();
|
|
} catch (error) {
|
|
console.error('Error approving device:', error);
|
|
if (error.response?.status === 401 || error.response?.status === 403) {
|
|
alert('Your session has expired. Please log in again.');
|
|
return;
|
|
}
|
|
alert('Error approving device: ' + (error.response?.data?.message || error.message));
|
|
}
|
|
};
|
|
|
|
const handleRejectDevice = async (deviceId) => {
|
|
if (window.confirm(t('devices.confirmReject'))) {
|
|
try {
|
|
await api.post(`/devices/${deviceId}/approve`, { approved: false });
|
|
fetchDevices();
|
|
} catch (error) {
|
|
console.error('Error rejecting device:', error);
|
|
if (error.response?.status === 401 || error.response?.status === 403) {
|
|
alert('Your session has expired. Please log in again.');
|
|
return;
|
|
}
|
|
alert(t('devices.errorRejecting') + ' ' + (error.response?.data?.message || error.message));
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleViewDetails = (device) => {
|
|
setSelectedDevice(device);
|
|
setShowDetailsModal(true);
|
|
};
|
|
|
|
const handleViewOnMap = (device) => {
|
|
if (device.geo_lat && device.geo_lon) {
|
|
// Navigate to map with device information
|
|
navigate('/map', {
|
|
state: {
|
|
focusDevice: {
|
|
id: device.id,
|
|
name: device.name || `Device ${device.id}`,
|
|
lat: device.geo_lat,
|
|
lon: device.geo_lon,
|
|
status: device.stats?.status || 'unknown'
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
alert('Device location coordinates are not available');
|
|
}
|
|
};
|
|
|
|
const handleDeleteDevice = async (deviceId) => {
|
|
if (window.confirm(t('devices.confirmDelete'))) {
|
|
try {
|
|
await api.delete(`/devices/${deviceId}`);
|
|
fetchDevices();
|
|
} catch (error) {
|
|
console.error(t('devices.errorDeleting'), error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status) => {
|
|
switch (status) {
|
|
case 'online':
|
|
return 'bg-green-100 text-green-800';
|
|
case 'offline':
|
|
return 'bg-red-100 text-red-800';
|
|
default:
|
|
return 'bg-gray-100 text-gray-800';
|
|
}
|
|
};
|
|
|
|
const getSignalStrength = (lastHeartbeat) => {
|
|
if (!lastHeartbeat) return t('devices.unknown');
|
|
|
|
const timeSince = (new Date() - new Date(lastHeartbeat)) / 1000 / 60; // minutes
|
|
if (timeSince < 5) return t('devices.signalStrong');
|
|
if (timeSince < 15) return t('devices.signalGood');
|
|
if (timeSince < 60) return t('devices.signalWeak');
|
|
return t('devices.signalLost');
|
|
};
|
|
|
|
const filteredDevices = devices.filter(device => {
|
|
if (filter === 'approved') return device.is_approved;
|
|
if (filter === 'pending') return !device.is_approved;
|
|
return true; // 'all'
|
|
});
|
|
|
|
const pendingCount = devices.filter(device => !device.is_approved).length;
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
|
<span className="ml-4 text-gray-600">{t('devices.loading')}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
{t('devices.title')}
|
|
</h3>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
{t('devices.description')}
|
|
{pendingCount > 0 && (
|
|
<span className="ml-2 px-2 py-1 text-xs font-medium bg-yellow-200 text-yellow-800 rounded-full">
|
|
{pendingCount} {t('devices.pendingApproval')}
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleAddDevice}
|
|
className="btn btn-primary flex items-center space-x-2"
|
|
>
|
|
<PlusIcon className="h-4 w-4" />
|
|
<span>{t('devices.addDevice')}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter Tabs */}
|
|
<div className="border-b border-gray-200">
|
|
<nav className="-mb-px flex space-x-8">
|
|
<button
|
|
onClick={() => setFilter('all')}
|
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
|
filter === 'all'
|
|
? 'border-primary-500 text-primary-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
{t('devices.allDevices')} ({devices.length})
|
|
</button>
|
|
<button
|
|
onClick={() => setFilter('approved')}
|
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
|
filter === 'approved'
|
|
? 'border-primary-500 text-primary-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
{t('devices.approved')} ({devices.filter(d => d.is_approved).length})
|
|
</button>
|
|
<button
|
|
onClick={() => setFilter('pending')}
|
|
className={`py-2 px-1 border-b-2 font-medium text-sm relative ${
|
|
filter === 'pending'
|
|
? 'border-primary-500 text-primary-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
{t('devices.pendingApprovalTab')} ({pendingCount})
|
|
{pendingCount > 0 && (
|
|
<span className="absolute -top-1 -right-1 h-2 w-2 bg-yellow-400 rounded-full"></span>
|
|
)}
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Device Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{filteredDevices.map((device) => (
|
|
<div key={device.id} className={`bg-white rounded-lg shadow border hover:shadow-md transition-shadow ${
|
|
!device.is_approved ? 'border-yellow-300 bg-yellow-50' : 'border-gray-200'
|
|
}`}>
|
|
<div className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center space-x-3">
|
|
<div className={`w-3 h-3 rounded-full ${
|
|
device.stats?.status === 'online' ? 'bg-green-400' : 'bg-red-400'
|
|
}`} />
|
|
<h4 className="text-lg font-medium text-gray-900">
|
|
{device.name || `${t('devices.device')} ${device.id}`}
|
|
</h4>
|
|
{!device.is_approved && (
|
|
<span className="px-2 py-1 text-xs font-medium bg-yellow-200 text-yellow-800 rounded-full">
|
|
{t('devices.needsApproval')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex space-x-1">
|
|
<button
|
|
onClick={() => handleEditDevice(device)}
|
|
className="p-1 text-gray-400 hover:text-gray-600"
|
|
>
|
|
<PencilIcon className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteDevice(device.id)}
|
|
className="p-1 text-gray-400 hover:text-red-600"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-500">{t('devices.status')}</span>
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
getStatusColor(device.stats?.status)
|
|
}`}>
|
|
{device.stats?.status ? t(`devices.${device.stats.status}`) : t('devices.unknown')}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-500">{t('devices.approval')}</span>
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
device.is_approved ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
|
|
}`}>
|
|
{device.is_approved ? t('devices.approved') : t('devices.pending')}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-500">{t('devices.deviceId')}</span>
|
|
<span className="text-sm font-medium text-gray-900">
|
|
{device.id}
|
|
</span>
|
|
</div>
|
|
|
|
{device.location_description && (
|
|
<div className="flex items-start justify-between">
|
|
<span className="text-sm text-gray-500">{t('devices.location')}</span>
|
|
<span className="text-sm text-gray-900 text-right">
|
|
{device.location_description}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{(device.geo_lat && device.geo_lon) && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-500">{t('devices.coordinates')}</span>
|
|
<span className="text-sm text-gray-900">
|
|
{device.geo_lat}, {device.geo_lon}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-500">{t('devices.signal')}</span>
|
|
<span className="text-sm text-gray-900">
|
|
{getSignalStrength(device.last_heartbeat)}
|
|
</span>
|
|
</div>
|
|
|
|
{device.stats && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-500">{t('devices.detections24h')}</span>
|
|
<span className={`text-sm font-medium ${
|
|
device.stats.detections_24h > 0 ? 'text-red-600' : 'text-green-600'
|
|
}`}>
|
|
{device.stats.detections_24h}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{device.last_heartbeat && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-500">{t('devices.lastSeen')}</span>
|
|
<span className="text-sm text-gray-900">
|
|
{format(new Date(device.last_heartbeat), 'MMM dd, HH:mm')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{device.firmware_version && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-gray-500">{t('devices.firmware')}</span>
|
|
<span className="text-sm text-gray-900">
|
|
{device.firmware_version}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Device Actions */}
|
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
|
{!device.is_approved ? (
|
|
<div className="flex space-x-2 mb-2">
|
|
<button
|
|
onClick={() => handleApproveDevice(device.id)}
|
|
className="flex-1 text-xs bg-green-100 text-green-700 py-2 px-3 rounded hover:bg-green-200 transition-colors font-medium"
|
|
>
|
|
✓ {t('devices.approveDevice')}
|
|
</button>
|
|
<button
|
|
onClick={() => handleRejectDevice(device.id)}
|
|
className="flex-1 text-xs bg-red-100 text-red-700 py-2 px-3 rounded hover:bg-red-200 transition-colors font-medium"
|
|
>
|
|
✗ {t('devices.reject')}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
<div className="flex space-x-2">
|
|
<button
|
|
onClick={() => handleViewDetails(device)}
|
|
className="flex-1 text-xs bg-gray-100 text-gray-700 py-2 px-3 rounded hover:bg-gray-200 transition-colors"
|
|
>
|
|
{t('devices.viewDetails')}
|
|
</button>
|
|
<button
|
|
onClick={() => handleViewOnMap(device)}
|
|
className="flex-1 text-xs bg-primary-100 text-primary-700 py-2 px-3 rounded hover:bg-primary-200 transition-colors"
|
|
>
|
|
{t('devices.viewOnMap')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{filteredDevices.length === 0 && devices.length > 0 && (
|
|
<div className="text-center py-12">
|
|
<ServerIcon className="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
|
{filter === 'all' ? t('devices.noDevices') : `${t('devices.noDevicesFiltered').replace('the current filter', filter)}`}
|
|
</h3>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
{filter === 'pending'
|
|
? t('devices.noDevicesPending')
|
|
: filter === 'approved'
|
|
? t('devices.noDevicesApproved')
|
|
: t('devices.noDevicesFiltered')
|
|
}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{devices.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<ServerIcon className="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('devices.noDevices')}</h3>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
{t('devices.noDevicesDescription')}
|
|
</p>
|
|
<div className="mt-6">
|
|
<button
|
|
onClick={handleAddDevice}
|
|
className="btn btn-primary"
|
|
>
|
|
<PlusIcon className="h-4 w-4 mr-2" />
|
|
Add Device
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add/Edit Device Modal */}
|
|
{showAddModal && (
|
|
<DeviceModal
|
|
device={editingDevice}
|
|
onClose={() => setShowAddModal(false)}
|
|
onSave={() => {
|
|
setShowAddModal(false);
|
|
fetchDevices();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Device Details Modal */}
|
|
{showDetailsModal && selectedDevice && (
|
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
|
<div className="mt-3">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-medium text-gray-900">
|
|
{t('devices.deviceDetails')}
|
|
</h3>
|
|
<button
|
|
onClick={() => setShowDetailsModal(false)}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="font-medium text-gray-700">{t('devices.deviceId')}:</span>
|
|
<span className="text-gray-900">{selectedDevice.id}</span>
|
|
</div>
|
|
|
|
<div className="flex justify-between">
|
|
<span className="font-medium text-gray-700">{t('devices.name')}:</span>
|
|
<span className="text-gray-900">{selectedDevice.name || t('devices.unnamed')}</span>
|
|
</div>
|
|
|
|
<div className="flex justify-between">
|
|
<span className="font-medium text-gray-700">{t('devices.status')}:</span>
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
getStatusColor(selectedDevice.stats?.status)
|
|
}`}>
|
|
{selectedDevice.stats?.status ? t(`devices.${selectedDevice.stats.status}`) : t('devices.unknown')}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex justify-between">
|
|
<span className="font-medium text-gray-700">{t('devices.approved')}:</span>
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
selectedDevice.is_approved ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
|
|
}`}>
|
|
{selectedDevice.is_approved ? t('devices.yes') : t('devices.pending')}
|
|
</span>
|
|
</div>
|
|
|
|
{selectedDevice.location_description && (
|
|
<div className="flex justify-between">
|
|
<span className="font-medium text-gray-700">{t('devices.location')}:</span>
|
|
<span className="text-gray-900 text-right">{selectedDevice.location_description}</span>
|
|
</div>
|
|
)}
|
|
|
|
{(selectedDevice.geo_lat && selectedDevice.geo_lon) && (
|
|
<div className="flex justify-between">
|
|
<span className="font-medium text-gray-700">{t('devices.coordinates')}:</span>
|
|
<span className="text-gray-900">{selectedDevice.geo_lat}, {selectedDevice.geo_lon}</span>
|
|
</div>
|
|
)}
|
|
|
|
{selectedDevice.last_heartbeat && (
|
|
<div className="flex justify-between">
|
|
<span className="font-medium text-gray-700">{t('devices.lastHeartbeat')}:</span>
|
|
<span className="text-gray-900">{format(new Date(selectedDevice.last_heartbeat), 'MMM dd, yyyy HH:mm')}</span>
|
|
</div>
|
|
)}
|
|
|
|
{selectedDevice.firmware_version && (
|
|
<div className="flex justify-between">
|
|
<span className="font-medium text-gray-700">{t('devices.firmware')}:</span>
|
|
<span className="text-gray-900">{selectedDevice.firmware_version}</span>
|
|
</div>
|
|
)}
|
|
|
|
{selectedDevice.stats && (
|
|
<div className="flex justify-between">
|
|
<span className="font-medium text-gray-700">{t('devices.detections24h')}:</span>
|
|
<span className={`font-medium ${
|
|
selectedDevice.stats.detections_24h > 0 ? 'text-red-600' : 'text-green-600'
|
|
}`}>
|
|
{selectedDevice.stats.detections_24h}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{selectedDevice.created_at && (
|
|
<div className="flex justify-between">
|
|
<span className="font-medium text-gray-700">{t('alerts.created')}:</span>
|
|
<span className="text-gray-900">{format(new Date(selectedDevice.created_at), 'MMM dd, yyyy HH:mm')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex space-x-2 mt-6">
|
|
{!selectedDevice.is_approved && (
|
|
<button
|
|
onClick={() => {
|
|
handleApproveDevice(selectedDevice.id);
|
|
setShowDetailsModal(false);
|
|
}}
|
|
className="flex-1 bg-green-100 text-green-700 py-2 px-3 rounded hover:bg-green-200 transition-colors text-sm font-medium"
|
|
>
|
|
✓ {t('devices.approve')}
|
|
</button>
|
|
)}
|
|
|
|
{(selectedDevice.geo_lat && selectedDevice.geo_lon) && (
|
|
<button
|
|
onClick={() => handleViewOnMap(selectedDevice)}
|
|
className="flex-1 bg-primary-100 text-primary-700 py-2 px-3 rounded hover:bg-primary-200 transition-colors text-sm font-medium"
|
|
>
|
|
{t('devices.viewOnMap')}
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => setShowDetailsModal(false)}
|
|
className="flex-1 bg-gray-100 text-gray-700 py-2 px-3 rounded hover:bg-gray-200 transition-colors text-sm font-medium"
|
|
>
|
|
{t('devices.close')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DeviceModal = ({ device, onClose, onSave }) => {
|
|
const [formData, setFormData] = useState({
|
|
id: device?.id || '',
|
|
name: device?.name || '',
|
|
geo_lat: device?.geo_lat || '',
|
|
geo_lon: device?.geo_lon || '',
|
|
location_description: device?.location_description || '',
|
|
heartbeat_interval: device?.heartbeat_interval || 300,
|
|
firmware_version: device?.firmware_version || '',
|
|
notes: device?.notes || ''
|
|
});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
|
|
try {
|
|
if (device) {
|
|
// Update existing device - include coordinates and other editable fields
|
|
const updateData = {
|
|
name: formData.name,
|
|
geo_lat: formData.geo_lat ? parseFloat(formData.geo_lat) : null,
|
|
geo_lon: formData.geo_lon ? parseFloat(formData.geo_lon) : null,
|
|
location_description: formData.location_description || null,
|
|
notes: formData.notes || null
|
|
};
|
|
|
|
// Remove null values to avoid sending unnecessary data
|
|
Object.keys(updateData).forEach(key => {
|
|
if (updateData[key] === null || updateData[key] === '') {
|
|
delete updateData[key];
|
|
}
|
|
});
|
|
|
|
await api.put(`/devices/${device.id}`, updateData);
|
|
} else {
|
|
// Create new device - include all fields, convert empty strings to null
|
|
const createData = { ...formData };
|
|
Object.keys(createData).forEach(key => {
|
|
if (createData[key] === '') {
|
|
createData[key] = null;
|
|
}
|
|
});
|
|
await api.post('/devices', createData);
|
|
}
|
|
onSave();
|
|
} catch (error) {
|
|
console.error('Error saving device:', error);
|
|
|
|
// Check if it's a token expiration error
|
|
if (error.response?.status === 401 || error.response?.status === 403) {
|
|
alert('Your session has expired. Please log in again.');
|
|
// The API interceptor will handle the logout automatically
|
|
return;
|
|
}
|
|
|
|
alert('Error saving device: ' + (error.response?.data?.message || error.message));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleChange = (e) => {
|
|
const { name, value } = e.target;
|
|
setFormData(prev => ({
|
|
...prev,
|
|
[name]: value
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
|
|
|
<div className="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 onSubmit={handleSubmit}>
|
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
<div className="mb-4">
|
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
{device ? 'Edit Device' : 'Add New Device'}
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{!device && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Device ID *
|
|
</label>
|
|
<input
|
|
type="number"
|
|
name="id"
|
|
required
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
|
value={formData.id}
|
|
onChange={handleChange}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Device Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="name"
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
|
value={formData.name}
|
|
onChange={handleChange}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Latitude
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
name="geo_lat"
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
|
value={formData.geo_lat}
|
|
onChange={handleChange}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Longitude
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
name="geo_lon"
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
|
value={formData.geo_lon}
|
|
onChange={handleChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Location Description
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="location_description"
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
|
value={formData.location_description}
|
|
onChange={handleChange}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Heartbeat Interval (seconds) {device && <span className="text-xs text-gray-500">(read-only)</span>}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
name="heartbeat_interval"
|
|
min="60"
|
|
max="3600"
|
|
className={`w-full border border-gray-300 rounded-md px-3 py-2 ${
|
|
device
|
|
? 'bg-gray-100 cursor-not-allowed'
|
|
: 'focus:ring-primary-500 focus:border-primary-500'
|
|
}`}
|
|
value={formData.heartbeat_interval}
|
|
onChange={handleChange}
|
|
disabled={!!device}
|
|
readOnly={!!device}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Notes
|
|
</label>
|
|
<textarea
|
|
name="notes"
|
|
rows="3"
|
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
|
value={formData.notes}
|
|
onChange={handleChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
<button
|
|
type="submit"
|
|
disabled={saving}
|
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
|
>
|
|
{saving ? 'Saving...' : (device ? 'Update' : 'Create')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Devices;
|