Attraktionen
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -47,3 +47,12 @@ pnpm-debug.log*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
llm_context.txt
|
||||
*.log
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
|
||||
@@ -7,3 +7,8 @@ services:
|
||||
- "3002:3001"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
# Spiegelt deinen lokalen Quellcode live in den Container:
|
||||
- ./src:/app/src
|
||||
- ./public:/app/public
|
||||
command: npm run dev
|
||||
@@ -1,6 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
serverExternalPackages: ['bullmq', 'ioredis'],
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['bullmq', 'ioredis'],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
serverExternalPackages: ['bullmq', 'ioredis'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
147
src/app/api/attractions/[id]/route.ts
Normal file
147
src/app/api/attractions/[id]/route.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
async function parseGoogleMapsUrl(inputUrl: string) {
|
||||
let targetUrl = inputUrl.trim();
|
||||
if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) {
|
||||
try {
|
||||
const res = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
|
||||
redirect: 'follow',
|
||||
});
|
||||
targetUrl = res.url || targetUrl;
|
||||
} catch (err) {}
|
||||
}
|
||||
let lat: number | null = null;
|
||||
let lon: number | null = null;
|
||||
const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coordMatch) {
|
||||
lat = parseFloat(coordMatch[1]);
|
||||
lon = parseFloat(coordMatch[2]);
|
||||
}
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
async function geocode(address: string) {
|
||||
if (!address || address.startsWith('http')) return null;
|
||||
const search = async (q: string) => {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1`;
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'TravelPlannerApp/1.0' } });
|
||||
const data = await res.json();
|
||||
if (data && data.length > 0) return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
|
||||
} catch (err) {}
|
||||
return null;
|
||||
};
|
||||
|
||||
let coords = await search(address);
|
||||
if (!coords && address.includes(',')) {
|
||||
const parts = address.split(',').map(p => p.trim());
|
||||
await sleep(1100);
|
||||
coords = await search(`${parts[parts.length - 2]}, ${parts[parts.length - 1]}`);
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) {
|
||||
try {
|
||||
const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.code === 'Ok' && data.routes?.length > 0) {
|
||||
return {
|
||||
distanceKm: Math.round((data.routes[0].distance / 1000) * 10) / 10,
|
||||
durationMin: Math.round(data.routes[0].duration / 60),
|
||||
};
|
||||
}
|
||||
} catch (err) {}
|
||||
return { distanceKm: null, durationMin: null };
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const { name, address, category_icon, notes, hotel_ids, trip_id } = await req.json();
|
||||
|
||||
let sightCoords: { lat: number; lon: number } | null = null;
|
||||
if (address.includes('google.com/maps') || address.includes('goo.gl')) {
|
||||
const parsed = await parseGoogleMapsUrl(address);
|
||||
if (parsed.lat && parsed.lon) sightCoords = { lat: parsed.lat, lon: parsed.lon };
|
||||
}
|
||||
if (!sightCoords) {
|
||||
sightCoords = await geocode(address || name);
|
||||
}
|
||||
|
||||
const res = await query(
|
||||
`UPDATE hotel_attractions
|
||||
SET name = $1,
|
||||
address = $2,
|
||||
category_icon = $3,
|
||||
notes = $4,
|
||||
trip_id = $5,
|
||||
lat = COALESCE($6, lat),
|
||||
lon = COALESCE($7, lon)
|
||||
WHERE id = $8
|
||||
RETURNING *`,
|
||||
[
|
||||
name,
|
||||
address,
|
||||
category_icon || '🏛️',
|
||||
notes || null,
|
||||
trip_id || null,
|
||||
sightCoords?.lat || null,
|
||||
sightCoords?.lon || null,
|
||||
id,
|
||||
]
|
||||
);
|
||||
|
||||
await query('DELETE FROM hotel_attraction_assignments WHERE attraction_id = $1', [id]);
|
||||
|
||||
const selectedHotelIds: string[] = Array.isArray(hotel_ids) ? hotel_ids : [];
|
||||
for (const hId of selectedHotelIds) {
|
||||
let distanceKm: number | null = null;
|
||||
let durationMin: number | null = null;
|
||||
|
||||
const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hId]);
|
||||
if (hotelRes.rows.length > 0) {
|
||||
const hotel = hotelRes.rows[0];
|
||||
await sleep(1100);
|
||||
let hCoords = await geocode(hotel.address);
|
||||
if (!hCoords && hotel.name) {
|
||||
await sleep(1100);
|
||||
hCoords = await geocode(hotel.name);
|
||||
}
|
||||
|
||||
if (hCoords && sightCoords) {
|
||||
const drive = await getDriveInfo(hCoords, sightCoords);
|
||||
distanceKm = drive.distanceKm;
|
||||
durationMin = drive.durationMin;
|
||||
}
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO hotel_attraction_assignments (attraction_id, hotel_id, distance_km, duration_minutes)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[id, hId, distanceKm, durationMin]
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, attraction: res.rows[0] });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
await query('DELETE FROM hotel_attractions WHERE id = $1', [params.id]);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
230
src/app/api/attractions/route.ts
Normal file
230
src/app/api/attractions/route.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
async function parseGoogleMapsUrl(inputUrl: string) {
|
||||
let targetUrl = inputUrl.trim();
|
||||
|
||||
if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) {
|
||||
try {
|
||||
const res = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
targetUrl = res.url || targetUrl;
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Auflösen des Google Maps Links:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let extractedName: string | null = null;
|
||||
let lat: number | null = null;
|
||||
let lon: number | null = null;
|
||||
|
||||
const placeMatch = targetUrl.match(/\/place\/([^/@?]+)/);
|
||||
if (placeMatch && placeMatch[1]) {
|
||||
extractedName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coordMatch) {
|
||||
lat = parseFloat(coordMatch[1]);
|
||||
lon = parseFloat(coordMatch[2]);
|
||||
} else {
|
||||
const dataCoordMatch = targetUrl.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (dataCoordMatch) {
|
||||
lat = parseFloat(dataCoordMatch[1]);
|
||||
lon = parseFloat(dataCoordMatch[2]);
|
||||
} else {
|
||||
const queryCoordMatch = targetUrl.match(/[?&](?:q|query|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (queryCoordMatch) {
|
||||
lat = parseFloat(queryCoordMatch[1]);
|
||||
lon = parseFloat(queryCoordMatch[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { name: extractedName, lat, lon, resolvedUrl: targetUrl };
|
||||
}
|
||||
|
||||
// Geocoding mit intelligentem Adress-Fallback
|
||||
async function geocode(address: string): Promise<{ lat: number; lon: number } | null> {
|
||||
if (!address || address.startsWith('http')) return null;
|
||||
|
||||
const search = async (q: string) => {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'TravelPlannerApp/1.0 (Homelab Private Usage)' },
|
||||
});
|
||||
if (res.status === 429) {
|
||||
await sleep(1500);
|
||||
return null;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data && data.length > 0) {
|
||||
return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Geocoding Fehler bei "${q}":`, err);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 1. Exakter Adresstext
|
||||
let coords = await search(address);
|
||||
if (coords) return coords;
|
||||
|
||||
// 2. Fallback: Ort und Land aus der Komma-Kette filtern
|
||||
const parts = address.split(',').map(p => p.trim());
|
||||
if (parts.length > 2) {
|
||||
await sleep(1100);
|
||||
const cityFallback = `${parts[parts.length - 2]}, ${parts[parts.length - 1]}`;
|
||||
coords = await search(cityFallback);
|
||||
}
|
||||
|
||||
return coords;
|
||||
}
|
||||
|
||||
// OSRM Routing-Berechnung
|
||||
async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) {
|
||||
try {
|
||||
const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.code === 'Ok' && data.routes && data.routes.length > 0) {
|
||||
const distanceKm = Math.round((data.routes[0].distance / 1000) * 10) / 10;
|
||||
const durationMin = Math.round(data.routes[0].duration / 60);
|
||||
return { distanceKm, durationMin };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('OSRM Routing Fehler:', err);
|
||||
}
|
||||
return { distanceKm: null, durationMin: null };
|
||||
}
|
||||
|
||||
// GET: Alle Sehenswürdigkeiten laden
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const tripId = searchParams.get('tripId');
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
a.*,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'hotel_id', haa.hotel_id,
|
||||
'distance_km', haa.distance_km,
|
||||
'duration_minutes', haa.duration_minutes
|
||||
)
|
||||
) FILTER (WHERE haa.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS assignments
|
||||
FROM hotel_attractions a
|
||||
LEFT JOIN hotel_attraction_assignments haa ON a.id = haa.attraction_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (tripId && tripId !== 'ALL') {
|
||||
params.push(tripId);
|
||||
sql += ` AND a.trip_id = $${params.length}`;
|
||||
}
|
||||
|
||||
sql += ' GROUP BY a.id ORDER BY a.created_at ASC';
|
||||
const res = await query(sql, params);
|
||||
return NextResponse.json({ success: true, attractions: res.rows });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message, attractions: [] }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Neues Ausflugsziel anlegen
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { trip_id, hotel_ids, name, address, category_icon, notes } = await req.json();
|
||||
|
||||
if (!address) {
|
||||
return NextResponse.json({ error: 'Google Maps Link oder Adresse erforderlich' }, { status: 400 });
|
||||
}
|
||||
|
||||
let finalName = name?.trim() || '';
|
||||
let finalAddress = address.trim();
|
||||
let sightCoords: { lat: number; lon: number } | null = null;
|
||||
|
||||
if (finalAddress.includes('google.com/maps') || finalAddress.includes('goo.gl')) {
|
||||
const parsed = await parseGoogleMapsUrl(finalAddress);
|
||||
if (parsed.name && !finalName) finalName = parsed.name;
|
||||
if (parsed.lat && parsed.lon) sightCoords = { lat: parsed.lat, lon: parsed.lon };
|
||||
}
|
||||
|
||||
if (!sightCoords) {
|
||||
sightCoords = await geocode(finalAddress || finalName);
|
||||
}
|
||||
if (!finalName) finalName = finalAddress;
|
||||
|
||||
// 1. In hotel_attractions anlegen
|
||||
const insertRes = await query(
|
||||
`INSERT INTO hotel_attractions (trip_id, name, address, category_icon, notes, lat, lon)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
trip_id || null,
|
||||
finalName,
|
||||
finalAddress,
|
||||
category_icon || '🏛️',
|
||||
notes || null,
|
||||
sightCoords?.lat || null,
|
||||
sightCoords?.lon || null,
|
||||
]
|
||||
);
|
||||
|
||||
const newAttraction = insertRes.rows[0];
|
||||
|
||||
// 2. Für alle ausgewählten Hotels Distanz berechnen (mit Pause gegen 429)
|
||||
const selectedHotelIds: string[] = Array.isArray(hotel_ids) ? hotel_ids : [];
|
||||
for (const hId of selectedHotelIds) {
|
||||
let distanceKm: number | null = null;
|
||||
let durationMin: number | null = null;
|
||||
|
||||
const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hId]);
|
||||
if (hotelRes.rows.length > 0) {
|
||||
const hotel = hotelRes.rows[0];
|
||||
|
||||
await sleep(1100); // Fair-Use Rate-Limit für Nominatim einhalten
|
||||
let hCoords = await geocode(hotel.address);
|
||||
if (!hCoords && hotel.name) {
|
||||
await sleep(1100);
|
||||
hCoords = await geocode(hotel.name);
|
||||
}
|
||||
|
||||
if (hCoords && sightCoords) {
|
||||
const drive = await getDriveInfo(hCoords, sightCoords);
|
||||
distanceKm = drive.distanceKm;
|
||||
durationMin = drive.durationMin;
|
||||
}
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO hotel_attraction_assignments (attraction_id, hotel_id, distance_km, duration_minutes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (attraction_id, hotel_id) DO UPDATE
|
||||
SET distance_km = EXCLUDED.distance_km, duration_minutes = EXCLUDED.duration_minutes`,
|
||||
[newAttraction.id, hId, distanceKm, durationMin]
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, attraction: newAttraction });
|
||||
} catch (e: any) {
|
||||
console.error('Fehler beim Anlegen:', e);
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
161
src/app/api/hotels/[id]/attractions/route.ts
Normal file
161
src/app/api/hotels/[id]/attractions/route.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Google Maps URL auflösen und Name + Koordinaten extrahieren
|
||||
async function parseGoogleMapsUrl(inputUrl: string) {
|
||||
let targetUrl = inputUrl.trim();
|
||||
|
||||
// Kurzlinks (maps.app.goo.gl oder goo.gl) auflösen
|
||||
if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) {
|
||||
try {
|
||||
const res = await fetch(targetUrl, { method: 'HEAD', redirect: 'follow' });
|
||||
targetUrl = res.url || targetUrl;
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Auflösen des Google Maps Kurzlinks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let extractedName: string | null = null;
|
||||
let lat: number | null = null;
|
||||
let lon: number | null = null;
|
||||
|
||||
// 1. Name extrahieren aus /place/Ortsname/
|
||||
const placeMatch = targetUrl.match(/\/place\/([^/@?]+)/);
|
||||
if (placeMatch && placeMatch[1]) {
|
||||
extractedName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||||
}
|
||||
|
||||
// 2. Koordinaten aus @lat,lon extrahieren
|
||||
const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||||
if (coordMatch) {
|
||||
lat = parseFloat(coordMatch[1]);
|
||||
lon = parseFloat(coordMatch[2]);
|
||||
} else {
|
||||
// Alternativ-Format: !3dLAT!4dLON
|
||||
const dataCoordMatch = targetUrl.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||||
if (dataCoordMatch) {
|
||||
lat = parseFloat(dataCoordMatch[1]);
|
||||
lon = parseFloat(dataCoordMatch[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return { name: extractedName, lat, lon, resolvedUrl: targetUrl };
|
||||
}
|
||||
|
||||
// Fallback Nominatim Geocoding
|
||||
async function geocode(address: string): Promise<{ lat: number; lon: number } | null> {
|
||||
try {
|
||||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'TravelPlannerApp/1.0 (Homelab Private Usage)' },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data && data.length > 0) {
|
||||
return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Geocoding Fehler:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// OSRM Fahrzeit- & Distanzberechnung
|
||||
async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) {
|
||||
try {
|
||||
const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.code === 'Ok' && data.routes && data.routes.length > 0) {
|
||||
const distanceKm = Math.round((data.routes[0].distance / 1000) * 10) / 10;
|
||||
const durationMin = Math.round(data.routes[0].duration / 60);
|
||||
return { distanceKm, durationMin };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('OSRM Routing Fehler:', err);
|
||||
}
|
||||
return { distanceKm: null, durationMin: null };
|
||||
}
|
||||
|
||||
// GET: Alle Sehenswürdigkeiten eines Hotels abrufen
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const res = await query(
|
||||
`SELECT * FROM hotel_attractions
|
||||
WHERE hotel_id = $1
|
||||
ORDER BY distance_km ASC NULLS LAST, created_at ASC`,
|
||||
[params.id]
|
||||
);
|
||||
return NextResponse.json({ success: true, attractions: res.rows });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Google Maps Link oder Adresse verarbeiten & Route berechnen
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const hotelId = params.id;
|
||||
const { name, input_value, category_icon, notes } = await req.json();
|
||||
|
||||
if (!input_value) {
|
||||
return NextResponse.json({ error: 'Google Maps Link oder Adresse ist erforderlich' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Hotel-Koordinaten ermitteln
|
||||
const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hotelId]);
|
||||
if (hotelRes.rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Hotel nicht gefunden' }, { status: 404 });
|
||||
}
|
||||
const hotel = hotelRes.rows[0];
|
||||
const hotelCoords = await geocode(hotel.address || hotel.name);
|
||||
|
||||
let finalName = name?.trim() || '';
|
||||
let finalAddress = input_value.trim();
|
||||
let sightCoords: { lat: number; lon: number } | null = null;
|
||||
|
||||
// 2. Google Maps URL prüfen & parsen
|
||||
if (input_value.includes('google.com/maps') || input_value.includes('goo.gl')) {
|
||||
const parsedMaps = await parseGoogleMapsUrl(input_value);
|
||||
if (parsedMaps.name && !finalName) {
|
||||
finalName = parsedMaps.name;
|
||||
}
|
||||
if (parsedMaps.lat && parsedMaps.lon) {
|
||||
sightCoords = { lat: parsedMaps.lat, lon: parsedMaps.lon };
|
||||
}
|
||||
}
|
||||
|
||||
// Falls keine Koordinaten aus dem Link gelesen werden konnten: Fallback auf Geocoding
|
||||
if (!sightCoords) {
|
||||
sightCoords = await geocode(finalAddress || finalName);
|
||||
}
|
||||
|
||||
if (!finalName) {
|
||||
finalName = finalAddress;
|
||||
}
|
||||
|
||||
// 3. Fahrzeit & Distanz via OSRM berechnen
|
||||
let distanceKm: number | null = null;
|
||||
let durationMin: number | null = null;
|
||||
|
||||
if (hotelCoords && sightCoords) {
|
||||
const drive = await getDriveInfo(hotelCoords, sightCoords);
|
||||
distanceKm = drive.distanceKm;
|
||||
durationMin = drive.durationMin;
|
||||
}
|
||||
|
||||
// 4. In Postgres speichern
|
||||
const insertRes = await query(
|
||||
`INSERT INTO hotel_attractions (hotel_id, name, address, category_icon, notes, distance_km, duration_minutes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[hotelId, finalName, finalAddress, category_icon || '🏛️', notes || null, distanceKm, durationMin]
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, attraction: insertRes.rows[0] });
|
||||
} catch (e: any) {
|
||||
console.error('Fehler beim Anlegen der Sehenswürdigkeit:', e);
|
||||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
522
src/app/page.tsx
522
src/app/page.tsx
@@ -17,6 +17,22 @@ interface PriceEntry {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AttractionAssignment {
|
||||
hotel_id: string;
|
||||
distance_km?: number | null;
|
||||
duration_minutes?: number | null;
|
||||
}
|
||||
|
||||
interface Attraction {
|
||||
id: string;
|
||||
trip_id?: string | null;
|
||||
name: string;
|
||||
address: string;
|
||||
category_icon: string;
|
||||
notes?: string | null;
|
||||
assignments?: AttractionAssignment[];
|
||||
}
|
||||
|
||||
interface Hotel {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -55,6 +71,7 @@ export default function TravelDashboard() {
|
||||
const [trips, setTrips] = useState<Trip[]>([]);
|
||||
const [activeTripId, setActiveTripId] = useState<string>('ALL');
|
||||
const [hotels, setHotels] = useState<Hotel[]>([]);
|
||||
const [attractions, setAttractions] = useState<Attraction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scanningAllPrices, setScanningAllPrices] = useState(false);
|
||||
const [scanningHotelId, setScanningHotelId] = useState<string | null>(null);
|
||||
@@ -64,7 +81,7 @@ export default function TravelDashboard() {
|
||||
const [priceHistory, setPriceHistory] = useState<PriceEntry[]>([]);
|
||||
const [loadingPrices, setLoadingPrices] = useState(false);
|
||||
|
||||
// Modals
|
||||
// Reise & Hotel Modals
|
||||
const [showAddTripModal, setShowAddTripModal] = useState(false);
|
||||
const [newTripName, setNewTripName] = useState('');
|
||||
const [showAddHotelModal, setShowAddHotelModal] = useState(false);
|
||||
@@ -73,6 +90,17 @@ export default function TravelDashboard() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showParkedModal, setShowParkedModal] = useState(false);
|
||||
|
||||
// Ausflugsziele: Zentral & Mehrfachzuordnung
|
||||
const [showManageSightsModal, setShowManageSightsModal] = useState(false);
|
||||
const [showSightFormModal, setShowSightFormModal] = useState(false);
|
||||
const [editingSight, setEditingSight] = useState<Attraction | null>(null);
|
||||
const [sightName, setSightName] = useState('');
|
||||
const [sightAddress, setSightAddress] = useState('');
|
||||
const [sightIcon, setSightIcon] = useState('🏛️');
|
||||
const [sightNotes, setSightNotes] = useState('');
|
||||
const [selectedHotelIds, setSelectedHotelIds] = useState<string[]>([]);
|
||||
const [isSubmittingSight, setIsSubmittingSight] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
@@ -97,20 +125,46 @@ export default function TravelDashboard() {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resTrips = await fetch('/api/trips');
|
||||
const dataTrips = await resTrips.json();
|
||||
setTrips(dataTrips.trips || []);
|
||||
try {
|
||||
const resTrips = await fetch('/api/trips');
|
||||
if (resTrips.ok) {
|
||||
const dataTrips = await resTrips.json();
|
||||
setTrips(dataTrips.trips || []);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const resHotels = await fetch('/api/hotels');
|
||||
const dataHotels = await resHotels.json();
|
||||
setHotels(dataHotels.hotels || []);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden:', e);
|
||||
try {
|
||||
const resHotels = await fetch('/api/hotels');
|
||||
if (resHotels.ok) {
|
||||
const dataHotels = await resHotels.json();
|
||||
setHotels(dataHotels.hotels || []);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const resAttractions = await fetch('/api/attractions');
|
||||
if (resAttractions.ok) {
|
||||
const dataAttractions = await resAttractions.json();
|
||||
setAttractions(dataAttractions.attractions || []);
|
||||
}
|
||||
} catch (e) {}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAttractions = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/attractions');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setAttractions(data.attractions || []);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Aktualisieren der Ausflugsziele:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const openPriceHistoryModal = async (hotel: Hotel) => {
|
||||
setSelectedPriceHotel(hotel);
|
||||
setLoadingPrices(true);
|
||||
@@ -118,9 +172,7 @@ export default function TravelDashboard() {
|
||||
try {
|
||||
const res = await fetch(`/api/hotels/${hotel.id}/prices`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setPriceHistory(data.prices || []);
|
||||
}
|
||||
if (data.success) setPriceHistory(data.prices || []);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Preise:', e);
|
||||
} finally {
|
||||
@@ -178,7 +230,6 @@ export default function TravelDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
// Hotel ausmustern (parken)
|
||||
const parkHotel = async (id: string, name: string) => {
|
||||
try {
|
||||
await fetch(`/api/hotels/${id}`, {
|
||||
@@ -192,7 +243,6 @@ export default function TravelDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
// Ausgemustertes Hotel wiederherstellen
|
||||
const restoreHotel = async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/hotels/${id}`, {
|
||||
@@ -206,6 +256,84 @@ export default function TravelDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
// Ausflug Modal öffnen (mit Checkboxen für Mehrfachauswahl)
|
||||
const openSightModal = (sight?: Attraction, preselectedHotelId?: string) => {
|
||||
if (sight) {
|
||||
setEditingSight(sight);
|
||||
setSightName(sight.name);
|
||||
setSightAddress(sight.address);
|
||||
setSightIcon(sight.category_icon || '🏛️');
|
||||
setSightNotes(sight.notes || '');
|
||||
setSelectedHotelIds((sight.assignments || []).map(a => a.hotel_id));
|
||||
} else {
|
||||
setEditingSight(null);
|
||||
setSightName('');
|
||||
setSightAddress('');
|
||||
setSightIcon('🏛️');
|
||||
setSightNotes('');
|
||||
setSelectedHotelIds(preselectedHotelId ? [preselectedHotelId] : []);
|
||||
}
|
||||
setShowSightFormModal(true);
|
||||
};
|
||||
|
||||
const toggleHotelSelection = (hotelId: string) => {
|
||||
setSelectedHotelIds(prev =>
|
||||
prev.includes(hotelId) ? prev.filter(id => id !== hotelId) : [...prev, hotelId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveSight = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!sightAddress) return;
|
||||
setIsSubmittingSight(true);
|
||||
try {
|
||||
const targetTripId = activeTripId === 'ALL' ? (trips[0]?.id || null) : activeTripId;
|
||||
const payload = {
|
||||
name: sightName,
|
||||
address: sightAddress,
|
||||
category_icon: sightIcon,
|
||||
notes: sightNotes,
|
||||
hotel_ids: selectedHotelIds,
|
||||
trip_id: targetTripId
|
||||
};
|
||||
|
||||
if (editingSight) {
|
||||
const res = await fetch(`/api/attractions/${editingSight.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error);
|
||||
} else {
|
||||
const res = await fetch('/api/attractions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error);
|
||||
}
|
||||
|
||||
await refreshAttractions();
|
||||
setShowSightFormModal(false);
|
||||
} catch (err: any) {
|
||||
alert('Fehler beim Speichern: ' + err.message);
|
||||
} finally {
|
||||
setIsSubmittingSight(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSight = async (sightId: string) => {
|
||||
if (!confirm('Sehenswürdigkeit wirklich löschen?')) return;
|
||||
try {
|
||||
await fetch(`/api/attractions/${sightId}`, { method: 'DELETE' });
|
||||
await refreshAttractions();
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Löschen:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerBatchPriceScan = async () => {
|
||||
setScanningAllPrices(true);
|
||||
try {
|
||||
@@ -229,7 +357,6 @@ export default function TravelDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Nur AKTIVE Hotels filtern und chronologisch sortieren
|
||||
const currentHotels = hotels
|
||||
.filter(h => h.is_active)
|
||||
.filter(h => {
|
||||
@@ -244,21 +371,19 @@ export default function TravelDashboard() {
|
||||
return new Date(a.checkin_date).getTime() - new Date(b.checkin_date).getTime();
|
||||
});
|
||||
|
||||
// 2. Ausgemusterte (inaktive) Hotels für das Modal ermitteln
|
||||
const parkedHotels = hotels.filter(h => !h.is_active);
|
||||
|
||||
// 3. Clustering der aktiven Hotels
|
||||
const currentAttractions = attractions.filter(a => {
|
||||
if (activeTripId === 'ALL') return true;
|
||||
return String(a.trip_id).toLowerCase() === String(activeTripId).toLowerCase();
|
||||
});
|
||||
|
||||
const clusteredStops = currentHotels.reduce<HotelCluster[]>((acc, hotel) => {
|
||||
const checkin = hotel.checkin_date ? hotel.checkin_date.split('T')[0] : null;
|
||||
const checkout = hotel.checkout_date ? hotel.checkout_date.split('T')[0] : null;
|
||||
|
||||
if (!checkin) {
|
||||
acc.push({
|
||||
clusterKey: `single_${hotel.id}`,
|
||||
checkin_date: null,
|
||||
checkout_date: null,
|
||||
hotels: [hotel]
|
||||
});
|
||||
acc.push({ clusterKey: `single_${hotel.id}`, checkin_date: null, checkout_date: null, hotels: [hotel] });
|
||||
return acc;
|
||||
}
|
||||
|
||||
@@ -268,12 +393,7 @@ export default function TravelDashboard() {
|
||||
if (existingCluster) {
|
||||
existingCluster.hotels.push(hotel);
|
||||
} else {
|
||||
acc.push({
|
||||
clusterKey: key,
|
||||
checkin_date: checkin,
|
||||
checkout_date: checkout,
|
||||
hotels: [hotel]
|
||||
});
|
||||
acc.push({ clusterKey: key, checkin_date: checkin, checkout_date: checkout, hotels: [hotel] });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
@@ -288,7 +408,7 @@ export default function TravelDashboard() {
|
||||
|
||||
<header style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: '2.2rem', marginBottom: '0.5rem', fontWeight: '800' }}>📍 Reise-Ablauf & Timeline</h1>
|
||||
<p style={{ color: '#6b7280', margin: 0 }}>Routenplanung, Datums-Cluster, Preishistorie & KI-Bewertungen</p>
|
||||
<p style={{ color: '#6b7280', margin: 0 }}>Routenplanung, Sehenswürdigkeiten, Fahrzeiten & KI-Reviews</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'center', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
@@ -298,11 +418,16 @@ export default function TravelDashboard() {
|
||||
➕ Neues Hotel hinzufügen
|
||||
</button>
|
||||
<button
|
||||
onClick={triggerBatchPriceScan}
|
||||
disabled={scanningAllPrices}
|
||||
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.7rem 1.3rem', borderRadius: '8px', fontWeight: '600', cursor: 'pointer', opacity: scanningAllPrices ? 0.7 : 1 }}
|
||||
onClick={() => openSightModal()}
|
||||
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.7rem 1.3rem', borderRadius: '8px', fontWeight: '600', cursor: 'pointer' }}
|
||||
>
|
||||
{scanningAllPrices ? '🔄 Scanne...' : '📊 Preise aller Hotels aktualisieren'}
|
||||
🏛️ Ausflug anlegen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowManageSightsModal(true)}
|
||||
style={{ background: '#f1f5f9', color: '#334155', border: '1px solid #cbd5e1', padding: '0.7rem 1.3rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
📋 Alle Ausflüge ({currentAttractions.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowParkedModal(true)}
|
||||
@@ -315,6 +440,13 @@ export default function TravelDashboard() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={triggerBatchPriceScan}
|
||||
disabled={scanningAllPrices}
|
||||
style={{ background: '#3b82f6', color: '#fff', border: 'none', padding: '0.7rem 1.3rem', borderRadius: '8px', fontWeight: '600', cursor: 'pointer', opacity: scanningAllPrices ? 0.7 : 1 }}
|
||||
>
|
||||
{scanningAllPrices ? '🔄 Scanne...' : '📊 Preise aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -361,6 +493,168 @@ export default function TravelDashboard() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MODAL: AUSFLÜGE ZENTRAL VERWALTEN */}
|
||||
{showManageSightsModal && (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
|
||||
<div style={{ background: '#fff', borderRadius: '16px', maxWidth: '750px', width: '100%', maxHeight: '85vh', display: 'flex', flexDirection: 'column', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' }}>
|
||||
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '1.3rem', fontWeight: '800' }}>🏛️ Zentrale Ausflugsziele</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.85rem', color: '#64748b' }}>Sehenswürdigkeiten bearbeiten und flexibel mehreren Hotels zuweisen</p>
|
||||
</div>
|
||||
<button onClick={() => setShowManageSightsModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||||
</div>
|
||||
<div style={{ padding: '1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
|
||||
{currentAttractions.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: '#64748b', padding: '2rem 0' }}>Noch keine Ausflugsziele für diesen Trip hinterlegt.</p>
|
||||
) : (
|
||||
currentAttractions.map(sight => {
|
||||
const assignedCount = (sight.assignments || []).length;
|
||||
return (
|
||||
<div key={sight.id} style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '10px', padding: '0.9rem 1.1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: '240px', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>{sight.category_icon || '🏛️'}</span>
|
||||
<span style={{ fontWeight: '700', fontSize: '0.95rem', color: '#0f172a' }}>{sight.name}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#64748b', marginTop: '0.3rem', display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{assignedCount > 0 ? (
|
||||
<span style={{ background: '#dbeafe', color: '#1e40af', padding: '0.15rem 0.5rem', borderRadius: '6px', fontWeight: '600' }}>
|
||||
🏨 Zugewiesen zu {assignedCount} {assignedCount === 1 ? 'Hotel' : 'Hotels'}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ background: '#f1f5f9', color: '#64748b', padding: '0.15rem 0.5rem', borderRadius: '6px' }}>
|
||||
Keinem Hotel zugewiesen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||||
<button
|
||||
onClick={() => { setShowManageSightsModal(false); openSightModal(sight); }}
|
||||
style={{ background: '#f1f5f9', border: '1px solid #cbd5e1', padding: '0.4rem 0.7rem', borderRadius: '6px', fontSize: '0.8rem', fontWeight: '600', cursor: 'pointer' }}
|
||||
>
|
||||
✏️ Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteSight(sight.id)}
|
||||
style={{ background: '#fee2e2', color: '#b91c1c', border: 'none', padding: '0.4rem 0.7rem', borderRadius: '6px', fontSize: '0.8rem', fontWeight: '600', cursor: 'pointer' }}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '1rem 1.5rem', borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { setShowManageSightsModal(false); openSightModal(); }}
|
||||
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.6rem 1.2rem', borderRadius: '6px', fontWeight: '700', cursor: 'pointer' }}
|
||||
>
|
||||
➕ Neues Ausflugsziel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL: AUSFLUG ANLEGEN / BEARBEITEN */}
|
||||
{showSightFormModal && (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1001, padding: '1rem' }}>
|
||||
<div style={{ background: '#fff', borderRadius: '16px', maxWidth: '520px', width: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.2rem', fontWeight: '800' }}>
|
||||
{editingSight ? '✏️ Ausflugsziel bearbeiten' : '🏛️ Neues Ausflugsziel'}
|
||||
</h3>
|
||||
<button onClick={() => setShowSightFormModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '30px', height: '30px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveSight} style={{ padding: '1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.9rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>
|
||||
🔗 Google Maps Link oder Adresse *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="https://maps.app.goo.gl/... oder Adresse"
|
||||
value={sightAddress}
|
||||
onChange={e => setSightAddress(e.target.value)}
|
||||
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>
|
||||
Name der Sehenswürdigkeit (optional bei Maps-Link)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z. B. Chinesische Fischernetze"
|
||||
value={sightName}
|
||||
onChange={e => setSightName(e.target.value)}
|
||||
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MEHRFACHAUSWAHL VON HOTELS */}
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.3rem' }}>
|
||||
🏨 Diesen Hotels zuordnen (Distanz wird für jedes Hotel berechnet):
|
||||
</label>
|
||||
<div style={{ maxHeight: '140px', overflowY: 'auto', border: '1px solid #d1d5db', borderRadius: '6px', padding: '0.5rem', background: '#fafafa', display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||
{hotels.length === 0 ? (
|
||||
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>Keine Hotels angelegt.</span>
|
||||
) : (
|
||||
hotels.map(h => {
|
||||
const isChecked = selectedHotelIds.includes(h.id);
|
||||
return (
|
||||
<label key={h.id} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.82rem', cursor: 'pointer', padding: '0.2rem 0.3rem', borderRadius: '4px', background: isChecked ? '#eff6ff' : 'transparent' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleHotelSelection(h.id)}
|
||||
/>
|
||||
<span style={{ fontWeight: isChecked ? '700' : 'normal', color: isChecked ? '#1e40af' : '#374151' }}>
|
||||
{h.name} {h.checkin_date ? `(${formatDateGerman(h.checkin_date)})` : ''}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>Kategorie</label>
|
||||
<select value={sightIcon} onChange={e => setSightIcon(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }}>
|
||||
<option value="🏛️">🏛️ Kultur & Bauwerk</option>
|
||||
<option value="🌿">🌿 Natur & Park</option>
|
||||
<option value="🏖️">🏖️ Strand & Meer</option>
|
||||
<option value="🐘">🐘 Safari & Tiere</option>
|
||||
<option value="🍛">🍛 Food & Markt</option>
|
||||
<option value="🧗">🧗 Abenteuer & Wandern</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>Notizen / Tipps (optional)</label>
|
||||
<input type="text" placeholder="z. B. Öffnungszeiten, Tickets vorab" value={sightNotes} onChange={e => setSightNotes(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.6rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
|
||||
<button type="button" onClick={() => setShowSightFormModal(false)} style={{ padding: '0.55rem 1rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem' }}>Abbrechen</button>
|
||||
<button type="submit" disabled={isSubmittingSight} style={{ padding: '0.55rem 1.1rem', background: '#2563eb', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.85rem' }}>
|
||||
{isSubmittingSight ? 'Berechne Routen...' : 'Speichern & Berechnen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL: AUSGEMUSTERTE HOTELS */}
|
||||
{showParkedModal && (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
|
||||
@@ -374,7 +668,7 @@ export default function TravelDashboard() {
|
||||
</div>
|
||||
<div style={{ padding: '1.5rem', overflowY: 'auto' }}>
|
||||
{parkedHotels.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: '#64748b', padding: '2rem 0' }}>🎉 Aktuell sind keine Hotels ausgemustert. Alle Hotels befinden sich in deiner Route!</p>
|
||||
<p style={{ textAlign: 'center', color: '#64748b', padding: '2rem 0' }}>🎉 Aktuell sind keine Hotels ausgemustert.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
|
||||
{parkedHotels.map(h => (
|
||||
@@ -416,7 +710,7 @@ export default function TravelDashboard() {
|
||||
{loadingPrices ? (
|
||||
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Lade Preishistorie...</p>
|
||||
) : priceHistory.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Noch keine Preishistorie vorhanden. Klicke oben auf "Preise aller Hotels aktualisieren".</p>
|
||||
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Noch keine Preishistorie vorhanden.</p>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem', marginBottom: '1.5rem', textAlign: 'center' }}>
|
||||
@@ -465,27 +759,6 @@ export default function TravelDashboard() {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ maxHeight: '180px', overflowY: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f3f4f6', textAlign: 'left' }}>
|
||||
<th style={{ padding: '0.5rem' }}>Datum & Uhrzeit</th>
|
||||
<th style={{ padding: '0.5rem' }}>Preis</th>
|
||||
<th style={{ padding: '0.5rem' }}>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{priceHistory.map(p => (
|
||||
<tr key={p.id} style={{ borderBottom: '1px solid #f3f4f6' }}>
|
||||
<td style={{ padding: '0.5rem' }}>{new Date(p.created_at).toLocaleString('de-DE')}</td>
|
||||
<td style={{ padding: '0.5rem', fontWeight: 'bold', color: '#2563eb' }}>{p.price} €</td>
|
||||
<td style={{ padding: '0.5rem', color: '#6b7280' }}>{p.nights || 1} Nächte, {p.persons || 2} Personen</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -501,7 +774,7 @@ export default function TravelDashboard() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="z. B. Indien 2026 oder Japan 2027"
|
||||
placeholder="z. B. Indien 2026"
|
||||
value={newTripName}
|
||||
onChange={e => setNewTripName(e.target.value)}
|
||||
style={{ width: '100%', padding: '0.7rem', borderRadius: '6px', border: '1px solid #d1d5db', marginBottom: '1rem' }}
|
||||
@@ -520,8 +793,6 @@ export default function TravelDashboard() {
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
|
||||
<div style={{ background: '#fff', padding: '2rem', borderRadius: '12px', maxWidth: '500px', width: '100%' }}>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '0.5rem' }}>➕ Neues Hotel hinzufügen</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: '#6b7280', marginBottom: '1.2rem' }}>Check-in/Check-out Datum wird automatisch aus dem Link gelesen.</p>
|
||||
|
||||
<form onSubmit={handleAddHotel} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Hotel Name (optional)</label>
|
||||
@@ -560,7 +831,6 @@ export default function TravelDashboard() {
|
||||
|
||||
return (
|
||||
<div key={stop.clusterKey} style={{ position: 'relative', marginBottom: '3rem' }}>
|
||||
{/* Timeline Knotenpunkt */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: '-47px',
|
||||
@@ -594,7 +864,7 @@ export default function TravelDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container: Cluster-Hintergrund wenn mehrere Hotels */}
|
||||
{/* Hotel Grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isCluster ? 'repeat(auto-fit, minmax(340px, 1fr))' : '1fr',
|
||||
@@ -618,6 +888,11 @@ export default function TravelDashboard() {
|
||||
const optionColors = ['#2563eb', '#7c3aed', '#059669', '#d97706'];
|
||||
const currentAccent = optionColors[hIdx % optionColors.length];
|
||||
|
||||
// Filtert Ausflüge, die DIESEM Hotel zugeordnet sind
|
||||
const hotelSights = attractions.filter(a =>
|
||||
(a.assignments || []).some(asgn => asgn.hotel_id === hotel.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hotel.id}
|
||||
@@ -628,13 +903,14 @@ export default function TravelDashboard() {
|
||||
borderRadius: '12px',
|
||||
padding: '1.2rem',
|
||||
boxShadow: '0 4px 6px -1px rgba(0,0,0,0.05)',
|
||||
opacity: hotel.is_active ? 1 : 0.5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{/* Option Header & Ausmustern-Button */}
|
||||
{/* Option Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.6rem' }}>
|
||||
{isCluster ? (
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: '800', textTransform: 'uppercase', background: '#f3f4f6', color: currentAccent, padding: '0.2rem 0.5rem', borderRadius: '4px', border: '1px solid #e5e7eb' }}>
|
||||
@@ -646,20 +922,8 @@ export default function TravelDashboard() {
|
||||
|
||||
<button
|
||||
onClick={() => parkHotel(hotel.id, hotel.name)}
|
||||
title="Dieses Hotel aus der aktiven Timeline ausmustern"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid #cbd5e1',
|
||||
color: '#64748b',
|
||||
padding: '0.3rem 0.6rem',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem'
|
||||
}}
|
||||
title="Dieses Hotel aus der Timeline nehmen"
|
||||
style={{ background: 'transparent', border: '1px solid #cbd5e1', color: '#64748b', padding: '0.3rem 0.6rem', borderRadius: '6px', fontSize: '0.78rem', fontWeight: '600', cursor: 'pointer' }}
|
||||
>
|
||||
👁️🗨️ Nicht betrachten
|
||||
</button>
|
||||
@@ -700,38 +964,80 @@ export default function TravelDashboard() {
|
||||
{hotel.room_size_m2 && <span>📐 {hotel.room_size_m2} m²</span>}
|
||||
</div>
|
||||
|
||||
{/* KI Reviews Accordion */}
|
||||
{(hotel.review_summary_positive || hotel.review_summary_bathroom) && (
|
||||
<div style={{ marginTop: '0.5rem', marginBottom: '0.8rem' }}>
|
||||
<details style={{ background: '#f9fafb', padding: '0.6rem 0.8rem', borderRadius: '8px', border: '1px solid #e5e7eb' }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 'bold', color: '#374151', fontSize: '0.82rem' }}>
|
||||
📋 KI-Zusammenfassung ({hotel.review_summary_count || 'viele'} Reviews)
|
||||
</summary>
|
||||
<div style={{ marginTop: '0.6rem', fontSize: '0.8rem', display: 'grid', gap: '0.5rem' }}>
|
||||
{hotel.review_summary_positive && (
|
||||
<div style={{ background: '#e8f5e9', padding: '0.5rem 0.7rem', borderRadius: '6px', borderLeft: '3px solid #2e7d32', color: '#1b5e20', whiteSpace: 'pre-line' }}>
|
||||
<strong>💚 Highlights:</strong><br />{hotel.review_summary_positive}
|
||||
</div>
|
||||
)}
|
||||
{hotel.review_summary_passable && (
|
||||
<div style={{ background: '#fffde7', padding: '0.5rem 0.7rem', borderRadius: '6px', borderLeft: '3px solid #fbc02d', color: '#f57f17', whiteSpace: 'pre-line' }}>
|
||||
<strong>🟡 Neutral:</strong><br />{hotel.review_summary_passable}
|
||||
</div>
|
||||
)}
|
||||
{hotel.review_summary_negative && (
|
||||
<div style={{ background: '#ffebee', padding: '0.5rem 0.7rem', borderRadius: '6px', borderLeft: '3px solid #c62828', color: '#b71c1c', whiteSpace: 'pre-line' }}>
|
||||
<strong>🔴 Mängel:</strong><br />{hotel.review_summary_negative}
|
||||
</div>
|
||||
)}
|
||||
{hotel.review_summary_bathroom && (
|
||||
<div style={{ background: '#e0f7fa', padding: '0.5rem 0.7rem', borderRadius: '6px', borderLeft: '3px solid #00acc1', color: '#006064', whiteSpace: 'pre-line' }}>
|
||||
<strong>🚽 Bad & WC:</strong><br />{hotel.review_summary_bathroom}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
{/* SEHENSWÜRDIGKEITEN & FAHRZEITEN FÜR DIESES HOTEL */}
|
||||
<div style={{ marginTop: '0.8rem', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '10px', padding: '0.8rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.6rem' }}>
|
||||
<div style={{ fontWeight: '700', fontSize: '0.85rem', color: '#334155', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
🏛️ Ausflugsziele ({hotelSights.length})
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openSightModal(undefined, hotel.id)}
|
||||
style={{ background: '#eff6ff', color: '#2563eb', border: '1px dashed #bfdbfe', padding: '0.25rem 0.6rem', borderRadius: '6px', fontSize: '0.75rem', fontWeight: '700', cursor: 'pointer' }}
|
||||
>
|
||||
➕ Ausflug zuordnen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{hotelSights.length === 0 ? (
|
||||
<p style={{ margin: 0, fontSize: '0.75rem', color: '#94a3b8', fontStyle: 'italic' }}>Keine Ausflugsziele zugeordnet.</p>
|
||||
) : (
|
||||
hotelSights.map(sight => {
|
||||
const assignment = (sight.assignments || []).find(a => a.hotel_id === hotel.id);
|
||||
return (
|
||||
<div key={sight.id} style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: '6px', padding: '0.5rem 0.7rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.6rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flex: 1, minWidth: '160px', overflow: 'hidden' }}>
|
||||
<span style={{ fontSize: '1.1rem', flexShrink: 0 }}>{sight.category_icon || '🏛️'}</span>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<div style={{ fontWeight: '700', fontSize: '0.82rem', color: '#0f172a', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{sight.name}
|
||||
</div>
|
||||
{sight.notes && (
|
||||
<div style={{ fontSize: '0.72rem', color: '#64748b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{sight.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assignment?.distance_km !== null && assignment?.distance_km !== undefined ? (
|
||||
<div style={{ background: '#ecfdf5', border: '1px solid #a7f3d0', color: '#047857', padding: '0.2rem 0.5rem', borderRadius: '6px', fontSize: '0.78rem', fontWeight: '700', whiteSpace: 'nowrap' }}>
|
||||
🚗 {assignment.distance_km} km • {assignment.duration_minutes} Min.
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.72rem', color: '#94a3b8' }}>📍 Route n.v.</span>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.3rem', alignItems: 'center' }}>
|
||||
<a
|
||||
href={sight.address.startsWith('http') ? sight.address : `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(sight.name + ' ' + sight.address)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#2563eb', background: '#eff6ff', border: '1px solid #bfdbfe', padding: '0.2rem 0.4rem', borderRadius: '4px', fontSize: '0.72rem', textDecoration: 'none', fontWeight: '600' }}
|
||||
>
|
||||
Maps ↗
|
||||
</a>
|
||||
<button
|
||||
onClick={() => openSightModal(sight)}
|
||||
title="Bearbeiten"
|
||||
style={{ background: '#f1f5f9', border: '1px solid #cbd5e1', cursor: 'pointer', fontSize: '0.75rem', padding: '0.2rem 0.4rem', borderRadius: '4px' }}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteSight(sight.id)}
|
||||
title="Löschen"
|
||||
style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: '0.8rem', padding: '0.1rem 0.3rem' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
|
||||
Reference in New Issue
Block a user