Implemented complete frontend for the Geofence MVP feature: **Pages:** - /admin/geofences - Management page with create/edit/delete modals - /admin/geofences/events - Event history with stats and filters - Dashboard widget showing active geofences and recent events **Features:** - Create/Edit geofences with device selection, coordinates, radius, and color - Toggle active/inactive status - View enter/exit events with notification status - Auto-refresh every 30 seconds - Zone limit enforcement (5 for users, unlimited for admins) - Stats cards showing total events, enters, exits, and notifications **API:** - GET /api/geofences/events - Fetch events with optional filters All frontend components follow the existing admin panel design system with gradient backgrounds, shadow effects, and responsive layouts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
701 lines
28 KiB
TypeScript
701 lines
28 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { useSession } from "next-auth/react";
|
||
import { Geofence } from "@/lib/types";
|
||
|
||
interface GeofenceWithDevice extends Geofence {
|
||
deviceName?: string;
|
||
}
|
||
|
||
interface Device {
|
||
id: string;
|
||
name: string;
|
||
}
|
||
|
||
export default function GeofencesPage() {
|
||
const { data: session } = useSession();
|
||
const userRole = (session?.user as any)?.role;
|
||
const userId = (session?.user as any)?.id;
|
||
const isAdmin = userRole === 'ADMIN';
|
||
|
||
const [geofences, setGeofences] = useState<GeofenceWithDevice[]>([]);
|
||
const [devices, setDevices] = useState<Device[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showAddModal, setShowAddModal] = useState(false);
|
||
const [showEditModal, setShowEditModal] = useState(false);
|
||
const [selectedGeofence, setSelectedGeofence] = useState<Geofence | null>(null);
|
||
|
||
const [formData, setFormData] = useState({
|
||
name: "",
|
||
description: "",
|
||
device_id: "",
|
||
center_latitude: "",
|
||
center_longitude: "",
|
||
radius_meters: "500",
|
||
color: "#10b981",
|
||
});
|
||
|
||
useEffect(() => {
|
||
fetchGeofences();
|
||
fetchDevices();
|
||
|
||
// Auto-refresh every 30 seconds
|
||
const interval = setInterval(fetchGeofences, 30000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
const fetchGeofences = async () => {
|
||
try {
|
||
const response = await fetch("/api/geofences");
|
||
if (!response.ok) throw new Error("Failed to fetch geofences");
|
||
const data = await response.json();
|
||
setGeofences(data.geofences || []);
|
||
setError(null);
|
||
} catch (err) {
|
||
console.error("Failed to fetch geofences", err);
|
||
setError("Failed to load geofences");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchDevices = async () => {
|
||
try {
|
||
const response = await fetch("/api/devices");
|
||
if (!response.ok) throw new Error("Failed to fetch devices");
|
||
const data = await response.json();
|
||
setDevices(data.devices || []);
|
||
} catch (err) {
|
||
console.error("Failed to fetch devices", err);
|
||
}
|
||
};
|
||
|
||
const handleAdd = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
try {
|
||
const response = await fetch("/api/geofences", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: formData.name,
|
||
description: formData.description || undefined,
|
||
device_id: formData.device_id,
|
||
center_latitude: parseFloat(formData.center_latitude),
|
||
center_longitude: parseFloat(formData.center_longitude),
|
||
radius_meters: parseInt(formData.radius_meters),
|
||
color: formData.color,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Failed to create geofence");
|
||
}
|
||
|
||
await fetchGeofences();
|
||
setShowAddModal(false);
|
||
setFormData({
|
||
name: "",
|
||
description: "",
|
||
device_id: "",
|
||
center_latitude: "",
|
||
center_longitude: "",
|
||
radius_meters: "500",
|
||
color: "#10b981",
|
||
});
|
||
} catch (err: any) {
|
||
alert(err.message);
|
||
}
|
||
};
|
||
|
||
const handleEdit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!selectedGeofence) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/geofences/${selectedGeofence.id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: formData.name,
|
||
description: formData.description || undefined,
|
||
radius_meters: parseInt(formData.radius_meters),
|
||
color: formData.color,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Failed to update geofence");
|
||
}
|
||
|
||
await fetchGeofences();
|
||
setShowEditModal(false);
|
||
setSelectedGeofence(null);
|
||
} catch (err: any) {
|
||
alert(err.message);
|
||
}
|
||
};
|
||
|
||
const openEditModal = (geofence: Geofence) => {
|
||
setSelectedGeofence(geofence);
|
||
setFormData({
|
||
name: geofence.name,
|
||
description: geofence.description || "",
|
||
device_id: geofence.device_id,
|
||
center_latitude: geofence.center_latitude.toString(),
|
||
center_longitude: geofence.center_longitude.toString(),
|
||
radius_meters: geofence.radius_meters.toString(),
|
||
color: geofence.color,
|
||
});
|
||
setShowEditModal(true);
|
||
};
|
||
|
||
const handleDelete = async (geofence: Geofence) => {
|
||
if (!confirm(`Delete geofence "${geofence.name}"? This will also delete all associated events.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/geofences/${geofence.id}`, {
|
||
method: "DELETE",
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Failed to delete geofence");
|
||
}
|
||
|
||
await fetchGeofences();
|
||
} catch (err: any) {
|
||
alert(err.message);
|
||
}
|
||
};
|
||
|
||
const toggleActive = async (geofence: Geofence) => {
|
||
try {
|
||
const response = await fetch(`/api/geofences/${geofence.id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ is_active: geofence.is_active === 1 ? 0 : 1 }),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || "Failed to update geofence");
|
||
}
|
||
|
||
await fetchGeofences();
|
||
} catch (err: any) {
|
||
alert(err.message);
|
||
}
|
||
};
|
||
|
||
// Calculate zone limit for current user
|
||
const zoneLimit = isAdmin ? null : 5;
|
||
const canCreateMore = zoneLimit === null || geofences.length < zoneLimit;
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-screen">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||
<p className="text-gray-600">Loading geofences...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Hero Section */}
|
||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-purple-600 via-violet-700 to-indigo-800 p-8 shadow-xl">
|
||
<div className="absolute top-0 right-0 -mt-4 -mr-4 h-40 w-40 rounded-full bg-white/10 blur-3xl"></div>
|
||
<div className="absolute bottom-0 left-0 -mb-4 -ml-4 h-40 w-40 rounded-full bg-white/10 blur-3xl"></div>
|
||
<div className="relative">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-4xl font-bold text-white mb-2">Geofences</h2>
|
||
<p className="text-purple-100 text-lg">Manage your location-based zones and alerts</p>
|
||
</div>
|
||
<div className="text-right">
|
||
{zoneLimit !== null && (
|
||
<div className="text-white/90 text-sm mb-2">
|
||
{geofences.length} / {zoneLimit} zones used
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={() => setShowAddModal(true)}
|
||
disabled={!canCreateMore}
|
||
className="px-6 py-3 bg-white text-purple-700 font-bold rounded-lg hover:bg-purple-50 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed shadow-lg transition-all"
|
||
>
|
||
+ Add Geofence
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error Message */}
|
||
{error && (
|
||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||
<p className="text-red-800 font-semibold">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Geofences Table */}
|
||
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
|
||
<div className="bg-gradient-to-r from-violet-50 to-purple-50 px-6 py-5 border-b border-purple-100">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-violet-600 to-purple-600 flex items-center justify-center text-white text-xl">
|
||
📍
|
||
</div>
|
||
<h3 className="text-xl font-bold text-gray-900">
|
||
Your Geofences
|
||
</h3>
|
||
</div>
|
||
</div>
|
||
|
||
{geofences.length === 0 ? (
|
||
<div className="p-12 text-center">
|
||
<div className="text-6xl mb-4">🗺️</div>
|
||
<h3 className="text-xl font-bold text-gray-700 mb-2">No geofences yet</h3>
|
||
<p className="text-gray-500 mb-6">Create your first geofence to get notified when devices enter or exit zones</p>
|
||
<button
|
||
onClick={() => setShowAddModal(true)}
|
||
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold rounded-lg hover:from-purple-700 hover:to-violet-700 shadow-md"
|
||
>
|
||
+ Create Geofence
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full">
|
||
<thead>
|
||
<tr className="border-b-2 border-gray-200">
|
||
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
|
||
Name
|
||
</th>
|
||
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
|
||
Device
|
||
</th>
|
||
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
|
||
Status
|
||
</th>
|
||
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
|
||
Radius
|
||
</th>
|
||
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
|
||
Location
|
||
</th>
|
||
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
|
||
Created
|
||
</th>
|
||
<th className="text-right py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{geofences.map((geofence) => (
|
||
<tr
|
||
key={geofence.id}
|
||
className="border-b border-gray-100 hover:bg-gradient-to-r hover:from-purple-50 hover:to-violet-50 transition-all"
|
||
>
|
||
<td className="py-4 px-4">
|
||
<div className="flex items-center gap-3">
|
||
<div
|
||
className="w-4 h-4 rounded-full ring-2 ring-white shadow-md"
|
||
style={{ backgroundColor: geofence.color }}
|
||
/>
|
||
<div>
|
||
<div className="text-sm font-bold text-gray-900">{geofence.name}</div>
|
||
{geofence.description && (
|
||
<div className="text-xs text-gray-500">{geofence.description}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="py-4 px-4 text-sm text-gray-700">
|
||
{geofence.deviceName || geofence.device_id}
|
||
</td>
|
||
<td className="py-4 px-4">
|
||
<button
|
||
onClick={() => toggleActive(geofence)}
|
||
className={`px-3 py-1 rounded-full text-xs font-bold ${
|
||
geofence.is_active === 1
|
||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
{geofence.is_active === 1 ? '✓ Active' : '○ Inactive'}
|
||
</button>
|
||
</td>
|
||
<td className="py-4 px-4 text-sm text-gray-700">
|
||
{geofence.radius_meters >= 1000
|
||
? `${(geofence.radius_meters / 1000).toFixed(1)} km`
|
||
: `${geofence.radius_meters} m`}
|
||
</td>
|
||
<td className="py-4 px-4 text-xs font-mono text-gray-600">
|
||
{geofence.center_latitude.toFixed(4)}, {geofence.center_longitude.toFixed(4)}
|
||
</td>
|
||
<td className="py-4 px-4 text-sm text-gray-600">
|
||
{new Date(geofence.created_at).toLocaleDateString()}
|
||
</td>
|
||
<td className="py-4 px-4">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<button
|
||
onClick={() => openEditModal(geofence)}
|
||
className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 font-semibold text-sm transition-colors"
|
||
>
|
||
Edit
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(geofence)}
|
||
className="px-3 py-1.5 bg-red-50 text-red-700 rounded-lg hover:bg-red-100 font-semibold text-sm transition-colors"
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Quick Links */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<a
|
||
href="/admin/geofences/events"
|
||
className="block bg-white rounded-xl shadow-md hover:shadow-lg transition-all p-6 border border-gray-200 hover:border-purple-200 group"
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center text-white text-2xl group-hover:scale-110 transition-transform">
|
||
📋
|
||
</div>
|
||
<div>
|
||
<h4 className="text-lg font-bold text-gray-900 mb-1">Event History</h4>
|
||
<p className="text-sm text-gray-600">View enter/exit events for all geofences</p>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
|
||
<a
|
||
href="/admin"
|
||
className="block bg-white rounded-xl shadow-md hover:shadow-lg transition-all p-6 border border-gray-200 hover:border-purple-200 group"
|
||
>
|
||
<div className="flex items-center gap-4">
|
||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-purple-600 to-violet-600 flex items-center justify-center text-white text-2xl group-hover:scale-110 transition-transform">
|
||
📊
|
||
</div>
|
||
<div>
|
||
<h4 className="text-lg font-bold text-gray-900 mb-1">Dashboard</h4>
|
||
<p className="text-sm text-gray-600">Back to main dashboard</p>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
|
||
{/* Add Geofence Modal */}
|
||
{showAddModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||
<div className="bg-white rounded-2xl max-w-2xl w-full p-6 space-y-4 max-h-[90vh] overflow-y-auto">
|
||
<h3 className="text-2xl font-bold text-gray-900">Create New Geofence</h3>
|
||
<form onSubmit={handleAdd} className="space-y-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Name *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.name}
|
||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
placeholder="e.g., Home, Office"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Device *
|
||
</label>
|
||
<select
|
||
required
|
||
value={formData.device_id}
|
||
onChange={(e) => setFormData({ ...formData, device_id: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
>
|
||
<option value="">Select a device</option>
|
||
{devices.map((device) => (
|
||
<option key={device.id} value={device.id}>
|
||
{device.name} (ID: {device.id})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Description (optional)
|
||
</label>
|
||
<textarea
|
||
value={formData.description}
|
||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
rows={2}
|
||
placeholder="Additional notes about this zone"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Center Latitude *
|
||
</label>
|
||
<input
|
||
type="number"
|
||
step="any"
|
||
required
|
||
value={formData.center_latitude}
|
||
onChange={(e) => setFormData({ ...formData, center_latitude: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
placeholder="e.g., 50.1109"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Center Longitude *
|
||
</label>
|
||
<input
|
||
type="number"
|
||
step="any"
|
||
required
|
||
value={formData.center_longitude}
|
||
onChange={(e) => setFormData({ ...formData, center_longitude: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
placeholder="e.g., 8.6821"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Radius (meters) *
|
||
</label>
|
||
<input
|
||
type="number"
|
||
required
|
||
min="50"
|
||
max="50000"
|
||
value={formData.radius_meters}
|
||
onChange={(e) => setFormData({ ...formData, radius_meters: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
{parseInt(formData.radius_meters) >= 1000
|
||
? `≈ ${(parseInt(formData.radius_meters) / 1000).toFixed(1)} km`
|
||
: `${formData.radius_meters} meters`}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Color
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="color"
|
||
value={formData.color}
|
||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||
className="h-10 w-20 rounded-lg border border-gray-300 cursor-pointer"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={formData.color}
|
||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowAddModal(false);
|
||
setFormData({
|
||
name: "",
|
||
description: "",
|
||
device_id: "",
|
||
center_latitude: "",
|
||
center_longitude: "",
|
||
radius_meters: "500",
|
||
color: "#10b981",
|
||
});
|
||
}}
|
||
className="flex-1 px-4 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-3 bg-gradient-to-r from-purple-600 to-violet-600 text-white rounded-lg hover:from-purple-700 hover:to-violet-700 font-semibold shadow-md"
|
||
>
|
||
Create Geofence
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Edit Geofence Modal */}
|
||
{showEditModal && selectedGeofence && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||
<div className="bg-white rounded-2xl max-w-2xl w-full p-6 space-y-4 max-h-[90vh] overflow-y-auto">
|
||
<h3 className="text-2xl font-bold text-gray-900">Edit Geofence</h3>
|
||
<form onSubmit={handleEdit} className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Name *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.name}
|
||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Description (optional)
|
||
</label>
|
||
<textarea
|
||
value={formData.description}
|
||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
rows={2}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Device
|
||
</label>
|
||
<input
|
||
type="text"
|
||
disabled
|
||
value={formData.device_id}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-600"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">Device cannot be changed after creation</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Center Latitude
|
||
</label>
|
||
<input
|
||
type="text"
|
||
disabled
|
||
value={formData.center_latitude}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-600"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Center Longitude
|
||
</label>
|
||
<input
|
||
type="text"
|
||
disabled
|
||
value={formData.center_longitude}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-600"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-500">Location cannot be changed after creation</p>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Radius (meters) *
|
||
</label>
|
||
<input
|
||
type="number"
|
||
required
|
||
min="50"
|
||
max="50000"
|
||
value={formData.radius_meters}
|
||
onChange={(e) => setFormData({ ...formData, radius_meters: e.target.value })}
|
||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
{parseInt(formData.radius_meters) >= 1000
|
||
? `≈ ${(parseInt(formData.radius_meters) / 1000).toFixed(1)} km`
|
||
: `${formData.radius_meters} meters`}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||
Color
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="color"
|
||
value={formData.color}
|
||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||
className="h-10 w-20 rounded-lg border border-gray-300 cursor-pointer"
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={formData.color}
|
||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowEditModal(false);
|
||
setSelectedGeofence(null);
|
||
}}
|
||
className="flex-1 px-4 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="flex-1 px-4 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg hover:from-blue-700 hover:to-indigo-700 font-semibold shadow-md"
|
||
>
|
||
Update Geofence
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|