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 [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">
|
||||
|
||||
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