Attraktionen

This commit is contained in:
2026-08-16 08:54:53 +00:00
parent 90a7d8d3d2
commit e2d05e6809
8 changed files with 973 additions and 119 deletions

9
.gitignore vendored
View File

@@ -47,3 +47,12 @@ pnpm-debug.log*
*.njsproj
*.sln
*.sw?
node_modules
.next
.git
.gitignore
llm_context.txt
*.log
Dockerfile
docker-compose.yml

View File

@@ -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

View File

@@ -1,6 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ['bullmq', 'ioredis'],
experimental: {
serverComponentsExternalPackages: ['bullmq', 'ioredis'],
},
};
module.exports = nextConfig;

View File

@@ -1,6 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ['bullmq', 'ioredis'],
};
export default nextConfig;

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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 &quot;Preise aller Hotels aktualisieren&quot;.</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>
@@ -498,11 +771,11 @@ export default function TravelDashboard() {
<div style={{ background: '#fff', padding: '2rem', borderRadius: '12px', maxWidth: '400px', width: '100%' }}>
<h3 style={{ marginTop: 0 }}>🌍 Neue Reise anlegen</h3>
<form onSubmit={handleAddTrip}>
<input
type="text"
required
placeholder="z. B. Indien 2026 oder Japan 2027"
value={newTripName}
<input
type="text"
required
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',
@@ -617,6 +887,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
@@ -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 */}