Files
location-mqtt-tracker-app/app/admin/geofences/page.tsx
Joachim Hummel bae6728f3f 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>
2025-12-02 23:07:29 +00:00

701 lines
28 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}