diff --git a/.gitignore b/.gitignore index dfa9a00..139c983 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,12 @@ pnpm-debug.log* *.njsproj *.sln *.sw? + +node_modules +.next +.git +.gitignore +llm_context.txt +*.log +Dockerfile +docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml index 82f7b6e..bbd63bb 100644 --- a/docker-compose.yml +++ b/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 \ No newline at end of file diff --git a/next.config.js b/next.config.js index 931d177..a93bfca 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,8 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - serverExternalPackages: ['bullmq', 'ioredis'], + experimental: { + serverComponentsExternalPackages: ['bullmq', 'ioredis'], + }, }; module.exports = nextConfig; diff --git a/next.config.mjs b/next.config.mjs deleted file mode 100644 index 8af0d21..0000000 --- a/next.config.mjs +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - serverExternalPackages: ['bullmq', 'ioredis'], -}; - -export default nextConfig; diff --git a/src/app/api/attractions/[id]/route.ts b/src/app/api/attractions/[id]/route.ts new file mode 100644 index 0000000..1726e52 --- /dev/null +++ b/src/app/api/attractions/[id]/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/attractions/route.ts b/src/app/api/attractions/route.ts new file mode 100644 index 0000000..6894ff4 --- /dev/null +++ b/src/app/api/attractions/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/api/hotels/[id]/attractions/route.ts b/src/app/api/hotels/[id]/attractions/route.ts new file mode 100644 index 0000000..f191c7a --- /dev/null +++ b/src/app/api/hotels/[id]/attractions/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 5b3a211..e35ec91 100644 --- a/src/app/page.tsx +++ b/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([]); const [activeTripId, setActiveTripId] = useState('ALL'); const [hotels, setHotels] = useState([]); + const [attractions, setAttractions] = useState([]); const [loading, setLoading] = useState(true); const [scanningAllPrices, setScanningAllPrices] = useState(false); const [scanningHotelId, setScanningHotelId] = useState(null); @@ -64,7 +81,7 @@ export default function TravelDashboard() { const [priceHistory, setPriceHistory] = useState([]); 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(null); + const [sightName, setSightName] = useState(''); + const [sightAddress, setSightAddress] = useState(''); + const [sightIcon, setSightIcon] = useState('🏛️'); + const [sightNotes, setSightNotes] = useState(''); + const [selectedHotelIds, setSelectedHotelIds] = useState([]); + 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((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() {

📍 Reise-Ablauf & Timeline

-

Routenplanung, Datums-Cluster, Preishistorie & KI-Bewertungen

+

Routenplanung, Sehenswürdigkeiten, Fahrzeiten & KI-Reviews

+ +
@@ -361,6 +493,168 @@ export default function TravelDashboard() { + {/* MODAL: AUSFLÜGE ZENTRAL VERWALTEN */} + {showManageSightsModal && ( +
+
+
+
+

🏛️ Zentrale Ausflugsziele

+

Sehenswürdigkeiten bearbeiten und flexibel mehreren Hotels zuweisen

+
+ +
+
+ {currentAttractions.length === 0 ? ( +

Noch keine Ausflugsziele für diesen Trip hinterlegt.

+ ) : ( + currentAttractions.map(sight => { + const assignedCount = (sight.assignments || []).length; + return ( +
+
+
+ {sight.category_icon || '🏛️'} + {sight.name} +
+
+ {assignedCount > 0 ? ( + + 🏨 Zugewiesen zu {assignedCount} {assignedCount === 1 ? 'Hotel' : 'Hotels'} + + ) : ( + + Keinem Hotel zugewiesen + + )} +
+
+
+ + +
+
+ ); + }) + )} +
+
+ +
+
+
+ )} + + {/* MODAL: AUSFLUG ANLEGEN / BEARBEITEN */} + {showSightFormModal && ( +
+
+
+

+ {editingSight ? '✏️ Ausflugsziel bearbeiten' : '🏛️ Neues Ausflugsziel'} +

+ +
+ +
+
+ + setSightAddress(e.target.value)} + style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} + /> +
+ +
+ + setSightName(e.target.value)} + style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} + /> +
+ + {/* MEHRFACHAUSWAHL VON HOTELS */} +
+ +
+ {hotels.length === 0 ? ( + Keine Hotels angelegt. + ) : ( + hotels.map(h => { + const isChecked = selectedHotelIds.includes(h.id); + return ( + + ); + }) + )} +
+
+ +
+ + +
+ +
+ + setSightNotes(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} /> +
+ +
+ + +
+
+
+
+ )} + {/* MODAL: AUSGEMUSTERTE HOTELS */} {showParkedModal && (
@@ -374,7 +668,7 @@ export default function TravelDashboard() {
{parkedHotels.length === 0 ? ( -

🎉 Aktuell sind keine Hotels ausgemustert. Alle Hotels befinden sich in deiner Route!

+

🎉 Aktuell sind keine Hotels ausgemustert.

) : (
{parkedHotels.map(h => ( @@ -416,7 +710,7 @@ export default function TravelDashboard() { {loadingPrices ? (

Lade Preishistorie...

) : priceHistory.length === 0 ? ( -

Noch keine Preishistorie vorhanden. Klicke oben auf "Preise aller Hotels aktualisieren".

+

Noch keine Preishistorie vorhanden.

) : (
@@ -465,27 +759,6 @@ export default function TravelDashboard() {
)} - -
- - - - - - - - - - {priceHistory.map(p => ( - - - - - - ))} - -
Datum & UhrzeitPreisDetails
{new Date(p.created_at).toLocaleString('de-DE')}{p.price} €{p.nights || 1} Nächte, {p.persons || 2} Personen
-
)}
@@ -498,11 +771,11 @@ export default function TravelDashboard() {

🌍 Neue Reise anlegen

- 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() {

➕ Neues Hotel hinzufügen

-

Check-in/Check-out Datum wird automatisch aus dem Link gelesen.

-
@@ -560,7 +831,6 @@ export default function TravelDashboard() { return (
- {/* Timeline Knotenpunkt */}
- {/* Container: Cluster-Hintergrund wenn mehrere Hotels */} + {/* Hotel Grid */}
+ (a.assignments || []).some(asgn => asgn.hotel_id === hotel.id) + ); return (
- {/* Option Header & Ausmustern-Button */} + {/* Option Header */}
{isCluster ? ( @@ -646,20 +922,8 @@ export default function TravelDashboard() { @@ -700,38 +964,80 @@ export default function TravelDashboard() { {hotel.room_size_m2 && 📐 {hotel.room_size_m2} m²}
- {/* KI Reviews Accordion */} - {(hotel.review_summary_positive || hotel.review_summary_bathroom) && ( -
-
- - 📋 KI-Zusammenfassung ({hotel.review_summary_count || 'viele'} Reviews) - -
- {hotel.review_summary_positive && ( -
- 💚 Highlights:
{hotel.review_summary_positive} -
- )} - {hotel.review_summary_passable && ( -
- 🟡 Neutral:
{hotel.review_summary_passable} -
- )} - {hotel.review_summary_negative && ( -
- 🔴 Mängel:
{hotel.review_summary_negative} -
- )} - {hotel.review_summary_bathroom && ( -
- 🚽 Bad & WC:
{hotel.review_summary_bathroom} -
- )} -
-
+ {/* SEHENSWÜRDIGKEITEN & FAHRZEITEN FÜR DIESES HOTEL */} +
+
+
+ 🏛️ Ausflugsziele ({hotelSights.length}) +
+
- )} + +
+ {hotelSights.length === 0 ? ( +

Keine Ausflugsziele zugeordnet.

+ ) : ( + hotelSights.map(sight => { + const assignment = (sight.assignments || []).find(a => a.hotel_id === hotel.id); + return ( +
+
+ {sight.category_icon || '🏛️'} +
+
+ {sight.name} +
+ {sight.notes && ( +
+ {sight.notes} +
+ )} +
+
+ + {assignment?.distance_km !== null && assignment?.distance_km !== undefined ? ( +
+ 🚗 {assignment.distance_km} km • {assignment.duration_minutes} Min. +
+ ) : ( + 📍 Route n.v. + )} + +
+ + Maps ↗ + + + +
+
+ ); + }) + )} +
+
{/* Action Buttons */}