Edit files

This commit is contained in:
2025-11-24 20:33:15 +00:00
parent 843e93a274
commit b1190e2e50
14 changed files with 846 additions and 1207 deletions

143
app/api/export/csv/route.ts Normal file
View File

@@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from "next/server";
import { locationDb, userDb } from "@/lib/db";
import { auth } from "@/lib/auth";
import { calculateDistance, RateLimitedGeocoder } from "@/lib/geo-utils";
/**
* GET /api/export/csv
*
* Export location data as CSV for Lexware digital logbook
*
* Query parameters:
* - username: Filter by device tracker ID
* - startTime: Custom range start (ISO string)
* - endTime: Custom range end (ISO string)
* - includeGeocoding: Whether to include addresses (default: false for preview)
*/
export async function GET(request: NextRequest) {
try {
// Check authentication
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get user's allowed device IDs
const userId = (session.user as any).id;
const role = (session.user as any).role;
const sessionUsername = session.user.name || '';
const userDeviceIds = userDb.getAllowedDeviceIds(userId, role, sessionUsername);
if (userDeviceIds.length === 0) {
return new NextResponse("Keine Geräte verfügbar", { status: 403 });
}
const searchParams = request.nextUrl.searchParams;
const username = searchParams.get('username') || undefined;
const startTime = searchParams.get('startTime') || undefined;
const endTime = searchParams.get('endTime') || undefined;
const includeGeocoding = searchParams.get('includeGeocoding') === 'true';
// Fetch locations from database
let locations = locationDb.findMany({
user_id: 0,
username,
startTime,
endTime,
limit: 10000, // High limit for exports
});
// Filter by user's allowed devices
locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username));
if (locations.length === 0) {
return new NextResponse("Keine Daten im gewählten Zeitraum", { status: 404 });
}
// Sort chronologically (oldest first) for proper distance calculation
locations.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
// Initialize geocoder if needed
const geocoder = includeGeocoding ? new RateLimitedGeocoder() : null;
// Build CSV
const csvRows: string[] = [];
// Header
csvRows.push('Datum,Uhrzeit,Latitude,Longitude,Adresse,Distanz (km),Geschwindigkeit (km/h),Gerät');
// Process each location
for (let i = 0; i < locations.length; i++) {
const loc = locations[i];
const lat = Number(loc.latitude);
const lon = Number(loc.longitude);
// Calculate distance from previous point
let distance = 0;
if (i > 0) {
const prevLoc = locations[i - 1];
distance = calculateDistance(
Number(prevLoc.latitude),
Number(prevLoc.longitude),
lat,
lon
);
}
// Geocode if requested
let address = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
if (geocoder) {
address = await geocoder.geocode(lat, lon);
}
// Format timestamp (German format)
const date = new Date(loc.timestamp);
const dateStr = date.toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const timeStr = date.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
// Speed (may be null)
const speed = loc.speed != null ? Number(loc.speed).toFixed(1) : '';
// Device name
const deviceName = loc.username || 'Unbekannt';
// Build CSV row - properly escape address field
const escapedAddress = address.includes(',') ? `"${address}"` : address;
const distanceStr = distance.toFixed(3).replace('.', ','); // German decimal separator
csvRows.push(
`${dateStr},${timeStr},${lat.toFixed(6)},${lon.toFixed(6)},${escapedAddress},${distanceStr},${speed},${deviceName}`
);
}
const csv = csvRows.join('\n');
// Return CSV with proper headers
const filename = `fahrtenbuch_${startTime ? new Date(startTime).toISOString().split('T')[0] : 'alle'}_${endTime ? new Date(endTime).toISOString().split('T')[0] : 'alle'}.csv`;
return new NextResponse(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
} catch (error) {
console.error("Error exporting CSV:", error);
return NextResponse.json(
{
error: "Failed to export CSV",
details: error instanceof Error ? error.message : "Unknown error"
},
{ status: 500 }
);
}
}

View File

@@ -4,8 +4,8 @@ import { locationDb, Location } from '@/lib/db';
/**
* POST /api/locations/ingest
*
* Endpoint for n8n to push location data to local SQLite cache.
* This is called AFTER n8n stores the data in NocoDB.
* Endpoint for external systems to push location data to local SQLite cache.
* Can be used for bulk imports or external integrations.
*
* Expected payload (single location or array):
* {

View File

@@ -3,15 +3,11 @@ import type { LocationResponse } from "@/types/location";
import { locationDb, Location, deviceDb, userDb } from "@/lib/db";
import { auth } from "@/lib/auth";
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
/**
* GET /api/locations
*
* Hybrid approach:
* 1. Fetch fresh data from n8n webhook
* 2. Store new locations in local SQLite cache
* 3. Return filtered data from SQLite (enables 24h+ history)
* Fetches location data from local SQLite cache.
* The MQTT subscriber automatically writes new locations to the cache.
*
* Query parameters:
* - username: Filter by device tracker ID
@@ -19,7 +15,6 @@ const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/
* - startTime: Custom range start (ISO string)
* - endTime: Custom range end (ISO string)
* - limit: Maximum number of records (default: 1000)
* - sync: Set to 'false' to skip n8n fetch and read only from cache
*/
export async function GET(request: NextRequest) {
try {
@@ -58,87 +53,8 @@ export async function GET(request: NextRequest) {
const limit = searchParams.get('limit')
? parseInt(searchParams.get('limit')!, 10)
: 1000;
const sync = searchParams.get('sync') !== 'false'; // Default: true
// Variable to store n8n data as fallback
let n8nData: LocationResponse | null = null;
// Step 1: Optionally fetch and sync from n8n
if (sync) {
try {
const response = await fetch(N8N_API_URL, {
cache: "no-store",
signal: AbortSignal.timeout(3000), // 3 second timeout
});
if (response.ok) {
const data: LocationResponse = await response.json();
// Debug: Log first location from n8n
if (data.history && data.history.length > 0) {
console.log('[N8N Debug] First location from n8n:', {
username: data.history[0].username,
speed: data.history[0].speed,
speed_type: typeof data.history[0].speed,
speed_exists: 'speed' in data.history[0],
battery: data.history[0].battery,
battery_type: typeof data.history[0].battery,
battery_exists: 'battery' in data.history[0]
});
}
// Normalize data: Ensure speed and battery fields exist (treat 0 explicitly)
if (data.history && Array.isArray(data.history)) {
data.history = data.history.map(loc => {
// Generate display_time in German locale (Europe/Berlin timezone)
const displayTime = new Date(loc.timestamp).toLocaleString('de-DE', {
timeZone: 'Europe/Berlin',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return {
...loc,
display_time: displayTime,
// Explicit handling: 0 is valid, only undefined/null → null
speed: typeof loc.speed === 'number' ? loc.speed : (loc.speed !== undefined && loc.speed !== null ? Number(loc.speed) : null),
battery: typeof loc.battery === 'number' ? loc.battery : (loc.battery !== undefined && loc.battery !== null ? Number(loc.battery) : null),
};
});
}
// Store n8n data for fallback
n8nData = data;
// Store new locations in SQLite
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
// Get latest timestamp from our DB
const stats = locationDb.getStats();
const lastLocalTimestamp = stats.newest || '1970-01-01T00:00:00Z';
// Filter for only newer locations
const newLocations = data.history.filter(loc =>
loc.timestamp > lastLocalTimestamp
);
if (newLocations.length > 0) {
const inserted = locationDb.createMany(newLocations as Location[]);
console.log(`[Location Sync] Inserted ${inserted} new locations from n8n`);
}
}
}
} catch (syncError) {
// n8n not reachable - that's ok, we'll use cached data
console.warn('[Location Sync] n8n webhook not reachable, using cache only:',
syncError instanceof Error ? syncError.message : 'Unknown error');
}
}
// Step 2: Read from local SQLite with filters
// Read from local SQLite with filters
let locations = locationDb.findMany({
user_id: 0, // Always filter for MQTT devices
username,
@@ -151,38 +67,6 @@ export async function GET(request: NextRequest) {
// Filter locations to only include user's devices
locations = locations.filter(loc => loc.username && userDeviceIds.includes(loc.username));
// Step 3: If DB is empty, use n8n data as fallback
if (locations.length === 0 && n8nData && n8nData.history) {
console.log('[API] DB empty, using n8n data as fallback');
// Filter n8n data if needed
let filteredHistory = n8nData.history;
// Filter by user's devices
filteredHistory = filteredHistory.filter(loc => loc.username && userDeviceIds.includes(loc.username));
if (username) {
filteredHistory = filteredHistory.filter(loc => loc.username === username);
}
// Apply time filters
if (startTime && endTime) {
// Custom range
filteredHistory = filteredHistory.filter(loc =>
loc.timestamp >= startTime && loc.timestamp <= endTime
);
} else if (timeRangeHours) {
// Quick filter
const cutoffTime = new Date(Date.now() - timeRangeHours * 60 * 60 * 1000).toISOString();
filteredHistory = filteredHistory.filter(loc => loc.timestamp >= cutoffTime);
}
return NextResponse.json({
...n8nData,
history: filteredHistory,
total_points: filteredHistory.length,
});
}
// Normalize locations: Ensure speed, battery, and display_time are correct
locations = locations.map(loc => {
// Generate display_time if missing or regenerate from timestamp
@@ -207,7 +91,7 @@ export async function GET(request: NextRequest) {
// Get actual total count from database (not limited by 'limit' parameter)
const stats = locationDb.getStats();
// Step 4: Return data in n8n-compatible format
// Return data in standard format
const response: LocationResponse = {
success: true,
current: locations.length > 0 ? locations[0] : null,

View File

@@ -1,86 +0,0 @@
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { locationDb, Location } from '@/lib/db';
import type { LocationResponse } from "@/types/location";
const N8N_API_URL = process.env.N8N_API_URL || "https://n8n.example.com/webhook/location";
/**
* POST /api/locations/sync (ADMIN only)
*
* Manually sync location data from n8n webhook to local SQLite cache.
* This fetches all available data from n8n and stores only new records.
*
* Useful for:
* - Initial database population
* - Recovery after downtime
* - Manual refresh
*/
export async function POST() {
try {
// ADMIN only
const session = await auth();
if (!session?.user || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get stats before sync
const statsBefore = locationDb.getStats();
// Fetch from n8n webhook
const response = await fetch(N8N_API_URL, {
cache: "no-store",
signal: AbortSignal.timeout(10000), // 10 second timeout for manual sync
});
if (!response.ok) {
throw new Error(`n8n webhook returned ${response.status}`);
}
const data: LocationResponse = await response.json();
let insertedCount = 0;
// Store new locations in SQLite
if (data.history && Array.isArray(data.history) && data.history.length > 0) {
// Get latest timestamp from our DB
const lastLocalTimestamp = statsBefore.newest || '1970-01-01T00:00:00Z';
// Filter for only newer locations
const newLocations = data.history.filter(loc =>
loc.timestamp > lastLocalTimestamp
);
if (newLocations.length > 0) {
insertedCount = locationDb.createMany(newLocations as Location[]);
console.log(`[Manual Sync] Inserted ${insertedCount} new locations from n8n`);
}
}
// Get stats after sync
const statsAfter = locationDb.getStats();
return NextResponse.json({
success: true,
synced: insertedCount,
n8nTotal: data.total_points || data.history.length,
before: {
total: statsBefore.total,
newest: statsBefore.newest,
},
after: {
total: statsAfter.total,
newest: statsAfter.newest,
},
});
} catch (error) {
console.error('Sync error:', error);
return NextResponse.json(
{
error: 'Failed to sync locations',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
}

416
app/export/page.tsx Normal file
View File

@@ -0,0 +1,416 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
interface Device {
id: string;
name: string;
color: string;
}
interface PreviewRow {
datum: string;
uhrzeit: string;
latitude: string;
longitude: string;
adresse: string;
distanz: string;
geschwindigkeit: string;
geraet: string;
}
export default function ExportPage() {
const { data: session, status } = useSession();
const router = useRouter();
const [devices, setDevices] = useState<Device[]>([]);
const [selectedDevice, setSelectedDevice] = useState<string>("all");
const [startTime, setStartTime] = useState<string>("");
const [endTime, setEndTime] = useState<string>("");
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
const [totalPoints, setTotalPoints] = useState<number>(0);
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<string>("");
// Redirect if not authenticated
useEffect(() => {
if (status === "unauthenticated") {
router.push("/login");
}
}, [status, router]);
// Fetch devices
useEffect(() => {
const fetchDevices = async () => {
try {
const response = await fetch("/api/devices/public");
if (response.ok) {
const data = await response.json();
setDevices(data.devices || []);
}
} catch (err) {
console.error("Failed to fetch devices:", err);
}
};
if (status === "authenticated") {
fetchDevices();
}
}, [status]);
// Set default time range (last 7 days)
useEffect(() => {
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
// Format for datetime-local input (YYYY-MM-DDTHH:mm)
setEndTime(now.toISOString().slice(0, 16));
setStartTime(sevenDaysAgo.toISOString().slice(0, 16));
}, []);
// Generate preview
const handlePreview = async () => {
setLoading(true);
setPreviewData([]);
setTotalPoints(0);
try {
const params = new URLSearchParams();
if (selectedDevice !== "all") {
params.set("username", selectedDevice);
}
if (startTime) {
params.set("startTime", new Date(startTime).toISOString());
}
if (endTime) {
params.set("endTime", new Date(endTime).toISOString());
}
params.set("limit", "50"); // Preview only first 50
const response = await fetch(`/api/locations?${params.toString()}`);
if (!response.ok) throw new Error("Failed to fetch preview");
const data = await response.json();
const locations = data.history || [];
setTotalPoints(locations.length);
// Sort chronologically
locations.sort((a: any, b: any) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Build preview rows (without geocoding)
const rows: PreviewRow[] = [];
for (let i = 0; i < Math.min(locations.length, 50); i++) {
const loc = locations[i];
const lat = Number(loc.latitude);
const lon = Number(loc.longitude);
// Calculate distance
let distance = 0;
if (i > 0) {
const prevLoc = locations[i - 1];
distance = calculateDistance(
Number(prevLoc.latitude),
Number(prevLoc.longitude),
lat,
lon
);
}
const date = new Date(loc.timestamp);
rows.push({
datum: date.toLocaleDateString('de-DE'),
uhrzeit: date.toLocaleTimeString('de-DE'),
latitude: lat.toFixed(6),
longitude: lon.toFixed(6),
adresse: `${lat.toFixed(6)}, ${lon.toFixed(6)}`,
distanz: distance.toFixed(3),
geschwindigkeit: loc.speed != null ? Number(loc.speed).toFixed(1) : '-',
geraet: loc.username || 'Unbekannt',
});
}
setPreviewData(rows);
} catch (error) {
console.error("Preview error:", error);
alert("Fehler beim Laden der Vorschau");
} finally {
setLoading(false);
}
};
// Export CSV with geocoding
const handleExport = async () => {
if (previewData.length === 0) {
alert("Bitte zuerst Vorschau laden");
return;
}
const confirmed = confirm(
`${totalPoints} GPS-Punkte werden exportiert.\n\n` +
`ACHTUNG: Adressauflösung (Geocoding) kann bei vielen Punkten sehr lange dauern (ca. 1 Punkt pro Sekunde).\n\n` +
`Geschätzte Dauer: ca. ${Math.ceil(totalPoints / 60)} Minuten.\n\n` +
`Möchten Sie fortfahren?`
);
if (!confirmed) return;
setExporting(true);
setExportProgress(`Starte Export von ${totalPoints} Punkten...`);
try {
const params = new URLSearchParams();
if (selectedDevice !== "all") {
params.set("username", selectedDevice);
}
if (startTime) {
params.set("startTime", new Date(startTime).toISOString());
}
if (endTime) {
params.set("endTime", new Date(endTime).toISOString());
}
params.set("includeGeocoding", "true");
setExportProgress("Lade Daten und löse Adressen auf...");
const response = await fetch(`/api/export/csv?${params.toString()}`);
if (!response.ok) throw new Error("Export failed");
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `fahrtenbuch_${startTime ? new Date(startTime).toISOString().split('T')[0] : 'alle'}_${endTime ? new Date(endTime).toISOString().split('T')[0] : 'alle'}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
setExportProgress("Export erfolgreich abgeschlossen!");
setTimeout(() => setExportProgress(""), 3000);
} catch (error) {
console.error("Export error:", error);
alert("Fehler beim Exportieren");
setExportProgress("");
} finally {
setExporting(false);
}
};
// Export CSV without geocoding (faster)
const handleQuickExport = async () => {
if (previewData.length === 0) {
alert("Bitte zuerst Vorschau laden");
return;
}
setExporting(true);
setExportProgress("Exportiere ohne Adressauflösung...");
try {
const params = new URLSearchParams();
if (selectedDevice !== "all") {
params.set("username", selectedDevice);
}
if (startTime) {
params.set("startTime", new Date(startTime).toISOString());
}
if (endTime) {
params.set("endTime", new Date(endTime).toISOString());
}
params.set("includeGeocoding", "false");
const response = await fetch(`/api/export/csv?${params.toString()}`);
if (!response.ok) throw new Error("Export failed");
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `fahrtenbuch_schnell_${startTime ? new Date(startTime).toISOString().split('T')[0] : 'alle'}_${endTime ? new Date(endTime).toISOString().split('T')[0] : 'alle'}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
setExportProgress("Export erfolgreich abgeschlossen!");
setTimeout(() => setExportProgress(""), 3000);
} catch (error) {
console.error("Export error:", error);
alert("Fehler beim Exportieren");
setExportProgress("");
} finally {
setExporting(false);
}
};
if (status === "loading") {
return (
<div className="min-h-screen flex items-center justify-center">
<p>Laden...</p>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h1 className="text-3xl font-bold mb-2">CSV-Export für Fahrtenbuch</h1>
<p className="text-gray-600 mb-6">
Exportieren Sie Ihre GPS-Tracking-Daten für Lexware oder andere Fahrtenbuch-Software
</p>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div>
<label className="block text-sm font-medium mb-2">Gerät</label>
<select
value={selectedDevice}
onChange={(e) => setSelectedDevice(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="all">Alle Geräte</option>
{devices.map((dev) => (
<option key={dev.id} value={dev.id}>
{dev.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Von</label>
<input
type="datetime-local"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Bis</label>
<input
type="datetime-local"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
/>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3 mb-6">
<button
onClick={handlePreview}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400"
>
{loading ? "Lädt..." : "Vorschau laden"}
</button>
<button
onClick={handleQuickExport}
disabled={exporting || previewData.length === 0}
className="px-6 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400"
>
Schnell-Export (ohne Adressen)
</button>
<button
onClick={handleExport}
disabled={exporting || previewData.length === 0}
className="px-6 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400"
>
Vollständiger Export (mit Adressen)
</button>
</div>
{/* Progress */}
{exportProgress && (
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded">
<p className="text-blue-800">{exportProgress}</p>
</div>
)}
{/* Info */}
{totalPoints > 0 && (
<div className="mb-4 p-4 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-sm text-yellow-800">
<strong>Gefunden:</strong> {totalPoints} GPS-Punkte im gewählten Zeitraum
{totalPoints > 50 && " (Vorschau zeigt erste 50)"}
</p>
<p className="text-sm text-yellow-800 mt-1">
<strong>Hinweis:</strong> Der vollständige Export mit Adressauflösung dauert ca. {Math.ceil(totalPoints / 60)} Minuten.
Für schnelle Exporte nutzen Sie den Schnell-Export.
</p>
</div>
)}
</div>
{/* Preview Table */}
{previewData.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-bold mb-4">Vorschau (erste 50 Zeilen)</h2>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Uhrzeit</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Latitude</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Longitude</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Adresse</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Distanz (km)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Geschw. (km/h)</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Gerät</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{previewData.map((row, idx) => (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm">{row.datum}</td>
<td className="px-4 py-3 text-sm">{row.uhrzeit}</td>
<td className="px-4 py-3 text-sm font-mono text-xs">{row.latitude}</td>
<td className="px-4 py-3 text-sm font-mono text-xs">{row.longitude}</td>
<td className="px-4 py-3 text-sm text-gray-600">{row.adresse}</td>
<td className="px-4 py-3 text-sm">{row.distanz}</td>
<td className="px-4 py-3 text-sm">{row.geschwindigkeit}</td>
<td className="px-4 py-3 text-sm">{row.geraet}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}
// Haversine distance calculation (client-side for preview)
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371;
const dLat = toRadians(lat2 - lat1);
const dLon = toRadians(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) *
Math.cos(toRadians(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function toRadians(degrees: number): number {
return degrees * (Math.PI / 180);
}

View File

@@ -69,12 +69,20 @@ export default function MapPage() {
{/* Top row: Title and Admin link */}
<div className="flex items-center justify-between">
<h1 className="text-lg sm:text-xl font-bold text-black">Location Tracker</h1>
<a
href="/admin"
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors whitespace-nowrap"
>
Admin
</a>
<div className="flex gap-2">
<a
href="/export"
className="px-3 py-1 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors whitespace-nowrap"
>
📥 Export
</a>
<a
href="/admin"
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors whitespace-nowrap"
>
Admin
</a>
</div>
</div>
{/* Controls row - responsive grid */}