diff --git a/src/app/api/hotels/[id]/ratings/route.ts b/src/app/api/hotels/[id]/ratings/route.ts new file mode 100644 index 0000000..09ba0a6 --- /dev/null +++ b/src/app/api/hotels/[id]/ratings/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +// POST: Bewertung speichern oder aktualisieren +export async function POST(req: NextRequest, { params }: { params: { id: string } }) { + try { + const { member_id, grade, comment } = await req.json(); + if (!member_id || !grade) { + return NextResponse.json({ error: 'Mitreisender und Note erforderlich' }, { status: 400 }); + } + + const res = await query( + `INSERT INTO hotel_ratings (hotel_id, member_id, grade, comment, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (hotel_id, member_id) + DO UPDATE SET grade = EXCLUDED.grade, comment = EXCLUDED.comment, updated_at = NOW() + RETURNING *`, + [params.id, member_id, grade, comment ? comment.trim() : null] + ); + + return NextResponse.json({ success: true, rating: res.rows[0] }); + } catch (e: any) { + return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + } +} diff --git a/src/app/api/hotels/route.ts b/src/app/api/hotels/route.ts index 54c7697..c51af53 100644 --- a/src/app/api/hotels/route.ts +++ b/src/app/api/hotels/route.ts @@ -6,33 +6,53 @@ export const dynamic = 'force-dynamic'; export async function GET() { try { const res = await query(` +SELECT + h.*, + t.name AS trip_name, + ph.price_numeric::float AS latest_price, + ph.price_per_night::float AS latest_price_per_night, + ph.price_per_person::float AS latest_price_per_person, + ph.nights AS latest_nights, + ph.persons AS latest_persons, + bp.best_price, + hr.avg_grade, + hr.total_votes, + hr.ratings_json + FROM hotels h + LEFT JOIN trips t ON h.trip_id = t.id + LEFT JOIN LATERAL ( + SELECT price_numeric, price_per_night, price_per_person, nights, persons + FROM hotel_price_history + WHERE hotel_id::text = h.id::text + ORDER BY tracked_at DESC NULLS LAST, id DESC + LIMIT 1 + ) ph ON true + LEFT JOIN LATERAL ( + SELECT MIN(price_numeric)::float AS best_price + FROM hotel_price_history + WHERE hotel_id::text = h.id::text AND price_numeric > 0 + ) bp ON true + LEFT JOIN LATERAL ( SELECT - h.*, - t.name AS trip_name, - ph.price_numeric::float AS latest_price, - ph.price_per_night::float AS latest_price_per_night, - ph.price_per_person::float AS latest_price_per_person, - ph.nights AS latest_nights, - ph.persons AS latest_persons, - bp.best_price, - bp.best_price_per_night - FROM hotels h - LEFT JOIN trips t ON h.trip_id = t.id - LEFT JOIN LATERAL ( - SELECT price_numeric, price_per_night, price_per_person, nights, persons - FROM hotel_price_history - WHERE hotel_id::text = h.id::text - ORDER BY tracked_at DESC NULLS LAST, id DESC - LIMIT 1 - ) ph ON true - LEFT JOIN LATERAL ( - SELECT - MIN(price_numeric)::float AS best_price, - MIN(price_per_night)::float AS best_price_per_night - FROM hotel_price_history - WHERE hotel_id::text = h.id::text AND price_numeric > 0 - ) bp ON true - ORDER BY h.checkin_date ASC NULLS LAST, h.id ASC + ROUND(AVG(hr_sub.grade), 1)::float AS avg_grade, + COUNT(hr_sub.id)::int AS total_votes, + COALESCE( + json_agg( + json_build_object( + 'member_id', hr_sub.member_id, + 'name', tm.name, + 'color', tm.color, + 'grade', hr_sub.grade, + 'comment', hr_sub.comment + ) + ORDER BY hr_sub.updated_at DESC + ), + '[]' + ) AS ratings_json + FROM hotel_ratings hr_sub + JOIN trip_members tm ON hr_sub.member_id = tm.id + WHERE hr_sub.hotel_id = h.id + ) hr ON true `); return NextResponse.json({ success: true, hotels: res.rows }); } catch (error: any) { diff --git a/src/app/api/trips/[id]/members/route.ts b/src/app/api/trips/[id]/members/route.ts new file mode 100644 index 0000000..36db606 --- /dev/null +++ b/src/app/api/trips/[id]/members/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +// GET: Alle Mitreisenden eines Trips laden +export async function GET(req: NextRequest, { params }: { params: { id: string } }) { + try { + const res = await query( + `SELECT * FROM trip_members WHERE trip_id = $1 ORDER BY created_at ASC`, + [params.id] + ); + return NextResponse.json({ success: true, members: res.rows }); + } catch (e: any) { + return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + } +} + +// POST: Neuen Mitreisenden hinzufügen +export async function POST(req: NextRequest, { params }: { params: { id: string } }) { + try { + const { name, color } = await req.json(); + if (!name) { + return NextResponse.json({ error: 'Name ist erforderlich' }, { status: 400 }); + } + + const defaultColors = ['#2563eb', '#db2777', '#059669', '#d97706', '#7c3aed', '#0891b2']; + const randomColor = color || defaultColors[Math.floor(Math.random() * defaultColors.length)]; + + const res = await query( + `INSERT INTO trip_members (trip_id, name, color) + VALUES ($1, $2, $3) + RETURNING *`, + [params.id, name.trim(), randomColor] + ); + + return NextResponse.json({ success: true, member: res.rows[0] }); + } catch (e: any) { + return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + } +} + +// DELETE: Mitreisenden entfernen +export async function DELETE(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const memberId = searchParams.get('memberId'); + if (!memberId) return NextResponse.json({ error: 'memberId fehlt' }, { status: 400 }); + + await query(`DELETE FROM trip_members WHERE id = $1`, [memberId]); + return NextResponse.json({ success: true }); + } catch (e: any) { + return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3a22978..59af5d0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,11 +2,30 @@ import { useState, useEffect } from 'react'; +// ========================================== +// TYPEN & INTERFACES +// ========================================== interface Trip { id: string; name: string; } +interface TripMember { + id: string; + trip_id: string; + name: string; + color: string; + created_at?: string; +} + +interface RatingEntry { + member_id: string; + name: string; + color: string; + grade: number; + comment?: string | null; +} + interface PriceEntry { id: string; price: number; @@ -47,6 +66,9 @@ interface Hotel { latest_persons?: number | null; best_price?: number | null; best_price_per_night?: number | null; + avg_grade?: number | null; + total_votes?: number | null; + ratings_json?: RatingEntry[]; rating?: number | null; review_count?: number | null; image_url?: string | null; @@ -70,8 +92,12 @@ interface HotelCluster { } export default function TravelDashboard() { + // ========================================== + // STATES + // ========================================== const [trips, setTrips] = useState([]); const [activeTripId, setActiveTripId] = useState('ALL'); + const [tripMembers, setTripMembers] = useState([]); const [hotels, setHotels] = useState([]); const [attractions, setAttractions] = useState([]); const [loading, setLoading] = useState(true); @@ -92,7 +118,19 @@ export default function TravelDashboard() { const [isSubmitting, setIsSubmitting] = useState(false); const [showParkedModal, setShowParkedModal] = useState(false); - // Ausflugsziele: Zentral & Mehrfachzuordnung + // Mitreisende Modal + const [showMembersModal, setShowMembersModal] = useState(false); + const [newMemberName, setNewMemberName] = useState(''); + const [isAddingMember, setIsAddingMember] = useState(false); + + // Hotel-Bewertungs-Modal (Schulnoten 1 bis 6) + const [ratingHotel, setRatingHotel] = useState(null); + const [selectedMemberId, setSelectedMemberId] = useState(''); + const [selectedGrade, setSelectedGrade] = useState(null); + const [ratingComment, setRatingComment] = useState(''); + const [isSubmittingRating, setIsSubmittingRating] = useState(false); + + // Ausflugsziele Modals const [showManageSightsModal, setShowManageSightsModal] = useState(false); const [showSightFormModal, setShowSightFormModal] = useState(false); const [editingSight, setEditingSight] = useState(null); @@ -103,10 +141,21 @@ export default function TravelDashboard() { const [selectedHotelIds, setSelectedHotelIds] = useState([]); const [isSubmittingSight, setIsSubmittingSight] = useState(false); + // ========================================== + // EFFEKTE & DATEN LADEN + // ========================================== useEffect(() => { loadData(); }, []); + useEffect(() => { + if (activeTripId !== 'ALL') { + loadMembers(activeTripId); + } else { + setTripMembers([]); + } + }, [activeTripId, trips]); + const formatDateGerman = (dateStr?: string | null) => { if (!dateStr) return ''; const date = new Date(dateStr); @@ -155,6 +204,18 @@ export default function TravelDashboard() { } }; + const loadMembers = async (tripId: string) => { + try { + const res = await fetch(`/api/trips/${tripId}/members`); + if (res.ok) { + const data = await res.json(); + setTripMembers(data.members || []); + } + } catch (e) { + console.error('Fehler beim Laden der Mitreisenden:', e); + } + }; + const refreshAttractions = async () => { try { const res = await fetch('/api/attractions'); @@ -167,21 +228,9 @@ export default function TravelDashboard() { } }; - const openPriceHistoryModal = async (hotel: Hotel) => { - setSelectedPriceHotel(hotel); - setLoadingPrices(true); - setPriceHistory([]); - try { - const res = await fetch(`/api/hotels/${hotel.id}/prices`); - const data = await res.json(); - if (data.success) setPriceHistory(data.prices || []); - } catch (e) { - console.error('Fehler beim Laden der Preise:', e); - } finally { - setLoadingPrices(false); - } - }; - + // ========================================== + // HANDLERS: REISEN & MITREISENDE + // ========================================== const handleAddTrip = async (e: React.FormEvent) => { e.preventDefault(); if (!newTripName.trim()) return; @@ -203,6 +252,124 @@ export default function TravelDashboard() { } }; + const handleAddMember = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newMemberName.trim() || activeTripId === 'ALL') return; + setIsAddingMember(true); + try { + const res = await fetch(`/api/trips/${activeTripId}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newMemberName.trim() }) + }); + const data = await res.json(); + if (data.success) { + setTripMembers([...tripMembers, data.member]); + setNewMemberName(''); + loadData(); + } + } catch (err: any) { + alert('Fehler beim Hinzufügen: ' + err.message); + } finally { + setIsAddingMember(false); + } + }; + + const handleDeleteMember = async (memberId: string) => { + if (!confirm('Mitreisenden wirklich entfernen? Alle zugehörigen Noten werden gelöscht.')) return; + try { + const res = await fetch(`/api/trips/${activeTripId}/members?memberId=${memberId}`, { + method: 'DELETE' + }); + if (res.ok) { + setTripMembers(tripMembers.filter(m => m.id !== memberId)); + loadData(); + } + } catch (err: any) { + console.error('Fehler beim Löschen:', err); + } + }; + + // ========================================== + // HANDLERS: BEWERTUNG (1 bis 6) + // ========================================== + const openRatingModal = (hotel: Hotel) => { + setRatingHotel(hotel); + setSelectedMemberId(tripMembers[0]?.id || ''); + setSelectedGrade(null); + setRatingComment(''); + + if (tripMembers.length > 0 && hotel.ratings_json) { + const existing = hotel.ratings_json.find(r => r.member_id === tripMembers[0].id); + if (existing) { + setSelectedGrade(existing.grade); + setRatingComment(existing.comment || ''); + } + } + }; + + const handleSelectMemberForRating = (mId: string) => { + setSelectedMemberId(mId); + if (ratingHotel?.ratings_json) { + const existing = ratingHotel.ratings_json.find(r => r.member_id === mId); + if (existing) { + setSelectedGrade(existing.grade); + setRatingComment(existing.comment || ''); + } else { + setSelectedGrade(null); + setRatingComment(''); + } + } + }; + + const handleSaveRating = async (e: React.FormEvent) => { + e.preventDefault(); + if (!ratingHotel || !selectedMemberId || !selectedGrade) { + return alert('Bitte wähle Mitreisenden und Note aus!'); + } + setIsSubmittingRating(true); + try { + const res = await fetch(`/api/hotels/${ratingHotel.id}/ratings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + member_id: selectedMemberId, + grade: selectedGrade, + comment: ratingComment + }) + }); + const data = await res.json(); + if (data.success) { + await loadData(); + setRatingHotel(null); + } else { + alert('Fehler: ' + data.error); + } + } catch (err: any) { + alert('Fehler beim Speichern: ' + err.message); + } finally { + setIsSubmittingRating(false); + } + }; + + // ========================================== + // HANDLERS: HOTELS, PREISE, AUSFLÜGE + // ========================================== + const openPriceHistoryModal = async (hotel: Hotel) => { + setSelectedPriceHotel(hotel); + setLoadingPrices(true); + setPriceHistory([]); + try { + const res = await fetch(`/api/hotels/${hotel.id}/prices`); + const data = await res.json(); + if (data.success) setPriceHistory(data.prices || []); + } catch (e) { + console.error('Fehler beim Laden der Preise:', e); + } finally { + setLoadingPrices(false); + } + }; + const handleAddHotel = async (e: React.FormEvent) => { e.preventDefault(); if (!newBookingUrl) return alert('Bitte URL eingeben'); @@ -232,14 +399,14 @@ export default function TravelDashboard() { } }; - const parkHotel = async (id: string, name: string) => { + const parkHotel = async (id: string) => { try { await fetch(`/api/hotels/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_active: false }) }); - setHotels(hotels.map(h => h.id === id ? { ...h, is_active: false } : h)); + setHotels(hotels.map(h => (h.id === id ? { ...h, is_active: false } : h))); } catch (e) { console.error('Fehler beim Ausmustern:', e); } @@ -252,13 +419,12 @@ export default function TravelDashboard() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_active: true }) }); - setHotels(hotels.map(h => h.id === id ? { ...h, is_active: true } : h)); + setHotels(hotels.map(h => (h.id === id ? { ...h, is_active: true } : h))); } catch (e) { console.error('Fehler beim Wiederaufnehmen:', e); } }; - // Ausflug Modal öffnen (mit Checkboxen für Mehrfachauswahl) const openSightModal = (sight?: Attraction, preselectedHotelId?: string) => { if (sight) { setEditingSight(sight); @@ -298,7 +464,6 @@ export default function TravelDashboard() { hotel_ids: selectedHotelIds, trip_id: targetTripId }; - if (editingSight) { const res = await fetch(`/api/attractions/${editingSight.id}`, { method: 'PUT', @@ -316,7 +481,6 @@ export default function TravelDashboard() { const data = await res.json(); if (!data.success) throw new Error(data.error); } - await refreshAttractions(); setShowSightFormModal(false); } catch (err: any) { @@ -359,6 +523,9 @@ export default function TravelDashboard() { } }; + // ========================================== + // FILTER & CLUSTERING MIT AUTOMATISCHEM RANKING + // ========================================== const currentHotels = hotels .filter(h => h.is_active) .filter(h => { @@ -374,7 +541,6 @@ export default function TravelDashboard() { }); const parkedHotels = hotels.filter(h => !h.is_active); - const currentAttractions = attractions.filter(a => { if (activeTripId === 'ALL') return true; return String(a.trip_id).toLowerCase() === String(activeTripId).toLowerCase(); @@ -383,15 +549,12 @@ export default function TravelDashboard() { 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] }); return acc; } - const key = `${checkin}_${checkout || 'NO_CHECKOUT'}`; const existingCluster = acc.find(c => c.clusterKey === key); - if (existingCluster) { existingCluster.hotels.push(hotel); } else { @@ -405,37 +568,44 @@ export default function TravelDashboard() { const maxPrice = pricesList.length > 0 ? Math.max(...pricesList) : 0; const currentPrice = pricesList.length > 0 ? pricesList[pricesList.length - 1] : 0; + const activeTripObj = trips.find(t => String(t.id) === String(activeTripId)); + return ( -
+
+ {/* HEADER */}
-

📍 Reise-Ablauf & Timeline

-

Routenplanung, Sehenswürdigkeiten, Fahrzeiten & KI-Reviews

- +

+ 📍 Reise-Ablauf & Freunde-Ranking +

+

+ Hotelvergleich nach Schulnoten (1–6), Bestpreise, Routen & Fahrzeiten +

+
- {/* TABS */} -
+ {/* REISE TABS */} +
{trips.map(trip => (
- {/* MODAL: AUSFLÜGE ZENTRAL VERWALTEN */} - {showManageSightsModal && ( -
-
-
-
-

🏛️ Zentrale Ausflugsziele

-

Sehenswürdigkeiten bearbeiten und flexibel mehreren Hotels zuweisen

+ {/* MITREISENDE SECTION */} + {activeTripId !== 'ALL' && activeTripObj && ( +
+
+ + 👥 Mitreisende ({tripMembers.length}): + + {tripMembers.length === 0 ? ( + + Noch keine Freunde hinterlegt + + ) : ( +
+ {tripMembers.map(m => ( + + + {m.name[0]} + + {m.name} + + ))}
- -
-
- {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 && ( -
-
+ {/* TIMELINE MIT AUTOMATISCHEM RANKING */} + {loading ? ( +

Lade Daten...

+ ) : clusteredStops.length === 0 ? ( +
+

Keine aktiven Hotels für diese Auswahl gefunden.

+ +
+ ) : ( +
+ {clusteredStops.map((stop, stopIndex) => { + const isCluster = stop.hotels.length > 1; + const nights = calculateNights(stop.checkin_date, stop.checkout_date) || stop.hotels[0]?.latest_nights || 1; + + const sortedHotels = [...stop.hotels].sort((a, b) => { + const gradeA = a.avg_grade !== null && a.avg_grade !== undefined ? Number(a.avg_grade) : 99; + const gradeB = b.avg_grade !== null && b.avg_grade !== undefined ? Number(b.avg_grade) : 99; + if (gradeA !== gradeB) return gradeA - gradeB; + return (a.latest_price || 99999) - (b.latest_price || 99999); + }); + + return ( +
+
+ + {/* Stop Header */} +
+
+ 📍 Stop #{stopIndex + 1} + {stop.checkin_date ? ( + + 🗓️ {formatDateGerman(stop.checkin_date)} – {formatDateGerman(stop.checkout_date) || '?'} ({nights} {nights === 1 ? 'Nacht' : 'Nächte'}) + + ) : ( + (Warte auf Scan für Datum) + )} + {isCluster && ( + + ⚖️ {stop.hotels.length} Optionen im Freunde-Ranking + + )} +
+
+ + {/* Hotel Grid */} +
+ {sortedHotels.map((hotel, hIdx) => { + const hotelNights = calculateNights(hotel.checkin_date, hotel.checkout_date) || hotel.latest_nights || nights || 1; + const persons = hotel.latest_persons || 2; + const totalPrice = hotel.latest_price !== null && hotel.latest_price !== undefined ? Number(hotel.latest_price) : null; + const pricePerNight = hotel.latest_price_per_night + ? Math.round(Number(hotel.latest_price_per_night)) + : (totalPrice ? Math.round(totalPrice / hotelNights) : null); + const pricePerPersonPerNight = hotel.latest_price_per_person + ? Math.round(Number(hotel.latest_price_per_person)) + : (totalPrice ? Math.round(totalPrice / (hotelNights * persons)) : null); + + const hasGrade = hotel.avg_grade !== null && hotel.avg_grade !== undefined; + const rankBadgeClass = hIdx === 0 && hasGrade ? '🥇 Rang #1 · Favorit' : hIdx === 1 && hasGrade ? '🥈 Rang #2' : `🥉 Rang #${hIdx + 1}`; + const rankTopBorder = hIdx === 0 && hasGrade ? '#f59e0b' : hIdx === 1 && hasGrade ? '#94a3b8' : '#cbd5e1'; + + let gradeColor = '#15803d'; + let gradeBg = '#f0fdf4'; + let gradeBorder = '#bbf7d0'; + let gradeDesc = 'Sehr gut'; + + if (hasGrade) { + const g = Number(hotel.avg_grade); + if (g > 1.5 && g <= 2.5) { gradeColor = '#059669'; gradeBg = '#ecfdf5'; gradeBorder = '#a7f3d0'; gradeDesc = 'Gut'; } + else if (g > 2.5 && g <= 3.5) { gradeColor = '#d97706'; gradeBg = '#fffbeb'; gradeBorder = '#fde68a'; gradeDesc = 'Befriedigend'; } + else if (g > 3.5 && g <= 4.5) { gradeColor = '#ea580c'; gradeBg = '#fff7ed'; gradeBorder = '#fed7aa'; gradeDesc = 'Ausreichend'; } + else if (g > 4.5) { gradeColor = '#dc2626'; gradeBg = '#fef2f2'; gradeBorder = '#fecaca'; gradeDesc = 'Mangelhaft'; } + } + + const hotelSights = attractions.filter(a => + (a.assignments || []).some(asgn => asgn.hotel_id === hotel.id) + ); + + return ( +
+
+ {/* Option / Ranking Header */} +
+ {isCluster ? ( + + {hasGrade ? rankBadgeClass : `Option ${String.fromCharCode(65 + hIdx)}`} + + ) : ( +
{hotel.trip_name && {hotel.trip_name}}
+ )} + +
+ + {/* Hotel Main Body */} +
+ {hotel.image_url && ( + {hotel.name} + )} +
+

{hotel.name}

+

📍 {hotel.address || 'Keine Adresse'}

+
+ ⭐ {hotel.rating || 'N/A'}/10 · {hotel.review_count || 0} Bew. +
+
+
+ + {/* NOTENBOX: FREUNDE BEWERTUNG */} +
+
+
+ {hasGrade ? Number(hotel.avg_grade).toFixed(1) : '–'} +
+
+ + {hasGrade ? `${gradeDesc} (⌀ Note)` : 'Noch keine Note'} + + + {hotel.total_votes || 0} von {tripMembers.length || 0} Stimmen + +
+
+ + +
+ + {/* MITREISENDE ABSTIMMUNGS-STATUS */} + {tripMembers.length > 0 && ( +
+
+ STIMMEN DER FREUNDE: + {hotel.ratings_json?.length || 0}/{tripMembers.length} abgegeben +
+
+ {tripMembers.map(m => { + const vote = hotel.ratings_json?.find(r => r.member_id === m.id); + return ( + + {vote ? `✓ ${m.name}: ${Number(vote.grade).toFixed(1)}` : `⏳ ${m.name}`} + + ); + })} +
+
+ )} + + {/* MINI-KOMMENTARE VORSCHAU */} + {hotel.ratings_json && hotel.ratings_json.filter(r => r.comment).length > 0 && ( +
+ {hotel.ratings_json.filter(r => r.comment).slice(0, 2).map((r, cIdx) => ( +
+ {r.name}: + + „{r.comment}“ + +
+ ))} +
+ )} + + {/* PREIS-BOX MIT BESTPREIS-VERGLEICH */} + {totalPrice !== null ? ( +
+
+
+ Aktuell: + + 💰 {totalPrice.toLocaleString('de-DE')} € + + + (🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.) + +
+ + {hotel.best_price && totalPrice > Number(hotel.best_price) ? ( + + ▲ +{(totalPrice - Number(hotel.best_price)).toLocaleString('de-DE')} € + + ) : ( + + 🔥 Tiefstpreis + + )} +
+ + {hotel.best_price && ( +
+ 🏆 Bestpreis: + + {Number(hotel.best_price).toLocaleString('de-DE')} € + + {hotel.best_price_per_night && ( + + (🌙 {Math.round(Number(hotel.best_price_per_night)).toLocaleString('de-DE')} € / N.) + + )} +
+ )} +
+ ) : ( +
+ 💰 Noch kein Preis erfasst +
+ )} + + {/* SEHENSWÜRDIGKEITEN & FAHRZEITEN */} +
+
+ + 🏛️ 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} + +
+ {assignment?.distance_km !== null && assignment?.distance_km !== undefined ? ( + + 🚗 {assignment.distance_km} km · {assignment.duration_minutes} Min. + + ) : null} +
+ ); + }) + )} +
+
+
+ + {/* Card Actions Footer */} +
+ + + + + Booking ↗ + +
+
+ ); + })} +
+
+ ); + })} +
+ )} + + {/* MODAL 1: STIMMABGABE */} + {ratingHotel && ( +
+
-

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

- +
+

Hotel bewerten

+

{ratingHotel.name}

+
+
-
+
-
- -
- - 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); + {tripMembers.length === 0 ? ( +

+ Bitte lege zuerst Mitreisende in der Reise-Leiste an! +

+ ) : ( +
+ {tripMembers.map(m => { + const isSelected = selectedMemberId === m.id; + const hasVoted = ratingHotel.ratings_json?.some(r => r.member_id === m.id); return ( -
+ )} +
+ +
+ +
+ {[1, 2, 3, 4, 5, 6].map(gradeNum => { + const isSelected = selectedGrade === gradeNum; + return ( + + ); + })}
- - + + setRatingComment(e.target.value)} + style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db', fontSize: '0.85rem' }} + />
-
- - setSightNotes(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} /> -
- -
- - + +
+ +
+

+ Bisherige Bewertungen ({ratingHotel.ratings_json?.length || 0}): +

+
+ {(!ratingHotel.ratings_json || ratingHotel.ratings_json.length === 0) ? ( + Noch keine Stimmen hinterlegt. + ) : ( + ratingHotel.ratings_json.map((r, idx) => ( +
+
+ 👤 {r.name}: + {r.comment || '(Kein Kommentar)'} +
+ + Note {Number(r.grade).toFixed(1)} + +
+ )) + )} +
)} - {/* MODAL: AUSGEMUSTERTE HOTELS */} - {showParkedModal && ( + {/* MODAL 2: MITREISENDE VERWALTEN */} + {showMembersModal && (
-
+
-

📦 Ausgemusterte Hotels

-

Diese Hotels sind aktuell nicht Teil deiner Timeline, bleiben aber gespeichert.

+

👥 Mitreisende verwalten

+

Für: {activeTripObj?.name}

- +
-
- {parkedHotels.length === 0 ? ( -

🎉 Aktuell sind keine Hotels ausgemustert.

- ) : ( -
- {parkedHotels.map(h => ( -
-
-
{h.name}
-
- {h.checkin_date ? `🗓️ ${formatDateGerman(h.checkin_date)} – ${formatDateGerman(h.checkout_date)}` : 'Kein Datum'} - {h.latest_price ? ` • 💰 Gesamt: ${h.latest_price.toLocaleString('de-DE')} €` : ''} -
+ +
+
+ setNewMemberName(e.target.value)} + style={{ flex: 1, padding: '0.55rem', borderRadius: '6px', border: '1px solid #cbd5e1', fontSize: '0.85rem' }} + /> + +
+ +
Aktuelle Mitreisende:
+ +
+ {tripMembers.length === 0 ? ( +

+ Noch keine Mitreisenden angelegt. +

+ ) : ( + tripMembers.map(m => ( +
+
+ + {m.name[0]} + + {m.name}
- ))} -
- )} + )) + )} +
+
+ +
+
)} - {/* PREISHISTORIE MODAL */} + {/* MODAL 3: PREISHISTORIE */} {selectedPriceHotel && (
@@ -708,7 +1307,6 @@ export default function TravelDashboard() {
- {loadingPrices ? (

Lade Preishistorie...

) : priceHistory.length === 0 ? ( @@ -729,7 +1327,6 @@ export default function TravelDashboard() {
{maxPrice} €
- {priceHistory.length > 1 && (
@@ -815,300 +1412,160 @@ export default function TravelDashboard() {
)} - {/* TIMELINE MIT CLUSTERING */} - {loading ? ( -

Lade Daten...

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

Keine aktiven Hotels für diese Auswahl gefunden.

- -
- ) : ( -
- {clusteredStops.map((stop, stopIndex) => { - const isCluster = stop.hotels.length > 1; - const nights = calculateNights(stop.checkin_date, stop.checkout_date) || stop.hotels[0]?.latest_nights || 1; - - return ( -
-
- - {/* Stop Header */} -
-
- 📍 Stop #{stopIndex + 1} - - {stop.checkin_date ? ( - - 🗓️ {formatDateGerman(stop.checkin_date)} – {formatDateGerman(stop.checkout_date) || '?'} ({nights} {nights === 1 ? 'Nacht' : 'Nächte'}) - - ) : ( - (Warte auf Scan für Datum) - )} - - {isCluster && ( - - ⚖️ {stop.hotels.length} Optionen im Vergleich - - )} -
-
- - {/* Hotel Grid */} -
- {stop.hotels.map((hotel, hIdx) => { - const hotelNights = calculateNights(hotel.checkin_date, hotel.checkout_date) || hotel.latest_nights || nights || 1; - const persons = hotel.latest_persons || 2; - const totalPrice = hotel.latest_price !== null && hotel.latest_price !== undefined ? Number(hotel.latest_price) : null; - const pricePerNight = hotel.latest_price_per_night - ? Math.round(Number(hotel.latest_price_per_night)) - : (totalPrice ? Math.round(totalPrice / hotelNights) : null); - const pricePerPersonPerNight = hotel.latest_price_per_person - ? Math.round(Number(hotel.latest_price_per_person)) - : (totalPrice ? Math.round(totalPrice / (hotelNights * persons)) : null); - - 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 ( -
-
- {/* Option Header */} -
- {isCluster ? ( - - Option {String.fromCharCode(65 + hIdx)} - - ) : ( -
{hotel.trip_name && {hotel.trip_name}}
- )} - - -
- - {/* Hotel Body */} -
- {hotel.image_url && {hotel.name}} -
-

{hotel.name}

-

📍 {hotel.address || 'Keine Adresse hinterlegt'}

-
-
- -{/* Preis-Box mit Aktuellem Kurs & Bestpreis */} -{totalPrice !== null ? ( -
- {/* Zeile 1: Aktueller Preis */} -
-
- Aktuell: - - 💰 {totalPrice.toLocaleString('de-DE')} € - - - (🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.) - -
- - {hotel.best_price && totalPrice > hotel.best_price ? ( - - ▲ +{(totalPrice - hotel.best_price).toLocaleString('de-DE')} € über Tiefst - - ) : ( - - 🔥 Tiefstpreis - - )} -
- - {/* Zeile 2: Bestpreis / Historischer Tiefststand */} - {hotel.best_price && ( -
-
- 🏆 Bestpreis: - - {Number(hotel.best_price).toLocaleString('de-DE')} € - - {hotel.best_price_per_night && ( - - (🌙 {Math.round(Number(hotel.best_price_per_night)).toLocaleString('de-DE')} € / N.) - - )} -
-
- )} -
-) : ( -
- 💰 Noch kein Preis erfasst -
-)} - - {/* Meta Tags */} -
- ⭐ {hotel.rating || 'N/A'}/10 - 💬 {hotel.review_count || 0} Bew. - {hotel.room_size_m2 && 📐 {hotel.room_size_m2} m²} -
- - {/* 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 */} -
- - - - 🔗 Booking ↗ - + {/* MODAL: AUSGEMUSTERTE HOTELS */} + {showParkedModal && ( +
+
+
+
+

📦 Ausgemusterte Hotels

+

Diese Hotels sind nicht in der Timeline, bleiben aber gespeichert.

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

🎉 Aktuell sind keine Hotels ausgemustert.

+ ) : ( +
+ {parkedHotels.map(h => ( +
+
+
{h.name}
+
+ {h.checkin_date ? `🗓️ ${formatDateGerman(h.checkin_date)} – ${formatDateGerman(h.checkout_date)}` : 'Kein Datum'} + {h.latest_price ? ` • 💰 Gesamt: ${h.latest_price.toLocaleString('de-DE')} €` : ''}
+ +
+ ))} +
+ )} +
+
+
+ )} + + {/* MODAL: AUSFLÜGE VERWALTEN */} + {showManageSightsModal && ( +
+
+
+
+

🏛️ Zentrale Ausflugsziele

+

Sehenswürdigkeiten bearbeiten und 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 ? ( + + 🏨 {assignedCount} {assignedCount === 1 ? 'Hotel' : 'Hotels'} zugeordnet + + ) : ( + 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' }} /> +
+
+ +
+ {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' }} /> +
+
+ + +
+
+
)} +
); } \ No newline at end of file