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:
2025-12-02 23:07:29 +00:00
parent 781e50b68a
commit bae6728f3f
4 changed files with 1186 additions and 0 deletions

View File

@@ -0,0 +1,282 @@
"use client";
import { useEffect, useState } from "react";
import { GeofenceEvent } from "@/lib/types";
interface EnrichedGeofenceEvent extends GeofenceEvent {
geofenceName?: string;
geofenceColor?: string;
}
export default function GeofenceEventsPage() {
const [events, setEvents] = useState<EnrichedGeofenceEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState({
deviceId: "",
geofenceId: "",
});
useEffect(() => {
fetchEvents();
// Auto-refresh every 30 seconds
const interval = setInterval(fetchEvents, 30000);
return () => clearInterval(interval);
}, [filter]);
const fetchEvents = async () => {
try {
const params = new URLSearchParams();
if (filter.deviceId) params.append("deviceId", filter.deviceId);
if (filter.geofenceId) params.append("geofenceId", filter.geofenceId);
params.append("limit", "100");
const response = await fetch(`/api/geofences/events?${params.toString()}`);
if (!response.ok) throw new Error("Failed to fetch events");
const data = await response.json();
setEvents(data.events || []);
setError(null);
} catch (err) {
console.error("Failed to fetch events", err);
setError("Failed to load events");
} finally {
setLoading(false);
}
};
const getNotificationStatusBadge = (status: number) => {
switch (status) {
case 0:
return (
<span className="px-2 py-1 rounded-full text-xs font-bold bg-yellow-100 text-yellow-800">
Pending
</span>
);
case 1:
return (
<span className="px-2 py-1 rounded-full text-xs font-bold bg-green-100 text-green-800">
Sent
</span>
);
case 2:
return (
<span className="px-2 py-1 rounded-full text-xs font-bold bg-red-100 text-red-800">
Failed
</span>
);
default:
return null;
}
};
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 events...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Hero Section */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-blue-600 via-indigo-700 to-violet-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">Geofence Events</h2>
<p className="text-blue-100 text-lg">View enter and exit events for all your geofences</p>
</div>
<a
href="/admin/geofences"
className="px-6 py-3 bg-white text-blue-700 font-bold rounded-lg hover:bg-blue-50 shadow-lg transition-all"
>
Back to Geofences
</a>
</div>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl shadow-md p-6">
<div className="flex items-center gap-3">
<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">
📊
</div>
<div>
<p className="text-sm text-gray-600 font-semibold">Total Events</p>
<p className="text-2xl font-bold text-gray-900">{events.length}</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-md p-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-green-600 to-emerald-600 flex items-center justify-center text-white text-2xl">
</div>
<div>
<p className="text-sm text-gray-600 font-semibold">Enters</p>
<p className="text-2xl font-bold text-gray-900">
{events.filter((e) => e.event_type === "enter").length}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-md p-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-orange-600 to-red-600 flex items-center justify-center text-white text-2xl">
</div>
<div>
<p className="text-sm text-gray-600 font-semibold">Exits</p>
<p className="text-2xl font-bold text-gray-900">
{events.filter((e) => e.event_type === "exit").length}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-md p-6">
<div className="flex items-center gap-3">
<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">
📧
</div>
<div>
<p className="text-sm text-gray-600 font-semibold">Sent</p>
<p className="text-2xl font-bold text-gray-900">
{events.filter((e) => e.notification_sent === 1).length}
</p>
</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>
)}
{/* Events Table */}
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-6 py-5 border-b border-blue-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center text-white text-xl">
📋
</div>
<h3 className="text-xl font-bold text-gray-900">Event History</h3>
</div>
</div>
{events.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 events yet</h3>
<p className="text-gray-500">
Events will appear here when devices enter or exit your geofences
</p>
</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">
Timestamp
</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">
Geofence
</th>
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
Event
</th>
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
Position
</th>
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
Distance
</th>
<th className="text-left py-4 px-4 text-xs font-bold text-gray-600 uppercase tracking-wider">
Notification
</th>
</tr>
</thead>
<tbody>
{events.map((event) => (
<tr
key={event.id}
className="border-b border-gray-100 hover:bg-gradient-to-r hover:from-blue-50 hover:to-indigo-50 transition-all"
>
<td className="py-4 px-4 text-sm text-gray-700">
{new Date(event.timestamp).toLocaleString()}
</td>
<td className="py-4 px-4 text-sm font-semibold text-gray-900">
Device {event.device_id}
</td>
<td className="py-4 px-4">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full ring-2 ring-white shadow-sm"
style={{ backgroundColor: event.geofenceColor }}
/>
<span className="text-sm font-semibold text-gray-900">
{event.geofenceName}
</span>
</div>
</td>
<td className="py-4 px-4">
{event.event_type === "enter" ? (
<span className="px-3 py-1 rounded-full text-xs font-bold bg-green-100 text-green-800">
Enter
</span>
) : (
<span className="px-3 py-1 rounded-full text-xs font-bold bg-orange-100 text-orange-800">
Exit
</span>
)}
</td>
<td className="py-4 px-4 text-xs font-mono text-gray-600">
{typeof event.latitude === 'number'
? event.latitude.toFixed(4)
: parseFloat(event.latitude as string).toFixed(4)},{" "}
{typeof event.longitude === 'number'
? event.longitude.toFixed(4)
: parseFloat(event.longitude as string).toFixed(4)}
</td>
<td className="py-4 px-4 text-sm text-gray-700">
{event.distance_from_center !== null
? `${Math.round(event.distance_from_center)} m`
: "N/A"}
</td>
<td className="py-4 px-4">
{getNotificationStatusBadge(event.notification_sent)}
{event.notification_sent === 2 && event.notification_error && (
<p className="text-xs text-red-600 mt-1">
{event.notification_error}
</p>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

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

View File

@@ -44,6 +44,7 @@ export default function AdminDashboard() {
});
const [dbStats, setDbStats] = useState<any>(null);
const [systemStatus, setSystemStatus] = useState<any>(null);
const [geofenceStats, setGeofenceStats] = useState<any>(null);
// Fetch devices from API
useEffect(() => {
@@ -143,6 +144,44 @@ export default function AdminDashboard() {
return () => clearInterval(interval);
}, []);
// Fetch geofence statistics
useEffect(() => {
const fetchGeofenceStats = async () => {
try {
const [geofencesRes, eventsRes] = await Promise.all([
fetch('/api/geofences'),
fetch('/api/geofences/events?limit=100'),
]);
if (geofencesRes.ok && eventsRes.ok) {
const geofencesData = await geofencesRes.json();
const eventsData = await eventsRes.json();
const now = Date.now();
const oneDayAgo = now - 24 * 60 * 60 * 1000;
const events24h = eventsData.events?.filter((e: any) =>
new Date(e.timestamp).getTime() > oneDayAgo
) || [];
setGeofenceStats({
total: geofencesData.total || 0,
active: geofencesData.geofences?.filter((g: any) => g.is_active === 1).length || 0,
events24h: events24h.length,
recentEvents: eventsData.events?.slice(0, 5) || [],
});
}
} catch (err) {
console.error('Failed to fetch geofence stats:', err);
}
};
fetchGeofenceStats();
// Refresh every 30 seconds
const interval = setInterval(fetchGeofenceStats, 30000);
return () => clearInterval(interval);
}, []);
// Cleanup old locations
const handleCleanup = async (retentionHours: number) => {
if (!confirm(`Delete all locations older than ${Math.round(retentionHours / 24)} days?`)) {
@@ -399,6 +438,102 @@ export default function AdminDashboard() {
</div>
)}
{/* Geofence Statistics */}
{geofenceStats && (geofenceStats.total > 0 || geofenceStats.events24h > 0) && (
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-purple-50 to-violet-50 px-6 py-5 border-b border-purple-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-purple-600 to-violet-600 flex items-center justify-center text-white text-xl">
📍
</div>
<h3 className="text-xl font-bold text-gray-900">
Geofence Overview
</h3>
</div>
<a
href="/admin/geofences"
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-violet-600 text-white rounded-lg hover:from-purple-700 hover:to-violet-700 font-semibold text-sm shadow-md transition-all"
>
Manage Geofences
</a>
</div>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="relative overflow-hidden bg-gradient-to-br from-purple-50 to-violet-50 p-5 rounded-xl border border-purple-100">
<div className="absolute top-0 right-0 text-6xl opacity-10">🗺</div>
<p className="text-sm font-semibold text-purple-700 uppercase tracking-wide mb-1">Active Geofences</p>
<p className="text-3xl font-bold text-purple-900">{geofenceStats.active}</p>
<p className="text-xs text-purple-600 mt-2">of {geofenceStats.total} total zones</p>
</div>
<div className="relative overflow-hidden bg-gradient-to-br from-blue-50 to-indigo-50 p-5 rounded-xl border border-blue-100">
<div className="absolute top-0 right-0 text-6xl opacity-10">📊</div>
<p className="text-sm font-semibold text-blue-700 uppercase tracking-wide mb-1">Events (24h)</p>
<p className="text-3xl font-bold text-blue-900">{geofenceStats.events24h}</p>
<p className="text-xs text-blue-600 mt-2">enter/exit events today</p>
</div>
<div className="relative overflow-hidden bg-gradient-to-br from-green-50 to-emerald-50 p-5 rounded-xl border border-green-100">
<div className="absolute top-0 right-0 text-6xl opacity-10"></div>
<p className="text-sm font-semibold text-green-700 uppercase tracking-wide mb-1">Status</p>
<p className="text-3xl font-bold text-green-900">
{geofenceStats.active > 0 ? '✓ Active' : 'Inactive'}
</p>
<p className="text-xs text-green-600 mt-2">monitoring enabled</p>
</div>
</div>
{/* Recent Events */}
{geofenceStats.recentEvents && geofenceStats.recentEvents.length > 0 && (
<div className="bg-gradient-to-br from-gray-50 to-slate-50 p-5 rounded-xl border border-gray-200">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-bold text-gray-700 uppercase tracking-wide flex items-center gap-2">
<span className="text-lg">📋</span>
Recent Events
</h4>
<a
href="/admin/geofences/events"
className="text-sm text-purple-600 hover:text-purple-700 font-semibold"
>
View All
</a>
</div>
<div className="space-y-2">
{geofenceStats.recentEvents.map((event: any, idx: number) => (
<div key={event.id || idx} className="flex items-center justify-between py-3 px-4 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow border border-gray-100">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full ring-2 ring-white shadow-sm"
style={{ backgroundColor: event.geofenceColor || '#gray' }}
/>
<div>
<span className="text-sm font-semibold text-gray-900">
{event.geofenceName || 'Unknown Zone'}
</span>
<p className="text-xs text-gray-500">
Device {event.device_id} {' '}
{new Date(event.timestamp).toLocaleString()}
</p>
</div>
</div>
{event.event_type === 'enter' ? (
<span className="px-2 py-1 rounded-full text-xs font-bold bg-green-100 text-green-800">
Enter
</span>
) : (
<span className="px-2 py-1 rounded-full text-xs font-bold bg-orange-100 text-orange-800">
Exit
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Device List */}
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-teal-50 to-cyan-50 px-6 py-5 border-b border-teal-100">

View File

@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { geofenceDb } from "@/lib/geofence-db";
// GET /api/geofences/events - List all geofence events for the authenticated user
export async function GET(request: Request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as any).id;
// Get all geofences owned by this user
const userGeofences = geofenceDb.findByOwner(userId);
const geofenceIds = userGeofences.map((g) => g.id);
if (geofenceIds.length === 0) {
return NextResponse.json({
success: true,
events: [],
total: 0,
});
}
// Parse query parameters
const { searchParams } = new URL(request.url);
const deviceId = searchParams.get("deviceId") || undefined;
const geofenceId = searchParams.get("geofenceId") || undefined;
const limit = parseInt(searchParams.get("limit") || "100");
// Get events (filtered by user's geofences)
let events = geofenceDb.findEvents({
deviceId,
geofenceId,
limit,
});
// Filter to only events from user's geofences
events = events.filter((event) => geofenceIds.includes(event.geofence_id));
// Enrich events with geofence and device names
const enrichedEvents = events.map((event) => {
const geofence = userGeofences.find((g) => g.id === event.geofence_id);
return {
...event,
geofenceName: geofence?.name || "Unknown",
geofenceColor: geofence?.color || "#gray",
};
});
return NextResponse.json({
success: true,
events: enrichedEvents,
total: enrichedEvents.length,
});
} catch (error) {
console.error("[GET /api/geofences/events] Error:", error);
return NextResponse.json(
{
error: "Failed to fetch geofence events",
details: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
}