diff --git a/copyChat.sh b/copyChat.sh
new file mode 100755
index 0000000..bada8fa
--- /dev/null
+++ b/copyChat.sh
@@ -0,0 +1,80 @@
+#!/usr/bin/env bash
+
+OUTPUT_FILE="${1:-llm_context.txt}"
+ROOT_DIR="${2:-.}"
+
+# Absolute Pfade auflösen
+OUTPUT_PATH="$(cd "$(dirname "$OUTPUT_FILE")" 2>/dev/null && pwd)/$(basename "$OUTPUT_FILE")"
+
+cd "$ROOT_DIR" || exit 1
+
+# Ausgabedatei initialisieren / leeren
+> "$OUTPUT_PATH"
+
+echo "========================================" >> "$OUTPUT_PATH"
+echo "PROJEKTSTRUKTUR" >> "$OUTPUT_PATH"
+echo "========================================" >> "$OUTPUT_PATH"
+
+# Prüfen, ob Git verfügbar und das Verzeichnis ein Git-Repo ist
+IS_GIT_REPO=false
+if command -v git &> /dev/null && git rev-parse --is-inside-work-tree &> /dev/null; then
+ IS_GIT_REPO=true
+fi
+
+if [ "$IS_GIT_REPO" = true ]; then
+ echo "(Ermittelt via Git unter Berücksichtigung von .gitignore)" >> "$OUTPUT_PATH"
+ # Zeigt alle getrackten + ungetrackten Dateien an, ignoriert .gitignore-Treffer
+ git ls-files -co --exclude-standard >> "$OUTPUT_PATH"
+else
+ if command -v tree &> /dev/null; then
+ tree -I 'node_modules|.git|venv|.venv|__pycache__|dist|build|.next|target' >> "$OUTPUT_PATH"
+ else
+ find . -maxdepth 3 -not -path '*/.*' -not -path '*/node_modules*' -not -path '*/venv*' >> "$OUTPUT_PATH"
+ fi
+fi
+
+echo -e "\n========================================" >> "$OUTPUT_PATH"
+echo "DATEIINHALTE" >> "$OUTPUT_PATH"
+echo "========================================" >> "$OUTPUT_PATH"
+
+# Dateiliste ermitteln
+get_file_list() {
+ if [ "$IS_GIT_REPO" = true ]; then
+ # -c = cached/tracked, -o = others/untracked, --exclude-standard = .gitignore beachten
+ git ls-files -co --exclude-standard
+ else
+ find . -type f \
+ -not -path '*/.git/*' \
+ -not -path '*/node_modules/*' \
+ -not -path '*/venv/*' \
+ -not -path '*/.venv/*' \
+ -not -path '*/__pycache__/*' \
+ -not -path '*/dist/*' \
+ -not -path '*/build/*' \
+ -not -path '*/.next/*'
+ fi
+}
+
+# Dateien verarbeiten
+get_file_list | sort | while IFS= read -r file; do
+ # Überspringe leere Pfade, die Zieldatei selbst oder typische Binär-Endungen
+ [ -z "$file" ] && continue
+ [ "$file" = "$OUTPUT_FILE" ] && continue
+ [ "$(realpath "$file" 2>/dev/null)" = "$OUTPUT_PATH" ] && continue
+
+ case "$file" in
+ *.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.zip|*.tar.gz|*.lock|package-lock.json|yarn.lock)
+ continue
+ ;;
+ esac
+
+ # Prüfen, ob die Datei existiert und lesbarer Text ist
+ if [ -f "$file" ] && (grep -Iq . "$file" 2>/dev/null || [ ! -s "$file" ]); then
+ echo -e "\n--- DATEI: $file ---" >> "$OUTPUT_PATH"
+ echo '```' >> "$OUTPUT_PATH"
+ cat "$file" >> "$OUTPUT_PATH"
+ echo -e "\n"'```' >> "$OUTPUT_PATH"
+ fi
+done
+
+echo "Fertig! Projektkontext liegt in: $OUTPUT_PATH"
\ No newline at end of file
diff --git a/llm_context.txt b/llm_context.txt
new file mode 100644
index 0000000..4449139
--- /dev/null
+++ b/llm_context.txt
@@ -0,0 +1,1404 @@
+========================================
+PROJEKTSTRUKTUR
+========================================
+(Ermittelt via Git unter Berücksichtigung von .gitignore)
+copyChat.sh
+llm_context.txt
+src/app/api/hotels/[id]/prices/route.ts
+src/app/api/hotels/[id]/scan/route.ts
+.gitignore
+Dockerfile
+README.md
+docker-compose.yml
+next.config.js
+next.config.mjs
+package-lock.json
+package.json
+src/app/api/hotels/[id]/history/route.ts
+src/app/api/hotels/[id]/review-summary/route.ts
+src/app/api/hotels/[id]/route.ts
+src/app/api/hotels/route.ts
+src/app/api/jobs/trigger-daily-check/route.ts
+src/app/api/trips/route.ts
+src/app/api/webhooks/scraper/route.ts
+src/app/layout.tsx
+src/app/page.tsx
+src/lib/db.ts
+tsconfig.json
+
+========================================
+DATEIINHALTE
+========================================
+
+--- DATEI: .gitignore ---
+```
+# ==========================================
+# 1. Lokale Umgebungs- & Geheimdateien
+# ==========================================
+.env
+.env*.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# ==========================================
+# 2. Abhängigkeiten & Paketmanager
+# ==========================================
+node_modules/
+.pnpm-store/
+
+# ==========================================
+# 3. Next.js Build- & Output-Dateien
+# ==========================================
+.next/
+out/
+build/
+dist/
+
+# ==========================================
+# 4. Logs & temporäre Dateien
+# ==========================================
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+.pnpm-debug.log*
+*.log
+
+# ==========================================
+# 5. IDEs & Betriebssystem
+# ==========================================
+.DS_Store
+*.pem
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.idea/
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+```
+
+--- DATEI: Dockerfile ---
+```
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm install
+
+COPY . .
+RUN npm run build
+
+EXPOSE 3001
+ENV PORT=3001
+
+CMD ["npm", "start"]
+
+```
+
+--- DATEI: README.md ---
+```
+
+```
+
+--- DATEI: copyChat.sh ---
+```
+#!/usr/bin/env bash
+
+OUTPUT_FILE="${1:-llm_context.txt}"
+ROOT_DIR="${2:-.}"
+
+# Absolute Pfade auflösen
+OUTPUT_PATH="$(cd "$(dirname "$OUTPUT_FILE")" 2>/dev/null && pwd)/$(basename "$OUTPUT_FILE")"
+
+cd "$ROOT_DIR" || exit 1
+
+# Ausgabedatei initialisieren / leeren
+> "$OUTPUT_PATH"
+
+echo "========================================" >> "$OUTPUT_PATH"
+echo "PROJEKTSTRUKTUR" >> "$OUTPUT_PATH"
+echo "========================================" >> "$OUTPUT_PATH"
+
+# Prüfen, ob Git verfügbar und das Verzeichnis ein Git-Repo ist
+IS_GIT_REPO=false
+if command -v git &> /dev/null && git rev-parse --is-inside-work-tree &> /dev/null; then
+ IS_GIT_REPO=true
+fi
+
+if [ "$IS_GIT_REPO" = true ]; then
+ echo "(Ermittelt via Git unter Berücksichtigung von .gitignore)" >> "$OUTPUT_PATH"
+ # Zeigt alle getrackten + ungetrackten Dateien an, ignoriert .gitignore-Treffer
+ git ls-files -co --exclude-standard >> "$OUTPUT_PATH"
+else
+ if command -v tree &> /dev/null; then
+ tree -I 'node_modules|.git|venv|.venv|__pycache__|dist|build|.next|target' >> "$OUTPUT_PATH"
+ else
+ find . -maxdepth 3 -not -path '*/.*' -not -path '*/node_modules*' -not -path '*/venv*' >> "$OUTPUT_PATH"
+ fi
+fi
+
+echo -e "\n========================================" >> "$OUTPUT_PATH"
+echo "DATEIINHALTE" >> "$OUTPUT_PATH"
+echo "========================================" >> "$OUTPUT_PATH"
+
+# Dateiliste ermitteln
+get_file_list() {
+ if [ "$IS_GIT_REPO" = true ]; then
+ # -c = cached/tracked, -o = others/untracked, --exclude-standard = .gitignore beachten
+ git ls-files -co --exclude-standard
+ else
+ find . -type f \
+ -not -path '*/.git/*' \
+ -not -path '*/node_modules/*' \
+ -not -path '*/venv/*' \
+ -not -path '*/.venv/*' \
+ -not -path '*/__pycache__/*' \
+ -not -path '*/dist/*' \
+ -not -path '*/build/*' \
+ -not -path '*/.next/*'
+ fi
+}
+
+# Dateien verarbeiten
+get_file_list | sort | while IFS= read -r file; do
+ # Überspringe leere Pfade, die Zieldatei selbst oder typische Binär-Endungen
+ [ -z "$file" ] && continue
+ [ "$file" = "$OUTPUT_FILE" ] && continue
+ [ "$(realpath "$file" 2>/dev/null)" = "$OUTPUT_PATH" ] && continue
+
+ case "$file" in
+ *.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.zip|*.tar.gz|*.lock|package-lock.json|yarn.lock)
+ continue
+ ;;
+ esac
+
+ # Prüfen, ob die Datei existiert und lesbarer Text ist
+ if [ -f "$file" ] && (grep -Iq . "$file" 2>/dev/null || [ ! -s "$file" ]); then
+ echo -e "\n--- DATEI: $file ---" >> "$OUTPUT_PATH"
+ echo '```' >> "$OUTPUT_PATH"
+ cat "$file" >> "$OUTPUT_PATH"
+ echo -e "\n"'```' >> "$OUTPUT_PATH"
+ fi
+done
+
+echo "Fertig! Projektkontext liegt in: $OUTPUT_PATH"
+```
+
+--- DATEI: docker-compose.yml ---
+```
+services:
+ travel-app:
+ build: .
+ container_name: travel-app
+ restart: always
+ ports:
+ - "3002:3001"
+ env_file:
+ - .env
+
+```
+
+--- DATEI: next.config.js ---
+```
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ serverExternalPackages: ['bullmq', 'ioredis'],
+};
+
+module.exports = nextConfig;
+
+```
+
+--- DATEI: next.config.mjs ---
+```
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ serverExternalPackages: ['bullmq', 'ioredis'],
+};
+
+export default nextConfig;
+
+```
+
+--- DATEI: package.json ---
+```
+{
+ "name": "travel-planner-app",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev -p 3001",
+ "build": "next build",
+ "start": "next start -p 3001"
+ },
+ "dependencies": {
+ "axios": "^1.7.0",
+ "bullmq": "^6.0.9",
+ "ioredis": "^6.0.0",
+ "next": "^14.2.0",
+ "pg": "^8.11.5",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "@types/pg": "^8.11.0",
+ "@types/react": "^18.3.0",
+ "autoprefixer": "^10.4.0",
+ "postcss": "^8.4.0",
+ "tailwindcss": "^3.4.0",
+ "typescript": "^5.0.0"
+ }
+}
+
+```
+
+--- DATEI: src/app/api/hotels/[id]/history/route.ts ---
+```
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
+ try {
+ const { id } = params;
+ const res = await query(
+ `SELECT * FROM hotel_price_history
+ WHERE hotel_id = $1
+ ORDER BY tracked_at DESC`,
+ [id]
+ );
+
+ // Alle SQL-NUMERIC Strings in echte JS-Numbers umwandeln
+ const parsedHistory = res.rows.map((entry) => ({
+ ...entry,
+ price_numeric: entry.price_numeric ? parseFloat(entry.price_numeric) : 0,
+ genius_price_numeric: entry.genius_price_numeric ? parseFloat(entry.genius_price_numeric) : null,
+ price_per_night: entry.price_per_night ? parseFloat(entry.price_per_night) : null,
+ price_per_person: entry.price_per_person ? parseFloat(entry.price_per_person) : null,
+ nights: entry.nights ? parseInt(entry.nights, 10) : 1,
+ persons: entry.persons ? parseInt(entry.persons, 10) : 2,
+ }));
+
+ return NextResponse.json({ success: true, history: parsedHistory });
+ } catch (e: any) {
+ console.error('Error fetching history:', e);
+ return NextResponse.json({ success: false, error: e.message }, { status: 500 });
+ }
+}
+
+```
+
+--- DATEI: src/app/api/hotels/[id]/prices/route.ts ---
+```
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
+ try {
+ const { id } = params;
+
+ const res = await query(
+ `SELECT
+ id,
+ price_numeric::float AS price,
+ price_per_night::float AS price_per_night,
+ price_per_person::float AS price_per_person,
+ nights,
+ persons,
+ tracked_at AS created_at
+ FROM hotel_price_history
+ WHERE hotel_id::text = $1::text
+ ORDER BY tracked_at ASC`,
+ [id]
+ );
+
+ return NextResponse.json({ success: true, prices: res.rows });
+ } catch (error: any) {
+ console.error('Fehler beim Laden der Preishistorie:', error.message);
+ return NextResponse.json({ success: false, error: error.message, prices: [] }, { status: 500 });
+ }
+}
+
+```
+
+--- DATEI: src/app/api/hotels/[id]/review-summary/route.ts ---
+```
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
+ try {
+ const { id } = params;
+ const { summary_positive, summary_passable, summary_negative, summary_bathroom, summary_count } = await req.json();
+
+ if (!id) {
+ return NextResponse.json({ error: 'Hotel-ID fehlt' }, { status: 400 });
+ }
+
+ await query(
+ `UPDATE hotels
+ SET review_summary_positive = $1,
+ review_summary_passable = $2,
+ review_summary_negative = $3,
+ review_summary_bathroom = $4,
+ review_summary_count = $5,
+ reviews_updated_at = NOW()
+ WHERE id = $6`,
+ [summary_positive, summary_passable, summary_negative, summary_bathroom || null, summary_count || null, id]
+ );
+
+ return NextResponse.json({ success: true, message: 'Review-Zusammenfassungen gespeichert.' });
+ } catch (error: any) {
+ console.error('Fehler beim Speichern der Review-Zusammenfassung:', error);
+ return NextResponse.json({ success: false, error: error.message }, { status: 500 });
+ }
+}
+
+```
+
+--- DATEI: src/app/api/hotels/[id]/route.ts ---
+```
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+// PATCH: Tracking aktivieren / deaktivieren (is_active)
+export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
+ try {
+ const { id } = params;
+ const { is_active } = await req.json();
+
+ await query('UPDATE hotels SET is_active = $1 WHERE id = $2', [is_active, id]);
+ return NextResponse.json({ success: true });
+ } catch (e: any) {
+ return NextResponse.json({ success: false, error: e.message }, { status: 500 });
+ }
+}
+
+// DELETE: Hotel entfernen
+export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
+ try {
+ const { id } = params;
+ await query('DELETE FROM hotels WHERE id = $1', [id]);
+ return NextResponse.json({ success: true });
+ } catch (e: any) {
+ return NextResponse.json({ success: false, error: e.message }, { status: 500 });
+ }
+}
+
+```
+
+--- DATEI: src/app/api/hotels/[id]/scan/route.ts ---
+```
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ try {
+ const { id } = params;
+
+ const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
+ if (!bullmq || !bullmq.Queue) {
+ throw new Error('BullMQ Queue Klasse konnte nicht geladen werden.');
+ }
+
+ 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 });
+
+ // Nur dieses eine Hotel laden
+ const res = await query('SELECT id, booking_url, name FROM hotels WHERE id = $1', [id]);
+ if (res.rows.length === 0) {
+ await scrapeQueue.close();
+ return NextResponse.json({ error: 'Hotel nicht gefunden' }, { status: 404 });
+ }
+
+ const hotel = res.rows[0];
+
+ const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews';
+ const 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
+ await scrapeQueue.add('scrape-hotel', {
+ hotelId: hotel.id,
+ url: hotel.booking_url,
+ appWebhookUrl,
+ webhookUrl: n8nWebhookUrl,
+ });
+
+ await scrapeQueue.close();
+
+ return NextResponse.json({
+ success: true,
+ message: `Scrape-Job für "${hotel.name}" erfolgreich gestartet.`,
+ });
+ } catch (error: any) {
+ console.error('Fehler beim Starten des Einzel-Scrapes:', error);
+ return NextResponse.json({ success: false, error: error.message }, { status: 500 });
+ }
+}
+
+```
+
+--- DATEI: src/app/api/hotels/route.ts ---
+```
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ try {
+ const res = await query(`
+ SELECT
+ h.*,
+ t.name AS trip_name,
+ ph.price_numeric::float AS latest_price,
+ ph.price_per_night::float AS latest_price_per_night,
+ ph.price_per_person::float AS latest_price_per_person,
+ ph.nights AS latest_nights,
+ ph.persons AS latest_persons
+ FROM hotels h
+ LEFT JOIN trips t ON h.trip_id = t.id
+ LEFT JOIN LATERAL (
+ SELECT price_numeric, price_per_night, price_per_person, nights, persons
+ FROM hotel_price_history
+ WHERE hotel_id::text = h.id::text
+ ORDER BY tracked_at DESC NULLS LAST, id DESC
+ LIMIT 1
+ ) ph ON true
+ ORDER BY h.checkin_date ASC NULLS LAST, h.id ASC
+ `);
+
+ return NextResponse.json({ success: true, hotels: res.rows });
+ } catch (error: any) {
+ console.error('Fehler in GET /api/hotels:', error.message);
+ try {
+ const fallback = await query(`SELECT * FROM hotels ORDER BY id ASC`);
+ return NextResponse.json({ success: true, hotels: fallback.rows });
+ } catch (fallbackErr: any) {
+ return NextResponse.json({ success: false, error: error.message, hotels: [] }, { status: 500 });
+ }
+ }
+}
+
+export async function POST(req: NextRequest) {
+ try {
+ const { name, booking_url, trip_id } = await req.json();
+
+ if (!booking_url) {
+ return NextResponse.json({ error: 'Booking.com URL erforderlich' }, { status: 400 });
+ }
+
+ let checkin_date: string | null = null;
+ let checkout_date: string | null = null;
+
+ try {
+ const parsedUrl = new URL(booking_url);
+ checkin_date = parsedUrl.searchParams.get('checkin');
+ checkout_date = parsedUrl.searchParams.get('checkout');
+ } catch (e) {}
+
+ const res = await query(
+ `INSERT INTO hotels (name, booking_url, checkin_date, checkout_date, trip_id, is_active)
+ VALUES ($1, $2, $3, $4, $5, true)
+ RETURNING *`,
+ [name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null]
+ );
+
+ return NextResponse.json({ success: true, hotel: res.rows[0] });
+ } catch (error: any) {
+ console.error('Fehler in POST /api/hotels:', error.message);
+ return NextResponse.json({ success: false, error: error.message }, { status: 500 });
+ }
+}
+
+```
+
+--- DATEI: src/app/api/jobs/trigger-daily-check/route.ts ---
+```
+import { NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+export async function POST() {
+ try {
+ const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
+ if (!bullmq || !bullmq.Queue) {
+ throw new Error('BullMQ Queue Klasse konnte nicht geladen werden.');
+ }
+
+ 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 res = await query('SELECT id, booking_url FROM hotels WHERE is_active = true');
+ const activeHotels = res.rows;
+
+ if (activeHotels.length === 0) {
+ await scrapeQueue.close();
+ return NextResponse.json({
+ success: true,
+ message: 'Keine aktiven Hotels zum Scrapen gefunden.',
+ });
+ }
+
+ // Für ALLE aktiven Hotels nur den Preis-Scan anwerfen (OHNE n8n / OHNE LLM)
+ for (const hotel of activeHotels) {
+ const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${hotel.id}`;
+
+ await scrapeQueue.add('scrape-hotel', {
+ hotelId: hotel.id,
+ url: hotel.booking_url,
+ appWebhookUrl, // Speichert den Preis in Postgres
+ webhookUrl: null, // KEIN n8n -> KOSTENLOS!
+ });
+ }
+
+ await scrapeQueue.close();
+
+ return NextResponse.json({
+ success: true,
+ message: `Preis-Scan für ${activeHotels.length} Hotels gestartet.`,
+ });
+ } catch (error: any) {
+ console.error('Fehler beim Starten des Preis-Scans:', error);
+ return NextResponse.json({ success: false, error: error.message }, { status: 500 });
+ }
+}
+
+```
+
+--- DATEI: src/app/api/trips/route.ts ---
+```
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ try {
+ const res = await query(`SELECT * FROM trips ORDER BY id ASC`);
+ return NextResponse.json({ success: true, trips: res.rows });
+ } catch (error: any) {
+ return NextResponse.json({ success: false, error: error.message, trips: [] }, { status: 500 });
+ }
+}
+
+export async function POST(req: NextRequest) {
+ try {
+ const { name } = await req.json();
+ if (!name) {
+ return NextResponse.json({ error: 'Name der Reise erforderlich' }, { status: 400 });
+ }
+
+ const res = await query(
+ `INSERT INTO trips (name) VALUES ($1) RETURNING *`,
+ [name]
+ );
+
+ return NextResponse.json({ success: true, trip: res.rows[0] });
+ } catch (error: any) {
+ return NextResponse.json({ success: false, error: error.message }, { status: 500 });
+ }
+}
+
+```
+
+--- DATEI: src/app/api/webhooks/scraper/route.ts ---
+```
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+function parseGermanPrice(priceStr: any): number | null {
+ if (!priceStr) return null;
+ if (typeof priceStr === 'number') return Math.round(priceStr);
+
+ let cleaned = String(priceStr).replace(/[^0-9.,]/g, '').trim();
+ if (!cleaned) return null;
+
+ if (cleaned.includes('.') && !cleaned.includes(',')) {
+ cleaned = cleaned.replace(/\./g, '');
+ } else if (cleaned.includes('.') && cleaned.includes(',')) {
+ cleaned = cleaned.replace(/\./g, '').replace(',', '.');
+ } else if (cleaned.includes(',')) {
+ cleaned = cleaned.replace(',', '.');
+ }
+
+ const parsed = parseFloat(cleaned);
+ return isNaN(parsed) ? null : Math.round(parsed);
+}
+
+export async function POST(req: NextRequest) {
+ try {
+ const body = await req.json().catch(() => ({}));
+ const { searchParams } = new URL(req.url);
+ const hotelId = searchParams.get('hotelId') || body.hotelId;
+
+ if (!hotelId) {
+ return NextResponse.json({ success: false, error: 'hotelId fehlt' }, { status: 400 });
+ }
+
+ const numericPrice = parseGermanPrice(body.price);
+
+ // 1. In hotel_price_history eintragen mit echten Spalten
+ if (numericPrice !== null && numericPrice > 0) {
+ try {
+ await query(
+ `INSERT INTO hotel_price_history (hotel_id, price_numeric, price_raw, currency, nights, persons, tracked_at)
+ VALUES ($1::uuid, $2, $3, 'EUR', $4, $5, NOW())`,
+ [hotelId, numericPrice, String(body.price || ''), body.nights || 1, body.persons || 2]
+ );
+ } catch (err: any) {
+ console.error('DB Insert Error (hotel_price_history):', err.message);
+ }
+ }
+
+ // 2. Stammdaten aktualisieren
+ try {
+ await query(
+ `UPDATE hotels
+ SET name = COALESCE($1, name),
+ rating = COALESCE($2, rating),
+ review_count = COALESCE($3, review_count),
+ address = COALESCE($4, address),
+ image_url = COALESCE($5, image_url),
+ room_size_m2 = COALESCE($6, room_size_m2),
+ updated_at = NOW()
+ WHERE id = $7::uuid`,
+ [
+ body.hotelName || null,
+ body.rating || null,
+ body.reviewCount || null,
+ body.address || null,
+ body.image || null,
+ body.roomSizeM2 || null,
+ hotelId
+ ]
+ );
+ } catch (err: any) {
+ console.error('DB Update Error (hotels):', err.message);
+ }
+
+ return NextResponse.json({ success: true, hotelId, parsedPrice: numericPrice });
+ } catch (error: any) {
+ console.error('Allgemeiner Fehler im Webhook:', error.message);
+ return NextResponse.json({ success: false, error: error.message }, { status: 200 });
+ }
+}
+
+```
+
+--- DATEI: src/app/layout.tsx ---
+```
+import type { Metadata } from 'next';
+
+export const metadata: Metadata = {
+ title: 'Reiseplaner App',
+ description: 'Hotelpreise und Reisen tracken',
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+```
+
+--- DATEI: src/app/page.tsx ---
+```
+'use client';
+
+import { useState, useEffect } from 'react';
+
+interface Trip {
+ id: string;
+ name: string;
+}
+
+interface PriceEntry {
+ id: string;
+ price: number;
+ price_per_night?: number | null;
+ price_per_person?: number | null;
+ nights: number;
+ persons: number;
+ created_at: string;
+}
+
+interface Hotel {
+ id: string;
+ name: string;
+ booking_url: string;
+ is_active: boolean;
+ trip_id?: string | null;
+ trip_name?: string | null;
+ latest_price?: number | null;
+ latest_price_per_night?: number | null;
+ latest_price_per_person?: number | null;
+ latest_nights?: number | null;
+ latest_persons?: number | null;
+ rating?: number | null;
+ review_count?: number | null;
+ image_url?: string | null;
+ address?: string | null;
+ room_size_m2?: number | null;
+ review_summary_positive?: string | null;
+ review_summary_passable?: string | null;
+ review_summary_negative?: string | null;
+ review_summary_bathroom?: string | null;
+ review_summary_count?: number | null;
+ reviews_updated_at?: string | null;
+ checkin_date?: string | null;
+ checkout_date?: string | null;
+}
+
+export default function TravelDashboard() {
+ const [trips, setTrips] = useState([]);
+ const [activeTripId, setActiveTripId] = useState('ALL');
+ const [hotels, setHotels] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const [scanningAllPrices, setScanningAllPrices] = useState(false);
+ const [scanningHotelId, setScanningHotelId] = useState(null);
+
+ // Preishistorie Modal
+ const [selectedPriceHotel, setSelectedPriceHotel] = useState(null);
+ const [priceHistory, setPriceHistory] = useState([]);
+ const [loadingPrices, setLoadingPrices] = useState(false);
+
+ // Modals
+ const [showAddTripModal, setShowAddTripModal] = useState(false);
+ const [newTripName, setNewTripName] = useState('');
+
+ const [showAddHotelModal, setShowAddHotelModal] = useState(false);
+ const [newBookingUrl, setNewBookingUrl] = useState('');
+ const [newHotelName, setNewHotelName] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ const formatDateGerman = (dateStr?: string | null) => {
+ if (!dateStr) return '';
+ const date = new Date(dateStr);
+ return isNaN(date.getTime()) ? dateStr : date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
+ };
+
+ const calculateNights = (checkin?: string | null, checkout?: string | null) => {
+ if (!checkin || !checkout) return null;
+ const start = new Date(checkin).getTime();
+ const end = new Date(checkout).getTime();
+ if (isNaN(start) || isNaN(end)) return null;
+ const diffDays = Math.round((end - start) / (1000 * 60 * 60 * 24));
+ return diffDays > 0 ? diffDays : null;
+ };
+
+ const loadData = async () => {
+ setLoading(true);
+ try {
+ const resTrips = await fetch('/api/trips');
+ const dataTrips = await resTrips.json();
+ setTrips(dataTrips.trips || []);
+
+ const resHotels = await fetch('/api/hotels');
+ const dataHotels = await resHotels.json();
+ setHotels(dataHotels.hotels || []);
+ } catch (e) {
+ console.error('Fehler beim Laden:', e);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ 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 handleAddTrip = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newTripName.trim()) return;
+
+ try {
+ const res = await fetch('/api/trips', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newTripName.trim() })
+ });
+ const data = await res.json();
+ if (data.success) {
+ setTrips([...trips, data.trip]);
+ setActiveTripId(String(data.trip.id));
+ setNewTripName('');
+ setShowAddTripModal(false);
+ }
+ } catch (e: any) {
+ alert('Fehler: ' + e.message);
+ }
+ };
+
+ const handleAddHotel = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newBookingUrl) return alert('Bitte URL eingeben');
+
+ setIsSubmitting(true);
+ try {
+ const targetTripId = activeTripId === 'ALL' ? (trips[0]?.id || null) : activeTripId;
+ const res = await fetch('/api/hotels', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: newHotelName || 'Neues Hotel',
+ booking_url: newBookingUrl,
+ trip_id: targetTripId
+ })
+ });
+
+ const data = await res.json();
+ if (res.ok || data.success) {
+ setNewBookingUrl('');
+ setNewHotelName('');
+ setShowAddHotelModal(false);
+ loadData();
+ }
+ } catch (err: any) {
+ alert('Fehler: ' + err.message);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const toggleHotelActive = async (hotel: Hotel) => {
+ try {
+ const updatedActive = !hotel.is_active;
+ await fetch(`/api/hotels/${hotel.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ is_active: updatedActive })
+ });
+ setHotels(hotels.map(h => h.id === hotel.id ? { ...h, is_active: updatedActive } : h));
+ } catch (e) {}
+ };
+
+ const deleteHotel = async (id: string, name: string) => {
+ if (!confirm(`Hotel "${name}" löschen?`)) return;
+ await fetch(`/api/hotels/${id}`, { method: 'DELETE' });
+ setHotels(hotels.filter(h => h.id !== id));
+ };
+
+ const triggerBatchPriceScan = async () => {
+ setScanningAllPrices(true);
+ try {
+ const res = await fetch('/api/jobs/trigger-daily-check', { method: 'POST' });
+ const data = await res.json();
+ alert(data.success ? '📊 Preis-Scan gestartet!' : 'Fehler: ' + data.error);
+ setTimeout(loadData, 3000);
+ } finally {
+ setScanningAllPrices(false);
+ }
+ };
+
+ 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);
+ } finally {
+ setScanningHotelId(null);
+ }
+ };
+
+ const currentHotels = hotels
+ .filter(h => {
+ if (activeTripId === 'ALL') return true;
+ if (!h.trip_id) return true;
+ return String(h.trip_id).toLowerCase() === String(activeTripId).toLowerCase();
+ })
+ .sort((a, b) => {
+ if (!a.checkin_date && !b.checkin_date) return 0;
+ if (!a.checkin_date) return 1;
+ if (!b.checkin_date) return -1;
+ return new Date(a.checkin_date).getTime() - new Date(b.checkin_date).getTime();
+ });
+
+ const pricesList = priceHistory.map(p => Number(p.price));
+ const minPrice = pricesList.length > 0 ? Math.min(...pricesList) : 0;
+ const maxPrice = pricesList.length > 0 ? Math.max(...pricesList) : 0;
+ const currentPrice = pricesList.length > 0 ? pricesList[pricesList.length - 1] : 0;
+
+ return (
+
+
+
+ 📍 Reise-Ablauf & Timeline
+ Routenplanung, Preishistorie & KI-Bewertungen
+
+
+
+
+
+
+
+
+ {/* TABS */}
+
+
+
+ {trips.map(trip => (
+
+ ))}
+
+
+
+
+ {/* PREISHISTORIE MODAL */}
+ {selectedPriceHotel && (
+
+
+
+
+
📈 Preishistorie: {selectedPriceHotel.name}
+
Entwicklung aller erfassten Preis-Scans
+
+
+
+
+ {loadingPrices ? (
+
Lade Preishistorie...
+ ) : priceHistory.length === 0 ? (
+
Noch keine Preishistorie vorhanden. Klicke oben auf "Preise aller Hotels aktualisieren".
+ ) : (
+
+
+
+
Tiefstpreis
+
{minPrice} €
+
+
+
Aktuellster Preis
+
{currentPrice} €
+
+
+
Höchstpreis
+
{maxPrice} €
+
+
+
+ {priceHistory.length > 1 && (
+
+
+
+ )}
+
+
+
+
+
+ | Datum & Uhrzeit |
+ Preis |
+ Details |
+
+
+
+ {priceHistory.map(p => (
+
+ | {new Date(p.created_at).toLocaleString('de-DE')} |
+ {p.price} € |
+ {p.nights || 1} Nächte, {p.persons || 2} Personen |
+
+ ))}
+
+
+
+
+ )}
+
+
+ )}
+
+ {/* MODAL: NEUE REISE */}
+ {showAddTripModal && (
+
+
+
🌍 Neue Reise anlegen
+
+
+
+ )}
+
+ {/* MODAL: HOTEL HINZUFÜGEN */}
+ {showAddHotelModal && (
+
+
+
➕ Neues Hotel hinzufügen
+
Check-in/Check-out Datum wird automatisch aus dem Link gelesen.
+
+
+
+
+ )}
+
+ {/* TIMELINE */}
+ {loading ? (
+
Lade Daten...
+ ) : currentHotels.length === 0 ? (
+
+
Keine Hotels für diese Auswahl gefunden.
+
+
+ ) : (
+
+ {currentHotels.map((hotel, index) => {
+ const nights = calculateNights(hotel.checkin_date, hotel.checkout_date) || hotel.latest_nights || 1;
+ const persons = hotel.latest_persons || 2;
+ const totalPrice = hotel.latest_price !== null && hotel.latest_price !== undefined ? Number(hotel.latest_price) : null;
+
+ // Bevorzuge vorberechnete Werte aus der DB, sonst dyn. Fallback
+ const pricePerNight = hotel.latest_price_per_night
+ ? Math.round(Number(hotel.latest_price_per_night))
+ : (totalPrice ? Math.round(totalPrice / nights) : null);
+
+ const pricePerPersonPerNight = hotel.latest_price_per_person
+ ? Math.round(Number(hotel.latest_price_per_person))
+ : (totalPrice ? Math.round(totalPrice / (nights * persons)) : null);
+
+ return (
+
+
+
+
+
+ 📍 Stop #{index + 1}
+ {hotel.trip_name && {hotel.trip_name}}
+ {hotel.checkin_date ? (
+
+ 🗓️ {formatDateGerman(hotel.checkin_date)} – {formatDateGerman(hotel.checkout_date) || '?'} ({nights} {nights === 1 ? 'Nacht' : 'Nächte'})
+
+ ) : (
+ (Warte auf Scan für Datum)
+ )}
+
+
+
+
+
+
+
+
+
+ {hotel.image_url &&

}
+
+
+
{hotel.name}
+
📍 {hotel.address || 'Keine Adresse hinterlegt'}
+
+ {/* ANZEIGE DER DREI PREIS-WERTE */}
+ {totalPrice !== null ? (
+
+
+ 💰 Gesamt: {totalPrice.toLocaleString('de-DE')} €
+
+
+ | 🌙 {pricePerNight?.toLocaleString('de-DE')} € / Nacht
+
+
+ | 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P. & Nacht
+
+
+ ) : (
+
+ 💰 Noch kein Preis erfasst
+
+ )}
+
+
+ ⭐ {hotel.rating || 'N/A'}/10
+ 💬 {hotel.review_count || 0} Bewertungen
+ {hotel.room_size_m2 && 📐 {hotel.room_size_m2} m²}
+
+
+
+
+
+
+
+
+ 🔗 Booking.com ↗
+
+
+
+
+
+ {(hotel.review_summary_positive || hotel.review_summary_bathroom) && (
+
+
+
+ 📋 KI-Zusammenfassung
+
+ (Basierend auf {hotel.review_summary_count || 'mehreren'} analysierten Bewertungen)
+
+
+
+
+ {hotel.review_summary_positive && (
+
+
💚 Bestnoten & Highlights:
+
{hotel.review_summary_positive}
+
+ )}
+ {hotel.review_summary_passable && (
+
+
🟡 Passabel & Neutrales:
+
{hotel.review_summary_passable}
+
+ )}
+ {hotel.review_summary_negative && (
+
+
🔴 Schlechteste & Mängel:
+
{hotel.review_summary_negative}
+
+ )}
+
+
🚽 Bad & WC Fokus:
+
+ {hotel.review_summary_bathroom || 'Keine spezifischen Anmerkungen zum Bad/WC gefunden.'}
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+```
+
+--- DATEI: src/lib/db.ts ---
+```
+import { Pool } from 'pg';
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL,
+});
+
+export const query = (text: string, params?: any[]) => pool.query(text, params);
+
+```
+
+--- DATEI: tsconfig.json ---
+```
+{
+ "compilerOptions": {
+ "target": "es2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
+
+```
diff --git a/src/app/api/hotels/[id]/prices/route.ts b/src/app/api/hotels/[id]/prices/route.ts
new file mode 100644
index 0000000..536b9e0
--- /dev/null
+++ b/src/app/api/hotels/[id]/prices/route.ts
@@ -0,0 +1,30 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
+ try {
+ const { id } = params;
+
+ const res = await query(
+ `SELECT
+ id,
+ price_numeric::float AS price,
+ price_per_night::float AS price_per_night,
+ price_per_person::float AS price_per_person,
+ nights,
+ persons,
+ tracked_at AS created_at
+ FROM hotel_price_history
+ WHERE hotel_id::text = $1::text
+ ORDER BY tracked_at ASC`,
+ [id]
+ );
+
+ return NextResponse.json({ success: true, prices: res.rows });
+ } catch (error: any) {
+ console.error('Fehler beim Laden der Preishistorie:', error.message);
+ return NextResponse.json({ success: false, error: error.message, prices: [] }, { status: 500 });
+ }
+}
diff --git a/src/app/api/hotels/[id]/review-summary/route.ts b/src/app/api/hotels/[id]/review-summary/route.ts
index 3759d88..e6526cb 100644
--- a/src/app/api/hotels/[id]/review-summary/route.ts
+++ b/src/app/api/hotels/[id]/review-summary/route.ts
@@ -4,7 +4,7 @@ import { query } from '@/lib/db';
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const { id } = params;
- const { summary_positive, summary_passable, summary_negative, summary_bathroom } = await req.json();
+ const { summary_positive, summary_passable, summary_negative, summary_bathroom, summary_count } = await req.json();
if (!id) {
return NextResponse.json({ error: 'Hotel-ID fehlt' }, { status: 400 });
@@ -16,9 +16,10 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
review_summary_passable = $2,
review_summary_negative = $3,
review_summary_bathroom = $4,
+ review_summary_count = $5,
reviews_updated_at = NOW()
- WHERE id = $5`,
- [summary_positive, summary_passable, summary_negative, summary_bathroom || null, id]
+ WHERE id = $6`,
+ [summary_positive, summary_passable, summary_negative, summary_bathroom || null, summary_count || null, id]
);
return NextResponse.json({ success: true, message: 'Review-Zusammenfassungen gespeichert.' });
diff --git a/src/app/api/hotels/[id]/scan/route.ts b/src/app/api/hotels/[id]/scan/route.ts
new file mode 100644
index 0000000..d8e3d76
--- /dev/null
+++ b/src/app/api/hotels/[id]/scan/route.ts
@@ -0,0 +1,64 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+export async function POST(
+ req: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ try {
+ const { id } = params;
+
+ const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
+ if (!bullmq || !bullmq.Queue) {
+ throw new Error('BullMQ Queue Klasse konnte nicht geladen werden.');
+ }
+
+ 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 });
+
+ // Nur dieses eine Hotel laden
+ const res = await query('SELECT id, booking_url, name FROM hotels WHERE id = $1', [id]);
+ if (res.rows.length === 0) {
+ await scrapeQueue.close();
+ return NextResponse.json({ error: 'Hotel nicht gefunden' }, { status: 404 });
+ }
+
+ const hotel = res.rows[0];
+
+ const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews';
+ const 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
+ await scrapeQueue.add('scrape-hotel', {
+ hotelId: hotel.id,
+ url: hotel.booking_url,
+ appWebhookUrl,
+ webhookUrl: n8nWebhookUrl,
+ });
+
+ await scrapeQueue.close();
+
+ return NextResponse.json({
+ success: true,
+ message: `Scrape-Job für "${hotel.name}" erfolgreich gestartet.`,
+ });
+ } catch (error: any) {
+ console.error('Fehler beim Starten des Einzel-Scrapes:', error);
+ return NextResponse.json({ success: false, error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/hotels/route.ts b/src/app/api/hotels/route.ts
index 425e236..8671604 100644
--- a/src/app/api/hotels/route.ts
+++ b/src/app/api/hotels/route.ts
@@ -1,21 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ try {
+ const res = await query(`
+ SELECT
+ h.*,
+ t.name AS trip_name,
+ ph.price_numeric::float AS latest_price,
+ ph.price_per_night::float AS latest_price_per_night,
+ ph.price_per_person::float AS latest_price_per_person,
+ ph.nights AS latest_nights,
+ ph.persons AS latest_persons
+ FROM hotels h
+ LEFT JOIN trips t ON h.trip_id = t.id
+ LEFT JOIN LATERAL (
+ SELECT price_numeric, price_per_night, price_per_person, nights, persons
+ FROM hotel_price_history
+ WHERE hotel_id::text = h.id::text
+ ORDER BY tracked_at DESC NULLS LAST, id DESC
+ LIMIT 1
+ ) ph ON true
+ ORDER BY h.checkin_date ASC NULLS LAST, h.id ASC
+ `);
+
+ return NextResponse.json({ success: true, hotels: res.rows });
+ } catch (error: any) {
+ console.error('Fehler in GET /api/hotels:', error.message);
+ try {
+ const fallback = await query(`SELECT * FROM hotels ORDER BY id ASC`);
+ return NextResponse.json({ success: true, hotels: fallback.rows });
+ } catch (fallbackErr: any) {
+ return NextResponse.json({ success: false, error: error.message, hotels: [] }, { status: 500 });
+ }
+ }
+}
+
export async function POST(req: NextRequest) {
try {
- const { trip_id, booking_url, name } = await req.json();
- if (!trip_id || !booking_url) {
- return NextResponse.json({ error: 'trip_id und booking_url sind erforderlich' }, { status: 400 });
+ const { name, booking_url, trip_id } = await req.json();
+
+ if (!booking_url) {
+ return NextResponse.json({ error: 'Booking.com URL erforderlich' }, { status: 400 });
}
+ let checkin_date: string | null = null;
+ let checkout_date: string | null = null;
+
+ try {
+ const parsedUrl = new URL(booking_url);
+ checkin_date = parsedUrl.searchParams.get('checkin');
+ checkout_date = parsedUrl.searchParams.get('checkout');
+ } catch (e) {}
+
const res = await query(
- `INSERT INTO hotels (trip_id, booking_url, name, is_active)
- VALUES ($1, $2, $3, true) RETURNING *`,
- [trip_id, booking_url, name || 'Booking Hotel']
+ `INSERT INTO hotels (name, booking_url, checkin_date, checkout_date, trip_id, is_active)
+ VALUES ($1, $2, $3, $4, $5, true)
+ RETURNING *`,
+ [name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null]
);
return NextResponse.json({ success: true, hotel: res.rows[0] });
- } catch (e: any) {
- return NextResponse.json({ success: false, error: e.message }, { status: 500 });
+ } catch (error: any) {
+ console.error('Fehler in POST /api/hotels:', error.message);
+ return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}
diff --git a/src/app/api/jobs/trigger-daily-check/route.ts b/src/app/api/jobs/trigger-daily-check/route.ts
index b1ff101..3fda6e4 100644
--- a/src/app/api/jobs/trigger-daily-check/route.ts
+++ b/src/app/api/jobs/trigger-daily-check/route.ts
@@ -33,21 +33,15 @@ export async function POST() {
});
}
- const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews';
-
+ // Für ALLE aktiven Hotels nur den Preis-Scan anwerfen (OHNE n8n / OHNE LLM)
for (const hotel of activeHotels) {
- const n8nWebhookUrl = n8nBaseUrl.includes('?')
- ? `${n8nBaseUrl}&hotelId=${hotel.id}`
- : `${n8nBaseUrl}?hotelId=${hotel.id}`;
-
- // Direkt-URL zur Next.js App für Preishistorie
const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${hotel.id}`;
await scrapeQueue.add('scrape-hotel', {
hotelId: hotel.id,
url: hotel.booking_url,
- appWebhookUrl, // Für Preishistorie in Postgres
- webhookUrl: n8nWebhookUrl, // Für n8n KI-Reviews
+ appWebhookUrl, // Speichert den Preis in Postgres
+ webhookUrl: null, // KEIN n8n -> KOSTENLOS!
});
}
@@ -55,13 +49,10 @@ export async function POST() {
return NextResponse.json({
success: true,
- message: `${activeHotels.length} Scrape-Jobs erfolgreich gestartet.`,
+ message: `Preis-Scan für ${activeHotels.length} Hotels gestartet.`,
});
} catch (error: any) {
- console.error('Fehler beim Starten der Jobs:', error);
- return NextResponse.json(
- { success: false, error: error.message },
- { status: 500 }
- );
+ console.error('Fehler beim Starten des Preis-Scans:', error);
+ return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}
diff --git a/src/app/api/trips/route.ts b/src/app/api/trips/route.ts
index 85b9e35..7c66068 100644
--- a/src/app/api/trips/route.ts
+++ b/src/app/api/trips/route.ts
@@ -1,88 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
+export const dynamic = 'force-dynamic';
+
export async function GET() {
try {
- const tripsRes = await query('SELECT * FROM trips ORDER BY created_at DESC');
- const trips = tripsRes.rows;
-
- const hotelsRes = await query(`
- SELECT h.*,
- ph.price_numeric as latest_price,
- ph.genius_price_numeric as latest_genius_price,
- ph.price_per_night,
- ph.price_per_person,
- ph.nights,
- ph.persons,
- ph.tracked_at as last_tracked
- FROM hotels h
- LEFT JOIN LATERAL (
- SELECT * FROM hotel_price_history
- WHERE hotel_id = h.id
- ORDER BY tracked_at DESC LIMIT 1
- ) ph ON true
- ORDER BY h.created_at DESC
- `);
-
- // Zahlen-Typen parsen & fehlende Raten bei älteren Einträgen live berechnen
- const parsedHotels = hotelsRes.rows.map((h) => {
- const latest_price = h.latest_price ? parseFloat(h.latest_price) : null;
- let latest_genius_price = h.latest_genius_price ? parseFloat(h.latest_genius_price) : null;
- if (!latest_genius_price && latest_price) {
- latest_genius_price = parseFloat((latest_price * 0.90).toFixed(2));
- }
-
- const nights = h.nights ? parseInt(h.nights, 10) : 1;
- const persons = h.persons ? parseInt(h.persons, 10) : 2;
-
- const activePrice = latest_genius_price || latest_price;
-
- // Fallback-Berechnung falls DB-Feld NULL ist
- let price_per_night = h.price_per_night ? parseFloat(h.price_per_night) : null;
- if (!price_per_night && activePrice && nights > 0) {
- price_per_night = parseFloat((activePrice / nights).toFixed(2));
- }
-
- let price_per_person = h.price_per_person ? parseFloat(h.price_per_person) : null;
- if (!price_per_person && activePrice && nights > 0 && persons > 0) {
- price_per_person = parseFloat((activePrice / (nights * persons)).toFixed(2));
- }
-
- return {
- ...h,
- rating: h.rating ? parseFloat(h.rating) : null,
- latest_price,
- latest_genius_price,
- price_per_night,
- price_per_person,
- nights,
- persons,
- };
- });
-
- const tripsWithHotels = trips.map((trip) => ({
- ...trip,
- hotels: parsedHotels.filter((h) => h.trip_id === trip.id),
- }));
-
- return NextResponse.json({ success: true, trips: tripsWithHotels });
- } catch (e: any) {
- console.error('Error in GET /api/trips:', e);
- return NextResponse.json({ success: false, error: e.message }, { status: 500 });
+ const res = await query(`SELECT * FROM trips ORDER BY id ASC`);
+ return NextResponse.json({ success: true, trips: res.rows });
+ } catch (error: any) {
+ return NextResponse.json({ success: false, error: error.message, trips: [] }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
- const { title, destination } = await req.json();
- if (!title) return NextResponse.json({ error: 'Titel ist erforderlich' }, { status: 400 });
+ const { name } = await req.json();
+ if (!name) {
+ return NextResponse.json({ error: 'Name der Reise erforderlich' }, { status: 400 });
+ }
const res = await query(
- 'INSERT INTO trips (title, destination) VALUES ($1, $2) RETURNING *',
- [title, destination || null]
+ `INSERT INTO trips (name) VALUES ($1) RETURNING *`,
+ [name]
);
+
return NextResponse.json({ success: true, trip: res.rows[0] });
- } catch (e: any) {
- return NextResponse.json({ success: false, error: e.message }, { status: 500 });
+ } catch (error: any) {
+ return NextResponse.json({ success: false, error: error.message }, { status: 500 });
}
}
diff --git a/src/app/api/webhooks/scraper/route.ts b/src/app/api/webhooks/scraper/route.ts
index 399174d..9469305 100644
--- a/src/app/api/webhooks/scraper/route.ts
+++ b/src/app/api/webhooks/scraper/route.ts
@@ -1,90 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
+function parseGermanPrice(priceStr: any): number | null {
+ if (!priceStr) return null;
+ if (typeof priceStr === 'number') return Math.round(priceStr);
+
+ let cleaned = String(priceStr).replace(/[^0-9.,]/g, '').trim();
+ if (!cleaned) return null;
+
+ if (cleaned.includes('.') && !cleaned.includes(',')) {
+ cleaned = cleaned.replace(/\./g, '');
+ } else if (cleaned.includes('.') && cleaned.includes(',')) {
+ cleaned = cleaned.replace(/\./g, '').replace(',', '.');
+ } else if (cleaned.includes(',')) {
+ cleaned = cleaned.replace(',', '.');
+ }
+
+ const parsed = parseFloat(cleaned);
+ return isNaN(parsed) ? null : Math.round(parsed);
+}
+
export async function POST(req: NextRequest) {
try {
+ const body = await req.json().catch(() => ({}));
const { searchParams } = new URL(req.url);
- const hotelId = searchParams.get('hotelId');
- const body = await req.json();
-
- const {
- price,
- roomSizeM2,
- hotelName,
- rating,
- reviewCount,
- address,
- image,
- nights = 1,
- persons = 2,
- } = body;
+ const hotelId = searchParams.get('hotelId') || body.hotelId;
if (!hotelId) {
- return NextResponse.json({ error: 'hotelId fehlt' }, { status: 400 });
+ return NextResponse.json({ success: false, error: 'hotelId fehlt' }, { status: 400 });
}
- const parseNum = (val: any): number | null => {
- if (typeof val === 'number') return val;
- if (!val || typeof val !== 'string') return null;
- const match = val.replace(',', '.').match(/\d+(\.\d+)?/);
- return match ? parseFloat(match[0]) : null;
- };
+ const numericPrice = parseGermanPrice(body.price);
- const numPrice = parseNum(price);
-
- // Automatisch 10 % Genius Rabatt berechnen
- let numGenius: number | null = null;
- if (numPrice !== null) {
- numGenius = parseFloat((numPrice * 0.90).toFixed(2));
+ // 1. In hotel_price_history eintragen mit echten Spalten
+ if (numericPrice !== null && numericPrice > 0) {
+ try {
+ await query(
+ `INSERT INTO hotel_price_history (hotel_id, price_numeric, price_raw, currency, nights, persons, tracked_at)
+ VALUES ($1::uuid, $2, $3, 'EUR', $4, $5, NOW())`,
+ [hotelId, numericPrice, String(body.price || ''), body.nights || 1, body.persons || 2]
+ );
+ } catch (err: any) {
+ console.error('DB Insert Error (hotel_price_history):', err.message);
+ }
}
- // Ratenberechnung auf Basis des Genius-Preises
- const basePrice = numGenius || numPrice;
- let pricePerNight = null;
- let pricePerPersonPerNight = null;
-
- if (basePrice !== null && nights > 0 && persons > 0) {
- // 1. Zimmerpreis pro Nacht
- pricePerNight = parseFloat((basePrice / nights).toFixed(2));
- // 2. Preis pro Person & Nacht
- pricePerPersonPerNight = parseFloat((basePrice / (nights * persons)).toFixed(2));
- }
-
- // 1. In Preishistorie eintragen
- if (numPrice !== null) {
+ // 2. Stammdaten aktualisieren
+ try {
await query(
- `INSERT INTO hotel_price_history
- (hotel_id, price_numeric, genius_price_numeric, price_raw, price_per_night, price_per_person, nights, persons)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
+ `UPDATE hotels
+ SET name = COALESCE($1, name),
+ rating = COALESCE($2, rating),
+ review_count = COALESCE($3, review_count),
+ address = COALESCE($4, address),
+ image_url = COALESCE($5, image_url),
+ room_size_m2 = COALESCE($6, room_size_m2),
+ updated_at = NOW()
+ WHERE id = $7::uuid`,
[
- hotelId,
- numPrice,
- numGenius,
- price ? String(price) : null,
- pricePerNight,
- pricePerPersonPerNight,
- nights,
- persons,
+ body.hotelName || null,
+ body.rating || null,
+ body.reviewCount || null,
+ body.address || null,
+ body.image || null,
+ body.roomSizeM2 || null,
+ hotelId
]
);
+ } catch (err: any) {
+ console.error('DB Update Error (hotels):', err.message);
}
- // 2. Hotel Stammdaten aktualisieren
- await query(
- `UPDATE hotels
- SET name = COALESCE($1, name),
- rating = COALESCE($2, rating),
- review_count = COALESCE($3, review_count),
- address = COALESCE($4, address),
- image_url = COALESCE($5, image_url),
- room_size_m2 = COALESCE($6, room_size_m2)
- WHERE id = $7`,
- [hotelName, rating, reviewCount, address, image, roomSizeM2, hotelId]
- );
-
- return NextResponse.json({ success: true });
+ return NextResponse.json({ success: true, hotelId, parsedPrice: numericPrice });
} catch (error: any) {
- console.error('Webhook Error:', error);
- return NextResponse.json({ success: false, error: error.message }, { status: 500 });
+ console.error('Allgemeiner Fehler im Webhook:', error.message);
+ return NextResponse.json({ success: false, error: error.message }, { status: 200 });
}
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 6dd959c..5b3a211 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -2,69 +2,108 @@
import { useState, useEffect } from 'react';
+interface Trip {
+ id: string;
+ name: string;
+}
+
+interface PriceEntry {
+ id: string;
+ price: number;
+ price_per_night?: number | null;
+ price_per_person?: number | null;
+ nights: number;
+ persons: number;
+ created_at: string;
+}
+
interface Hotel {
id: string;
name: string;
booking_url: string;
is_active: boolean;
- latest_price: number | string | null;
- latest_genius_price: number | string | null;
- price_per_night: number | string | null;
- price_per_person: number | string | null;
- nights: number | null;
- persons: number | null;
- rating: number | string | null;
- review_count: number | null;
- room_size_m2: number | null;
- address: string | null;
- image_url: string | null;
+ trip_id?: string | null;
+ trip_name?: string | null;
+ latest_price?: number | null;
+ latest_price_per_night?: number | null;
+ latest_price_per_person?: number | null;
+ latest_nights?: number | null;
+ latest_persons?: number | null;
+ rating?: number | null;
+ review_count?: number | null;
+ image_url?: string | null;
+ address?: string | null;
+ room_size_m2?: number | null;
review_summary_positive?: string | null;
review_summary_passable?: string | null;
review_summary_negative?: string | null;
review_summary_bathroom?: string | null;
+ review_summary_count?: number | null;
reviews_updated_at?: string | null;
+ checkin_date?: string | null;
+ checkout_date?: string | null;
}
-interface Trip {
- id: string;
- title: string;
- destination: string | null;
+interface HotelCluster {
+ clusterKey: string;
+ checkin_date?: string | null;
+ checkout_date?: string | null;
hotels: Hotel[];
}
-interface PriceHistoryEntry {
- id: string;
- price_numeric: number;
- genius_price_numeric: number | null;
- price_per_night: number | null;
- price_per_person: number | null;
- nights: number | null;
- persons: number | null;
- tracked_at: string;
-}
-
-export default function Dashboard() {
+export default function TravelDashboard() {
const [trips, setTrips] = useState([]);
+ const [activeTripId, setActiveTripId] = useState('ALL');
+ const [hotels, setHotels] = useState([]);
const [loading, setLoading] = useState(true);
+ const [scanningAllPrices, setScanningAllPrices] = useState(false);
+ const [scanningHotelId, setScanningHotelId] = useState(null);
- // Detail / Modal State
- const [selectedHotel, setSelectedHotel] = useState(null);
- const [activeTab, setActiveTab] = useState<'history' | 'reviews'>('history');
- const [history, setHistory] = useState([]);
- const [loadingHistory, setLoadingHistory] = useState(false);
+ // Preishistorie Modal
+ const [selectedPriceHotel, setSelectedPriceHotel] = useState(null);
+ const [priceHistory, setPriceHistory] = useState([]);
+ const [loadingPrices, setLoadingPrices] = useState(false);
- // Forms
- const [newTripTitle, setNewTripTitle] = useState('');
- const [newTripDest, setNewTripDest] = useState('');
- const [selectedTripId, setSelectedTripId] = useState(null);
+ // Modals
+ const [showAddTripModal, setShowAddTripModal] = useState(false);
+ const [newTripName, setNewTripName] = useState('');
+ const [showAddHotelModal, setShowAddHotelModal] = useState(false);
const [newBookingUrl, setNewBookingUrl] = useState('');
+ const [newHotelName, setNewHotelName] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [showParkedModal, setShowParkedModal] = useState(false);
+
+ useEffect(() => {
+ loadData();
+ }, []);
+
+ const formatDateGerman = (dateStr?: string | null) => {
+ if (!dateStr) return '';
+ const date = new Date(dateStr);
+ return isNaN(date.getTime())
+ ? dateStr
+ : date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
+ };
+
+ const calculateNights = (checkin?: string | null, checkout?: string | null) => {
+ if (!checkin || !checkout) return null;
+ const start = new Date(checkin).getTime();
+ const end = new Date(checkout).getTime();
+ if (isNaN(start) || isNaN(end)) return null;
+ const diffDays = Math.round((end - start) / (1000 * 60 * 60 * 24));
+ return diffDays > 0 ? diffDays : null;
+ };
const loadData = async () => {
setLoading(true);
try {
- const res = await fetch('/api/trips');
- const data = await res.json();
- if (data.success) setTrips(data.trips);
+ const resTrips = await fetch('/api/trips');
+ const dataTrips = await resTrips.json();
+ setTrips(dataTrips.trips || []);
+
+ const resHotels = await fetch('/api/hotels');
+ const dataHotels = await resHotels.json();
+ setHotels(dataHotels.hotels || []);
} catch (e) {
console.error('Fehler beim Laden:', e);
} finally {
@@ -72,321 +111,657 @@ export default function Dashboard() {
}
};
- useEffect(() => { loadData(); }, []);
-
- const openHotelDetails = async (hotel: Hotel) => {
- setSelectedHotel(hotel);
- setActiveTab('history');
- setHistory([]);
- setLoadingHistory(true);
+ const openPriceHistoryModal = async (hotel: Hotel) => {
+ setSelectedPriceHotel(hotel);
+ setLoadingPrices(true);
+ setPriceHistory([]);
try {
- const res = await fetch(`/api/hotels/${hotel.id}/history`);
+ const res = await fetch(`/api/hotels/${hotel.id}/prices`);
const data = await res.json();
- if (data.success) setHistory(data.history);
+ if (data.success) {
+ setPriceHistory(data.prices || []);
+ }
} catch (e) {
- console.error('Fehler beim Laden der Historie:', e);
+ console.error('Fehler beim Laden der Preise:', e);
} finally {
- setLoadingHistory(false);
+ setLoadingPrices(false);
}
};
- const handleCreateTrip = async (e: React.FormEvent) => {
+ const handleAddTrip = async (e: React.FormEvent) => {
e.preventDefault();
- if (!newTripTitle) return;
- await fetch('/api/trips', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ title: newTripTitle, destination: newTripDest })
+ if (!newTripName.trim()) return;
+ try {
+ const res = await fetch('/api/trips', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newTripName.trim() })
+ });
+ const data = await res.json();
+ if (data.success) {
+ setTrips([...trips, data.trip]);
+ setActiveTripId(String(data.trip.id));
+ setNewTripName('');
+ setShowAddTripModal(false);
+ }
+ } catch (e: any) {
+ alert('Fehler: ' + e.message);
+ }
+ };
+
+ const handleAddHotel = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newBookingUrl) return alert('Bitte URL eingeben');
+ setIsSubmitting(true);
+ try {
+ const targetTripId = activeTripId === 'ALL' ? (trips[0]?.id || null) : activeTripId;
+ const res = await fetch('/api/hotels', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: newHotelName || 'Neues Hotel',
+ booking_url: newBookingUrl,
+ trip_id: targetTripId
+ })
+ });
+ const data = await res.json();
+ if (res.ok || data.success) {
+ setNewBookingUrl('');
+ setNewHotelName('');
+ setShowAddHotelModal(false);
+ loadData();
+ }
+ } catch (err: any) {
+ alert('Fehler: ' + err.message);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // Hotel ausmustern (parken)
+ const parkHotel = async (id: string, name: string) => {
+ try {
+ await fetch(`/api/hotels/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ is_active: false })
+ });
+ setHotels(hotels.map(h => h.id === id ? { ...h, is_active: false } : h));
+ } catch (e) {
+ console.error('Fehler beim Ausmustern:', e);
+ }
+ };
+
+ // Ausgemustertes Hotel wiederherstellen
+ const restoreHotel = async (id: string) => {
+ try {
+ await fetch(`/api/hotels/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ is_active: true })
+ });
+ setHotels(hotels.map(h => h.id === id ? { ...h, is_active: true } : h));
+ } catch (e) {
+ console.error('Fehler beim Wiederaufnehmen:', e);
+ }
+ };
+
+ const triggerBatchPriceScan = async () => {
+ setScanningAllPrices(true);
+ try {
+ const res = await fetch('/api/jobs/trigger-daily-check', { method: 'POST' });
+ const data = await res.json();
+ alert(data.success ? '📊 Preis-Scan gestartet!' : 'Fehler: ' + data.error);
+ setTimeout(loadData, 3000);
+ } finally {
+ setScanningAllPrices(false);
+ }
+ };
+
+ 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);
+ } finally {
+ setScanningHotelId(null);
+ }
+ };
+
+ // 1. Nur AKTIVE Hotels filtern und chronologisch sortieren
+ const currentHotels = hotels
+ .filter(h => h.is_active)
+ .filter(h => {
+ if (activeTripId === 'ALL') return true;
+ if (!h.trip_id) return true;
+ return String(h.trip_id).toLowerCase() === String(activeTripId).toLowerCase();
+ })
+ .sort((a, b) => {
+ if (!a.checkin_date && !b.checkin_date) return 0;
+ if (!a.checkin_date) return 1;
+ if (!b.checkin_date) return -1;
+ return new Date(a.checkin_date).getTime() - new Date(b.checkin_date).getTime();
});
- setNewTripTitle('');
- setNewTripDest('');
- loadData();
- };
- const handleAddHotel = async (tripId: string) => {
- if (!newBookingUrl) return alert('Bitte Booking.com URL eingeben');
- await fetch('/api/hotels', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ trip_id: tripId, booking_url: newBookingUrl })
- });
- setNewBookingUrl('');
- setSelectedTripId(null);
- loadData();
- };
+ // 2. Ausgemusterte (inaktive) Hotels für das Modal ermitteln
+ const parkedHotels = hotels.filter(h => !h.is_active);
- const toggleHotelActive = async (e: React.SyntheticEvent, hotelId: string, currentStatus: boolean) => {
- e.stopPropagation();
- await fetch(`/api/hotels/${hotelId}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ is_active: !currentStatus })
- });
- loadData();
- };
+ // 3. Clustering der aktiven Hotels
+ const clusteredStops = currentHotels.reduce((acc, hotel) => {
+ const checkin = hotel.checkin_date ? hotel.checkin_date.split('T')[0] : null;
+ const checkout = hotel.checkout_date ? hotel.checkout_date.split('T')[0] : null;
- const deleteHotel = async (e: React.MouseEvent, hotelId: string) => {
- e.stopPropagation();
- if (!confirm('Hotel wirklich löschen?')) return;
- await fetch(`/api/hotels/${hotelId}`, { method: 'DELETE' });
- loadData();
- };
+ if (!checkin) {
+ acc.push({
+ clusterKey: `single_${hotel.id}`,
+ checkin_date: null,
+ checkout_date: null,
+ hotels: [hotel]
+ });
+ return acc;
+ }
- const triggerScrape = async () => {
- alert('Scrape & Review-Analyse gestartet...');
- await fetch('/api/jobs/trigger-daily-check', { method: 'POST' });
- setTimeout(() => loadData(), 12000);
- };
+ const key = `${checkin}_${checkout || 'NO_CHECKOUT'}`;
+ const existingCluster = acc.find(c => c.clusterKey === key);
+
+ if (existingCluster) {
+ existingCluster.hotels.push(hotel);
+ } else {
+ acc.push({
+ clusterKey: key,
+ checkin_date: checkin,
+ checkout_date: checkout,
+ hotels: [hotel]
+ });
+ }
+ return acc;
+ }, []);
+
+ const pricesList = priceHistory.map(p => Number(p.price));
+ const minPrice = pricesList.length > 0 ? Math.min(...pricesList) : 0;
+ const maxPrice = pricesList.length > 0 ? Math.max(...pricesList) : 0;
+ const currentPrice = pricesList.length > 0 ? pricesList[pricesList.length - 1] : 0;
return (
-
-
- ✈️ Reiseplaner & Preis-Tracker
-
+
+
+
+ 📍 Reise-Ablauf & Timeline
+ Routenplanung, Datums-Cluster, Preishistorie & KI-Bewertungen
+
+
+
+
+
+
- {/* Neue Reise */}
-
- ➕ Neue Reise planen
-
-
+ {/* TABS */}
+
+
+ {trips.map(trip => (
+
+ ))}
+
+
- {/* Reisen Liste */}
- {loading ?
Lade Reisen...
: trips.map((trip) => (
-
-
-
-
{trip.title}
-
📍 {trip.destination || 'Kein Zielort angegeben'}
+ {/* MODAL: AUSGEMUSTERTE HOTELS */}
+ {showParkedModal && (
+
+
+
+
+
📦 Ausgemusterte Hotels
+
Diese Hotels sind aktuell nicht Teil deiner Timeline, bleiben aber gespeichert.
+
+
+
+
+ {parkedHotels.length === 0 ? (
+
🎉 Aktuell sind keine Hotels ausgemustert. Alle Hotels befinden sich in deiner Route!
+ ) : (
+
+ {parkedHotels.map(h => (
+
+
+
{h.name}
+
+ {h.checkin_date ? `🗓️ ${formatDateGerman(h.checkin_date)} – ${formatDateGerman(h.checkout_date)}` : 'Kein Datum'}
+ {h.latest_price ? ` • 💰 Gesamt: ${h.latest_price.toLocaleString('de-DE')} €` : ''}
+
+
+
+
+ ))}
+
+ )}
-
+
+ )}
- {selectedTripId === trip.id && (
-
-
setNewBookingUrl(e.target.value)} style={{ ...styles.input, width: '100%', marginBottom: '10px' }} />
-
+ {/* PREISHISTORIE MODAL */}
+ {selectedPriceHotel && (
+
+
+
+
+
📈 Preishistorie: {selectedPriceHotel.name}
+
Entwicklung aller erfassten Preis-Scans
+
+
- )}
-
- {trip.hotels.length === 0 ? (
-
Noch keine Hotels angelegt.
+ {loadingPrices ? (
+
Lade Preishistorie...
+ ) : priceHistory.length === 0 ? (
+
Noch keine Preishistorie vorhanden. Klicke oben auf "Preise aller Hotels aktualisieren".
) : (
- trip.hotels.map((hotel) => {
- const pReg = hotel.latest_price !== null ? parseFloat(String(hotel.latest_price)) : null;
- const pGenius = hotel.latest_genius_price !== null ? parseFloat(String(hotel.latest_genius_price)) : (pReg ? pReg * 0.9 : null);
- const nights = hotel.nights ? parseInt(String(hotel.nights), 10) : 1;
- const persons = hotel.persons ? parseInt(String(hotel.persons), 10) : 2;
- const activePrice = pGenius || pReg;
+
+
+
+
Tiefstpreis
+
{minPrice} €
+
+
+
Aktuellster Preis
+
{currentPrice} €
+
+
+
Höchstpreis
+
{maxPrice} €
+
+
- const pNight = hotel.price_per_night ? parseFloat(String(hotel.price_per_night)).toFixed(2) : (activePrice ? (activePrice / nights).toFixed(2) : '-');
- const pPerson = hotel.price_per_person ? parseFloat(String(hotel.price_per_person)).toFixed(2) : (activePrice ? (activePrice / (nights * persons)).toFixed(2) : '-');
+ {priceHistory.length > 1 && (
+
+
+
+ )}
- return (
-
openHotelDetails(hotel)}
- style={{
- ...styles.hotelRow,
- opacity: hotel.is_active ? 1 : 0.6,
- backgroundColor: hotel.is_active ? '#fff' : '#f9f9f9',
- cursor: 'pointer'
- }}
- >
-
- {hotel.image_url && (
-

- )}
-
-
{hotel.name || 'Unbekanntes Hotel'}
-
{hotel.address || 'Keine Adresse'}
-
-
+ )}
+
+
+ )}
+
+ {/* MODAL: NEUE REISE */}
+ {showAddTripModal && (
+
+
+
🌍 Neue Reise anlegen
+
+
+
+ )}
+
+ {/* MODAL: HOTEL HINZUFÜGEN */}
+ {showAddHotelModal && (
+
+
+
➕ Neues Hotel hinzufügen
+
Check-in/Check-out Datum wird automatisch aus dem Link gelesen.
+
+
+
+
+ )}
+
+ {/* TIMELINE MIT CLUSTERING */}
+ {loading ? (
+
Lade Daten...
+ ) : clusteredStops.length === 0 ? (
+
+
Keine aktiven Hotels für diese Auswahl gefunden.
+
+
+ ) : (
+
+ {clusteredStops.map((stop, stopIndex) => {
+ const isCluster = stop.hotels.length > 1;
+ const nights = calculateNights(stop.checkin_date, stop.checkout_date) || stop.hotels[0]?.latest_nights || 1;
+
+ return (
+
+ {/* Timeline Knotenpunkt */}
+
+
+ {/* Stop Header */}
+
+
+ 📍 Stop #{stopIndex + 1}
+
+ {stop.checkin_date ? (
+
+ 🗓️ {formatDateGerman(stop.checkin_date)} – {formatDateGerman(stop.checkout_date) || '?'} ({nights} {nights === 1 ? 'Nacht' : 'Nächte'})
+
+ ) : (
+ (Warte auf Scan für Datum)
+ )}
+
+ {isCluster && (
+
+ ⚖️ {stop.hotels.length} Optionen im Vergleich
+
+ )}
+
+
+
+ {/* Container: Cluster-Hintergrund wenn mehrere Hotels */}
+
+ {stop.hotels.map((hotel, hIdx) => {
+ const hotelNights = calculateNights(hotel.checkin_date, hotel.checkout_date) || hotel.latest_nights || nights || 1;
+ const persons = hotel.latest_persons || 2;
+ const totalPrice = hotel.latest_price !== null && hotel.latest_price !== undefined ? Number(hotel.latest_price) : null;
+ const pricePerNight = hotel.latest_price_per_night
+ ? Math.round(Number(hotel.latest_price_per_night))
+ : (totalPrice ? Math.round(totalPrice / hotelNights) : null);
+ const pricePerPersonPerNight = hotel.latest_price_per_person
+ ? Math.round(Number(hotel.latest_price_per_person))
+ : (totalPrice ? Math.round(totalPrice / (hotelNights * persons)) : null);
+
+ const optionColors = ['#2563eb', '#7c3aed', '#059669', '#d97706'];
+ const currentAccent = optionColors[hIdx % optionColors.length];
+
+ return (
+
+
+ {/* Option Header & Ausmustern-Button */}
+
+ {isCluster ? (
+
+ Option {String.fromCharCode(65 + hIdx)}
+
+ ) : (
+
{hotel.trip_name && {hotel.trip_name}}
+ )}
+
+
+
+
+ {/* Hotel Body */}
+
+ {hotel.image_url &&

}
+
+
{hotel.name}
+
📍 {hotel.address || 'Keine Adresse hinterlegt'}
+
+
+
+ {/* Preis-Box */}
+ {totalPrice !== null ? (
+
+
+ 💰 Gesamt: {totalPrice.toLocaleString('de-DE')} €
+
+
+ | 🌙 {pricePerNight?.toLocaleString('de-DE')} € / N.
+
+
+ | 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.
+
+
+ ) : (
+
+ 💰 Noch kein Preis erfasst
+
+ )}
+
+ {/* Meta Tags */}
+
+ ⭐ {hotel.rating || 'N/A'}/10
+ 💬 {hotel.review_count || 0} Bew.
+ {hotel.room_size_m2 && 📐 {hotel.room_size_m2} m²}
+
+
+ {/* KI Reviews Accordion */}
+ {(hotel.review_summary_positive || hotel.review_summary_bathroom) && (
+
+
+
+ 📋 KI-Zusammenfassung ({hotel.review_summary_count || 'viele'} Reviews)
+
+
+ {hotel.review_summary_positive && (
+
+ 💚 Highlights:
{hotel.review_summary_positive}
+
+ )}
+ {hotel.review_summary_passable && (
+
+ 🟡 Neutral:
{hotel.review_summary_passable}
+
+ )}
+ {hotel.review_summary_negative && (
+
+ 🔴 Mängel:
{hotel.review_summary_negative}
+
+ )}
+ {hotel.review_summary_bathroom && (
+
+ 🚽 Bad & WC:
{hotel.review_summary_bathroom}
+
+ )}
+
+
+
+ )}
+
+
+ {/* Action Buttons */}
+
+
+
+
+ 🔗 Booking ↗
-
-
-
-
- {pReg !== null ? (
-
-
Regulär: {pReg.toFixed(2)} €
-
- Genius
- {pGenius ? `${pGenius.toFixed(2)} €` : '-'}
-
-
-
-
{pNight} € / Nacht (Zimmer)
-
{pPerson} € / Pers. & Nacht
-
({nights} {nights === 1 ? 'Nacht' : 'Nächte'}, {persons} Pers.)
-
-
- ) : (
-
Kein Preis
- )}
-
-
-
-
-
-
-
- );
- })
- )}
-
-
- ))}
-
- {/* Detail Modal (Historie & KI Reviews) */}
- {selectedHotel && (
-
setSelectedHotel(null)}>
-
e.stopPropagation()}>
-
-
-
-
- {/* Tab Navigation */}
-
-
-
-
-
- {/* TAB 1: PREISHISTORIE */}
- {activeTab === 'history' && (
- loadingHistory ?
Lade Historie...
: (
-
-
-
- | Datum |
- Regulär |
- Genius (-10%) |
- Pro Nacht |
- Pro Person/Nacht |
-
-
-
- {history.map((entry) => (
-
- | {new Date(entry.tracked_at).toLocaleString('de-DE')} |
- {entry.price_numeric} € |
- {entry.genius_price_numeric || (entry.price_numeric * 0.9).toFixed(2)} € |
- {entry.price_per_night ? `${entry.price_per_night} €` : '-'} |
- {entry.price_per_person ? `${entry.price_per_person} €` : '-'} |
-
- ))}
-
-
- )
- )}
-{/* TAB 2: KI REVIEWS */}
-{activeTab === 'reviews' && (
-
- {!selectedHotel.review_summary_positive ? (
-
- Noch keine KI-Analyse vorhanden. Klicke oben auf "Alle Hotels scannen", um die Bewertungen zu scrapen.
-
- ) : (
-
- {/* 1. Bestnoten */}
-
-
💚 Bestnoten & Highlights (Positiv)
-
- {selectedHotel.review_summary_positive}
-
-
-
- {/* 2. Passabel */}
-
-
🟡 Passabel & Neutrales
-
- {selectedHotel.review_summary_passable}
-
-
-
- {/* 3. Schlechteste */}
-
-
🔴 Schlechteste & Mängel (Kritik)
-
- {selectedHotel.review_summary_negative}
-
-
-
- {/* 4. BAD & WC FOKUS (NEU) */}
-
-
🚽 Bad & WC Fokus
-
- {selectedHotel.review_summary_bathroom || 'Keine spezifischen Anmerkungen zum Bad/WC in den Kommentaren gefunden.'}
-
-
-
- {selectedHotel.reviews_updated_at && (
-
- Zuletzt analysiert: {new Date(selectedHotel.reviews_updated_at).toLocaleString('de-DE')}
-
- )}
-
- )}
-
-)}
-
+ );
+ })}
)}
);
-}
-
-const styles: Record
= {
- container: { maxWidth: '980px', margin: '0 auto', padding: '2rem', fontFamily: 'system-ui, sans-serif' },
- header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' },
- card: { background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.05)', marginBottom: '2rem' },
- tripCard: { background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: '1.5rem' },
- tripHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #eee', paddingBottom: '0.8rem' },
- inlineForm: { display: 'flex', gap: '1rem', marginTop: '1rem' },
- input: { padding: '0.6rem 1rem', borderRadius: '4px', border: '1px solid #ccc', flex: 1 },
- btn: { padding: '0.6rem 1.2rem', background: '#333', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' },
- primaryBtn: { padding: '0.6rem 1.2rem', background: '#0070f3', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold' },
- btnSecondary: { padding: '0.4rem 0.8rem', background: '#e0e0e0', border: 'none', borderRadius: '4px', cursor: 'pointer' },
- tabBtn: { padding: '0.6rem 1rem', background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.95rem' },
- dangerBtn: { background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.1rem' },
- addHotelBox: { background: '#f8f9fa', padding: '1rem', borderRadius: '6px', marginTop: '1rem', border: '1px solid #e9ecef' },
- hotelRow: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem', borderRadius: '6px', marginBottom: '0.5rem', border: '1px solid #eee', transition: 'all 0.2s' },
- badge: { background: '#f0f0f0', padding: '3px 7px', borderRadius: '4px', color: '#444', fontSize: '0.75rem' },
- bookingLinkBtn: { background: '#003580', color: '#fff', padding: '3px 8px', borderRadius: '4px', fontSize: '0.75rem', textDecoration: 'none', fontWeight: 'bold' },
- geniusTag: { background: '#003580', color: '#fff', fontSize: '0.7rem', padding: '2px 6px', borderRadius: '4px', marginRight: '6px', fontWeight: 'bold' },
- modalOverlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 9999 },
- modalContent: { background: '#fff', padding: '2rem', borderRadius: '8px', width: '90%', maxWidth: '850px', maxHeight: '80vh', overflowY: 'auto' }
-};
+}
\ No newline at end of file