Add Geofence frontend UI with management and event history
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>
This commit is contained in:
700
app/admin/geofences/page.tsx
Normal file
700
app/admin/geofences/page.tsx
Normal file
@@ -0,0 +1,700 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user