Files
Joachim Hummel b24941e95f Fix return statement syntax in map functions
Add missing semicolons after return statement closing in:
- devices page map function
- users page map function

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 23:40:34 +00:00

607 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
interface Device {
id: string;
name: string;
color: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
owner?: {
id: string;
username: string;
};
latestLocation?: {
latitude: string | number;
longitude: string | number;
timestamp: string;
battery?: number;
speed?: number;
};
_count?: {
locations: number;
};
}
export default function DevicesPage() {
const { data: session } = useSession();
const userRole = (session?.user as any)?.role;
const isAdmin = userRole === 'ADMIN';
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const [error, setError] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState({
id: "",
name: "",
color: "#3498db",
description: "",
});
useEffect(() => {
fetchDevices();
// Auto-refresh every 10 seconds to update online/offline status
const interval = setInterval(fetchDevices, 10000);
return () => clearInterval(interval);
}, []);
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 || []);
setError(null);
} catch (err) {
console.error("Failed to fetch devices", err);
setError("Failed to load devices");
} finally {
setLoading(false);
}
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch("/api/devices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to create device");
}
await fetchDevices();
setShowAddModal(false);
setFormData({ id: "", name: "", color: "#3498db", description: "" });
} catch (err: any) {
alert(err.message);
}
};
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedDevice) return;
try {
// If ID changed, we need to delete old and create new device
if (formData.id !== selectedDevice.id) {
// Delete old device
const deleteResponse = await fetch(`/api/devices/${selectedDevice.id}`, {
method: "DELETE",
});
if (!deleteResponse.ok) {
throw new Error("Failed to delete old device");
}
// Create new device with new ID
const createResponse = await fetch("/api/devices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!createResponse.ok) {
const error = await createResponse.json();
throw new Error(error.error || "Failed to create device with new ID");
}
} else {
// Just update existing device
const response = await fetch(`/api/devices/${selectedDevice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.name,
color: formData.color,
description: formData.description,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to update device");
}
}
await fetchDevices();
setShowEditModal(false);
setSelectedDevice(null);
} catch (err: any) {
alert(err.message);
}
};
const handleDelete = async () => {
if (!selectedDevice) return;
try {
const response = await fetch(`/api/devices/${selectedDevice.id}`, {
method: "DELETE",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to delete device");
}
await fetchDevices();
setShowDeleteModal(false);
setSelectedDevice(null);
} catch (err: any) {
alert(err.message);
}
};
const openEditModal = (device: Device) => {
setSelectedDevice(device);
setFormData({
id: device.id,
name: device.name,
color: device.color,
description: device.description || "",
});
setShowEditModal(true);
};
const openDeleteModal = (device: Device) => {
setSelectedDevice(device);
setShowDeleteModal(true);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-600">Loading devices...</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Hero Section with Gradient */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-purple-600 via-purple-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 flex justify-between items-center">
<div>
<h2 className="text-4xl font-bold text-white mb-2">Device Management</h2>
<p className="text-purple-100 text-lg">
{!isAdmin ? "Read-only view" : "Verwalte deine Tracking-Geräte"}
</p>
</div>
{isAdmin && (
<button
onClick={() => setShowAddModal(true)}
className="px-6 py-3 bg-white text-purple-700 rounded-xl hover:bg-purple-50 font-semibold shadow-lg hover:shadow-xl transition-all transform hover:-translate-y-0.5"
>
+ Add Device
</button>
)}
</div>
</div>
{error && (
<div className="bg-gradient-to-r from-red-50 to-red-100 border border-red-200 text-red-700 px-6 py-4 rounded-xl shadow-md">
<div className="flex items-center gap-2">
<span className="text-xl"></span>
<span className="font-semibold">{error}</span>
</div>
</div>
)}
{/* Device Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{devices.map((device) => {
const lastSeen = device.latestLocation
? new Date(device.latestLocation.timestamp)
: null;
const isRecent = lastSeen
? Date.now() - lastSeen.getTime() < 10 * 60 * 1000
: false;
return (
<div
key={device.id}
className="group relative overflow-hidden bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1 p-6 space-y-4"
>
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-indigo-50 opacity-0 group-hover:opacity-100 transition-opacity"></div>
<div className="relative">
<div className="flex justify-between items-start">
<div className="flex items-center gap-3">
<div
className="w-14 h-14 rounded-xl shadow-lg flex items-center justify-center ring-2 ring-white transform group-hover:scale-110 transition-transform"
style={{ backgroundColor: device.color }}
>
<span className="text-white text-2xl">📱</span>
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">
{device.name}
</h3>
<p className="text-xs text-gray-500 font-mono bg-gray-100 px-2 py-0.5 rounded">ID: {device.id}</p>
</div>
</div>
<span
className={`px-3 py-1.5 rounded-lg text-xs font-bold shadow-md ${
isRecent
? "bg-gradient-to-r from-green-500 to-emerald-600 text-white"
: "bg-gradient-to-r from-gray-400 to-gray-500 text-white"
}`}
>
{isRecent ? "🟢 Online" : "⚫ Offline"}
</span>
</div>
{device.description && (
<p className="text-sm text-gray-600">{device.description}</p>
)}
{device.latestLocation && (
<div className="bg-gradient-to-br from-gray-50 to-slate-50 rounded-xl p-4 space-y-2 border border-gray-200">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 flex items-center gap-2">
<span className="text-lg">🕒</span>
Last Seen:
</span>
<span className="font-medium text-gray-900">
{new Date(device.latestLocation.timestamp).toLocaleString()}
</span>
</div>
{device.latestLocation.battery !== undefined && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 flex items-center gap-2">
<span className="text-lg">🔋</span>
Battery:
</span>
<span className={`font-medium ${
device.latestLocation.battery > 20 ? 'text-green-600' : 'text-red-600'
}`}>
{device.latestLocation.battery}%
</span>
</div>
)}
{device.latestLocation.speed !== undefined && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 flex items-center gap-2">
<span className="text-lg">🚗</span>
Speed:
</span>
<span className="font-medium text-gray-900">
{(Number(device.latestLocation.speed) * 3.6).toFixed(1)} km/h
</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 flex items-center gap-2">
<span className="text-lg">📍</span>
Location:
</span>
<span className="font-medium text-gray-900">
{Number(device.latestLocation.latitude).toFixed(5)},{" "}
{Number(device.latestLocation.longitude).toFixed(5)}
</span>
</div>
</div>
)}
{device._count && (
<div className="text-sm text-gray-600">
{device._count.locations} location points
</div>
)}
{isAdmin && (
<div className="flex gap-2 pt-2">
<button
onClick={() => openEditModal(device)}
className="flex-1 px-4 py-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg hover:from-blue-700 hover:to-blue-800 text-sm font-semibold shadow-md hover:shadow-lg transition-all"
>
Edit
</button>
<button
onClick={() => openDeleteModal(device)}
className="flex-1 px-4 py-2.5 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-lg hover:from-red-700 hover:to-red-800 text-sm font-semibold shadow-md hover:shadow-lg transition-all"
>
Delete
</button>
</div>
)}
</div>
</div>
);
})}
</div>
{devices.length === 0 && (
<div className="relative overflow-hidden bg-gradient-to-br from-gray-50 to-slate-50 rounded-2xl shadow-lg p-12 text-center border border-gray-200">
<div className="absolute top-0 right-0 text-9xl opacity-5">📱</div>
<p className="text-xl font-semibold text-gray-600 mb-2">Keine Devices gefunden</p>
<p className="text-gray-500">Füge dein erstes Device hinzu, um zu starten.</p>
</div>
)}
{/* Add Device 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-lg max-w-md w-full p-6 space-y-4">
<h3 className="text-xl font-bold text-gray-900">Add New Device</h3>
<form onSubmit={handleAdd} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device ID *
</label>
<input
type="text"
required
value={formData.id}
onChange={(e) =>
setFormData({ ...formData, id: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., 12, 13"
/>
<p className="text-xs text-gray-500 mt-1">
Must match OwnTracks tracker ID (tid)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device Name *
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., iPhone 13"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
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 border border-gray-300"
/>
<input
type="text"
value={formData.color}
onChange={(e) =>
setFormData({ ...formData, color: e.target.value })
}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="#3498db"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description (optional)
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="Additional notes about this device"
/>
</div>
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={() => {
setShowAddModal(false);
setFormData({
id: "",
name: "",
color: "#3498db",
description: "",
});
}}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-medium"
>
Add Device
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Device Modal */}
{showEditModal && selectedDevice && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-md w-full p-6 space-y-4">
<h3 className="text-xl font-bold text-gray-900">
Edit Device
</h3>
<form onSubmit={handleEdit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device ID *
</label>
<input
type="text"
required
value={formData.id}
onChange={(e) =>
setFormData({ ...formData, id: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-amber-600 mt-1">
Changing ID will create a new device (location history stays with old ID)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Device Name *
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
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 border border-gray-300"
/>
<input
type="text"
value={formData.color}
onChange={(e) =>
setFormData({ ...formData, color: e.target.value })
}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description (optional)
</label>
<textarea
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
/>
</div>
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={() => {
setShowEditModal(false);
setSelectedDevice(null);
}}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-medium"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && selectedDevice && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg max-w-md w-full p-6 space-y-4">
<h3 className="text-xl font-bold text-gray-900">Delete Device</h3>
<p className="text-gray-600">
Are you sure you want to delete <strong>{selectedDevice.name}</strong>{" "}
(ID: {selectedDevice.id})?
</p>
<p className="text-sm text-red-600">
This will also delete all location history for this device. This action
cannot be undone.
</p>
<div className="flex gap-2 pt-4">
<button
onClick={() => {
setShowDeleteModal(false);
setSelectedDevice(null);
}}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Cancel
</button>
<button
onClick={handleDelete}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 font-medium"
>
Delete Device
</button>
</div>
</div>
</div>
)}
</div>
);
}