Preishistory mit Genius

This commit is contained in:
2026-08-16 10:00:13 +00:00
parent 472d225fda
commit 2b13daf3a0
4 changed files with 1374 additions and 607 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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 (
<div style={{ maxWidth: '1100px', margin: '0 auto', padding: '2rem 1rem', fontFamily: 'system-ui, -apple-system, sans-serif', color: '#1f2937' }}>
{/* HEADER */}
<header style={{ marginBottom: '2rem', textAlign: 'center' }}>
<h1 style={{ fontSize: '2.2rem', marginBottom: '0.5rem', fontWeight: '800', color: '#0f172a' }}>
📍 Reise-Ablauf & Freunde-Ranking
</h1>
<p style={{ color: '#64748b', margin: 0, fontSize: '0.95rem' }}>
Hotelvergleich nach Schulnoten (1–6), Bestpreise, Routen & Fahrzeiten
Hotelvergleich nach Schulnoten (1–6), Bestpreise, Genius-Tracking, Routen & Fahrzeiten
</p>
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'center', marginTop: '1.5rem', flexWrap: 'wrap' }}>
<button
onClick={() => setShowAddHotelModal(true)}
@@ -716,7 +724,6 @@ export default function TravelDashboard() {
</div>
)}
</div>
<button
onClick={() => setShowMembersModal(true)}
style={{
@@ -750,7 +757,6 @@ export default function TravelDashboard() {
{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;
@@ -794,7 +800,7 @@ export default function TravelDashboard() {
{/* Hotel Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: isCluster ? 'repeat(auto-fit, minmax(340px, 1fr))' : '1fr',
gridTemplateColumns: isCluster ? 'repeat(auto-fit, minmax(350px, 1fr))' : '1fr',
gap: '1.3rem',
background: isCluster ? '#fdfcff' : 'transparent',
padding: isCluster ? '1.2rem' : '0',
@@ -820,7 +826,6 @@ export default function TravelDashboard() {
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'; }
@@ -916,7 +921,6 @@ export default function TravelDashboard() {
</span>
</div>
</div>
<button
onClick={() => openRatingModal(hotel)}
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.35rem 0.75rem', borderRadius: '6px', fontSize: '0.78rem', fontWeight: '700', cursor: 'pointer' }}
@@ -970,80 +974,72 @@ export default function TravelDashboard() {
</div>
)}
{/* PREIS-BOX MIT BESTPREIS-VERGLEICH & GENIUS */}
{/* PREIS-BOX MIT GETRENNTEM GENIUS- & NORMALPREIS-VERGLEICH */}
{totalPrice !== null ? (
<div style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '10px', padding: '0.6rem 0.8rem', marginBottom: '0.8rem', display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{hotel.latest_genius_price ? (
<>
{/* Durchgestrichener Normalpreis bei aktivem Genius */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.76rem', color: '#64748b' }}>
<span>Regulär: <span style={{ textDecoration: 'line-through' }}>{totalPrice.toLocaleString('de-DE')} €</span></span>
<span style={{ background: '#16a34a', color: '#fff', fontSize: '0.68rem', fontWeight: '800', padding: '0.1rem 0.35rem', borderRadius: '4px' }}>-10% GENIUS</span>
</div>
{/* Aktive Genius-Zeile mit Tiefstpreis-Badge */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.3rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.78rem', fontWeight: '700', color: '#003580' }}>Genius:</span>
<span style={{ fontWeight: '900', color: '#003580', fontSize: '0.95rem' }}>
💙 {Number(hotel.latest_genius_price).toLocaleString('de-DE')} €
</span>
<span style={{ color: '#0052b4', fontSize: '0.76rem' }}>
(🌙 {Math.round(Number(hotel.latest_genius_price) / hotelNights).toLocaleString('de-DE')} € / N.)
</span>
</div>
{hotel.best_price && Number(hotel.latest_genius_price) > Number(hotel.best_price) ? (
<span style={{ background: '#fee2e2', color: '#991b1b', border: '1px solid #fecaca', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '700' }}>
▲ +{(Number(hotel.latest_genius_price) - Number(hotel.best_price)).toLocaleString('de-DE')} €
</span>
) : (
<span style={{ background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '800' }}>
🔥 Tiefstpreis
</span>
)}
</div>
</>
) : (
/* Standard-Preiszeile mit Tiefstpreis-Badge */
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.3rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.78rem', fontWeight: '700', color: '#475569' }}>Aktuell:</span>
<span style={{ fontWeight: '800', color: '#0f172a', fontSize: '0.92rem' }}>
💰 {totalPrice.toLocaleString('de-DE')} €
</span>
<span style={{ color: '#64748b', fontSize: '0.76rem' }}>
(🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.)
</span>
</div>
<div style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '10px', padding: '0.65rem 0.8rem', marginBottom: '0.8rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{/* 1. Normalpreis vs. Normaler Bestpreis */}
<div style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '0.45rem 0.6rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.74rem', color: '#64748b', marginBottom: '0.2rem' }}>
<span style={{ fontWeight: '700' }}>🏷️ REGULÄR</span>
{hotel.best_price && totalPrice > Number(hotel.best_price) ? (
<span style={{ background: '#fee2e2', color: '#991b1b', border: '1px solid #fecaca', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '700' }}>
▲ +{(totalPrice - Number(hotel.best_price)).toLocaleString('de-DE')} €
<span style={{ background: '#fee2e2', color: '#991b1b', border: '1px solid #fecaca', fontSize: '0.68rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '700' }}>
▲ +{(totalPrice - Number(hotel.best_price)).toLocaleString('de-DE')} € vs. Best
</span>
) : (
<span style={{ background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '800' }}>
<span style={{ background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0', fontSize: '0.68rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '800' }}>
🔥 Tiefstpreis
</span>
)}
</div>
)}
{/* Allzeit-Bestpreis Zeile */}
{hotel.best_price && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', borderTop: '1px dashed #e2e8f0', paddingTop: '0.3rem', fontSize: '0.75rem' }}>
<span style={{ fontWeight: '700', color: '#059669' }}>🏆 Bestpreis:</span>
<span style={{ fontWeight: '800', color: '#047857' }}>
{Number(hotel.best_price).toLocaleString('de-DE')} €
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: '800', color: '#0f172a', fontSize: '0.92rem' }}>
{totalPrice.toLocaleString('de-DE')} €
</span>
{hotel.best_price_per_night && (
<span style={{ color: '#64748b', fontSize: '0.72rem' }}>
(🌙 {Math.round(Number(hotel.best_price_per_night)).toLocaleString('de-DE')} € / N.)
<span style={{ color: '#64748b', fontSize: '0.74rem' }}>
(🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.)
</span>
</div>
{hotel.best_price && (
<div style={{ fontSize: '0.7rem', color: '#059669', marginTop: '0.2rem', borderTop: '1px dashed #f1f5f9', paddingTop: '0.2rem' }}>
🏆 Normal-Bestpreis: <strong>{Number(hotel.best_price).toLocaleString('de-DE')} €</strong>
</div>
)}
</div>
{/* 2. Genius-Preis vs. Genius Bestpreis (wenn vorhanden) */}
{hotel.latest_genius_price && (
<div style={{ background: 'linear-gradient(135deg, #f0f7ff, #e0f2fe)', border: '1.5px solid #93c5fd', borderRadius: '8px', padding: '0.5rem 0.6rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.2rem' }}>
<span style={{ background: '#003580', color: '#fff', fontSize: '0.66rem', fontWeight: '800', padding: '0.1rem 0.35rem', borderRadius: '4px' }}>
💙 GENIUS (-10%)
</span>
{hotel.best_genius_price && Number(hotel.latest_genius_price) > Number(hotel.best_genius_price) ? (
<span style={{ background: '#fee2e2', color: '#991b1b', border: '1px solid #fecaca', fontSize: '0.68rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '700' }}>
▲ +{(Number(hotel.latest_genius_price) - Number(hotel.best_genius_price)).toLocaleString('de-DE')} € vs. Genius-Best
</span>
) : (
<span style={{ background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0', fontSize: '0.68rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '800' }}>
🔥 Genius-Tiefstpreis
</span>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: '900', color: '#003580', fontSize: '0.98rem' }}>
{Number(hotel.latest_genius_price).toLocaleString('de-DE')} €
</span>
<span style={{ color: '#0284c7', fontSize: '0.74rem' }}>
(🌙 {Math.round(Number(hotel.latest_genius_price) / hotelNights).toLocaleString('de-DE')} € / N.)
</span>
</div>
{hotel.best_genius_price && (
<div style={{ fontSize: '0.7rem', color: '#003580', marginTop: '0.2rem', borderTop: '1px dashed rgba(0,53,128,0.15)', paddingTop: '0.2rem' }}>
🏆 Genius-Bestpreis: <strong>{Number(hotel.best_genius_price).toLocaleString('de-DE')} €</strong>
</div>
)}
</div>
)}
</div>
) : (
<div style={{ marginBottom: '0.8rem', fontSize: '0.8rem', color: '#6b7280', fontStyle: 'italic' }}>
@@ -1064,7 +1060,6 @@ export default function TravelDashboard() {
➕ Zuordnen
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
{hotelSights.length === 0 ? (
<span style={{ fontSize: '0.74rem', color: '#94a3b8', fontStyle: 'italic' }}>Keine Ausflugsziele zugeordnet.</span>
@@ -1138,7 +1133,6 @@ export default function TravelDashboard() {
</div>
<button onClick={() => setRatingHotel(null)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
</div>
<form onSubmit={handleSaveRating} style={{ padding: '1.3rem 1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1.1rem' }}>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.4rem' }}>
@@ -1185,7 +1179,6 @@ export default function TravelDashboard() {
</div>
)}
</div>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.3rem' }}>
2. Schulnote (1 = Sehr gut bis 6 = Ungenügend) *
@@ -1215,7 +1208,6 @@ export default function TravelDashboard() {
})}
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.3rem' }}>
3. Notiz / Begründung (optional)
@@ -1228,7 +1220,6 @@ export default function TravelDashboard() {
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db', fontSize: '0.85rem' }}
/>
</div>
<div style={{ display: 'flex', gap: '0.6rem', justifyContent: 'flex-end', marginTop: '0.4rem' }}>
<button type="button" onClick={() => setRatingHotel(null)} style={{ padding: '0.55rem 1rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem' }}>
Abbrechen
@@ -1237,7 +1228,6 @@ export default function TravelDashboard() {
{isSubmittingRating ? 'Speichere...' : '💾 Note speichern'}
</button>
</div>
<div style={{ borderTop: '1px solid #e2e8f0', paddingTop: '0.8rem', marginTop: '0.4rem' }}>
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: '800', color: '#1e293b' }}>
Bisherige Bewertungen ({ratingHotel.ratings_json?.length || 0}):
@@ -1276,7 +1266,6 @@ export default function TravelDashboard() {
</div>
<button onClick={() => setShowMembersModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
</div>
<div style={{ padding: '1.3rem 1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<form onSubmit={handleAddMember} style={{ display: 'flex', gap: '0.5rem' }}>
<input
@@ -1295,9 +1284,7 @@ export default function TravelDashboard() {
➕ Hinzufügen
</button>
</form>
<div style={{ fontSize: '0.8rem', fontWeight: '700', color: '#475569' }}>Aktuelle Mitreisende:</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{tripMembers.length === 0 ? (
<p style={{ margin: 0, fontSize: '0.8rem', color: '#94a3b8', fontStyle: 'italic', textAlign: 'center', padding: '1rem 0' }}>
@@ -1323,7 +1310,6 @@ export default function TravelDashboard() {
)}
</div>
</div>
<div style={{ padding: '0.8rem 1.5rem', borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={() => setShowMembersModal(false)} style={{ padding: '0.5rem 1rem', background: '#2563eb', color: '#fff', border: 'none', borderRadius: '6px', fontWeight: '700', cursor: 'pointer', fontSize: '0.85rem' }}>
Fertig
@@ -1333,68 +1319,189 @@ export default function TravelDashboard() {
</div>
)}
{/* MODAL 3: PREISHISTORIE */}
{/* MODAL 3: PREISHISTORIE (GENIUS & REGULÄR) */}
{selectedPriceHotel && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
<div style={{ background: '#fff', padding: '1.8rem', borderRadius: '12px', maxWidth: '650px', width: '100%', boxShadow: '0 20px 25px -5px rgba(0,0,0,0.1)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div style={{ background: '#fff', padding: '1.8rem', borderRadius: '16px', maxWidth: '720px', width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 25px -5px rgba(0,0,0,0.1)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.2rem' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.3rem' }}>📈 Preishistorie: {selectedPriceHotel.name}</h3>
<p style={{ margin: 0, color: '#6b7280', fontSize: '0.85rem' }}>Entwicklung aller erfassten Preis-Scans</p>
<h3 style={{ margin: 0, fontSize: '1.3rem', fontWeight: '800', color: '#0f172a' }}>📈 Preishistorie: {selectedPriceHotel.name}</h3>
<p style={{ margin: '0.2rem 0 0 0', color: '#6b7280', fontSize: '0.85rem' }}>Entwicklung der Scans & Genius-Vergleich</p>
</div>
<button onClick={() => setSelectedPriceHotel(null)} style={{ background: '#f3f4f6', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
</div>
{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.</p>
) : (
<div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem', marginBottom: '1.5rem', textAlign: 'center' }}>
<div style={{ background: '#f0fdf4', padding: '0.8rem', borderRadius: '8px', border: '1px solid #bbf7d0' }}>
<small style={{ color: '#166534', fontWeight: 'bold' }}>Tiefstpreis</small>
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#15803d' }}>{minPrice} €</div>
</div>
<div style={{ background: '#eff6ff', padding: '0.8rem', borderRadius: '8px', border: '1px solid #bfdbfe' }}>
<small style={{ color: '#1e40af', fontWeight: 'bold' }}>Aktuellster Preis</small>
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#1d4ed8' }}>{currentPrice} €</div>
</div>
<div style={{ background: '#fef2f2', padding: '0.8rem', borderRadius: '8px', border: '1px solid #fecaca' }}>
<small style={{ color: '#991b1b', fontWeight: 'bold' }}>Höchstpreis</small>
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#b91c1c' }}>{maxPrice} €</div>
{/* DUAL STATS CARDS */}
<div style={{ display: 'grid', gridTemplateColumns: hasAnyGeniusPrice ? '1fr 1fr' : '1fr', gap: '1rem', marginBottom: '1.5rem' }}>
{/* Regulärer Preis */}
<div style={{ background: '#f8fafc', padding: '0.9rem', borderRadius: '10px', border: '1px solid #cbd5e1' }}>
<div style={{ fontSize: '0.78rem', fontWeight: '800', color: '#475569', textTransform: 'uppercase', marginBottom: '0.5rem' }}>
🏷️ Regulärer Preis
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.4rem', textAlign: 'center' }}>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
<small style={{ color: '#166534', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Tiefst</small>
<strong style={{ color: '#15803d', fontSize: '1rem' }}>{minPrice} €</strong>
</div>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
<small style={{ color: '#1e40af', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Aktuell</small>
<strong style={{ color: '#1d4ed8', fontSize: '1rem' }}>{currentPrice} €</strong>
</div>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
<small style={{ color: '#991b1b', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Höchst</small>
<strong style={{ color: '#b91c1c', fontSize: '1rem' }}>{maxPrice} €</strong>
</div>
</div>
</div>
{/* Genius Preis */}
{hasAnyGeniusPrice && (
<div style={{ background: '#f0f7ff', padding: '0.9rem', borderRadius: '10px', border: '1.5px solid #93c5fd' }}>
<div style={{ fontSize: '0.78rem', fontWeight: '800', color: '#003580', textTransform: 'uppercase', marginBottom: '0.5rem' }}>
💙 Genius Vorteil (-10%)
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.4rem', textAlign: 'center' }}>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
<small style={{ color: '#003580', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Genius Min</small>
<strong style={{ color: '#003580', fontSize: '1rem' }}>{minGeniusPrice} € 🔥</strong>
</div>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
<small style={{ color: '#003580', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Aktuell</small>
<strong style={{ color: '#003580', fontSize: '1rem' }}>{currentGeniusPrice} €</strong>
</div>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
<small style={{ color: '#64748b', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Höchst</small>
<strong style={{ color: '#64748b', fontSize: '1rem' }}>{maxGeniusPrice} €</strong>
</div>
</div>
</div>
)}
</div>
{/* DUAL SVG CHART */}
{priceHistory.length > 1 && (
<div style={{ background: '#fafafa', padding: '1rem', borderRadius: '8px', border: '1px solid #f3f4f6', marginBottom: '1.5rem' }}>
<svg viewBox="0 0 500 150" style={{ width: '100%', height: '160px', overflow: 'visible' }}>
<div style={{ background: '#fafafa', padding: '1rem', borderRadius: '10px', border: '1px solid #e2e8f0', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', gap: '1.2rem', justifyContent: 'center', marginBottom: '0.8rem', fontSize: '0.78rem', fontWeight: '700' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<div style={{ width: '14px', height: '3px', background: '#2563eb' }}></div>
<span style={{ color: '#2563eb' }}>Regulärer Preis</span>
</div>
{hasAnyGeniusPrice && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<div style={{ width: '14px', height: '3px', background: '#003580' }}></div>
<span style={{ color: '#003580' }}>Genius Preis (-10%)</span>
</div>
)}
</div>
<svg viewBox="0 0 500 160" style={{ width: '100%', height: '170px', overflow: 'visible' }}>
<line x1="0" y1="20" x2="500" y2="20" stroke="#e5e7eb" strokeDasharray="4" />
<line x1="0" y1="130" x2="500" y2="130" stroke="#e5e7eb" strokeDasharray="4" />
{(() => {
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 (
<>
<polyline fill="none" stroke="#2563eb" strokeWidth="3" points={points} />
{/* Normal Line */}
<polyline fill="none" stroke="#2563eb" strokeWidth="2.5" strokeDasharray="4,3" points={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 (
<g key={p.id}>
<circle cx={x} cy={y} r="5" fill="#2563eb" stroke="#ffffff" strokeWidth="2" />
<text x={x} y={y - 10} fontSize="10" textAnchor="middle" fill="#374151" fontWeight="bold">{p.price}€</text>
<g key={`norm-${p.id || idx}`}>
<circle cx={x} cy={y} r="4" fill="#2563eb" stroke="#ffffff" strokeWidth="1.5" />
<text x={x} y={y - 8} fontSize="9.5" textAnchor="middle" fill="#1e40af" fontWeight="bold">
{p.price}€
</text>
</g>
);
})}
{/* Genius Line */}
{geniusPoints.length > 1 && (
<polyline fill="none" stroke="#003580" strokeWidth="3" points={geniusPolylineStr} />
)}
{geniusPoints.map((pt, idx) => (
<g key={`gen-${pt.id || idx}`}>
<circle cx={pt.x} cy={pt.y} r="4.5" fill="#003580" stroke="#ffffff" strokeWidth="1.5" />
<text x={pt.x} y={pt.y + 14} fontSize="9.5" textAnchor="middle" fill="#003580" fontWeight="900">
{pt.val}€
</text>
</g>
))}
</>
);
})()}
</svg>
</div>
)}
{/* HISTORIEN TABELLE */}
<div style={{ maxHeight: '220px', overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: '8px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr style={{ background: '#f8fafc', color: '#475569', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>
<th style={{ padding: '0.5rem 0.75rem' }}>Datum / Uhrzeit</th>
<th style={{ padding: '0.5rem 0.75rem' }}>Regulär</th>
<th style={{ padding: '0.5rem 0.75rem' }}>Genius (-10%)</th>
<th style={{ padding: '0.5rem 0.75rem' }}>Ersparnis</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={entry.id || idx} style={{ borderBottom: '1px solid #f1f5f9' }}>
<td style={{ padding: '0.45rem 0.75rem', color: '#64748b' }}>
{new Date(entry.created_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
</td>
<td style={{ padding: '0.45rem 0.75rem', fontWeight: '700', color: '#1e293b' }}>
{Number(entry.price).toLocaleString('de-DE')} €
</td>
<td style={{ padding: '0.45rem 0.75rem', fontWeight: '800', color: hasGen ? '#003580' : '#94a3b8' }}>
{hasGen ? `${Number(entry.genius_price).toLocaleString('de-DE')} €` : '–'}
</td>
<td style={{ padding: '0.45rem 0.75rem', color: saving > 0 ? '#047857' : '#94a3b8', fontWeight: '700' }}>
{saving > 0 ? `-${saving} €` : '–'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
@@ -1602,7 +1709,6 @@ export default function TravelDashboard() {
</div>
</div>
)}
</div>
);
}