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:
282
app/admin/geofences/events/page.tsx
Normal file
282
app/admin/geofences/events/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user