3096 lines
130 KiB
Plaintext
3096 lines
130 KiB
Plaintext
========================================
|
||
PROJEKTSTRUKTUR
|
||
========================================
|
||
(Ermittelt via Git unter Berücksichtigung von .gitignore)
|
||
.gitignore
|
||
Dockerfile
|
||
README.md
|
||
copyChat.sh
|
||
docker-compose.yml
|
||
llm_context.txt
|
||
next.config.js
|
||
package-lock.json
|
||
package.json
|
||
src/app/api/attractions/[id]/route.ts
|
||
src/app/api/attractions/route.ts
|
||
src/app/api/hotels/[id]/attractions/route.ts
|
||
src/app/api/hotels/[id]/history/route.ts
|
||
src/app/api/hotels/[id]/prices/route.ts
|
||
src/app/api/hotels/[id]/ratings/route.ts
|
||
src/app/api/hotels/[id]/review-summary/route.ts
|
||
src/app/api/hotels/[id]/route.ts
|
||
src/app/api/hotels/[id]/scan/route.ts
|
||
src/app/api/hotels/route.ts
|
||
src/app/api/jobs/trigger-daily-check/route.ts
|
||
src/app/api/trips/[id]/members/route.ts
|
||
src/app/api/trips/route.ts
|
||
src/app/api/webhooks/scraper/route.ts
|
||
src/app/layout.tsx
|
||
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?
|
||
|
||
node_modules
|
||
.next
|
||
.git
|
||
.gitignore
|
||
llm_context.txt
|
||
*.log
|
||
Dockerfile
|
||
docker-compose.yml
|
||
|
||
```
|
||
|
||
--- 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
|
||
volumes:
|
||
# Spiegelt deinen lokalen Quellcode live in den Container:
|
||
- ./src:/app/src
|
||
- ./public:/app/public
|
||
command: npm run dev
|
||
```
|
||
|
||
--- DATEI: next.config.js ---
|
||
```
|
||
/** @type {import('next').NextConfig} */
|
||
const nextConfig = {
|
||
experimental: {
|
||
serverComponentsExternalPackages: ['bullmq', 'ioredis'],
|
||
},
|
||
};
|
||
|
||
module.exports = 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/attractions/[id]/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { query } from '@/lib/db';
|
||
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||
|
||
async function parseGoogleMapsUrl(inputUrl: string) {
|
||
let targetUrl = inputUrl.trim();
|
||
if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) {
|
||
try {
|
||
const res = await fetch(targetUrl, {
|
||
method: 'GET',
|
||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
|
||
redirect: 'follow',
|
||
});
|
||
targetUrl = res.url || targetUrl;
|
||
} catch (err) {}
|
||
}
|
||
let lat: number | null = null;
|
||
let lon: number | null = null;
|
||
const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||
if (coordMatch) {
|
||
lat = parseFloat(coordMatch[1]);
|
||
lon = parseFloat(coordMatch[2]);
|
||
}
|
||
return { lat, lon };
|
||
}
|
||
|
||
async function geocode(address: string) {
|
||
if (!address || address.startsWith('http')) return null;
|
||
const search = async (q: string) => {
|
||
try {
|
||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1`;
|
||
const res = await fetch(url, { headers: { 'User-Agent': 'TravelPlannerApp/1.0' } });
|
||
const data = await res.json();
|
||
if (data && data.length > 0) return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
|
||
} catch (err) {}
|
||
return null;
|
||
};
|
||
|
||
let coords = await search(address);
|
||
if (!coords && address.includes(',')) {
|
||
const parts = address.split(',').map(p => p.trim());
|
||
await sleep(1100);
|
||
coords = await search(`${parts[parts.length - 2]}, ${parts[parts.length - 1]}`);
|
||
}
|
||
return coords;
|
||
}
|
||
|
||
async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) {
|
||
try {
|
||
const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`;
|
||
const res = await fetch(url);
|
||
const data = await res.json();
|
||
if (data.code === 'Ok' && data.routes?.length > 0) {
|
||
return {
|
||
distanceKm: Math.round((data.routes[0].distance / 1000) * 10) / 10,
|
||
durationMin: Math.round(data.routes[0].duration / 60),
|
||
};
|
||
}
|
||
} catch (err) {}
|
||
return { distanceKm: null, durationMin: null };
|
||
}
|
||
|
||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
||
try {
|
||
const { id } = params;
|
||
const { name, address, category_icon, notes, hotel_ids, trip_id } = await req.json();
|
||
|
||
let sightCoords: { lat: number; lon: number } | null = null;
|
||
if (address.includes('google.com/maps') || address.includes('goo.gl')) {
|
||
const parsed = await parseGoogleMapsUrl(address);
|
||
if (parsed.lat && parsed.lon) sightCoords = { lat: parsed.lat, lon: parsed.lon };
|
||
}
|
||
if (!sightCoords) {
|
||
sightCoords = await geocode(address || name);
|
||
}
|
||
|
||
const res = await query(
|
||
`UPDATE hotel_attractions
|
||
SET name = $1,
|
||
address = $2,
|
||
category_icon = $3,
|
||
notes = $4,
|
||
trip_id = $5,
|
||
lat = COALESCE($6, lat),
|
||
lon = COALESCE($7, lon)
|
||
WHERE id = $8
|
||
RETURNING *`,
|
||
[
|
||
name,
|
||
address,
|
||
category_icon || '🏛️',
|
||
notes || null,
|
||
trip_id || null,
|
||
sightCoords?.lat || null,
|
||
sightCoords?.lon || null,
|
||
id,
|
||
]
|
||
);
|
||
|
||
await query('DELETE FROM hotel_attraction_assignments WHERE attraction_id = $1', [id]);
|
||
|
||
const selectedHotelIds: string[] = Array.isArray(hotel_ids) ? hotel_ids : [];
|
||
for (const hId of selectedHotelIds) {
|
||
let distanceKm: number | null = null;
|
||
let durationMin: number | null = null;
|
||
|
||
const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hId]);
|
||
if (hotelRes.rows.length > 0) {
|
||
const hotel = hotelRes.rows[0];
|
||
await sleep(1100);
|
||
let hCoords = await geocode(hotel.address);
|
||
if (!hCoords && hotel.name) {
|
||
await sleep(1100);
|
||
hCoords = await geocode(hotel.name);
|
||
}
|
||
|
||
if (hCoords && sightCoords) {
|
||
const drive = await getDriveInfo(hCoords, sightCoords);
|
||
distanceKm = drive.distanceKm;
|
||
durationMin = drive.durationMin;
|
||
}
|
||
}
|
||
|
||
await query(
|
||
`INSERT INTO hotel_attraction_assignments (attraction_id, hotel_id, distance_km, duration_minutes)
|
||
VALUES ($1, $2, $3, $4)`,
|
||
[id, hId, distanceKm, durationMin]
|
||
);
|
||
}
|
||
|
||
return NextResponse.json({ success: true, attraction: res.rows[0] });
|
||
} catch (e: any) {
|
||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||
try {
|
||
await query('DELETE FROM hotel_attractions WHERE id = $1', [params.id]);
|
||
return NextResponse.json({ success: true });
|
||
} catch (e: any) {
|
||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||
}
|
||
}
|
||
```
|
||
|
||
--- DATEI: src/app/api/attractions/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { query } from '@/lib/db';
|
||
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||
|
||
async function parseGoogleMapsUrl(inputUrl: string) {
|
||
let targetUrl = inputUrl.trim();
|
||
|
||
if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) {
|
||
try {
|
||
const res = await fetch(targetUrl, {
|
||
method: 'GET',
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||
},
|
||
redirect: 'follow',
|
||
});
|
||
targetUrl = res.url || targetUrl;
|
||
} catch (err) {
|
||
console.error('Fehler beim Auflösen des Google Maps Links:', err);
|
||
}
|
||
}
|
||
|
||
let extractedName: string | null = null;
|
||
let lat: number | null = null;
|
||
let lon: number | null = null;
|
||
|
||
const placeMatch = targetUrl.match(/\/place\/([^/@?]+)/);
|
||
if (placeMatch && placeMatch[1]) {
|
||
extractedName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||
}
|
||
|
||
const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||
if (coordMatch) {
|
||
lat = parseFloat(coordMatch[1]);
|
||
lon = parseFloat(coordMatch[2]);
|
||
} else {
|
||
const dataCoordMatch = targetUrl.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||
if (dataCoordMatch) {
|
||
lat = parseFloat(dataCoordMatch[1]);
|
||
lon = parseFloat(dataCoordMatch[2]);
|
||
} else {
|
||
const queryCoordMatch = targetUrl.match(/[?&](?:q|query|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||
if (queryCoordMatch) {
|
||
lat = parseFloat(queryCoordMatch[1]);
|
||
lon = parseFloat(queryCoordMatch[2]);
|
||
}
|
||
}
|
||
}
|
||
|
||
return { name: extractedName, lat, lon, resolvedUrl: targetUrl };
|
||
}
|
||
|
||
// Geocoding mit intelligentem Adress-Fallback
|
||
async function geocode(address: string): Promise<{ lat: number; lon: number } | null> {
|
||
if (!address || address.startsWith('http')) return null;
|
||
|
||
const search = async (q: string) => {
|
||
try {
|
||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1`;
|
||
const res = await fetch(url, {
|
||
headers: { 'User-Agent': 'TravelPlannerApp/1.0 (Homelab Private Usage)' },
|
||
});
|
||
if (res.status === 429) {
|
||
await sleep(1500);
|
||
return null;
|
||
}
|
||
const data = await res.json();
|
||
if (data && data.length > 0) {
|
||
return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
|
||
}
|
||
} catch (err) {
|
||
console.error(`Geocoding Fehler bei "${q}":`, err);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// 1. Exakter Adresstext
|
||
let coords = await search(address);
|
||
if (coords) return coords;
|
||
|
||
// 2. Fallback: Ort und Land aus der Komma-Kette filtern
|
||
const parts = address.split(',').map(p => p.trim());
|
||
if (parts.length > 2) {
|
||
await sleep(1100);
|
||
const cityFallback = `${parts[parts.length - 2]}, ${parts[parts.length - 1]}`;
|
||
coords = await search(cityFallback);
|
||
}
|
||
|
||
return coords;
|
||
}
|
||
|
||
// OSRM Routing-Berechnung
|
||
async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) {
|
||
try {
|
||
const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`;
|
||
const res = await fetch(url);
|
||
const data = await res.json();
|
||
if (data.code === 'Ok' && data.routes && data.routes.length > 0) {
|
||
const distanceKm = Math.round((data.routes[0].distance / 1000) * 10) / 10;
|
||
const durationMin = Math.round(data.routes[0].duration / 60);
|
||
return { distanceKm, durationMin };
|
||
}
|
||
} catch (err) {
|
||
console.error('OSRM Routing Fehler:', err);
|
||
}
|
||
return { distanceKm: null, durationMin: null };
|
||
}
|
||
|
||
// GET: Alle Sehenswürdigkeiten laden
|
||
export async function GET(req: NextRequest) {
|
||
try {
|
||
const { searchParams } = new URL(req.url);
|
||
const tripId = searchParams.get('tripId');
|
||
|
||
let sql = `
|
||
SELECT
|
||
a.*,
|
||
COALESCE(
|
||
json_agg(
|
||
json_build_object(
|
||
'hotel_id', haa.hotel_id,
|
||
'distance_km', haa.distance_km,
|
||
'duration_minutes', haa.duration_minutes
|
||
)
|
||
) FILTER (WHERE haa.id IS NOT NULL),
|
||
'[]'
|
||
) AS assignments
|
||
FROM hotel_attractions a
|
||
LEFT JOIN hotel_attraction_assignments haa ON a.id = haa.attraction_id
|
||
WHERE 1=1
|
||
`;
|
||
const params: any[] = [];
|
||
|
||
if (tripId && tripId !== 'ALL') {
|
||
params.push(tripId);
|
||
sql += ` AND a.trip_id = $${params.length}`;
|
||
}
|
||
|
||
sql += ' GROUP BY a.id ORDER BY a.created_at ASC';
|
||
const res = await query(sql, params);
|
||
return NextResponse.json({ success: true, attractions: res.rows });
|
||
} catch (e: any) {
|
||
return NextResponse.json({ success: false, error: e.message, attractions: [] }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
// POST: Neues Ausflugsziel anlegen
|
||
export async function POST(req: NextRequest) {
|
||
try {
|
||
const { trip_id, hotel_ids, name, address, category_icon, notes } = await req.json();
|
||
|
||
if (!address) {
|
||
return NextResponse.json({ error: 'Google Maps Link oder Adresse erforderlich' }, { status: 400 });
|
||
}
|
||
|
||
let finalName = name?.trim() || '';
|
||
let finalAddress = address.trim();
|
||
let sightCoords: { lat: number; lon: number } | null = null;
|
||
|
||
if (finalAddress.includes('google.com/maps') || finalAddress.includes('goo.gl')) {
|
||
const parsed = await parseGoogleMapsUrl(finalAddress);
|
||
if (parsed.name && !finalName) finalName = parsed.name;
|
||
if (parsed.lat && parsed.lon) sightCoords = { lat: parsed.lat, lon: parsed.lon };
|
||
}
|
||
|
||
if (!sightCoords) {
|
||
sightCoords = await geocode(finalAddress || finalName);
|
||
}
|
||
if (!finalName) finalName = finalAddress;
|
||
|
||
// 1. In hotel_attractions anlegen
|
||
const insertRes = await query(
|
||
`INSERT INTO hotel_attractions (trip_id, name, address, category_icon, notes, lat, lon)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
RETURNING *`,
|
||
[
|
||
trip_id || null,
|
||
finalName,
|
||
finalAddress,
|
||
category_icon || '🏛️',
|
||
notes || null,
|
||
sightCoords?.lat || null,
|
||
sightCoords?.lon || null,
|
||
]
|
||
);
|
||
|
||
const newAttraction = insertRes.rows[0];
|
||
|
||
// 2. Für alle ausgewählten Hotels Distanz berechnen (mit Pause gegen 429)
|
||
const selectedHotelIds: string[] = Array.isArray(hotel_ids) ? hotel_ids : [];
|
||
for (const hId of selectedHotelIds) {
|
||
let distanceKm: number | null = null;
|
||
let durationMin: number | null = null;
|
||
|
||
const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hId]);
|
||
if (hotelRes.rows.length > 0) {
|
||
const hotel = hotelRes.rows[0];
|
||
|
||
await sleep(1100); // Fair-Use Rate-Limit für Nominatim einhalten
|
||
let hCoords = await geocode(hotel.address);
|
||
if (!hCoords && hotel.name) {
|
||
await sleep(1100);
|
||
hCoords = await geocode(hotel.name);
|
||
}
|
||
|
||
if (hCoords && sightCoords) {
|
||
const drive = await getDriveInfo(hCoords, sightCoords);
|
||
distanceKm = drive.distanceKm;
|
||
durationMin = drive.durationMin;
|
||
}
|
||
}
|
||
|
||
await query(
|
||
`INSERT INTO hotel_attraction_assignments (attraction_id, hotel_id, distance_km, duration_minutes)
|
||
VALUES ($1, $2, $3, $4)
|
||
ON CONFLICT (attraction_id, hotel_id) DO UPDATE
|
||
SET distance_km = EXCLUDED.distance_km, duration_minutes = EXCLUDED.duration_minutes`,
|
||
[newAttraction.id, hId, distanceKm, durationMin]
|
||
);
|
||
}
|
||
|
||
return NextResponse.json({ success: true, attraction: newAttraction });
|
||
} catch (e: any) {
|
||
console.error('Fehler beim Anlegen:', e);
|
||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||
}
|
||
}
|
||
```
|
||
|
||
--- DATEI: src/app/api/hotels/[id]/attractions/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { query } from '@/lib/db';
|
||
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
// Google Maps URL auflösen und Name + Koordinaten extrahieren
|
||
async function parseGoogleMapsUrl(inputUrl: string) {
|
||
let targetUrl = inputUrl.trim();
|
||
|
||
// Kurzlinks (maps.app.goo.gl oder goo.gl) auflösen
|
||
if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) {
|
||
try {
|
||
const res = await fetch(targetUrl, { method: 'HEAD', redirect: 'follow' });
|
||
targetUrl = res.url || targetUrl;
|
||
} catch (err) {
|
||
console.error('Fehler beim Auflösen des Google Maps Kurzlinks:', err);
|
||
}
|
||
}
|
||
|
||
let extractedName: string | null = null;
|
||
let lat: number | null = null;
|
||
let lon: number | null = null;
|
||
|
||
// 1. Name extrahieren aus /place/Ortsname/
|
||
const placeMatch = targetUrl.match(/\/place\/([^/@?]+)/);
|
||
if (placeMatch && placeMatch[1]) {
|
||
extractedName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
|
||
}
|
||
|
||
// 2. Koordinaten aus @lat,lon extrahieren
|
||
const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/);
|
||
if (coordMatch) {
|
||
lat = parseFloat(coordMatch[1]);
|
||
lon = parseFloat(coordMatch[2]);
|
||
} else {
|
||
// Alternativ-Format: !3dLAT!4dLON
|
||
const dataCoordMatch = targetUrl.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/);
|
||
if (dataCoordMatch) {
|
||
lat = parseFloat(dataCoordMatch[1]);
|
||
lon = parseFloat(dataCoordMatch[2]);
|
||
}
|
||
}
|
||
|
||
return { name: extractedName, lat, lon, resolvedUrl: targetUrl };
|
||
}
|
||
|
||
// Fallback Nominatim Geocoding
|
||
async function geocode(address: string): Promise<{ lat: number; lon: number } | null> {
|
||
try {
|
||
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`;
|
||
const res = await fetch(url, {
|
||
headers: { 'User-Agent': 'TravelPlannerApp/1.0 (Homelab Private Usage)' },
|
||
});
|
||
const data = await res.json();
|
||
if (data && data.length > 0) {
|
||
return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) };
|
||
}
|
||
} catch (err) {
|
||
console.error('Geocoding Fehler:', err);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// OSRM Fahrzeit- & Distanzberechnung
|
||
async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) {
|
||
try {
|
||
const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`;
|
||
const res = await fetch(url);
|
||
const data = await res.json();
|
||
if (data.code === 'Ok' && data.routes && data.routes.length > 0) {
|
||
const distanceKm = Math.round((data.routes[0].distance / 1000) * 10) / 10;
|
||
const durationMin = Math.round(data.routes[0].duration / 60);
|
||
return { distanceKm, durationMin };
|
||
}
|
||
} catch (err) {
|
||
console.error('OSRM Routing Fehler:', err);
|
||
}
|
||
return { distanceKm: null, durationMin: null };
|
||
}
|
||
|
||
// GET: Alle Sehenswürdigkeiten eines Hotels abrufen
|
||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||
try {
|
||
const res = await query(
|
||
`SELECT * FROM hotel_attractions
|
||
WHERE hotel_id = $1
|
||
ORDER BY distance_km ASC NULLS LAST, created_at ASC`,
|
||
[params.id]
|
||
);
|
||
return NextResponse.json({ success: true, attractions: res.rows });
|
||
} catch (e: any) {
|
||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
// POST: Google Maps Link oder Adresse verarbeiten & Route berechnen
|
||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||
try {
|
||
const hotelId = params.id;
|
||
const { name, input_value, category_icon, notes } = await req.json();
|
||
|
||
if (!input_value) {
|
||
return NextResponse.json({ error: 'Google Maps Link oder Adresse ist erforderlich' }, { status: 400 });
|
||
}
|
||
|
||
// 1. Hotel-Koordinaten ermitteln
|
||
const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hotelId]);
|
||
if (hotelRes.rows.length === 0) {
|
||
return NextResponse.json({ error: 'Hotel nicht gefunden' }, { status: 404 });
|
||
}
|
||
const hotel = hotelRes.rows[0];
|
||
const hotelCoords = await geocode(hotel.address || hotel.name);
|
||
|
||
let finalName = name?.trim() || '';
|
||
let finalAddress = input_value.trim();
|
||
let sightCoords: { lat: number; lon: number } | null = null;
|
||
|
||
// 2. Google Maps URL prüfen & parsen
|
||
if (input_value.includes('google.com/maps') || input_value.includes('goo.gl')) {
|
||
const parsedMaps = await parseGoogleMapsUrl(input_value);
|
||
if (parsedMaps.name && !finalName) {
|
||
finalName = parsedMaps.name;
|
||
}
|
||
if (parsedMaps.lat && parsedMaps.lon) {
|
||
sightCoords = { lat: parsedMaps.lat, lon: parsedMaps.lon };
|
||
}
|
||
}
|
||
|
||
// Falls keine Koordinaten aus dem Link gelesen werden konnten: Fallback auf Geocoding
|
||
if (!sightCoords) {
|
||
sightCoords = await geocode(finalAddress || finalName);
|
||
}
|
||
|
||
if (!finalName) {
|
||
finalName = finalAddress;
|
||
}
|
||
|
||
// 3. Fahrzeit & Distanz via OSRM berechnen
|
||
let distanceKm: number | null = null;
|
||
let durationMin: number | null = null;
|
||
|
||
if (hotelCoords && sightCoords) {
|
||
const drive = await getDriveInfo(hotelCoords, sightCoords);
|
||
distanceKm = drive.distanceKm;
|
||
durationMin = drive.durationMin;
|
||
}
|
||
|
||
// 4. In Postgres speichern
|
||
const insertRes = await query(
|
||
`INSERT INTO hotel_attractions (hotel_id, name, address, category_icon, notes, distance_km, duration_minutes)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||
RETURNING *`,
|
||
[hotelId, finalName, finalAddress, category_icon || '🏛️', notes || null, distanceKm, durationMin]
|
||
);
|
||
|
||
return NextResponse.json({ success: true, attraction: insertRes.rows[0] });
|
||
} catch (e: any) {
|
||
console.error('Fehler beim Anlegen der Sehenswürdigkeit:', e);
|
||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||
}
|
||
}
|
||
```
|
||
|
||
--- 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]/ratings/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { query } from '@/lib/db';
|
||
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
// POST: Bewertung speichern oder aktualisieren
|
||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||
try {
|
||
const { member_id, grade, comment } = await req.json();
|
||
if (!member_id || !grade) {
|
||
return NextResponse.json({ error: 'Mitreisender und Note erforderlich' }, { status: 400 });
|
||
}
|
||
|
||
const res = await query(
|
||
`INSERT INTO hotel_ratings (hotel_id, member_id, grade, comment, updated_at)
|
||
VALUES ($1, $2, $3, $4, NOW())
|
||
ON CONFLICT (hotel_id, member_id)
|
||
DO UPDATE SET grade = EXCLUDED.grade, comment = EXCLUDED.comment, updated_at = NOW()
|
||
RETURNING *`,
|
||
[params.id, member_id, grade, comment ? comment.trim() : null]
|
||
);
|
||
|
||
return NextResponse.json({ success: true, rating: res.rows[0] });
|
||
} catch (e: any) {
|
||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/api/hotels/[id]/review-summary/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
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.genius_price_numeric::float AS latest_genius_price,
|
||
ph.nights AS latest_nights,
|
||
ph.persons AS latest_persons,
|
||
bp.best_price,
|
||
hr.avg_grade,
|
||
hr.total_votes,
|
||
hr.ratings_json
|
||
FROM hotels h
|
||
LEFT JOIN trips t ON h.trip_id = t.id
|
||
LEFT JOIN LATERAL (
|
||
SELECT price_numeric, genius_price_numeric, price_per_night, price_per_person, nights, persons
|
||
FROM hotel_price_history
|
||
WHERE hotel_id::text = h.id::text
|
||
ORDER BY tracked_at DESC NULLS LAST, id DESC
|
||
LIMIT 1
|
||
) ph ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT MIN(price_numeric)::float AS best_price
|
||
FROM hotel_price_history
|
||
WHERE hotel_id::text = h.id::text AND price_numeric > 0
|
||
) bp ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT
|
||
ROUND(AVG(hr_sub.grade), 1)::float AS avg_grade,
|
||
COUNT(hr_sub.id)::int AS total_votes,
|
||
COALESCE(
|
||
json_agg(
|
||
json_build_object(
|
||
'member_id', hr_sub.member_id,
|
||
'name', tm.name,
|
||
'color', tm.color,
|
||
'grade', hr_sub.grade,
|
||
'comment', hr_sub.comment
|
||
)
|
||
ORDER BY hr_sub.updated_at DESC
|
||
),
|
||
'[]'
|
||
) AS ratings_json
|
||
FROM hotel_ratings hr_sub
|
||
JOIN trip_members tm ON hr_sub.member_id = tm.id
|
||
WHERE hr_sub.hotel_id = h.id
|
||
) hr ON true
|
||
`);
|
||
return NextResponse.json({ success: true, hotels: res.rows });
|
||
} catch (error: any) {
|
||
console.error('Fehler in GET /api/hotels:', error.message);
|
||
try {
|
||
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/[id]/members/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { query } from '@/lib/db';
|
||
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
// GET: Alle Mitreisenden eines Trips laden
|
||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||
try {
|
||
const res = await query(
|
||
`SELECT * FROM trip_members WHERE trip_id = $1 ORDER BY created_at ASC`,
|
||
[params.id]
|
||
);
|
||
return NextResponse.json({ success: true, members: res.rows });
|
||
} catch (e: any) {
|
||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
// POST: Neuen Mitreisenden hinzufügen
|
||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||
try {
|
||
const { name, color } = await req.json();
|
||
if (!name) {
|
||
return NextResponse.json({ error: 'Name ist erforderlich' }, { status: 400 });
|
||
}
|
||
|
||
const defaultColors = ['#2563eb', '#db2777', '#059669', '#d97706', '#7c3aed', '#0891b2'];
|
||
const randomColor = color || defaultColors[Math.floor(Math.random() * defaultColors.length)];
|
||
|
||
const res = await query(
|
||
`INSERT INTO trip_members (trip_id, name, color)
|
||
VALUES ($1, $2, $3)
|
||
RETURNING *`,
|
||
[params.id, name.trim(), randomColor]
|
||
);
|
||
|
||
return NextResponse.json({ success: true, member: res.rows[0] });
|
||
} catch (e: any) {
|
||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
// DELETE: Mitreisenden entfernen
|
||
export async function DELETE(req: NextRequest) {
|
||
try {
|
||
const { searchParams } = new URL(req.url);
|
||
const memberId = searchParams.get('memberId');
|
||
if (!memberId) return NextResponse.json({ error: 'memberId fehlt' }, { status: 400 });
|
||
|
||
await query(`DELETE FROM trip_members WHERE id = $1`, [memberId]);
|
||
return NextResponse.json({ success: true });
|
||
} catch (e: any) {
|
||
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/api/trips/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
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);
|
||
|
||
// Prüfen, ob das Genius-Badge vom Scraper erkannt wurde (z.B. body.hasGenius oder body.isGenius)
|
||
const hasGeniusBadge = Boolean(body.hasGenius || body.isGenius || body.genius);
|
||
|
||
// Wenn Genius vorhanden ist: 10% abziehen und runden
|
||
const geniusPriceNumeric = (numericPrice !== null && hasGeniusBadge)
|
||
? Math.round(numericPrice * 0.9)
|
||
: null;
|
||
|
||
if (numericPrice !== null && numericPrice > 0) {
|
||
try {
|
||
await query(
|
||
`INSERT INTO hotel_price_history (hotel_id, price_numeric, genius_price_numeric, price_raw, currency, nights, persons, tracked_at)
|
||
VALUES ($1::uuid, $2, $3, $4, 'EUR', $5, $6, NOW())`,
|
||
[hotelId, numericPrice, geniusPriceNumeric, String(body.price || ''), body.nights || 1, body.persons || 2]
|
||
);
|
||
} catch (err: any) {
|
||
console.error('DB Insert Error (hotel_price_history):', err.message);
|
||
}
|
||
}
|
||
|
||
// 2. Stammdaten aktualisieren
|
||
try {
|
||
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 (
|
||
<html lang="de">
|
||
<body style={{ margin: 0, padding: 0, backgroundColor: '#fafafa' }}>
|
||
{children}
|
||
</body>
|
||
</html>
|
||
);
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/page.tsx ---
|
||
```
|
||
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
|
||
// ==========================================
|
||
// TYPEN & INTERFACES
|
||
// ==========================================
|
||
interface Trip {
|
||
id: string;
|
||
name: string;
|
||
}
|
||
|
||
interface TripMember {
|
||
id: string;
|
||
trip_id: string;
|
||
name: string;
|
||
color: string;
|
||
created_at?: string;
|
||
}
|
||
|
||
interface RatingEntry {
|
||
member_id: string;
|
||
name: string;
|
||
color: string;
|
||
grade: number;
|
||
comment?: string | null;
|
||
}
|
||
|
||
interface PriceEntry {
|
||
id: string;
|
||
price: number;
|
||
price_per_night?: number | null;
|
||
price_per_person?: number | null;
|
||
nights: number;
|
||
persons: number;
|
||
created_at: string;
|
||
}
|
||
|
||
interface AttractionAssignment {
|
||
hotel_id: string;
|
||
distance_km?: number | null;
|
||
duration_minutes?: number | null;
|
||
}
|
||
|
||
interface Attraction {
|
||
id: string;
|
||
trip_id?: string | null;
|
||
name: string;
|
||
address: string;
|
||
category_icon: string;
|
||
notes?: string | null;
|
||
assignments?: AttractionAssignment[];
|
||
}
|
||
|
||
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_genius_price?: number | null;
|
||
latest_nights?: number | null;
|
||
latest_persons?: number | null;
|
||
best_price?: number | null;
|
||
best_price_per_night?: number | null;
|
||
avg_grade?: number | null;
|
||
total_votes?: number | null;
|
||
ratings_json?: RatingEntry[];
|
||
rating?: number | null;
|
||
review_count?: number | null;
|
||
image_url?: string | null;
|
||
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 HotelCluster {
|
||
clusterKey: string;
|
||
checkin_date?: string | null;
|
||
checkout_date?: string | null;
|
||
hotels: Hotel[];
|
||
}
|
||
|
||
export default function TravelDashboard() {
|
||
// ==========================================
|
||
// STATES
|
||
// ==========================================
|
||
const [trips, setTrips] = useState<Trip[]>([]);
|
||
const [activeTripId, setActiveTripId] = useState<string>('ALL');
|
||
const [tripMembers, setTripMembers] = useState<TripMember[]>([]);
|
||
const [hotels, setHotels] = useState<Hotel[]>([]);
|
||
const [attractions, setAttractions] = useState<Attraction[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [scanningAllPrices, setScanningAllPrices] = useState(false);
|
||
const [scanningHotelId, setScanningHotelId] = useState<string | null>(null);
|
||
|
||
// Preishistorie Modal
|
||
const [selectedPriceHotel, setSelectedPriceHotel] = useState<Hotel | null>(null);
|
||
const [priceHistory, setPriceHistory] = useState<PriceEntry[]>([]);
|
||
const [loadingPrices, setLoadingPrices] = useState(false);
|
||
|
||
// Reise & Hotel 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);
|
||
|
||
// Mitreisende Modal
|
||
const [showMembersModal, setShowMembersModal] = useState(false);
|
||
const [newMemberName, setNewMemberName] = useState('');
|
||
const [isAddingMember, setIsAddingMember] = useState(false);
|
||
|
||
// Hotel-Bewertungs-Modal (Schulnoten 1 bis 6)
|
||
const [ratingHotel, setRatingHotel] = useState<Hotel | null>(null);
|
||
const [selectedMemberId, setSelectedMemberId] = useState<string>('');
|
||
const [selectedGrade, setSelectedGrade] = useState<number | null>(null);
|
||
const [ratingComment, setRatingComment] = useState('');
|
||
const [isSubmittingRating, setIsSubmittingRating] = useState(false);
|
||
|
||
// Ausflugsziele Modals
|
||
const [showManageSightsModal, setShowManageSightsModal] = useState(false);
|
||
const [showSightFormModal, setShowSightFormModal] = useState(false);
|
||
const [editingSight, setEditingSight] = useState<Attraction | null>(null);
|
||
const [sightName, setSightName] = useState('');
|
||
const [sightAddress, setSightAddress] = useState('');
|
||
const [sightIcon, setSightIcon] = useState('🏛️');
|
||
const [sightNotes, setSightNotes] = useState('');
|
||
const [selectedHotelIds, setSelectedHotelIds] = useState<string[]>([]);
|
||
const [isSubmittingSight, setIsSubmittingSight] = useState(false);
|
||
|
||
// ==========================================
|
||
// EFFEKTE & DATEN LADEN
|
||
// ==========================================
|
||
useEffect(() => {
|
||
loadData();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (activeTripId !== 'ALL') {
|
||
loadMembers(activeTripId);
|
||
} else {
|
||
setTripMembers([]);
|
||
}
|
||
}, [activeTripId, trips]);
|
||
|
||
const formatDateGerman = (dateStr?: string | null) => {
|
||
if (!dateStr) return '';
|
||
const date = new Date(dateStr);
|
||
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 {
|
||
try {
|
||
const resTrips = await fetch('/api/trips');
|
||
if (resTrips.ok) {
|
||
const dataTrips = await resTrips.json();
|
||
setTrips(dataTrips.trips || []);
|
||
}
|
||
} catch (e) { }
|
||
|
||
try {
|
||
const resHotels = await fetch('/api/hotels');
|
||
if (resHotels.ok) {
|
||
const dataHotels = await resHotels.json();
|
||
setHotels(dataHotels.hotels || []);
|
||
}
|
||
} catch (e) { }
|
||
|
||
try {
|
||
const resAttractions = await fetch('/api/attractions');
|
||
if (resAttractions.ok) {
|
||
const dataAttractions = await resAttractions.json();
|
||
setAttractions(dataAttractions.attractions || []);
|
||
}
|
||
} catch (e) { }
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const loadMembers = async (tripId: string) => {
|
||
try {
|
||
const res = await fetch(`/api/trips/${tripId}/members`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setTripMembers(data.members || []);
|
||
}
|
||
} catch (e) {
|
||
console.error('Fehler beim Laden der Mitreisenden:', e);
|
||
}
|
||
};
|
||
|
||
const refreshAttractions = async () => {
|
||
try {
|
||
const res = await fetch('/api/attractions');
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
setAttractions(data.attractions || []);
|
||
}
|
||
} catch (e) {
|
||
console.error('Fehler beim Aktualisieren der Ausflugsziele:', e);
|
||
}
|
||
};
|
||
|
||
// ==========================================
|
||
// HANDLERS: REISEN & MITREISENDE
|
||
// ==========================================
|
||
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 handleAddMember = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!newMemberName.trim() || activeTripId === 'ALL') return;
|
||
setIsAddingMember(true);
|
||
try {
|
||
const res = await fetch(`/api/trips/${activeTripId}/members`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: newMemberName.trim() })
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
setTripMembers([...tripMembers, data.member]);
|
||
setNewMemberName('');
|
||
loadData();
|
||
}
|
||
} catch (err: any) {
|
||
alert('Fehler beim Hinzufügen: ' + err.message);
|
||
} finally {
|
||
setIsAddingMember(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteMember = async (memberId: string) => {
|
||
if (!confirm('Mitreisenden wirklich entfernen? Alle zugehörigen Noten werden gelöscht.')) return;
|
||
try {
|
||
const res = await fetch(`/api/trips/${activeTripId}/members?memberId=${memberId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
if (res.ok) {
|
||
setTripMembers(tripMembers.filter(m => m.id !== memberId));
|
||
loadData();
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Fehler beim Löschen:', err);
|
||
}
|
||
};
|
||
|
||
// ==========================================
|
||
// HANDLERS: BEWERTUNG (1 bis 6)
|
||
// ==========================================
|
||
const openRatingModal = (hotel: Hotel) => {
|
||
setRatingHotel(hotel);
|
||
setSelectedMemberId(tripMembers[0]?.id || '');
|
||
setSelectedGrade(null);
|
||
setRatingComment('');
|
||
|
||
if (tripMembers.length > 0 && hotel.ratings_json) {
|
||
const existing = hotel.ratings_json.find(r => r.member_id === tripMembers[0].id);
|
||
if (existing) {
|
||
setSelectedGrade(existing.grade);
|
||
setRatingComment(existing.comment || '');
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleSelectMemberForRating = (mId: string) => {
|
||
setSelectedMemberId(mId);
|
||
if (ratingHotel?.ratings_json) {
|
||
const existing = ratingHotel.ratings_json.find(r => r.member_id === mId);
|
||
if (existing) {
|
||
setSelectedGrade(existing.grade);
|
||
setRatingComment(existing.comment || '');
|
||
} else {
|
||
setSelectedGrade(null);
|
||
setRatingComment('');
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleSaveRating = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!ratingHotel || !selectedMemberId || !selectedGrade) {
|
||
return alert('Bitte wähle Mitreisenden und Note aus!');
|
||
}
|
||
setIsSubmittingRating(true);
|
||
try {
|
||
const res = await fetch(`/api/hotels/${ratingHotel.id}/ratings`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
member_id: selectedMemberId,
|
||
grade: selectedGrade,
|
||
comment: ratingComment
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
await loadData();
|
||
setRatingHotel(null);
|
||
} else {
|
||
alert('Fehler: ' + data.error);
|
||
}
|
||
} catch (err: any) {
|
||
alert('Fehler beim Speichern: ' + err.message);
|
||
} finally {
|
||
setIsSubmittingRating(false);
|
||
}
|
||
};
|
||
|
||
// ==========================================
|
||
// HANDLERS: HOTELS, PREISE, AUSFLÜGE
|
||
// ==========================================
|
||
const openPriceHistoryModal = async (hotel: Hotel) => {
|
||
setSelectedPriceHotel(hotel);
|
||
setLoadingPrices(true);
|
||
setPriceHistory([]);
|
||
try {
|
||
const res = await fetch(`/api/hotels/${hotel.id}/prices`);
|
||
const data = await res.json();
|
||
if (data.success) setPriceHistory(data.prices || []);
|
||
} catch (e) {
|
||
console.error('Fehler beim Laden der Preise:', e);
|
||
} finally {
|
||
setLoadingPrices(false);
|
||
}
|
||
};
|
||
|
||
const handleAddHotel = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!newBookingUrl) return alert('Bitte URL eingeben');
|
||
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 parkHotel = async (id: string) => {
|
||
try {
|
||
await fetch(`/api/hotels/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ is_active: false })
|
||
});
|
||
setHotels(hotels.map(h => (h.id === id ? { ...h, is_active: false } : h)));
|
||
} catch (e) {
|
||
console.error('Fehler beim Ausmustern:', e);
|
||
}
|
||
};
|
||
|
||
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 openSightModal = (sight?: Attraction, preselectedHotelId?: string) => {
|
||
if (sight) {
|
||
setEditingSight(sight);
|
||
setSightName(sight.name);
|
||
setSightAddress(sight.address);
|
||
setSightIcon(sight.category_icon || '🏛️');
|
||
setSightNotes(sight.notes || '');
|
||
setSelectedHotelIds((sight.assignments || []).map(a => a.hotel_id));
|
||
} else {
|
||
setEditingSight(null);
|
||
setSightName('');
|
||
setSightAddress('');
|
||
setSightIcon('🏛️');
|
||
setSightNotes('');
|
||
setSelectedHotelIds(preselectedHotelId ? [preselectedHotelId] : []);
|
||
}
|
||
setShowSightFormModal(true);
|
||
};
|
||
|
||
const toggleHotelSelection = (hotelId: string) => {
|
||
setSelectedHotelIds(prev =>
|
||
prev.includes(hotelId) ? prev.filter(id => id !== hotelId) : [...prev, hotelId]
|
||
);
|
||
};
|
||
|
||
const handleSaveSight = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!sightAddress) return;
|
||
setIsSubmittingSight(true);
|
||
try {
|
||
const targetTripId = activeTripId === 'ALL' ? (trips[0]?.id || null) : activeTripId;
|
||
const payload = {
|
||
name: sightName,
|
||
address: sightAddress,
|
||
category_icon: sightIcon,
|
||
notes: sightNotes,
|
||
hotel_ids: selectedHotelIds,
|
||
trip_id: targetTripId
|
||
};
|
||
if (editingSight) {
|
||
const res = await fetch(`/api/attractions/${editingSight.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
} else {
|
||
const res = await fetch('/api/attractions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
}
|
||
await refreshAttractions();
|
||
setShowSightFormModal(false);
|
||
} catch (err: any) {
|
||
alert('Fehler beim Speichern: ' + err.message);
|
||
} finally {
|
||
setIsSubmittingSight(false);
|
||
}
|
||
};
|
||
|
||
const deleteSight = async (sightId: string) => {
|
||
if (!confirm('Sehenswürdigkeit wirklich löschen?')) return;
|
||
try {
|
||
await fetch(`/api/attractions/${sightId}`, { method: 'DELETE' });
|
||
await refreshAttractions();
|
||
} catch (e) {
|
||
console.error('Fehler beim Löschen:', 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);
|
||
}
|
||
};
|
||
|
||
// ==========================================
|
||
// FILTER & CLUSTERING MIT AUTOMATISCHEM RANKING
|
||
// ==========================================
|
||
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();
|
||
});
|
||
|
||
const parkedHotels = hotels.filter(h => !h.is_active);
|
||
const currentAttractions = attractions.filter(a => {
|
||
if (activeTripId === 'ALL') return true;
|
||
return String(a.trip_id).toLowerCase() === String(activeTripId).toLowerCase();
|
||
});
|
||
|
||
const clusteredStops = currentHotels.reduce<HotelCluster[]>((acc, hotel) => {
|
||
const checkin = hotel.checkin_date ? hotel.checkin_date.split('T')[0] : null;
|
||
const checkout = hotel.checkout_date ? hotel.checkout_date.split('T')[0] : null;
|
||
if (!checkin) {
|
||
acc.push({ clusterKey: `single_${hotel.id}`, checkin_date: null, checkout_date: null, hotels: [hotel] });
|
||
return acc;
|
||
}
|
||
const key = `${checkin}_${checkout || 'NO_CHECKOUT'}`;
|
||
const existingCluster = acc.find(c => c.clusterKey === key);
|
||
if (existingCluster) {
|
||
existingCluster.hotels.push(hotel);
|
||
} else {
|
||
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;
|
||
|
||
const activeTripObj = trips.find(t => String(t.id) === String(activeTripId));
|
||
|
||
return (
|
||
<div style={{ maxWidth: '1100px', margin: '0 auto', padding: '2rem 1rem', fontFamily: 'system-ui, -apple-system, sans-serif', color: '#1f2937' }}>
|
||
|
||
{/* HEADER */}
|
||
<header style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
||
<h1 style={{ fontSize: '2.2rem', marginBottom: '0.5rem', fontWeight: '800', color: '#0f172a' }}>
|
||
📍 Reise-Ablauf & Freunde-Ranking
|
||
</h1>
|
||
<p style={{ color: '#64748b', margin: 0, fontSize: '0.95rem' }}>
|
||
Hotelvergleich nach Schulnoten (1–6), Bestpreise, Routen & Fahrzeiten
|
||
</p>
|
||
|
||
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'center', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||
<button
|
||
onClick={() => setShowAddHotelModal(true)}
|
||
style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.65rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer' }}
|
||
>
|
||
➕ Neues Hotel hinzufügen
|
||
</button>
|
||
<button
|
||
onClick={() => openSightModal()}
|
||
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.65rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer' }}
|
||
>
|
||
🏛️ Ausflug anlegen
|
||
</button>
|
||
<button
|
||
onClick={() => setShowManageSightsModal(true)}
|
||
style={{ background: '#f1f5f9', color: '#334155', border: '1px solid #cbd5e1', padding: '0.65rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||
>
|
||
📋 Alle Ausflüge ({currentAttractions.length})
|
||
</button>
|
||
<button
|
||
onClick={() => setShowParkedModal(true)}
|
||
style={{ background: '#f1f5f9', color: '#334155', border: '1px solid #cbd5e1', padding: '0.65rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||
>
|
||
📦 Ausgemustert
|
||
{parkedHotels.length > 0 && (
|
||
<span style={{ background: '#dc2626', color: '#fff', fontSize: '0.75rem', padding: '0.15rem 0.5rem', borderRadius: '9999px', fontWeight: '800' }}>
|
||
{parkedHotels.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
<button
|
||
onClick={triggerBatchPriceScan}
|
||
disabled={scanningAllPrices}
|
||
style={{ background: '#3b82f6', color: '#fff', border: 'none', padding: '0.65rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', opacity: scanningAllPrices ? 0.7 : 1 }}
|
||
>
|
||
{scanningAllPrices ? '🔄 Scanne...' : '📊 Preise aktualisieren'}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* REISE TABS */}
|
||
<div style={{ display: 'flex', gap: '0.5rem', borderBottom: '2px solid #e2e8f0', marginBottom: '1.5rem', overflowX: 'auto', paddingBottom: '0.5rem' }}>
|
||
<button
|
||
onClick={() => setActiveTripId('ALL')}
|
||
style={{
|
||
padding: '0.6rem 1.2rem',
|
||
borderRadius: '8px',
|
||
border: 'none',
|
||
background: activeTripId === 'ALL' ? '#2563eb' : '#f1f5f9',
|
||
color: activeTripId === 'ALL' ? '#ffffff' : '#475569',
|
||
fontWeight: 'bold',
|
||
cursor: 'pointer',
|
||
whiteSpace: 'nowrap'
|
||
}}
|
||
>
|
||
🌐 Alle Hotels ({currentHotels.length})
|
||
</button>
|
||
{trips.map(trip => (
|
||
<button
|
||
key={trip.id}
|
||
onClick={() => setActiveTripId(String(trip.id))}
|
||
style={{
|
||
padding: '0.6rem 1.2rem',
|
||
borderRadius: '8px',
|
||
border: 'none',
|
||
background: String(activeTripId).toLowerCase() === String(trip.id).toLowerCase() ? '#2563eb' : '#f1f5f9',
|
||
color: String(activeTripId).toLowerCase() === String(trip.id).toLowerCase() ? '#ffffff' : '#475569',
|
||
fontWeight: 'bold',
|
||
cursor: 'pointer',
|
||
whiteSpace: 'nowrap'
|
||
}}
|
||
>
|
||
🌍 {trip.name}
|
||
</button>
|
||
))}
|
||
<button
|
||
onClick={() => setShowAddTripModal(true)}
|
||
style={{ padding: '0.6rem 1rem', borderRadius: '8px', border: '1px dashed #2563eb', background: '#eff6ff', color: '#2563eb', fontWeight: 'bold', cursor: 'pointer' }}
|
||
>
|
||
➕ Neue Reise
|
||
</button>
|
||
</div>
|
||
|
||
{/* MITREISENDE SECTION */}
|
||
{activeTripId !== 'ALL' && activeTripObj && (
|
||
<div style={{
|
||
background: '#ffffff',
|
||
border: '1px solid #e2e8f0',
|
||
borderRadius: '12px',
|
||
padding: '0.85rem 1.2rem',
|
||
marginBottom: '2rem',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
flexWrap: 'wrap',
|
||
gap: '1rem',
|
||
boxShadow: '0 2px 4px rgba(0,0,0,0.02)'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', flexWrap: 'wrap' }}>
|
||
<span style={{ fontWeight: '800', fontSize: '0.95rem', color: '#1e293b' }}>
|
||
👥 Mitreisende ({tripMembers.length}):
|
||
</span>
|
||
{tripMembers.length === 0 ? (
|
||
<span style={{ fontSize: '0.82rem', color: '#94a3b8', fontStyle: 'italic' }}>
|
||
Noch keine Freunde hinterlegt
|
||
</span>
|
||
) : (
|
||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||
{tripMembers.map(m => (
|
||
<span
|
||
key={m.id}
|
||
style={{
|
||
background: '#f8fafc',
|
||
border: '1px solid #cbd5e1',
|
||
padding: '0.2rem 0.6rem',
|
||
borderRadius: '9999px',
|
||
fontSize: '0.8rem',
|
||
fontWeight: '700',
|
||
color: '#334155',
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '0.35rem'
|
||
}}
|
||
>
|
||
<span style={{ width: '16px', height: '16px', borderRadius: '50%', background: m.color || '#2563eb', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.65rem', fontWeight: '800' }}>
|
||
{m.name[0]}
|
||
</span>
|
||
{m.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setShowMembersModal(true)}
|
||
style={{
|
||
background: '#eff6ff',
|
||
color: '#2563eb',
|
||
border: '1px dashed #93c5fd',
|
||
padding: '0.4rem 0.8rem',
|
||
borderRadius: '6px',
|
||
fontWeight: '700',
|
||
fontSize: '0.8rem',
|
||
cursor: 'pointer'
|
||
}}
|
||
>
|
||
⚙️ Mitreisende verwalten
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* TIMELINE MIT AUTOMATISCHEM RANKING */}
|
||
{loading ? (
|
||
<p style={{ textAlign: 'center', color: '#64748b', padding: '3rem 0' }}>Lade Daten...</p>
|
||
) : 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' }}>
|
||
Erstes Hotel hinzufügen
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div style={{ position: 'relative', paddingLeft: '35px', borderLeft: '4px solid #3b82f6', marginLeft: '15px' }}>
|
||
{clusteredStops.map((stop, stopIndex) => {
|
||
const isCluster = stop.hotels.length > 1;
|
||
const nights = calculateNights(stop.checkin_date, stop.checkout_date) || stop.hotels[0]?.latest_nights || 1;
|
||
|
||
const sortedHotels = [...stop.hotels].sort((a, b) => {
|
||
const gradeA = a.avg_grade !== null && a.avg_grade !== undefined ? Number(a.avg_grade) : 99;
|
||
const gradeB = b.avg_grade !== null && b.avg_grade !== undefined ? Number(b.avg_grade) : 99;
|
||
if (gradeA !== gradeB) return gradeA - gradeB;
|
||
return (a.latest_price || 99999) - (b.latest_price || 99999);
|
||
});
|
||
|
||
return (
|
||
<div key={stop.clusterKey} style={{ position: 'relative', marginBottom: '3.5rem' }}>
|
||
<div style={{
|
||
position: 'absolute',
|
||
left: '-47px',
|
||
top: '2px',
|
||
width: '20px',
|
||
height: '20px',
|
||
borderRadius: '50%',
|
||
background: isCluster ? '#7c3aed' : '#3b82f6',
|
||
border: '4px solid #ffffff',
|
||
boxShadow: `0 0 0 2px ${isCluster ? '#7c3aed' : '#3b82f6'}`
|
||
}} />
|
||
|
||
{/* Stop Header */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.9rem', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||
<div style={{ fontSize: '0.95rem', fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: '0.6rem', flexWrap: 'wrap' }}>
|
||
<span style={{ color: isCluster ? '#6d28d9' : '#2563eb' }}>📍 Stop #{stopIndex + 1}</span>
|
||
{stop.checkin_date ? (
|
||
<span style={{ background: isCluster ? '#faf5ff' : '#dbeafe', color: isCluster ? '#6b21a8' : '#1e40af', padding: '0.25rem 0.7rem', borderRadius: '9999px', fontSize: '0.82rem', border: isCluster ? '1px solid #e9d5ff' : 'none', fontWeight: '600' }}>
|
||
🗓️ {formatDateGerman(stop.checkin_date)} – {formatDateGerman(stop.checkout_date) || '?'} ({nights} {nights === 1 ? 'Nacht' : 'Nächte'})
|
||
</span>
|
||
) : (
|
||
<span style={{ color: '#9ca3af', fontSize: '0.8rem', fontStyle: 'italic' }}>(Warte auf Scan für Datum)</span>
|
||
)}
|
||
{isCluster && (
|
||
<span style={{ background: '#f5f3ff', color: '#7c3aed', border: '1px solid #ddd6fe', padding: '0.25rem 0.7rem', borderRadius: '9999px', fontSize: '0.8rem', fontWeight: '700' }}>
|
||
⚖️ {stop.hotels.length} Optionen im Freunde-Ranking
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Hotel Grid */}
|
||
<div style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: isCluster ? 'repeat(auto-fit, minmax(340px, 1fr))' : '1fr',
|
||
gap: '1.3rem',
|
||
background: isCluster ? '#fdfcff' : 'transparent',
|
||
padding: isCluster ? '1.2rem' : '0',
|
||
borderRadius: '16px',
|
||
border: isCluster ? '2px dashed #c4b5fd' : 'none'
|
||
}}>
|
||
{sortedHotels.map((hotel, hIdx) => {
|
||
const hotelNights = calculateNights(hotel.checkin_date, hotel.checkout_date) || hotel.latest_nights || nights || 1;
|
||
const persons = hotel.latest_persons || 2;
|
||
const totalPrice = hotel.latest_price !== null && hotel.latest_price !== undefined ? Number(hotel.latest_price) : null;
|
||
const pricePerNight = hotel.latest_price_per_night
|
||
? Math.round(Number(hotel.latest_price_per_night))
|
||
: (totalPrice ? Math.round(totalPrice / hotelNights) : null);
|
||
const pricePerPersonPerNight = hotel.latest_price_per_person
|
||
? Math.round(Number(hotel.latest_price_per_person))
|
||
: (totalPrice ? Math.round(totalPrice / (hotelNights * persons)) : null);
|
||
|
||
const hasGrade = hotel.avg_grade !== null && hotel.avg_grade !== undefined;
|
||
const rankBadgeClass = hIdx === 0 && hasGrade ? '🥇 Rang #1 · Favorit' : hIdx === 1 && hasGrade ? '🥈 Rang #2' : `🥉 Rang #${hIdx + 1}`;
|
||
const rankTopBorder = hIdx === 0 && hasGrade ? '#f59e0b' : hIdx === 1 && hasGrade ? '#94a3b8' : '#cbd5e1';
|
||
|
||
let gradeColor = '#15803d';
|
||
let gradeBg = '#f0fdf4';
|
||
let gradeBorder = '#bbf7d0';
|
||
let gradeDesc = 'Sehr gut';
|
||
|
||
if (hasGrade) {
|
||
const g = Number(hotel.avg_grade);
|
||
if (g > 1.5 && g <= 2.5) { gradeColor = '#059669'; gradeBg = '#ecfdf5'; gradeBorder = '#a7f3d0'; gradeDesc = 'Gut'; }
|
||
else if (g > 2.5 && g <= 3.5) { gradeColor = '#d97706'; gradeBg = '#fffbeb'; gradeBorder = '#fde68a'; gradeDesc = 'Befriedigend'; }
|
||
else if (g > 3.5 && g <= 4.5) { gradeColor = '#ea580c'; gradeBg = '#fff7ed'; gradeBorder = '#fed7aa'; gradeDesc = 'Ausreichend'; }
|
||
else if (g > 4.5) { gradeColor = '#dc2626'; gradeBg = '#fef2f2'; gradeBorder = '#fecaca'; gradeDesc = 'Mangelhaft'; }
|
||
}
|
||
|
||
const hotelSights = attractions.filter(a =>
|
||
(a.assignments || []).some(asgn => asgn.hotel_id === hotel.id)
|
||
);
|
||
|
||
return (
|
||
<div
|
||
key={hotel.id}
|
||
style={{
|
||
background: '#ffffff',
|
||
border: '1px solid #e2e8f0',
|
||
borderTop: isCluster ? `4px solid ${rankTopBorder}` : '1px solid #e2e8f0',
|
||
borderRadius: '14px',
|
||
padding: '1.2rem',
|
||
boxShadow: '0 4px 6px -1px rgba(0,0,0,0.05)',
|
||
opacity: hotel.is_active ? 1 : 0.5,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
justifyContent: 'space-between'
|
||
}}
|
||
>
|
||
<div>
|
||
{/* Option / Ranking Header */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.65rem' }}>
|
||
{isCluster ? (
|
||
<span style={{
|
||
fontSize: '0.78rem',
|
||
fontWeight: '800',
|
||
textTransform: 'uppercase',
|
||
background: hIdx === 0 && hasGrade ? '#fef3c7' : '#f1f5f9',
|
||
color: hIdx === 0 && hasGrade ? '#92400e' : '#475569',
|
||
padding: '0.2rem 0.55rem',
|
||
borderRadius: '6px',
|
||
border: hIdx === 0 && hasGrade ? '1px solid #fde68a' : '1px solid #e2e8f0'
|
||
}}>
|
||
{hasGrade ? rankBadgeClass : `Option ${String.fromCharCode(65 + hIdx)}`}
|
||
</span>
|
||
) : (
|
||
<div>{hotel.trip_name && <span style={{ background: '#f3f4f6', color: '#4b5563', padding: '0.2rem 0.5rem', borderRadius: '6px', fontSize: '0.75rem' }}>{hotel.trip_name}</span>}</div>
|
||
)}
|
||
<button
|
||
onClick={() => parkHotel(hotel.id)}
|
||
title="Dieses Hotel aus der Timeline nehmen"
|
||
style={{ background: 'transparent', border: '1px solid #cbd5e1', color: '#64748b', padding: '0.25rem 0.55rem', borderRadius: '6px', fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer' }}
|
||
>
|
||
👁️🗨️ Nicht betrachten
|
||
</button>
|
||
</div>
|
||
|
||
{/* Hotel Main Body */}
|
||
<div style={{ display: 'flex', gap: '0.9rem', marginBottom: '0.8rem' }}>
|
||
{hotel.image_url && (
|
||
<img src={hotel.image_url} alt={hotel.name} style={{ width: '100px', height: '80px', objectFit: 'cover', borderRadius: '8px', flexShrink: 0 }} />
|
||
)}
|
||
<div style={{ flex: 1, minWidth: '160px' }}>
|
||
<h3 style={{ margin: '0 0 0.25rem 0', fontSize: '1.05rem', color: '#0f172a', lineHeight: 1.3 }}>{hotel.name}</h3>
|
||
<p style={{ margin: '0 0 0.3rem 0', fontSize: '0.78rem', color: '#64748b' }}>📍 {hotel.address || 'Keine Adresse'}</p>
|
||
<div style={{ fontSize: '0.75rem', color: '#475569' }}>
|
||
⭐ <strong>{hotel.rating || 'N/A'}</strong>/10 · {hotel.review_count || 0} Bew.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* NOTENBOX: FREUNDE BEWERTUNG */}
|
||
<div style={{
|
||
background: gradeBg,
|
||
border: `1px solid ${gradeBorder}`,
|
||
borderLeft: `5px solid ${gradeColor}`,
|
||
borderRadius: '10px',
|
||
padding: '0.65rem 0.85rem',
|
||
marginBottom: '0.75rem',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center'
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||
<div style={{ background: gradeColor, color: '#fff', fontSize: '1.2rem', fontWeight: '900', padding: '0.2rem 0.5rem', borderRadius: '6px', lineHeight: 1 }}>
|
||
{hasGrade ? Number(hotel.avg_grade).toFixed(1) : '–'}
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<span style={{ fontSize: '0.8rem', fontWeight: '800', color: '#0f172a' }}>
|
||
{hasGrade ? `${gradeDesc} (⌀ Note)` : 'Noch keine Note'}
|
||
</span>
|
||
<span style={{ fontSize: '0.72rem', color: '#64748b' }}>
|
||
{hotel.total_votes || 0} von {tripMembers.length || 0} Stimmen
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => openRatingModal(hotel)}
|
||
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.35rem 0.75rem', borderRadius: '6px', fontSize: '0.78rem', fontWeight: '700', cursor: 'pointer' }}
|
||
>
|
||
✍️ Bewerten
|
||
</button>
|
||
</div>
|
||
|
||
{/* MITREISENDE ABSTIMMUNGS-STATUS */}
|
||
{tripMembers.length > 0 && (
|
||
<div style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '0.5rem 0.7rem', marginBottom: '0.75rem' }}>
|
||
<div style={{ fontSize: '0.7rem', fontWeight: '700', color: '#64748b', marginBottom: '0.3rem', display: 'flex', justifyContent: 'space-between' }}>
|
||
<span>STIMMEN DER FREUNDE:</span>
|
||
<span>{hotel.ratings_json?.length || 0}/{tripMembers.length} abgegeben</span>
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||
{tripMembers.map(m => {
|
||
const vote = hotel.ratings_json?.find(r => r.member_id === m.id);
|
||
return (
|
||
<span
|
||
key={m.id}
|
||
style={{
|
||
fontSize: '0.72rem',
|
||
padding: '0.1rem 0.4rem',
|
||
borderRadius: '4px',
|
||
fontWeight: '600',
|
||
background: vote ? '#ecfdf5' : '#f1f5f9',
|
||
color: vote ? '#065f46' : '#94a3b8',
|
||
border: vote ? '1px solid #a7f3d0' : '1px dashed #cbd5e1'
|
||
}}
|
||
>
|
||
{vote ? `✓ ${m.name}: ${Number(vote.grade).toFixed(1)}` : `⏳ ${m.name}`}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MINI-KOMMENTARE VORSCHAU */}
|
||
{hotel.ratings_json && hotel.ratings_json.filter(r => r.comment).length > 0 && (
|
||
<div style={{ background: '#fafafa', borderTop: '1px dashed #e2e8f0', paddingTop: '0.4rem', marginBottom: '0.75rem', fontSize: '0.75rem' }}>
|
||
{hotel.ratings_json.filter(r => r.comment).slice(0, 2).map((r, cIdx) => (
|
||
<div key={cIdx} style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.2rem', alignItems: 'baseline' }}>
|
||
<span style={{ fontWeight: '700', color: '#334155' }}>{r.name}:</span>
|
||
<span style={{ color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
„{r.comment}“
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* PREIS-BOX MIT BESTPREIS-VERGLEICH & GENIUS */}
|
||
{totalPrice !== null ? (
|
||
<div style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '10px', padding: '0.6rem 0.8rem', marginBottom: '0.8rem', display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||
|
||
{hotel.latest_genius_price ? (
|
||
<>
|
||
{/* Durchgestrichener Normalpreis bei aktivem Genius */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.76rem', color: '#64748b' }}>
|
||
<span>Regulär: <span style={{ textDecoration: 'line-through' }}>{totalPrice.toLocaleString('de-DE')} €</span></span>
|
||
<span style={{ background: '#16a34a', color: '#fff', fontSize: '0.68rem', fontWeight: '800', padding: '0.1rem 0.35rem', borderRadius: '4px' }}>-10% GENIUS</span>
|
||
</div>
|
||
|
||
{/* Aktive Genius-Zeile mit Tiefstpreis-Badge */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: '0.78rem', fontWeight: '700', color: '#003580' }}>Genius:</span>
|
||
<span style={{ fontWeight: '900', color: '#003580', fontSize: '0.95rem' }}>
|
||
💙 {Number(hotel.latest_genius_price).toLocaleString('de-DE')} €
|
||
</span>
|
||
<span style={{ color: '#0052b4', fontSize: '0.76rem' }}>
|
||
(🌙 {Math.round(Number(hotel.latest_genius_price) / hotelNights).toLocaleString('de-DE')} € / N.)
|
||
</span>
|
||
</div>
|
||
|
||
{hotel.best_price && Number(hotel.latest_genius_price) > Number(hotel.best_price) ? (
|
||
<span style={{ background: '#fee2e2', color: '#991b1b', border: '1px solid #fecaca', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '700' }}>
|
||
▲ +{(Number(hotel.latest_genius_price) - Number(hotel.best_price)).toLocaleString('de-DE')} €
|
||
</span>
|
||
) : (
|
||
<span style={{ background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '800' }}>
|
||
🔥 Tiefstpreis
|
||
</span>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
/* Standard-Preiszeile mit Tiefstpreis-Badge */
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.3rem' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: '0.78rem', fontWeight: '700', color: '#475569' }}>Aktuell:</span>
|
||
<span style={{ fontWeight: '800', color: '#0f172a', fontSize: '0.92rem' }}>
|
||
💰 {totalPrice.toLocaleString('de-DE')} €
|
||
</span>
|
||
<span style={{ color: '#64748b', fontSize: '0.76rem' }}>
|
||
(🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.)
|
||
</span>
|
||
</div>
|
||
|
||
{hotel.best_price && totalPrice > Number(hotel.best_price) ? (
|
||
<span style={{ background: '#fee2e2', color: '#991b1b', border: '1px solid #fecaca', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '700' }}>
|
||
▲ +{(totalPrice - Number(hotel.best_price)).toLocaleString('de-DE')} €
|
||
</span>
|
||
) : (
|
||
<span style={{ background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '800' }}>
|
||
🔥 Tiefstpreis
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Allzeit-Bestpreis Zeile */}
|
||
{hotel.best_price && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', borderTop: '1px dashed #e2e8f0', paddingTop: '0.3rem', fontSize: '0.75rem' }}>
|
||
<span style={{ fontWeight: '700', color: '#059669' }}>🏆 Bestpreis:</span>
|
||
<span style={{ fontWeight: '800', color: '#047857' }}>
|
||
{Number(hotel.best_price).toLocaleString('de-DE')} €
|
||
</span>
|
||
{hotel.best_price_per_night && (
|
||
<span style={{ color: '#64748b', fontSize: '0.72rem' }}>
|
||
(🌙 {Math.round(Number(hotel.best_price_per_night)).toLocaleString('de-DE')} € / N.)
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div style={{ marginBottom: '0.8rem', fontSize: '0.8rem', color: '#6b7280', fontStyle: 'italic' }}>
|
||
💰 Noch kein Preis erfasst
|
||
</div>
|
||
)}
|
||
|
||
{/* SEHENSWÜRDIGKEITEN & FAHRZEITEN */}
|
||
<div style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '0.7rem' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.4rem' }}>
|
||
<span style={{ fontWeight: '700', fontSize: '0.8rem', color: '#334155' }}>
|
||
🏛️ Ausflugsziele ({hotelSights.length})
|
||
</span>
|
||
<button
|
||
onClick={() => openSightModal(undefined, hotel.id)}
|
||
style={{ background: '#eff6ff', color: '#2563eb', border: '1px dashed #bfdbfe', padding: '0.2rem 0.5rem', borderRadius: '4px', fontSize: '0.72rem', fontWeight: '700', cursor: 'pointer' }}
|
||
>
|
||
➕ Zuordnen
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||
{hotelSights.length === 0 ? (
|
||
<span style={{ fontSize: '0.74rem', color: '#94a3b8', fontStyle: 'italic' }}>Keine Ausflugsziele zugeordnet.</span>
|
||
) : (
|
||
hotelSights.map(sight => {
|
||
const assignment = (sight.assignments || []).find(a => a.hotel_id === hotel.id);
|
||
return (
|
||
<div key={sight.id} style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: '6px', padding: '0.4rem 0.6rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.4rem' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', overflow: 'hidden' }}>
|
||
<span>{sight.category_icon || '🏛️'}</span>
|
||
<span style={{ fontWeight: '700', fontSize: '0.78rem', color: '#0f172a', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||
{sight.name}
|
||
</span>
|
||
</div>
|
||
{assignment?.distance_km !== null && assignment?.distance_km !== undefined ? (
|
||
<span style={{ background: '#ecfdf5', border: '1px solid #a7f3d0', color: '#047857', padding: '0.15rem 0.4rem', borderRadius: '4px', fontSize: '0.72rem', fontWeight: '700', whiteSpace: 'nowrap' }}>
|
||
🚗 {assignment.distance_km} km · {assignment.duration_minutes} Min.
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
</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' }}>
|
||
<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' }}
|
||
>
|
||
💬 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' }}
|
||
>
|
||
📈 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 }}
|
||
>
|
||
{scanningHotelId === hotel.id ? '🔄 Scanne...' : '🤖 KI-Reviews'}
|
||
</button>
|
||
<a href={hotel.booking_url} target="_blank" rel="noopener noreferrer" style={{ color: '#2563eb', textDecoration: 'none', fontSize: '0.78rem', marginLeft: 'auto', fontWeight: '700' }}>
|
||
Booking ↗
|
||
</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* MODAL 1: STIMMABGABE */}
|
||
{ratingHotel && (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
|
||
<div style={{ background: '#fff', borderRadius: '16px', maxWidth: '560px', width: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)', overflow: 'hidden' }}>
|
||
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div>
|
||
<h3 style={{ margin: 0, fontSize: '1.15rem', fontWeight: '800', color: '#0f172a' }}>Hotel bewerten</h3>
|
||
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}>{ratingHotel.name}</p>
|
||
</div>
|
||
<button onClick={() => setRatingHotel(null)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSaveRating} style={{ padding: '1.3rem 1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1.1rem' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.4rem' }}>
|
||
1. Wer bewertet? *
|
||
</label>
|
||
{tripMembers.length === 0 ? (
|
||
<p style={{ margin: 0, fontSize: '0.8rem', color: '#dc2626' }}>
|
||
Bitte lege zuerst Mitreisende in der Reise-Leiste an!
|
||
</p>
|
||
) : (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: '0.4rem' }}>
|
||
{tripMembers.map(m => {
|
||
const isSelected = selectedMemberId === m.id;
|
||
const hasVoted = ratingHotel.ratings_json?.some(r => r.member_id === m.id);
|
||
return (
|
||
<div
|
||
key={m.id}
|
||
onClick={() => handleSelectMemberForRating(m.id)}
|
||
style={{
|
||
border: isSelected ? '2px solid #2563eb' : '1px solid #e2e8f0',
|
||
background: isSelected ? '#eff6ff' : '#f8fafc',
|
||
borderRadius: '8px',
|
||
padding: '0.45rem',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '0.4rem'
|
||
}}
|
||
>
|
||
<span style={{ width: '18px', height: '18px', borderRadius: '50%', background: m.color || '#2563eb', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.65rem', fontWeight: '800' }}>
|
||
{m.name[0]}
|
||
</span>
|
||
<div>
|
||
<div style={{ fontSize: '0.8rem', fontWeight: isSelected ? '800' : '600', color: isSelected ? '#1e40af' : '#1e293b' }}>
|
||
{m.name}
|
||
</div>
|
||
<div style={{ fontSize: '0.65rem', color: hasVoted ? '#059669' : '#94a3b8' }}>
|
||
{hasVoted ? 'Bereits bewertet' : 'Offen'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.3rem' }}>
|
||
2. Schulnote (1 = Sehr gut bis 6 = Ungenügend) *
|
||
</label>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '0.4rem' }}>
|
||
{[1, 2, 3, 4, 5, 6].map(gradeNum => {
|
||
const isSelected = selectedGrade === gradeNum;
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={gradeNum}
|
||
onClick={() => setSelectedGrade(gradeNum)}
|
||
style={{
|
||
padding: '0.6rem 0.2rem',
|
||
borderRadius: '8px',
|
||
fontWeight: '800',
|
||
fontSize: '1.1rem',
|
||
cursor: 'pointer',
|
||
border: isSelected ? '2px solid #2563eb' : '1px solid #cbd5e1',
|
||
background: isSelected ? '#2563eb' : '#f8fafc',
|
||
color: isSelected ? '#ffffff' : '#1e293b'
|
||
}}
|
||
>
|
||
{gradeNum}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.3rem' }}>
|
||
3. Notiz / Begründung (optional)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
placeholder="z. B. Strandnähe top, schöner Balkon..."
|
||
value={ratingComment}
|
||
onChange={e => setRatingComment(e.target.value)}
|
||
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db', fontSize: '0.85rem' }}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: '0.6rem', justifyContent: 'flex-end', marginTop: '0.4rem' }}>
|
||
<button type="button" onClick={() => setRatingHotel(null)} style={{ padding: '0.55rem 1rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||
Abbrechen
|
||
</button>
|
||
<button type="submit" disabled={isSubmittingRating} style={{ padding: '0.55rem 1.1rem', background: '#2563eb', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.85rem' }}>
|
||
{isSubmittingRating ? 'Speichere...' : '💾 Note speichern'}
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ borderTop: '1px solid #e2e8f0', paddingTop: '0.8rem', marginTop: '0.4rem' }}>
|
||
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: '800', color: '#1e293b' }}>
|
||
Bisherige Bewertungen ({ratingHotel.ratings_json?.length || 0}):
|
||
</h4>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||
{(!ratingHotel.ratings_json || ratingHotel.ratings_json.length === 0) ? (
|
||
<span style={{ fontSize: '0.78rem', color: '#94a3b8', fontStyle: 'italic' }}>Noch keine Stimmen hinterlegt.</span>
|
||
) : (
|
||
ratingHotel.ratings_json.map((r, idx) => (
|
||
<div key={idx} style={{ background: '#f8fafc', border: '1px solid #e2e8f0', padding: '0.45rem 0.7rem', borderRadius: '6px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div>
|
||
<span style={{ fontWeight: '700', fontSize: '0.8rem', color: '#0f172a' }}>👤 {r.name}: </span>
|
||
<span style={{ fontSize: '0.78rem', color: '#475569' }}>{r.comment || '(Kein Kommentar)'}</span>
|
||
</div>
|
||
<span style={{ background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0', padding: '0.1rem 0.4rem', borderRadius: '4px', fontWeight: '800', fontSize: '0.75rem' }}>
|
||
Note {Number(r.grade).toFixed(1)}
|
||
</span>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MODAL 2: MITREISENDE VERWALTEN */}
|
||
{showMembersModal && (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
|
||
<div style={{ background: '#fff', borderRadius: '16px', maxWidth: '450px', width: '100%', maxHeight: '85vh', display: 'flex', flexDirection: 'column', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)' }}>
|
||
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div>
|
||
<h3 style={{ margin: 0, fontSize: '1.2rem', fontWeight: '800' }}>👥 Mitreisende verwalten</h3>
|
||
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}>Für: {activeTripObj?.name}</p>
|
||
</div>
|
||
<button onClick={() => setShowMembersModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||
</div>
|
||
|
||
<div style={{ padding: '1.3rem 1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||
<form onSubmit={handleAddMember} style={{ display: 'flex', gap: '0.5rem' }}>
|
||
<input
|
||
type="text"
|
||
required
|
||
placeholder="Name (z. B. Alex)..."
|
||
value={newMemberName}
|
||
onChange={e => setNewMemberName(e.target.value)}
|
||
style={{ flex: 1, padding: '0.55rem', borderRadius: '6px', border: '1px solid #cbd5e1', fontSize: '0.85rem' }}
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={isAddingMember}
|
||
style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.55rem 1rem', borderRadius: '6px', fontWeight: '700', fontSize: '0.85rem', cursor: 'pointer' }}
|
||
>
|
||
➕ Hinzufügen
|
||
</button>
|
||
</form>
|
||
|
||
<div style={{ fontSize: '0.8rem', fontWeight: '700', color: '#475569' }}>Aktuelle Mitreisende:</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||
{tripMembers.length === 0 ? (
|
||
<p style={{ margin: 0, fontSize: '0.8rem', color: '#94a3b8', fontStyle: 'italic', textAlign: 'center', padding: '1rem 0' }}>
|
||
Noch keine Mitreisenden angelegt.
|
||
</p>
|
||
) : (
|
||
tripMembers.map(m => (
|
||
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#f8fafc', border: '1px solid #e2e8f0', padding: '0.5rem 0.8rem', borderRadius: '6px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||
<span style={{ width: '18px', height: '18px', borderRadius: '50%', background: m.color || '#2563eb', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.65rem', fontWeight: '800' }}>
|
||
{m.name[0]}
|
||
</span>
|
||
<span style={{ fontWeight: '700', fontSize: '0.85rem', color: '#1e293b' }}>{m.name}</span>
|
||
</div>
|
||
<button
|
||
onClick={() => handleDeleteMember(m.id)}
|
||
style={{ background: 'none', border: 'none', color: '#ef4444', fontWeight: '700', cursor: 'pointer', fontSize: '0.8rem' }}
|
||
>
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ padding: '0.8rem 1.5rem', borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'flex-end' }}>
|
||
<button onClick={() => setShowMembersModal(false)} style={{ padding: '0.5rem 1rem', background: '#2563eb', color: '#fff', border: 'none', borderRadius: '6px', fontWeight: '700', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||
Fertig
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MODAL 3: PREISHISTORIE */}
|
||
{selectedPriceHotel && (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
|
||
<div style={{ background: '#fff', padding: '1.8rem', borderRadius: '12px', maxWidth: '650px', width: '100%', boxShadow: '0 20px 25px -5px rgba(0,0,0,0.1)' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||
<div>
|
||
<h3 style={{ margin: 0, fontSize: '1.3rem' }}>📈 Preishistorie: {selectedPriceHotel.name}</h3>
|
||
<p style={{ margin: 0, color: '#6b7280', fontSize: '0.85rem' }}>Entwicklung aller erfassten Preis-Scans</p>
|
||
</div>
|
||
<button onClick={() => setSelectedPriceHotel(null)} style={{ background: '#f3f4f6', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||
</div>
|
||
{loadingPrices ? (
|
||
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Lade Preishistorie...</p>
|
||
) : priceHistory.length === 0 ? (
|
||
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Noch keine Preishistorie vorhanden.</p>
|
||
) : (
|
||
<div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem', marginBottom: '1.5rem', textAlign: 'center' }}>
|
||
<div style={{ background: '#f0fdf4', padding: '0.8rem', borderRadius: '8px', border: '1px solid #bbf7d0' }}>
|
||
<small style={{ color: '#166534', fontWeight: 'bold' }}>Tiefstpreis</small>
|
||
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#15803d' }}>{minPrice} €</div>
|
||
</div>
|
||
<div style={{ background: '#eff6ff', padding: '0.8rem', borderRadius: '8px', border: '1px solid #bfdbfe' }}>
|
||
<small style={{ color: '#1e40af', fontWeight: 'bold' }}>Aktuellster Preis</small>
|
||
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#1d4ed8' }}>{currentPrice} €</div>
|
||
</div>
|
||
<div style={{ background: '#fef2f2', padding: '0.8rem', borderRadius: '8px', border: '1px solid #fecaca' }}>
|
||
<small style={{ color: '#991b1b', fontWeight: 'bold' }}>Höchstpreis</small>
|
||
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#b91c1c' }}>{maxPrice} €</div>
|
||
</div>
|
||
</div>
|
||
{priceHistory.length > 1 && (
|
||
<div style={{ background: '#fafafa', padding: '1rem', borderRadius: '8px', border: '1px solid #f3f4f6', marginBottom: '1.5rem' }}>
|
||
<svg viewBox="0 0 500 150" style={{ width: '100%', height: '160px', overflow: 'visible' }}>
|
||
<line x1="0" y1="20" x2="500" y2="20" stroke="#e5e7eb" strokeDasharray="4" />
|
||
<line x1="0" y1="130" x2="500" y2="130" stroke="#e5e7eb" strokeDasharray="4" />
|
||
{(() => {
|
||
const range = maxPrice - minPrice || 1;
|
||
const points = priceHistory.map((p, idx) => {
|
||
const x = (idx / (priceHistory.length - 1)) * 500;
|
||
const y = 130 - ((p.price - minPrice) / range) * 110;
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
return (
|
||
<>
|
||
<polyline fill="none" stroke="#2563eb" strokeWidth="3" points={points} />
|
||
{priceHistory.map((p, idx) => {
|
||
const x = (idx / (priceHistory.length - 1)) * 500;
|
||
const y = 130 - ((p.price - minPrice) / range) * 110;
|
||
return (
|
||
<g key={p.id}>
|
||
<circle cx={x} cy={y} r="5" fill="#2563eb" stroke="#ffffff" strokeWidth="2" />
|
||
<text x={x} y={y - 10} fontSize="10" textAnchor="middle" fill="#374151" fontWeight="bold">{p.price}€</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
})()}
|
||
</svg>
|
||
</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' }}>
|
||
<div style={{ background: '#fff', padding: '2rem', borderRadius: '12px', maxWidth: '400px', width: '100%' }}>
|
||
<h3 style={{ marginTop: 0 }}>🌍 Neue Reise anlegen</h3>
|
||
<form onSubmit={handleAddTrip}>
|
||
<input
|
||
type="text"
|
||
required
|
||
placeholder="z. B. Indien 2026"
|
||
value={newTripName}
|
||
onChange={e => setNewTripName(e.target.value)}
|
||
style={{ width: '100%', padding: '0.7rem', borderRadius: '6px', border: '1px solid #d1d5db', marginBottom: '1rem' }}
|
||
/>
|
||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||
<button type="button" onClick={() => setShowAddTripModal(false)} style={{ padding: '0.6rem 1rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Abbrechen</button>
|
||
<button type="submit" style={{ padding: '0.6rem 1rem', background: '#2563eb', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}>Anlegen</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MODAL: HOTEL HINZUFÜGEN */}
|
||
{showAddHotelModal && (
|
||
<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' }}>
|
||
<div style={{ background: '#fff', padding: '2rem', borderRadius: '12px', maxWidth: '500px', width: '100%' }}>
|
||
<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' }} />
|
||
</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 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'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MODAL: AUSGEMUSTERTE HOTELS */}
|
||
{showParkedModal && (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
|
||
<div style={{ background: '#fff', borderRadius: '16px', maxWidth: '700px', width: '100%', maxHeight: '85vh', display: 'flex', flexDirection: 'column', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)' }}>
|
||
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div>
|
||
<h3 style={{ margin: 0, fontSize: '1.3rem', fontWeight: '800' }}>📦 Ausgemusterte Hotels</h3>
|
||
<p style={{ margin: 0, fontSize: '0.85rem', color: '#64748b' }}>Diese Hotels sind nicht in der Timeline, bleiben aber gespeichert.</p>
|
||
</div>
|
||
<button onClick={() => setShowParkedModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||
</div>
|
||
<div style={{ padding: '1.5rem', overflowY: 'auto' }}>
|
||
{parkedHotels.length === 0 ? (
|
||
<p style={{ textAlign: 'center', color: '#64748b', padding: '2rem 0' }}>🎉 Aktuell sind keine Hotels ausgemustert.</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
|
||
{parkedHotels.map(h => (
|
||
<div key={h.id} style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '10px', padding: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||
<div style={{ flex: 1, minWidth: '220px' }}>
|
||
<div style={{ fontWeight: '700', fontSize: '1rem', color: '#0f172a' }}>{h.name}</div>
|
||
<div style={{ fontSize: '0.82rem', color: '#64748b', marginTop: '0.2rem' }}>
|
||
{h.checkin_date ? `🗓️ ${formatDateGerman(h.checkin_date)} – ${formatDateGerman(h.checkout_date)}` : 'Kein Datum'}
|
||
{h.latest_price ? ` • 💰 Gesamt: ${h.latest_price.toLocaleString('de-DE')} €` : ''}
|
||
</div>
|
||
</div>
|
||
<button onClick={() => restoreHotel(h.id)} style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.5rem 1rem', borderRadius: '6px', fontSize: '0.85rem', fontWeight: '700', cursor: 'pointer' }}>
|
||
➕ Wieder in Trip aufnehmen
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MODAL: AUSFLÜGE VERWALTEN */}
|
||
{showManageSightsModal && (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
|
||
<div style={{ background: '#fff', borderRadius: '16px', maxWidth: '750px', width: '100%', maxHeight: '85vh', display: 'flex', flexDirection: 'column', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)' }}>
|
||
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<div>
|
||
<h3 style={{ margin: 0, fontSize: '1.3rem', fontWeight: '800' }}>🏛️ Zentrale Ausflugsziele</h3>
|
||
<p style={{ margin: 0, fontSize: '0.85rem', color: '#64748b' }}>Sehenswürdigkeiten bearbeiten und mehreren Hotels zuweisen</p>
|
||
</div>
|
||
<button onClick={() => setShowManageSightsModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||
</div>
|
||
<div style={{ padding: '1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
|
||
{currentAttractions.length === 0 ? (
|
||
<p style={{ textAlign: 'center', color: '#64748b', padding: '2rem 0' }}>Noch keine Ausflugsziele für diesen Trip hinterlegt.</p>
|
||
) : (
|
||
currentAttractions.map(sight => {
|
||
const assignedCount = (sight.assignments || []).length;
|
||
return (
|
||
<div key={sight.id} style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '10px', padding: '0.9rem 1.1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||
<div style={{ flex: 1, minWidth: '240px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
<span style={{ fontSize: '1.2rem' }}>{sight.category_icon || '🏛️'}</span>
|
||
<span style={{ fontWeight: '700', fontSize: '0.95rem', color: '#0f172a' }}>{sight.name}</span>
|
||
</div>
|
||
<div style={{ fontSize: '0.8rem', color: '#64748b', marginTop: '0.3rem' }}>
|
||
{assignedCount > 0 ? (
|
||
<span style={{ background: '#dbeafe', color: '#1e40af', padding: '0.15rem 0.5rem', borderRadius: '6px', fontWeight: '600' }}>
|
||
🏨 {assignedCount} {assignedCount === 1 ? 'Hotel' : 'Hotels'} zugeordnet
|
||
</span>
|
||
) : (
|
||
<span style={{ background: '#f1f5f9', color: '#64748b', padding: '0.15rem 0.5rem', borderRadius: '6px' }}>Keinem Hotel zugewiesen</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
||
<button onClick={() => { setShowManageSightsModal(false); openSightModal(sight); }} style={{ background: '#f1f5f9', border: '1px solid #cbd5e1', padding: '0.4rem 0.7rem', borderRadius: '6px', fontSize: '0.8rem', fontWeight: '600', cursor: 'pointer' }}>
|
||
✏️ Bearbeiten
|
||
</button>
|
||
<button onClick={() => deleteSight(sight.id)} style={{ background: '#fee2e2', color: '#b91c1c', border: 'none', padding: '0.4rem 0.7rem', borderRadius: '6px', fontSize: '0.8rem', fontWeight: '600', cursor: 'pointer' }}>
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
<div style={{ padding: '1rem 1.5rem', borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'flex-end' }}>
|
||
<button onClick={() => { setShowManageSightsModal(false); openSightModal(); }} style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.6rem 1.2rem', borderRadius: '6px', fontWeight: '700', cursor: 'pointer' }}>
|
||
➕ Neues Ausflugsziel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MODAL: AUSFLUG ANLEGEN / BEARBEITEN */}
|
||
{showSightFormModal && (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1001, padding: '1rem' }}>
|
||
<div style={{ background: '#fff', borderRadius: '16px', maxWidth: '520px', width: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)', overflow: 'hidden' }}>
|
||
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<h3 style={{ margin: 0, fontSize: '1.2rem', fontWeight: '800' }}>
|
||
{editingSight ? '✏️ Ausflugsziel bearbeiten' : '🏛️ Neues Ausflugsziel'}
|
||
</h3>
|
||
<button onClick={() => setShowSightFormModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '30px', height: '30px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||
</div>
|
||
<form onSubmit={handleSaveSight} style={{ padding: '1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.9rem' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>🔗 Google Maps Link oder Adresse *</label>
|
||
<input type="text" required placeholder="https://maps.app.goo.gl/... oder Adresse" value={sightAddress} onChange={e => setSightAddress(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>Name der Sehenswürdigkeit</label>
|
||
<input type="text" placeholder="z. B. Chinesische Fischernetze" value={sightName} onChange={e => setSightName(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.3rem' }}>🏨 Diesen Hotels zuordnen (Distanz wird berechnet):</label>
|
||
<div style={{ maxHeight: '140px', overflowY: 'auto', border: '1px solid #d1d5db', borderRadius: '6px', padding: '0.5rem', background: '#fafafa', display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
|
||
{hotels.map(h => {
|
||
const isChecked = selectedHotelIds.includes(h.id);
|
||
return (
|
||
<label key={h.id} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.82rem', cursor: 'pointer', padding: '0.2rem 0.3rem', borderRadius: '4px', background: isChecked ? '#eff6ff' : 'transparent' }}>
|
||
<input type="checkbox" checked={isChecked} onChange={() => toggleHotelSelection(h.id)} />
|
||
<span style={{ fontWeight: isChecked ? '700' : 'normal', color: isChecked ? '#1e40af' : '#374151' }}>
|
||
{h.name} {h.checkin_date ? `(${formatDateGerman(h.checkin_date)})` : ''}
|
||
</span>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>Kategorie</label>
|
||
<select value={sightIcon} onChange={e => setSightIcon(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }}>
|
||
<option value="🏛️">🏛️ Kultur & Bauwerk</option>
|
||
<option value="🌿">🌿 Natur & Park</option>
|
||
<option value="🏖️">🏖️ Strand & Meer</option>
|
||
<option value="🐘">🐘 Safari & Tiere</option>
|
||
<option value="🍛">🍛 Food & Markt</option>
|
||
<option value="🧗">🧗 Abenteuer & Wandern</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>Notizen / Tipps (optional)</label>
|
||
<input type="text" placeholder="z. B. Vorab Tickets kaufen" value={sightNotes} onChange={e => setSightNotes(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.6rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
|
||
<button type="button" onClick={() => setShowSightFormModal(false)} style={{ padding: '0.55rem 1rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem' }}>Abbrechen</button>
|
||
<button type="submit" disabled={isSubmittingSight} style={{ padding: '0.55rem 1.1rem', background: '#2563eb', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.85rem' }}>
|
||
{isSubmittingSight ? 'Berechne Routen...' : 'Speichern & Berechnen'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
--- 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"]
|
||
}
|
||
|
||
```
|