Preishistory mit Genius
This commit is contained in:
1624
llm_context.txt
1624
llm_context.txt
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,11 @@ export const dynamic = 'force-dynamic';
|
|||||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
const res = await query(
|
const res = await query(
|
||||||
`SELECT
|
`SELECT
|
||||||
id,
|
id,
|
||||||
price_numeric::float AS price,
|
price_numeric::float AS price,
|
||||||
|
genius_price_numeric::float AS genius_price,
|
||||||
price_per_night::float AS price_per_night,
|
price_per_night::float AS price_per_night,
|
||||||
price_per_person::float AS price_per_person,
|
price_per_person::float AS price_per_person,
|
||||||
nights,
|
nights,
|
||||||
@@ -21,10 +21,9 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||||||
ORDER BY tracked_at ASC`,
|
ORDER BY tracked_at ASC`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, prices: res.rows });
|
return NextResponse.json({ success: true, prices: res.rows });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Fehler beim Laden der Preishistorie:', error.message);
|
console.error('Fehler beim Laden der Preishistorie:', error.message);
|
||||||
return NextResponse.json({ success: false, error: error.message, prices: [] }, { status: 500 });
|
return NextResponse.json({ success: false, error: error.message, prices: [] }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { query } from '@/lib/db';
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const res = await query(`
|
const res = await query(`
|
||||||
SELECT
|
SELECT
|
||||||
h.*,
|
h.*,
|
||||||
t.name AS trip_name,
|
t.name AS trip_name,
|
||||||
ph.price_numeric::float AS latest_price,
|
ph.price_numeric::float AS latest_price,
|
||||||
@@ -16,6 +15,7 @@ SELECT
|
|||||||
ph.nights AS latest_nights,
|
ph.nights AS latest_nights,
|
||||||
ph.persons AS latest_persons,
|
ph.persons AS latest_persons,
|
||||||
bp.best_price,
|
bp.best_price,
|
||||||
|
bp.best_genius_price,
|
||||||
hr.avg_grade,
|
hr.avg_grade,
|
||||||
hr.total_votes,
|
hr.total_votes,
|
||||||
hr.ratings_json
|
hr.ratings_json
|
||||||
@@ -29,7 +29,9 @@ SELECT
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) ph ON true
|
) ph ON true
|
||||||
LEFT JOIN LATERAL (
|
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
|
FROM hotel_price_history
|
||||||
WHERE hotel_id::text = h.id::text AND price_numeric > 0
|
WHERE hotel_id::text = h.id::text AND price_numeric > 0
|
||||||
) bp ON true
|
) bp ON true
|
||||||
@@ -54,8 +56,8 @@ SELECT
|
|||||||
JOIN trip_members tm ON hr_sub.member_id = tm.id
|
JOIN trip_members tm ON hr_sub.member_id = tm.id
|
||||||
WHERE hr_sub.hotel_id = h.id
|
WHERE hr_sub.hotel_id = h.id
|
||||||
) hr ON true
|
) hr ON true
|
||||||
`);
|
`);
|
||||||
return NextResponse.json({ success: true, hotels: res.rows });
|
return NextResponse.json({ success: true, hotels: res.rows });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Fehler in GET /api/hotels:', error.message);
|
console.error('Fehler in GET /api/hotels:', error.message);
|
||||||
try {
|
try {
|
||||||
@@ -70,33 +72,25 @@ SELECT
|
|||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { name, booking_url, trip_id } = await req.json();
|
const { name, booking_url, trip_id } = await req.json();
|
||||||
|
|
||||||
if (!booking_url) {
|
if (!booking_url) {
|
||||||
return NextResponse.json({ error: 'Booking.com URL erforderlich' }, { status: 400 });
|
return NextResponse.json({ error: 'Booking.com URL erforderlich' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let checkin_date: string | null = null;
|
let checkin_date: string | null = null;
|
||||||
let checkout_date: string | null = null;
|
let checkout_date: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(booking_url);
|
const parsedUrl = new URL(booking_url);
|
||||||
checkin_date = parsedUrl.searchParams.get('checkin');
|
checkin_date = parsedUrl.searchParams.get('checkin');
|
||||||
checkout_date = parsedUrl.searchParams.get('checkout');
|
checkout_date = parsedUrl.searchParams.get('checkout');
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const res = await query(
|
const res = await query(
|
||||||
`INSERT INTO hotels (name, booking_url, checkin_date, checkout_date, trip_id, is_active)
|
`INSERT INTO hotels (name, booking_url, checkin_date, checkout_date, trip_id, is_active)
|
||||||
VALUES ($1, $2, $3, $4, $5, true)
|
VALUES ($1, $2, $3, $4, $5, true)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null]
|
[name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null]
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true, hotel: res.rows[0] });
|
return NextResponse.json({ success: true, hotel: res.rows[0] });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Fehler in POST /api/hotels:', error.message);
|
console.error('Fehler in POST /api/hotels:', error.message);
|
||||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
328
src/app/page.tsx
328
src/app/page.tsx
@@ -1,5 +1,4 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -29,6 +28,7 @@ interface RatingEntry {
|
|||||||
interface PriceEntry {
|
interface PriceEntry {
|
||||||
id: string;
|
id: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
genius_price?: number | null;
|
||||||
price_per_night?: number | null;
|
price_per_night?: number | null;
|
||||||
price_per_person?: number | null;
|
price_per_person?: number | null;
|
||||||
nights: number;
|
nights: number;
|
||||||
@@ -66,6 +66,7 @@ interface Hotel {
|
|||||||
latest_nights?: number | null;
|
latest_nights?: number | null;
|
||||||
latest_persons?: number | null;
|
latest_persons?: number | null;
|
||||||
best_price?: number | null;
|
best_price?: number | null;
|
||||||
|
best_genius_price?: number | null;
|
||||||
best_price_per_night?: number | null;
|
best_price_per_night?: number | null;
|
||||||
avg_grade?: number | null;
|
avg_grade?: number | null;
|
||||||
total_votes?: 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) => {
|
const openRatingModal = (hotel: Hotel) => {
|
||||||
setRatingHotel(hotel);
|
setRatingHotel(hotel);
|
||||||
setSelectedMemberId(tripMembers[0]?.id || '');
|
setSelectedMemberId(tripMembers[0]?.id || '');
|
||||||
setSelectedGrade(null);
|
setSelectedGrade(null);
|
||||||
setRatingComment('');
|
setRatingComment('');
|
||||||
|
|
||||||
if (tripMembers.length > 0 && hotel.ratings_json) {
|
if (tripMembers.length > 0 && hotel.ratings_json) {
|
||||||
const existing = hotel.ratings_json.find(r => r.member_id === tripMembers[0].id);
|
const existing = hotel.ratings_json.find(r => r.member_id === tripMembers[0].id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -564,25 +564,33 @@ export default function TravelDashboard() {
|
|||||||
return acc;
|
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 minPrice = pricesList.length > 0 ? Math.min(...pricesList) : 0;
|
||||||
const maxPrice = pricesList.length > 0 ? Math.max(...pricesList) : 0;
|
const maxPrice = pricesList.length > 0 ? Math.max(...pricesList) : 0;
|
||||||
const currentPrice = pricesList.length > 0 ? pricesList[pricesList.length - 1] : 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));
|
const activeTripObj = trips.find(t => String(t.id) === String(activeTripId));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: '1100px', margin: '0 auto', padding: '2rem 1rem', fontFamily: 'system-ui, -apple-system, sans-serif', color: '#1f2937' }}>
|
<div style={{ maxWidth: '1100px', margin: '0 auto', padding: '2rem 1rem', fontFamily: 'system-ui, -apple-system, sans-serif', color: '#1f2937' }}>
|
||||||
|
|
||||||
{/* HEADER */}
|
{/* HEADER */}
|
||||||
<header style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
<header style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
||||||
<h1 style={{ fontSize: '2.2rem', marginBottom: '0.5rem', fontWeight: '800', color: '#0f172a' }}>
|
<h1 style={{ fontSize: '2.2rem', marginBottom: '0.5rem', fontWeight: '800', color: '#0f172a' }}>
|
||||||
📍 Reise-Ablauf & Freunde-Ranking
|
📍 Reise-Ablauf & Freunde-Ranking
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: '#64748b', margin: 0, fontSize: '0.95rem' }}>
|
<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>
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'center', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'center', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddHotelModal(true)}
|
onClick={() => setShowAddHotelModal(true)}
|
||||||
@@ -716,7 +724,6 @@ export default function TravelDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMembersModal(true)}
|
onClick={() => setShowMembersModal(true)}
|
||||||
style={{
|
style={{
|
||||||
@@ -750,7 +757,6 @@ export default function TravelDashboard() {
|
|||||||
{clusteredStops.map((stop, stopIndex) => {
|
{clusteredStops.map((stop, stopIndex) => {
|
||||||
const isCluster = stop.hotels.length > 1;
|
const isCluster = stop.hotels.length > 1;
|
||||||
const nights = calculateNights(stop.checkin_date, stop.checkout_date) || stop.hotels[0]?.latest_nights || 1;
|
const nights = calculateNights(stop.checkin_date, stop.checkout_date) || stop.hotels[0]?.latest_nights || 1;
|
||||||
|
|
||||||
const sortedHotels = [...stop.hotels].sort((a, b) => {
|
const sortedHotels = [...stop.hotels].sort((a, b) => {
|
||||||
const gradeA = a.avg_grade !== null && a.avg_grade !== undefined ? Number(a.avg_grade) : 99;
|
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;
|
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 */}
|
{/* Hotel Grid */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: isCluster ? 'repeat(auto-fit, minmax(340px, 1fr))' : '1fr',
|
gridTemplateColumns: isCluster ? 'repeat(auto-fit, minmax(350px, 1fr))' : '1fr',
|
||||||
gap: '1.3rem',
|
gap: '1.3rem',
|
||||||
background: isCluster ? '#fdfcff' : 'transparent',
|
background: isCluster ? '#fdfcff' : 'transparent',
|
||||||
padding: isCluster ? '1.2rem' : '0',
|
padding: isCluster ? '1.2rem' : '0',
|
||||||
@@ -820,7 +826,6 @@ export default function TravelDashboard() {
|
|||||||
let gradeBg = '#f0fdf4';
|
let gradeBg = '#f0fdf4';
|
||||||
let gradeBorder = '#bbf7d0';
|
let gradeBorder = '#bbf7d0';
|
||||||
let gradeDesc = 'Sehr gut';
|
let gradeDesc = 'Sehr gut';
|
||||||
|
|
||||||
if (hasGrade) {
|
if (hasGrade) {
|
||||||
const g = Number(hotel.avg_grade);
|
const g = Number(hotel.avg_grade);
|
||||||
if (g > 1.5 && g <= 2.5) { gradeColor = '#059669'; gradeBg = '#ecfdf5'; gradeBorder = '#a7f3d0'; gradeDesc = 'Gut'; }
|
if (g > 1.5 && g <= 2.5) { gradeColor = '#059669'; gradeBg = '#ecfdf5'; gradeBorder = '#a7f3d0'; gradeDesc = 'Gut'; }
|
||||||
@@ -916,7 +921,6 @@ export default function TravelDashboard() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => openRatingModal(hotel)}
|
onClick={() => openRatingModal(hotel)}
|
||||||
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.35rem 0.75rem', borderRadius: '6px', fontSize: '0.78rem', fontWeight: '700', cursor: 'pointer' }}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* PREIS-BOX MIT BESTPREIS-VERGLEICH & GENIUS */}
|
{/* PREIS-BOX MIT GETRENNTEM GENIUS- & NORMALPREIS-VERGLEICH */}
|
||||||
{totalPrice !== null ? (
|
{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' }}>
|
<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' }}>
|
||||||
|
|
||||||
{hotel.latest_genius_price ? (
|
{/* 1. Normalpreis vs. Normaler Bestpreis */}
|
||||||
<>
|
<div style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '0.45rem 0.6rem' }}>
|
||||||
{/* Durchgestrichener Normalpreis bei aktivem Genius */}
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.74rem', color: '#64748b', marginBottom: '0.2rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.76rem', color: '#64748b' }}>
|
<span style={{ fontWeight: '700' }}>🏷️ REGULÄR</span>
|
||||||
<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>
|
|
||||||
|
|
||||||
{hotel.best_price && totalPrice > Number(hotel.best_price) ? (
|
{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' }}>
|
<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')} €
|
▲ +{(totalPrice - Number(hotel.best_price)).toLocaleString('de-DE')} € vs. Best
|
||||||
</span>
|
</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
|
🔥 Tiefstpreis
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontWeight: '800', color: '#0f172a', fontSize: '0.92rem' }}>
|
||||||
{/* Allzeit-Bestpreis Zeile */}
|
{totalPrice.toLocaleString('de-DE')} €
|
||||||
{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')} €
|
|
||||||
</span>
|
</span>
|
||||||
{hotel.best_price_per_night && (
|
<span style={{ color: '#64748b', fontSize: '0.74rem' }}>
|
||||||
<span style={{ color: '#64748b', fontSize: '0.72rem' }}>
|
(🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.)
|
||||||
(🌙 {Math.round(Number(hotel.best_price_per_night)).toLocaleString('de-DE')} € / N.)
|
</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>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ marginBottom: '0.8rem', fontSize: '0.8rem', color: '#6b7280', fontStyle: 'italic' }}>
|
<div style={{ marginBottom: '0.8rem', fontSize: '0.8rem', color: '#6b7280', fontStyle: 'italic' }}>
|
||||||
@@ -1064,7 +1060,6 @@ export default function TravelDashboard() {
|
|||||||
➕ Zuordnen
|
➕ Zuordnen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||||||
{hotelSights.length === 0 ? (
|
{hotelSights.length === 0 ? (
|
||||||
<span style={{ fontSize: '0.74rem', color: '#94a3b8', fontStyle: 'italic' }}>Keine Ausflugsziele zugeordnet.</span>
|
<span style={{ fontSize: '0.74rem', color: '#94a3b8', fontStyle: 'italic' }}>Keine Ausflugsziele zugeordnet.</span>
|
||||||
@@ -1138,7 +1133,6 @@ export default function TravelDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<button onClick={() => setRatingHotel(null)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
<button onClick={() => setRatingHotel(null)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSaveRating} style={{ padding: '1.3rem 1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1.1rem' }}>
|
<form onSubmit={handleSaveRating} style={{ padding: '1.3rem 1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1.1rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.4rem' }}>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.3rem' }}>
|
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.3rem' }}>
|
||||||
2. Schulnote (1 = Sehr gut bis 6 = Ungenügend) *
|
2. Schulnote (1 = Sehr gut bis 6 = Ungenügend) *
|
||||||
@@ -1215,7 +1208,6 @@ export default function TravelDashboard() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.3rem' }}>
|
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.3rem' }}>
|
||||||
3. Notiz / Begründung (optional)
|
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' }}
|
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db', fontSize: '0.85rem' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.6rem', justifyContent: 'flex-end', marginTop: '0.4rem' }}>
|
<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' }}>
|
<button type="button" onClick={() => setRatingHotel(null)} style={{ padding: '0.55rem 1rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
@@ -1237,7 +1228,6 @@ export default function TravelDashboard() {
|
|||||||
{isSubmittingRating ? 'Speichere...' : '💾 Note speichern'}
|
{isSubmittingRating ? 'Speichere...' : '💾 Note speichern'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ borderTop: '1px solid #e2e8f0', paddingTop: '0.8rem', marginTop: '0.4rem' }}>
|
<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' }}>
|
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: '800', color: '#1e293b' }}>
|
||||||
Bisherige Bewertungen ({ratingHotel.ratings_json?.length || 0}):
|
Bisherige Bewertungen ({ratingHotel.ratings_json?.length || 0}):
|
||||||
@@ -1276,7 +1266,6 @@ export default function TravelDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowMembersModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
<button onClick={() => setShowMembersModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '1.3rem 1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
<div style={{ padding: '1.3rem 1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
<form onSubmit={handleAddMember} style={{ display: 'flex', gap: '0.5rem' }}>
|
<form onSubmit={handleAddMember} style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
<input
|
<input
|
||||||
@@ -1295,9 +1284,7 @@ export default function TravelDashboard() {
|
|||||||
➕ Hinzufügen
|
➕ Hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div style={{ fontSize: '0.8rem', fontWeight: '700', color: '#475569' }}>Aktuelle Mitreisende:</div>
|
<div style={{ fontSize: '0.8rem', fontWeight: '700', color: '#475569' }}>Aktuelle Mitreisende:</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||||
{tripMembers.length === 0 ? (
|
{tripMembers.length === 0 ? (
|
||||||
<p style={{ margin: 0, fontSize: '0.8rem', color: '#94a3b8', fontStyle: 'italic', textAlign: 'center', padding: '1rem 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>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '0.8rem 1.5rem', borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'flex-end' }}>
|
<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' }}>
|
<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
|
Fertig
|
||||||
@@ -1333,68 +1319,189 @@ export default function TravelDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* MODAL 3: PREISHISTORIE */}
|
{/* MODAL 3: PREISHISTORIE (GENIUS & REGULÄR) */}
|
||||||
{selectedPriceHotel && (
|
{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={{ 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={{ 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: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.2rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: 0, fontSize: '1.3rem' }}>📈 Preishistorie: {selectedPriceHotel.name}</h3>
|
<h3 style={{ margin: 0, fontSize: '1.3rem', fontWeight: '800', color: '#0f172a' }}>📈 Preishistorie: {selectedPriceHotel.name}</h3>
|
||||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '0.85rem' }}>Entwicklung aller erfassten Preis-Scans</p>
|
<p style={{ margin: '0.2rem 0 0 0', color: '#6b7280', fontSize: '0.85rem' }}>Entwicklung der Scans & Genius-Vergleich</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setSelectedPriceHotel(null)} style={{ background: '#f3f4f6', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
<button onClick={() => setSelectedPriceHotel(null)} style={{ background: '#f3f4f6', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingPrices ? (
|
{loadingPrices ? (
|
||||||
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Lade Preishistorie...</p>
|
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Lade Preishistorie...</p>
|
||||||
) : priceHistory.length === 0 ? (
|
) : priceHistory.length === 0 ? (
|
||||||
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Noch keine Preishistorie vorhanden.</p>
|
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Noch keine Preishistorie vorhanden.</p>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem', marginBottom: '1.5rem', textAlign: 'center' }}>
|
{/* DUAL STATS CARDS */}
|
||||||
<div style={{ background: '#f0fdf4', padding: '0.8rem', borderRadius: '8px', border: '1px solid #bbf7d0' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: hasAnyGeniusPrice ? '1fr 1fr' : '1fr', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||||
<small style={{ color: '#166534', fontWeight: 'bold' }}>Tiefstpreis</small>
|
|
||||||
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#15803d' }}>{minPrice} €</div>
|
{/* Regulärer Preis */}
|
||||||
</div>
|
<div style={{ background: '#f8fafc', padding: '0.9rem', borderRadius: '10px', border: '1px solid #cbd5e1' }}>
|
||||||
<div style={{ background: '#eff6ff', padding: '0.8rem', borderRadius: '8px', border: '1px solid #bfdbfe' }}>
|
<div style={{ fontSize: '0.78rem', fontWeight: '800', color: '#475569', textTransform: 'uppercase', marginBottom: '0.5rem' }}>
|
||||||
<small style={{ color: '#1e40af', fontWeight: 'bold' }}>Aktuellster Preis</small>
|
🏷️ Regulärer Preis
|
||||||
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#1d4ed8' }}>{currentPrice} €</div>
|
</div>
|
||||||
</div>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.4rem', textAlign: 'center' }}>
|
||||||
<div style={{ background: '#fef2f2', padding: '0.8rem', borderRadius: '8px', border: '1px solid #fecaca' }}>
|
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
|
||||||
<small style={{ color: '#991b1b', fontWeight: 'bold' }}>Höchstpreis</small>
|
<small style={{ color: '#166534', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Tiefst</small>
|
||||||
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#b91c1c' }}>{maxPrice} €</div>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{/* DUAL SVG CHART */}
|
||||||
{priceHistory.length > 1 && (
|
{priceHistory.length > 1 && (
|
||||||
<div style={{ background: '#fafafa', padding: '1rem', borderRadius: '8px', border: '1px solid #f3f4f6', marginBottom: '1.5rem' }}>
|
<div style={{ background: '#fafafa', padding: '1rem', borderRadius: '10px', border: '1px solid #e2e8f0', marginBottom: '1.5rem' }}>
|
||||||
<svg viewBox="0 0 500 150" style={{ width: '100%', height: '160px', overflow: 'visible' }}>
|
<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="20" x2="500" y2="20" stroke="#e5e7eb" strokeDasharray="4" />
|
||||||
<line x1="0" y1="130" x2="500" y2="130" stroke="#e5e7eb" strokeDasharray="4" />
|
<line x1="0" y1="130" x2="500" y2="130" stroke="#e5e7eb" strokeDasharray="4" />
|
||||||
{(() => {
|
{(() => {
|
||||||
const range = maxPrice - minPrice || 1;
|
const allValues = [
|
||||||
const points = priceHistory.map((p, idx) => {
|
...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 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}`;
|
return `${x},${y}`;
|
||||||
}).join(' ');
|
}).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 (
|
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) => {
|
{priceHistory.map((p, idx) => {
|
||||||
const x = (idx / (priceHistory.length - 1)) * 500;
|
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 (
|
return (
|
||||||
<g key={p.id}>
|
<g key={`norm-${p.id || idx}`}>
|
||||||
<circle cx={x} cy={y} r="5" fill="#2563eb" stroke="#ffffff" strokeWidth="2" />
|
<circle cx={x} cy={y} r="4" fill="#2563eb" stroke="#ffffff" strokeWidth="1.5" />
|
||||||
<text x={x} y={y - 10} fontSize="10" textAnchor="middle" fill="#374151" fontWeight="bold">{p.price}€</text>
|
<text x={x} y={y - 8} fontSize="9.5" textAnchor="middle" fill="#1e40af" fontWeight="bold">
|
||||||
|
{p.price}€
|
||||||
|
</text>
|
||||||
</g>
|
</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>
|
</svg>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1602,7 +1709,6 @@ export default function TravelDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user