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>
|
||||||
|
);
|
||||||
|
}
|
||||||
700
app/admin/geofences/page.tsx
Normal file
700
app/admin/geofences/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ export default function AdminDashboard() {
|
|||||||
});
|
});
|
||||||
const [dbStats, setDbStats] = useState<any>(null);
|
const [dbStats, setDbStats] = useState<any>(null);
|
||||||
const [systemStatus, setSystemStatus] = useState<any>(null);
|
const [systemStatus, setSystemStatus] = useState<any>(null);
|
||||||
|
const [geofenceStats, setGeofenceStats] = useState<any>(null);
|
||||||
|
|
||||||
// Fetch devices from API
|
// Fetch devices from API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -143,6 +144,44 @@ export default function AdminDashboard() {
|
|||||||
return () => clearInterval(interval);
|
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
|
// Cleanup old locations
|
||||||
const handleCleanup = async (retentionHours: number) => {
|
const handleCleanup = async (retentionHours: number) => {
|
||||||
if (!confirm(`Delete all locations older than ${Math.round(retentionHours / 24)} days?`)) {
|
if (!confirm(`Delete all locations older than ${Math.round(retentionHours / 24)} days?`)) {
|
||||||
@@ -399,6 +438,102 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</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 */}
|
{/* Device List */}
|
||||||
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
|
<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">
|
<div className="bg-gradient-to-r from-teal-50 to-cyan-50 px-6 py-5 border-b border-teal-100">
|
||||||
|
|||||||
69
app/api/geofences/events/route.ts
Normal file
69
app/api/geofences/events/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user