diff --git a/app/admin/geofences/events/page.tsx b/app/admin/geofences/events/page.tsx new file mode 100644 index 0000000..6362668 --- /dev/null +++ b/app/admin/geofences/events/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + Pending + + ); + case 1: + return ( + + βœ“ Sent + + ); + case 2: + return ( + + βœ— Failed + + ); + default: + return null; + } + }; + + if (loading) { + return ( +
+
+
+

Loading events...

+
+
+ ); + } + + return ( +
+ {/* Hero Section */} +
+
+
+
+
+
+

Geofence Events

+

View enter and exit events for all your geofences

+
+ + ← Back to Geofences + +
+
+
+ + {/* Stats Cards */} +
+
+
+
+ πŸ“Š +
+
+

Total Events

+

{events.length}

+
+
+
+ +
+
+
+ ↓ +
+
+

Enters

+

+ {events.filter((e) => e.event_type === "enter").length} +

+
+
+
+ +
+
+
+ ↑ +
+
+

Exits

+

+ {events.filter((e) => e.event_type === "exit").length} +

+
+
+
+ +
+
+
+ πŸ“§ +
+
+

Sent

+

+ {events.filter((e) => e.notification_sent === 1).length} +

+
+
+
+
+ + {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* Events Table */} +
+
+
+
+ πŸ“‹ +
+

Event History

+
+
+ + {events.length === 0 ? ( +
+
πŸ“­
+

No events yet

+

+ Events will appear here when devices enter or exit your geofences +

+
+ ) : ( +
+ + + + + + + + + + + + + + {events.map((event) => ( + + + + + + + + + + ))} + +
+ Timestamp + + Device + + Geofence + + Event + + Position + + Distance + + Notification +
+ {new Date(event.timestamp).toLocaleString()} + + Device {event.device_id} + +
+
+ + {event.geofenceName} + +
+
+ {event.event_type === "enter" ? ( + + ↓ Enter + + ) : ( + + ↑ Exit + + )} + + {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)} + + {event.distance_from_center !== null + ? `${Math.round(event.distance_from_center)} m` + : "N/A"} + + {getNotificationStatusBadge(event.notification_sent)} + {event.notification_sent === 2 && event.notification_error && ( +

+ {event.notification_error} +

+ )} +
+
+ )} +
+
+ ); +} diff --git a/app/admin/geofences/page.tsx b/app/admin/geofences/page.tsx new file mode 100644 index 0000000..91b597b --- /dev/null +++ b/app/admin/geofences/page.tsx @@ -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([]); + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [selectedGeofence, setSelectedGeofence] = useState(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 ( +
+
+
+

Loading geofences...

+
+
+ ); + } + + return ( +
+ {/* Hero Section */} +
+
+
+
+
+
+

Geofences

+

Manage your location-based zones and alerts

+
+
+ {zoneLimit !== null && ( +
+ {geofences.length} / {zoneLimit} zones used +
+ )} + +
+
+
+
+ + {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* Geofences Table */} +
+
+
+
+ πŸ“ +
+

+ Your Geofences +

+
+
+ + {geofences.length === 0 ? ( +
+
πŸ—ΊοΈ
+

No geofences yet

+

Create your first geofence to get notified when devices enter or exit zones

+ +
+ ) : ( +
+ + + + + + + + + + + + + + {geofences.map((geofence) => ( + + + + + + + + + + ))} + +
+ Name + + Device + + Status + + Radius + + Location + + Created + + Actions +
+
+
+
+
{geofence.name}
+ {geofence.description && ( +
{geofence.description}
+ )} +
+
+
+ {geofence.deviceName || geofence.device_id} + + + + {geofence.radius_meters >= 1000 + ? `${(geofence.radius_meters / 1000).toFixed(1)} km` + : `${geofence.radius_meters} m`} + + {geofence.center_latitude.toFixed(4)}, {geofence.center_longitude.toFixed(4)} + + {new Date(geofence.created_at).toLocaleDateString()} + +
+ + +
+
+
+ )} +
+ + {/* Quick Links */} + + + {/* Add Geofence Modal */} + {showAddModal && ( +
+
+

Create New Geofence

+
+
+
+ + 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" + /> +
+ +
+ + +
+
+ +
+ +