From 2b13daf3a0a58130fdba25f60ab617f3a5f2bf0a Mon Sep 17 00:00:00 2001 From: gitea Date: Sun, 16 Aug 2026 10:00:13 +0000 Subject: [PATCH] Preishistory mit Genius --- llm_context.txt | 1624 ++++++++++++++++------- src/app/api/hotels/[id]/prices/route.ts | 5 +- src/app/api/hotels/route.ts | 24 +- src/app/page.tsx | 328 +++-- 4 files changed, 1374 insertions(+), 607 deletions(-) diff --git a/llm_context.txt b/llm_context.txt index bbf54ae..c400156 100644 --- a/llm_context.txt +++ b/llm_context.txt @@ -16,11 +16,13 @@ src/app/api/attractions/route.ts src/app/api/hotels/[id]/attractions/route.ts src/app/api/hotels/[id]/history/route.ts src/app/api/hotels/[id]/prices/route.ts +src/app/api/hotels/[id]/ratings/route.ts src/app/api/hotels/[id]/review-summary/route.ts src/app/api/hotels/[id]/route.ts src/app/api/hotels/[id]/scan/route.ts src/app/api/hotels/route.ts src/app/api/jobs/trigger-daily-check/route.ts +src/app/api/trips/[id]/members/route.ts src/app/api/trips/route.ts src/app/api/webhooks/scraper/route.ts src/app/layout.tsx @@ -887,6 +889,38 @@ export async function GET(req: NextRequest, { params }: { params: { id: string } ``` +--- DATEI: src/app/api/hotels/[id]/ratings/route.ts --- +``` +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 }); + } +} + +``` + --- DATEI: src/app/api/hotels/[id]/review-summary/route.ts --- ``` import { NextRequest, NextResponse } from 'next/server'; @@ -1031,28 +1065,57 @@ export const dynamic = 'force-dynamic'; export async function GET() { try { - const res = await query(` - SELECT + 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.genius_price_numeric::float AS latest_genius_price, ph.nights AS latest_nights, - ph.persons AS latest_persons + 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 + SELECT price_numeric, genius_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 - ORDER BY h.checkin_date ASC NULLS LAST, h.id ASC - `); - - return NextResponse.json({ success: true, hotels: res.rows }); + 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 + 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) { console.error('Fehler in GET /api/hotels:', error.message); try { @@ -1093,8 +1156,11 @@ export async function POST(req: NextRequest) { console.error('Fehler in POST /api/hotels:', error.message); return NextResponse.json({ success: false, error: error.message }, { status: 500 }); } + + } + ``` --- DATEI: src/app/api/jobs/trigger-daily-check/route.ts --- @@ -1160,6 +1226,66 @@ export async function POST() { ``` +--- DATEI: src/app/api/trips/[id]/members/route.ts --- +``` +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 }); + } +} + +``` + --- DATEI: src/app/api/trips/route.ts --- ``` import { NextRequest, NextResponse } from 'next/server'; @@ -1232,18 +1358,25 @@ export async function POST(req: NextRequest) { const numericPrice = parseGermanPrice(body.price); - // 1. In hotel_price_history eintragen mit echten Spalten - if (numericPrice !== null && numericPrice > 0) { - try { - await query( - `INSERT INTO hotel_price_history (hotel_id, price_numeric, price_raw, currency, nights, persons, tracked_at) - VALUES ($1::uuid, $2, $3, 'EUR', $4, $5, NOW())`, - [hotelId, numericPrice, String(body.price || ''), body.nights || 1, body.persons || 2] - ); - } catch (err: any) { - console.error('DB Insert Error (hotel_price_history):', err.message); - } - } +// Prüfen, ob das Genius-Badge vom Scraper erkannt wurde (z.B. body.hasGenius oder body.isGenius) +const hasGeniusBadge = Boolean(body.hasGenius || body.isGenius || body.genius); + +// Wenn Genius vorhanden ist: 10% abziehen und runden +const geniusPriceNumeric = (numericPrice !== null && hasGeniusBadge) + ? Math.round(numericPrice * 0.9) + : null; + +if (numericPrice !== null && numericPrice > 0) { + try { + await query( + `INSERT INTO hotel_price_history (hotel_id, price_numeric, genius_price_numeric, price_raw, currency, nights, persons, tracked_at) + VALUES ($1::uuid, $2, $3, $4, 'EUR', $5, $6, NOW())`, + [hotelId, numericPrice, geniusPriceNumeric, String(body.price || ''), body.nights || 1, body.persons || 2] + ); + } catch (err: any) { + console.error('DB Insert Error (hotel_price_history):', err.message); + } +} // 2. Stammdaten aktualisieren try { @@ -1311,11 +1444,30 @@ export default function RootLayout({ 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; @@ -1352,8 +1504,14 @@ interface Hotel { latest_price?: number | null; latest_price_per_night?: number | null; latest_price_per_person?: number | null; + latest_genius_price?: number | null; latest_nights?: number | null; 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; @@ -1377,8 +1535,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); @@ -1399,7 +1561,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); @@ -1410,10 +1584,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); @@ -1440,7 +1625,7 @@ export default function TravelDashboard() { const dataTrips = await resTrips.json(); setTrips(dataTrips.trips || []); } - } catch (e) {} + } catch (e) { } try { const resHotels = await fetch('/api/hotels'); @@ -1448,7 +1633,7 @@ export default function TravelDashboard() { const dataHotels = await resHotels.json(); setHotels(dataHotels.hotels || []); } - } catch (e) {} + } catch (e) { } try { const resAttractions = await fetch('/api/attractions'); @@ -1456,12 +1641,24 @@ export default function TravelDashboard() { const dataAttractions = await resAttractions.json(); setAttractions(dataAttractions.attractions || []); } - } catch (e) {} + } catch (e) { } } finally { setLoading(false); } }; + 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'); @@ -1474,21 +1671,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; @@ -1510,6 +1695,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'); @@ -1539,14 +1842,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); } @@ -1559,13 +1862,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); @@ -1605,7 +1907,6 @@ export default function TravelDashboard() { hotel_ids: selectedHotelIds, trip_id: targetTripId }; - if (editingSight) { const res = await fetch(`/api/attractions/${editingSight.id}`, { method: 'PUT', @@ -1623,7 +1924,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) { @@ -1666,6 +1966,9 @@ export default function TravelDashboard() { } }; + // ========================================== + // FILTER & CLUSTERING MIT AUTOMATISCHEM RANKING + // ========================================== const currentHotels = hotels .filter(h => h.is_active) .filter(h => { @@ -1681,7 +1984,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(); @@ -1690,15 +1992,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 { @@ -1712,37 +2011,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 & GENIUS */} + {totalPrice !== null ? ( +
+ + {hotel.latest_genius_price ? ( + <> + {/* Durchgestrichener Normalpreis bei aktivem Genius */} +
+ Regulär: {totalPrice.toLocaleString('de-DE')} € + -10% GENIUS +
+ + {/* Aktive Genius-Zeile mit Tiefstpreis-Badge */} +
+
+ Genius: + + 💙 {Number(hotel.latest_genius_price).toLocaleString('de-DE')} € + + + (🌙 {Math.round(Number(hotel.latest_genius_price) / hotelNights).toLocaleString('de-DE')} € / N.) + +
+ + {hotel.best_price && Number(hotel.latest_genius_price) > Number(hotel.best_price) ? ( + + ▲ +{(Number(hotel.latest_genius_price) - Number(hotel.best_price)).toLocaleString('de-DE')} € + + ) : ( + + 🔥 Tiefstpreis + + )} +
+ + ) : ( + /* Standard-Preiszeile mit Tiefstpreis-Badge */ +
+
+ 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 + + )} +
+ )} + + {/* Allzeit-Bestpreis Zeile */} + {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 && (
@@ -2015,7 +2786,6 @@ export default function TravelDashboard() {
- {loadingPrices ? (

Lade Preishistorie...

) : priceHistory.length === 0 ? ( @@ -2036,7 +2806,6 @@ export default function TravelDashboard() {
{maxPrice} €
- {priceHistory.length > 1 && (
@@ -2122,261 +2891,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 */} - {totalPrice !== null ? ( -
- - 💰 Gesamt: {totalPrice.toLocaleString('de-DE')} € - - - | 🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. - - - | 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P. - -
- ) : ( -
- 💰 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' }} /> +
+
+ + +
+
+
)} +
); } diff --git a/src/app/api/hotels/[id]/prices/route.ts b/src/app/api/hotels/[id]/prices/route.ts index 536b9e0..b3ccd7e 100644 --- a/src/app/api/hotels/[id]/prices/route.ts +++ b/src/app/api/hotels/[id]/prices/route.ts @@ -6,11 +6,11 @@ export const dynamic = 'force-dynamic'; export async function GET(req: NextRequest, { params }: { params: { id: string } }) { try { const { id } = params; - const res = await query( `SELECT id, price_numeric::float AS price, + genius_price_numeric::float AS genius_price, price_per_night::float AS price_per_night, price_per_person::float AS price_per_person, nights, @@ -21,10 +21,9 @@ export async function GET(req: NextRequest, { params }: { params: { id: string } ORDER BY tracked_at ASC`, [id] ); - return NextResponse.json({ success: true, prices: res.rows }); } catch (error: any) { console.error('Fehler beim Laden der Preishistorie:', error.message); return NextResponse.json({ success: false, error: error.message, prices: [] }, { status: 500 }); } -} +} \ No newline at end of file diff --git a/src/app/api/hotels/route.ts b/src/app/api/hotels/route.ts index d6bf2eb..bbd07cf 100644 --- a/src/app/api/hotels/route.ts +++ b/src/app/api/hotels/route.ts @@ -1,12 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; - export const dynamic = 'force-dynamic'; export async function GET() { try { - const res = await query(` -SELECT + const res = await query(` + SELECT h.*, t.name AS trip_name, ph.price_numeric::float AS latest_price, @@ -16,6 +15,7 @@ SELECT ph.nights AS latest_nights, ph.persons AS latest_persons, bp.best_price, + bp.best_genius_price, hr.avg_grade, hr.total_votes, hr.ratings_json @@ -29,7 +29,9 @@ SELECT LIMIT 1 ) ph ON true LEFT JOIN LATERAL ( - SELECT MIN(price_numeric)::float AS best_price + SELECT + MIN(price_numeric)::float AS best_price, + MIN(genius_price_numeric)::float AS best_genius_price FROM hotel_price_history WHERE hotel_id::text = h.id::text AND price_numeric > 0 ) bp ON true @@ -54,8 +56,8 @@ SELECT 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 }); + `); + return NextResponse.json({ success: true, hotels: res.rows }); } catch (error: any) { console.error('Fehler in GET /api/hotels:', error.message); try { @@ -70,33 +72,25 @@ SELECT export async function POST(req: NextRequest) { try { const { name, booking_url, trip_id } = await req.json(); - if (!booking_url) { return NextResponse.json({ error: 'Booking.com URL erforderlich' }, { status: 400 }); } - let checkin_date: string | null = null; let checkout_date: string | null = null; - try { const parsedUrl = new URL(booking_url); checkin_date = parsedUrl.searchParams.get('checkin'); checkout_date = parsedUrl.searchParams.get('checkout'); } catch (e) {} - const res = await query( `INSERT INTO hotels (name, booking_url, checkin_date, checkout_date, trip_id, is_active) VALUES ($1, $2, $3, $4, $5, true) RETURNING *`, [name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null] ); - return NextResponse.json({ success: true, hotel: res.rows[0] }); } catch (error: any) { console.error('Fehler in POST /api/hotels:', error.message); return NextResponse.json({ success: false, error: error.message }, { status: 500 }); } - - -} - +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 4588331..37ec02c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,4 @@ 'use client'; - import { useState, useEffect } from 'react'; // ========================================== @@ -29,6 +28,7 @@ interface RatingEntry { interface PriceEntry { id: string; price: number; + genius_price?: number | null; price_per_night?: number | null; price_per_person?: number | null; nights: number; @@ -66,6 +66,7 @@ interface Hotel { latest_nights?: number | null; latest_persons?: number | null; best_price?: number | null; + best_genius_price?: number | null; best_price_per_night?: number | null; avg_grade?: number | null; total_votes?: number | null; @@ -292,14 +293,13 @@ export default function TravelDashboard() { }; // ========================================== - // HANDLERS: BEWERTUNG (1 bis 6) + // HANDLERS: BEWERTUNG // ========================================== 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) { @@ -564,25 +564,33 @@ export default function TravelDashboard() { return acc; }, []); - const pricesList = priceHistory.map(p => Number(p.price)); + // Normalpreise für Statistik + const pricesList = priceHistory.map(p => Number(p.price)).filter(p => p > 0); const minPrice = pricesList.length > 0 ? Math.min(...pricesList) : 0; const maxPrice = pricesList.length > 0 ? Math.max(...pricesList) : 0; const currentPrice = pricesList.length > 0 ? pricesList[pricesList.length - 1] : 0; + // Geniuspreise für Statistik + const geniusPricesList = priceHistory + .map(p => (p.genius_price !== null && p.genius_price !== undefined ? Number(p.genius_price) : null)) + .filter((p): p is number => p !== null && p > 0); + const minGeniusPrice = geniusPricesList.length > 0 ? Math.min(...geniusPricesList) : null; + const maxGeniusPrice = geniusPricesList.length > 0 ? Math.max(...geniusPricesList) : null; + const currentGeniusPrice = geniusPricesList.length > 0 ? geniusPricesList[geniusPricesList.length - 1] : null; + const hasAnyGeniusPrice = geniusPricesList.length > 0; + const activeTripObj = trips.find(t => String(t.id) === String(activeTripId)); return (
- {/* HEADER */}

📍 Reise-Ablauf & Freunde-Ranking

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

-
)}
-
-
)} - {/* PREIS-BOX MIT BESTPREIS-VERGLEICH & GENIUS */} + {/* PREIS-BOX MIT GETRENNTEM GENIUS- & NORMALPREIS-VERGLEICH */} {totalPrice !== null ? ( -
- - {hotel.latest_genius_price ? ( - <> - {/* Durchgestrichener Normalpreis bei aktivem Genius */} -
- Regulär: {totalPrice.toLocaleString('de-DE')} € - -10% GENIUS -
- - {/* Aktive Genius-Zeile mit Tiefstpreis-Badge */} -
-
- Genius: - - 💙 {Number(hotel.latest_genius_price).toLocaleString('de-DE')} € - - - (🌙 {Math.round(Number(hotel.latest_genius_price) / hotelNights).toLocaleString('de-DE')} € / N.) - -
- - {hotel.best_price && Number(hotel.latest_genius_price) > Number(hotel.best_price) ? ( - - ▲ +{(Number(hotel.latest_genius_price) - Number(hotel.best_price)).toLocaleString('de-DE')} € - - ) : ( - - 🔥 Tiefstpreis - - )} -
- - ) : ( - /* Standard-Preiszeile mit Tiefstpreis-Badge */ -
-
- Aktuell: - - 💰 {totalPrice.toLocaleString('de-DE')} € - - - (🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.) - -
- +
+ + {/* 1. Normalpreis vs. Normaler Bestpreis */} +
+
+ 🏷️ REGULÄR {hotel.best_price && totalPrice > Number(hotel.best_price) ? ( - - ▲ +{(totalPrice - Number(hotel.best_price)).toLocaleString('de-DE')} € + + ▲ +{(totalPrice - Number(hotel.best_price)).toLocaleString('de-DE')} € vs. Best ) : ( - + 🔥 Tiefstpreis )}
- )} - - {/* Allzeit-Bestpreis Zeile */} - {hotel.best_price && ( -
- 🏆 Bestpreis: - - {Number(hotel.best_price).toLocaleString('de-DE')} € +
+ + {totalPrice.toLocaleString('de-DE')} € - {hotel.best_price_per_night && ( - - (🌙 {Math.round(Number(hotel.best_price_per_night)).toLocaleString('de-DE')} € / N.) + + (🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.) + +
+ {hotel.best_price && ( +
+ 🏆 Normal-Bestpreis: {Number(hotel.best_price).toLocaleString('de-DE')} € +
+ )} +
+ + {/* 2. Genius-Preis vs. Genius Bestpreis (wenn vorhanden) */} + {hotel.latest_genius_price && ( +
+
+ + 💙 GENIUS (-10%) + {hotel.best_genius_price && Number(hotel.latest_genius_price) > Number(hotel.best_genius_price) ? ( + + ▲ +{(Number(hotel.latest_genius_price) - Number(hotel.best_genius_price)).toLocaleString('de-DE')} € vs. Genius-Best + + ) : ( + + 🔥 Genius-Tiefstpreis + + )} +
+
+ + {Number(hotel.latest_genius_price).toLocaleString('de-DE')} € + + + (🌙 {Math.round(Number(hotel.latest_genius_price) / hotelNights).toLocaleString('de-DE')} € / N.) + +
+ {hotel.best_genius_price && ( +
+ 🏆 Genius-Bestpreis: {Number(hotel.best_genius_price).toLocaleString('de-DE')} € +
)}
)} +
) : (
@@ -1064,7 +1060,6 @@ export default function TravelDashboard() { ➕ Zuordnen
-
{hotelSights.length === 0 ? ( Keine Ausflugsziele zugeordnet. @@ -1138,7 +1133,6 @@ export default function TravelDashboard() {
-
)}
-
-
-
-

Bisherige Bewertungen ({ratingHotel.ratings_json?.length || 0}): @@ -1276,7 +1266,6 @@ export default function TravelDashboard() {

-
-
Aktuelle Mitreisende:
-
{tripMembers.length === 0 ? (

@@ -1323,7 +1310,6 @@ export default function TravelDashboard() { )}

-
)} - {/* MODAL 3: PREISHISTORIE */} + {/* MODAL 3: PREISHISTORIE (GENIUS & REGULÄR) */} {selectedPriceHotel && (
-
-
+
+
-

📈 Preishistorie: {selectedPriceHotel.name}

-

Entwicklung aller erfassten Preis-Scans

+

📈 Preishistorie: {selectedPriceHotel.name}

+

Entwicklung der Scans & Genius-Vergleich

+ {loadingPrices ? (

Lade Preishistorie...

) : priceHistory.length === 0 ? (

Noch keine Preishistorie vorhanden.

) : (
-
-
- Tiefstpreis -
{minPrice} €
-
-
- Aktuellster Preis -
{currentPrice} €
-
-
- Höchstpreis -
{maxPrice} €
+ {/* DUAL STATS CARDS */} +
+ + {/* Regulärer Preis */} +
+
+ 🏷️ Regulärer Preis +
+
+
+ Tiefst + {minPrice} € +
+
+ Aktuell + {currentPrice} € +
+
+ Höchst + {maxPrice} € +
+
+ + {/* Genius Preis */} + {hasAnyGeniusPrice && ( +
+
+ 💙 Genius Vorteil (-10%) +
+
+
+ Genius Min + {minGeniusPrice} € 🔥 +
+
+ Aktuell + {currentGeniusPrice} € +
+
+ Höchst + {maxGeniusPrice} € +
+
+
+ )} +
+ + {/* DUAL SVG CHART */} {priceHistory.length > 1 && ( -
- +
+
+
+
+ Regulärer Preis +
+ {hasAnyGeniusPrice && ( +
+
+ Genius Preis (-10%) +
+ )} +
+ {(() => { - const range = maxPrice - minPrice || 1; - const points = priceHistory.map((p, idx) => { + const allValues = [ + ...pricesList, + ...geniusPricesList + ]; + const chartMin = allValues.length > 0 ? Math.min(...allValues) : 0; + const chartMax = allValues.length > 0 ? Math.max(...allValues) : 100; + const range = (chartMax - chartMin) || 1; + + const normalPoints = priceHistory.map((p, idx) => { const x = (idx / (priceHistory.length - 1)) * 500; - const y = 130 - ((p.price - minPrice) / range) * 110; + const y = 130 - ((Number(p.price) - chartMin) / range) * 110; return `${x},${y}`; }).join(' '); + + const geniusPoints = priceHistory + .map((p, idx) => { + if (p.genius_price === null || p.genius_price === undefined) return null; + const x = (idx / (priceHistory.length - 1)) * 500; + const y = 130 - ((Number(p.genius_price) - chartMin) / range) * 110; + return { x, y, val: p.genius_price, id: p.id }; + }) + .filter((pt): pt is { x: number; y: number; val: number; id: string } => pt !== null); + + const geniusPolylineStr = geniusPoints.map(pt => `${pt.x},${pt.y}`).join(' '); + return ( <> - + {/* Normal Line */} + {priceHistory.map((p, idx) => { const x = (idx / (priceHistory.length - 1)) * 500; - const y = 130 - ((p.price - minPrice) / range) * 110; + const y = 130 - ((Number(p.price) - chartMin) / range) * 110; return ( - - - {p.price}€ + + + + {p.price}€ + ); })} + + {/* Genius Line */} + {geniusPoints.length > 1 && ( + + )} + {geniusPoints.map((pt, idx) => ( + + + + {pt.val}€ + + + ))} ); })()}
)} + + {/* HISTORIEN TABELLE */} +
+ + + + + + + + + + + {priceHistory.slice().reverse().map((entry, idx) => { + const hasGen = entry.genius_price !== null && entry.genius_price !== undefined; + const saving = hasGen ? Math.round(Number(entry.price) - Number(entry.genius_price)) : 0; + return ( + + + + + + + ); + })} + +
Datum / UhrzeitRegulärGenius (-10%)Ersparnis
+ {new Date(entry.created_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })} + + {Number(entry.price).toLocaleString('de-DE')} € + + {hasGen ? `${Number(entry.genius_price).toLocaleString('de-DE')} €` : '–'} + 0 ? '#047857' : '#94a3b8', fontWeight: '700' }}> + {saving > 0 ? `-${saving} €` : '–'} +
+
+
)}
@@ -1602,7 +1709,6 @@ export default function TravelDashboard() {
)} -
); } \ No newline at end of file