feat: add trip selection modal, auto-scrape on hotel creation, on-demand stammdaten refresh, and update README
This commit is contained in:
63
README.md
63
README.md
@@ -0,0 +1,63 @@
|
||||
# 📍 Travel Dashboard & Hotel Ranking
|
||||
|
||||
A Next.js web application for comparing hotels, planning travel stops, tracking historical Booking.com prices (including Genius discounts), and rating accommodations with friends using German school grades (1–6).
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
- **Trip Management & Hotel Assignment:** Organize hotels across multiple trips or unassigned stops. When adding a hotel, you can directly choose which trip to assign it to.
|
||||
- **Automated Metadata & Price Scraping:** Adding a Booking.com URL automatically triggers a background BullMQ job that fetches the hotel name, star rating, address, hero image, room size, and prices.
|
||||
- **On-Demand Stammdaten Refresh:** Each hotel card features a **"🔄 Stammdaten"** button to perform a fast re-scan of metadata and prices on demand.
|
||||
- **AI Review Summaries:** Integrates with local LLMs (via Ollama/n8n) to summarize up to 80+ guest reviews into categorized highlights (Positive, Neutral, Negative, Bathroom inspection).
|
||||
- **Bathroom Photos & Google Maps Inspection:** Upload manual bathroom photos or inspect Google Maps imagery to assess room cleanliness.
|
||||
- **Price History & Genius Tracking:** Records price changes over time with automatic detection of Genius discount badges (-10%).
|
||||
- **Sights & Attractions:** Manage points of interest and calculate distances/durations from each hotel.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture & Stack
|
||||
|
||||
- **Frontend & API:** Next.js (App Router, TypeScript, React)
|
||||
- **Database:** PostgreSQL
|
||||
- **Task Queue:** BullMQ / Redis (shared with `scraper-service`)
|
||||
- **Containerization:** Docker & Docker Compose
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose
|
||||
- Running PostgreSQL & Redis instances
|
||||
|
||||
### Environment Variables (`.env`)
|
||||
```env
|
||||
DATABASE_URL=postgresql://user:password@host:5432/travel_db
|
||||
REDIS_HOST=172.17.0.1
|
||||
REDIS_PORT=6379
|
||||
APP_WEBHOOK_URL=https://n8n.your-domain.de/webhook/scrape-reviews
|
||||
```
|
||||
|
||||
### Running with Docker Compose
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
The app will be accessible at `http://localhost:3002`.
|
||||
|
||||
---
|
||||
|
||||
## 📡 API Endpoints
|
||||
|
||||
- `GET /api/hotels` — List all hotels, current prices, and ratings.
|
||||
- `POST /api/hotels` — Create a new hotel and enqueue background metadata scraping.
|
||||
- `PATCH /api/hotels/[id]` — Update hotel properties (e.g. active/parked status).
|
||||
- `POST /api/hotels/[id]/scan` — Trigger on-demand scraping:
|
||||
- `?mode=stammdaten` (or `mode=quick`): Fast scan for name, address, rating, price, and images.
|
||||
- Default: Full review scan with n8n/LLM review summary.
|
||||
- `?debug=true`: Generates raw HTML and screenshot dumps in `scraper-service`.
|
||||
- `POST /api/jobs/trigger-daily-check` — Enqueue batch price updates for all active hotels.
|
||||
- `POST /api/webhooks/scraper` — Webhook receiver for scraped metadata from `scraper-service`.
|
||||
- `GET /api/trips` / `POST /api/trips` — Manage trip entities.
|
||||
- `GET /api/attractions` / `POST /api/attractions` — Manage central sights and hotel assignments.
|
||||
|
||||
|
||||
@@ -36,26 +36,37 @@ export async function POST(
|
||||
|
||||
const hotel = res.rows[0];
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const mode = searchParams.get('mode');
|
||||
const isQuick = mode === 'stammdaten' || mode === 'quick';
|
||||
const isDebug = searchParams.get('debug') === 'true' || searchParams.get('debug') === '1';
|
||||
|
||||
let n8nWebhookUrl: string | null = null;
|
||||
if (!isQuick) {
|
||||
const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews';
|
||||
const n8nWebhookUrl = n8nBaseUrl.includes('?')
|
||||
n8nWebhookUrl = n8nBaseUrl.includes('?')
|
||||
? `${n8nBaseUrl}&hotelId=${hotel.id}`
|
||||
: `${n8nBaseUrl}?hotelId=${hotel.id}`;
|
||||
}
|
||||
|
||||
const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${hotel.id}`;
|
||||
|
||||
// Nur DIESEN einzelnen Job in die Queue schreiben
|
||||
// Job in die Queue schreiben
|
||||
await scrapeQueue.add('scrape-hotel', {
|
||||
hotelId: hotel.id,
|
||||
url: hotel.booking_url,
|
||||
appWebhookUrl,
|
||||
webhookUrl: n8nWebhookUrl,
|
||||
debug: isDebug,
|
||||
});
|
||||
|
||||
await scrapeQueue.close();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Scrape-Job für "${hotel.name}" erfolgreich gestartet.`,
|
||||
message: isQuick
|
||||
? `Stammdaten-Scan für "${hotel.name}" erfolgreich gestartet.`
|
||||
: `KI-Review-Job für "${hotel.name}" erfolgreich gestartet.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Fehler beim Starten des Einzel-Scrapes:', error);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
@@ -88,7 +90,37 @@ export async function POST(req: NextRequest) {
|
||||
RETURNING *`,
|
||||
[name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null]
|
||||
);
|
||||
return NextResponse.json({ success: true, hotel: res.rows[0] });
|
||||
|
||||
const newHotel = res.rows[0];
|
||||
|
||||
// Trigger background scraper job to fetch hotel details (name, rating, price, images, address, etc.)
|
||||
try {
|
||||
const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
|
||||
if (bullmq && bullmq.Queue) {
|
||||
const { Queue } = bullmq;
|
||||
const connection = {
|
||||
host: process.env.REDIS_HOST || '172.17.0.1',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
connectTimeout: 5000,
|
||||
maxRetriesPerRequest: 3,
|
||||
};
|
||||
const scrapeQueue = new Queue('scrape-jobs', { connection });
|
||||
const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${newHotel.id}`;
|
||||
|
||||
await scrapeQueue.add('scrape-hotel', {
|
||||
hotelId: newHotel.id,
|
||||
url: newHotel.booking_url,
|
||||
appWebhookUrl,
|
||||
webhookUrl: null,
|
||||
});
|
||||
|
||||
await scrapeQueue.close();
|
||||
}
|
||||
} catch (queueErr: any) {
|
||||
console.error('Fehler beim Einreihen des Scrape-Jobs nach Hotel-Erstellung:', queueErr.message);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, hotel: newHotel });
|
||||
} catch (error: any) {
|
||||
console.error('Fehler in POST /api/hotels:', error.message);
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
|
||||
768
src/app/page.tsx
768
src/app/page.tsx
@@ -106,9 +106,11 @@ export default function TravelDashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scanningAllPrices, setScanningAllPrices] = useState(false);
|
||||
const [scanningHotelId, setScanningHotelId] = useState<string | null>(null);
|
||||
const [refreshingMetadataHotelId, setRefreshingMetadataHotelId] = useState<string | null>(null);
|
||||
|
||||
// Preishistorie Modal
|
||||
const [selectedPriceHotel, setSelectedPriceHotel] = useState<Hotel | null>(null);
|
||||
// Detail Modal: Preishistorie & KI-Reviews
|
||||
const [selectedDetailHotel, setSelectedDetailHotel] = useState<Hotel | null>(null);
|
||||
const [detailActiveTab, setDetailActiveTab] = useState<'reviews' | 'history'>('reviews');
|
||||
const [priceHistory, setPriceHistory] = useState<PriceEntry[]>([]);
|
||||
const [loadingPrices, setLoadingPrices] = useState(false);
|
||||
|
||||
@@ -124,6 +126,7 @@ export default function TravelDashboard() {
|
||||
const [showAddHotelModal, setShowAddHotelModal] = useState(false);
|
||||
const [newBookingUrl, setNewBookingUrl] = useState('');
|
||||
const [newHotelName, setNewHotelName] = useState('');
|
||||
const [newHotelTripId, setNewHotelTripId] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showParkedModal, setShowParkedModal] = useState(false);
|
||||
|
||||
@@ -259,6 +262,59 @@ export default function TravelDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// HANDLERS: DETAIL MODAL (HISTORIE & REVIEWS)
|
||||
// ==========================================
|
||||
const openHotelDetails = async (hotel: Hotel, initialTab: 'reviews' | 'history' = 'reviews') => {
|
||||
setSelectedDetailHotel(hotel);
|
||||
setDetailActiveTab(initialTab);
|
||||
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 scanSingleHotel = async (hotel: Hotel) => {
|
||||
const reviewCountDisplay = hotel.review_summary_count || hotel.review_count || 80;
|
||||
const isConfirmed = confirm(
|
||||
`Möchtest du die KI-Review-Analyse für "${hotel.name}" wirklich neu starten?\n\nHinweis: Dabei werden bis zu ${reviewCountDisplay} Gästebewertungen gescrapt und per n8n/Ollama neu zusammengefasst.`
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
|
||||
setScanningHotelId(hotel.id);
|
||||
try {
|
||||
const res = await fetch(`/api/hotels/${hotel.id}/scan`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
alert(data.message || (data.success ? 'Scrape-Job erfolgreich gestartet!' : 'Fehler beim Starten'));
|
||||
setTimeout(loadData, 5000);
|
||||
} catch (err: any) {
|
||||
alert('Fehler beim Ausführen: ' + err.message);
|
||||
} finally {
|
||||
setScanningHotelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshHotelMetadata = async (hotel: Hotel) => {
|
||||
setRefreshingMetadataHotelId(hotel.id);
|
||||
try {
|
||||
const res = await fetch(`/api/hotels/${hotel.id}/scan?mode=stammdaten`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
alert(data.message || (data.success ? 'Stammdaten-Scan gestartet!' : 'Fehler beim Starten'));
|
||||
setTimeout(loadData, 4000);
|
||||
} catch (err: any) {
|
||||
alert('Fehler beim Ausführen: ' + err.message);
|
||||
} finally {
|
||||
setRefreshingMetadataHotelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// HANDLERS: MANUELLE BAD-BILDER
|
||||
// ==========================================
|
||||
@@ -276,7 +332,6 @@ export default function TravelDashboard() {
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setManualPhotoUrl('');
|
||||
// Lokalen Zustand aktualisieren
|
||||
const updatedImages = data.bathroom_images;
|
||||
setSelectedBathroomHotel({ ...selectedBathroomHotel, bathroom_images: updatedImages });
|
||||
setHotels(hotels.map(h => h.id === selectedBathroomHotel.id ? { ...h, bathroom_images: updatedImages } : h));
|
||||
@@ -433,21 +488,13 @@ export default function TravelDashboard() {
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// HANDLERS: HOTELS & SCANS
|
||||
// HANDLERS: HOTELS & 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 openAddHotelModal = () => {
|
||||
setNewHotelTripId(activeTripId !== 'ALL' ? activeTripId : (trips[0] ? String(trips[0].id) : ''));
|
||||
setNewBookingUrl('');
|
||||
setNewHotelName('');
|
||||
setShowAddHotelModal(true);
|
||||
};
|
||||
|
||||
const handleAddHotel = async (e: React.FormEvent) => {
|
||||
@@ -455,13 +502,13 @@ export default function TravelDashboard() {
|
||||
if (!newBookingUrl) return alert('Bitte URL eingeben');
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const targetTripId = activeTripId === 'ALL' ? (trips[0]?.id || null) : activeTripId;
|
||||
const targetTripId = newHotelTripId || null;
|
||||
const res = await fetch('/api/hotels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: newHotelName || 'Neues Hotel',
|
||||
booking_url: newBookingUrl,
|
||||
name: newHotelName.trim() || null,
|
||||
booking_url: newBookingUrl.trim(),
|
||||
trip_id: targetTripId
|
||||
})
|
||||
});
|
||||
@@ -471,6 +518,8 @@ export default function TravelDashboard() {
|
||||
setNewHotelName('');
|
||||
setShowAddHotelModal(false);
|
||||
loadData();
|
||||
} else {
|
||||
alert('Fehler: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert('Fehler: ' + err.message);
|
||||
@@ -592,18 +641,6 @@ export default function TravelDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const scanSingleHotel = async (hotelId: string) => {
|
||||
setScanningHotelId(hotelId);
|
||||
try {
|
||||
const res = await fetch(`/api/hotels/${hotelId}/scan`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
alert(data.message || data.error);
|
||||
setTimeout(loadData, 4000);
|
||||
} finally {
|
||||
setScanningHotelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// CLUSTERING & FILTER
|
||||
// ==========================================
|
||||
@@ -667,11 +704,11 @@ export default function TravelDashboard() {
|
||||
📍 Reise-Ablauf & Freunde-Ranking
|
||||
</h1>
|
||||
<p style={{ color: '#64748b', margin: 0, fontSize: '0.95rem' }}>
|
||||
Hotelvergleich nach Schulnoten (1–6), Bad-Inspektion, Genius-Tracking & Routen
|
||||
Hotelvergleich nach Schulnoten (1–6), Bad-Inspektion, Genius-Tracking & KI-Reviews
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'center', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => setShowAddHotelModal(true)}
|
||||
onClick={openAddHotelModal}
|
||||
style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.65rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer' }}
|
||||
>
|
||||
➕ Neues Hotel hinzufügen
|
||||
@@ -826,7 +863,7 @@ export default function TravelDashboard() {
|
||||
) : clusteredStops.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '3rem', background: '#f9fafb', borderRadius: '12px', border: '1px dashed #d1d5db' }}>
|
||||
<p style={{ color: '#6b7280', marginBottom: '1rem' }}>Keine aktiven Hotels für diese Auswahl gefunden.</p>
|
||||
<button onClick={() => setShowAddHotelModal(true)} style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.6rem 1.2rem', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}>
|
||||
<button onClick={openAddHotelModal} style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.6rem 1.2rem', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}>
|
||||
Erstes Hotel hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
@@ -973,6 +1010,9 @@ export default function TravelDashboard() {
|
||||
<div style={{ fontSize: '0.75rem', color: '#475569', display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span>⭐ <strong>{hotel.rating || 'N/A'}</strong>/10 ({hotel.review_count || 0})</span>
|
||||
{hotel.room_size_m2 && <span>· 📐 {hotel.room_size_m2} m²</span>}
|
||||
<span style={{ background: '#f1f5f9', color: '#1e293b', padding: '0.1rem 0.4rem', borderRadius: '4px', fontWeight: '700' }}>
|
||||
👥 {persons} {persons === 1 ? 'Person' : 'Personen'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1056,7 +1096,7 @@ export default function TravelDashboard() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* PREIS-BOX */}
|
||||
{/* PREIS-BOX MIT PERSONEN-AUFSCHLÜSSELUNG */}
|
||||
{totalPrice !== null ? (
|
||||
<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' }}>
|
||||
|
||||
@@ -1074,12 +1114,12 @@ export default function TravelDashboard() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
<span style={{ fontWeight: '800', color: '#0f172a', fontSize: '0.92rem' }}>
|
||||
{totalPrice.toLocaleString('de-DE')} €
|
||||
</span>
|
||||
<span style={{ color: '#64748b', fontSize: '0.74rem' }}>
|
||||
(🌙 {pricePerNight?.toLocaleString('de-DE')} € / N.)
|
||||
(🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P. · 👥 {persons} Pers.)
|
||||
</span>
|
||||
</div>
|
||||
{hotel.best_price && (
|
||||
@@ -1106,12 +1146,12 @@ export default function TravelDashboard() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||||
<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.)
|
||||
(🌙 {Math.round(Number(hotel.latest_genius_price) / hotelNights).toLocaleString('de-DE')} € / N. · 👤 {Math.round(Number(hotel.latest_genius_price) / (hotelNights * persons)).toLocaleString('de-DE')} € / P. · 👥 {persons} Pers.)
|
||||
</span>
|
||||
</div>
|
||||
{hotel.best_genius_price && (
|
||||
@@ -1170,25 +1210,39 @@ export default function TravelDashboard() {
|
||||
</div>
|
||||
|
||||
{/* Card Actions Footer */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.8rem', paddingTop: '0.6rem', borderTop: '1px solid #f1f5f9', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginTop: '0.8rem', paddingTop: '0.6rem', borderTop: '1px solid #f1f5f9', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => openRatingModal(hotel)}
|
||||
style={{ background: '#f1f5f9', border: '1px solid #cbd5e1', padding: '0.35rem 0.65rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem', color: '#334155' }}
|
||||
style={{ background: '#f1f5f9', border: '1px solid #cbd5e1', padding: '0.35rem 0.55rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem', color: '#334155' }}
|
||||
>
|
||||
💬 Stimmen ({hotel.ratings_json?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openPriceHistoryModal(hotel)}
|
||||
style={{ background: '#0284c7', color: '#ffffff', border: 'none', padding: '0.35rem 0.65rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', fontSize: '0.75rem' }}
|
||||
onClick={() => openHotelDetails(hotel, 'reviews')}
|
||||
style={{ background: '#f8fafc', border: '1px solid #cbd5e1', color: '#334155', padding: '0.35rem 0.55rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem' }}
|
||||
>
|
||||
🤖 Reviews
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openHotelDetails(hotel, 'history')}
|
||||
style={{ background: '#0284c7', color: '#ffffff', border: 'none', padding: '0.35rem 0.55rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem' }}
|
||||
>
|
||||
📈 Preishistorie
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scanSingleHotel(hotel.id)}
|
||||
disabled={scanningHotelId === hotel.id || !hotel.is_active}
|
||||
style={{ background: '#8b5cf6', color: '#ffffff', border: 'none', padding: '0.35rem 0.65rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', fontSize: '0.75rem', opacity: (!hotel.is_active || scanningHotelId === hotel.id) ? 0.6 : 1 }}
|
||||
onClick={() => refreshHotelMetadata(hotel)}
|
||||
disabled={refreshingMetadataHotelId === hotel.id || !hotel.is_active}
|
||||
style={{ background: '#10b981', color: '#ffffff', border: 'none', padding: '0.35rem 0.6rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem', opacity: (!hotel.is_active || refreshingMetadataHotelId === hotel.id) ? 0.6 : 1 }}
|
||||
title="Aktualisiert Name, Adresse, Sterne, Fotos und Preis (schnell)"
|
||||
>
|
||||
{scanningHotelId === hotel.id ? '🔄 Scanne...' : '🤖 KI-Reviews'}
|
||||
{refreshingMetadataHotelId === hotel.id ? '🔄 Lädt...' : '🔄 Stammdaten'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scanSingleHotel(hotel)}
|
||||
disabled={scanningHotelId === hotel.id || !hotel.is_active}
|
||||
style={{ background: '#8b5cf6', color: '#ffffff', border: 'none', padding: '0.35rem 0.6rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem', opacity: (!hotel.is_active || scanningHotelId === hotel.id) ? 0.6 : 1 }}
|
||||
>
|
||||
{scanningHotelId === hotel.id ? '🔄 Scanne...' : '🤖 KI-Review neu erfassen'}
|
||||
</button>
|
||||
<a href={hotel.booking_url} target="_blank" rel="noopener noreferrer" style={{ color: '#2563eb', textDecoration: 'none', fontSize: '0.78rem', marginLeft: 'auto', fontWeight: '700' }}>
|
||||
Booking ↗
|
||||
@@ -1204,6 +1258,399 @@ export default function TravelDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ======================================================== */}
|
||||
{/* MODAL 3: KOMBINIERTES DETAIL-MODAL (HISTORIE & REVIEWS) */}
|
||||
{/* ======================================================== */}
|
||||
{selectedDetailHotel && (
|
||||
<div
|
||||
onClick={() => setSelectedDetailHotel(null)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(15, 23, 42, 0.75)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: '1rem'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: '16px',
|
||||
maxWidth: '880px',
|
||||
width: '100%',
|
||||
maxHeight: '92vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.35)',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#f8fafc' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: '800', color: '#0f172a' }}>
|
||||
{selectedDetailHotel.name}
|
||||
</h3>
|
||||
<a href={selectedDetailHotel.booking_url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.82rem', color: '#2563eb', textDecoration: 'none', fontWeight: '700' }}>
|
||||
🔗 Auf Booking.com öffnen ↗
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedDetailHotel(null)}
|
||||
style={{ background: '#e2e8f0', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation mit dynamischer Review-Anzahl */}
|
||||
<div style={{ display: 'flex', gap: '1rem', borderBottom: '2px solid #e2e8f0', padding: '0 1.5rem', background: '#f8fafc' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailActiveTab('reviews')}
|
||||
style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: detailActiveTab === 'reviews' ? '800' : '600',
|
||||
color: detailActiveTab === 'reviews' ? '#2563eb' : '#64748b',
|
||||
cursor: 'pointer',
|
||||
borderBottom: detailActiveTab === 'reviews' ? '3px solid #2563eb' : '3px solid transparent',
|
||||
marginBottom: '-2px'
|
||||
}}
|
||||
>
|
||||
🤖 KI-Bewertungsanalyse ({selectedDetailHotel.review_summary_count || selectedDetailHotel.review_count || 0} Reviews)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailActiveTab('history')}
|
||||
style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: detailActiveTab === 'history' ? '800' : '600',
|
||||
color: detailActiveTab === 'history' ? '#2563eb' : '#64748b',
|
||||
cursor: 'pointer',
|
||||
borderBottom: detailActiveTab === 'history' ? '3px solid #2563eb' : '3px solid transparent',
|
||||
marginBottom: '-2px'
|
||||
}}
|
||||
>
|
||||
📈 Preishistorie & Genius-Tracking
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div style={{ padding: '1.5rem', overflowY: 'auto' }}>
|
||||
|
||||
{/* TAB 1: KI REVIEWS (VERTIKALE KARTEN UNTEREINANDER) */}
|
||||
{detailActiveTab === 'reviews' && (
|
||||
<div>
|
||||
{!selectedDetailHotel.review_summary_positive ? (
|
||||
<div style={{ textAlign: 'center', padding: '2.5rem 1rem', background: '#f8fafc', borderRadius: '12px', border: '1px dashed #cbd5e1' }}>
|
||||
<span style={{ fontSize: '2.5rem', display: 'block', marginBottom: '0.5rem' }}>🤖</span>
|
||||
<p style={{ margin: '0 0 0.5rem 0', fontWeight: '700', color: '#1e293b' }}>
|
||||
Noch keine KI-Bewertungsanalyse vorhanden.
|
||||
</p>
|
||||
<p style={{ margin: '0 0 1.2rem 0', fontSize: '0.85rem', color: '#64748b' }}>
|
||||
Klicke auf den Button, um bis zu {selectedDetailHotel.review_summary_count || selectedDetailHotel.review_count || 80} Gästebewertungen über Playwright & Ollama auswerten zu lassen.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => scanSingleHotel(selectedDetailHotel)}
|
||||
style={{ background: '#8b5cf6', color: '#fff', border: 'none', padding: '0.6rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', fontSize: '0.85rem' }}
|
||||
>
|
||||
🤖 KI-Review neu erfassen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.2rem' }}>
|
||||
|
||||
{/* 1. Bestnoten */}
|
||||
<div style={{ background: 'linear-gradient(to right, #f0fdf4 0%, #ffffff 20%)', border: '1px solid #e2e8f0', borderLeft: '6px solid #16a34a', borderRadius: '12px', padding: '1.2rem 1.4rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.6rem' }}>
|
||||
<h4 style={{ margin: 0, color: '#15803d', fontSize: '1rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span>💚</span> Bestnoten & Highlights (Positiv)
|
||||
</h4>
|
||||
<span style={{ background: '#dcfce7', color: '#15803d', fontSize: '0.72rem', fontWeight: '700', padding: '0.2rem 0.55rem', borderRadius: '9999px' }}>
|
||||
Großes Lob
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-line', fontSize: '0.9rem', color: '#334155', lineHeight: 1.6 }}>
|
||||
{selectedDetailHotel.review_summary_positive}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Passabel */}
|
||||
<div style={{ background: 'linear-gradient(to right, #fefce8 0%, #ffffff 20%)', border: '1px solid #e2e8f0', borderLeft: '6px solid #eab308', borderRadius: '12px', padding: '1.2rem 1.4rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.6rem' }}>
|
||||
<h4 style={{ margin: 0, color: '#a16207', fontSize: '1rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span>🟡</span> Passabel & Neutrale Hinweise
|
||||
</h4>
|
||||
<span style={{ background: '#fef9c3', color: '#854d0e', fontSize: '0.72rem', fontWeight: '700', padding: '0.2rem 0.55rem', borderRadius: '9999px' }}>
|
||||
Hinweise
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-line', fontSize: '0.9rem', color: '#334155', lineHeight: 1.6 }}>
|
||||
{selectedDetailHotel.review_summary_passable}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Schlechteste / Mängel */}
|
||||
<div style={{ background: 'linear-gradient(to right, #fef2f2 0%, #ffffff 20%)', border: '1px solid #e2e8f0', borderLeft: '6px solid #dc2626', borderRadius: '12px', padding: '1.2rem 1.4rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.6rem' }}>
|
||||
<h4 style={{ margin: 0, color: '#b91c1c', fontSize: '1rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span>🔴</span> Schlechteste Aspekte & Mängel (Kritik)
|
||||
</h4>
|
||||
<span style={{ background: '#fee2e2', color: '#991b1b', fontSize: '0.72rem', fontWeight: '700', padding: '0.2rem 0.55rem', borderRadius: '9999px' }}>
|
||||
Zu beachten
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-line', fontSize: '0.9rem', color: '#334155', lineHeight: 1.6 }}>
|
||||
{selectedDetailHotel.review_summary_negative}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Bad & WC Fokus */}
|
||||
<div style={{ background: 'linear-gradient(to right, #f0f9ff 0%, #ffffff 20%)', border: '1px solid #e2e8f0', borderLeft: '6px solid #0284c7', borderRadius: '12px', padding: '1.2rem 1.4rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.6rem' }}>
|
||||
<h4 style={{ margin: 0, color: '#0369a1', fontSize: '1rem', fontWeight: '800', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span>🚽</span> Bad & WC Fokus
|
||||
</h4>
|
||||
<span style={{ background: '#e0f2fe', color: '#0369a1', fontSize: '0.72rem', fontWeight: '700', padding: '0.2rem 0.55rem', borderRadius: '9999px' }}>
|
||||
Inspektion
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-line', fontSize: '0.9rem', color: '#334155', lineHeight: 1.6 }}>
|
||||
{selectedDetailHotel.review_summary_bathroom || 'Keine spezifischen Anmerkungen zum Bad/WC in den Kommentaren gefunden.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedDetailHotel.reviews_updated_at && (
|
||||
<div style={{ textAlign: 'right', color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.3rem' }}>
|
||||
Zuletzt analysiert: {new Date(selectedDetailHotel.reviews_updated_at).toLocaleString('de-DE')} (via n8n & Ollama)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: PREISHISTORIE (MIT WIEDERHERGESTELLTEM 2-KURVEN-CHART) */}
|
||||
{detailActiveTab === 'history' && (
|
||||
<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>
|
||||
{/* DUAL STATS CARDS */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: hasAnyGeniusPrice ? '1fr 1fr' : '1fr', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
|
||||
{/* 2-KURVEN SVG-CHART */}
|
||||
{priceHistory.length > 1 && (
|
||||
<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 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 - ((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 */}
|
||||
<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 - ((Number(p.price) - chartMin) / range) * 110;
|
||||
return (
|
||||
<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: '240px', overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: '8px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f8fafc', color: '#475569', textAlign: 'left', borderBottom: '1px solid #e2e8f0' }}>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Datum / Uhrzeit</th>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Regulär</th>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Genius (-10%)</th>
|
||||
<th style={{ padding: '0.55rem 0.75rem' }}>Pro Nacht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{priceHistory.slice().reverse().map((entry, idx) => {
|
||||
const hasGen = entry.genius_price !== null && entry.genius_price !== undefined;
|
||||
return (
|
||||
<tr key={entry.id || idx} style={{ borderBottom: '1px solid #f1f5f9' }}>
|
||||
<td style={{ padding: '0.5rem 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.5rem 0.75rem', fontWeight: '700', color: '#1e293b' }}>
|
||||
{Number(entry.price).toLocaleString('de-DE')} €
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', fontWeight: '800', color: hasGen ? '#003580' : '#94a3b8' }}>
|
||||
{hasGen ? `${Number(entry.genius_price).toLocaleString('de-DE')} €` : '–'}
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: '#475569' }}>
|
||||
{entry.price_per_night ? `${Number(entry.price_per_night).toLocaleString('de-DE')} €` : '–'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div style={{ padding: '1rem 1.5rem', borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#f8fafc', gap: '0.6rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refreshHotelMetadata(selectedDetailHotel)}
|
||||
disabled={refreshingMetadataHotelId === selectedDetailHotel.id || !selectedDetailHotel.is_active}
|
||||
style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.55rem 1.1rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', fontSize: '0.85rem', opacity: (!selectedDetailHotel.is_active || refreshingMetadataHotelId === selectedDetailHotel.id) ? 0.6 : 1 }}
|
||||
>
|
||||
{refreshingMetadataHotelId === selectedDetailHotel.id ? '🔄 Lädt...' : '🔄 Stammdaten aktualisieren'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scanSingleHotel(selectedDetailHotel)}
|
||||
style={{ background: '#8b5cf6', color: '#fff', border: 'none', padding: '0.55rem 1.1rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', fontSize: '0.85rem' }}
|
||||
>
|
||||
🤖 KI-Review neu erfassen
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedDetailHotel(null)}
|
||||
style={{ background: '#2563eb', color: '#ffffff', border: 'none', padding: '0.5rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', fontSize: '0.85rem' }}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ======================================================== */}
|
||||
{/* MODAL: BADEZIMMER & GAST-FOTOS MIT MANUELLEM IMPORT */}
|
||||
{/* ======================================================== */}
|
||||
@@ -1261,7 +1708,7 @@ export default function TravelDashboard() {
|
||||
{/* Modal Body */}
|
||||
<div style={{ padding: '1.5rem', overflowY: 'auto' }}>
|
||||
|
||||
{/* 1. MANUELLER LINK-IMPORT */}
|
||||
{/* MANUELLER LINK-IMPORT */}
|
||||
<form onSubmit={handleAddManualPhoto} style={{ background: '#f0fdf4', border: '1.5px solid #a7f3d0', borderRadius: '12px', padding: '1rem 1.2rem', marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: '800', color: '#065f46', marginBottom: '0.4rem', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span>➕ Google Maps Bild-Link oder Foto-URL einfügen:</span>
|
||||
@@ -1285,18 +1732,6 @@ export default function TravelDashboard() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* KI-ZUSAMMENFASSUNG */}
|
||||
{selectedBathroomHotel.review_summary_bathroom && (
|
||||
<div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1.2rem' }}>
|
||||
<span style={{ fontWeight: '800', fontSize: '0.82rem', color: '#166534', display: 'block', marginBottom: '0.2rem' }}>
|
||||
🤖 KI-Zusammenfassung zu den Bädern:
|
||||
</span>
|
||||
<span style={{ fontSize: '0.85rem', color: '#14532d' }}>
|
||||
{selectedBathroomHotel.review_summary_bathroom}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FOTOGALERIE MIT LÖSCH-FUNKTION */}
|
||||
{(() => {
|
||||
const photos = parseBathroomImages(selectedBathroomHotel.bathroom_images);
|
||||
@@ -1611,195 +2046,6 @@ export default function TravelDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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: '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', 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>
|
||||
{/* 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: '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 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 - ((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 */}
|
||||
<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 - ((Number(p.price) - chartMin) / range) * 110;
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL: NEUE REISE */}
|
||||
{showAddTripModal && (
|
||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
|
||||
@@ -1830,17 +2076,33 @@ export default function TravelDashboard() {
|
||||
<h2 style={{ marginTop: 0, marginBottom: '0.5rem' }}>➕ Neues Hotel hinzufügen</h2>
|
||||
<form onSubmit={handleAddHotel} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Hotel Name (optional)</label>
|
||||
<input type="text" placeholder="z. B. Abad Fort" value={newHotelName} onChange={e => setNewHotelName(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Reise zuordnen</label>
|
||||
<select
|
||||
value={newHotelTripId}
|
||||
onChange={e => setNewHotelTripId(e.target.value)}
|
||||
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db', background: '#fff', fontSize: '0.95rem' }}
|
||||
>
|
||||
{trips.length === 0 && <option value="">Keine Reise vorhanden</option>}
|
||||
{trips.map(trip => (
|
||||
<option key={trip.id} value={String(trip.id)}>
|
||||
🌍 {trip.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="">Keiner Reise zuordnen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Booking.com URL *</label>
|
||||
<input type="url" required placeholder="https://www.booking.com/hotel/..." value={newBookingUrl} onChange={e => setNewBookingUrl(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Hotel Name (optional)</label>
|
||||
<input type="text" placeholder="z. B. Abad Fort (wird sonst automatisch via Link ermittelt)" value={newHotelName} onChange={e => setNewHotelName(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
||||
<button type="button" onClick={() => setShowAddHotelModal(false)} style={{ padding: '0.6rem 1.2rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Abbrechen</button>
|
||||
<button type="submit" disabled={isSubmitting} style={{ padding: '0.6rem 1.2rem', background: '#10b981', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}>
|
||||
{isSubmitting ? 'Speichere...' : 'Speichern'}
|
||||
{isSubmitting ? 'Speichere & Scanne...' : 'Speichern & Scannen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user