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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user